diff --git a/.docusaurus_site/docusaurus.config.js b/.docusaurus_site/docusaurus.config.js index 4133f66c4f..9865d0d46a 100644 --- a/.docusaurus_site/docusaurus.config.js +++ b/.docusaurus_site/docusaurus.config.js @@ -53,6 +53,12 @@ export default async function createConfigAsync() { routeBasePath: "/nextflow", path: "docs", sidebarPath: "./sidebars.js", + exclude: [ + "**/_*.{js,jsx,ts,tsx,md,mdx}", + "**/_*/**", + "**/*.test.{js,jsx,ts,tsx}", + "**/__tests__/**", + ], showLastUpdateAuthor: false, showLastUpdateTime: false, // For PR Previews we want to see the latest doc-set with expected changes. diff --git a/.docusaurus_site/sidebars.js b/.docusaurus_site/sidebars.js index 2caf2566be..95d2d80ab2 100644 --- a/.docusaurus_site/sidebars.js +++ b/.docusaurus_site/sidebars.js @@ -59,6 +59,7 @@ module.exports = { "working-with-files", "process", "workflow", + "agent", { type: "category", label: "Static typing", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b92f8086a..43c0ba7ef3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -253,7 +253,17 @@ jobs: if: ${{ always() && contains(needs.build.outputs.commit_message, '[release]') && needs.build.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped') }} needs: [build, test] runs-on: ubuntu-latest - timeout-minutes: 10 + # Raised from 10 for the nf-agent-pi image build in release.sh step 1: installing binfmt, + # bootstrapping buildkit, pulling two base images and running an emulated arm64 `npm ci` + # and `apt-get` do not fit the old budget. Sized as a ceiling, not a budget - a timeout + # that fires mid-release.sh is the one failure the step-1 ordering does NOT make harmless, + # so erring high costs nothing on a run that succeeds. A cold `build-image.sh build` (both + # arches, --no-cache) measures ~15s on an arm64 developer Mac, but that number cannot be + # scaled: Docker Desktop runs the amd64 leg under Rosetta at near-native speed (emulated + # `npm ci` 5.9s vs 5.6s native), whereas this runner emulates arm64 through QEMU user-mode, + # which is slower by a large and unmeasured factor. Revisit once a real release reports its + # actual duration. + timeout-minutes: 45 permissions: contents: write steps: @@ -289,6 +299,10 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + # Also authorizes the nf-agent-pi runner image push in release.sh step 1: it publishes to + # public.cr.seqera.io/nextflow, the same registry and namespace this release already pushes + # `nextflow/nextflow` to (docker/Makefile). No new credential, and this login runs before + # `Run release`, so build-image.sh needs none of its own. - name: Docker Login to Seqera public CR uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: @@ -296,6 +310,23 @@ jobs: username: ${{ vars.SEQERA_PUBLIC_CR_USERNAME }} password: ${{ secrets.SEQERA_PUBLIC_CR_PASSWORD }} + # The nf-agent-pi runner image is multi-arch, and the node stage of its Dockerfile is not + # pinned to $BUILDPLATFORM, so `apt-get` and `npm ci` execute on the target platform: the + # linux/arm64 leg needs binfmt emulation on this amd64 runner. Placed AFTER the Docker Hub + # login on purpose - the action does `docker run --privileged tonistiigi/binfmt`, and an + # anonymous Docker Hub pull from a shared runner IP hits `toomanyrequests`, which ahead of + # the login would abort every release, including ones whose image push would have skipped. + # No docker/setup-buildx-action: ensure_builder in build-image.sh creates and bootstraps + # the docker-container builder it then passes to --builder, so the action's builder would + # be created and never used. + - name: Set up QEMU for the runner image build + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + with: + # Digest-pinned like every action in this file. The input's default is a floating + # docker.io/tonistiigi/binfmt:latest, which would otherwise be the only unpinned + # thing the release newly depends on. + image: docker.io/tonistiigi/binfmt:qemu-v9.2.2@sha256:1b804311fe87047a4c96d38b4b3ef6f62fca8cd125265917a9e3dc3c996c39e6 + - name: Run release run: | echo "Starting release process..." @@ -314,5 +345,10 @@ jobs: # plugin registry NPR_API_URL: ${{ vars.NPR_API_URL }} NPR_API_KEY: ${{ secrets.NPR_API_KEY }} + # nf-agent-pi runner image (step 1). The push itself is authorized by the + # `Docker Login to Seqera public CR` step above; these are passed so build-image.sh + # can name them when a push is refused. + SEQERA_PUBLIC_CR_USERNAME: ${{ vars.SEQERA_PUBLIC_CR_USERNAME }} + SEQERA_PUBLIC_CR_PASSWORD: ${{ secrets.SEQERA_PUBLIC_CR_PASSWORD }} # GitHub secrets GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ccc9165a42..83631a1e80 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,12 @@ docker/dist docker/nextflow temp .dockerignore +# ... except the one that belongs to a committed Dockerfile: it is what keeps a developer's +# node_modules/ out of the runner image build context - see plugins/nf-agent-pi/Dockerfile +!plugins/nf-agent-pi/.dockerignore +# the agent proxy binary a local `go build` leaves beside its source; the image builds it +# in its own stage, so a checked-in copy would only ever be stale +plugins/nf-agent-pi/agent-rpc/agent-rpc .launch.classpath plugins-prod /minio @@ -72,3 +78,6 @@ node_modules npm-debug.log* yarn-debug.log* yarn-error.log* + +# local agent example validation output +examples/agents/.validate/ diff --git a/Makefile b/Makefile index be6cdf10c5..904247af72 100644 --- a/Makefile +++ b/Makefile @@ -139,6 +139,15 @@ dockerPack: release-plugins: $(gradle) releasePluginToRegistryIfNotExists +# +# Publish the `pi` agent runner image, which is the distribution unit of the nf-agent-pi +# runtime. Runs first in release.sh, because it is the only release step that reaches a +# third-party registry and it must not be able to abort a release that has already +# published something. A no-op when the tag is already published. +# +release-agent-image: + $(gradle) :plugins:nf-agent-pi:releaseImageIfNotExists + publish-artifacts: $(gradle) publishAllPublicationsToSeqeraRepository diff --git a/adr/20260801-agent-primitive.md b/adr/20260801-agent-primitive.md new file mode 100644 index 0000000000..002cf8449c --- /dev/null +++ b/adr/20260801-agent-primitive.md @@ -0,0 +1,155 @@ +# Agent Primitive + +- Authors: Paolo Di Tommaso, Ben Sherman +- Status: accepted +- Date: 2026-08-01 +- Tags: dsl, agent, llm, language + +## Summary + +Extend Nextflow's reproducibility model to cover **non-deterministic** compute, by adding a top-level `agent` construct to the DSL: a task-shaped primitive whose body is an agentic tool-calling loop, where each tool call is dispatched as a real Nextflow module invocation inside the same session. The agentic model -- skills, tool invocation, goals, iteration -- then coexists in one artifact with containerization, compute abstraction, portability and caching, rather than living in a harness outside them. + +## Problem Statement + +Genomics is entering the era of **agentic genomics**: multi-step analyses delegated to autonomous agents that choose their own tools, adapt to intermediate results, and are driven by natural language instead of a hand-written DAG, constrained by libraries of domain-specific *skills* ([Cell Genomics **6**, 101305, 2026](https://doi.org/10.1016/j.xgen.2026.101305)). When the cost of *producing* an analysis collapses, the binding constraint becomes *trusting* it -- the bottleneck moves from pipeline construction to validation. + +That makes agentic genomics, first and foremost, a **reproducibility** problem. An agent's tool choices, parameters, iteration count and stopping condition are decided at runtime by a model, so the very things a reproducible pipeline pins down are the things now left open. The surrounding infrastructure that would make such analyses trustworthy -- versioned and discoverable skill registries, deterministic replay of an agent's decisions, signed audit trails, enforced validation tiers, benchmarking, bias detection, equity-aware defaults, viable local-first deployment -- is largely still missing. + +**This is the problem Nextflow already solved once, for deterministic compute.** Reproducible, portable analysis was never a property of the science; it was engineered -- containerized tool environments, an executor abstraction that decouples a pipeline from the compute it runs on, explicitly declared inputs and outputs, and content-addressed caching that ties a result to the exact code and data that produced it. Nearly every requirement agentic genomics now raises is that same class of problem, restated for **non-deterministic compute**: replay is caching, audit is lineage, skill distribution is module distribution, local-first deployment is portability. + +There is a second convergence, on **scale**. A single agent with one long context does not survive a real workload; the pattern the field is settling on is **agentic map-reduce** -- plan, shard deterministically, map agents in parallel over bounded shards, then reduce -- because it gives coverage by construction and keeps cost proportional to the relevant work rather than to the size of the input ([Devin, *Agentic MapReduce*](https://devin.ai/blog/agentic-map-reduce)). Its guiding rule is one a workflow engineer will recognize immediately: + +> Put agents where reasoning is required -- synthesizing the decomposition function, inspecting the shards, and the reduction. Everything else is deterministic. + +That is fan-out/fan-in with reasoning at selected nodes, which is a description of a dataflow graph. And the fit is deeper than the shape: in a dataflow model a node *awaits its inputs and fires when values arrive*, which is exactly what an agent is -- something that waits for a signal to process, runs, and emits. Nextflow already provides that execution model, along with the parallelism, backpressure, fan-in and ordering semantics that come with it, so an agentic map-reduce needs no orchestration layer of its own: the shard is a process, the map is an agent over a channel, the reduce is the same agent over a collected one. Expressing this pattern outside the engine means rebuilding concurrency control, work distribution and gather semantics that a dataflow runtime already has. + +A third convergence, on **packaging**. An agent's durable value is not a prompt someone typed once; it is a reviewed unit -- an instruction, a model, an output contract, the skills that constrain it and the tools it may call -- and that unit is only reusable if those parts travel together, versioned, so a second lab can run it and get the same behaviour. The skill-registry requirement above is, at bottom, a packaging and distribution problem: units that are findable, versioned, tested, documented and carrying enough metadata to be selected by something other than their author. + +This is a solved problem in Nextflow, and solved in the same shape the paper is asking for. Modules already encapsulate a unit of analysis with its dependencies and metadata; `include { x as y }` composes and aliases them; the registry and semantic versioning resolve them; config selectors parameterize them from outside without editing them; and nf-core has demonstrated the community governance the paper explicitly proposes as the model for skill registries. Extending that to agentic compute means an agent is a **module** too -- encapsulated together with its skills and its tools, included under an alias, versioned and resolved like everything else -- rather than a per-project prompt file with a bespoke loader. + +The overlap is therefore not incidental, and the gap is one of placement rather than capability. Today agentic compute sits *outside* the engine: an external harness calls in over MCP or a platform API to launch pipelines, holding its own provenance model, its own retry semantics, its own concurrency model, and no shared work directory -- so every requirement above has to be solved again inside that harness, where none of the existing machinery reaches. + +**This ADR proposes the inverse: make the agent a node in the workflow graph.** Rather than build a parallel stack for agentic compute, extend the reproducibility framework to cover non-deterministic steps, so that skills, tool invocation, goals and iteration coexist in a single artifact with containerization, compute abstraction, portability and caching -- and an agent's decisions become subject to the same execution, caching, provenance and container machinery as every other task. + +## The infrastructure agenda, mapped + +The claim above -- that the overlap is real and the gap is placement -- is only worth making if it survives contact with the specifics. Below are the infrastructure requirements the perspective identifies, against what Nextflow already provides and what this design does with it. "Substrate" means the mechanism exists and is load-bearing today; "gap" means this ADR does not address it and something else must. + +| Requirement | Nextflow substrate | This design | +|---|---|---| +| **Skill registries** -- standardized skill metadata, semantic discoverability, versioning; GA4GH and FAIR alignment; DOI-minted releases. The paper explicitly proposes modelling governance on nf-core: *"agent-native skill registries should adopt analogous governance structures"* | nf-core's curation model, and the module registry with versioned modules, `meta.yml` metadata and semantic resolution ([module system ADR](20251114-module-system.md)) | A tool **is** a registry module -- no parallel packaging format. Tool descriptions and I/O schemas come from `meta.yml` / registry `ModuleMetadata`. Skills are versioned module-local `SKILL.md` bundles. DOI minting and formal GA4GH metadata are registry work, not language work | +| **Deterministic replay** -- *"given a logged sequence of decisions, it must be possible to reproduce the exact execution path, even if the original selection was mediated by an agent"*; required for regulatory audit, debugging and multi-site concordance | `-resume` over a content-addressed task hash; the work directory as the durable record of every step | **Delivered, and the sharpest fit in the paper.** Because an agent invocation is a task, its canonical identity -- runner, model, instruction, goal, prompt, output schema, skill-content fingerprint, tool fingerprint -- is folded into the hash, and a replay is served only for that exact configuration. Editing a tool invalidates it | +| **Signed reproducibility bundles and audit trails** -- BioCompute Objects, version-locked dependencies | Lineage records ([data lineage ADR](20250508-data-lineage.md)); container digests; the work dir | Substrate: an agent run emits an `AgentRun` lineage record naming the resolved model, tools and skills, and tool tasks are container-pinned. BCO emission and bundle signing are **gaps** | +| **Enforced tier boundaries** -- research / benchmarked / clinical grade, *"preventing agents from applying research-grade skills in clinical contexts without explicit override"* | Config scopes and selectors; module metadata as the place a tier would be declared | **Gap.** Nothing declares or enforces a tier. This is the most consequential unaddressed item, and module metadata is where it belongs | +| **Formal benchmarking infrastructure** -- standardized datasets, confidence intervals, controlled multi-site evaluation; plus held-out partitions and rotated benchmarks to avoid circular tuning | `nf-test`; nf-core's community benchmarking practice; portability across sites, which is what makes multi-site evaluation mechanically possible at all | **Gap** as infrastructure. The primitive contributes only the precondition: because a tool is a tested module and an agent result is an ordinary channel value, an agent is benchmarkable by the same harness as a pipeline | +| **Out-of-distribution and bias detectors as first-class components** -- population mismatch, sequencing-platform drift, tissue-context mismatch; the conceptual machinery (calibration curves, conformal prediction, density-ratio estimators) exists and *"what is missing is their routine integration into agentic pipelines"* | Processes and channels: a detector is just another module in the graph | Substrate. Because agent output is a typed channel value, a detector composes downstream of an agent exactly as it would after a caller. What is missing is the **library** of such detectors and any obligation to run them -- a community/registry concern | +| **Equity as a systems requirement** -- equity-aware defaults, population-aware model selection with population metadata, bias monitoring during execution, measurable metrics such as the proportion of skills validated on non-European populations | Module metadata; declared, reviewable tool sets | Partial, and indirect: because the tool set is *declared* rather than discovered, an agent cannot silently reach for the most abundant reference resource -- it selects from a curated set a human reviewed. Population metadata and equity metrics themselves are **gaps** | +| **Reproducibility by design, and lightweight preregistration** -- a plan expressed as a fixed sequence of skill invocations deposited at a registry (e.g. OSF), with deviations *"automatically detectable as differences between the registered and executed skill traces"* | Pipelines are already versioned, executable, git-resolved artifacts; the DAG and lineage are the trace | Strong fit, unclaimed. A Nextflow script with pinned module versions *is* a preregisterable plan, and lineage is the executed trace, so the registered-vs-executed diff the paper wants is mechanically available. Nothing implements the comparison | +| **Model hosting for low-resource settings** -- intermittent connectivity, documented minimum hardware, pre-packaged equity-aware defaults, local-first computation so *"genetic data never leave the researcher's machine"* | The executor abstraction and containers: the same pipeline runs on a laptop and on a cluster, which is precisely the local-first property being asked for | Substrate, plus one addition: the `AgentRunner` SPI makes the model endpoint swappable like an executor, so the same pipeline can target a hosted API or a local model without touching pipeline code | + +Two conclusions follow. First, Nextflow is not incidental to this agenda -- for replay, audit, portability, local-first execution and preregistration, the mechanism the paper asks for already exists and is in production. Second, the items that remain gaps cluster in one place: **skill and module metadata** (tiers, population scope, equity metrics) and **community governance** (benchmark datasets, detector libraries). Those are registry and ecosystem problems. They are not reasons to keep the agent outside the engine; they are the next ADRs. + +## Why a primitive and not a process? + +A Nextflow pipeline could implement agentic workflows today, using processes that call an agent harness via CLI or API. However, this approach requires a significant amount of boilerplate to implement while preserving Nextflow best practices: + +- Supporting one or more agent harnesses in a portable and scalable manner +- Designing the agent-in-a-process to cache only the relevant inputs +- Enforcing the declared output structure with a JSON schema +- Recording an agent run correctly as a `TaskRun` lineage record +- Enabling dynamic workflows over a set of modules + +This "boilerplate" is significant enough to warrant a language primitive, as implementing all of this pipeline code would distract from the pipeline's core purpose of defining the data flow. + +An agent primitive solves all of these issues in the runtime: + +- An `agent` definition is more concise than an equivalent `process` definition. It provides built-in caching and output validation. +- The agent runner (harness) is a plugin extension, allowing it to be configured independently from the pipeline code (like executors). +- Agent runs are recorded in lineage with a dedicated `AgentRun` lineage record. +- Agents can run modules in the same compute environment as the main run. + +The following examples highlight agentic use cases that would be difficult to implement without an agent primitive: + +- `examples/agents/11_contig-filter` +- `examples/agents/12_isolate-triage` +- `examples/agents/15_map-reduce` + +## Goals or Decision Drivers + +- **Extend the reproducibility framework, do not fork it**: a non-deterministic step must inherit the same guarantees a deterministic one gets -- container-pinned dependencies, an executor-agnostic work dir, a content-addressed cache key, and a lineage record. +- **First-class language construct**: agents compose with processes and other agents through the existing channel/workflow model. +- **Task-shaped primitive**: one invocation per input record, executed as a `TaskRun`, so parallelism, caching, resume, retry, lineage and reporting are inherited rather than rebuilt. +- **Agentic map-reduce with no orchestration layer**: an agent is a dataflow node awaiting its inputs, so fan-out over a channel, parallel map and `collect()`-style fan-in come from the execution model rather than from a scheduler written on top of it. +- **Agents and skills are modules**: an agent is distributable as a versioned module carrying its own skills and tools, includable and aliasable like a process, and configurable from outside via the `agent` scope and selectors -- reusing module resolution rather than inventing a skill format. +- **Tool calls are real dataflow nodes**: the module runs through the standard executor/container/retry/cache machinery and its output flows back to the agent. +- **Modules are tools**: a module's description, inputs, and outputs come from its `meta.yml` / registry `ModuleMetadata`, so there is no parallel tool-metadata format to maintain. +- **Runner behind an SPI**: the agent harness is swappable like an executor; core does not depend on a specific harness. +- **Backward compatibility**: zero impact on existing `process`/`workflow`/`function` declarations. + +## Non-goals (v1) + +- Long-lived conversational agents and cross-invocation memory (one invocation → one result). +- Channel-aware orchestrator agents (an agent does not subscribe to channels mid-loop). +- Agent-to-agent invocation as tools (composition is via channels). +- Cost tracking, token budgets, prompt analytics. +- Streaming partial outputs. +- The registry- and community-side items in the agenda above: validation-tier declaration and enforcement, benchmarking infrastructure, OOD/bias detector libraries, population and equity metadata, BCO bundle emission, preregistration diffing. These are metadata and governance work that this language change should enable, not absorb. + +## Solution + +A new `agent` keyword, lowered to a V2 `TaskProcessor` whose body is the agent loop; declared `tools` are namespaced references -- `nf:module_run[:]` over the processes in scope, whether declared locally or `include`d from a local or registry module, plus `fs:`/`shell:` for the runner's own tools -- and the selected modules are pre-wired as dataflow gateways; the harness lives behind the `AgentRunner` SPI in a plugin. + +```nextflow +agent shouty { + model 'openai/gpt-5-mini' + instruction 'Call the `uppercase` tool, then reply with only its result.' + tools 'nf:module_run' + + input: + request: String + + output: + answer: String + + prompt: + "${request}" +} +``` + +## Rationale & discussion + +The defining choice is **agent-as-task**. Each invocation is one record-in / result-out node; multi-turn reasoning happens inside it, and multi-agent pipelines compose like multi-process pipelines. This reuses channel composition and adds exactly one node type -- and, crucially, it is what makes the reproducibility machinery apply to agent decisions at all. + +Modules-as-tools over a bespoke tool format reuses the `meta.yml` schema already validated by `ModuleSchemaValidator`, so tool descriptors come for free. This is the same interoperability argument the paper makes from the other direction -- *"a skill can wrap a Nextflow module […] the agent paradigm must build on, not displace, two decades of community investment in reproducible infrastructure"* -- except that here the module **is** the skill, with no wrapper layer. + +### Does the primitive satisfy the definition? + +The infrastructure agenda is mapped above; this is the narrower question of whether the construct is the right *shape*. The paper's definition is four necessary conditions, and it makes them empirically testable via a **perturbation test**: a system fails to qualify as agentic if, given identical input with a perturbed intermediate result, it does not alter its execution strategy. + +| Condition | Mechanism here | +|---|---| +| **Autonomy** -- decisions during execution, not a static specification | The body is a tool-calling loop: which tool runs, with which arguments, and how many turns, are decided at runtime up to `maxIterations` | +| **Domain constraint** -- a structured library of validated operations, not code generated ad hoc | `tools` / `skills` resolve to in-scope processes and versioned registry modules; the agent cannot invent a tool outside the declared set. Enforced by the language, not by prompt discipline | +| **Iterative refinement** -- evaluate intermediate results, diagnose, self-repair | Tool results, including dispatch-level errors, are returned to the model so it can adapt within the loop -- which is what makes the perturbation test pass rather than fail | +| **Natural-language mediation** | `prompt:` with typed interpolation, plus `instruction` / `goal` | + +So the construct qualifies on the paper's own terms, while remaining a task -- which is the whole point. + +It is worth stating the counter-position plainly, because the perspective makes it directly: + +> Nextflow, Snakemake and Galaxy provide powerful, reproducible pipeline orchestration, but their workflows are specified in advance by a human developer. […] Agentic genomics builds on workflow infrastructure (an agent may invoke a Nextflow pipeline as one step in a larger analysis) but is not reducible to it. + +That is correct about workflow managers *as they are*, and it is the reason this is a language change rather than a library: what makes the DAG insufficient is precisely that it is fixed in advance, so the fix is a node type whose edges are chosen at runtime. Notably, the paper treats deterministic replay as an unmet gap across all four systems it surveys (CellAtria, AutoBA, Bio-Copilot, ClawBio), every one of which sits *outside* a workflow engine. The claim here is that this is not a coincidence: putting the agent *inside* the engine is what makes replay fall out of existing machinery instead of having to be built. + +## Links + +- Agentic genomics framing: Corpas, M., Guio, H., and Fatumo, S. (2026). *Agentic genomics: From pipeline automation to autonomous validation.* Cell Genomics **6**, 101305. +- Agentic map-reduce as the scaling pattern: [Devin, *Agentic MapReduce*](https://devin.ai/blog/agentic-map-reduce) +- User guide: [`docs/agent.mdx`](../docs/agent.mdx) +- Design: [`adr/specs/agent-design.md`](specs/agent-design.md) -- the primitive: lowering to a task, typed I/O, tools, skills, modules, configuration, caching and lineage +- Design: [`adr/specs/agent-runners.md`](specs/agent-runners.md) -- the `AgentRunner` SPI, runner selection, and the two shipped harnesses +- Design: [`adr/specs/agent-rpc.md`](specs/agent-rpc.md) -- canonical task execution, the driver broker protocol, transport security and scalability +- Examples: [`examples/agents/`](../examples/agents) (`validate.sh` runs them end to end; `-r` also checks that each replays from cache on `-resume`) +- Related ADRs: [`record types`](20260306-record-types.md), [`module system`](20251114-module-system.md), [`data lineage`](20250508-data-lineage.md), [`type system`](20260501-type-system.md) +- Inverse pattern this design inverts: [`colbyford/nf-foundry-workflow`](https://github.com/colbyford/nf-foundry-workflow) -- Foundry agents calling Seqera MCP to launch Nextflow pipelines +- Runtime engine: [`langchain4j`](https://docs.langchain4j.dev/tutorials/agents) diff --git a/adr/specs/agent-design.md b/adr/specs/agent-design.md new file mode 100644 index 0000000000..d5217b0697 --- /dev/null +++ b/adr/specs/agent-design.md @@ -0,0 +1,501 @@ +# Agent primitive — language surface and execution model + +- Status: implemented (experimental, unreleased) +- Scope: the `agent` construct, its lowering to a task, typed I/O, tools, skills, modules, + configuration, caching and lineage +- Companion documents: [agent runners](agent-runners.md) (the harness SPI and its + implementations), [agent RPC](agent-rpc.md) (how an out-of-process runner reaches the driver) +- Decision record: [`adr/20260505-llm-agent-primitive.md`](../20260505-llm-agent-primitive.md) +- User guide: [`docs/agent.mdx`](../../docs/agent.mdx) + +This document is the architectural reference for the primitive. It records *what was decided and +why*; the code is the reference for *how*. Where a decision has a non-obvious rationale or a known +sharp edge, that is stated here rather than left to be rediscovered. + +## 1. The shape of the thing + +An `agent` is a **task-shaped** primitive whose body is an LLM call, optionally with a tool-calling +loop. One input record in, one result out, executed as an ordinary `TaskRun`. + +```nextflow +agent triage { + model 'openai/gpt-5-mini' + instruction 'You triage bacterial isolate assemblies step by step.' + tools 'nf:module_run' + + input: isolate: Isolate + output: verdict: Verdict + + prompt: "Triage isolate ${isolate.sample_id} (${isolate.organism})." +} +``` + +Everything else in this document follows from one choice: **agent-as-task**. Because an agent +invocation is a `TaskRun` on the standard `TaskProcessor` machinery, parallelism, `maxForks`, +retries, `errorStrategy`, work directories, `-resume`, lineage, the progress table and the executor +abstraction are all *inherited* rather than rebuilt. The alternative considered and rejected — a +bespoke GPars operator with bolted-on progress and caching — would have had to hand-roll +`invokeTask → checkCachedOrLaunchTask → submitTask → monitor → finalizeTask → collectOutputsV2`, +strictly more code than reuse. + +### 1.1 The boundary invariant + +Two rules hold everywhere and are the reason the design stays composable: + +- **Core never imports `dev.langchain4j.*`** (or any other LLM client). `modules/nextflow` and + `modules/nf-lang` know only about the portable `AgentRunner` SPI: `ToolDescriptor` / + `SkillDescriptor` maps, an `AgentRunnerRequest`, and a `String`/JSON result. +- **A runner plugin never touches `ProcessDef`, `Channel` or `Path`.** Dataflow, module resolution, + schema derivation and JSON→record binding are core concerns. + +Every mechanism below is designed to keep that seam intact. See [agent runners](agent-runners.md). + +## 2. Lowering: agent → `TaskRun` + +`AgentDef.run(Object[])` mirrors `ProcessDef.runV2` and produces the same three artifacts a v2 +process produces: + +1. a **`ProcessConfigV2`** with one `ProcessInput` per declared `AgentInput` and one `ProcessOutput` + per declared `AgentOutput`; +2. a **`BodyDef`** — `exec` (Groovy, in-JVM runner) or `script` (canonical, out-of-process runner); +3. **invocation wiring** identical to `runV2`: one source channel per argument, `CH.create(singleton)` + per output into a `LinkedHashMap`, then `createTaskProcessor().run()`, returning a `ChannelOut` so + `myAgent.out.` resolves. + +`AgentDef` is deliberately **not** a `ProcessDef` subclass. It reproduces the ~12 lines of +`createTaskProcessor()`/`applyConfig()` instead, because making it a subclass would drag in +`ProcessDslV2`-driven config construction, which an agent cannot use: a process builds its +`ProcessConfigV2` once per definition, an agent builds one **per invocation** with synthesized +directives, body and output values. + +### 2.1 Where the model call sits + +| Runner kind | `BodyDef` type | Executed by | Model call runs | +|---|---|---|---| +| in-JVM (`langchain4j`) | `exec` → `ScriptType.GROOVY` | `AgentTaskHandler` on the dedicated `agent` executor | driver JVM | +| canonical (`pi`) | `script` → `SCRIPTLET` | whichever executor `agent.executor` names | agent task container | + +The in-JVM body runs on a worker thread, **not** the GPars operator thread. That single fact is what +turns a serial operator into parallel tasks: the operator returns as soon as the task is submitted. + +**The dedicated `agent` executor exists because of a two-throttle problem.** The stock +`LocalPollingMonitor` admits tasks only while `taskCpus <= availCpus`, so agent concurrency would be +capped at host CPU count — and would degrade to *serial* on a 1-vCPU CI runner, silently. Lifting +that requires **both** a non-CPU-gating monitor (`AgentPollingMonitor`) **and** a dedicated run pool +(`AgentTaskHandler`), because a non-gating monitor alone would admit handlers that then queue inside +the shared `session.getExecService()` pool — over-reporting "running" in the progress table while +only `poolSize` bodies actually execute. A dedicated pool also stops long LLM calls from starving +bash task launches, which hold a pool thread for their whole duration. + +### 2.2 The output contract + +`NativeTaskHandler`-style bodies put their return value in `task.stdout` — a single value. Multiple +outputs cannot travel that way, so **the body writes each output into `task.context`**, which +`collectOutputsV2` resolves through `TaskOutputResolver` and `bindOutputsV2` binds per channel. + +The body closure runs `DELEGATE_ONLY` with `delegate = task.context`, and output names are dynamic +strings, so the write is `getDelegate().put(name, value)` — never bare assignment. `task.stdout` is +deliberately ignored on the in-JVM path. This is the highest-risk contract in the lowering and is +covered by focused tests. + +## 3. Typed I/O + +I/O uses the same typed declarations as a process and means the same thing. Exactly one deliberate +divergence exists (§3.3). + +### 3.1 Inputs + +N inputs, each `name: Type`. GPars' "fire when every input channel has a message" rule performs the +N-input combine natively, and `ProcessInputsDef.isSingleton()` decides fire-once vs fire-per-item — +so **fan-in needs no code at all**: `reducer(findings.collect())` feeds one value-channel item and +the task runs once. + +`Path` inputs reach full process parity — staged into the task directory, bind-mounted in a +container, rendered as the plain filename in both the prompt and the input JSON. This works for a +bare `Path`, a `Set`/`List`, and `Path` fields of a record input, recursively. + +**Path inference is shared at compile time, not reimplemented at runtime.** `_input_` receives a +`Class`, so `Set` arrives with its element type erased; runtime inference *cannot* reproduce +the `Collection` branch. `AgentToGroovyVisitor` therefore calls the same nf-lang helper +`ProcessToGroovyVisitorV2` uses, making agent staging identical to process staging by construction. + +`tuple(...)` inputs are **rejected** with an explicit diagnostic rather than silently half-ignored, +and so is the destructured `record(...)` form. + +### 3.2 Outputs + +Two independent facts, and different consumers key on different ones: + +- **An explicit right-hand side makes the output's value the RHS**, which removes it from the set the + model answers. This is the process rule verbatim. +- **A `file(...)`/`files(...)` call in that RHS additionally registers an unstager**, making the value + a work-directory collection. + +| Declaration | Model answers it? | Collected from the work dir? | +|---|---|---| +| `answer: String` | yes | no | +| `report: Path = file('report.md')` | no | yes | +| `answer: String = 'fixed'` | no | no | + +An agent whose outputs are *all* work-dir outputs is legal: the model is given no output contract and +its final text is discarded. **Nothing tells the model which filename to write**, so the prompt must +name it; a mismatch fails the task with an arity error. Automatic injection was rejected because it +would render an arbitrary pattern closure into instruction prose and change the resume cache key. + +`AgentDsl` declares a nested `OutputDsl` exposing only `file`/`files`, so `eval()` and `stdout()` +fail to resolve in an agent output — a compile error, which is what we want. `ProcessUnstageVisitor` +gained a `filesOnly` mode for the same reason: its `env`/`eval` branches produce a `.command.env` +that only `BashWrapperBuilder` writes, which an in-JVM agent never has. + +### 3.3 Structured output, and the wrapper rule + +A named `record` output enables structured output: the record is reflected into a JSON schema +(`RecordSchema`) that constrains the model's response. + +| Outputs | Schema | +|---|---| +| one scalar | none — free-text passthrough | +| one record | the **bare** `RecordSchema.of(type)` | +| more than one | a synthesized wrapper object, one property per output name, all `required`, `additionalProperties: false` | + +A single record is **not** wrapped. Wrapping it would burn one of OpenAI's five nesting levels +(dropping usable record depth 5→4) and change the wire shape, invalidating every existing cache +entry — for no benefit, since a record schema is already a valid strict object root. + +Top-level scalars in the multi-output case need explicit coercion: `JsonSlurper` types JSON numbers +on its own, so a `count: Long` would otherwise arrive as an `Integer` on the channel. + +Optional (`?`) record fields are handled by the runner, not by `RecordSchema`. Under OpenAI strict +mode langchain4j rewrites the field's type into the nullable union `["string","null"]` and forces +every field into `required` — the canonical strict-optional idiom. Encoding a union in +`RecordSchema` too would double-encode it and emit the more verbose `anyOf` shape, a *different* +wire schema and therefore a different cache key. This is pinned by a regression test rather than +fixed in source. + +### 3.4 The prompt + +`prompt:` is the templated user message. Like a process `script:` block it may contain helper +statements, and its **last expression** is what is sent. The whole input is additionally serialized +as JSON and appended, so the model sees structured values the template omits. + +nf-lang captures the prompt closure's free-variable references into `PromptDef`, mirroring the +`VariableVisitor` that populates `BodyDef.valRefs` for a process body. Without that, a prompt closing +over a workflow global would be invisible to the cache key with no way to even warn. + +## 4. Tools + +### 4.1 The declaration grammar + +`tools` entries are **namespaced references** — `family[:segment]*:name` — replacing the earlier flat +list that multiplexed capability literals, bare process names, module paths and registry coordinates +into one string and resolved them by trying each shape in turn. + +| Family | Members | Executed by | +|---|---|---| +| `nf:` | `module_run`, itself a non-leaf over every in-scope process | the driver, as a real Nextflow task | +| `fs:` | `read`, `write`, `edit`, `ls`, `grep`, `find` | the runner | +| `shell:` | `bash` | the runner (`pi` only) | +| `mcp:` | reserved, unspecified | — | + +Nine rules govern it; the load-bearing ones: + +- **A non-leaf means its whole subtree**, so `nf:module_run` ≡ `nf:module_run:*`. +- **Globs only in the trailing segment, and only anchored to a family.** Bare `*` is rejected; + `nf:*` and `fs:*` are legal because a family's membership is fixed by the Nextflow release rather + than by remote configuration. A family with remote membership (`mcp:`) may never be globbed at its + root. +- **Zero match is an error** — unknown family, missing explicit leaf, glob matching nothing, non-leaf + over an empty subtree. A directive is a declaration, not a filter. +- **No negation operator.** The safety boundary sits on a family line instead: `shell:` escapes the + filesystem sandbox, so it is its own family and `fs:*` never selects it. That is what makes "files + but not a shell" expressible without `!`. +- **Order-independent set union**; overlapping references are idempotent. + +The shape is Claude Code's permission-rule grammar with `:` for `__` and one deliberate deviation: +bare-prefix-implies-subtree is promoted from the server segment to *every* non-leaf depth. The colon +separator is not cosmetic — MCP tool names may contain `_`, so `__` is ambiguous, while `:` cannot +occur in a Nextflow identifier and is illegal in an OpenAI function name, forcing the +declaration/wire split below to be explicit. + +### 4.2 Declaration namespace vs wire namespace + +OpenAI function names are `[a-zA-Z0-9_-]{1,64}`, where a colon is illegal, so **a wire name never +contains a colon**. `nf:module_run:SAMTOOLS_SORT` is `SAMTOOLS_SORT` on the wire; `fs:read` is +`read`. The wire names are identical on both runners, so a pipeline stays portable. + +Two checks run once at agent-build time, where skills and the output schema are also known: + +- **Validate, never sanitize.** A selected process whose name is legal Nextflow but illegal on the + wire (`my$proc`, over 64 characters) is a hard error. Silent renaming would collide `my$proc` with + an existing `my_proc`. +- **One wire name from two different sources is a hard error naming both**, with a message stable + under declaration order. The namespace being checked is the whole one the model sees, including the + runner-injected `activate_skill`, `read_skill_resource` and `final_answer`. + +### 4.3 Brokered vs runner-native + +**`toolSpecs` carries brokered tools only.** This is the load-bearing invariant of the tool layer. + +- **Brokered** (`nf:module_run:X`) — a `ToolDescriptor` crosses to the runner and the call comes back + to the driver, which runs the module as a real Nextflow task wherever the agent runs. +- **Runner-native** (`fs:`, `shell:`) — resolved to *names* the runner is told to enable, carried on + their own `AgentRunnerRequest` field, never as descriptors. The `pi` harness passes them to its SDK + allowlist; `langchain4j` rebuilds descriptors from them and serves the tools in-JVM. + +The alternative — a per-descriptor locality flag — was rejected: a mis-set flag would silently run a +container-side tool in the driver JVM, and it obliges the harness to reimplement what its runner +already ships. + +**The sandbox boundary is per-runner and they are not the same boundary.** On `langchain4j`, `fs:` +tools go through `SandboxGuard`, confined to the task work dir for writes and additionally to +per-invocation whitelisted module-output paths for reads. On `pi` the SDK builtins are rooted at the +session `cwd` with the container as the outer bound — coarser — and `shell:bash` has no boundary +inside the container at all. This is documented rather than papered over. + +### 4.4 The module tool bridge + +A tool call must run synchronously from inside the agent body: the model is blocked waiting. But +invoking a `ProcessDef` after `session.fireDataflowNetwork()` **deadlocks** — a new operator's start +is deferred onto the igniter list, which is drained exactly once at ignition. Creating a dataflow +node post-ignition is not available to user code, and this is precisely why the primitive must live +in the engine rather than in a pipeline. + +`ModuleToolBridge` is therefore built **in the workflow body, before ignition**. For each tool it +derives a `ToolDescriptor` and starts a dataflow **gateway** over a request queue. Each call carries +its own reply variable; the gateway creates fresh input/output channels and invokes a cloned +`ProcessDef`. Correlation is represented by dataflow variables rather than by ordering on a shared +lane, so **independent requests — including repeat calls to the same tool — execute concurrently**. +Request-scoped processors created after ignition start through the isolated +`Session.addProcessorIgniter` path, leaving workflow igniter behaviour unchanged. + +> An earlier design pre-wired one persistent process instance per tool and made dispatch +> `synchronized`, because binding two calls' arguments before reading would break the 1:1 +> input→output correlation on broadcast channels. The gateway replaced that. Serialization of tool +> calls today is a **runner** property — both harnesses execute tool calls sequentially — not a +> bridge property. + +Descriptor sources, in order of preference: + +| Source | When | +|---|---| +| registry `ModuleMetadata` | the module resolves in the registry — richest: per-field descriptions, patterns, enums, the nf-core `meta.id` convention | +| sibling `meta.yml` `ModuleSpec` | a local module, or registry metadata unreachable | +| declared typed process I/O (`ProcessToolSchema`) | an in-scope process with no `meta.yml` | + +Schemas are **flattened**: `tuple(val(meta), path(fastq))` contributes one top-level property per +component, so the model passes `{"meta": …, "fastq": "/abs/path"}` rather than a nested array. A +drift guard warns when registry metadata and the installed `meta.yml` disagree on flattened input +names. + +Per call: parse arguments; marshal them through `ProcessEntryHandler.getProcessArguments` (the same +path as `nextflow module run`, so type coercion and tuple assembly are shared); bind onto the +gateway; block on the reply; serialize outputs. `eval`/`topic`-routed bookkeeping outputs (nf-core +`versions`) are skipped authoritatively from the `ProcessDef`, because their channel never binds a +per-call value and reading it would block forever. + +File outputs are **opaque absolute path strings** — the model chains them between tools and never +reads contents — with one deliberate exception: small structured text (`.json`/`.tsv`/`.csv`/…, under +`agent.maxToolOutputInlineSize`, non-binary) is **inlined**, which is what enables data-driven control +flow such as gating on a QC statistic. + +Dispatch-level failures (unknown tool, unparseable arguments, bad marshalling) are returned as +`{"error": …}` so the model can correct and retry, counting toward `maxIterations`. A failing tool +**task** is not recovered — it surfaces through the process's own dataflow operator and aborts the +session. That asymmetry is a known limitation, not an oversight. + +## 5. Skills + +A skill is a `SKILL.md` folder — YAML frontmatter with `name` and `description`, then a Markdown body +— optionally carrying bundled resources. + +- **Local**: a bare name resolves to `/skills//`. A single entry whose directory + has no `SKILL.md` expands to every immediate subdirectory that has one. +- **Remote**: an explicit GitHub host (`github.com//[@rev]` and the `https://`/`git@` + forms) is shallow-cloned into a **rev-keyed** cache directory. The bare `org/repo` form is + deliberately *not* accepted — the registry-module rule already claims it. + +Skills cross the SPI as portable `SkillDescriptor`/`SkillResource` DTOs with content eagerly loaded, +so no `Path` crosses and core stays LLM-client-free. The model sees each skill's name and description +up front, reads the body through `activate_skill`, and resources through `read_skill_resource`. +**Skills run no code**; the clone executes nothing from the repository. + +Resource scanning skips `.git/` and symlinks, rejects paths escaping the skill directory, and caps at +64 files / 256 KB. Frontmatter is split by hand rather than through `fromYaml`, tolerating a BOM, +blank lines and CRLF so a skill that loads in langchain4j's own loader also loads here. + +> A remote `SKILL.md` becomes model instructions — a prompt-injection surface, worsened by a moving +> ref. Pin a commit SHA. + +## 6. Agents as modules + +An agent is authorable as a module and consumed with the ordinary `include` statement. The headline +finding of that work: **the include path already worked**, because it is generic over `ComponentDef` +and never casts to `ProcessDef`, and the language layer already enumerated agents as includable +definitions. The deliverable was tests, docs and a handful of small parity fixes, each of which +*deleted an asymmetry* between an agent and a process — a stable `baseName` so selectors match an +aliased agent, agent names reaching the selector registry, an nf-lang formatter that was **deleting** +agent declarations, and a compile-time arity check. + +The design principle: **a module's consumer cannot edit the module file.** Everything the consumer +needs from outside — aliasing, config selectors, comprehensible errors — must work without touching +it. That is the only reason any code changed. + +Locked semantics: + +- **File-relative resolution uses the *defining* module directory** — skills, relative tool paths, + `moduleDir` — including under an alias, because `cloneWithName` preserves `owner`. There is **no + fallback** to the project directory: a fallback would make a module's behaviour depend on the + including project's layout, and would let a consumer silently shadow a module's instructions. +- **`nf:module_run` is module-lexical.** An agent sees only processes defined or included by the + script that declares it. If the tool surface depended on the includer, the tool schema — and + therefore the model's behaviour and the cache content — would vary by consumer, which is not a + module. It is also the same rule a process body already obeys. Consequence to state loudly: **a + module agent must include its own tool modules.** +- **Params are inherited** from the run; `params()`/`addParams()` are dead on the v2 path. +- Registry-hosted agent modules are designed but deferred: the transport works, publishing and + validation do not. + +`AgentDef.cloneWithName` is a bare `Object.clone()`, so aliases **share** directives, inputs, outputs +and the prompt with the template. This is safe because those fields are read-only after construction +and `buildAgentTask` runs single-threaded during graph construction. Do not "fix" it into a deep copy +without a failing test. + +## 7. Configuration + +The `agent` scope has two axes — **agent options** and **task directives** — and is fully independent +of `process`: an agent never reads `process` defaults or selectors, and vice versa. + +Parity with the process scope (selectors, regex and `!` negation, `withLabel:`, `ext` merge, +repeatable and dynamic directives, selectors inside `profiles`) is achieved by **reusing +`ProcessConfigBuilder` verbatim** plus the smallest possible glue: a scope-name list, a reflective +agent-only key set, two inert constructor parameters and a `kind` noun for user-visible messages. No +second selector engine, no copy of `matchesSelector`/`ext`-merge/`putWithRepeat`. + +The agent-only key set is **derived reflectively from `AgentConfig`**, not hand-listed, so an option +added to the scope cannot drift into being treated as a bogus task directive. + +Two consequences worth stating: + +- The `agent` scope uses the option names `AgentConfig` declares (`model`, `maxIterations`), not the + body-directive names. Writing `agent { instruction = … }` is a user error and is reported as one, + the same diagnostic a process typo gets. No alias layer was introduced. +- `agent.executor` defaults to `local`, **never** to the global `executor.name`. An in-JVM runner + accepts no other value and runs on the dedicated `agent` executor (§2.1). `agent.container` falls + back to the runner's own declared image when the key is absent — see + [agent runners §4.4](agent-runners.md#44-the-image-is-a-release-artifact-and-the-plugin-declares-it). +- `agent.rpc.*` is read once per session, so a value inside a selector block resolves but has no + effect. + +### 7.1 Endpoint and credentials + +One defect drove this design: `prefix == 'openai'` gated credential resolution, endpoint resolution +*and* client selection. One prefix answered three questions — which **wire protocol** to speak, whose +**credential** this is, whose **endpoint** this is. Fusing (1) with (2)–(3) is why Anthropic and +OpenRouter had no expressible spelling. + +`agent.apiProvider` splits them out: it names the **credential and endpoint namespace** and does not +touch protocol selection, which the model-id prefix keeps. + +``` +provider = agent.apiProvider // explicit always wins + ?: inferFrom(neutral baseUrl) // exact host or dot-suffix, never substring + ?: prefixOf(model) +``` + +Inference outranks the prefix because the prefix names a *protocol*, so it is the weaker signal for +"whose key". Circularity is broken by inferring only from the **provider-neutral** base URL. + +Both `apiKey` and `baseUrl` then resolve on the same four tiers: config → `NXF_AGENT_*` → +`_*` → unset. Tiers 1 and 2 are provider-neutral and reach any endpoint. **Tier 3 is +gated**, because a runner installs the credential it is handed ahead of anything it could resolve +itself — so an unrestricted tier 3 would be a credential-disclosure primitive. A `_API_KEY` +travels only when the endpoint agrees: it is that provider's host, it came from the same namespace's +`_BASE_URL`, `agent.apiProvider` is explicit and the host is not a *different* known +provider's, or no endpoint resolved at all **and** the provider equals the model prefix's own. + +"Withheld" is not "missing". Both used to answer `null` and both used to become +`PLACEHOLDER_API_KEY`, converting a diagnosable misconfiguration into an opaque 401. +`credentialWithheld` carries the distinction, and the placeholder now exists only for a genuine +no-credential local endpoint. The two runners then diverge deliberately: `langchain4j` **errors** +(core is its only credential source), `pi` **warns once** (it has a store and a container environment +core cannot see, and a driver-side abort would break a working deployment). + +## 8. Caching, resume and lineage + +### 8.1 The cache key + +No `TaskHasher`, `HashBuilder` or `CacheDB` edits. `TaskHasher.compute()` already hashes +`task.source` and the resolved per-record `task.inputs`, so **the single injection seam for static +agent identity is `BodyDef.source`**, set to a canonical string. It folds: + +runner · resolved model id · resolved endpoint · instruction · goal · `maxIterations` · prompt +template and its captured variables · output schema · a name-sorted content fingerprint of every skill +· a fingerprint of every module tool (schema + backing process script) · the resolved runner-native +tool refs plus the runner's identity and version. + +Three notes on that list: + +- It folds **no agent name and no file path**, so moving or renaming a module directory does not + invalidate; aliasing does, via the fully-qualified `task.processor.name` in `TaskHasher`. +- **Runner-native tools need their own contribution** precisely because they have no descriptor. + Without it, an agent's `fs:*` would be absent from its key and a runner upgrade that changed what + `edit` does would replay a stale generation. Runner identity + version is deliberately coarser than + a schema hash — it is the granularity at which those tools' behaviour actually changes — and it + correctly makes the key runner-dependent, since the same `fs:read` is a different implementation on + each runner. +- **Globs are forward-open.** `nf:*` silently acquires tools added in a later release. The grammar + does not mitigate that, but the key covers the **expanded** set, so gaining a tool re-runs the agent + rather than replaying. Explicit refs are the reproducible choice. + +Resume required exactly one shared-code change: `TaskRun.hasCacheableValues()` had to persist an +`exec` task's context. Everything else is inherited. `cache false` is the documented opt-out and is +free (`isCacheable()` false → no lookup, no storage). + +**Resume replays a stored generation; it does not make the generation reproducible.** A floating model +alias warns on a cache-writing run, but pinning a dated snapshot is advisory, not enforced. Whether +the engine should ever refuse an unpinned model is an open question, and it is the closest thing here +to a validation-tier boundary. + +Temperature is pinned to 0 for reproducible replays. For a `collect()`-fed reduce, `ArrayBag` +implements `Bag` and `HashBuilder` hashes it order-independently, so the key is stable across fan-in +reordering — but the *prompt* item order can still vary, so `toSortedList()` is the recommendation +when reproducible *fresh* runs matter. + +### 8.2 Lineage + +Zero new observers. A genuine `TaskProcessor`/`TaskHandler`/`TaskRun` routed through the monitor +fires the same `Session.notifyTask*` events that `WorkflowStatsObserver` and `LinObserver` already +subscribe to. An agent emits an **`AgentRun`** record naming the runner, the requested and resolved +model, instruction, goal, prompt template, iteration ceiling, output schema, and the resolved **wire** +tool names. + +Recording the *declared* refs alongside the resolved ones was considered and rejected: it needs a new +field threaded through three layers for no consumer that exists today, and the expanded set is the one +that determines behaviour. Consequence: `fs:*` and `fs:read, fs:write, …` leave identical traces. + +Not recorded: the rendered prompt (the template plus the recorded inputs reconstruct it), the resolved +command (it embeds the RPC capability token), token/turn/tool-call counts (not instrumented), and +`resolvedModel` for an out-of-process agent. + +## 9. Known limitations and open surfaces + +- **Tool-task failure aborts the run.** Only dispatch-level errors are recoverable by the model. +- **Tool calls are serialized per agent** by both runners; parallel/correlated tool calls within one + invocation are a correlation question, deferred. +- **`nf:module_run` exposes declared process inputs only.** A stock nf-core module hides its tuning + flags in `task.ext.args`, which is not in the schema, so an agent cannot tune it without a wrapper + process that surfaces the knob as an input. +- **In-JVM agents do not materialize staged files.** Staging is `SCRIPTLET`-only; an `exec` body goes + to a handler with no wrapper and no stage-in. +- **Agents whose inputs cannot be captured are pinned non-cacheable**, which is honestly + non-replayable rather than falsely replayable. The boundary between keyable and non-keyable tools is + a design surface, not a settled rule. +- **Validation is enabled, not obliged.** An agent result is an ordinary typed channel value, so it can + flow into assertions, `nf-test` and QC processes. Nothing requires that it does. +- No duplicate-invocation guard; an agent-only module is not directly runnable; `nextflow inspect` does + not see agents. + +Registry- and community-side items — validation-tier declaration and enforcement, benchmarking +infrastructure, OOD/bias detector libraries, population and equity metadata, BCO bundle emission, +preregistration diffing — are metadata and governance work that this language change is meant to +enable, not absorb. See the ADR. diff --git a/adr/specs/agent-rpc.md b/adr/specs/agent-rpc.md new file mode 100644 index 0000000000..e2cbd4a48d --- /dev/null +++ b/adr/specs/agent-rpc.md @@ -0,0 +1,383 @@ +# Agent RPC — canonical task execution and the driver broker + +- Status: implemented for direct driver connections; parts of the production hardening remain + proposed and are marked as such +- Scope: lowering an agent to a normal executor-submitted task, the protocol between that task and the + driver, transport security, driver-address inference, and scalability +- Companion documents: [agent design](agent-design.md) (the primitive), + [agent runners](agent-runners.md) (which runners use this transport) + +## 1. The problem this solves + +The first out-of-process runner still ran its loop from inside the Nextflow JVM: the agent was a +`TaskRun`, but its body was a Groovy closure that spawned a child process. Remote executors submit +`SCRIPTLET` tasks, so a Groovy body is always executed locally — the harness could be put in a local +container, but it could never be submitted by Kubernetes, Slurm or Batch. + +The obvious alternative, **checkpointing at every tool boundary**, makes each segment independently +schedulable at the cost of a pod start, a runtime initialization and a session restore *per turn*. +Convergence loops make that overhead impossible to ignore. + +So the target is: + +``` +one logical agent invocation += one canonical local or remote Nextflow task ++ one proxy holding one long-lived outbound RPC stream ++ one harness child process on JSONL stdio ++ zero or more canonical Nextflow tool tasks +``` + +``` +Nextflow driver Canonical local/remote agent task +┌──────────────────────┐ ┌───────────────────────────┐ +│ JVM Agent RPC broker │◄══ gRPC ════►│ Go agent-rpc task command │ +│ Tool dispatcher │ │ ↕ JSONL stdio │ +│ Workflow runtime │ │ Pi or third-party harness │ +└──────────┬───────────┘ └───────────────────────────┘ + ├──► SKESA task on Kubernetes + └──► other canonical tasks +``` + +The feature is **not** a new agent-specific executor. It is lowering an agent invocation to a normal +`SCRIPTLET` `TaskRun` submitted through the existing `ExecutorFactory` / `Executor` / `TaskHandler` / +staging / trace / retry / cancellation lifecycle. + +## 2. Design principles + +1. The agent task is submitted and monitored through the **unmodified** executor lifecycle. +2. The task-side proxy initiates all network connections; workers need no inbound reachability. +3. RPC carries intermediate control messages, **not task completion authority** — the `TaskHandler` + remains the authoritative source of task status, and a disconnected stream is not by itself + evidence that a pod failed. +4. `ToolDispatcher` and workflow runtime objects never leave the JVM. +5. Module tools execute as real Nextflow tasks using **their own** executor and container + configuration. +6. Agent placement and tool placement are independent decisions. All four combinations of + local/remote agent × local/remote tool are architecturally supported, and three were validated. +7. Tool delivery is at least once; execution is idempotent by invocation, attempt and call ID. +8. No runner may invoke a tool outside the allowlist issued for its task. +9. The Nextflow installation does not depend on a platform-specific binary, and a harness needs + neither Java nor gRPC. + +Agent tasks resolve placement and resources from the `agent` scope, defaulting to +`agent.executor = 'local'` — never from `process` defaults or selectors. Wave, Fusion, +container-engine and work-directory scopes remain shared session services. Kubernetes `pod` +directives are deferred: their additive/list semantics need a dedicated configuration model rather +than a scalar pass-through. + +## 3. Transport + +``` +Canonical task ↔ driver: gRPC bidirectional streaming over TLS +Proxy ↔ harness: JSONL over stdio +Unit-test compatibility path: JSONL over stdio, no proxy +``` + +```protobuf +service AgentBroker { + rpc Connect(stream RunnerMessage) returns (stream HostMessage); +} +``` + +The semantic protocol is **independent of gRPC**. Invocation identity, call idempotency, tool +authorization and terminal-state validation are shared by every transport; JVM-side adapters feed one +invocation registry, and the proxy translates frames without owning workflow semantics. JSON-RPC over +WebSocket may later be added as a compatibility adapter for third-party harnesses, but only against +the same state machine — not with different delivery, authorization or recovery semantics. + +Frames currently in use: + +| Direction | Messages | +|---|---| +| harness → driver | `connect`, `tool_call`, `trace`, `complete`, `error` | +| driver → harness | `ready`, `start` (the portable invocation spec, including the credential), `tool_result`, `cancel` | + +Every message after `connect` carries the `invocationId`; tool frames carry a unique `callId`. The +driver **memoizes completed `callId` results** for the invocation's lifetime, so a duplicated protocol +frame cannot execute an expensive scientific module twice. Unknown message types, malformed JSON, +oversized lines, reused call IDs with changed payloads and mismatched invocation IDs are fatal +protocol errors. Stderr is diagnostic output and is never parsed as protocol data. + +**Deferred production vocabulary.** Durable identity (`workflowRunId`, `taskAttempt`, `sequence`), +`heartbeat`, `ack`, and an acknowledged terminal state in which the proxy writes declared result and +manifest artifacts before exiting — so that completion behaviour is uniform across harness +implementations — are designed but not implemented. Today the proxy prints the `complete` payload on +stdout and the driver decodes the last non-empty line. + +## 4. Lifecycle + +Before submission the driver assigns an invocation ID, registers it with the broker, issues a +short-lived capability token, retains the portable agent specification driver-side, and submits the +task through the executor the `agent` scope selects. The generated script is equivalent to: + +```bash +/usr/local/bin/agent-rpc \ + --endpoint : \ + --invocation \ + --token \ + --fingerprint \ + -- node /opt/nf-agent-pi/runner.mjs +``` + +``` +harness ↔ JSONL ↔ proxy gRPC/TLS driver broker + ├── ready + │ connect ──────────────► (invocationId + one-use token) + │◄──────────────────── start/spec ─────────┤ + ├── tool_call (callId, name, args) ───────►├── schedule a Nextflow task + │◄──────────────────── tool_result ────────┤ + ├── trace ────────────────────────────────►│ + ├── complete ─────────────────────────────►│ + └── print the complete payload, exit 0 +``` + +## 5. Authorization and transport security + +### 5.1 What the capability is + +32 bytes of `SecureRandom`, base64url-encoded, **single use** — consumed atomically on connect, so an +observed token cannot be replayed and a second stream cannot race a connected one. Every subsequent +frame is authorized by invocation identity instead. The token is never logged, and provider keys are +redacted from diagnostics. Dispatch enforces a per-invocation tool allowlist, call-ID deduplication +and an atomic call ceiling. + +The authorization model was never the problem. **Lifetime** and **transport** were. + +### 5.2 Capability lifetime + +The capability is registered when the task **script is generated**, which `TaskProcessor` does long +before it submits. The original budget of `max(requestTimeout, 30) + 60` therefore started ticking +during queueing, and any executor that queues — Kubernetes under pressure, Slurm, AWS Batch — would +routinely blow through 180 seconds and fail with `Invalid agent RPC invocation identity or token`: a +security-shaped message for what is really scheduling latency, pointing the operator in entirely the +wrong direction. + +Fix: `agent.rpc.capabilityTimeout`, default **1 hour**, governing the pre-connect wait only. Because +the value is a `Duration`, a bare number is milliseconds — `= 3600` is three seconds, not an hour — +which the user guide calls out in two places. + +Two rejected alternatives, both worth recording: + +- **Revoking the capability when its task reaches a terminal state.** Theoretically tidy, but it makes + a plugin-hosted broker observe `TaskRun` lifecycle across a module boundary and fails badly in both + directions: a missed hook leaks capabilities for the run, a misfiring one revokes mid-stream and + produces exactly the spurious security error this fix exists to remove. A constant cannot misfire. +- **A post-connect timer.** The design originally kept the old budget and re-armed it at connect. That + budget is sized for *one* model request, and a connected agent legitimately makes many — with + `maxIterations` 20 and `requestTimeout` 120 s, ~40 minutes of model time alone, plus every + `tool_call` blocking on a real task that queues behind the pipeline's own work. A 180 s post-connect + deadline would kill essentially every non-trivial agent, reintroducing the same error at the other + end of the lifecycle; re-arming at a larger constant only moves the guess. **Nothing is armed + post-connect.** What such a timer actually covered — a peer that vanishes without a FIN, an + OOM-killed pod, a reclaimed spot instance — is covered by gRPC keepalive, which notices a dead node + in ~80 s without capping a live one. + +`ScheduledThreadPoolExecutor` defaults `removeOnCancelPolicy` to `false`, so the pool is built +explicitly with it enabled — otherwise a *successful* invocation's cancelled deadline stays in the +delay queue, pinning its whole `AgentRunnerRequest` for an hour after it finished. A `-resume` cache +hit registers a capability nothing ever consumes (registration happens before the cache is consulted), +so unconnected capabilities are counted and reported once at shutdown; without that the retention +would be invisible rather than absent. + +### 5.3 TLS with a pinned ephemeral certificate + +The broker generates an EC P-256 key pair and a self-signed certificate **in memory, once per run**, +serves gRPC over TLS with it, and passes the certificate's SHA-256 fingerprint to the task, which +connects only on an exact match. This is the SSH-known-hosts pattern, and the key move is that **a +fingerprint is a public commitment, not a secret** — putting it in `.command.sh` is harmless. + +With no PKI, no CA, no certificate files and nothing to rotate, it buys: + +- **payload confidentiality** — the start frame carries model, instruction, goal, prompt, input JSON, + output schema, tool and skill specs and the credential, and every tool argument and result crosses + the same link. For this domain that is sample identifiers, phenotype text, file paths and + intermediate results; +- **server authentication** — a spoofed endpoint cannot present a matching certificate, closing driver + impersonation. The inbound direction was already defended by the unguessable single-use token; the + *outbound* trust direction had nothing; +- defence in depth for the token, which no longer crosses the network in cleartext. + +BouncyCastle supplies the certificate object — the JDK has no public API for generating a self-signed +X.509 — at the version `nf-k8s` already pins, declared independently because PF4J isolates plugin +classloaders. It is **not** registered as a JVM-wide security provider. Netty's bundled +`SelfSignedCertificate` was rejected for living at a shaded package path that is not stable across +gRPC upgrades; shelling out to `keytool` for being a process spawn per run. The ~10 MB this adds back +to a plugin the packaging change had just taken from 186 MB to under 1 MB is an ordinary pinned Maven +dependency — pure bytecode, every architecture, no extraction, no toolchain — so none of the four +defects that work fixed returns; only the byte count partly rebounds, and bytes were the symptom. +Slimming it is not an option: BouncyCastle jars are signed, and shading breaks the signature. + +`agent.rpc.tls = false` remains as a debugging escape hatch, with a warning, and withholds the +credential. + +### 5.4 The accepted residual: the token on argv + +The token is on the task command line, so it is written into `.command.sh` and persists in the work +directory — commonly group-readable on a shared filesystem, and subject to bucket ACLs on object +storage. + +Calibrate it honestly. Anyone who can read `.command.sh` can already read the staged inputs, +`.command.env` and the outputs — the pipeline's data is already theirs. The token's *marginal* harm is +live invocation hijack, driver-side tool dispatch within the invocation's allowlist, and — since the +driver answers a connection with the start frame — **disclosure of the provider API key**. That last +one is a consequence of in-band credential delivery that the credential design did not consider. + +Certificate pinning does not help here: it is a client-side check, so a thief simply connects to the +real driver and validates. **The lifetime fix is the load-bearing mitigation** — a capability that is +single-use and dies with its window means the durable copy is almost always already dead. + +What *was* fixed is the token escaping the work directory. `TaskHandler.getTraceRecord` set +`record.script` verbatim, and a `TraceRecord` is persisted in the resume cache and POSTed to Seqera +Platform — turning a work-dir-local secret into a stored and transmitted one, for the life of the +cache. `TaskRun.getTraceScript()` now redacts `--token` **for agent tasks only**, guarded by the same +check lineage already uses to omit the agent script entirely. Every non-agent task gets a +byte-identical value, and the executed `.command.sh` keeps the real token. + +Rejected for token delivery: **Nextflow secrets** (writing per-invocation machine state into a +user-facing secret store, racy and invasive); an **environment variable** (Nextflow writes task env +into `.command.run`, the same durable artifact with extra steps); and **mTLS with a per-invocation +client certificate** (strictly better cryptographically, but the private key has exactly the delivery +problem the token has). A `--token-file` with mode 0600 is a contained additive follow-up if a +security review requires it — the gain is POSIX-only and **zero** on object-storage work dirs, which +is why it was not shipped. + +`agent.rpc.bindAddress` was specified and then dropped. Its impersonation half is closed by pinning +outright; its exposure half is not what a bind address fixes, since practically every agent task now +reaches the driver from *outside* the driver's network namespace. What would remain is an option whose +wrong value fails silently in the wrong direction. The server binds every interface, and the code says +so explicitly rather than leaving it to be inferred. + +## 6. Reaching the driver + +Because the runner is container-only, `agent.rpc.remoteHost` became load-bearing on every run — and +its old default, `host.docker.internal`, resolves only on Docker Desktop. Advertising an address +nothing can reach surfaces as a connect timeout *inside the task*, an hour later, possibly after paying +for model calls. + +The address is now **inferred** from what the driver already knows, and the cases that genuinely +cannot work are rejected **before ignition**, each with its own message. + +| # | Condition | Address | +|---|---|---| +| R1 | `agent.rpc.remoteHost` set | that value | +| R2 | `NXF_AGENT_RPC_REMOTE_HOST` set | that value | +| R3 | local executor, `docker` or `podman` | `host.docker.internal` / `host.containers.internal` | +| R4 | local executor, `apptainer` or `singularity` | `127.0.0.1` | +| R5 | local executor, `smolvm` (networking enabled) or `apple-container` | the driver's default-route address | +| R6 | everything else — any other engine on the driver host, grid, Kubernetes, cloud batch | the driver's default-route address | + +R4 is a property of the engine, not a guess: apptainer and singularity create no network namespace, +so the driver's loopback *is* the container's. An explicit `--network host` does not create a rung of +its own. Every other engine on the driver host — Shifter, Charliecloud, Sarus, anything unrecognized +— falls through to R6 rather than to loopback: an unknown engine still containerizes, it simply names +no address for the driver host. + +Only three configurations are rejected: **no container engine enabled at all** (nothing is +containerized, so there is no task to reach back), **`smolvm.network = false`** (the microVM is +created with no network), and **no usable address** (the default route yields loopback, a wildcard, +a link-local address or nothing, and the interfaces do not settle on one unambiguous answer). + +Two measured facts drive this. **The guest's own default gateway is never a candidate** — for Docker +it is the `docker0` bridge inside Docker Desktop's Linux VM, and dialling it fails. And **the host's +default-route source address covers every engine that could be tested**, including both VM-isolated +ones, so a single advertised address suffices. + +The outbound address is the local address the kernel picks for the default route +(`DatagramSocket.connect(…)` then `getLocalAddress()`) — a routing-table lookup, no packet sent. It is +memoized per session, since it is a property of the host rather than of the agent. Loopback, wildcard, +link-local and null are rejected; a multi-homed host is warned about, naming the alternatives, because +the default route may not be the interface the compute fabric reaches the driver on. + +Explicitly **not** address sources: cloud instance metadata (on EC2 the default route already returns +the right address, and off-cloud an unbounded IMDS call hangs) and the Kubernetes downward API (inside +a pod the lookup already returns the pod IP; the API adds a manifest requirement for the same value). +Do not add either back. + +R6 is deliberately **not** split per executor: grid, Kubernetes and cloud batch resolve to the same +address by the same reasoning, so separate rungs would be branches with no different behaviour behind +them — and in-cluster or cloud-membership probes are behaviour gated on a signal that cannot be +tested. An earlier draft carried seventeen rungs, each describing a real topology and each arriving +from an adversarial review rather than an observed failure; they were cut. + +> **Vestigial code from that cut.** `AgentRpcHostResolver`'s class javadoc still describes a ten-rung +> ladder — a containerized-driver rung, a rootless-podman check, in-cluster Kubernetes detection, a +> cloud-membership probe — and the class still carries the `containerizedValue` / `rootlessValue` / +> `cloudValue` fields, a `DEFAULT_BRIDGE_PREFIX` map and an `AbstractGridExecutor` import for them. +> None of it is reachable: `Probes` exposes only `outboundAddress()` and `interfaceAddresses()`, and +> `ladder()` implements the six rows above. The test file likewise keeps an empty `R3 — the +> containerized driver` section header with no test under it. Read the ladder, not the javadoc. + +The advertised endpoint is a **single** `host:port`, not a candidate list. A candidate list would +require withholding the capability token until the fingerprint pin verifies, so that trying N addresses +never offers a bearer token to N hosts. Instead the proxy fails immediately when it cannot connect, +naming the endpoint, and the driver logs the chosen address **with its source label**, so a wrong +answer is one line rather than an hour of silence. + +`podman` is unverified: its VM would not boot on the measurement host, so `host.containers.internal` +rests on documentation. + +## 7. Tool dispatch and scalability + +The stream layer is not the bottleneck. gRPC multiplexes connections on a shared event loop, so an +agent waiting on the model holds an HTTP/2 stream and **no JVM thread**; per-invocation state is small +and every write funnels through a sink that serializes `onNext` and guards post-terminal sends. + +**Dispatch is where a thread is consumed.** `ToolDispatcher.call` returns a `String`, so the pool +thread parks for the entire lifetime of the underlying Nextflow task — minutes to hours for a real +module. + +| Load | Cost | +|---|---| +| agent waiting on the model | ~0 threads | +| agent inside a tool call | **1 parked thread**, for the task's whole duration | +| 200 such agents | ~200 parked threads, unbounded and growing | + +Four defects, ranked: an **unbounded dispatch pool** absorbing load by creating threads until the JVM +degrades, with no push-back and no error naming the cause; the **blocking dispatch SPI**, which is the +ceiling itself; **no global admission control**; and **unbounded diagnostics plus default gRPC +limits**. Practical ceiling: tens of concurrently tool-calling agents, degrading by thread exhaustion +rather than a clean error, against a stated target of hundreds of concurrent invocations and thousands +of idle streams. + +The plan is deliberately ordered so the cheap half ships alone. + +**M1 — make the ceiling visible and survivable** (no SPI change): a fixed pool plus a bounded queue +sized from `agent.rpc.maxConcurrentTools`; on overflow return a dispatch-level `{"error": …}` tool +result, which the model can already recover from, rather than dropping a frame or growing without +limit; a bounded executor and explicit `maxConcurrentCallsPerConnection` and keepalive on the gRPC +server; rate-limited trace/stderr frames that **report** what they dropped; and a high-water mark +logged at shutdown. M1 does not raise the ceiling — it converts silent thread explosion into +observable queuing. + +**M2 — remove the ceiling**: widen the SPI to `CompletableFuture callAsync(…)` with `call` as a +blocking default, so existing runners are unaffected, and have dispatch attach a completion callback +that sends `tool_result` from the completing thread. No thread is parked while a tool task runs, and +concurrency is bounded by the executor's own limits — where it belongs. + +Bounding agent concurrency is also a **prerequisite for TLS to scale**: every agent holds an open bidi +stream for its whole life, now each with TLS state and a handshake, so a wide `map` step is what breaks +the broker first, not the crypto. + +Non-goals: sharding the broker across processes; a relay transport (deferred for driver reachability, +not for load); parallel tool calls *within* one agent, which is a correlation question; and reconnect +or resume of a broken stream. + +## 8. Known limitations + +- **A network fault is not recoverable.** The agent holds one stream for its whole life, so an + interruption fails the task — possibly after paying for model calls — and `errorStrategy 'retry'` + re-runs the entire loop. Proper reconnect needs idempotent tool replay; the call-ID dedup set is + groundwork, not a solution. Documented rather than half-built. +- **Completion is stdout-decoded.** A `complete` frame does not by itself mark the task complete today; + the acknowledged-terminal-state design (§3) is not implemented. +- **A relay transport is deferred**, for drivers that are not reachable from workers at all. +- **`agent.rpc.remoteHost` must still be set** for a driver on a different network from its tasks, a + Kubernetes driver outside the cluster its pods run in, a cloud-batch driver in a different VPC from + the compute environment, and a multi-homed submit node whose default route is not the compute fabric. +- **Cacheable local external agents** do not fully represent the packaged runtime and launch command in + their task identity, so a runtime upgrade can in principle replay an older result. The image tag + tracking the plugin version narrows this, but does not close it. +- **The image must carry an `agent-rpc` build that understands the current transport flags.** The + driver always passes `--fingerprint`, or `--insecure` when TLS is off; an older proxy fails with + `flag provided but not defined`. Turning TLS off is not a workaround. diff --git a/adr/specs/agent-runners.md b/adr/specs/agent-runners.md new file mode 100644 index 0000000000..ef1e162556 --- /dev/null +++ b/adr/specs/agent-runners.md @@ -0,0 +1,305 @@ +# Pluggable agent runners — the harness SPI and its implementations + +- Status: implemented (experimental, unreleased) +- Scope: why the LLM harness is an extension point, the `AgentRunner` SPI, runner selection, the two + shipped runners, and how the runner-native/brokered tool split falls out +- Companion documents: [agent design](agent-design.md) (the primitive itself), + [agent RPC](agent-rpc.md) (the transport an out-of-process runner uses) + +## 1. Why the harness is an extension point + +`agent.runner` selects the LLM harness the way `process.executor` selects the executor. A pipeline +that hard-codes an HTTP call to one provider's endpoint has made that choice permanent; a pipeline +that declares an agent has not. + +That matters beyond taste. The model endpoint being swappable is what lets the same pipeline target a +hosted API or a locally-hosted model without touching pipeline code — the local-first property the +agentic-genomics agenda asks for, delivered by the same mechanism that makes an executor swappable. + +Two runners ship: + +| Runner | Plugin | Loop runs | Container | +|---|---|---|---| +| `langchain4j` | `nf-agent` | driver JVM, on the dedicated `agent` executor | none | +| `pi` | `nf-agent-pi` | agent task container, on any executor | required | + +## 2. The SPI + +```groovy +interface AgentRunner { + default String getName() // stable: 'pi', 'langchain4j' + default AgentLaunchSpec getLaunchSpec() // null => in-JVM runner + default AgentRpcRegistration register(AgentRunnerRequest, boolean remote) + String run(AgentRunnerRequest request) // in-JVM call path +} +``` + +Everything crossing this boundary is portable: `ToolDescriptor` and `SkillDescriptor` maps, an +`AgentRunnerRequest`, and a `String`/JSON result. Core never imports an LLM client; a runner never +touches `ProcessDef`, `Channel` or `Path`. `ToolDispatcher` in particular **never crosses** — the +host-side broker is the only component allowed to invoke it. + +`getName()` is a defaulted method so existing SAM-shaped test runners stay source-compatible. +`getLaunchSpec()` returning `null` is the discriminator between the two execution shapes: a null spec +means the in-JVM path, a non-null one means the agent lowers to a `SCRIPTLET` submitted through the +configured executor. + +### 2.1 Selection + +`AgentRunnerProvider.get(name)` resolves an exact name. The no-argument form remains for tests and +single-runner compatibility but **fails on ambiguity instead of silently taking PF4J priority** — +priority is an unsafe user-facing selection mechanism once more than one runner is installed. + +`AgentDef` resolves the runner once while building the task and folds the selected name into the +canonical task source, so **switching runners invalidates `-resume` entries**. Equal prompts sent +through different harnesses are not reproducibly equivalent, so runner identity belongs in the cache +key. + +### 2.2 Where the request is assembled + +Endpoint, credential and API provider are resolved **once, in core** (see +[agent design §7.1](agent-design.md#71-endpoint-and-credentials)) and handed to whichever runner is +selected. Credential resolution deliberately does **not** depend on runner selection: switching +`agent.runner` silently changing which environment variable is read would break the "resolved once in +core" invariant. Only when core resolves nothing may a runner resolve for itself — which `pi` can and +`langchain4j` cannot. + +## 3. `langchain4j` (`nf-agent`) + +The in-JVM runner, driven by langchain4j's **`AiServices`** API rather than a hand-rolled `ChatModel` +tool loop. `AiServices` owns the turn loop, tool advertisement, tool-result appending and the +structured-output response schema, so the plugin shrinks to model construction plus the adapters that +map portable descriptors onto langchain4j types: + +- `ChatModelFactory` — `provider/model` → a langchain4j `ChatModel`, applying strict JSON schema when + an output schema is present; +- `ModuleToolAdapter` — `ToolDescriptor` → `ToolSpecification`; +- `JsonSchemaMapper` — a portable schema `Map` → `JsonSchema`; +- `SkillAdapter` — `SkillDescriptor` → langchain4j `Skills`, providing `activate_skill` and + `read_skill_resource` as an additive `ToolProvider`, with the skill catalog appended to the system + message (the `skill_name` parameter is free text, not an enum — without the catalog the model cannot + name a skill and activation never fires). + +The runner supports the **OpenAI wire protocol only**. `openai/` names the protocol, so with +`agent.baseUrl` it reaches any endpoint that speaks it — vLLM, Ollama, llama.cpp, a gateway. Azure +OpenAI is *not* reachable this way: its URL shape and `api-version` handling need a client the plugin +does not ship. + +**The runner must throw on failure, never return an error object.** Retries, `errorStrategy` and the +whole `resumeOrDie` path key on a `Throwable` reaching `task.error`; an error object would be bound as +a successful result. Provider-error retries should be capped low, since the OpenAI SDK already retries +internally and a 429 storm under fan-out compounds. + +`shell:bash` is **rejected at agent-build time** on this runner, naming `pi`. The loop runs in the +driver JVM, so a bash tool would execute model-authored commands on the driver host with no container +boundary — and on a Kubernetes or Batch deployment the driver pod holds none of the pipeline's tooling +anyway. This is the one leaf where the cross-runner portability promise does not hold, and it is +deliberate. + +## 4. `pi` (`nf-agent-pi`) + +### 4.1 Why a child process + +| Option | Verdict | +|---|---| +| Embed the JS runtime in the Nextflow JVM | Rejected — Pi targets Node APIs; Graal/JS embedding adds a second compatibility surface and weak dependency isolation | +| **Per-invocation child process with a bidirectional protocol** | **Selected** — natural Node runtime, live reverse tool calls, explicit cancellation and isolation | +| An ordinary file-in/file-out Nextflow process | Insufficient — a file-oriented task cannot synchronously call the live `ToolDispatcher`, and multi-turn state is lost | +| Turn-at-a-time canonical processes | Deferred — requires serializing and restoring full model/session state and scheduling a task per turn; high latency, provider-specific | +| Long-lived remote runner service plus callback gateway | Deferred — authentication, routing, tenancy isolation, persistence and failure recovery well beyond a runner SPI | + +The selected protocol works identically for a local container and a remote one, and leaves a clean +path to a remote transport implementing the same messages. + +### 4.2 The harness + +`harness/runner.mjs` drives `@earendil-works/pi-coding-agent` through its public SDK: + +- an in-memory session per invocation; +- a system-prompt override carrying `instruction`, optional `goal` and the skill catalog; +- **no built-in Pi coding tools** except the ones the agent's `tools` directive explicitly selected; +- one custom tool per brokered `ToolDescriptor`, which writes a `tool_call` frame and awaits its + correlated `tool_result`; +- skill tools compatible with `activate_skill` / `read_skill_resource`; +- a terminating `final_answer` tool for structured output, carrying the Nextflow output schema. + +**Structured output uses a terminating tool rather than a second structuring pass.** It avoids a lossy +re-read of the conversation and works uniformly with and without other tools. One corrective turn is +allowed when a model returns prose instead of calling it. + +### 4.3 Packaging: the image *is* the distribution unit + +The plugin originally vendored the whole runtime — 173 MB of `node_modules` (18,569 files) plus a +13 MB Go binary built for whichever architecture ran the build, unpacked at runtime with a +`deleteOnExit` hook per file. **The plugin artifact was ~186 MB**, against single-digit MB for a +typical Nextflow plugin, and releasing it required Go and npm toolchains. + +Decision: **`nf-agent-pi` ships no runtime.** The proxy and harness live in a container image and the +jar is Groovy only. A container is therefore required for the `pi` runner on **every** executor, +including `local`. + +That fixes all four defects at once — no vendored tree, no architecture matrix, no toolchain in the +release build, no extraction. Cross-compiling a `GOOS`/`GOARCH` matrix was rejected because it fixes +only the architecture problem and makes the size problem worse. Porting the Go proxy to Node is +attractive on its own terms and would delete a whole build stage, but it leaves the 173 MB, so it does +not make the plugin releasable; it remains worth doing later to reach one runtime. + +**Containerization belongs to the executor layer, and that was achieved by deleting code.** The old +`createCanonicalBody` hand-rolled the decision between a driver-local and an in-container path. With +no host-local runtime there is one path, so the predicate, `shouldUseContainer`, `isOffloaded` and +`AgentLaunchSpec`'s local command pair all disappeared, and `BashWrapperBuilder` wraps the agent task +exactly as it wraps any other. Two bugs died with the predicate: `isOffloaded` treated *any* non-local +executor as containerized (wrong for a grid executor with no engine), and nothing consulted +`Executor.isContainerNative()`, so Kubernetes worked only incidentally. + +What replaces the decision is **validation**. `AgentLaunchConditions.requireCanonicalLaunch` resolves +the `Executor` **instance** — not its name, because `isContainerNative()` can depend on the session +(the local executor is container-native under Fusion) and `containerConfigEngine()` decides which +engine block must be enabled — and rejects, before the run starts, a configuration that would not +containerize the task. The failure is a message naming `agent.container` and the engine, not a +`No such file` from inside the container. + +| Agent executor | Required | +|---|---| +| `local`, or a grid executor | an enabled engine | +| container-native (`k8s`, `awsbatch`, …) | nothing further | + +The image sets `ENTRYPOINT []` deliberately: canonical Nextflow/Fusion wrappers must retain entrypoint +control, so the generated agent script `exec`s the proxy explicitly. + +### 4.4 The image is a release artifact, and the plugin declares it + +If the image is the distribution unit, then leaving it outside the release makes the jar and its +runtime two deliverables that can drift — and they did: a proxy change landed after `VERSION` named a +tag that had already been published by hand, so the published `0.4.1` did not describe the tree. +Four decisions close that. + +**The plugin declares the coordinate it needs.** A build-time generator reads the registry and image +name out of `build-image.sh` and the tag out of `plugins/nf-agent-pi/VERSION`, writing +`META-INF/nf-agent-pi-image.properties` into the jar; `PiAgentRunner` reads it back through a new +`AgentRunner.getDefaultContainer()` (defaulting to `null`, so `langchain4j` and every closure-coerced +test runner are untouched). What the jar asks for is by construction what the release publishes. A jar +without the resource yields `null` rather than throwing — degrading to core's existing +"must declare a container" error instead of an exception out of a static initializer inside PF4J +extension loading. + +**`agent.container` becomes optional for `pi`.** `AgentDef` fills it from +`selectedRunner.getDefaultContainer()` when, and only when, the key is absent — after the +`executor`/`maxForks` defaults and before anything reads `config.get('container')`, so the +containerization guard, the per-task re-check and the container fingerprint all see one kind of value +and know nothing about the default. The test is `containsKey`, **not** truthiness: +`agent.container = false` keeps meaning "no container" and keeps failing. + +> **Cache-key consequence.** The resolved container is not in `canonicalAgentSource` or +> `toolsFingerprint`; it reaches the hash generically, through `TaskHasher` adding +> `task.getContainerFingerprint()` for a containerized task. Existing agents are unaffected, because a +> canonical agent with no image is rejected *today* — every configuration that currently runs sets +> `agent.container` explicitly, so the new branch never fires for them. But bumping the plugin +> `VERSION` changes the default coordinate and therefore invalidates the cache of every agent relying +> on it. That is correct — a different runtime is a different task — and it is why the coordinate must +> never be a floating tag. Do **not** also add the container to the agent-specific fingerprints: it +> would be redundant and would move existing hashes. + +**Publishing is idempotent, and verifies before it skips.** The tag is a runtime pin, hence immutable: +`push` publishes only when the tag is absent and exits 0 when it is present, mirroring the contract +`releasePluginToRegistryIfNotExists` already gives the plugin jars, so a partially failed release is +re-runnable. The multi-arch assertion runs on **both** paths — an earlier version checked it only +after a push, so a tag that existed but was single-arch was reported as "nothing to do" and the +release completed, git tag included, against an image that fails to pull on the other architecture. An +unreadable manifest ("absent", "unreachable" and "unauthenticated" are indistinguishable) falls +through to build-and-push, which is the authority. + +**The release publishes it first.** `release.sh` runs the image build as step 1, ahead of everything +else, because it is the only step that reaches a third-party registry and because nothing before it is +undoable — the S3 upload, the plugin-registry entries and the git tag all are. Placed first, a failure +leaves nothing published. + +**A drift guard makes "context changed ⇒ `VERSION` bumped" checkable.** It derives the guarded paths +from the `!` allowlist in `.dockerignore` rather than repeating them, since that file is already the +single statement of what enters the image — so a test-only proxy change does not fire, and a +hand-written `plugins/nf-agent-pi/**` would. Paths are prefixed with the plugin directory, because +`.dockerignore` is relative to the build context while a git pathspec is relative to the repo root; +unprefixed, the guard is a silent no-op. Not knowing the answer is always a warning, never a failure: +no git, no `VERSION`, and shallow clones all skip — the shallow case explicitly, because a graft +boundary is parentless, so a path-limited log names the *boundary* commit and the diff from there is a +false failure on a correct tree. + +The registry is `public.cr.seqera.io/nextflow`, expressed as the single named constant everything +else derives from — the same registry and namespace the release already publishes `nextflow/nextflow` +to. That is deliberate: the release job's existing `public.cr.seqera.io` login authorizes the runner +image push too, so the image needs no credential of its own. + +One operational note: the image build needs QEMU. The node stage is not `--platform=$BUILDPLATFORM`, +so `apt-get` and `npm ci` run on the target platform and the arm64 leg needs binfmt on an amd64 +runner. + +Delivering the image through Wave, building it on demand from the Dockerfile so each platform gets its +own architecture, remains the intended follow-up. The open question there is how Wave gets +build-context access to `agent-rpc/`, `package.json` and `package-lock.json`. + +## 5. The tool split, per runner + +**A tool reference is a contract, not an implementation.** Each runner satisfies it with whichever of +its own tools matches, and the wire name is the coordination point. + +| Reference | Wire name | `langchain4j` | `pi` | +|---|---|---|---| +| `nf:module_run:X` | `X` | descriptor → `AiServices` tools; dispatch into `ModuleToolBridge` | brokered to the driver over RPC | +| `fs:read` / `write` / `ls` | `read` / `write` / `ls` | Groovy implementation in the driver JVM, behind `SandboxGuard` | SDK builtin, rooted at the session `cwd` | +| `fs:edit` / `grep` / `find` | `edit` / `grep` / `find` | Groovy implementation | SDK builtin | +| `shell:bash` | `bash` | **rejected at build time** | SDK builtin, inside the container | + +The `pi` harness has **no local-vs-RPC branch**: every descriptor it receives is brokered, +unconditionally. Runner-native tools never become descriptors, so they cannot arrive that way — they +travel on their own request field, which **both** runners must consume (the `pi` harness passes the +names into the SDK allowlist; `langchain4j` rebuilds descriptors from them). + +> **Known regression from that split.** The harness counts a tool turn inside each tool it *defines*, +> and the old mirrored filesystem tool was one of those. An SDK builtin is executed by the SDK and +> never passes through the counter, so on `pi` the whole `fs:` family and `shell:bash` are **not +> bounded by `maxIterations`**; the budget now covers only brokered module tools, skills and +> `final_answer`. The fix is to drive the counter off the session event stream +> (`tool_execution_start`), which counts every tool the model calls regardless of who executes it. + +## 6. Credential delivery to a container + +The resolved credential is delivered **in band** on the encrypted RPC start frame and installed as an +in-memory runtime key for that process only. It never enters the task environment, the task script or +the runner's credential store, and it takes precedence over the runner's own sources. Installing it +under the **model's** provider (the prefix the runner will dial), not under the driver-side resolved +`apiProvider`, is deliberate. + +Sending is **gated on TLS**. With `agent.rpc.tls = false` the frame is cleartext, so a resolved +credential is withheld with a warning rather than shipped — `tls = false` stays a working escape hatch +and the out-of-band channels still reach the task. + +This is what let the examples drop `agent.containerOptions = '-e OPENAI_API_KEY'` and their whole +`env { … }` blocks. Those channels remain documented for `tls = false` and for a provider whose +variable core does not resolve; the agent-scoped `containerOptions` form is preferable to `env`, since +a key in `env` also reaches every tool process. + +## 7. Parity contract + +Both runners are tested against the same behaviour contract: scalar and structured output; multiple +typed inputs and outputs; goal composition; module tools and recoverable dispatch errors; fatal tool +task failure; the filesystem sandbox; local skills and skill resources; tools plus structured output; +trace events and the resolved model; max-iteration failure; timeout and cancellation; parallel agent +execution without child-task starvation; and resume for tool-free/skills-only agents together with +resume opt-out for tool agents. + +## 8. Residual risks + +- **SDK API churn.** Pin exact versions and cover the harness with protocol tests. +- **Node/container startup overhead.** Measure before considering a pooled runner service — per- + invocation isolation and correctness come first. +- **Provider differences in tool-schema support.** Fail capability checks before the first model call + where the runner exposes enough metadata; otherwise surface a typed runner failure. +- **Secrets in child diagnostics.** Stderr is captured with a bounded size and redacted before + inclusion in errors. +- **Cancellation while a module tool is running.** Session cancellation stays authoritative; the child + is destroyed and the failure path clears interrupt state. +- **Which runner is the supported default** is still open. `pi` is the de-facto primary and every + example selects it, while `langchain4j` has no launch spec and cannot offload at all. If + `langchain4j` is the intended default *local* experience (in-JVM, no container), requiring a + container engine locally for `pi` is uncontroversial; if `pi` is the default everywhere, that + requirement deserves explicit sign-off. diff --git a/build.gradle b/build.gradle index 8b049437c5..3defb69a15 100644 --- a/build.gradle +++ b/build.gradle @@ -271,6 +271,127 @@ task releaseInfo { file('modules/nextflow/src/main/resources/META-INF/plugins-info.txt').text = meta.toSorted().join('\n') }} +/* + * Guard the nf-agent-pi runner image against silent version drift. + * + * That image IS the distribution unit of the `pi` agent runner, and its tag is the plugin + * VERSION -- a tag `build-image.sh push` treats as immutable, publishing only when it does not + * exist yet. So build-context content that lands WITHOUT a version bump can never reach a + * registry under the tag that names it: the release ships a jar hard-pinned to a tag whose + * content this tree no longer describes, permanently. This task fails the release instead. + * + * It hangs off validatePluginVersions so it runs on every path that already validates plugin + * versions -- `upload`, `deploy` and `release` -- and therefore always before the git tag. It is + * deliberately NOT attached to :plugins:nf-agent-pi:releaseImageIfNotExists: drift is exactly the + * case where the tag already exists and that push is skipped, so this check has to be independent + * of it. + * + * What it cannot see: it compares git trees, not the registry, so a tag pushed by hand outside + * the release chain is invisible to it. That is covered by inspecting the reference + * `plugins/nf-agent-pi/build-image.sh ref` prints, before merging -- doing it here would drag + * docker into `upload` and `deploy`. + */ +task validateAgentImageVersion { + // a question about git history has no file inputs that could make it up-to-date + outputs.upToDateWhen { false } + + doLast { + final pluginDir = 'plugins/nf-agent-pi' + final ignoreFile = file("$pluginDir/.dockerignore") + // Self-check, so the guard cannot silently degrade into a no-op that reports success on + // every release -- which would be worse than no guard, since the release plan records + // this task passing as proof that it works. + final disconnected = { String why -> new GradleException( + "the nf-agent-pi drift guard derived no usable paths from $pluginDir/.dockerignore " + + "- a rename or an allowlist edit has disconnected it: $why" as String) } + + // The `!` allowlist in .dockerignore is already the single statement of what enters the + // image, so derive the guarded paths from it instead of repeating them here. Deriving is + // what keeps the guard honest: agent-rpc/main_test.go is deliberately not admitted to the + // build context, and a hand-written `agent-rpc/**` would fire on a test-only change. + if( !ignoreFile.exists() ) + throw disconnected("$ignoreFile does not exist") + // Prefix every entry: .dockerignore paths are relative to the BUILD CONTEXT while a git + // pathspec is relative to the repo root. Unprefixed they match nothing at all. + final derived = ignoreFile.readLines() + .collect { it.trim() } + .findAll { it.startsWith('!') } + .collect { "$pluginDir/${it.substring(1).trim()}" as String } + if( !derived ) + throw disconnected('it admits no `!` allowlist entry') + // the Dockerfile and the allowlist itself are build inputs too, and neither is derivable + // from the allowlist + final guarded = derived + ["$pluginDir/Dockerfile" as String, "$pluginDir/.dockerignore" as String] + final absent = guarded.findAll { !file(it).exists() } + if( absent ) + throw disconnected("these paths do not exist: ${absent.join(', ')}") + + final git = { List args -> + try { + final proc = new ProcessBuilder(['git'] + args) + .directory(rootProject.rootDir) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + final out = proc.inputStream.getText('UTF-8') + return [ ok: proc.waitFor()==0, out: out.trim() ] + } + catch( IOException e ) { + return [ ok: false, out: '' ] // git is not on PATH + } + } + + // Not knowing the answer is a warning, never a failure. No git, not a git repo, and a + // shallow clone all land here. The release job checks out with fetch-depth: 0, so in the + // environment that matters the data is there. + // + // A shallow clone is excluded explicitly, and NOT because `git log` comes back empty: + // measured at depths 2, 3 and 5, it does not. The graft boundary is recorded as having no + // parents, so a path-limited log reports the BOUNDARY commit as the one that last set + // VERSION even when the real bump is older than the window - and the diff from there is + // whatever the window happens to contain, i.e. a false failure on a correct tree. + final versionFile = file("$pluginDir/VERSION") + if( !versionFile.exists() ) { + println "! nf-agent-pi drift guard skipped: $pluginDir/VERSION does not exist" + return + } + final shallow = git(['rev-parse', '--is-shallow-repository']) + if( !shallow.ok ) { + println "! nf-agent-pi drift guard skipped: git is not on PATH, or ${rootProject.rootDir} is not a git repository" + return + } + if( shallow.out == 'true' ) { + println "! nf-agent-pi drift guard skipped: this is a shallow clone, so the commit that last set $pluginDir/VERSION cannot be located - fetch the full history to run it" + return + } + final base = git(['log', '-1', '--format=%H', '--', "$pluginDir/VERSION" as String]) + if( !base.ok || !base.out ) { + println "! nf-agent-pi drift guard skipped: no commit in this history sets $pluginDir/VERSION" + return + } + + // The window is the last VERSION bump, not the last release tag. The invariant is "the + // image tag this tree names is not already published with different content", which + // release tags do not track -- plugins/nf-agent-pi/VERSION does not even exist at the + // current one, so a tag-anchored guard would be vacuous for a whole release cycle. This + // task reads no tag at all, which is also why an unreleased branch cannot upset it. + final version = versionFile.text.trim() + final diff = git(['diff', '--name-only', "${base.out}..HEAD" as String, '--'] + guarded) + if( !diff.ok ) { + println "! nf-agent-pi drift guard skipped: cannot diff ${base.out.take(9)}..HEAD" + return + } + if( diff.out ) + throw new GradleException( + "the nf-agent-pi image build context changed since $pluginDir/VERSION was last " + + "bumped (${base.out.take(9)}, version $version) but the version did not - bump it " + + "and add a changelog.txt entry so the release publishes a new image tag; " + + "$version is already published and immutable.\nChanged:\n " + + diff.out.readLines().join('\n ') as String) + + println "✅ nf-agent-pi image validation passed: build context unchanged since $version (${base.out.take(9)})" + } +} + /* * Validate that plugins-info.txt matches plugin VERSION files and that build-info.properties * contains the correct build number and commit ID when running in GitHub Actions. @@ -279,11 +400,12 @@ task releaseInfo { * 1. All plugin versions in plugins-info.txt match their corresponding VERSION files * 2. The build number in build-info.properties matches GITHUB_RUN_NUMBER (in CI) * 3. The commit ID in build-info.properties matches GITHUB_SHA (in CI) + * 4. The nf-agent-pi runner image has not drifted from its VERSION (validateAgentImageVersion) * * This validation prevents releases with stale or mismatched metadata. */ task validatePluginVersions { - dependsOn buildInfo + dependsOn buildInfo, validateAgentImageVersion inputs.file('modules/nextflow/src/main/resources/META-INF/plugins-info.txt') inputs.file('modules/nextflow/src/main/resources/META-INF/build-info.properties') inputs.files(fileTree('plugins') { include '*/VERSION' }) @@ -308,6 +430,48 @@ task validatePluginVersions { throw new GradleException("Plugin version mismatch:\n${diffs.join('\n')}\nRun 'make assemble' to fix.") } + // A plugin's `nextflowVersion` is a MINIMUM core requirement, written into the jar manifest + // as `Plugin-Requires` and enforced at runtime by BasePlugin.start(). One that names a core + // NEWER than this tree's own VERSION is therefore unloadable from this checkout -- every run + // of that plugin aborts at plugin start with "Failed requirement" -- and, worse, a release + // cut at the current VERSION would ship a plugin the core it ships with refuses to load. + // + // That is easy to write by accident: the requirement has to name the release the plugin + // ships IN, which is the NEXT root VERSION, so a plugin authored ahead of a version bump is + // correct-looking and broken until the bump lands. Checking it here means the bump cannot be + // forgotten silently -- this task runs on `upload`, `deploy` and `release`, i.e. always + // before the git tag. + final coreVersion = rootProject.file('VERSION').text.trim() + // ordered as one number rather than a list: Groovy will not compare two ArrayLists with `>`. + // The multipliers leave room for a 2-digit month and a 4-digit patch, which is well beyond + // anything the calendar scheme in CLAUDE.md produces. + final calver = { String v -> + final m = (v =~ /^(\d+)\.(\d+)\.(\d+)/) + return m ? (m[0][1] as int) * 1000000 + (m[0][2] as int) * 10000 + (m[0][3] as int) : null + } + final core = calver(coreVersion) + if( core == null ) + throw new GradleException("Cannot parse the root VERSION `${coreVersion}` as a calendar version") + final ahead = [] + expected.each { p -> + final gradleFile = new File(p.projectDir, 'build.gradle') + // anchored on the assignment the plugin DSL uses; a plugin that omits it inherits the + // default and is not this check's business + final line = gradleFile.readLines().find { it =~ /^\s*nextflowVersion\s*=/ } + if( !line ) + return + final declared = (line =~ /['"]([^'"]+)['"]/).with { it ? it[0][1] : null } + final v = declared ? calver(declared) : null + if( v == null ) + throw new GradleException("Cannot parse `nextflowVersion` for ${p.name}: ${line.trim()}") + if( v > core ) + ahead << " ${p.name} requires >=${declared}, but the root VERSION is ${coreVersion}" + } + if( ahead ) + throw new GradleException( + "Plugin requires a Nextflow version newer than this tree:\n${ahead.join('\n')}\n" + + "Bump the root VERSION to the release these plugins ship in, or lower their `nextflowVersion`.") + // Validate build-info.properties - require GitHub Actions environment variables if (!System.getenv('GITHUB_RUN_NUMBER')) { throw new GradleException("GITHUB_RUN_NUMBER environment variable is required") diff --git a/docs/agent.mdx b/docs/agent.mdx new file mode 100644 index 0000000000..7c5abc4480 --- /dev/null +++ b/docs/agent.mdx @@ -0,0 +1,495 @@ +--- +title: Agents +description: Define and run AI agents in Nextflow pipelines. +--- + +# Agents + +:::warning +Agents are a preview feature. Their syntax and behavior may change in future releases. +::: + +An *agent* is a process-shaped primitive that wraps an agent run. It declares typed inputs and outputs, renders a prompt, calls a language model -- optionally letting the model call Nextflow modules as tools -- and emits the result on a channel. Each invocation runs as an ordinary Nextflow task, so work directories, retries, parallelism, resume, and lineage all apply. + +Agents require a runner plugin: **`langchain4j`** (`nf-agent`), which calls the model from the driver JVM, or **`pi`** (`nf-agent-pi`), which runs the agent in a container. + +## Quick start + +```groovy +// nextflow.config +agent.runner = 'langchain4j' +``` + +```bash +export OPENAI_API_KEY="sk-..." +``` + +```nextflow +// main.nf +agent qa { + model 'openai/gpt-5-mini' + instruction 'You are a concise scientific assistant.' + + input: + question: String + + output: + answer: String + + prompt: + """ + Answer briefly: ${question} + """ +} + +workflow { + qa('What is FASTQ format?').view() +} +``` + +The runner's plugin is automatically loaded **only when the configuration declares an `agent` scope**; otherwise the run fails with `No agent runner available`. Declare the plugin explicitly to pin a version: + +```groovy +plugins { + id 'nf-agent-pi@0.5.0' +} +``` + +## Directives + +##### `goal` + +High-level objective, appended to the system message as a `Goal:` section. Advisory; `maxIterations` remains the hard cap. + +##### `instruction` + +System prompt describing the agent's role. + +##### `label` + +Mnemonic identifier for `agent { withLabel: ... }` selectors. Repeatable. + +##### `maxIterations` + +Cap on the tool-calling loop (default: `20`). + +##### `model` + +Model as `provider/model`, e.g. `openai/gpt-5-mini`. The prefix selects the chat-model backend; for `openai/` it is the OpenAI wire protocol. Required at run time, but may come from `agent.model`. + +##### `skills` + +Skills (`SKILL.md` folders) the agent may use. See [Skills](#skills). + +##### `tools` + +Namespaced tool references, `family[:group]:name` -- `'nf:module_run'`, `'fs:*'`, `'shell:bash'`. See [Tools](#tools). + +## Inputs, outputs, and prompt + +Agent inputs and outputs use the same syntax as a typed process, with one exception: destructured records and tuples are not supported (see [Limitations](#limitations)). + +Each input is also serialized as JSON and appended to the model message, so the agent sees it even if it isn't explicitly referenced in the prompt. + +The `prompt:` section uses the same syntax as the process `script:` section. The last statement is the prompt: + +```nextflow +prompt: +def findings = report.collect { v -> "- ${v.summary}" }.join('\n') +""" +Summarize these findings: +${findings} +""" +``` + +### Path inputs + +Path inputs are staged into the agent's work directory, just like a process: + +```nextflow +agent inspector { + input: + contigs: Path + + output: + answer: String + + // the model receives "contigs.fa", a name it can open in its working directory + prompt: "Inspect ${contigs} and report the longest sequence." +} +``` + +### Path outputs + +Use the `file(...)`/`files(...)` output functions to collect output files written by the agent, just like a process: + +```nextflow +agent reporter { + input: + findings: String + + output: + report: Path = file('report.md') + + prompt: + "Summarize ${findings} and write the result to report.md" +} +``` + +The agent must be explicitly prompted to write this file. If the agent doesn't write a required output file, a missing-output error is reported. + +### Structured output + +Use a record type to declare a structured output. The record type is provided to the agent as a JSON schema, and the agent's response is validated against the schema. + +```nextflow +record Answer { + answer: String + confidence: Float +} + +agent qa { + model 'openai/gpt-5-mini' + + input: + question: String + + output: + a: Answer + + prompt: + "Answer briefly: ${question}" +} +``` + +Supported field types: `Boolean`, `Float`, `Integer`, `List`, `String`, and nested records. `Path` is not currently supported. + +## Tools + +Tools are declared as namespaced references, `family[:group]:name`. An agent only receives the tools it declares. A reference selecting nothing (unknown family, a process not in scope, a glob matching no tool) is an error. + +- **`nf:`**: Nextflow tools. `nf:module_run` exposes each in-scope module or process as its **own** tool, named after the module, discovered from `include` statements and locally-defined processes; `nf:module_run:SAMTOOLS_SORT` selects one of them, `nf:module_run:SAMTOOLS_*` those whose name matches. Each tool's `parameters` schema is that module's flattened input schema, so the model cannot omit or rename a field. + +- **`fs:`**: filesystem tools: `read`, `write`, `edit`, `ls`, `grep`, `find`. Use `fs:*` to select all six. Can only access files in the agent runner's sandbox (see below). + +- **`shell:`**: `shell:bash`, a shell inside the runner container. **`pi` only**: the `langchain4j` loop runs in the driver JVM, so a shell there would execute model-authored commands on the driver host with no container boundary; declaring it on `langchain4j` is rejected before the run starts. It is its own family precisely so that `fs:*` never selects it. + +Reference syntax: + +- A non-leaf reference means its whole subtree, so `nf:module_run` is exactly `nf:module_run:*`. + +- `*` may appear only in the last segment and must be anchored to a family. Bare `*` is rejected; `nf:*` and `fs:*` are not, a family's membership being fixed by the Nextflow release rather than by remote configuration. + +- Entries union in any order, and overlapping references are idempotent: `fs:*, fs:read` selects `read` once. + +- Matching is case-sensitive, so `nf:module_run:samtools_*` does not match `SAMTOOLS_SORT`. + +The colon form is declaration-side only. The model sees bare names: `nf:module_run:SAMTOOLS_SORT` as `SAMTOOLS_SORT`, `fs:read` as `read`. + +For example: + +```nextflow +process uppercase { + input: + text: String + + output: + result: String + + exec: + result = text.toUpperCase() +} + +agent shouty { + model 'openai/gpt-5-mini' + instruction 'To uppercase text call the `uppercase` tool, then reply with only the result.' + tools 'nf:module_run' + + input: + request: String + + output: + answer: String + + prompt: + "${request}" +} +``` + +Nextflow maps the tool call's JSON arguments to module inputs and runs the module directly -- normal executor, container and cache machinery, its own work directory -- then serializes the outputs as the tool call result. + +The tool schema for a module is derived from the module spec when available, or the declared inputs and outputs if they are typed. Legacy processes with no module spec cannot be called as tools. + +The `fs:` tools are limited to the agent runner's sandbox: + +- On `langchain4j` the tools run in the driver JVM, restricted to the agent's work directory, its staged `Path` inputs and the module-output paths returned by module tools; only the work directory is writable. + +- On `pi` the runner's own file tools are rooted at the work directory with the container as the outer bound. `shell:bash` has no boundary inside the container at all. + +## Skills + +Skills are folders containing `SKILL.md` files that disclose instructions to the agent on demand. + +For example: + +```nextflow +agent reporter { + model 'openai/gpt-5-mini' + skills 'sequence-report' + + input: + request: String + + output: + answer: String + + prompt: + "${request}" +} +``` + +- **Local**: a bare name resolves to `skills//` alongside the declaring file. +- **Remote**: `github.com//[@rev]` (supports both `https://` and `git@` forms) is cloned and cached into `skills/.remote/[@]`. + +The model sees each skill's name and description up front, reads the body through `activate_skill`, and bundled files through `read_skill_resource`. Skills do not execute code. + +:::warning +A remote skill's `SKILL.md` becomes model instructions. Pin a commit hash rather than a branch. +::: + +## Agent modules + +An agent can be included like a process or workflow: + +```nextflow +include { reporter } from './agents/reporter' // directory -> main.nf +include { reporter as qc } from './agents/reporter/main.nf' +``` + +Local paths are resolved relative to the *including* script. Remote agent modules are not currently supported. + +The module directory may carry its own skills and tools: + +``` +agents/reporter/ +├── main.nf +├── skills/qa-report/SKILL.md +└── tools/qc_verdict.nf +``` + +An agent's declared skills and tools are included in the task hash, so that editing them invalidates the cache. + +See [`17_agent-module`](https://github.com/nextflow-io/nextflow/tree/master/examples/agents/17_agent-module) for a complete example. + +## Configuration + +The `agent` scope supports both **agent options** (below) and **task directives** (process directives applied to the agent task). + +```groovy +agent { + // agent options + runner = 'pi' + model = 'openai/gpt-5-mini' + apiKey = secrets.LLM_KEY + + // task directives + executor = 'k8s' + container = '' + cpus = 1 + memory = '1 GB' + + rpc.remoteHost = 'nextflow-driver.default.svc' +} +``` + +Notes: + +- Agents do not inherit any configuration from the `process` scope. +- Directives declared in the agent definition take precedence over config options. +- By default, agents are executed locally (`local` executor). + +### Agent options + +##### `apiKey` + +Provider credential, on either runner. See [Model provider](#model-provider). + +##### `apiProvider` + +Namespace the environment credential and endpoint are read from: `anthropic`, `azure`, `gemini`, `google`, `mistral`, `openai`, `openrouter`. Inferred when unset. Does not select the wire protocol. Any other value aborts the run. + +##### `baseUrl` + +Endpoint serving the model, e.g. `http://localhost:8000/v1`. Defaults to the provider's endpoint. + +##### `maxIterations` + +Default tool-loop cap (default: `20`). + +##### `maxToolOutputInlineSize` + +Largest tool-output file passed to the model inline; bigger ones become path handles (default: `32 KB`). + +##### `model` + +Default model for an agent that omits the directive. + +##### `requestTimeout` + +Timeout for a single model request (default: `120 sec`). + +##### `runner` + +`pi` or `langchain4j`. When unset, Nextflow loads `nf-agent` (`langchain4j`) unless an agent plugin is declared explicitly; if more than one runner is installed, the run aborts asking you to name one. + +##### `trace` + +Log a readable trace of each agent's execution. Enabled by `-with-agent-trace`. + +##### `rpc.port` + +Broker port; `0` (default) picks an ephemeral port. + +##### `rpc.remoteHost` + +Host a containerized task uses to reach the driver. See [RPC configuration](#rpc-configuration). + +##### `rpc.capabilityTimeout` + +Queuing budget for an agent task's one-time connection capability (default: `1h`). + +##### `rpc.tls` + +Enable TLS on the broker connection (default: `true`). Disable only for debugging. + +### Selectors + +The `agent` scope can use selectors just like the `process` scope: + +```groovy +agent { + cpus = 1 + + withName: 'planner' { + cpus = 2 + ext.args = '--fast' + } + + withName: '!critic' { maxRetries = 3 } + withLabel: 'reasoning' { model = 'openai/gpt-5' } +} +``` + +:::note +The `agent.rpc.*` settings are global; they cannot be applied per-agent via config selector. +::: + +## Model provider + +Nextflow resolves the model provider in the following order: + +1. The `agent.apiProvider` config option +2. The host of `agent.baseUrl` when recognized +3. The `model` directive (prefix) + +For example, the model `openai/gpt-5` with `agent.baseUrl = 'https://openrouter.ai/api/v1'` uses OpenRouter. + +Nextflow resolves the provider endpoint and credentials in the following order: + +1. Configuration: `agent.apiKey` and `agent.baseUrl` +2. Nextflow variable: `NXF_AGENT_API_KEY` and `NXF_AGENT_BASE_URL` +3. Provider variable: `_API_KEY` and `_BASE_URL` + +Provider-specific credentials (`_API_KEY`) are applied only to agents using that provider. + +| `apiProvider` | Credential | Endpoint | Recognized host | +| --- | --- | --- | --- | +| `anthropic` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL` | `api.anthropic.com` | +| `azure` | `AZURE_OPENAI_API_KEY` | `AZURE_OPENAI_ENDPOINT` | -- | +| `gemini` | `GEMINI_API_KEY`, `GOOGLE_API_KEY` | -- | -- | +| `google` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | -- | -- | +| `mistral` | `MISTRAL_API_KEY` | -- | `api.mistral.ai` | +| `openai` | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | `api.openai.com` | +| `openrouter` | `OPENROUTER_API_KEY` | -- | `openrouter.ai` | + +## Execution model + +Every agent invocation runs as a task. Work directories, caching, retries, and lineage function the same as processes. + +Tool calls are sent back from the agent and executed by Nextflow. Module tool calls are run as tasks alongside agent runs. + +### Caching + +An agent run can be replayed from the cache on a resumed run. + +An agent's task hash includes the following: + +- runner +- model +- provider endpoint +- instruction +- goal +- max iterations +- prompt +- inputs +- output schema +- skills +- tools + +Resume *replays a stored run*: reproducible, but stale if the model changes server-side. Pin a dated snapshot (`openai/gpt-4o-2024-08-06`) rather than a floating alias for improved reproducibility; a cache-writing run warns when an alias is used. Set `cache false` to opt out of caching. + +## Containerization + +The `pi` runner requires a container for agent runs. By default, it uses an image published alongside each Nextflow release. Set `agent.container` to override it. + +The `langchain4j` runner does not support containerization. + +### RPC configuration + +Nextflow uses RPC to send provider credentials to containerized agents, and receive tool calls from them. The driver host is inferred where possible. Use `agent.rpc.remoteHost` or `NXF_AGENT_RPC_REMOTE_HOST` as needed to override it manually. + +### Provider credentials + +Provider credentials are delivered securely to agent tasks via RPC. Credentials never enter the task environment, the task script, or the runner's credential store. + +## Data lineage + +Agent runs are recorded as `AgentRun` lineage records instead of `TaskRun`. + +```console +$ nextflow lineage find type=AgentRun +lid://c47bf9183c56715c9bca1a67a4acdc68 +``` + +See [Agent runs][data-lineage-agent] for more information. + +## Limitations + +**Language** + +- The process `stage:` section is not supported for agents. +- Destructured records and tuples are not supported in agent inputs/outputs. +- The `Path` type is not supported in output records. +- The `output:` section does not support the `env()`, `eval()`, or `stdout()` output functions. + +**Tools** + +- The `shell:bash` tool is only supported by the `pi` runner. +- A module tool call can only supply declared inputs, not directives such as `ext`. +- A failing tool *task* fails the agent run. Only dispatch-level errors -- unknown tool, malformed arguments -- can be retried by the agent. + +**Agent runners** + +- The `langchain4j` runner only supports the OpenAI wire protocol. + +**Modules** + +- Direct execution for agent modules is not currently supported. +- Registry-hosted agent modules are not supported; local paths only. + +**Caching** + +- Skill resources dropped by the per-skill caps (64 files / 256 KB) are outside the resume fingerprint. +- A tool's fingerprint covers its schema and process script, so a change only to its *environment* -- a container tag resolving to different content -- does not invalidate the cache entry, exactly as it does not invalidate the tool task's own entry. + +[data-lineage-agent]: ./tutorials/data-lineage#agent-runs diff --git a/docs/config.mdx b/docs/config.mdx index e138028c31..dd3acb3e2d 100644 --- a/docs/config.mdx +++ b/docs/config.mdx @@ -282,6 +282,8 @@ This configuration: - Sets 16 CPUs for any process named `bye` (or imported as `bye`) - Sets 32 CPUs for any process named `bye` (or imported as `bye`) invoked by a workflow named `aloha` +The same selector syntax and the same priority order apply in the `agent` scope, which configures agent tasks independently from the `process` scope. See [Agents](agent.mdx#configuration). + ## Config profiles Configuration files can define one or more *profiles*. A profile is a set of configuration settings that can be selected at runtime using the `-profile` command line option. diff --git a/docs/reference/cli/lineage.mdx b/docs/reference/cli/lineage.mdx index fe80a7a6f8..0e710f26c0 100644 --- a/docs/reference/cli/lineage.mdx +++ b/docs/reference/cli/lineage.mdx @@ -53,7 +53,7 @@ List the Nextflow runs with lineage enabled and print the lineage ID (LID) of ea Render the lineage graph for a lineage record as an HTML file (default output path: `./lineage.html`). -The lineage record should be of type `FileOutput`, `TaskRun`, or `WorkflowRun`. +The lineage record should be of type `FileOutput`, `TaskRun`, `AgentRun`, or `WorkflowRun`. ##### `view ` diff --git a/docs/reference/cli/run.mdx b/docs/reference/cli/run.mdx index 70fbc7a473..a5b6abe721 100644 --- a/docs/reference/cli/run.mdx +++ b/docs/reference/cli/run.mdx @@ -163,6 +163,10 @@ Enable process execution in an Apptainer container. Enable process execution in a Charliecloud container. +##### `-with-agent-trace` + +Log a readable trace of each [agent][agent-page]'s execution — its turns, the model reasoning and the tool invocations — at `INFO` level; tool inputs and outputs are logged at `DEBUG`. Equivalent to the `agent.trace` configuration setting. + ##### `-with-cloudcache` Store cache metadata in an object storage bucket with the Cloud cache plugin. @@ -276,6 +280,7 @@ $ nextflow run main.nf -params-file pipeline_params.yml See [Pipeline parameters][cli-params] for more information about writing custom parameters files. +[agent-page]: ../../agent [cache-compare-hashes]: ../../cache-and-resume#comparing-the-hashes-of-two-runs [cli-params]: ../../cli#pipeline-parameters [tracing-page]: ../../reports diff --git a/docs/reference/env-vars.mdx b/docs/reference/env-vars.mdx index 614742225e..c2ae7c59e1 100644 --- a/docs/reference/env-vars.mdx +++ b/docs/reference/env-vars.mdx @@ -19,6 +19,22 @@ Defines the path location of the Java VM installation used to run Nextflow. ## Nextflow settings +##### `NXF_AGENT_API_KEY` + + + +The credential used to authenticate with the model provider of an [agent][agent-page]. Overridden by the `agent.apiKey` configuration setting, and takes precedence over the API provider's own variable (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and the others listed under [Other settings](#other-settings)). Unlike those it is provider-neutral: it applies to any model provider, and it is presented to whatever endpoint the agent targets — including one Nextflow cannot attribute to a provider, such as a gateway. + +##### `NXF_AGENT_BASE_URL` + + + +The base URL of the endpoint that serves an [agent][agent-page] model, e.g. `http://localhost:8000/v1`. Overridden by the `agent.baseUrl` configuration setting, and takes precedence over the API provider's own variable (`OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL`, `AZURE_OPENAI_ENDPOINT`). Unlike those it applies to any model provider. When unset, the model provider's default endpoint is used. + +:::note +This value is part of an agent's cache key, so changing it re-runs the agent instead of resuming it. `NXF_AGENT_API_KEY` is not part of the cache key. +::: + ##### `NXF_AGENT_MODE` @@ -26,6 +42,16 @@ Defines the path location of the Java VM installation used to run Nextflow. When `true`, enables agent output mode. In this mode, Nextflow replaces the interactive ANSI log with minimal, structured output optimized for AI agents and non-interactive environments. The output uses tagged lines such as `[PIPELINE]`, `[PROCESS]`, `[WARN]`, `[ERROR]`, and `[SUCCESS]`/`[FAILED]` written to standard output (default: `false`). +:::note +This setting is unrelated to the other `NXF_AGENT_*` variables. It controls how Nextflow formats its own console output for an *external* AI agent, and has no connection to the `agent` script definition. +::: + +##### `NXF_AGENT_RPC_REMOTE_HOST` + + + +Host name a containerized agent task uses to reach the driver's agent RPC broker, for deployments where that address belongs to the environment rather than to the pipeline. Equivalent to `agent.rpc.remoteHost`, which takes precedence when both are set. These are the first two rungs of a ladder that continues into inference: with neither set, Nextflow derives the address from the executor, the container engine and the driver host, and rejects the configurations no address can serve before the run starts. See [Agents](../agent.mdx). + ##### `NXF_ANSI_LOG` Enables/disables ANSI console output (default `true` when ANSI terminal is detected). @@ -359,6 +385,30 @@ The Seqera Platform workspace ID. Can also be configured using the `tower.worksp Defines a proxy server used as a fallback for any protocol (HTTP, HTTPS, FTP) that does not have a scheme-specific proxy variable set. A scheme-specific variable (e.g. `HTTPS_PROXY`) takes precedence over `ALL_PROXY`. Proxy authentication is supported by providing the credentials in the proxy URL. +##### `ANTHROPIC_API_KEY` + + + +The credential used to authenticate with the model provider of an [agent][agent-page] whose [API provider][agent-provider] is `anthropic`. Used only when neither the `agent.apiKey` configuration setting nor `NXF_AGENT_API_KEY` is set, and only when the endpoint the agent resolved belongs to Anthropic — being an Anthropic credential, it is never presented elsewhere. + +##### `ANTHROPIC_BASE_URL` + + + +The base URL of the endpoint that serves an [agent][agent-page] model whose [API provider][agent-provider] is `anthropic`. Used only when neither the `agent.baseUrl` configuration setting nor `NXF_AGENT_BASE_URL` is set. + +##### `AZURE_OPENAI_API_KEY` + + + +The credential used to authenticate with the model provider of an [agent][agent-page] whose [API provider][agent-provider] is `azure`. Used under the same conditions as `ANTHROPIC_API_KEY`. The `langchain4j` runner cannot reach Azure OpenAI — its URL shape and `api-version` handling need a client it does not ship — so this applies to a runner that can. + +##### `AZURE_OPENAI_ENDPOINT` + + + +The endpoint that serves an [agent][agent-page] model whose [API provider][agent-provider] is `azure` — Azure's spelling of `_BASE_URL`. Used only when neither the `agent.baseUrl` configuration setting nor `NXF_AGENT_BASE_URL` is set. + ##### `COLUMNS` @@ -373,6 +423,18 @@ Bash sets `COLUMNS` in interactive shells but does not export it, so it is not v Defines the FTP proxy server. Proxy authentication is supported by providing the credentials in the proxy URL, e.g. `ftp://user:password@proxy-host.com:port`. Credentials containing special characters must be URL-encoded (percent-encoded). +##### `GEMINI_API_KEY` + + + +The credential used to authenticate with the model provider of an [agent][agent-page] whose [API provider][agent-provider] is `gemini`, or `google` when `GOOGLE_API_KEY` is unset. Used under the same conditions as `ANTHROPIC_API_KEY`. + +##### `GOOGLE_API_KEY` + + + +The credential used to authenticate with the model provider of an [agent][agent-page] whose [API provider][agent-provider] is `google`, or `gemini` when `GEMINI_API_KEY` is unset. Used under the same conditions as `ANTHROPIC_API_KEY`. + ##### `HTTP_PROXY` Defines the HTTP proxy server. Proxy authentication is supported by providing the credentials in the proxy URL, e.g. `http://user:password@proxy-host.com:port`. Credentials containing special characters must be URL-encoded (percent-encoded). @@ -385,6 +447,12 @@ Defines the HTTPS proxy server. Proxy authentication is supported by providing t When connecting to HTTPS targets through an authenticating proxy, the JDK strips proxy credentials from the `CONNECT` request for the schemes listed in `jdk.http.auth.tunneling.disabledSchemes` (default `Basic`), which can cause a `407` error. Nextflow clears this property automatically when the proxy URL carries credentials. If you set the property yourself, Nextflow does not override it — clear it explicitly to allow Basic proxy authentication over HTTPS with `NXF_OPTS='-Djdk.http.auth.tunneling.disabledSchemes='`. ::: +##### `MISTRAL_API_KEY` + + + +The credential used to authenticate with the model provider of an [agent][agent-page] whose [API provider][agent-provider] is `mistral`. Used under the same conditions as `ANTHROPIC_API_KEY`. + ##### `NO_COLOR` Disables ANSI color codes in Nextflow log output. When this variable is set, Nextflow prints plain text logs following the [NO_COLOR standard](https://no-color.org/). @@ -395,10 +463,30 @@ If both `NO_COLOR` and `NXF_ANSI_LOG` are set, `NXF_ANSI_LOG` takes precedence. Defines one or more host names that should not use the proxy server. Separate multiple names using a comma character. +##### `OPENAI_API_KEY` + + + +The credential used to authenticate with the model provider of an [agent][agent-page] whose [API provider][agent-provider] is `openai`. Used only when neither the `agent.apiKey` configuration setting nor `NXF_AGENT_API_KEY` is set, and only when the endpoint the agent resolved belongs to OpenAI — being an OpenAI credential, it is never presented to another provider or to an unattributed endpoint such as a gateway. Set `agent.apiProvider = 'openai'` to declare that a gateway accepts it. + +##### `OPENAI_BASE_URL` + + + +The base URL of the OpenAI-compatible endpoint that serves an [agent][agent-page] model whose [API provider][agent-provider] is `openai`. Used only when neither the `agent.baseUrl` configuration setting nor `NXF_AGENT_BASE_URL` is set. + +##### `OPENROUTER_API_KEY` + + + +The credential used to authenticate with the model provider of an [agent][agent-page] whose [API provider][agent-provider] is `openrouter` — which includes an `openai/` model pointed at `https://openrouter.ai/…`, since the model prefix names the wire protocol and the endpoint names the credential namespace. Used under the same conditions as `ANTHROPIC_API_KEY`. + ##### `TERMINAL_WIDTH` Forces the terminal width of ANSI-formatted log output. Overrides automatic terminal width detection and uses the specified width for line wrapping when set to a positive integer. Values that are not positive integers are ignored, in which case Nextflow falls back to `COLUMNS` and then to automatic detection. +[agent-page]: ../agent +[agent-provider]: ../agent#the-api-provider [process-conda]: ./process/directives/conda [process-publishdir]: ./process/directives/publish-dir [process-spack]: ./process/directives/spack diff --git a/docs/reference/syntax.mdx b/docs/reference/syntax.mdx index f5abac769e..dd400124cb 100644 --- a/docs/reference/syntax.mdx +++ b/docs/reference/syntax.mdx @@ -35,6 +35,7 @@ A Nextflow script may contain the following top-level declarations: - Parameter declarations (legacy) - Workflow definitions - Process definitions +- Agent definitions - Function definitions - Enum types - Record types @@ -328,6 +329,51 @@ Typed processes use the same behavior as legacy processes for all other sections See [Typed processes][process-typed] for more information on the semantics of typed processes. +### Agent + +An agent consists of a name and a body. The agent body may define sections for *directives*, *inputs*, *outputs*, and *prompt*: + +```nextflow +agent qa { + // directives + model 'openai/gpt-5-mini' + instruction 'You are a concise scientific assistant.' + + input: + question: String + + output: + answer: String + + prompt: + """ + Answer briefly: ${question} + """ +} +``` + +- An agent must define a prompt section. All other sections are optional. Directives do not have an explicit section label, but must be defined first. + +- Sections must be defined in the order shown above. + +- Agent inputs and outputs are declared with a name and type, in the same manner as a [typed process](#process-typed). + +Agents may specify the following sections: + +##### `input:` + +Consists of one or more agent inputs. Each input has a name and type. + +##### `output:` + +Consists of one or more *output statements*. Each output has a name and type. An output may also specify a source expression after an equals sign, which collects a file written by the agent instead of asking the model for the value. + +##### `prompt:` + +Consists of one or more [statements](#statements) and must return a string in the same manner as a [function](#function). The returned string is the prompt sent to the model. + +Directives must be [function calls](#function-call). See [Agents][agent-page] for the set of available directives and more information on the semantics of each agent section. + ### Function A function consists of a name, parameter list, and a body: @@ -954,6 +1000,7 @@ The following legacy features were excluded from this page because they are depr See [strict syntax][strict-syntax-page] for more information. +[agent-page]: ../agent [config-feature-flags]: ./feature-flags [operator-page]: ./operator [process-page]: ../process diff --git a/docs/tutorials/data-lineage.mdx b/docs/tutorials/data-lineage.mdx index 2228034c54..2ac9e60111 100644 --- a/docs/tutorials/data-lineage.mdx +++ b/docs/tutorials/data-lineage.mdx @@ -22,7 +22,7 @@ Data lineage tracks the complete history of your Nextflow runs, including: Each lineage record has a unique identifier called a *lineage ID* (LID) that you can use to access and query the data. :::note -The data model for every lineage record is defined in the Nextflow [source code](https://github.com/nextflow-io/nextflow/tree/master/modules/nf-lineage/src/main/nextflow/lineage/model). +The data model for every lineage record is defined in the Nextflow [source code](https://github.com/nextflow-io/nextflow/tree/master/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1). ::: ## Enable data lineage @@ -241,6 +241,63 @@ $ nextflow lineage view lid://862df53160e07cd823c0c3960545e747 Every task run is represented in the lineage store as a `TaskRun`, which includes information such as the name, script, inputs, and software dependencies. From here, you can continue traversing through the file inputs to view upstream tasks. +### Agent runs + +An [agent][agent-page] also runs as a task, but its provenance is not a script -- it is the model that was called and the prompt, tools and skills it was called with. Agent executions are therefore recorded as a separate `AgentRun` record in place of a `TaskRun`: + +```console +$ nextflow lineage view lid://c47bf9183c56715c9bca1a67a4acdc68 +{ + "version": "lineage/v1beta1", + "kind": "AgentRun", + "spec": { + "sessionId": "554fe81b-8034-4f5a-81c4-b07195258201", + "name": "analyst (1)", + "codeChecksum": { + "value": "f8acadb0cd9048eaf953b1b30836dffd", + "algorithm": "nextflow", + "mode": "standard" + }, + "runner": "pi", + "model": "openai/gpt-5-mini", + "resolvedModel": null, + "instruction": "You are a precise scientific analyst. Be concise and honest about uncertainty.", + "goal": null, + "promptTemplate": " \"\"\"\n Analyze the following question and return a structured analysis.\n\n Question: ${query.question}\n \"\"\"\n", + "maxIterations": 20, + "outputSchema": "{\"additionalProperties\":false,\"properties\":{\"actionable\":{\"type\":\"boolean\"},\"confidence\":{\"type\":\"number\"},\"key_points\":{\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"summary\":{\"type\":\"string\"}},\"required\":[\"summary\",\"confidence\",\"actionable\",\"key_points\"],\"type\":\"object\"}", + "tools": null, + "skills": null, + "input": [ + { + "type": "val", + "name": "query", + "value": { + "question": "Is FASTQ a binary or a text format?", + "context": "bioinformatics file formats" + } + } + ], + "container": null, + "workflowRun": "lid://6334982d0dd5e6573989fa5640fc01d3", + "moduleId": null + } +} +``` + +The fields that make an agent run reproducible are: + +- `model` is the model that was requested, while `resolvedModel` is the concrete model the provider actually served. These differ when a floating alias (`openai/gpt-4o`) resolves to a dated snapshot -- which is why pinning a snapshot is recommended for [reproducible resume][agent-page]. `resolvedModel` is `null` when the runner executes out of process or the agent is not cacheable. +- `promptTemplate` is the verbatim source text of the `prompt:` block -- quote delimiters, indentation and trailing newline included -- not the rendered text. Combined with `input`, it tells you what the model was asked. +- `tools` and `skills` list what the agent was allowed to use, and `outputSchema` is the JSON schema its response had to satisfy. +- `codeChecksum` covers the agent's canonical identity -- the same text that feeds the [task hash][cache-resume-task-hash] -- so two agent runs with the same LID were driven by the same model, prompt, schema and skills. + +An agent's results are recorded as a `TaskOutput`, exactly like a process, so agent outputs traverse and query the same way as any other task output. + +:::note +Agent runs record the prompt *template*, never the rendered prompt, and do not record token usage. See [Data lineage][agent-lineage] in the agent documentation for the full list of what is and is not captured. +::: + Finally, use the `render` subcommand to render the entire lineage of the MULTIQC report as an HTML report: ```console @@ -258,18 +315,21 @@ Use the `find` subcommand to find all tasks executed by a workflow run: ```console $ nextflow lineage find type=TaskRun workflowRun=lid://16b31030474f2e96c55f4940bca3ab64 -[ - "lid://2d8bd92c69f732605bc99941e60d5319", - "lid://eff8846883b46c5a76f11e7e4480a6c8", - "lid://862df53160e07cd823c0c3960545e747", - "lid://6d3bff36bf2c3c14c2d383384621e8ca" -] +lid://2d8bd92c69f732605bc99941e60d5319 +lid://eff8846883b46c5a76f11e7e4480a6c8 +lid://862df53160e07cd823c0c3960545e747 +lid://6d3bff36bf2c3c14c2d383384621e8ca ``` -You can use any field defined in the [lineage data model](https://github.com/nextflow-io/nextflow/tree/master/modules/nf-lineage/src/main/nextflow/lineage/model). +You can use any field defined in the [lineage data model](https://github.com/nextflow-io/nextflow/tree/master/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1). Fields specific to a record kind work the same way -- for example, to find every agent run in the store that used a given model: + +```console +$ nextflow lineage find type=AgentRun model=openai/gpt-5-mini +lid://c47bf9183c56715c9bca1a67a4acdc68 +``` :::tip -Since the `find` and `view` subcommands always output JSON, you can use JSON processing tools such as [jq](https://jqlang.org/) to further query and transform results. +The `view` subcommand outputs JSON, so you can use JSON processing tools such as [jq](https://jqlang.org/) to further query and transform results. The `find` subcommand prints one LID per line, which pipes directly into `xargs`. ::: ## Compare task runs @@ -402,6 +462,8 @@ It should produce the following output: The `fromLineage` channel factory can also be used to query lineage records in a similar manner as the `find` subcommand. See [fromLineage][channel-from-lineage] for details. +[agent-lineage]: ../agent#data-lineage +[agent-page]: ../agent [cache-resume-task-hash]: ../cache-and-resume#task-cache [channel-from-lineage]: ../reference/stdlib-namespaces/channel#fromlineage [cli-lineage]: ../reference/cli/lineage diff --git a/examples/agents/.gitignore b/examples/agents/.gitignore new file mode 100644 index 0000000000..af9a10e3f7 --- /dev/null +++ b/examples/agents/.gitignore @@ -0,0 +1,10 @@ +# Nextflow run artifacts and auto-installed/registry-fetched modules +**/work/ +**/.nextflow* +**/modules/ +*.log +run.log + +# No rule for data fixtures: they live once in examples/data/, which ignores its own +# contents, and the examples that need them carry a `data` symlink into it. 19_shell-tools +# is the one example with a real data/ directory, and its FASTA is committed on purpose. diff --git a/examples/agents/01_structured-output/README.md b/examples/agents/01_structured-output/README.md new file mode 100644 index 0000000000..9e7a278556 --- /dev/null +++ b/examples/agents/01_structured-output/README.md @@ -0,0 +1,98 @@ +# structured-output — structured output from a record-typed agent + +The simplest possible agent: one LLM call with no tools, demonstrating how a +named `record` output type turns the model's response into a typed, validated +Nextflow value. + +## Purpose / What it demonstrates + +This example is the entry point to the `agent` primitive. It shows the one +feature that distinguishes agents from plain processes: **structured output via +record types**. + +When an agent's output is a named `record`, Nextflow reflects that record's +fields and types into a JSON schema and passes it to the model as an OpenAI +structured-output contract. The model *must* return JSON that matches the +schema — no parsing heuristics, no post-processing. The returned JSON is +validated and bound to a record instance, which is emitted on the output +channel exactly like any other typed value. + +Concretely, this example sends a scientific question to `gpt-5-mini` and gets +back an `Analysis` record with four fields: a plain-text summary, a numeric +confidence score, a boolean actionability flag, and a list of key points. Those +fields are immediately readable in the workflow without any string parsing. + +No tools and no input data file — beyond the OpenAI key and the `pi` runner image +every agent task runs in. It is safe to run locally as a first test of the +`nf-agent-pi` plugin. + +## How it works + +1. **The `Query` input record** carries the question and an optional context + string. Declaring `context` as `String?` makes it optional — the agent + gracefully handles a missing value on the input side. + +2. **The `Analysis` output record** has four fields: + - `summary: String` — a concise plain-text answer + - `confidence: Float` — the model's self-reported confidence (0–1) + - `actionable: Boolean` — whether the answer implies a concrete next step + - `key_points: List` — a bullet-list of the main takeaways + + These four types (`String`, `Float`, `Boolean`, `List`) cover the + full set of scalar and collection types the v1 JSON-schema deriver supports. + +3. **The `analyst` agent** is declared with: + - `model 'openai/gpt-5-mini'` — the LLM to call. + - `instruction` — a one-line system prompt that fixes the model's persona. + - `input: query: Query` / `output: result: Analysis` — typed I/O. Because + the output is a `record`, structured output is enabled automatically. + - `prompt:` — the per-input message, interpolating `query.question`. The + full `query` record is also serialized as JSON and appended to the model + message, so the model also sees the `context` field even though the + prompt template does not reference it explicitly. + +4. **The workflow** constructs a `Query` value inline with `record(...)`, + passes it to `analyst(channel.of(...))`, and chains `.view { }` to print + each field of the returned `Analysis`. + + Under `nextflow.enable.types = true` the pipe operator (`|`) is replaced by + the call form (`analyst(...)`) and `.view { }` for chaining operators. + +## Key concepts + +| Concept | In this example | +|---|---| +| `record` output | `result: Analysis` — enables structured output | +| JSON-schema contract | Nextflow derives the schema from `Analysis`'s fields; OpenAI enforces it | +| Optional input field | `context: String?` on `Query` | +| Typed DSL | `nextflow.enable.types = true` required for `record` syntax | +| `model` directive | `'openai/gpt-5-mini'` — provider/model string | +| `instruction` directive | System-prompt persona, set once per agent | +| `prompt:` block | Per-input message; input fields are interpolated with `${}` | +| No tools | Plain single-turn call; no `goal`, no `tools`, no `maxIterations` | + +## Running it + +**Requirements:** + +- An OpenAI API key. +- The `nf-agent-pi` plugin (declared in `nextflow.config`). +- A container engine and the `pi` runner image — every agent task is containerized + (see [the examples README](../README.md#requirements)). No input data file needed. + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output (values vary by model run): + +``` +summary : FASTQ is a plain-text format storing sequences and per-base quality scores. +confidence : 0.98 +actionable : false +key_points : [Each record has four lines., Quality scores are Phred-encoded ASCII., ...] +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. diff --git a/examples/agents/01_structured-output/main.nf b/examples/agents/01_structured-output/main.nf new file mode 100644 index 0000000000..27b3f7e13e --- /dev/null +++ b/examples/agents/01_structured-output/main.nf @@ -0,0 +1,48 @@ +nextflow.enable.types = true + +// Input record — `context` is optional (note `?`). +record Query { + question: String + context: String? +} + +// Output record — its fields become the model's JSON-schema contract (structured output). +record Analysis { + summary: String + confidence: Float + actionable: Boolean + key_points: List +} + +agent analyst { + model 'openai/gpt-5-mini' + instruction 'You are a precise scientific analyst. Be concise and honest about uncertainty.' + + input: + query: Query + + output: + result: Analysis + + prompt: + """ + Analyze the following question and return a structured analysis. + + Question: ${query.question} + """ +} + +workflow { + // Under typed DSL, use the call form; `| view` pipe is not available. + analyst(channel.of( + record(question: 'Is FASTQ a binary or a text format?', context: 'bioinformatics file formats') + )) + .view { r -> + """\ + summary : ${r.summary} + confidence : ${r.confidence} + actionable : ${r.actionable} + key_points : ${r.key_points} + """.stripIndent() + } +} diff --git a/examples/agents/01_structured-output/nextflow.config b/examples/agents/01_structured-output/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/01_structured-output/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/02_two-agents/README.md b/examples/agents/02_two-agents/README.md new file mode 100644 index 0000000000..60e327b3cb --- /dev/null +++ b/examples/agents/02_two-agents/README.md @@ -0,0 +1,87 @@ +# two-agents — composing agents over a channel + +Two no-tool agents chained end to end: the first proposes a hypothesis, the +second peer-reviews it. Shows how agents compose with the rest of a pipeline +through the normal channel/workflow model. + +## Purpose / What it demonstrates + +Agents are process-shaped: one typed input per channel item in, one typed output +out. That means they **compose like processes** — the output channel of one +agent can feed straight into another. This example chains two agents and +highlights the one thing that makes the chain type-safe: + +> The first agent's **output record type** is the second agent's **input record +> type**. That shared record is the entire contract between them — no tools, no +> glue code, no manual parsing. + +Both agents are **no-tool** agents (single-shot LLM calls, no tool-call loop) and +both use **structured output** (their output record type is reflected into a JSON +schema the model is constrained to). So structured data flows agent → agent over +a plain Nextflow channel, exactly like any other dataflow value. It builds +directly on [`structured-output`](../01_structured-output) (a single record-typed +agent) by wiring two of them together. + +## How it works + +1. **`Hypothesis`** is the shared contract — the record type that stage 1 emits + and stage 2 consumes: + - `statement: String` — the proposed hypothesis + - `rationale: String` — a brief justification + - `confidence: Float` — the author's self-reported confidence (0–1) + +2. **`Review`** is the pipeline's final output: + - `verdict: String` — e.g. "plausible", "doubtful" + - `critique: String` — a concise peer-review note + +3. **Stage 1 — `hypothesizer`**: `input: question: String` → + `output: hypothesis: Hypothesis`. Given a question, it proposes one testable + hypothesis as a structured `Hypothesis` record. + +4. **Stage 2 — `critic`**: `input: hypothesis: Hypothesis` → + `output: review: Review`. It reads the `Hypothesis` fields in its prompt and + returns a structured `Review`. + +5. **The workflow** passes the `ChannelOut` of `hypothesizer` straight into + `critic`; the `Hypothesis` records flow over the channel and a `Review` is + emitted per input question: + + ```groovy + def hypotheses = hypothesizer(channel.of( ...questions... )) + critic(hypotheses).view { r -> "${r.verdict}: ${r.critique}" } + ``` + + Under `nextflow.enable.types = true` the call form is used (the bare `|` pipe + is not used in the typed DSL). + +## Key concepts + +| Concept | In this example | +|---|---| +| Agent composition | Two agents chained over a channel | +| Shared record contract | Stage-1 output type **==** stage-2 input type (`Hypothesis`) | +| Structured output | Each agent's output record becomes a JSON-schema contract | +| No tools | Both are single-shot calls — no `tools`, `goal`, or `maxIterations` | +| Typed DSL | `nextflow.enable.types = true`; call form, not `|` | + +## Running it + +**Requirements:** an OpenAI API key, the `nf-agent-pi` plugin, and the container +engine plus `pi` runner image every agent task needs (see +[the examples README](../README.md#requirements)). No data file. + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output — one `Review` per input question (values vary): + +``` +verdict : plausible +critique : Testable via a randomized crossover trial; control for habitual + caffeine intake and time-of-day effects … +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. diff --git a/examples/agents/02_two-agents/main.nf b/examples/agents/02_two-agents/main.nf new file mode 100644 index 0000000000..eb35aa00ef --- /dev/null +++ b/examples/agents/02_two-agents/main.nf @@ -0,0 +1,74 @@ +nextflow.enable.types = true + +// Two no-tool agents chained over a channel: stage-1's output record type IS +// stage-2's input type, so structured output flows agent→agent. See README.md. + +// The contract BETWEEN the two agents: stage-1 output == stage-2 input. +record Hypothesis { + statement: String + rationale: String + confidence: Float // the author agent's self-reported confidence, 0..1 +} + +// The final output of the pipeline. +record Review { + verdict: String // e.g. "plausible", "doubtful", "needs revision" + critique: String // a concise peer-review note +} + +// Stage 1: propose a hypothesis (String question -> Hypothesis record). +agent hypothesizer { + model 'openai/gpt-5-mini' + instruction 'You are a scientist. Given a question, propose ONE concrete, testable hypothesis.' + + input: + question: String + + output: + hypothesis: Hypothesis + + prompt: + """ + Question: ${question} + + Propose a single, testable hypothesis. Include a brief rationale and your + confidence (a number between 0 and 1). + """ +} + +// Stage 2: peer-review the hypothesis (Hypothesis -> Review record). +agent critic { + model 'openai/gpt-5-mini' + instruction 'You are a skeptical peer reviewer. Judge a hypothesis for plausibility and testability.' + + input: + hypothesis: Hypothesis + + output: + review: Review + + prompt: + """ + Hypothesis: ${hypothesis.statement} + Rationale: ${hypothesis.rationale} + Author confidence: ${hypothesis.confidence} + + Give a one-word verdict and a concise critique (testability, confounders, + whether the author's confidence is justified). + """ +} + +workflow { + // stage-1's output channel feeds straight into stage-2. + def hypotheses = hypothesizer(channel.of( + 'Does caffeine improve short-term memory consolidation?', + 'Can a high-fiber diet reduce systemic inflammation?' + )) + + critic(hypotheses).view { r -> + """\ + verdict : ${r.verdict} + critique : ${r.critique} + """.stripIndent() + } +} diff --git a/examples/agents/02_two-agents/nextflow.config b/examples/agents/02_two-agents/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/02_two-agents/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/03_skills/README.md b/examples/agents/03_skills/README.md new file mode 100644 index 0000000000..04ddab7b3f --- /dev/null +++ b/examples/agents/03_skills/README.md @@ -0,0 +1,58 @@ +# Agent skills + +Demonstrates the `skills` directive: giving an agent one or more Anthropic-style +**skills** (`SKILL.md` folders) that progressively disclose instructions to the +model. Skills extend an agent's capability the way `tools` do, but instead of +calling code they inject expert instructions on demand. + +## What's here + +``` +skills/ + main.nf # an agent declaring `skills 'sequence-report'` + skills/sequence-report/SKILL.md # a local skill: a standardized QC report format +``` + +The `reporter` agent declares `skills 'sequence-report'`. That name resolves to +the local `skills/sequence-report/` directory (the `skills/` directory sits +beside `main.nf`). At run time the model sees the skill's `name` + `description` +in an available-skills catalog; when the prompt asks for an assembly summary it +calls `activate_skill` to read the full instructions, then formats its answer as +the skill dictates. + +## Run it + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected: the answer is the distinctive skill-dictated format, e.g. + +``` +ANSWER= +[SEQ-REPORT v1] +STATUS: PASS +METRICS: N50 = 45 kb, total length = 5.1 Mb, GC = 50.8% +NOTE: A 5.1 Mb assembly with 45 kb N50 looks suitable to proceed. +``` + +The `[SEQ-REPORT v1]` header is the tell that the skill was activated — without +the skill the model would answer in free prose. Add `-with-agent-trace` to see +the `activate_skill` call in the execution trace. + +## Local vs remote skills + +- **Local** — a bare name (e.g. `sequence-report`) resolves to + `skills//` beside the script. +- **Remote** — a GitHub reference (`github.com//[@rev]`, + `https://github.com/...`, or `git@github.com:...`) is cloned and cached into + the same `skills/` directory, then loaded the same way. + +> **Trust note:** a remote skill's `SKILL.md` becomes model instructions, so +> activating a remote skill means trusting its authors. Pin a commit SHA +> (`@`) rather than a moving branch so the content can't change under you. + +> **Caching note:** remote skills are cloned into this `skills/` directory (a full +> git clone, including its `.git`). Add the cached clone directories to your +> `.gitignore` to avoid committing third-party code into your project. diff --git a/examples/agents/03_skills/main.nf b/examples/agents/03_skills/main.nf new file mode 100644 index 0000000000..d46c30cd3a --- /dev/null +++ b/examples/agents/03_skills/main.nf @@ -0,0 +1,36 @@ +nextflow.enable.types = true + +/* + * Agent skills example (Milestone 4). + * + * The `skills` directive gives the agent access to one or more Anthropic-style + * skills (SKILL.md folders). This agent declares the local `sequence-report` + * skill found under `skills/sequence-report/`. When the prompt calls for an + * assembly/QC summary the model activates the skill (langchain4j Tool Mode: + * `activate_skill`) and follows its instructions — producing the distinctive + * `[SEQ-REPORT v1]` formatted report it would not otherwise emit. + * + * A skill entry may also be a remote GitHub reference, e.g. + * skills 'github.com//@' + * which is cloned and cached into this same `skills/` directory. + */ +agent reporter { + model 'openai/gpt-5-mini' + instruction 'You summarize sequencing and genome-assembly results for bioinformaticians.' + skills 'sequence-report' + + input: + request: String + output: + answer: String + + prompt: + """ + ${request} + """ +} + +workflow { + reporter(channel.of('Summarize this assembly: N50 = 45 kb, total length = 5.1 Mb, GC = 50.8%. Is it good enough to proceed?')) + .view { a -> "ANSWER=\n${a}" } +} diff --git a/examples/agents/03_skills/nextflow.config b/examples/agents/03_skills/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/03_skills/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/03_skills/skills/sequence-report/SKILL.md b/examples/agents/03_skills/skills/sequence-report/SKILL.md new file mode 100644 index 0000000000..69235bb185 --- /dev/null +++ b/examples/agents/03_skills/skills/sequence-report/SKILL.md @@ -0,0 +1,20 @@ +--- +name: sequence-report +description: Format a sequencing or genome-assembly summary as a standardized QC report. Use this skill whenever the user asks for a sequence, assembly, or read-QC summary or verdict. +--- +# Sequence QC report format + +When producing a sequencing or assembly summary, format the answer EXACTLY as +follows and nothing else (no preamble, no extra sections): + +``` +[SEQ-REPORT v1] +STATUS: +METRICS: +NOTE: +``` + +Rules: +- `STATUS` is your overall verdict from the metrics provided. +- `METRICS` echoes the metrics from the request, normalized (name = value). +- `NOTE` is a single terse sentence — no more. diff --git a/examples/agents/04_tool/README.md b/examples/agents/04_tool/README.md new file mode 100644 index 0000000000..228966827b --- /dev/null +++ b/examples/agents/04_tool/README.md @@ -0,0 +1,75 @@ +# tool — the simplest tool-calling agent + +An agent that calls a single canonical Nextflow process as a tool. The smallest +possible demonstration of the `nf:module_run` tool family. + +## Purpose / What it demonstrates + +The previous examples (`structured-output`, `two-agents`) are no-tool agents: +one LLM call in, one structured value out. This example introduces **tools** — +the ability for the model to *run Nextflow processes* mid-conversation and use +their results. + +It shows the simplest form: a plain in-scope process (`uppercase`) is exposed to +the model as a tool via `tools 'nf:module_run'`. No `include` statement is needed — +`nf:module_run` automatically discovers every process defined in (or included into) +the script and advertises **each one as its own tool**, named after the process. +The tool's parameter schema is derived from the process's declared inputs, so +the model is constrained to call it with the right arguments. + +This is the foundation every other tool example builds on. The process uses a +portable shell `script:` body, so it can run through the local executor or be +offloaded through any canonical Nextflow executor. + +## How it works + +1. **The `uppercase` process** takes a `text: String` input and returns + `result: String` captured from a portable shell `script:` block. It is + a normal Nextflow process; nothing about it is agent-specific. + +2. **The `shouty` agent** declares `tools 'nf:module_run'`. At run time the harness + discovers `uppercase` and advertises a tool named `uppercase` whose parameter + schema is `{text: string}` (required) — taken straight from the process's + input declaration. The model cannot misname or omit the field. + +3. **The tool-call loop:** given the prompt *"uppercase the word hello"*, the + model decides to call `uppercase({"text": "hello"})`. The harness runs the + process as a real dataflow node (executor, work dir, caching), serializes its + output back to the model as JSON, and the model produces its final answer. + +4. **Exact scalar output:** the agent declares `answer: String`. The Pi harness + returns it through a structured final-answer contract so explanatory prose + cannot contaminate the task value. + +## Key concepts + +| Concept | In this example | +|---|---| +| `tools 'nf:module_run'` | Exposes each in-scope process as its own tool | +| Auto-discovery | No `include` needed for a locally-defined process | +| Tool schema | Derived from the process inputs (`{text}`, required) | +| Tool-call loop | The model decides when to call the tool, then replies | +| Exact scalar output | Tool-backed `String` result uses the final-answer contract | +| Executor-portable tool | `script:` process can run locally or on a remote backend | +| No input data | The shell tool only needs a basic POSIX container remotely | + +## Running it + +**Requirements:** an OpenAI API key and the `nf-agent-pi` plugin (in +`nextflow.config`). No data file, and no image for the tool process — only the +container engine and `pi` runner image the agent task itself needs (see +[the examples README](../README.md#requirements)). + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output: + +``` +ANSWER=HELLO +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. diff --git a/examples/agents/04_tool/main.nf b/examples/agents/04_tool/main.nf new file mode 100644 index 0000000000..eb7a1e3f06 --- /dev/null +++ b/examples/agents/04_tool/main.nf @@ -0,0 +1,55 @@ +#!/usr/bin/env nextflow +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +nextflow.enable.types = true + +// A canonical script task that can run through any Nextflow executor. +process uppercase { + container 'ubuntu:24.04' + input: + text: String + + output: + result: String = stdout() + + script: + """ + printf '%s' '${text}' | tr '[:lower:]' '[:upper:]' + """ +} + +agent shouty { + model 'openai/gpt-5-mini' + instruction 'Call the `uppercase` tool, then reply with only its result.' + tools 'nf:module_run' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ +} + +workflow { + shouty(channel.of('uppercase the word hello')) + .view { answer -> "ANSWER=${answer}" } +} diff --git a/examples/agents/04_tool/nextflow.config b/examples/agents/04_tool/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/04_tool/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/05_tool-parallel/README.md b/examples/agents/05_tool-parallel/README.md new file mode 100644 index 0000000000..8e387db682 --- /dev/null +++ b/examples/agents/05_tool-parallel/README.md @@ -0,0 +1,77 @@ +# tool-parallel — agent tools on the task path (parallel + structured) + +A tool-using agent now lowers to a real `TaskProcessor`/`TaskRun`, exactly like a +tool-free or skills-only agent. So it **fans out over a queue in parallel**, gets +per-task work dirs and progress rows, and can pair `tools` with a **structured +(record) output**. Before this milestone a tool agent ran on a serial legacy +operator path (one item at a time); that path has been removed and `runLegacy` is +gone — every agent runs as a task. + +``` +seqs (queue of 4) ─► annotate (agent + `revcomp` tool, PARALLEL map) ─► SeqAnnotation record +``` + +## What it demonstrates + +| Capability | Where | +|---|---| +| A tool agent runs as a real task (parallel fan-out, progress rows, work dirs) | `annotate` over a 4-item queue → 4 concurrent `annotate` TaskRuns | +| `tools` + a **structured record** output | `output: annotation: SeqAnnotation` (the model ends the tool loop by calling the terminating `final_answer` tool, whose arguments are the record) | +| The tool does the exact work the LLM shouldn't | `revcomp` (reverse-complement) is deterministic string surgery an LLM gets subtly wrong; the agent is instructed to **call** it | +| Multiple could-be-wrong inputs handled | the `ACGTACGTNACG` sequence (an `N`) is flagged in `note` | + +## Why an agent (and not a plain `revcomp` process)? + +The mechanical part (reverse-complement) is a process — deterministic, cached, +exact. The *judgment* is the agent's: validate the sequence, decide whether to +flag a non-ACGT base, carry context into a structured record. The agent delegates +the exact computation to the tool and keeps only the reasoning. + +## Run + +```bash +nextflow run main.nf # needs OPENAI_API_KEY +``` + +Expected (order varies — the map is parallel): + +``` +[PROCESS 76/a83f94] annotate (1) +[PROCESS 2d/7d0065] annotate (2) +[PROCESS b8/d97c45] annotate (3) +[PROCESS 32/d92056] annotate (4) +[PROCESS 70/a98504] revcomp (1) +... +len=12 note=ok + seq = ATGCATTAGCCG + revcomp = CGGCTAATGCAT +len=12 note=contains non-ACGT base(s): N + seq = ACGTACGTNACG + revcomp = CGTNACGTACGT +... +[SUCCESS] completed=8 failed=0 cached=0 +``` + +## Resume behaviour + +Everything caches. Each tool's schema and backing script source are folded into the +agent's resume key, so a replay is only ever served for the exact tools it was +produced with — editing `revcomp` re-runs `annotate`. On `-resume` both the agent +tasks and the deterministic `revcomp` tool tasks are served from cache, with no +model calls: + +``` +$ nextflow run main.nf -resume +[SUCCESS] completed=0 failed=0 cached=8 # 4 annotate + 4 revcomp, all cached +``` + +Resume for an agent means **replay**, not reproduce: the stored generation is +returned rather than the model being asked again. + +## Notes + +- Within one agent the tool calls are sequential — the model waits for each result + before asking for the next — but the agent *tasks* run in parallel and their + calls are dispatched concurrently, each into its own cloned process graph. +- A failing tool task aborts the run; dispatch-level errors (bad args, unknown + tool) are returned to the model so it can recover. diff --git a/examples/agents/05_tool-parallel/main.nf b/examples/agents/05_tool-parallel/main.nf new file mode 100644 index 0000000000..f5d5133621 --- /dev/null +++ b/examples/agents/05_tool-parallel/main.nf @@ -0,0 +1,74 @@ +nextflow.enable.types = true + +// A tool agent fanning out over a queue in PARALLEL, paired with structured output. +// See README.md. + +// One sequence's annotation, produced by the agent (structured output). +record SeqAnnotation { + seq: String // the original sequence (upper-cased, validated) + revcomp: String // the reverse complement — MUST come from the `revcomp` tool + length: Integer // sequence length + note: String // any caveat (e.g. non-ACGT bases seen) or "ok" +} + +// Deterministic tool: reverse-complement a DNA string. `nf:module_run` exposes this in-scope +// process to the LLM as the tool `revcomp` (input seq:String -> output rc:String). +process revcomp { + input: + seq: String + output: + rc: String + exec: + def comp = [A:'T', T:'A', C:'G', G:'C', N:'N'] + rc = seq.toUpperCase().reverse().collect { comp[it as String] ?: 'N' }.join() +} + +// The agent: for each sequence it validates the input, calls the `revcomp` tool for the +// exact reverse complement, and returns a structured SeqAnnotation. Combining `tools` with +// a record output runs the tool loop schema-free then a final structuring turn encodes the +// answer into the SeqAnnotation schema (M5), now on the task path. +agent annotate { + model 'openai/gpt-5-mini' + instruction '''\ + You annotate short DNA sequences. For the input sequence: + - Upper-case it and report its length. + - To obtain the reverse complement you MUST call the `revcomp` tool with the + sequence; never compute the reverse complement yourself. + - If the sequence contains any base other than A/C/G/T, say so in `note` + (otherwise set `note` to "ok"). + '''.stripIndent() + + tools 'nf:module_run' + + input: + seq: String + output: + annotation: SeqAnnotation + + prompt: + """ + Annotate this DNA sequence: + + ${seq} + """ +} + +workflow { + // A small queue of sequences (one has an ambiguous base to exercise the `note` path). + // Each item runs `annotate` as its own parallel agent TaskRun, each making a `revcomp` + // tool call — the whole point of tools-on-the-task-path. + def seqs = channel.of( + 'ATGCATTAGCCG', + 'GGGGCCCCTTTT', + 'ACGTACGTNACG', // contains an N -> agent should flag it in `note` + 'TTACGGATCCAA', + ) + + annotate(seqs).view { a -> + """\ + len=${a.length} note=${a.note} + seq = ${a.seq} + revcomp = ${a.revcomp} + """.stripIndent() + } +} diff --git a/examples/agents/05_tool-parallel/nextflow.config b/examples/agents/05_tool-parallel/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/05_tool-parallel/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/06_tool-structured/README.md b/examples/agents/06_tool-structured/README.md new file mode 100644 index 0000000000..c611628f45 --- /dev/null +++ b/examples/agents/06_tool-structured/README.md @@ -0,0 +1,56 @@ +# tool-structured — tools + structured output together + +A tool-calling agent that **also** returns a record-typed structured output. It +builds directly on [`tool/`](../04_tool/main.nf) by changing the output type from a +plain `String` to a `record`. + +## Purpose / What it demonstrates + +Earlier, declaring `tools` (or `skills`) forced a plain (non-record) output — the +tool loop emitted the model's free text. This example shows the M5 capability: +**tools and a structured record output combined**. + +## How it works + +1. **The `uppercase` process** is exposed as a tool via `tools 'nf:module_run'`, + exactly as in the `tool/` example. + +2. **The tool loop:** the model calls `uppercase({"text": "hello"})`, the harness + runs the real process and feeds the JSON result back. No response format is + forced on the loop itself. + +3. **A terminating `final_answer` tool:** because the agent declares a `record` + output (`Shout`), the harness offers one extra tool whose parameter schema *is* + the record schema, and tells the model it is the only valid way to finish. Its + arguments bind to the `Shout` record and are emitted on the output channel. + +## Trade-offs (read before using) + +- **No second pass.** The record comes out of the loop's own final tool call, so + there is no extra request and nothing is re-encoded from free text. +- **The model has to call it.** If it answers in prose instead, the runner allows + one corrective turn; if it still does not call the tool, the task fails. +- **Keep the schema close to the tools' results.** The model fills the record from + what the loop produced, so a schema demanding facts no tool returned invites + invention. + +## Running it + +**Requirements:** an OpenAI API key and the `nf-agent-pi` plugin (in +`nextflow.config`). No data file, and no tool image — only the container engine +and `pi` runner image the agent task needs (see +[the examples README](../README.md#requirements)). + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output (a bound `Shout` record): + +``` +ANSWER=[result:HELLO] +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. diff --git a/examples/agents/06_tool-structured/main.nf b/examples/agents/06_tool-structured/main.nf new file mode 100644 index 0000000000..a263a015c1 --- /dev/null +++ b/examples/agents/06_tool-structured/main.nf @@ -0,0 +1,42 @@ +nextflow.enable.types = true + +// A local process; `nf:module_run` exposes it to the LLM as the tool `uppercase`. +process uppercase { + input: + text: String + output: + result: String + exec: + result = text.toUpperCase() +} + +// A record output type: combining `tools` with a structured (record) output is +// supported (M5). The tool loop runs schema-free; a final structuring turn then +// converts the loop's free-text answer into schema-valid JSON that binds to the +// record. NOTE: the structuring turn is +1 LLM call and is potentially lossy +// (it re-encodes free text to JSON), so keep the output schema close to the +// tool's own result. +record Shout { + result: String +} + +agent shouty { + model 'openai/gpt-5-mini' + instruction 'To uppercase text, call the `uppercase` tool, then reply with the result.' + tools 'nf:module_run' + + input: + request: String + output: + answer: Shout + + prompt: + """ + ${request} + """ +} + +workflow { + shouty(channel.of('uppercase the word hello')) + .view { a -> "ANSWER=${a}" } +} diff --git a/examples/agents/06_tool-structured/nextflow.config b/examples/agents/06_tool-structured/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/06_tool-structured/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/07_module-as-tool/README.md b/examples/agents/07_module-as-tool/README.md new file mode 100644 index 0000000000..1051bce10a --- /dev/null +++ b/examples/agents/07_module-as-tool/README.md @@ -0,0 +1,83 @@ +# skesa — running an included nf-core module as a tool + +An agent that calls a real **nf-core module** (`nf-core/skesa`) to assemble a +genome, with the LLM bridging the agent's input record to the module's input +schema. + +## Purpose / What it demonstrates + +The `tool` example exposed a trivial local process. This one exposes a real, +containerized **nf-core module** pulled in with an `include` statement — and +shows the feature that makes that practical: the tool's schema is **fetched from +the module's registry / `meta.yml` metadata**, not written by hand. + +Two things to take away: + +1. **`include` + `nf:module_run` = a tool.** `include { SKESA } from 'nf-core/skesa'` + brings the module into scope; `tools 'nf:module_run'` then advertises it to the + model as a tool named `SKESA`, whose parameter schema (`{meta:{id}, fastq}`, + required) is derived automatically from the module's metadata. OpenAI + function-calling enforces those exact field names. + +2. **The LLM bridges mismatched shapes.** The agent's input record + (`AssemblyRequest{sample_id, reads}`) looks *nothing* like the module's input + (`{meta, fastq}`). There is no field-to-field mapping in the code — the model + reads the prompt and the registry-derived tool schema and **synthesizes** the + correct call, mapping `sample_id → meta.id` and `reads → fastq`. The + `instruction` never spells out the field structure; that is the whole value of + the feature (the schema comes from the registry, not the prompt). + +## How it works + +1. **`AssemblyRequest`** carries `sample_id` and `reads` (an absolute FASTQ + path, treated as an opaque handle). + +2. **The `assembler` agent** declares `tools 'nf:module_run'` and a high-level + `instruction` ("use the SKESA tool to assemble the provided reads, then report + the path to the assembled contigs") — no field names. + +3. **The model's tool call**, observed in the run, is e.g. + `SKESA({"meta":{"id":"sample1"},"fastq":"/…/data/sample.fastq"})` — produced + purely from the registry-derived schema plus the prompt's `reads` path. + +4. **Execution:** SKESA runs as a real dataflow node in its container + (provisioned by Wave), and its `fasta` output comes back to the model as JSON + with the contigs path as an absolute handle. + +5. **Plain output:** the agent output is `assembly_path: Path` (a plain type) — + required because the agent declares `tools`. + +## Key concepts + +| Concept | In this example | +|---|---| +| `include` + `nf:module_run` | An nf-core module becomes a tool named `SKESA` | +| Registry-derived schema | `{meta:{id}, fastq}` comes from `meta.yml`/registry, enforced by OpenAI | +| Record→tuple bridging | The LLM maps `sample_id`/`reads` to `meta.id`/`fastq` — no code mapping | +| No structure in the prompt | The `instruction` stays high-level; the schema carries the shape | +| Containerized module | SKESA runs in its container via Wave | +| Plain output | `assembly_path: Path` (this example emits the model's text; tools *can* also return a record — see `tool-structured/`) | + +## Running it + +**Requirements:** an OpenAI API key, the `nf-agent-pi` plugin, a container runtime +(Docker/Wave), and an input FASTQ at `data/sample.fastq` (the `data/` dir is +gitignored). Fetch the sarscov2 test dataset: + +```bash +mkdir -p data +curl -sL https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/sarscov2/illumina/fastq/sarscov2_mus-musculus.fastq.gz \ + | gunzip > data/sample.fastq + +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output (path varies): + +``` +ASSEMBLY=/…/work//sample1.fa +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. diff --git a/examples/agents/07_module-as-tool/data b/examples/agents/07_module-as-tool/data new file mode 120000 index 0000000000..e67b455909 --- /dev/null +++ b/examples/agents/07_module-as-tool/data @@ -0,0 +1 @@ +../../data \ No newline at end of file diff --git a/examples/agents/07_module-as-tool/main.nf b/examples/agents/07_module-as-tool/main.nf new file mode 100644 index 0000000000..50ce4ccc67 --- /dev/null +++ b/examples/agents/07_module-as-tool/main.nf @@ -0,0 +1,47 @@ +nextflow.enable.types = true + +// The agent's input record. Its shape need NOT match skesa's tool input +// ({meta, fastq}) — the LLM bridges the two from the prompt. See README.md. +record AssemblyRequest { + sample_id: String + reads: Path // absolute path to a FASTQ file (an opaque path handle) +} + +// Include nf-core/skesa so `nf:module_run` surfaces it as the `SKESA` tool. +include { SKESA } from 'nf-core/skesa' + +// Agent that calls the SKESA tool to assemble reads into contigs. An agent that +// declares `tools` must use a plain output type (here `Path`), not a record. +agent assembler { + model 'openai/gpt-5-mini' + instruction """ + You are a genome assembly assistant. Use the available tools to + assemble the provided sequencing reads into contigs, + then report the path to the assembled contigs. + """ + + tools 'nf:module_run' + + input: + req: AssemblyRequest + output: + assembly_path: Path + + prompt: + """ + Assemble the genome for sample '${req.sample_id}'. + The input FASTQ reads are at: ${req.reads} + """ +} + +workflow { + // NOTE: this example needs a FASTQ at `data/sample.fastq`. `data` is a symlink to + // `examples/data/`, which is shared by every example needing input and fetched once -- + // see examples/data/README.md for the command. + // nf-core/skesa runs in a container, so Docker + Wave (or another container + // runtime) is also required. + assembler(channel.of( + record(sample_id: 'sample1', reads: "${projectDir}/data/sample.fastq") + )) + .view { path -> "ASSEMBLY=${path}" } +} diff --git a/examples/agents/07_module-as-tool/nextflow.config b/examples/agents/07_module-as-tool/nextflow.config new file mode 100644 index 0000000000..07d25f7136 --- /dev/null +++ b/examples/agents/07_module-as-tool/nextflow.config @@ -0,0 +1,9 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. + +// skesa runs in its container, provisioned on-demand by Wave +wave.enabled = true +wave.strategy = 'conda,container' +docker.enabled = true diff --git a/examples/agents/08_filesystem/README.md b/examples/agents/08_filesystem/README.md new file mode 100644 index 0000000000..9484bf40e6 --- /dev/null +++ b/examples/agents/08_filesystem/README.md @@ -0,0 +1,88 @@ +# filesystem — the `fs:` tool family alongside `nf:module_run` + +An agent that runs a tool, then **writes and reads files** in its own sandboxed +work directory. No tool image, no input data. + +## Purpose / What it demonstrates + +This example adds the second built-in tool family, **`fs:`**, and shows it +working together with `nf:module_run`: + +```groovy +tools 'nf:module_run', 'fs:*' +``` + +`'fs:*'` selects the whole family — the six sandboxed file tools `read`, +`write`, `edit`, `ls`, `grep` and `find` — scoped to its **per-invocation work +directory** (plus the output paths of any modules it ran). The agent can persist +intermediate artifacts, re-read them, and reference their paths in its answer, +without ever escaping the sandbox. Naming the leaves individually +(`'fs:read', 'fs:write'`) hands over fewer. + +The tool side needs nothing: the only "module" is a trivial `exec:` process +(`word_stats`), so no tool image and no input data are required — it is a +self-contained way to see the filesystem tools in action. The agent task itself +runs in the `pi` runner image, as every agent does. + +## How it works + +1. **The `word_stats` process** takes `text: String` and returns a small JSON + string `{"words":N,"chars":M}` (an `exec:` block). Returning JSON means the + agent can read the numbers straight out of the tool result. + +2. **The `analyst` agent** declares both families and is given a + step-by-step `instruction`: + 1. call the `word_stats` tool to get the counts; + 2. **write** them as `report.json` in the work directory (the `write` tool); + 3. **read** `report.json` back to confirm it is there (the `read` tool); + 4. reply with a one-line summary that includes the absolute report path. + +3. **The sandbox:** the `fs:` writes land in the agent's work dir, so the + final answer can quote a real, existing absolute path. Writes outside the + sandbox are rejected. + +4. **Plain output:** as with any tool-using agent, the output is a plain + `summary: String` (the model's reply), not a record. + +## Key concepts + +| Concept | In this example | +|---|---| +| `'fs:*'` family | Sandboxed `read`, `write`, `edit`, `ls`, `grep`, `find` | +| Work-dir sandbox | Files live in the agent's per-invocation work dir | +| Combining families | `tools 'nf:module_run', 'fs:*'` together | +| JSON tool output | `word_stats` returns JSON the model reads directly | +| No tool image | `exec:` process — no tool container, no input data | + +## Running it + +**Requirements:** an OpenAI API key, the `nf-agent-pi` plugin, and the container +engine plus `pi` runner image every agent task needs (see +[the examples README](../README.md#requirements)). No data file. + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output (path varies): + +``` +RESULT: Stats: 9 words, 43 chars. Report written to /…/work//report.json +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. + +## Resume + +The agent caches like any other. Its sandbox reaches only the task work directory +and the files `nf:module_run` produced during the invocation — both already covered by +the resume key — so a second run replays the stored generation with no model call: + +```bash +nextflow run main.nf -resume +[SUCCESS] completed=0 failed=0 cached=2 +``` + +Add `agent.cache = false` to force a fresh generation each run. diff --git a/examples/agents/08_filesystem/main.nf b/examples/agents/08_filesystem/main.nf new file mode 100644 index 0000000000..87242ecd2e --- /dev/null +++ b/examples/agents/08_filesystem/main.nf @@ -0,0 +1,50 @@ +nextflow.enable.types = true + +// Demonstrates two tool families together (`tools 'nf:module_run', 'fs:*'`): +// the agent runs the local `word_stats` tool, then writes/reads a report file in its +// sandboxed work dir. No data is fetched; the tool is a local `exec:` process, while the +// agent task itself runs in the pi runner image. See README.md. + +// Counts words/chars; returns a small JSON string the agent reads from the tool result. +process word_stats { + input: + text: String + output: + stats: String + exec: + def words = text.trim().split(/\s+/).length + def chars = text.length() + stats = "{\"words\":${words},\"chars\":${chars}}" +} + +agent analyst { + model 'openai/gpt-5-mini' + instruction '''\ + You analyse text. Work step by step: + 1. Call the word_stats tool to get the word and character counts. + 2. Write the statistics as a JSON file named "report.json" in the work directory + using the `write` tool. + 3. Read "report.json" back using the `read` tool to confirm it is there. + 4. Reply: "Stats: words, chars. Report written to ." + '''.stripIndent() + + tools 'nf:module_run', 'fs:*' + + input: + text: String + output: + summary: String + + prompt: + """ + Analyse the following text: + ${text} + """ +} + +workflow { + analyst(channel.of( + 'The quick brown fox jumps over the lazy dog' + )) + .view { s -> "RESULT: ${s}" } +} diff --git a/examples/agents/08_filesystem/nextflow.config b/examples/agents/08_filesystem/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/08_filesystem/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/09_goal-directed/README.md b/examples/agents/09_goal-directed/README.md new file mode 100644 index 0000000000..3b2ca9b3e7 --- /dev/null +++ b/examples/agents/09_goal-directed/README.md @@ -0,0 +1,99 @@ +# goal-directed — let the agent plan the steps from a `goal` + +You state an objective; the agent works out *which* tools to run and *in what +order* to achieve it. A declarative `goal` instead of an imperative recipe. + +## Purpose / What it demonstrates + +The earlier tool examples tell the agent exactly what to do. This one does the +opposite: it hands the agent a **`goal`** — a high-level statement of the +outcome — and lets the model figure out the plan. + +`goal` is a directive, distinct from `instruction`. `instruction` fixes the +agent's role and constraints ("you are a QC assistant; never guess a number"); +`goal` states the objective to reach ("assess whether this assembly meets the +quality bar"). The objective is folded into the system message and the model +pursues it across as many turns as it needs, deciding each tool call from what +it has learned so far. The prompt deliberately contains **no step list and names +no tools** — the model discovers the available tools (and their input schemas) +from `nf:module_run` and assembles the plan itself. + +Given the goal "assess the assembly quality," the model arrives at this plan on +its own: + +1. assemble the reads with the **SKESA** tool → contigs; +2. compute statistics on those contigs with the **ASSEMBLYSCAN** tool; +3. read the N50 and contig count and compare them to the quality bar (N50 ≥ 500); +4. report a verdict. + +The interesting part is step 1 → 2: the model takes SKESA's output and feeds it +as ASSEMBLYSCAN's input. That data dependency is reasoned out at run time, not +wired in code. + +## Composition vs. iteration + +This example **composes distinct tools** — each module runs once and the model +reasons over the results. It does **not** re-run a tool with adjusted parameters +to drive a metric. That stricter "same tool, many runs, converge on an optimum" +pattern is a different thing, shown in +[`convergence-loop`](../10_convergence-loop) and [`contig-filter`](../11_contig-filter). +Keeping the two ideas separate is exactly why this example is named +*goal-directed* rather than a "loop." + +## A note on the `fs:` tools + +The agent declares `tools 'nf:module_run', 'fs:*'`, but in this run it does +not actually need a file read: ASSEMBLYSCAN produces a *small* stats file, and +Nextflow **inlines small text/JSON tool outputs into the tool result**, so the +model reads N50 and the contig count straight from ASSEMBLYSCAN's return value. +The `fs:` family is there for persisting artifacts; this particular +task simply doesn't exercise it. + +## How it works + +- **`Sample`** carries `sample_id` and `reads` (a FASTQ path handle). +- **The `qc` agent** declares `model`, a role-only `instruction`, a `goal` + describing the objective, `tools 'nf:module_run', 'fs:*'`, and + `maxIterations 15` as a safety cap. Its output is a plain `report: String` + (the model's free-text verdict). +- `SKESA` and `ASSEMBLYSCAN` are `include`d, so `nf:module_run` advertises each as + its own tool with a registry-derived input schema. +- In a verified run the model called `SKESA`, then `ASSEMBLYSCAN` on the + resulting contigs, and reported that the assembly (~N50 = 310, ~6 contigs) + does not meet the N50 ≥ 500 bar — the correct outcome for the small sarscov2 + test set. + +## Key concepts + +| Concept | In this example | +|---|---| +| `goal` directive | A declared objective folded into the system message (advisory) | +| `goal` vs `instruction` | Outcome to reach vs role/constraints | +| Model-planned steps | The tool sequence isn't scripted; the model derives it | +| Data dependency at run time | SKESA's output becomes ASSEMBLYSCAN's input | +| Small outputs inlined | Stats are read from the tool result, not via a file read | +| `maxIterations` | Hard cap on how many turns the agent may take | + +## Running it + +**Requirements:** an OpenAI API key, the `nf-agent-pi` plugin, a container runtime +(Docker/Wave), and an input FASTQ at `data/sample.fastq` (the `data/` dir is +gitignored): + +```bash +mkdir -p data +curl -sL https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/sarscov2/illumina/fastq/sarscov2_mus-musculus.fastq.gz \ + | gunzip > data/sample.fastq + +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output (the model's free-text verdict): + +``` +QC=… N50 ≈ 310, contigs ≈ 6 — does NOT meet the quality bar (N50 ≥ 500 bp). +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. diff --git a/examples/agents/09_goal-directed/data b/examples/agents/09_goal-directed/data new file mode 120000 index 0000000000..e67b455909 --- /dev/null +++ b/examples/agents/09_goal-directed/data @@ -0,0 +1 @@ +../../data \ No newline at end of file diff --git a/examples/agents/09_goal-directed/main.nf b/examples/agents/09_goal-directed/main.nf new file mode 100644 index 0000000000..01ed8e0c92 --- /dev/null +++ b/examples/agents/09_goal-directed/main.nf @@ -0,0 +1,73 @@ +nextflow.enable.types = true + +// The agent's input record. +record Sample { + sample_id: String + reads: String // absolute path to a FASTQ file (an opaque path handle) +} + +// Include the nf-core modules; `nf:module_run` surfaces each as a tool (SKESA, ASSEMBLYSCAN). +include { SKESA } from 'nf-core/skesa' +include { ASSEMBLYSCAN } from 'nf-core/assemblyscan' + +// Goal-directed = DECLARATIVE PLANNING: you state the objective, not the steps. +// From a high-level `goal` (no step list) the model plans the tool sequence +// itself — here assemble (SKESA) → assembly-stats (ASSEMBLYSCAN) → judge against +// an N50 bar. `goal` does NOT create iteration; the tool sequence here is a fixed +// linear chain (each tool called once). For goal-SEEKING iteration — re-running a +// tool and refining until a metric converges — see `convergence-loop` / +// `contig-filter`. See README.md. +agent qc { + model 'openai/gpt-5-mini' + + // `instruction` = the role + constraints (HOW the agent may act). It does + // NOT list the steps and does NOT name any tool's input fields — those come + // from the goal and the registry-derived tool schemas. + instruction '''\ + You are a genome-assembly QC assistant. Use the available tools to do + the work; pick whichever tools fit each step. When a tool returns a path + to a results file, read that file to obtain the actual numbers. Base + every metric on tool output — never guess a value. + '''.stripIndent() + + // `goal` = the OBJECTIVE that drives the multi-turn loop. The model keeps + // calling tools until it has what the goal asks for, then stops. + goal '''\ + Assemble the provided sequencing reads into contigs, compute the + assembly quality statistics (N50 and the number of contigs) from those + contigs, then report whether the assembly meets the quality bar of + N50 >= 500 bp. Include the N50, the contig count, and the absolute path + to the contigs FASTA in your final answer. + '''.stripIndent() + + tools 'nf:module_run', 'fs:*' + + input: + sample: Sample + output: + report: String // tools => plain (non-record) output; the LLM's final report + + prompt: + """ + Assess the assembly quality for sample '${sample.sample_id}'. + The sequencing reads (FASTQ) are at: ${sample.reads} + """ +} + +workflow { + // NOTE: this example needs a FASTQ at `data/sample.fastq`. `data` is a symlink to + // `examples/data/`, which is shared by every example needing input and fetched once -- + // see examples/data/README.md for the command. + // The nf-core modules run in containers, so Docker + Wave (or another + // container runtime) is also required. + // + // EXPECTED with the sarscov2 test FASTQ: it assembles to ~N50=310, ~6 + // contigs, which is BELOW the N50>=500 bar, so the agent reports the + // assembly does NOT meet the quality bar. That is the correct, deterministic + // outcome for this small viral read set — the point of the demo is the + // goal-directed loop, not the verdict. + qc(channel.of( + record(sample_id: 'sample1', reads: "${projectDir}/data/sample.fastq") + )) + .view { r -> "QC=${r}" } +} diff --git a/examples/agents/09_goal-directed/nextflow.config b/examples/agents/09_goal-directed/nextflow.config new file mode 100644 index 0000000000..f5463c1a3c --- /dev/null +++ b/examples/agents/09_goal-directed/nextflow.config @@ -0,0 +1,10 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. + +// the nf-core tools (skesa, assemblyscan, prokka) run in their containers, +// provisioned on-demand by Wave +wave.enabled = true +wave.strategy = 'conda,container' +docker.enabled = true diff --git a/examples/agents/10_convergence-loop/README.md b/examples/agents/10_convergence-loop/README.md new file mode 100644 index 0000000000..9b3ae97386 --- /dev/null +++ b/examples/agents/10_convergence-loop/README.md @@ -0,0 +1,83 @@ +# convergence-loop — a true convergence loop (same tool, many runs) + +An agent that re-runs the **same** tool many times, varying one parameter and +reading the metric each time, until it converges on the optimum. No tool image, +no data fetch. + +## Purpose / What it demonstrates + +This is a *loop* in the strict sense — not tool composition (different tools +chained once, as in [`goal-directed`](../09_goal-directed)) but **iterated execution of one +module to optimise a result**. The agent runs the `score_threshold` tool over +and over, each time with a different `threshold`, reads the F1 it returns, and +steers toward the value that maximises F1: a genuine coarse-scan-then-refine +search where each next guess depends on the previous result. + +The key enabling fact: **iterative tuning needs no core change today** because +the tunable knob is a *declared process input*. `nf:module_run` feeds declared +inputs per call, so the agent can vary `threshold` on every invocation. (Tuning +a *stock* nf-core module's hidden `task.ext.args` would instead require a planned +core feature — surfacing `ext.args` in the tool schema — and is out of scope +here.) + +The task is a real one: choosing the score cutoff that maximises the **F1** of a +binary filter against a truth-labelled set — a standard "operating point" +decision (e.g. a variant or quality-score threshold). The F1-vs-threshold curve +has a single interior peak the model cannot know without running the tool, so it +*must* iterate to find it. + +## How it works + +1. **The `score_threshold` process** reads a fixed, committed labelled dataset + (`scores.txt`, columns `score label`) and computes precision / recall / F1 of + the rule `score >= threshold`, returning a one-line string. It is a local + `exec:` process — no container of its own. + +2. **Fixed dataset, single knob.** The dataset path is a config value + (`params.scores`), **not** an agent input, so every tool call is just + `{"threshold": }`. That keeps the loop a clean single-parameter search and + removes any chance of the model mangling a long constant path across dozens of + calls. (A local process tool supports only scalar inputs, so the knob is a + `BigDecimal` — the JSON number type.) + +3. **The `tuner` agent** has a role-only `instruction` ("the tool is the only + way to learn F1 — never guess; scan coarsely then refine") and a `goal` ("find + the threshold that maximises F1; converge on the best; report it"), with + `maxIterations 30`. + +4. **Verified convergence:** in a real run the model made **32 calls** — a + coarse scan `0.0 → 1.0`, then refinement around `0.45–0.56`, then fine steps + `0.472 … 0.484` — converging on **threshold 0.48, F1 = 0.8889** (the true + optimum), with zero failures. + +## Key concepts + +| Concept | In this example | +|---|---| +| Convergence loop | The same tool run many times, converging on an optimum | +| Loop ≠ composition | Iteration of one tool vs chaining different tools once | +| Declared-input knob | `threshold` is a process input ⇒ tunable per call, no core change | +| Fixed dataset | `params.scores` (not an agent input) ⇒ tool call is just `{threshold}` | +| Scalar-only local tool | Knob is `BigDecimal`; result is a `String` | +| The `ext.args` limit | Stock-module hidden params would need a core feature (see `docs/agent.mdx`) | + +## Running it + +**Requirements:** an OpenAI API key, the `nf-agent-pi` plugin, and the container +engine plus `pi` runner image every agent task needs (see +[the examples README](../README.md#requirements)). The tool needs no image and +there is no data fetch — `scores.txt` is committed alongside the example. + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output (the model's report): + +``` +OPTIMAL=Optimal threshold = 0.48 — precision 0.80, recall 1.00, F1 0.8889 +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. diff --git a/examples/agents/10_convergence-loop/main.nf b/examples/agents/10_convergence-loop/main.nf new file mode 100644 index 0000000000..8de1dc30c8 --- /dev/null +++ b/examples/agents/10_convergence-loop/main.nf @@ -0,0 +1,75 @@ +nextflow.enable.types = true + +// Convergence loop: the agent re-runs the SAME tool (score_threshold) many times, +// varying `threshold`, reading the F1 each call, converging on the optimum. The knob +// is a declared input; the dataset path is fixed (params.scores) so each tool call is +// just {"threshold": }. No data is fetched and the tool needs no image of its own; the +// agent task itself runs in the pi runner image. See README.md. + +// Tool `score_threshold`: precision/recall/F1 of `score >= threshold` on the dataset. +process score_threshold { + input: + threshold: BigDecimal + output: + result: String + exec: + def tp = 0 + def fp = 0 + def fn = 0 + def t = threshold as Float + new File(params.scores as String).eachLine { line, n -> + if( n == 1 ) return // skip header + def parts = line.trim().split(/\s+/) + if( parts.size() < 2 ) return + def score = parts[0] as Float + def label = parts[1] as Integer + def pred = score >= t ? 1 : 0 + if( pred == 1 && label == 1 ) { tp = tp + 1 } + if( pred == 1 && label == 0 ) { fp = fp + 1 } + if( pred == 0 && label == 1 ) { fn = fn + 1 } + } + def prec = (tp + fp) > 0 ? (tp / (tp + fp)) : 0.0 + def rec = (tp + fn) > 0 ? (tp / (tp + fn)) : 0.0 + def f1 = (prec + rec) > 0 ? (2 * prec * rec / (prec + rec)) : 0.0 + result = String.format('threshold=%.3f tp=%d fp=%d fn=%d precision=%.4f recall=%.4f f1=%.4f', t, tp, fp, fn, prec as Float, rec as Float, f1 as Float) +} + +agent tuner { + model 'openai/gpt-5-mini' + + // role/constraints only — no tool-field structure, no fixed step list. + instruction '''\ + You optimise a decision threshold. The score_threshold tool is the ONLY + way to learn the F1 for a given threshold — never guess F1, always call + the tool. Search efficiently: scan the range coarsely, then refine around + the best value until further changes do not improve F1. + '''.stripIndent() + + // the OBJECTIVE that drives the convergence loop. + goal '''\ + Find the score threshold in [0, 1] that MAXIMISES F1. Call score_threshold + with different thresholds, read the F1 it returns, and converge on the + best one. Report the optimal threshold and its F1 (with precision and + recall). + '''.stripIndent() + + tools 'nf:module_run' + maxIterations 30 + + input: + request: String + output: + report: String + + prompt: + """ + ${request} + """ +} + +workflow { + // The dataset (params.scores, default below) is committed alongside this + // example — small, self-contained, no network and no image for the tool. + tuner(channel.of('Find the score threshold that maximises F1 on the labelled dataset.')) + .view { r -> "OPTIMAL=${r}" } +} diff --git a/examples/agents/10_convergence-loop/nextflow.config b/examples/agents/10_convergence-loop/nextflow.config new file mode 100644 index 0000000000..0c4d601f09 --- /dev/null +++ b/examples/agents/10_convergence-loop/nextflow.config @@ -0,0 +1,12 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true + +// The labelled dataset read by the `score_threshold` tool. Fixed here (not an +// agent input) so every tool call is just {"threshold": }. +params.scores = "${projectDir}/scores.txt" + +// The `score_threshold` tool itself needs no image: it is a local `exec:` process that +// reads the dataset and computes F1 in-process. Only the agent task is containerized. diff --git a/examples/agents/10_convergence-loop/scores.txt b/examples/agents/10_convergence-loop/scores.txt new file mode 100644 index 0000000000..859e48ed75 --- /dev/null +++ b/examples/agents/10_convergence-loop/scores.txt @@ -0,0 +1,25 @@ +score label +0.95 1 +0.92 1 +0.88 1 +0.85 1 +0.80 1 +0.78 1 +0.72 1 +0.68 1 +0.62 1 +0.58 1 +0.52 1 +0.48 1 +0.45 0 +0.42 0 +0.38 0 +0.35 0 +0.30 0 +0.28 0 +0.22 0 +0.18 0 +0.55 0 +0.60 0 +0.50 0 +0.40 0 diff --git a/examples/agents/11_contig-filter/README.md b/examples/agents/11_contig-filter/README.md new file mode 100644 index 0000000000..dc5b353062 --- /dev/null +++ b/examples/agents/11_contig-filter/README.md @@ -0,0 +1,103 @@ +# contig-filter — a convergence loop driving real nf-core modules + +This example shows an agent running a **convergence loop** over **real nf-core +modules**: it re-runs the *same* tools many times, varying one parameter and +reading the metric each round, to converge on an optimum. + +It is the bioinformatics counterpart to [`convergence-loop`](../10_convergence-loop) +(the same loop pattern on a static local tool that needs no image) — here the tuned +parameter is a **declared input of a stock nf-core module**, so the loop turns a +knob on a real containerized tool. + +## The real-world task + +Sequencing usually produces **more reads than an assembly needs**. Deeper data +costs money and compute, and past a point adds little assembly quality. So a +practical question is **depth right-sizing**: how few reads can you subsample and +still get a good assembly? + +Quality is measured by **N50**: the contig length at which half the total +assembly sits in contigs that long or longer — higher N50 means a less +fragmented, "better" assembly. Subsample too aggressively and the assembly +fragments (N50 falls); subsample too little and you waste data. So the example +poses an optimization: **what is the smallest read subsample whose assembly still +clears an N50 bar?** + +## What the agent does + +Driven only by the `goal` (no scripted steps), for each candidate subsample size +the model: + +1. **Subsamples** — calls the `SEQTK_SAMPLE` tool (`{meta, reads, sample_size}`) + with a `sample_size` fraction → a smaller FASTQ. A stock nf-core module in a + container; `sample_size` is a **declared input**, so `nf:module_run` exposes it + as a tunable tool parameter. +2. **Assembles** — calls the `SKESA` tool (`{meta, fastq}`) on the subsample → a + contigs FASTA. Also a real nf-core module in a container. +3. **Measures** — calls `assembly_stats(contigs)` (a cheap local `exec:` process + in `main.nf`) → the **N50, total length, and contig count**. +4. **Steers the search** — reads the N50 from each round and picks the next + `sample_size` (a coarse scan, then refinement), **converging** on the + *smallest* subsample whose assembly still has N50 ≥ 300 bp. +5. **Reports** the optimal `sample_size`, the resulting N50, contig count, and the + path to the contigs. + +**Steps 1–4 are the convergence loop**: the *same* tools (`SEQTK_SAMPLE` + +`SKESA`) executed many times, the agent driving `sample_size` from the metric the +previous round returned — unlike [`goal-directed`](../09_goal-directed), which +chains *different* tools once (tool composition, not iteration). + +## Example run + +On the sarscov2 test FASTQ the agent binary-searched the subsample fraction, +running `SEQTK_SAMPLE → SKESA → assembly_stats` each round: + +``` +fraction → N50: 0.50 → 287 0.75 → 354 0.625 → 287 0.6875 → 364 0.65625 → 364 +``` + +> 0.65625 is the smallest tested fraction that still yields N50 ≥ 300 bp. + +``` +Optimal subsample fraction: 0.65625 (65.6%) +N50: 364 bp Contigs: 7 Total length: 2424 bp +``` + +Exact numbers depend on the read set and the seqtk seed; the point of the demo is +the loop — real nf-core modules (`SEQTK_SAMPLE`, `SKESA`) invoked many times with +a varying declared input — not a specific verdict. On a real bacterial isolate +the depth-vs-N50 curve is smooth and the right-sizing is genuinely meaningful — +same code, richer data. + +## Two design notes + +- **Why it needs no core change.** The tunable knob `sample_size` is a **declared + input** of the stock `SEQTK_SAMPLE` module, and `nf:module_run` feeds declared + inputs per call — no wrapper needed. What the agent still **cannot** tune are a + module's *flag-only* parameters (SKESA's k-mer size, seqtk's seed, etc.): those + live in Nextflow's `task.ext.args`, which is not part of the tool schema. + Surfacing `ext.args` in the schema is a planned follow-up. +- **Why `assembly_stats` is local.** N50 could come from an nf-core module + (`assemblyscan`, as in [`goal-directed`](../09_goal-directed)), but that would + spin a third container every iteration. Measuring is cheap and deterministic, so + it is a small local `exec:` process — the expensive containerized work + (`SEQTK_SAMPLE`, `SKESA`) is what the loop actually re-runs. + +## Running it + +Needs an OpenAI key, a container runtime (Docker/Wave for SEQTK_SAMPLE + SKESA), +and a **gzipped** input FASTQ at `data/sample.fastq.gz` (the `data/` dir is +gitignored). `SEQTK_SAMPLE` names its output after the input and emits +`*.fastq.gz`, so the input must be gzipped. Fetch the sarscov2 test dataset: + +```bash +mkdir -p data +curl -sL https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/sarscov2/illumina/fastq/sarscov2_mus-musculus.fastq.gz \ + -o data/sample.fastq.gz + +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +See the top-level [examples/agents/README.md](../README.md) for the dev-build +(run-from-repo) instructions. diff --git a/examples/agents/11_contig-filter/data b/examples/agents/11_contig-filter/data new file mode 120000 index 0000000000..e67b455909 --- /dev/null +++ b/examples/agents/11_contig-filter/data @@ -0,0 +1 @@ +../../data \ No newline at end of file diff --git a/examples/agents/11_contig-filter/main.nf b/examples/agents/11_contig-filter/main.nf new file mode 100644 index 0000000000..1e67562512 --- /dev/null +++ b/examples/agents/11_contig-filter/main.nf @@ -0,0 +1,114 @@ +nextflow.enable.types = true + +// Convergence loop that drives REAL nf-core modules many times. The agent titrates +// sequencing depth: it re-runs SEQTK_SAMPLE (subsample the reads) + SKESA (assemble) +// with a varying `sample_size`, measures the resulting N50 each round, and converges +// on the SMALLEST subsample whose assembly still clears the N50 bar. `sample_size` is +// a declared module input, so `nf:module_run` exposes it as a tunable tool parameter — +// the knob the loop turns. Contrast `convergence-loop` (a local tool). See README.md. + +include { SEQTK_SAMPLE } from 'nf-core/seqtk/sample' +include { SKESA } from 'nf-core/skesa' + +record Isolate { + sample_id: String + reads: String // absolute path to a FASTQ file (an opaque path handle) +} + +// Local tool `assembly_stats`: compute N50 and contig count from a contigs FASTA. +// (A cheap exec: step so measuring the metric doesn't spin a container each round.) +process assembly_stats { + input: + // Stays `String`: a `Path` tool input is rejected by the agent tool schema + // ("not yet supported as an agent tool"), so the agent hands the path over as text. + contigs: String + output: + result: String + exec: + def lengths = [] + def cur = 0 + // `file(...)`, not `new File(...)`: the assembler emits an `s3://`/`az://`/`gs://` + // URI whenever the run has a remote work dir, and only Nextflow's `file()` resolves + // those through the matching filesystem provider. `new File()` silently mangles a + // URI into a relative local path and fails with FileNotFoundException. + file(contigs).eachLine { line, n -> + def s = line.trim() + if( s.startsWith('>') ) { + if( cur > 0 ) { lengths.add(cur) } + cur = 0 + } + else { + cur = cur + s.length() + } + } + if( cur > 0 ) { lengths.add(cur) } + + def sorted = lengths.sort().reverse() // descending + def total = 0 + sorted.each { L -> total = total + L } + + def half = total / 2.0 + def cum = 0 + def n50 = 0 + sorted.each { L -> + cum = cum + L + if( n50 == 0 && cum >= half ) { n50 = L } + } + + result = String.format('contigs=%d total_length=%d n50=%d', + sorted.size(), total as Integer, n50 as Integer) +} + +agent optimizer { + model 'openai/gpt-5-mini' + + // role/constraints only — no tool-field structure, no fixed step list. + instruction '''\ + You right-size sequencing depth for genome assembly. Use the available + tools to subsample the reads to a given size, assemble the subsample, and + measure the assembly. The tools are the ONLY way to learn the N50 for a + given subsample — never guess, always call them. Search the subsample size + coarsely, then refine. + '''.stripIndent() + + // the OBJECTIVE that drives the convergence loop. + goal '''\ + Find the SMALLEST read subsample that still assembles well. For a candidate + subsample size (a fraction between 0 and 1 of the reads), subsample the + reads, assemble the subsample into contigs, and measure the N50. Smaller + subsamples generally give a lower (worse) N50, so binary-search the + fraction: start at 0.5, then halve or raise the search toward the smallest + fraction whose assembly still has N50 >= 300 bp. Evaluate at most 6 + fractions — do NOT scan exhaustively. Report the optimal subsample size, + the resulting N50, the contig count, and the path to the contigs FASTA. + '''.stripIndent() + + tools 'nf:module_run' + maxIterations 25 + + input: + isolate: Isolate + output: + report: String + + prompt: + """ + Right-size the sequencing depth for isolate '${isolate.sample_id}'. + Sequencing reads (FASTQ): ${isolate.reads} + """ +} + +workflow { + // NOTE: needs the GZIPPED FASTQ at `data/sample.fastq.gz` -- SEQTK_SAMPLE names its + // output after the input and emits `*.fastq.gz`, so the input must be gzipped, unlike + // the sibling examples that read `sample.fastq`. `data` is a symlink to + // `examples/data/`, which holds both forms and is fetched once -- see + // examples/data/README.md for the command. + // SEQTK_SAMPLE and SKESA run in containers, so Docker + Wave (or another + // runtime) is required — and each loop iteration runs both, so this is slower + // than the local-tool `convergence-loop`. + optimizer(channel.of( + record(sample_id: 'isolate_001', reads: "${projectDir}/data/sample.fastq.gz") + )) + .view { r -> "RESULT=${r}" } +} diff --git a/examples/agents/11_contig-filter/nextflow.config b/examples/agents/11_contig-filter/nextflow.config new file mode 100644 index 0000000000..07d25f7136 --- /dev/null +++ b/examples/agents/11_contig-filter/nextflow.config @@ -0,0 +1,9 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. + +// skesa runs in its container, provisioned on-demand by Wave +wave.enabled = true +wave.strategy = 'conda,container' +docker.enabled = true diff --git a/examples/agents/12_isolate-triage/README.md b/examples/agents/12_isolate-triage/README.md new file mode 100644 index 0000000000..885be5193d --- /dev/null +++ b/examples/agents/12_isolate-triage/README.md @@ -0,0 +1,96 @@ +# isolate-triage — a real-world adaptive triage agent (capstone) + +The most complete example: a goal-directed agent that uses three nf-core modules +plus the filesystem, with a **data-driven QC gate** and a **conditional** +annotation branch. The route through the tools is decided at run time, not +hard-wired in the DAG. + +## Purpose / What it demonstrates + +A surveillance / clinical-microbiology lab receives short reads from a bacterial +isolate. The agent is asked, in plain language, to assemble the genome, decide +whether the assembly is good enough to annotate, and only then run the +(expensive) annotation step. This pulls together everything from the earlier +examples into one adaptive pipeline: + +- **`goal` + `instruction`** drive a multi-turn loop with branching logic. +- **Three nf-core modules** (`SKESA`, `ASSEMBLYSCAN`, `PROKKA`) are `include`d + and surfaced by `nf:module_run`, each as its own registry-schema'd tool. +- **The `fs:` tools** are used to write a short triage summary JSON. +- **A QC gate decides the path:** the model reads the assembly stats and chooses + whether to annotate — the conditional is a *reasoning step*, not a hard-coded + edge in a DAG. + +### Two kinds of tool output + +This example is the clearest illustration of how module outputs reach the model: + +- **Bulk / binary artifacts** the model only forwards between tools — the SKESA + contigs, the PROKKA annotations — come back as **opaque absolute-path + handles**. The model never reads their bytes; it just passes the path on. +- **Small text/JSON outputs** — ASSEMBLYSCAN's stats — are **inlined into the + tool result**, so the model can *reason over the numbers* (N50, contig count) + and gate on them. This inlining is inferred from the output file's format and + size; you do not annotate anything for it. + +## How it works + +1. **`Isolate`** carries `sample_id`, `organism`, and `reads` (a FASTQ handle). +2. **The `triage` agent** declares `tools 'nf:module_run', 'fs:*'`, a `goal` + (assemble → QC-gate → annotate-only-if-passing → report PASS/FAIL), and an + `instruction` spelling out the QC-gate logic: + - assemble with `SKESA`; + - compute stats with `ASSEMBLYSCAN`; + - **QC gate** — if the assembly is too fragmented (**N50 < 500 bp** OR + **> 1000 contigs**) it **FAILs**: write a summary JSON via the `write` tool and + report `FAIL …`, skipping annotation; + - otherwise it **PASSes**: annotate with `PROKKA`, write the summary, and + report `PASS …` with the annotation path. + The output is a plain `verdict: String`. + +3. **With the sarscov2 test data** the assembly reaches ~N50 = 310, ~6 contigs, + which **fails** the gate — so the agent returns a `FAIL` verdict and **skips + PROKKA**. That is the correct, deterministic outcome for this small viral read + set. + +## Exercising the PASS + PROKKA branch + +To see the annotation branch run, use a real bacterial isolate FASTQ (reads that +assemble above the gate), or tighten the thresholds in the `instruction` to match +the test data. Note that **PROKKA's container is large (~6 GB)** — its first pull +takes a while, and because tool calls are serialized the run will look idle while +Docker fetches the image. + +## Key concepts + +| Concept | In this example | +|---|---| +| Adaptive branching | The QC gate is a reasoning step, not a DAG edge | +| `goal` + `instruction` | Objective plus the gate logic | +| Three module tools | `SKESA`, `ASSEMBLYSCAN`, `PROKKA` via `nf:module_run` | +| Path handles vs inlined output | Bulk artifacts are paths; small stats are inlined | +| `fs:*` for artifacts | Writes the triage summary JSON to the sandbox | +| Plain output | `verdict: String` (this example emits the model's text; tools *can* also return a record — see `tool-structured/`) | + +## Running it + +**Requirements:** an OpenAI API key, the `nf-agent-pi` plugin, a container runtime +(Docker/Wave), and an input FASTQ at `data/sample.fastq` (gitignored): + +```bash +mkdir -p data +curl -sL https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/sarscov2/illumina/fastq/sarscov2_mus-musculus.fastq.gz \ + | gunzip > data/sample.fastq + +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected output with the sarscov2 test data: + +``` +TRIAGE: FAIL isolate_001: fragmented (N50=310, contigs=6), needs manual review +``` + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. diff --git a/examples/agents/12_isolate-triage/data b/examples/agents/12_isolate-triage/data new file mode 120000 index 0000000000..e67b455909 --- /dev/null +++ b/examples/agents/12_isolate-triage/data @@ -0,0 +1 @@ +../../data \ No newline at end of file diff --git a/examples/agents/12_isolate-triage/main.nf b/examples/agents/12_isolate-triage/main.nf new file mode 100644 index 0000000000..40910451ed --- /dev/null +++ b/examples/agents/12_isolate-triage/main.nf @@ -0,0 +1,75 @@ +nextflow.enable.types = true + +// Real-world adaptive agent: assemble (SKESA) → QC stats (ASSEMBLYSCAN) → a +// data-driven QC gate → annotate (PROKKA) only if it passes, writing a summary via the +// `fs:` tools. The route through the tools is decided at run time, and +// ASSEMBLYSCAN's small JSON stats are inlined into the tool result so the model can gate +// on N50/#contigs. See README.md. +include { SKESA } from 'nf-core/skesa' +include { ASSEMBLYSCAN } from 'nf-core/assemblyscan' +include { PROKKA } from 'nf-core/prokka' + +record Isolate { + sample_id: String + organism: String // best-guess species, e.g. "Escherichia coli" + reads: String // path to the short-read FASTQ (a handle for the tools) +} + +agent triage { + model 'openai/gpt-5-mini' + // `goal` = high-level objective, folded into the system message (advisory). + goal 'Assemble the isolate, QC-gate the assembly, annotate only if it passes, and report a clear PASS/FAIL verdict with a short written summary.' + instruction '''\ + You triage bacterial isolate assemblies. Work step by step: + 1. Assemble the isolate's sequencing reads into contigs using the SKESA tool. + 2. Compute assembly QC statistics from the contigs using the ASSEMBLYSCAN tool. + 3. QC GATE: read the assembly statistics and decide. If the assembly is + too fragmented — N50 below 500 bp OR more than 1000 contigs — it + FAILS QC: write a brief summary JSON to the work directory using the + `write` tool, then reply + "FAIL : fragmented (N50=, contigs=), needs manual review" + and DO NOT annotate. + 4. Otherwise the assembly PASSES QC: annotate the contigs using the PROKKA tool, + write a brief summary JSON to the work directory using the `write` tool, + then reply + "PASS : N50=, contigs=, annotation=". + '''.stripIndent() + + // `nf:module_run` discovers all included modules (skesa, assemblyscan, prokka) + // and surfaces EACH as its OWN tool (SKESA, ASSEMBLYSCAN, PROKKA) with an + // enforced input schema. + // `fs:*` enables the sandboxed file tools — read, write, edit, ls, grep and find — + // in the agent work dir. + tools 'nf:module_run', 'fs:*' + + input: + isolate: Isolate + output: + verdict: String // tools => plain (non-record) output; the LLM's final report + + prompt: + """ + Triage isolate '${isolate.sample_id}' (${isolate.organism}). + Short reads: ${isolate.reads} + """ +} + +workflow { + // NOTE: this example needs a FASTQ at `data/sample.fastq`. `data` is a symlink to + // `examples/data/`, which is shared by every example needing input and fetched once -- + // see examples/data/README.md for the command. + // The nf-core modules run in containers, so Docker + Wave (or another container + // runtime) is also required. + // + // EXPECTED BEHAVIOUR with the sarscov2 test FASTQ: the assembly assembles to + // ~N50=310, ~6 contigs — which FAILS the lenient gate (N50 < 500), so the agent + // returns a FAIL verdict and skips PROKKA annotation. That is the correct and + // intended gate behaviour for this small viral read set. To exercise the PASS + + // PROKKA branch, use a real bacterial isolate FASTQ or adjust the N50/contig + // thresholds in the `instruction` above. + triage(channel.of( + record(sample_id: 'isolate_001', organism: 'Escherichia coli', + reads: "${projectDir}/data/sample.fastq") + )) + .view { v -> "TRIAGE: ${v}" } +} diff --git a/examples/agents/12_isolate-triage/nextflow.config b/examples/agents/12_isolate-triage/nextflow.config new file mode 100644 index 0000000000..f5463c1a3c --- /dev/null +++ b/examples/agents/12_isolate-triage/nextflow.config @@ -0,0 +1,10 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. + +// the nf-core tools (skesa, assemblyscan, prokka) run in their containers, +// provisioned on-demand by Wave +wave.enabled = true +wave.strategy = 'conda,container' +docker.enabled = true diff --git a/examples/agents/13_samplesheet-builder/.gitignore b/examples/agents/13_samplesheet-builder/.gitignore new file mode 100644 index 0000000000..a14704f9ed --- /dev/null +++ b/examples/agents/13_samplesheet-builder/.gitignore @@ -0,0 +1,2 @@ +# Generated by the workflow +samplesheet.csv diff --git a/examples/agents/13_samplesheet-builder/README.md b/examples/agents/13_samplesheet-builder/README.md new file mode 100644 index 0000000000..6d70fe421c --- /dev/null +++ b/examples/agents/13_samplesheet-builder/README.md @@ -0,0 +1,106 @@ +# samplesheet-builder — agentic map-reduce over public metadata + +Build a valid FASTQ samplesheet from messy public sequencing metadata, using the +classic **map-reduce** shape where only the reasoning steps are agents and +everything else is plain Nextflow. + +## Purpose / What it demonstrates + +This is the `agent` primitive used as the **map** and **reduce** nodes of a +map-reduce pipeline (the pattern from Devin's *agentic map-reduce*, which is a +natural fit for Nextflow's dataflow model): + +``` +accessions ──► ENA_FETCH ──► normalize ──► collect() ──► consolidate ──► samplesheet.csv + queue SHARD MAP gather REDUCE + (process) (agent) (agent) + deterministic agentic agentic +``` + +- **SHARD is deterministic.** `ENA_FETCH` is an ordinary Nextflow process that + pulls one run's raw metadata from the ENA `filereport` API. The channel is the + work queue; the process fans out one task per accession. No LLM. +- **MAP is agentic.** `normalize` turns one run's *messy* metadata into one clean + samplesheet candidate. This is real judgment, not regex: it decides + single- vs paired-end, picks the correct `_1`/`_2` mate files out of ENA's + semicolon-joined URL list (ENA sometimes lists an extra **orphan** FASTQ that + must be dropped), and derives a filesystem-safe sample name from a free-text + title. +- **REDUCE is agentic.** `consolidate` receives *all* candidates at once (via + `collect()`) and reconciles them: it groups runs that belong to the same + biological sample under one consistent `sample` name (so nf-core merges them as + technical replicates), guarantees unique names, and flags conflicts. + +The point is the division of labour: Nextflow already provides the deterministic +scaffolding that agentic map-reduce frameworks have to build by hand — the work +queue (channel), the shard (process fan-out), and the gather (`collect()`) — so +the agents only supply reasoning. + +## Why an agent (and not a process)? + +The MAP and REDUCE steps are exactly the parts a bioinformatician does by hand +because they resist clean rules: + +| Messy case (all present in the example accessions) | Handled by | +|---|---| +| ENA lists a stray orphan `.fastq.gz` next to `_1`/`_2` (e.g. `SRR1039513`) | MAP drops it, keeps the mates | +| Mixed single-end and paired-end runs | MAP sets `fastq_2` only when paired | +| Free-text titles → safe, consistent sample names | MAP derives the name | +| Several runs are technical replicates of one biosample | REDUCE merges them under one name | +| A sample accidentally mixing single/paired runs | REDUCE flags it in `notes` | + +## How it works + +1. **`ENA_FETCH`** (process, `exec:`) fetches the ENA filereport JSON for one + accession and emits it as a `String`. Runs locally — no container of its own. +2. **`normalize`** (agent) has a `Candidate` **record** output, so it uses + structured output: the model must return JSON matching the record's fields. + No tools, so it stays within the v1 *tools-XOR-structured* rule. +3. **`collect()`** gathers every `Candidate` into a single list — the standard + Nextflow gather — which becomes the single input of the reduce agent. +4. **`consolidate`** (agent) takes `List` and returns a `Samplesheet` + record (`csv` + `notes`). The workflow writes `csv` to `samplesheet.csv`. + +## Running it + +**Requirements:** + +- An OpenAI API key (`export OPENAI_API_KEY="sk-..."`). +- The `nf-agent-pi` plugin (declared in `nextflow.config`). +- Network access to the ENA API and OpenAI. **No local data**, and no image for the + fetch process — only the container engine and `pi` runner image the agent tasks + need (see [the examples README](../README.md#requirements)). + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +It writes `samplesheet.csv` (gitignored) and prints it plus the reduce agent's +`notes`. Expected shape (exact names/URLs vary by model run): + +``` +sample,fastq_1,fastq_2 +N61311_untreated,ftp://ftp.sra.ebi.ac.uk/.../SRR1039508_1.fastq.gz,ftp://.../SRR1039508_2.fastq.gz +... +S2_DRSC_Untreated_1,ftp://.../SRR031708.fastq.gz, +S2_DRSC_Untreated_1,ftp://.../SRR031712.fastq.gz, +``` + +(The two `S2_DRSC_Untreated_1` rows are technical replicates the REDUCE agent +merged under one sample name; the `SRR1039513` orphan file was dropped by MAP.) + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. + +## Notes / caveats + +- **The map runs in parallel.** A tool-free agent now runs as a real task, so the + `normalize` calls fan out concurrently (one `TaskRun` per run), bounded by + `maxForks` / `executor.cpus` — like the `ENA_FETCH` *shard*. Each call is also + cached, so `-resume` replays without re-calling the model. +- **Not perfectly reproducible.** ENA metadata can change upstream; the pinned + accession list keeps it stable in practice. An invalid accession makes + `ENA_FETCH` fail the run. +- **To change the cohort,** edit the `channel.of(...)` accession list in + `main.nf`. diff --git a/examples/agents/13_samplesheet-builder/main.nf b/examples/agents/13_samplesheet-builder/main.nf new file mode 100644 index 0000000000..f02fd0dfbd --- /dev/null +++ b/examples/agents/13_samplesheet-builder/main.nf @@ -0,0 +1,146 @@ +nextflow.enable.types = true + +// Agentic map-reduce: build a valid FASTQ samplesheet from messy public metadata. +// +// SHARD (deterministic) : ENA_FETCH pulls raw run metadata, one task per accession. +// MAP (agentic) : `normalize` turns each run's messy metadata into one clean +// samplesheet candidate (picks R1/R2, handles single/paired, +// derives a safe sample name). +// REDUCE (agentic) : `consolidate` gathers ALL candidates and reconciles them into +// one samplesheet — merging technical replicates, guaranteeing +// unique sample names, flagging conflicts. +// +// The deterministic scaffolding (channel = work queue, process fan-out = shard, +// `collect()` = gather) is plain Nextflow; the agents supply only the judgment. +// See README.md. + +// One normalized samplesheet row, produced by the MAP agent (structured output). +record Candidate { + sample: String // clean, filesystem-safe sample name derived from the metadata + run: String // run accession (traceability) + biosample: String // biological-sample accession (so REDUCE can merge replicates) + fastq_1: String // full ftp:// URL of R1 (or the single-end reads) + fastq_2: String // full ftp:// URL of R2, or "" for single-end + layout: String // "single" | "paired" — the agent's determination + note: String // any non-obvious choice the agent made (e.g. dropped an orphan file) +} + +// The final samplesheet, produced by the REDUCE agent (structured output). +record Samplesheet { + csv: String // full samplesheet CSV text (header + one row per run) + notes: String // human-readable summary of merges / drops / conflicts +} + +// SHARD: fetch one run's raw metadata from the ENA "filereport" API. Deterministic, +// no reasoning — runs locally (exec), no container. One task per accession (fan-out). +process ENA_FETCH { + input: + accession: String + output: + meta: String + exec: + def fields = 'run_accession,sample_accession,sample_title,library_layout,' + + 'library_strategy,library_name,scientific_name,fastq_ftp,read_count' + def url = "https://www.ebi.ac.uk/ena/portal/api/filereport" + + "?accession=${accession}&result=read_run&fields=${fields}&format=json" + meta = new URL(url).getText('UTF-8') +} + +// MAP: one run's messy ENA metadata -> one clean candidate. Structured output, no tools. +agent normalize { + model 'openai/gpt-5-mini' + instruction '''\ + You convert ONE sequencing run's raw ENA metadata into a single normalized + FASTQ samplesheet entry. The input is JSON from the ENA "filereport" API + (an array holding one run record). Work carefully: + - Determine the library layout (single- or paired-end) from the metadata. + - The fastq_ftp field lists download URLs joined by ";". For paired-end runs + ENA sometimes ALSO lists an extra unpaired/orphan file next to the _1/_2 + mates — use only the two mate files (ending _1 and _2) as fastq_1 and + fastq_2 and ignore the orphan. For single-end runs use the single file as + fastq_1 and leave fastq_2 empty. + - Return every FASTQ as a complete URL with an ftp:// scheme. + - Derive a short, human-readable, filesystem-safe sample name (letters, + digits, underscores) from the sample title/description. Name runs of the + same biological sample consistently so they can be grouped later. + - Carry the run accession and the biological-sample accession through so + downstream consolidation can merge technical replicates. + - If you drop a file or make a non-obvious choice, note it briefly. + Base every value on the metadata; never invent a URL or an accession. + '''.stripIndent() + + input: + meta: String + output: + candidate: Candidate + + prompt: + """ + Normalize this ENA run record into one samplesheet candidate: + + ${meta} + """ +} + +// REDUCE: all candidates -> one reconciled samplesheet. Structured output, no tools. +agent consolidate { + model 'openai/gpt-5-mini' + instruction '''\ + You assemble a set of normalized run entries into ONE valid nf-core-style + FASTQ samplesheet. Produce CSV with the exact header `sample,fastq_1,fastq_2` + and one row per run: + - Group runs belonging to the same biological sample (same biosample + accession, or clearly the same sample) under ONE consistent `sample` + name, so nf-core merges them as technical replicates. Give every distinct + biological sample a unique, filesystem-safe name; disambiguate collisions. + - Preserve fastq_1 and fastq_2 exactly as provided (fastq_2 empty for + single-end runs). + - Order rows stably, grouped by sample. + Put the complete CSV text in `csv`. In `notes`, briefly summarize your + decisions: which runs you merged, files dropped upstream, and any conflicts + (e.g. a sample mixing single- and paired-end runs). + '''.stripIndent() + + input: + candidates: List + output: + sheet: Samplesheet + + prompt: + """ + Assemble the samplesheet from these normalized run candidates: + + ${groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(candidates))} + """ +} + +workflow { + // Work queue: a handful of real ENA run accessions chosen to exercise the messy + // cases (edit this list to point at your own study). Drawn from two public RNA-seq + // studies on purpose: + // SRR1039508 airway (SRP033351) PAIRED, clean two-file record + // SRR1039513 airway (SRP033351) PAIRED, ENA also lists an orphan .fastq.gz to drop + // SRR031708 pasilla (SRP001537) SINGLE ) same biosample SAMN00006272 + // SRR031712 pasilla (SRP001537) SINGLE ) -> REDUCE merges as replicates + // SRR031714 pasilla (SRP001537) PAIRED + // SRR031726 pasilla (SRP001537) PAIRED ) same biosample SAMN00006277 + // SRR031727 pasilla (SRP001537) PAIRED ) -> REDUCE merges as replicates + def accessions = channel.of( + 'SRR1039508', 'SRR1039513', + 'SRR031708', 'SRR031712', 'SRR031714', 'SRR031726', 'SRR031727' + ) + + def meta = ENA_FETCH(accessions) // SHARD (parallel process fan-out) + def cands = normalize(meta) // MAP (one TaskRun per run, parallel up to executor.cpus) + + consolidate(cands.collect()).view { s -> // REDUCE (gather -> one agent call) + file("${projectDir}/samplesheet.csv").text = s.csv + """\ + Wrote ${projectDir}/samplesheet.csv + + ${s.csv} + --- notes --- + ${s.notes} + """.stripIndent() + } +} diff --git a/examples/agents/13_samplesheet-builder/nextflow.config b/examples/agents/13_samplesheet-builder/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/13_samplesheet-builder/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/14_data-labelling/.gitignore b/examples/agents/14_data-labelling/.gitignore new file mode 100644 index 0000000000..8ad44c0295 --- /dev/null +++ b/examples/agents/14_data-labelling/.gitignore @@ -0,0 +1,2 @@ +# Generated by the workflow +harmonized.tsv diff --git a/examples/agents/14_data-labelling/README.md b/examples/agents/14_data-labelling/README.md new file mode 100644 index 0000000000..6a9b09ba50 --- /dev/null +++ b/examples/agents/14_data-labelling/README.md @@ -0,0 +1,130 @@ +# data-labelling — agentic data labelling over public metadata + +Label messy public sequencing-sample metadata (from ENA/SRA/GEO) with +**controlled-vocabulary ontology terms** — organism, tissue/cell type, disease, +assay — using the classic **map-reduce** shape where only the reasoning steps are +agents and everything else is plain Nextflow. + +This is the sibling of [`samplesheet-builder/`](../13_samplesheet-builder/main.nf): +same shard → map → reduce skeleton, but the payload is *semantic labelling to an +ontology* rather than file plumbing, and the ontology is injected as a **skill**. + +## Purpose / What it demonstrates + +Data labelling is the textbook fit for the `agent` primitive: a label is a +*structured record*, you need *one label per item in parallel*, the labels must +obey *your* controlled vocabulary, and low-confidence items should be *routed to +a human*. Each of those maps onto a feature: + +``` +accessions ─► META_FETCH ─► classify ──► toSortedList() ─► audit ──► harmonized.tsv + queue SHARD MAP gather REDUCE + review queue + (process) (agent) (agent) + deterministic agentic + skill agentic +``` + +| Labelling need | Feature | Where | +|---|---|---| +| A label is structured (term + ontology ID + confidence + rationale) | **record output** → JSON-schema contract | `SampleLabels` | +| Label N samples, one per item | **map agent** (one call per run) | `classify` | +| Labels must use *your* ontology, not the model's prior | **`skills`** — a `SKILL.md` carrying the vocabulary + rules | `metadata-ontology` | +| Reconcile the cohort; split confident from review-needed | **reduce agent** over the gathered list | `audit` | +| Re-labelling a cohort is expensive | **resume** replays the map from cache | see below | + +## Why an agent (and not a script that calls an LLM)? + +The labelling itself is exactly the part that resists clean rules: + +| Messy case (all present in the example cohort) | Handled by | +|---|---| +| `library_strategy=OTHER` but the title says `ATACseq` (`SRR891268`) | MAP reads the free text → labels assay `ATAC-seq` | +| `GM12878` cell-line name → its cell type is not stated | MAP knows `GM12878` → `B lymphocyte [CL:0000236]` | +| The same cell line appears under two runs (`SRR891268`, `SRR307898`) | REDUCE unifies them to one term | +| A bare replicate title (`N61311_untreated`, `Set7KD_rep1`) evidences no tissue | MAP returns `unknown` (does not hallucinate), lowers confidence | +| Low-confidence samples must not silently enter the dataset | REDUCE puts them in a `review_queue` | + +The differentiator over a plain OpenAI call: the *structured contract*, *resumable* +execution over the cohort as first-class dataflow, and the *skill-injected +vocabulary* — all composing with the rest of a Nextflow pipeline. + +## How it works + +1. **`META_FETCH`** (process, `exec:`) fetches one accession's ENA filereport + JSON and emits it as a `String`. Runs locally — no container of its own. +2. **`classify`** (agent) has a `SampleLabels` **record** output → structured + output, and declares `skills 'metadata-ontology'`. The skill (under + `skills/metadata-ontology/SKILL.md`) carries the allowed terms, the + `term [ONTOLOGY:ID]` format, and the labelling rules; the model activates it on + demand, then ends the turn by calling `final_answer`, whose arguments are the + record. +3. **`toSortedList { it.accession }`** gathers every label into one list, ordered + by accession (stable fan-in → deterministic output + hashable reduce input). +4. **`audit`** (agent) takes the `List`, unifies equivalent terms + across the cohort, splits confident labels from a `review_queue`, and returns a + `HarmonizedCohort` (`tsv` + `review_queue` + `summary`). The workflow writes + `tsv` to `harmonized.tsv`. + +## Running it + +**Requirements:** + +- An OpenAI API key (`export OPENAI_API_KEY="sk-..."`). +- The `nf-agent-pi` plugin (declared in `nextflow.config`). +- Network access to the ENA API and OpenAI. **No local data**, and no image for the + fetch process — only the container engine and `pi` runner image the agent tasks + need (see [the examples README](../README.md#requirements)). + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +It writes `harmonized.tsv` (gitignored) and prints it plus the review queue and a +summary. Expected shape (exact terms/confidences vary by model run): + +``` +accession organism tissue disease assay confidence +SRR031708 Drosophila melanogaster [NCBITaxon:7227] unknown none [—] RNA-seq 0.75 +SRR1039508 Homo sapiens [NCBITaxon:9606] unknown unknown RNA-seq 0.50 +SRR307898 Homo sapiens [NCBITaxon:9606] B lymphocyte [CL:0000236] none [—] RNA-seq 0.75 +SRR3192396 Homo sapiens [NCBITaxon:9606] B lymphocyte [CL:0000236] none [—] RNA-seq 0.80 +SRR5150592 Homo sapiens [NCBITaxon:9606] unknown unknown RNA-seq 0.50 +SRR891268 Homo sapiens [NCBITaxon:9606] B lymphocyte [CL:0000236] none [—] ATAC-seq 0.50 +``` + +The tells that the **skill** fired: labels use the curated ontology IDs +(`CL:0000236`, `NCBITaxon:…`), `GM12878` runs resolve to `B lymphocyte`, and +`SRR891268` is labelled `ATAC-seq` from its title despite `library_strategy=OTHER`. +The **reduce** then flags `SRR5150592` (confidence 0.50) into the review queue. +Add `-with-agent-trace` to see the `activate_skill` call. + +See [examples/agents/README.md](../README.md) for the dev-build (run-from-repo) +instructions. + +## Notes / caveats + +- **The map is parallel, even though it uses a skill.** A `skills` agent lowers to + a real task (skills carry no dataflow coupling), so `classify` fans out up to + `executor.cpus` with progress rows and resume caching — exactly like a tool-free + map agent (cf. [`samplesheet-builder/`](../13_samplesheet-builder/main.nf), + [`map-reduce/`](../15_map-reduce/main.nf)). Only `tools` agents still take the legacy + serial path. +- **`-resume` caches end to end.** The map agents (`classify`, one LLM labelling + call per sample) are served from cache with **zero LLM calls** on resume. With + `classify` on the task path its output is a task, so the downstream `audit` reduce + has a stable resume hash and caches too — like [`map-reduce/`](../15_map-reduce/main.nf). + A changed `SKILL.md` invalidates the cache (skill identity is folded into the + resume key), so an updated vocabulary correctly re-runs the labelling. +- **Skills + structured output costs +1 turn and can be lossy** (the tool/skill + loop runs schema-free, then a structuring turn re-encodes to JSON). Keep the + record close to the answer and the vocabulary small — as here. +- **Confidence is the gate.** The `confidence` field lets you hard-route review + items in the workflow with a `.branch { it.confidence < 0.6 }` if you want them + handled downstream rather than only listed by the reduce. (LLMs occasionally + emit an out-of-range value; the `audit` agent flags anomalies it spots.) +- **Swap in your real ontology.** `skills/metadata-ontology/SKILL.md` is a small + illustrative subset — replace it with your institution's controlled vocabulary, + or point `skills` at a pinned remote `SKILL.md` (`github.com//@`). +- **Not perfectly reproducible.** ENA metadata can change upstream; the pinned + accession list keeps it stable in practice. An invalid accession fails the run. +- **To change the cohort,** edit the `channel.of(...)` accession list in `main.nf`. diff --git a/examples/agents/14_data-labelling/main.nf b/examples/agents/14_data-labelling/main.nf new file mode 100644 index 0000000000..8a2f5f12ca --- /dev/null +++ b/examples/agents/14_data-labelling/main.nf @@ -0,0 +1,164 @@ +nextflow.enable.types = true + +// Agentic map-reduce for DATA LABELLING: harmonize messy public sample metadata +// into controlled-vocabulary ontology labels. +// +// SHARD (deterministic) : META_FETCH pulls one run's raw metadata from ENA. +// MAP (agentic) : `classify` labels each run's free-text metadata against a +// controlled vocabulary (the `metadata-ontology` skill), +// emitting one structured label record per sample. +// REDUCE (agentic) : `audit` gathers ALL labels, unifies equivalent terms across +// the cohort, and splits confident labels from a human-review +// queue — emitting a harmonized table. +// +// The deterministic scaffolding (channel = work queue, process fan-out = shard, +// `collect()` = gather) is plain Nextflow; the agents supply only the judgment. +// The `skill` carries the ontology, so labels conform to a fixed vocabulary the model +// would not otherwise use. See README.md. + +// One sample's controlled-vocabulary labels, produced by the MAP agent (structured output). +// Each facet is `term [ONTOLOGY:ID]` or the literal `unknown` (see the skill). +record SampleLabels { + accession: String // run accession (from the metadata) — for traceability + organism: String // e.g. "Homo sapiens [NCBITaxon:9606]" + tissue: String // tissue / cell type, e.g. "B lymphocyte [CL:0000236]" or "unknown" + disease: String // e.g. "none [—]" or "unknown" + assay: String // controlled assay term, e.g. "RNA-seq", "ATAC-seq" + confidence: Float // 0..1 overall labelling confidence — the review gate keys on this + rationale: String // free-text phrases keyed on; names any facet left `unknown` +} + +// The harmonized cohort, produced by the REDUCE agent (structured output). +record HarmonizedCohort { + tsv: String // harmonized table: accession, organism, tissue, disease, assay, confidence + review_queue: String // samples/facets below the confidence floor or in conflict — for a human + summary: String // brief: how many labelled confidently, terms unified, conflicts found +} + +// SHARD: fetch one run's raw metadata from the ENA "filereport" API. Deterministic, +// no reasoning — runs as a canonical container task on any executor. One task per +// accession (fan-out). +process META_FETCH { + conda 'conda-forge::curl' + + input: + accession: String + + output: + meta: String = stdout() + + script: + def fields = 'run_accession,sample_accession,sample_title,scientific_name,tax_id,' + + 'library_strategy,library_source,library_selection,instrument_platform,read_count' + """ + curl --fail --silent --show-error --location --get \ + --data-urlencode 'accession=${accession}' \ + --data-urlencode 'result=read_run' \ + --data-urlencode 'fields=${fields}' \ + --data-urlencode 'format=json' \ + 'https://www.ebi.ac.uk/ena/portal/api/filereport' + """ +} + +// MAP: one run's messy metadata -> one controlled-vocabulary label record. +// Structured output + a `skill` (the ontology): the skill loop runs, then a final +// structuring turn encodes the answer into the SampleLabels schema. +agent classify { + model 'openai/gpt-5-mini' + instruction '''\ + You are a biomedical metadata curator. Given one sequencing run's raw metadata + (JSON from the ENA "filereport" API — an array holding one run record), assign + controlled-vocabulary labels for organism, tissue/cell type, disease, and assay. + Use the metadata-ontology skill for the allowed terms, ID format, and labelling + rules. Base every label on evidence in the metadata; when a facet is not + evidenced, label it `unknown` rather than guessing, and lower the overall + confidence accordingly. Carry the run accession through for traceability. + '''.stripIndent() + + // The ontology lives in the skill, not in this prompt — so labels conform to a fixed + // vocabulary and the same skill can be swapped for an institution's real one. + skills 'metadata-ontology' + + input: + meta: String + output: + labels: SampleLabels + + prompt: + """ + Label this sequencing run's metadata: + + ${meta} + """ +} + +// REDUCE: all label records -> one harmonized cohort + review queue. Structured, no tools. +agent audit { + model 'openai/gpt-5-mini' + instruction '''\ + You reconcile a cohort of per-sample metadata labels into one harmonized table. + - Unify labels that mean the same thing across samples (same cell line or term + written differently) so the cohort is internally consistent. + - Split the cohort: samples whose overall confidence is below 0.6, or that + disagree with an otherwise-consistent group, go into a human-review queue with + a one-line reason; the rest are considered accepted. + - Emit `tsv` as a tab-separated table with the exact header + `accession\torganism\ttissue\tdisease\tassay\tconfidence` and one row per + sample, ordered by accession. + - In `review_queue`, list each flagged sample as `accession: ` (or + "none" if every sample is confidently labelled). + - In `summary`, briefly state how many samples were accepted vs queued, which + terms you unified, and any conflicts. + '''.stripIndent() + + input: + cohort: List // fan-in of all labels, deterministically ordered (see toSortedList below) + output: + report: HarmonizedCohort + + prompt: + """ + Reconcile these per-sample labels into a harmonized cohort: + + ${groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(cohort))} + """ +} + +workflow { + // Work queue: real ENA run accessions chosen to exercise the messy cases (edit to + // point at your own study). Deliberately mixed: + // SRR3192396 human RNA-seq RICH title: "B-lymphocyte, lymphoblastoid, ...EBV" -> confident + // SRR031708 fly RNA-seq "S2_DRSC_Untreated-1" -> organism/cell-line from a name + // SRR891268 human ATAC-seq library_strategy=OTHER but title says ATACseq -> assay from free text + // SRR307898 human RNA-seq GM12878 again (same line as 891268) -> REDUCE unifies + // SRR1039508 human RNA-seq "N61311_untreated" -> tissue/disease unevidenced -> review queue + // SRR5150592 human RNA-seq "Set7KD_rep1" -> unevidenced -> review queue + def accessions = channel.of( + 'SRR3192396', 'SRR031708', 'SRR891268', + 'SRR307898', 'SRR1039508', 'SRR5150592' + ) + + def meta = META_FETCH(accessions) // SHARD (parallel process fan-out) + // MAP: even though `classify` declares `skills`, it lowers to a real parallel TaskRun (skills + // carry no dataflow coupling), fanning out up to executor.cpus with progress rows and resume + // caching — exactly like a tool-free map agent (cf. samplesheet-builder). Only `tools` agents + // still take the legacy serial path. + def labels = classify(meta) + + // Gather all labels into ONE list for the reduce. `toSortedList` (vs `collect()`) fixes the + // fan-in order by accession, so the output TSV is deterministically ordered. (With `classify` + // on the task path its output is a task, so the reduce upstream has a stable resume hash and + // caches too. The expensive map calls are what -resume saves.) + audit(labels.toSortedList { it.accession }).view { r -> // REDUCE (gather -> one agent call) + file("${projectDir}/harmonized.tsv").text = r.tsv + """\ + Wrote ${projectDir}/harmonized.tsv + + ${r.tsv} + --- review queue --- + ${r.review_queue} + --- summary --- + ${r.summary} + """.stripIndent() + } +} diff --git a/examples/agents/14_data-labelling/nextflow.config b/examples/agents/14_data-labelling/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/14_data-labelling/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/14_data-labelling/skills/metadata-ontology/SKILL.md b/examples/agents/14_data-labelling/skills/metadata-ontology/SKILL.md new file mode 100644 index 0000000000..8fc502b860 --- /dev/null +++ b/examples/agents/14_data-labelling/skills/metadata-ontology/SKILL.md @@ -0,0 +1,72 @@ +--- +name: metadata-ontology +description: Map free-text sequencing-sample metadata to controlled-vocabulary ontology terms. Use this skill whenever labelling a sample's organism, tissue/cell type, disease, or assay from its metadata. +--- +# Sample metadata → ontology labelling + +You assign controlled-vocabulary labels to a sequencing sample from its (often +messy, free-text) metadata. Follow these rules exactly. + +## Output convention + +Every facet is either a single term written as `label [ONTOLOGY:ID]`, or the +literal string `unknown` when the metadata does not evidence it. Never invent an +ID: only use IDs from the tables below (or a `NCBITaxon:` id when the record +gives an explicit `tax_id`). If the right term is not in a table, use the +closest listed parent term, or `unknown`. + +## Allowed terms (illustrative curated subset — replace with your own vocabulary) + +**Organism** (from `scientific_name` / `tax_id`): +| term | id | +|---|---| +| Homo sapiens | NCBITaxon:9606 | +| Mus musculus | NCBITaxon:10090 | +| Drosophila melanogaster | NCBITaxon:7227 | +| Saccharomyces cerevisiae | NCBITaxon:4932 | + +**Tissue / cell type** (UBERON tissues, CL cell types): +| term | id | +|---|---| +| B lymphocyte | CL:0000236 | +| epithelial cell | CL:0000066 | +| lung | UBERON:0002048 | +| blood | UBERON:0000178 | +| liver | UBERON:0002107 | +| breast | UBERON:0000310 | + +**Disease** (MONDO): +| term | id | +|---|---| +| lung adenocarcinoma | MONDO:0005097 | +| none | — | + +Use `none [—]` only when the sample is clearly a normal/healthy or engineered +non-disease sample; otherwise `unknown`. + +**Assay** (functional-genomics assay term): +| term | +|---| +| RNA-seq | +| ATAC-seq | +| ChIP-seq | +| WGS | +| WES | + +Write the assay as the bare term (no id). + +## Rules + +- **Read the free-text title, do not trust structured fields blindly.** The + `library_strategy` field is frequently `OTHER` or wrong; if the `sample_title` + says `ATAC`/`ATACseq`, the assay is `ATAC-seq` regardless of `library_strategy`. +- **Recognize common cell lines** and label their cell type: `GM12878` is a + lymphoblastoid line → `B lymphocyte [CL:0000236]`; `A549` → lung epithelial; + `S2`/`S2-DRSC` is a *Drosophila* embryonic line. +- **Set confidence to the evidence.** A facet named explicitly in the title → + high; inferred from a cell-line name → medium; not evidenced at all → output + `unknown` and lower the overall confidence. +- **Never fabricate.** A bare replicate label like `N61311_untreated` or + `Set7KD_rep1` evidences no tissue or disease — return `unknown`, not a guess. +- In the rationale, quote the exact phrase(s) you keyed on, and name any facet + you left `unknown`. diff --git a/examples/agents/15_map-reduce/README.md b/examples/agents/15_map-reduce/README.md new file mode 100644 index 0000000000..a28003758c --- /dev/null +++ b/examples/agents/15_map-reduce/README.md @@ -0,0 +1,71 @@ +# map-reduce — fully-agentic map-reduce (planner → mapper → reducer) + +The agentic map-reduce pattern where **every phase is an `agent`**, composed with +plain Nextflow channels. This is the workflow the *agent process-parity* work +unlocks: agents now run as real tasks, so the map fans out in parallel, every call +is cached/resumable, and each step appears in the progress table and lineage. + +## The shape + +``` +brief ──► planner ──► plan.flatMap{ it.shards } ──► mapper ──► collect() ──► reducer ──► report + PLAN SHARD MAP REDUCE + (agent) (deterministic channel op) (agent, parallel) (agent, fan-in) +``` + +- **PLAN** — `planner` turns one brief into a `Plan` with a list of independent + research shards (one LLM call, single value input → runs once). +- **SHARD** — `plan.flatMap { it.shards }` is ordinary Groovy on a channel: the + shards become a queue. +- **MAP** — `mapper(shards)` runs **one task per shard**, up to `maxForks` / + `executor.cpus` **in parallel**. Each is an independent, cached, resumable task. +- **REDUCE** — `reducer(findings.collect())` receives the whole `Bag` of findings + as one value input, so it fires **exactly once** (fan-in) and synthesises a + single `Report`. + +Contrast [`samplesheet-builder`](../13_samplesheet-builder/main.nf), where the shard +step is a deterministic *process* (fetching metadata); here the plan itself is +agentic. + +## Why this needs agent-as-task + +Each capability the workflow relies on is a process-parity feature the agent now has: + +| Workflow line | Capability | +|---|---| +| `mapper(shards)` over a queue | **parallel map** — one `TaskRun` per shard, concurrent | +| `reducer(findings.collect())` | **fan-in** — a `Bag`-typed value input fires the reduce once | +| `planner(...)` → `Plan{shards: List}` | **structured record output** | +| `nextflow run … -resume` | **resume** — every cached generation replays with no model call | + +An agent that only reads its declared inputs and returns records (no `tools`/`skills`) +runs on the **task path**; tool/skill agents keep the legacy behaviour. + +## Running it + +**Requirements:** an OpenAI API key; the `nf-agent-pi` plugin (in `nextflow.config`); +the container engine and `pi` runner image every agent task needs (see +[the examples README](../README.md#requirements)). No local data. + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf # planner(1) + mapper(N) + reducer(1) tasks +nextflow run main.nf -resume # every task cached — no LLM calls +nextflow run main.nf --brief 'Your own research brief here' +``` + +The single-output agents auto-unwrap to their channel, so `planner(...)` **is** the +`Plan` channel (no `.plan` accessor) — mirror this when adapting the example. + +## Notes + +- **Parallelism is configured, not free.** On the local executor, concurrency is + gated by CPU count and drops to serial on a 1-vCPU machine. `nextflow.config` + raises `executor.cpus` so the per-shard mappers run concurrently; the number of + shards is decided by the planner at run time. +- **Resume replays a stored generation** (input-keyed memoization), so it is + reproducible but can be stale if the model changes server-side. The example uses a + floating alias (no dated `gpt-5*` snapshot is available to the Pi runner yet), so the + cache key follows the alias rather than the concrete model behind it. +- **Output order over the shard queue is not preserved** (parallel tasks); each + `Finding` carries its `shardId` so results correlate by key, not position. diff --git a/examples/agents/15_map-reduce/main.nf b/examples/agents/15_map-reduce/main.nf new file mode 100644 index 0000000000..3df2ad1000 --- /dev/null +++ b/examples/agents/15_map-reduce/main.nf @@ -0,0 +1,101 @@ +nextflow.enable.types = true + +// Fully-agentic map-reduce: every phase is an `agent`, composed with plain channels. +// +// PLAN (agent) : planner breaks a brief into independent research shards (one LLM call). +// SHARD (channel): plan.plan.flatMap { it.shards } — deterministic work queue. +// MAP (agent) : mapper answers each shard — one TaskRun per shard, run in PARALLEL +// (bounded by maxForks / executor.local.cpus). +// REDUCE (agent) : reducer synthesises one report from ALL findings via collect() (fan-in). +// +// This is the shape from the process-parity design (spec §13): the agent primitive now runs as +// a real task, so the map fans out concurrently, each call is cached/resumable, and every box +// shows up in the progress table and lineage. Contrast `samplesheet-builder`, where the SHARD is +// a deterministic process; here the plan itself is agentic. See README.md. + +record Shard { id: String; question: String } +record Plan { title: String; shards: List } +record Finding { shardId: String; summary: String } +record Report { title: String; body: String } + +// PLAN — one value input -> runs once -> planner.out.plan (single record, bare schema). +agent planner { + // NOTE: a floating alias. The Pi model registry currently ships no dated `gpt-5*` + // snapshot, and every dated `gpt-4o*` id it does know returns HTTP 500 from OpenAI's + // Responses API (the only API the Pi runner uses). Pin a dated snapshot here once one + // is available: the cache key follows the alias, so a server-side model upgrade makes + // `-resume` replay a generation the current model would not produce. + model 'openai/gpt-5-mini' + instruction '''\ + You decompose a research brief into a small set of INDEPENDENT sub-questions + ("shards") that can be investigated in parallel without depending on each other. + Give each shard a short stable id and one focused question. Prefer 3-5 shards. + '''.stripIndent() + + input: + brief: String + output: + plan: Plan + + prompt: + """ + Break this brief into independent research shards: + + ${brief} + """ +} + +// MAP — one shard per invocation -> one TaskRun each (parallel). +agent mapper { + model 'openai/gpt-5-mini' + instruction 'You are a focused researcher. Answer the single question concisely and factually, and echo the shard id you were given so results can be correlated.' + + input: + shard: Shard + output: + finding: Finding + + prompt: + """ + Shard id: ${shard.id} + Answer this shard question: + + ${shard.question} + """ +} + +// REDUCE — the whole bag of findings as ONE value input -> fires once (fan-in). +agent reducer { + model 'openai/gpt-5-mini' + instruction 'You synthesise many independent findings into one coherent, non-redundant report.' + + input: + findings: Bag // `collect()` yields a Bag (order-independent) — fan-in of all findings + output: + report: Report + + prompt: + """ + Synthesise ONE report from these findings: + + ${findings.collect { "- (${it.shardId}) ${it.summary}" }.join('\n')} + """ +} + +workflow { + def brief = params.brief // default in nextflow.config; override with --brief '...' + + // A single-output agent auto-unwraps to its output channel under the typed DSL, + // so `planner(...)` IS the channel of Plan records (no `.plan` accessor needed). + def plan = planner(channel.of(brief)) // PLAN (agentic -> channel) + def shards = plan.flatMap { it.shards } // SHARD (deterministic work queue -> channel) + def findings = mapper(shards) // MAP (one TaskRun per shard, parallel -> channel) + + reducer(findings.collect()) // REDUCE (collect() -> one value -> single TaskRun) + .view { r -> + """\ + ===== ${r.title} ===== + ${r.body} + """.stripIndent() + } +} diff --git a/examples/agents/15_map-reduce/nextflow.config b/examples/agents/15_map-reduce/nextflow.config new file mode 100644 index 0000000000..3dc3513878 --- /dev/null +++ b/examples/agents/15_map-reduce/nextflow.config @@ -0,0 +1,15 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true + +// The research brief the planner shards. Override with `--brief '...'`. +params.brief = 'What are the main practical considerations when adopting Nextflow for reproducible bioinformatics pipelines?' + +// Observable parallel map on the local executor. LLM calls are I/O-bound, but the +// LocalPollingMonitor throttles concurrency by a CPU gate, so on a low-core machine the +// map would otherwise run (near-)serially. Raise the gate above physical cores to let the +// per-shard mapper TaskRuns run concurrently. (A dedicated non-cpu-gating `agent` executor +// is the follow-on milestone; this knob is the portable way to observe parallel map today.) +executor.cpus = 8 diff --git a/examples/agents/16_fan-in-parity/README.md b/examples/agents/16_fan-in-parity/README.md new file mode 100644 index 0000000000..4528fed521 --- /dev/null +++ b/examples/agents/16_fan-in-parity/README.md @@ -0,0 +1,74 @@ +# fan-in-parity — an `agent` reduces exactly like a `process` + +Side-by-side proof that a tool-free `agent` inherits **canonical Nextflow cardinality**. +A `process` reducer and an `agent` reducer consume the **same** collected channel — a +`Bag` produced by `collect()` — and **both fire exactly once** over the whole +bag. Nothing about the fan-in is agent-specific; only the body differs (deterministic +Groovy vs an LLM call). + +## The two reducers (identical input contract) + +```nextflow +process reduce_with_process { agent reduce_with_agent { + input: model 'openai/gpt-5-mini' + findings: Bag instruction '...' + output: input: + report: String findings: Bag + exec: output: + report = "...${findings.size()}" report: String +} prompt: "...${findings.size()}..." + } +``` + +```nextflow +def bag = channel.of(f1, f2, f3).collect() // one value item: a Bag of all 3 +reduce_with_process(bag) // fires once +reduce_with_agent(bag) // fires once +``` + +## Why they behave the same + +This is the ordinary process rule, not a special "reduce" primitive: + +- `collect()` turns a queue channel into a **value (singleton) channel** holding one + `Bag` of all items. +- A process fires N times = the length of its longest **queue** input; **value** inputs + are broadcast (reused), not consumed. All-value inputs ⇒ **one** invocation. +- The agent reuses the exact same input-matching (`ProcessInputsDef.isSingleton()`), so a + `collect()`/value input fires it once — identical to the process. + +The `Bag` type is not agent-specific either: `Channel.collect()` is typed +`Value>`, so a typed *process* reducing over `collect()` declares `Bag` too. +(Order over the bag is not guaranteed and is hashed order-independently — again, standard +Nextflow, so resume survives reordering.) + +## Observed (verified end-to-end) + +Fresh run — each reducer is one task: + +``` +[c4/4bf198] Submitted process > reduce_with_process +[aa/805a26] Submitted process > reduce_with_agent +PROCESS reducer => combined 3 findings [f1, f2, f3] +AGENT reducer => The analysis shows low contamination, minimal adapter content, and good coverage. +[SUCCESS] completed=2 failed=0 cached=0 +``` + +`-resume` — both cache identically: + +``` +[c4/4bf198] Cached process > reduce_with_process +[aa/805a26] Cached process > reduce_with_agent +(Submitted=0 Cached=2) +``` + +## Running it + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf # completed=2 (one process task + one agent task) +nextflow run main.nf -resume # cached=2 +``` + +Requires the `nf-agent-pi` plugin (in `nextflow.config`) and an OpenAI key for the agent +reducer; the process reducer is a plain `exec:` and needs neither. diff --git a/examples/agents/16_fan-in-parity/main.nf b/examples/agents/16_fan-in-parity/main.nf new file mode 100644 index 0000000000..3a4a0c6e01 --- /dev/null +++ b/examples/agents/16_fan-in-parity/main.nf @@ -0,0 +1,54 @@ +nextflow.enable.types = true + +// Fan-in parity: a canonical `process` and an `agent` consume the SAME collected +// channel (a `Bag` produced by `collect()`) and BOTH fire exactly once over +// the whole bag. This shows the agent inherits canonical Nextflow cardinality: a +// value/singleton input (what `collect()` yields) => one invocation. The only +// difference is what the body does — deterministic Groovy vs an LLM call. See README.md. + +record Finding { + id: String + summary: String +} + +// Canonical process reducer: one value input (the collected Bag) -> runs ONCE. +process reduce_with_process { + input: + findings: Bag + output: + report: String + exec: + report = "combined ${findings.size()} findings [${findings.collect { it.id }.sort().join(', ')}]" +} + +// Agent reducer: SAME input shape (Bag) -> also runs ONCE (same isSingleton rule). +agent reduce_with_agent { + model 'openai/gpt-5-mini' + instruction 'You synthesise a set of findings into ONE short summary sentence.' + input: + findings: Bag + output: + report: String + prompt: + """ + Combine these ${findings.size()} findings into one short sentence: + + ${findings.collect { "- ${it.id}: ${it.summary}" }.join('\n')} + """ +} + +workflow { + // A queue channel of 3 findings... + def findings = channel.of( + record(id: 'f1', summary: 'coverage looks good'), + record(id: 'f2', summary: 'contamination is low'), + record(id: 'f3', summary: 'adapter content is minimal') + ) + + // ...collected into ONE value item (a Bag of all three). A value/singleton channel + // is broadcast to every consumer, so both reducers below read the same bag. + def bag = findings.collect() + + reduce_with_process(bag).view { r -> "PROCESS reducer => ${r}" } + reduce_with_agent(bag).view { r -> "AGENT reducer => ${r}" } +} diff --git a/examples/agents/16_fan-in-parity/nextflow.config b/examples/agents/16_fan-in-parity/nextflow.config new file mode 100644 index 0000000000..4d5a4c34ca --- /dev/null +++ b/examples/agents/16_fan-in-parity/nextflow.config @@ -0,0 +1,5 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true diff --git a/examples/agents/17_agent-module/README.md b/examples/agents/17_agent-module/README.md new file mode 100644 index 0000000000..5f2a5fab39 --- /dev/null +++ b/examples/agents/17_agent-module/README.md @@ -0,0 +1,80 @@ +# Agents as modules + +An `agent` can be authored as its own **module** and consumed with the ordinary +`include` statement, exactly like a process — including under an alias. The module +directory carries the agent's own skills and its own tool, so it is a shippable +unit rather than a snippet to copy into a script. + +## What's here + +``` +main.nf include { reporter as qc } from './mods/reporter' +mods/reporter/ + main.nf the agent: skills 'qa-report','style' + tools 'nf:module_run' + skills/qa-report/SKILL.md report format (module-local skill) + skills/style/SKILL.md house style (module-local skill) + tools/qc_verdict.nf the deterministic PASS/WARN/FAIL rule, included by the module +``` + +Three things are being demonstrated at once: + +1. **The include, aliased.** `include { reporter as qc } from './mods/reporter'` + resolves the directory to `mods/reporter/main.nf`. The alias renames the task, + so the work dir, progress table and trace all say `qc`. +2. **Module-local skills.** Both skills resolve under + `mods/reporter/skills//` — the directory of the file that *declares* the + agent, **not** the directory of the script that includes it, and that holds + under the alias. There is no fallback to the project dir, so a + `skills/qa-report/` next to `main.nf` could not shadow the module's own. +3. **Module-scoped tools.** `tools 'nf:module_run'` sees only what + `mods/reporter/main.nf` itself defines or includes — hence the module includes + its own `tools/qc_verdict.nf`. A process defined by the *including* script is + deliberately invisible, so the agent's tool surface does not change depending + on who imported it. + +The `qc_verdict` tool also splits the work honestly: the verdict is computed by +code — a plain `script:` block whose `stdout()` becomes the tool result — and the +prose is the model's job. + +## Run it + +```bash +export OPENAI_API_KEY="sk-..." +nextflow run main.nf +``` + +Expected: + +``` +ANSWER= +[MOD-QA v1] +VERDICT: PASS +METRICS: N50 = 45 kb, completeness = 96.4%, total length = 5.1 Mb +NOTE: Metrics clear the thresholds; proceed. -- mods/reporter +``` + +`[MOD-QA v1]` and the `-- mods/reporter` sign-off are the tell that both +module-local skills were activated; `VERDICT` comes from the tool, not the model. +Add `-with-agent-trace` to watch the `activate_skill` and `qc_verdict` calls. + +To prove the skills really come from the module directory, create +`skills/qa-report/SKILL.md` next to this `main.nf` with different instructions and +re-run: the output does not change. + +## Notes + +- **`nextflow.enable.types` is per file.** An `agent` block does not need it; the + typed process in `tools/qc_verdict.nf` does. Neither this script nor the agent + module sets it. +- **Config selectors work from the outside** (see `nextflow.config`): an + `agent { withName: … }` block matches the declared name `reporter`, the alias + `qc`, or the fully-qualified name — so the consumer can place the agent without + editing the module. Agents read the `agent` scope only; a `process` selector + matching an agent's name has no effect on it. +- **Params are inherited** from the run; a module agent's prompt may read + `params.*`. `addParams`/`params` on the include are not an agent feature. +- **Resume**: editing a module skill invalidates the agent's cache entry; moving + or renaming the module directory does not; aliasing does (the task name is part + of the key). +- **Registry-hosted agent modules** (`include { a } from 'scope/name'`) are not + supported yet — local paths only. diff --git a/examples/agents/17_agent-module/main.nf b/examples/agents/17_agent-module/main.nf new file mode 100644 index 0000000000..bd6eee4c73 --- /dev/null +++ b/examples/agents/17_agent-module/main.nf @@ -0,0 +1,17 @@ +/* + * Agents as modules. + * + * The whole agent — its model, instruction, prompt, two skills and its tool — lives + * in `mods/reporter/`. This script only includes it, under an alias, and calls it. + * + * Note there is no `nextflow.enable.types = true` here: the flag is per FILE, and + * this script declares no typed process of its own. The agent module and the tool + * module each carry whatever they need. + */ + +include { reporter as qc } from './mods/reporter' + +workflow { + qc(channel.of('Assembly for isolate SRR001: N50 = 45 kb, completeness = 96.4%, total length = 5.1 Mb. Should we proceed?')) + .view { a -> "ANSWER=\n${a}" } +} diff --git a/examples/agents/17_agent-module/mods/reporter/main.nf b/examples/agents/17_agent-module/mods/reporter/main.nf new file mode 100644 index 0000000000..1b4b92d3b1 --- /dev/null +++ b/examples/agents/17_agent-module/mods/reporter/main.nf @@ -0,0 +1,46 @@ +/* + * A self-contained agent module. + * + * Everything the agent needs is inside THIS directory: + * skills/qa-report/SKILL.md report format + * skills/style/SKILL.md house style + * tools/qc_verdict.nf the deterministic PASS/WARN/FAIL rule + * + * Both are resolved relative to the module directory — the directory of the file + * that DECLARES the agent — not the directory of whoever includes it, and that + * holds under an alias too. So this module keeps behaving the same wherever it + * is dropped, and a `skills/qa-report/` sitting next to the consumer's script + * cannot shadow the module's own. + * + * `tools 'nf:module_run'` sees only what THIS file defines or includes, which is why + * the module includes its own tool. A process defined by the including script is + * deliberately invisible. + * + * No `nextflow.enable.types = true` here: an `agent` block does not need it. The + * tool module does, because it declares a typed process — the flag is per file. + */ + +include { qc_verdict } from './tools/qc_verdict.nf' + +agent reporter { + model 'openai/gpt-5-mini' + instruction """ + You report on genome assembly QC for bioinformaticians. + The PASS/WARN/FAIL verdict is NOT yours to guess: always obtain it by + calling the qc_verdict tool with the metrics you were given. + """ + + skills 'qa-report', 'style' + tools 'nf:module_run' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ +} diff --git a/examples/agents/17_agent-module/mods/reporter/skills/qa-report/SKILL.md b/examples/agents/17_agent-module/mods/reporter/skills/qa-report/SKILL.md new file mode 100644 index 0000000000..41652bbf10 --- /dev/null +++ b/examples/agents/17_agent-module/mods/reporter/skills/qa-report/SKILL.md @@ -0,0 +1,21 @@ +--- +name: qa-report +description: Format an assembly QC report as the standardized MOD-QA block. Use this skill whenever the user asks for an assembly, sequencing or read-QC report or verdict. +--- +# Assembly QC report format + +Format the answer EXACTLY as follows and nothing else (no preamble, no extra +sections): + +``` +[MOD-QA v1] +VERDICT: +METRICS: +NOTE: +``` + +Rules: +- `VERDICT` is whatever the `qc_verdict` tool returned. Never decide it yourself, + and never override it. +- `METRICS` echoes the metrics from the request, normalized. +- `NOTE` is a single sentence. diff --git a/examples/agents/17_agent-module/mods/reporter/skills/style/SKILL.md b/examples/agents/17_agent-module/mods/reporter/skills/style/SKILL.md new file mode 100644 index 0000000000..c01a33a82b --- /dev/null +++ b/examples/agents/17_agent-module/mods/reporter/skills/style/SKILL.md @@ -0,0 +1,11 @@ +--- +name: style +description: House writing style for this module's reports. Use whenever writing any report text, note or summary. +--- +# House style + +- Never hedge. If the data does not support a claim, say so in one clause. +- Never use the words "delve", "leverage" or "robust". +- Report every metric in the unit it was given; do not convert. +- Sign the NOTE line off with `-- mods/reporter` so the reader can see which + module produced the report. diff --git a/examples/agents/17_agent-module/mods/reporter/tools/qc_verdict.nf b/examples/agents/17_agent-module/mods/reporter/tools/qc_verdict.nf new file mode 100644 index 0000000000..6748bcfeb8 --- /dev/null +++ b/examples/agents/17_agent-module/mods/reporter/tools/qc_verdict.nf @@ -0,0 +1,27 @@ +nextflow.enable.types = true + +/* + * The deterministic half of the report: the verdict is decided by CODE, not by the + * model. `nf:module_run` exposes this process to the agent as the `qc_verdict` tool + * because the agent module includes it — no network. The agent task itself always runs + * in the `pi` runner image, so the engine that image needs also runs this container. + */ +process qc_verdict { + container 'ubuntu:24.04' + input: + n50_kb: Integer + completeness_pct: Float + + output: + verdict: String = stdout() + + script: + """ + awk -v n50='${n50_kb}' -v comp='${completeness_pct}' 'BEGIN { + if( n50 >= 40 && comp >= 95.0 ) v = "PASS" + else if( n50 >= 20 && comp >= 90.0 ) v = "WARN" + else v = "FAIL" + printf "%s", v + }' + """ +} diff --git a/examples/agents/17_agent-module/nextflow.config b/examples/agents/17_agent-module/nextflow.config new file mode 100644 index 0000000000..9ae7a8abcd --- /dev/null +++ b/examples/agents/17_agent-module/nextflow.config @@ -0,0 +1,16 @@ +agent.runner = 'pi' + +// `nf-agent-pi` ships no runtime: the agent proxy and the Node harness live in the +// runner image, so a `pi` agent always runs as a containerized task. +docker.enabled = true + +// The module agent is configured from the CONSUMER's config, without editing the +// module: a `withName:` selector matches the DECLARED name (`reporter`), the alias +// (`qc`), or the fully-qualified name. Agents read the `agent` scope only -- a +// `process` selector matching an agent's name has no effect on it. +agent { + withName: 'reporter' { + errorStrategy = 'retry' + maxRetries = 1 + } +} diff --git a/examples/agents/19_shell-tools/README.md b/examples/agents/19_shell-tools/README.md new file mode 100644 index 0000000000..106fcb98e7 --- /dev/null +++ b/examples/agents/19_shell-tools/README.md @@ -0,0 +1,85 @@ +# 19_shell-tools — runner-native file and shell tools + +```groovy +tools 'fs:*', 'shell:bash' +``` + +## What it shows + +Every other tool example brokers its tools back to the driver: `nf:module_run` marshals the model's +arguments into channel values and runs a real Nextflow task. These tools do the opposite — they are +the **runner's own**, activated by name and executed where the agent already is. On `pi` that is the +SDK's builtins inside the agent container, so no tool call crosses the RPC link. + +The task is arithmetic no model can do by reading. `data/contigs.fa` holds 8 contigs and 2,670 +bases; GC content and N50 are exact quantities over every base. Without a shell the agent can only +estimate, and an estimate here is just a wrong answer. With `awk` it computes. + +That is the case the tools redesign was argued from: *"grep / sed / awk a file to find targeted +information, or write custom code to compute things it can't do directly with tokens."* + +## How the FASTA reaches the agent + +The agent declares `contigs: Path`, which means for an agent exactly what it means for a process: +the file is **staged into the agent's task directory** and bind-mounted into the runner container, +and both the prompt interpolation and the input JSON render it as the plain name `contigs.fa`. So +the agent's shell opens it directly — no `write` step, no copy of the sequence through the context +window. + +Two consequences worth carrying: + +- Beyond its declared inputs, `fs:*` and `shell:bash` on `pi` see **only what the agent itself + creates** in its sandbox. They are not a general window onto pipeline data. +- Module outputs reach the model differently again: small text/JSON results are **inlined into the + tool result** (`ToolOutputReader`, 32 kB cap, `.json`/`.tsv`/`.txt`/…), so an agent gets those + numbers without ever opening a file. Anything else arrives as an opaque path handle. + +On `langchain4j` the picture differs — the loop runs in the driver JVM, so `fs:*` reaches the real +filesystem behind `SandboxGuard` (the work dir, the agent's staged inputs, and module outputs). +`shell:bash` is not available there at all. + +## Expected result + +The numbers are fixed — `data/contigs.fa` is generated from a pinned seed — so this example is +checkable rather than merely plausible: + +``` +contigs = 8 +bases = 2670 +GC% = 55.51 +N50 = 620 +longest = 800 +``` + +Verify independently: + +```bash +grep -c '^>' data/contigs.fa +awk '/^>/{next}{n=length($0); tot+=n; gc+=gsub(/[GCgc]/,"")}END{printf "%d bases, %.2f%% GC\n", tot, gc*100/tot}' data/contigs.fa +``` + +A wrong N50 is the interesting failure: it is the one statistic needing a sort and a running total +rather than a single pass, so it is where an agent that quietly stopped using the shell gives itself +away. + +## Why `pi` only + +`shell:bash` is rejected at agent-build time on `langchain4j`. That loop runs in the **driver JVM**, +so a shell tool there would execute model-authored commands on the driver host with no container +boundary — and on Kubernetes or Batch the driver pod does not have the pipeline's tooling anyway. In +the `pi` container, the container *is* the sandbox. + +| runner | `fs:*` bounded by | `shell:bash` | +|---|---|---| +| `pi` | the session `cwd` plus the container | the container only | +| `langchain4j` | `SandboxGuard` (work dir + staged inputs + module outputs) | not available | + +## Run + +```bash +export OPENAI_API_KEY=... +nextflow run . +``` + +Requires the `pi` runner image the `nf-agent-pi` plugin declares and a running +container engine. diff --git a/examples/agents/19_shell-tools/data/contigs.fa b/examples/agents/19_shell-tools/data/contigs.fa new file mode 100644 index 0000000000..dc6e241cd5 --- /dev/null +++ b/examples/agents/19_shell-tools/data/contigs.fa @@ -0,0 +1,49 @@ +>contig_1 length=800 +GACGATGTAATTGTTCATGCGACGAATTCCGTCCTCGTCGCTGCTGCTGGTTAGCGTCGGGAAAGCGACT +CTCTGGGAAAGCACGGTAGTCGTACAAGGCCGATGTGCGCCTCTGCCACGAACCCGCCCGAGCCCTCCCA +AACAGATCAGTCGTGTCCCCTAGCTAAGTAGTCCGGTGTGTAGGGGGACTATTGCATCCTGTCAGGGAAA +TAGGATGTGGTGAAGATACGACAGTTAAGCAACTGCAAGCAAGAGCTCTAGTCATTAGACACTGGGGGGG +TAGGCCGTAGCGGCCCAGGTATGGGGCAAGTGAGACGGTTCCCGGCGTGGGCTAGGATGACTCTGGTTCT +CATCAATGGCAGCTGTAAACTCGAGTCCGAAAAGCATGTTCCTAGTATAGGTAAGAAACCCACTAGCGCC +TGAACTAGCGTTAGAGTCAGCCCCGGGCCCCCGACGTTTTACGCGCGTCGGGTGAGGCCGACGGTGTGGG +GACTTATGGCCAAGGCGTGCATTCAGGGGACTTTTAGCGCTTTACCTCCGCGCGTGTGGTGCGTGCTCCC +TGCAAGCCCCCTGTTTAGTTGAGTGAGAACGAGTTTGCTGTCTCTCATGCATATCTCTACACTGTCGCCG +CTCTTAAAACGCAGAGATTGACGGCGGCACTACGGGCAGACAAGTGTTACAGCGGGAGCTCGTGCGGACT +GCAGTGGGCCTTAGGACGAAAAAGTGGGGCGGTAGTAGACCCTCCTCCGGCGTGCACCGAATATATGGCG +TGGGATACACGAAGCGCAAACTCTAGGCCT +>contig_2 length=620 +ACGTCATAGATCGTGAGTCAACGGTACGTTATCGAACTCAGGTCCACCCGAAGTCGTGGATTAGGCTGCC +TCTCTGTTCCTTTCCATGGACCCATAAAGCAAGAATAGGAACTGGTGTTGCAGACCTCTGGTCGGCAGGT +AACGCGAAATTAATCCCCAAGTTGTACGGGTTAGGGCATACACCGATTTCCAATTAAATGCGCGACGCCC +GGGCAAACACTCTTGCTGCATAGGACATTCGCTGCATGATGTACATGGAGGAATGTCAGGTAGGCCGGTT +TGGGTTCAGAAGATCCCCCATTGTGCCTTGGATAGCAAGGTGCACCGACGTACATGACCATTAGCCTTCC +TTCTGTGGGCTTTCGTTGCCCCCTGCGTCCACCCGCGAGACTACTCTCCCAATCCCGTGGCACCGACGCG +GATTCCATCGAGTGGGTCAATTTTGTGTGGGACTGAGCGGCCTTTCGCTGGATGGACCTTGTCACTCCGC +CGTGAGGGTACGCTAGCTCAAGCTACACGCGACGCAGTTGGCGGCGAATAACGCACAGATAGCTATCGAA +CTTCCCTCCGATAAACTGTGTTCAAACTGCACACGCCTTGCGCACGGAACTGTATTTTGC +>contig_3 length=450 +TCAGTTAAGGATAAATCACTGTCCCCATACAGGACGTACTCCTCGTACCTACCGGGTTTATTGTAGCCGC +GGTCCGCCTCTGCGGTTGAGACTAACCCTGCTTCACGAGGAGAGGGCGCGTTTGAACCGGCGGCAAAAGC +GGGCGGAACTTAAGTCTCCTGCCATCTTCAATCAATGGATTTCGGGGGGAGTCTCTTACACCTGGGCATG +TTCCTGGGAAGGGGACTAGGCAATGAAGCCAATCAAGCACGTCTTGGATATGGGACTGAGCGGCCAGCCT +TAGGGAGGACCTCCACCAACGTGGGACGTTGTGCCGGCTTGGCGAATGGGCCCACTACGACAACGACACT +AGTCGAGCCTGATCCAAGTAGAAGCCTGGAACTGGAAGCGTCTAATTGCAAGGCTCCTTGTGACCTTCCA +GCATGAGTCCTGCAACTACAGCAAACGAAG +>contig_4 length=300 +CACGACCGGCCGGACGGCCGCAGGGCACTGACACGAGAGGAACGGCGCCCCCTTCTATATGTCATGCAGG +CCGGAGAGTCAGCCGTATCAGGTACAAGCGCAGCGGGCACGCGCACCAGAAGGATCTCTGGGATGGTTCA +GAGGCGCTTAACCTGTGACGTGGTGTTCCCCCTCTCTCCGGCGTCGGTGGGGGTCCCGCAGACCTGGCAA +GGCGGGCCCGACAAGTTAATTTTGATTCAGCGTGGTGCATCGAGAAACATGCGATCGTGCTAGGGCTCTC +ACACTCACGGCCTAACACAC +>contig_5 length=210 +TGCGAAACTGGCAATGTTGGGAAATCGGATATCTAATGGATGATGAGTTCATCTTTGCGTGACTAAGCCC +CAACAATAGATTGTAGGCCCTAGCGTGATTCCTCAAACCAGAATACTAGCCACCTCTCAACCACTAGTAC +ACTTCACGGCCGGACGTCCCGCCTGCCAGGGCTTAGCCCCTGTTCTTGGAAGGGGGTGGTGATCAGATCC +>contig_6 length=140 +CGGGCAATCCTGTCAGACTCACCATCGGAACTAAATTCTCGGTACAGCTCATAGGACTGGCCGAACGACT +AACACATAGCAATAGGCTTCAAACGTCTGCGCCCCGAGCGTCAACTCCATCGTGTATCGCCCGAAAGTGG +>contig_7 length=90 +TCGTGCTAAGAAAACTACAATAGAGGCTCCCGTCGTTTGTTTACCGTCGGCGTCTGCCAGCCTTTAAAAA +AGCTATGGTAGTATGCCTCG +>contig_8 length=60 +GGGGTGCAGGTTAGCCCCGTACAGAAGGGTTAGTCGGGTATCGAGACTGACCTGTCAGCG diff --git a/examples/agents/19_shell-tools/main.nf b/examples/agents/19_shell-tools/main.nf new file mode 100644 index 0000000000..ecf206623e --- /dev/null +++ b/examples/agents/19_shell-tools/main.nf @@ -0,0 +1,69 @@ +nextflow.enable.types = true + +// Runner-native tools (`fs:*`, `shell:bash`), executed by the runner rather than brokered back to +// the driver. `shell:bash` is `pi`-only. See README.md. + +// Every field is checkable against the file, which makes this example a test, not a demo. +record AssemblyStats { + contig_count: Integer + total_bases: Integer + gc_percent: Float + n50: Integer + longest_contig: Integer +} + +agent assembly_qc { + model 'openai/gpt-5-mini' + + instruction '''\ + You are an assembly QC assistant with a POSIX shell in your working directory. + + The FASTA is already staged in your working directory under the name the prompt gives you. + Compute every number from that file with the shell — `awk`, `grep`, `sort`, `wc`. + + Do NOT count by reading. Reading thousands of bases and estimating is simply a wrong + answer. + + The FASTA is line-wrapped: a contig's sequence is EVERY line after its `>` header up to the + next header, so concatenate those lines before measuring one. A wrapped line is not a + contig — if your longest contig comes out equal to the wrap width, you measured lines. + + Definitions, so the arithmetic is unambiguous: + - contig_count number of `>` header lines + - total_bases total sequence characters, headers and newlines excluded + - gc_percent 100 * (G + C) / total_bases, case-insensitive, 2 decimal places + - n50 per-contig lengths sorted descending, accumulated; report the length of + the contig at which the running total first reaches half of total_bases + - longest_contig length of the longest contig, concatenated across its wrapped lines + + Write your working to `stats.tsv` so the numbers can be audited, then report them. + '''.stripIndent() + + tools 'fs:*', 'shell:bash' + + input: + contigs: Path + + output: + stats: AssemblyStats + + prompt: + """ + Compute the assembly statistics for the FASTA file ${contigs} in your working directory. + """ +} + +workflow { + // A `Path` input is staged into the agent's task directory and bind-mounted into the runner + // container, so the agent's shell opens it under its plain name — see README.md. + assembly_qc(channel.fromPath("${moduleDir}/data/contigs.fa")) + .view { s -> + """\ + contigs = ${s.contig_count} + bases = ${s.total_bases} + GC% = ${s.gc_percent} + N50 = ${s.n50} + longest = ${s.longest_contig} + """.stripIndent() + } +} diff --git a/examples/agents/19_shell-tools/nextflow.config b/examples/agents/19_shell-tools/nextflow.config new file mode 100644 index 0000000000..91807002d3 --- /dev/null +++ b/examples/agents/19_shell-tools/nextflow.config @@ -0,0 +1,6 @@ +agent.runner = 'pi' + +// `shell:bash` is served by the runner's own tools, so it exists only where the runner owns a +// container: `pi`. The langchain4j runner would have to execute the model's commands in the +// driver JVM, on the driver host, with no boundary at all. +docker.enabled = true diff --git a/examples/agents/README.md b/examples/agents/README.md new file mode 100644 index 0000000000..21e051078d --- /dev/null +++ b/examples/agents/README.md @@ -0,0 +1,229 @@ +# Nextflow agent examples + +An **agent** is a process-shaped primitive that wraps an LLM-driven step: it +takes one typed input per channel item, renders a prompt, runs a language model +(optionally calling tools in a loop), and emits one typed output. Agents compose +with processes and other agents through the normal channel/workflow model. + +These examples build up from a single no-tool agent to multi-module, +tool-calling, sandboxed, and skill-equipped agents. + +## A minimal agent + +```nextflow +nextflow.enable.types = true + +record Analysis { + summary: String + confidence: Float +} + +agent analyst { + model 'openai/gpt-5-mini' + instruction 'You are a precise scientific analyst. Be concise and honest about uncertainty.' + + input: + question: String + + output: + result: Analysis + + prompt: + """ + Analyze the following question and return a structured analysis. + + Question: ${question} + """ +} + +workflow { + analyst(channel.of('Is FASTQ a binary or a text format?')) + .view { r -> "summary: ${r.summary} (confidence: ${r.confidence})" } +} +``` + +An `agent` looks like a process: typed `input:`/`output:`, plus a `model`, an +`instruction` (the system prompt), and a `prompt` rendered per channel item. +Here the record-typed `output:` makes the model return structured JSON. Run it +over a channel and it composes with the rest of your workflow. Everything below +builds on this shape. + +## The model in one minute + +- **`model`** — the LLM, as `provider/model` (e.g. `openai/gpt-5-mini`). +- **`instruction`** — the agent's role/persona (system prompt). +- **`goal`** *(optional)* — a high-level objective that steers the multi-turn + loop; advisory (it never raises `maxIterations`). See `09_goal-directed/`, + `10_convergence-loop/`, and `11_contig-filter/`. +- **`input:` / `output:`** — process-style typed I/O: a scalar, a `path`, or a + named `record`. A **record output** opts into *structured output* (the record + type becomes the model's JSON-schema contract). A plain output (e.g. `String`) + emits the model's text. +- **`tools`** — a list of **namespaced tool refs** (not module names). Every entry + is `family[:group]:name`; a ref that names a *non-leaf* selects its whole + subtree, so `'nf:module_run'` means `'nf:module_run:*'`. A ref that selects + nothing is an error, never a silent no-op. + - **`'nf:module_run'`** — exposes **each** process in scope as its **own** tool, + named after the module: `include`d modules **and** locally-defined + processes. Each tool's `parameters` schema IS that module's flattened input + schema (required fields, `additionalProperties:false`, the nf-core `meta.id` + convention), so OpenAI function-calling enforces the field names and the + model cannot omit or rename a field. The LLM picks which to call; it executes + as a real dataflow node (container / work dir / cache) and its outputs return + as JSON (files as absolute path handles; small text/JSON outputs are inlined + so the model can reason over them). Narrow it by naming one process + (`'nf:module_run:SKESA'`) or a group of them (`'nf:module_run:SAMTOOLS_*'`); + the glob is case-sensitive and only ever trails. + - **`'fs:*'`** — the sandboxed file tools — `read`, `write`, `edit`, `ls`, + `grep` and `find` — scoped to the agent's per-invocation work directory (plus + the output paths of modules it ran). Writes stay inside the sandbox. Take a + subset by naming the leaves you want (`'fs:read', 'fs:write'`). +- **`skills`** *(optional)* — a list of **skills** (Anthropic-style `SKILL.md` + folders) the agent may use. A skill packages expert instructions (plus optional + reference files) the model loads *on demand* through runner tools ( + `activate_skill` / `read_skill_resource`) — where `tools` let the agent *run + code*, `skills` give it *instructions*. Each entry is a **local** name (resolved + under `skills//` beside the script) or a **remote** GitHub ref + (`github.com//[@rev]`) cloned and cached into that same `skills/` + directory. No code runs at inference. See `03_skills/`. + + Declaring `tools` **or `skills`** can be combined with a **record-typed + structured output**. The Pi runner exposes the schema as a terminating + `final_answer` tool; the JSON result binds directly to the record. If a model + initially omits that tool, the runner gives it one corrective turn. Multiple + named outputs use the same schema wrapper and split into N output channels. + See `06_tool-structured/`. + +## The examples (simple → complex) + +| Example | Tools | Demonstrates | +|---|---|---| +| [`01_structured-output/`](01_structured-output/main.nf) | none | A single agent with **record-typed structured output** (`Query` → `Analysis`). | +| [`02_two-agents/`](02_two-agents/main.nf) | none | Two no-tool agents **chained over a channel** — stage-1's output record type is stage-2's input type. | +| [`03_skills/`](03_skills/main.nf) | `skills` | A **local skill** (`sequence-report`): the model activates the `SKILL.md` on demand and follows its instructions — emitting a fixed report format it would not otherwise produce. No module and no tool image — the agent's own runner image is the only one pulled. | +| [`04_tool/`](04_tool/main.nf) | `nf:module_run` | The simplest tool agent: an **executor-portable in-scope script process** auto-discovered and run via `nf:module_run`. | +| [`05_tool-parallel/`](05_tool-parallel/main.nf) | `nf:module_run` | A tool agent **fanning out over a queue**: one `TaskRun` per record, running in parallel, each driving its own tool calls. | +| [`06_tool-structured/`](06_tool-structured/main.nf) | `nf:module_run` | **Tools + structured output together**: the tool loop runs, then the model ends it by calling the terminating `final_answer` tool, whose arguments are the record (`Shout`). | +| [`07_module-as-tool/`](07_module-as-tool/main.nf) | `nf:module_run` | An **`include`d nf-core module** (`nf-core/skesa`) run as a tool; the LLM bridges the agent's input record to the module's tuple input. | +| [`08_filesystem/`](08_filesystem/main.nf) | `nf:module_run`, `fs:*` | Both families together, with **no tool image** (a trivial `exec:` process): run a module, then write/read a report file in the sandbox. | +| [`09_goal-directed/`](09_goal-directed/main.nf) | `nf:module_run`, `fs:*` | The **`goal` directive**: from a high-level `goal` (no step list, tools not named in the prompt) the agent plans and chains the tools itself — assemble → assembly-stats → judge against an N50 bar. Tool *composition* of distinct tools (contrast the iterative `10_convergence-loop/`). | +| [`10_convergence-loop/`](10_convergence-loop/main.nf) | `nf:module_run` | A **convergence loop** with no tool image: the agent re-runs the **same** tool many times, varying one numeric parameter, reading the metric each call, and converging (coarse scan → refine) on the threshold that maximises F1. The tunable knob is a **declared input**, so iterative tuning needs no core change. | +| [`11_contig-filter/`](11_contig-filter/main.nf) | `nf:module_run` | A **convergence loop driving real nf-core modules**: the agent titrates depth — re-running `SEQTK_SAMPLE(sample_size)` + `SKESA` with a varying subsample size to find the smallest subsample whose assembly still clears an N50 bar. `sample_size` is a declared module input, so `nf:module_run` exposes it as the tunable knob. | +| [`12_isolate-triage/`](12_isolate-triage/main.nf) | `nf:module_run`, `fs:*` | A real-world **adaptive** agent: a `goal`, three nf-core modules, a data-driven QC gate (inline JSON stats), and a conditional annotation branch. | +| [`13_samplesheet-builder/`](13_samplesheet-builder/main.nf) | none | **Agentic map-reduce**: a deterministic process *shards* (fetches raw ENA metadata per accession), a **map** agent normalizes each messy run into a samplesheet row (structured output — picks R1/R2, single/paired, drops ENA orphan files), and a **reduce** agent `collect()`s them all to reconcile replicates into one `samplesheet.csv`. Agents supply only the reasoning; the queue/shard/gather are plain Nextflow. | +| [`14_data-labelling/`](14_data-labelling/main.nf) | `skills` | **Agentic data labelling** (map-reduce): shard fetches raw ENA metadata, a **map** agent labels each sample with controlled-vocabulary ontology terms (organism/tissue/disease/assay) guided by a **skill** carrying the vocabulary (structured output — reads free text, resolves cell lines, returns `unknown` rather than hallucinate), and a **reduce** agent unifies terms across the cohort and splits confident labels from a human `review_queue`. Sibling of `13_samplesheet-builder/`, showing `skills` + structured output as the map node. | +| [`15_map-reduce/`](15_map-reduce/main.nf) | none | **Fully-agentic map-reduce**: every phase is an agent — `planner` shards a brief, `mapper` answers each shard as one **parallel** TaskRun, `reducer` fans in the whole `Bag` via `collect()`. Exercises parallel map + multiple structured I/O + fan-in + resume end to end. | +| [`16_fan-in-parity/`](16_fan-in-parity/main.nf) | none | A canonical `process` and an `agent` reduce the **same** `collect()`ed `Bag` — both fire exactly **once** over the bag and cache identically on `-resume`, showing the agent inherits canonical Nextflow fan-in cardinality. | +| [`17_agent-module/`](17_agent-module/main.nf) | `skills`, `nf:module_run` | An **agent authored as a module** and `include`d under an alias (`reporter as qc`). The module directory bundles its own two `skills/` and its own `tools/` module: skills, relative tool paths and `moduleDir` all resolve from the **defining** module dir, and `nf:module_run` sees only the module's own scope. No network; the tool declares a container, run by the same engine the agent's runner image needs. | +| [`19_shell-tools/`](19_shell-tools/main.nf) | `fs:*`, `shell:bash` | The **runner-native** families, which are executed by the runner rather than brokered back to the driver — on `pi`, the SDK builtins inside the agent container, so no tool call crosses the RPC link. The agent computes exact assembly statistics (GC%, N50) over 15 kB of sequence with `awk`: arithmetic no model can do by reading tokens, so an agent without a shell can only estimate. Expected numbers are fixed and verifiable. `shell:bash` is **`pi`-only** — on `langchain4j` it is rejected at agent-build time, because that loop runs in the driver JVM. | + +## Requirements + +- An OpenAI API key: + ```bash + export OPENAI_API_KEY="sk-..." + ``` + The model is called from *inside* the agent container, which does not inherit your shell — + exporting it is nevertheless enough: Nextflow resolves the credential once in the driver and + hands it to the agent task in band, on the TLS-protected RPC link, so nothing is forwarded + through the container environment and no tool process an agent invokes ever sees it. Set + `agent.rpc.tls = false` while debugging and the credential is withheld instead, and the agent + then needs an out-of-band channel — `agent.containerOptions = '-e OPENAI_API_KEY'` on Docker or + Podman, the `env` scope or a `secret` elsewhere. Repinning `agent.container` to an older image is + not a way around it: the harness enforces the RPC protocol version, so anything below + `nf-agent-pi 0.4.1` is refused at the start frame with `Unsupported protocol version` rather than + running with a feature missing. +- The `nf-agent-pi` plugin and `agent.runner = 'pi'` (declared in each example's `nextflow.config`). +- **A container engine, and the `pi` runner image.** `nf-agent-pi` ships no runtime of its own: + the agent proxy and the Node harness live in the runner image, so *every* example runs its agent + as a containerized task — and nothing needs Node (or any other interpreter) on the machine + running Nextflow. Each `nextflow.config` therefore enables Docker — and nothing more: + **no example names an image**. The plugin declares the one it needs, derived from its own + `plugins/nf-agent-pi/VERSION` and published by the Nextflow release; + `plugins/nf-agent-pi/build-image.sh ref` prints it. Set `agent.container` only to override + that — with a locally built image, for instance, which is what + `plugins/nf-agent-pi/build-image.sh build -l` prints. Using another engine is a one-line swap + of `docker.enabled` for `podman.enabled`, `singularity.enabled`, and so on. +- The tool examples that use `nf-core/*` modules (`07_module-as-tool/`, `09_goal-directed/`, + `11_contig-filter/`, `12_isolate-triage/`) additionally need Wave (enabled in their configs) to + provision the *module* images — those modules run as real containerized tasks — + **and an input FASTQ**. Each of those four carries a `data` **symlink** to + [`examples/data/`](../data/README.md), so the fixture is fetched **once** and shared by all + of them rather than copied per example. From the repository root: + ```bash + mkdir -p examples/data + curl -sL https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/sarscov2/illumina/fastq/sarscov2_mus-musculus.fastq.gz \ + -o examples/data/sample.fastq.gz + gunzip -c examples/data/sample.fastq.gz > examples/data/sample.fastq + ``` + That single command produces both forms, and both are needed: `11_contig-filter/` reads + `data/sample.fastq.gz` because its `SEQTK_SAMPLE` step names its output after the input and + emits `*.fastq.gz`, while the other three read the uncompressed `data/sample.fastq`. + The fixture files are gitignored; the `examples/data/` directory and the symlinks are not, + so a fresh clone has the wiring in place and only the download missing. + **Note for `12_isolate-triage/`:** the sarscov2 test FASTQ assembles to ~N50=310, + ~6 contigs, which **correctly FAILS** the lenient QC gate (N50 < 500 bp), so the + agent returns a FAIL verdict and skips PROKKA annotation. This is the expected + and intended gate behaviour for this small viral read set. To exercise the PASS + + PROKKA annotation branch, use a real bacterial isolate FASTQ or adjust the + thresholds in `isolate-triage/main.nf`. + The `08_filesystem/` and `10_convergence-loop/` examples need no input data and no + *tool* image: their tools are local `exec:` processes, so the runner image is the only + one pulled. `04_tool/` uses a portable `script:` process, which needs no image of its + own locally and can be assigned one when offloaded to a remote executor. `03_skills/` + runs no tool at all (a pure-LLM agent with a local skill — the OpenAI key and the + runner image are all it takes). A **remote** skill ref additionally needs network + access to clone the GitHub repo on first use (it is then cached locally). + +## Run (released Nextflow) + +```bash +cd +nextflow run main.nf +``` + +## Run from this repo (development build) + +The plugin must be built and discovered in dev mode: + +```bash +# from the repo root +make compile +./gradlew :plugins:nf-agent-pi:jar :plugins:nf-agent-pi:copyPluginManifest :plugins:nf-agent-pi:copyPluginLibs + +# then, from an example directory +NXF_PLUGINS_MODE=dev OPENAI_API_KEY="$OPENAI_API_KEY" \ + ../../../launch.sh run main.nf +``` + +## v1 limits / notes + +- Multiple inputs and multiple named structured outputs are supported (a queue + input maps per-item, a value/singleton input fans in); at least one output is + required (zero outputs are not yet supported). Provider support follows the Pi SDK. +- Structured output requires a named `record` type; `Path` fields are not + allowed in output records. Optional (`?`) fields are preserved in the portable + schema for both input and output records. +- A tool agent runs on the **task path** like any other agent, so it fans out over + a queue in parallel (one `TaskRun` per record — see [`05_tool-parallel/`](05_tool-parallel/main.nf)). + Within a single agent the tool calls are **sequential** — the model waits for each + result before asking for the next — but calls from different agent tasks are + dispatched concurrently, each into its own cloned process graph. A tool agent + **caches on `-resume`** like any other agent: each + tool's schema and backing script are folded into the resume key, so a replay is + only ever served for the exact tools it was produced with. + A failing tool *task* aborts the run (only dispatch-level errors — bad module name, + malformed args — are returned to the model for recovery). +- No `nextflow run` tool in this release. `shell:bash` exists on the `pi` runner + only — the `langchain4j` loop runs in the driver JVM, so declaring it there is + rejected before the run starts. The `tools ''` string form is + **gone** — a module path or a registry ref is no longer a tool ref: `include` + the module, then name it with `'nf:module_run:'` (or take every process + in scope with `'nf:module_run'`). +- **Skills** run in *Tool Mode* only — instructions plus bundled resources, no code + execution at inference. A **remote** skill's `SKILL.md` becomes model + instructions, so treat it as untrusted and **pin a commit SHA** rather than a + moving branch; cached clones land in the local `skills/` dir (add them to + `.gitignore`). Skills resolve from `skills/` beside the script. + +See [`docs/agent.mdx`](../../docs/agent.mdx) for the full reference. diff --git a/examples/agents/k8s-local.config b/examples/agents/k8s-local.config new file mode 100644 index 0000000000..7ee05cfb97 --- /dev/null +++ b/examples/agents/k8s-local.config @@ -0,0 +1,54 @@ +/* Docker Desktop validation profile for canonical agent executor tasks. */ + +agent { + executor = 'k8s' + arch = 'arm64' + // Kubernetes containerizes the task itself, so no engine alias exists for the driver's + // host and `agent.rpc.remoteHost` is REQUIRED. Docker Desktop's Kubernetes resolves + // `host.docker.internal` from inside a pod; a real cluster needs a routable driver + // address or an in-cluster service name instead. + rpc.remoteHost = 'host.docker.internal' +} + +// The Kubernetes task inherits nothing from the driver and the executor ignores container run +// options, so this `env` allow-list used to be the only credential channel here. It no longer is: +// the driver sends the credential it resolved to the agent task in band, on the TLS-protected RPC +// link. Uncomment it for `agent.rpc.tls = false`, or for a provider whose variable the core ladder +// does not read -- and in +// production inject the values from a Kubernetes Secret (or the corresponding backend secret +// mechanism) rather than from the driver's shell. +// +// env { +// OPENAI_API_KEY = System.getenv('OPENAI_API_KEY') +// ANTHROPIC_API_KEY = System.getenv('ANTHROPIC_API_KEY') +// OPENROUTER_API_KEY = System.getenv('OPENROUTER_API_KEY') +// } + +wave { + enabled = true + strategy = 'conda,container' +} + +fusion { + enabled = true + exportStorageCredentials = true + privileged = true +} + +process { + executor = 'k8s' +} + +k8s { + context = 'docker-desktop' + pullPolicy = 'IfNotPresent' + // Keep completed pods around so they can be inspected after the run. + cleanup = false + debug { + yaml = true + } +} + +process.arch = 'arm64' + +workDir = 's3://nextflow-ci/work' diff --git a/examples/agents/validate.sh b/examples/agents/validate.sh new file mode 100755 index 0000000000..2079f5c903 --- /dev/null +++ b/examples/agents/validate.sh @@ -0,0 +1,296 @@ +#!/bin/bash +# +# Copyright 2013-2026, Seqera Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# End-to-end validation of examples/agents/* against the local development +# build, on the local executor and/or the Kubernetes profile. +# +# make compile assemble # .launch.classpath + plugin dev libs +# ./examples/agents/validate.sh # every example, both modes +# ./examples/agents/validate.sh -m local 01_structured-output 04_tool +# ./examples/agents/validate.sh -r # ... and check -resume replays from cache +# +# Requires OPENAI_API_KEY (the examples call a real model) and, in local mode, a +# running Docker daemon: the `pi` runner ships no host-local runtime, so every +# agent task runs in the runner image the nf-agent-pi plugin declares -- nothing +# needs Node on this machine. The k8s +# mode additionally needs the cluster, Wave and S3 access that +# examples/agents/k8s-local.config describes. +# +# `-r` adds the companion check: a second run, with -resume, in the SAME directory, +# which must replay from cache rather than call the model again. It costs one extra +# run per example rather than two, because the fresh run above IS the first half of +# the pair. +# +# Exits non-zero if any run fails, so it can gate a release check. +set -u + +MODES="local k8s" +TIMEOUT=2400 +JOBS=3 +DRY=0 +RESUME=0 +RESULTS=${AGENT_VALIDATION_DIR:-} + +usage() { + cat <<'TXT' +Validate examples/agents/* against the local development build. + + usage: ./examples/agents/validate.sh [-m local|k8s|both] [-t secs] [-j n] [-o dir] [-r] [-n] [example ...] + + -m mode; default both + -t per-run timeout in seconds; default 2400 + -j runs in parallel; default 3 + -o results directory; default build/agent-validation + -r also re-run each example with -resume and check it replays from cache + -n dry run: run every setup check and print the plan, launch nothing + +Run 'make compile assemble' first. Requires OPENAI_API_KEY; local mode needs a +running Docker daemon (every agent task runs in the pi runner image the plugin +declares), and k8s mode also needs the cluster, Wave and +S3 access examples/agents/k8s-local.config expects. + +A full run calls a real model once per agent invocation and can take an hour, so +-n first: it answers "is this machine set up, and is the example list what I +think it is" for free. -r roughly doubles the wall clock. +TXT + exit "${1:-0}" +} + +while getopts ':m:t:j:o:rnh' opt; do + case $opt in + m) case $OPTARG in local|k8s) MODES=$OPTARG ;; both) MODES="local k8s" ;; + *) echo "Unknown mode: $OPTARG (use local, k8s or both)" >&2; exit 2 ;; esac ;; + t) TIMEOUT=$OPTARG ;; + j) JOBS=$OPTARG ;; + o) RESULTS=$OPTARG ;; + r) RESUME=1 ;; + n) DRY=1 ;; + h) usage 0 ;; + *) usage 2 ;; + esac +done +shift $((OPTIND-1)) + +AGENTS_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +BASE_DIR=$(cd "$AGENTS_DIR/../.." && pwd) +RESULTS=${RESULTS:-$BASE_DIR/build/agent-validation} + +# `timeout` is GNU coreutils; on macOS it arrives as gtimeout with brew. +TIMEOUT_BIN=$(command -v timeout || command -v gtimeout) || { + echo "ERROR: no 'timeout' command found (macOS: brew install coreutils)" >&2; exit 2; } + +[[ -f $BASE_DIR/.launch.classpath ]] || { + echo "ERROR: missing .launch.classpath -- run 'make compile' first" >&2; exit 2; } +[[ -d $BASE_DIR/plugins/nf-agent-pi/build/target/libs ]] || { + echo "ERROR: nf-agent-pi is not built -- run 'make assemble' first" >&2; exit 2; } +[[ -n ${OPENAI_API_KEY:-} ]] || { + echo "ERROR: OPENAI_API_KEY is not set -- the examples call a real model" >&2; exit 2; } +# The `pi` runner carries no host-local runtime, so every agent task runs in the runner +# image through the engine its config enables -- Docker, for these examples. Check it here +# rather than let each run fail on the first task. +[[ $MODES != *local* ]] || docker info >/dev/null 2>&1 || { + echo "ERROR: the Docker daemon is not available -- local mode runs every agent task" >&2 + echo " in the pi runner image the nf-agent-pi plugin declares" >&2; exit 2; } + +# An example is a directory holding a `main.nf` -- NOT merely a directory. Discovering by +# directory alone also picks up this script's own results dir, a dot-dir left behind by some +# other tool, and a half-built example carrying only `data/`; each of those then fails every +# single run with `Missing project main script`, which reads like a product bug in the report. +EXAMPLES=("$@") +if [[ ${#EXAMPLES[@]} -eq 0 ]]; then + while IFS= read -r d; do EXAMPLES+=("$(basename "$d")"); done \ + < <(find "$AGENTS_DIR" -mindepth 1 -maxdepth 1 -type d -exec test -f '{}/main.nf' \; -print | sort) +fi + +# Discovery can no longer yield one of these, so this only ever catches a name typed on the +# command line. Say so before burning a run on it. +notexamples=() +for ex in "${EXAMPLES[@]}"; do + [[ -f $AGENTS_DIR/$ex/main.nf ]] || notexamples+=("$ex") +done +if [[ ${#notexamples[@]} -gt 0 ]]; then + echo "ERROR: not an agent example (no main.nf): ${notexamples[*]}" >&2 + exit 2 +fi + +# Four examples assemble a real genome and need an input FASTQ that is gitignored on purpose +# (examples/data/README.md). Without it they fail on a missing input long before reaching any +# agent, which reads like a product bug. +# +# Checked through each example's `data` symlink rather than at $DATA_DIR directly: the symlink +# is what the run actually resolves, so a missing or broken one has to fail here too -- and it +# is the only failure mode this layout adds over a per-example copy. +FASTQ_URL=https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/sarscov2/illumina/fastq/sarscov2_mus-musculus.fastq.gz +# ${AGENTS_DIR%/*} rather than $AGENTS_DIR/..: AGENTS_DIR is already absolute (line 87), and +# this keeps the paths printed below readable instead of ending in `/agents/../data`. +DATA_DIR=${AGENTS_DIR%/*}/data +missing=() +for ex in "${EXAMPLES[@]}"; do + case $ex in + 11_contig-filter) [[ -f $AGENTS_DIR/$ex/data/sample.fastq.gz ]] || missing+=("$ex") ;; + 07_module-as-tool|09_goal-directed|12_isolate-triage) + [[ -f $AGENTS_DIR/$ex/data/sample.fastq ]] || missing+=("$ex") ;; + esac +done +if [[ ${#missing[@]} -gt 0 ]]; then + echo "ERROR: missing input FASTQ for: ${missing[*]}" >&2 + echo "One fetch serves all of them -- they share $DATA_DIR through a 'data' symlink." >&2 + echo "Both forms are needed (11_contig-filter reads the gzipped one):" >&2 + echo " mkdir -p $DATA_DIR" >&2 + echo " curl -sL $FASTQ_URL -o $DATA_DIR/sample.fastq.gz" >&2 + echo " gunzip -c $DATA_DIR/sample.fastq.gz > $DATA_DIR/sample.fastq" >&2 + for ex in "${missing[@]}"; do + [[ -L $AGENTS_DIR/$ex/data ]] \ + || echo "NOTE: $ex/data is not a symlink -- expected one pointing at ../../data" >&2 + done + exit 2 +fi + +# One run, in its own directory: concurrent runs must not share .nextflow.log, +# .nextflow/cache or work/. With -r the pair runs SEQUENTIALLY inside this function -- +# the resume run must see the cache the fresh run just wrote -- while different +# examples still run concurrently. +run_one() { + local ex=$1 mode=$2 dir=$RESULTS/$mode/$1 t0=$SECONDS + rm -rf "$dir" && mkdir -p "$dir" || return 2 + local args=( run -ansi-log false "$AGENTS_DIR/$ex" ) + [[ $mode == k8s ]] && args+=( -c "$AGENTS_DIR/k8s-local.config" ) + ( cd "$dir" && "$TIMEOUT_BIN" -s TERM -k 30 "$TIMEOUT" "$BASE_DIR/launch.sh" "${args[@]}" ) \ + > "$dir/console.log" 2>&1 + echo $? > "$dir/status" + echo $((SECONDS-t0)) > "$dir/elapsed" + # Snapshot the fresh run's log: a resume run would rotate it to .nextflow.log.1 and the + # transport signals below must keep describing the run that actually called the model. + cp "$dir/.nextflow.log" "$dir/fresh.nextflow.log" 2>/dev/null + (( RESUME )) || return 0 + t0=$SECONDS + ( cd "$dir" && "$TIMEOUT_BIN" -s TERM -k 30 "$TIMEOUT" "$BASE_DIR/launch.sh" "${args[@]}" -resume ) \ + > "$dir/resume.log" 2>&1 + echo $? > "$dir/resume-status" + echo $((SECONDS-t0)) > "$dir/resume-elapsed" +} + +# Every check above has now passed, so a dry run has already answered the question it +# exists for -- is this machine set up, and is the example list the expected one. Print +# the plan and stop before the first model call. +if (( DRY )); then + for mode in $MODES; do + echo "== $mode: ${#EXAMPLES[@]} examples, $JOBS at a time (dry run)$( (( RESUME )) && echo ', + -resume pass')" + for ex in "${EXAMPLES[@]}"; do + cfg=""; [[ $mode == k8s ]] && cfg=" -c $AGENTS_DIR/k8s-local.config" + printf ' %-24s %s\n' "$ex" "launch.sh run -ansi-log false $AGENTS_DIR/$ex$cfg" + done + done + echo + echo "dry run: setup checks passed; nothing launched" + echo "results would go to: $RESULTS" + exit 0 +fi + +for mode in $MODES; do + echo "== $mode: ${#EXAMPLES[@]} examples, $JOBS at a time$( (( RESUME )) && echo ' (fresh + resume)')" + for ex in "${EXAMPLES[@]}"; do + while [[ $(jobs -rp | wc -l) -ge $JOBS ]]; do wait -n 2>/dev/null || sleep 2; done + run_one "$ex" "$mode" & + done + wait +done + +# -- report. Every example runs through the plugin-hosted RPC broker, so the +# transport signals are as much the result as the exit code. `remote` is now +# always true -- the task is always containerized -- so a `remote=false` means a +# stale build resolved a host-local launch path that no longer exists. +printf '\n%-24s' EXAMPLE; for m in $MODES; do printf '%-34s' "$(echo "$m" | tr a-z A-Z)"; done; echo +failed=0 +for ex in "${EXAMPLES[@]}"; do + printf '%-24s' "$ex" + for mode in $MODES; do + dir=$RESULTS/$mode/$ex log=$RESULTS/$mode/$ex/fresh.nextflow.log + rc=$(cat "$dir/status" 2>/dev/null || echo '?') + secs=$(cat "$dir/elapsed" 2>/dev/null || echo '?') + # `grep -c` prints the count AND exits 1 on no match, so reset rather than + # appending a fallback -- `|| echo 0` yields a two-line "0\n0" that compares unequal + reg=$(grep -c 'Registering agent RPC invocation' "$log" 2>/dev/null) || reg=0 + rej=$(grep -c 'Rejected agent RPC connection' "$log" 2>/dev/null) || rej=0 + remote=$(grep -o 'remote=[a-z]*' "$log" 2>/dev/null | sort -u | sed 's/remote=//' | paste -sd, -) + [[ $rc == 0 && $rej == 0 ]] || failed=$((failed+1)) + printf '%-34s' "$([[ $rc == 0 ]] && echo ok || echo "FAIL($rc)") ${secs}s reg=$reg remote=${remote:--} rej=$rej" + done + echo +done + +# -- resume report. +# +# The signal that the model was NOT called again is `completed=0`, NOT reg=0. A capability +# is minted while the task script is GENERATED, which happens before `storeDir` and the +# resume cache are consulted, so a cache-hit task still registers one and leaves it +# unconsumed -- the broker says exactly that at shutdown. Gating on reg would fail every +# correct resume. +if (( RESUME )); then + # What the workflow itself printed, with Nextflow's chrome removed. `view` output is the + # only thing that must match across the pair; staging/pull lines legitimately appear on the + # fresh run and not on the resumed one. + answer_of() { + perl -pe 's/(\[(?:WARN|ERROR|INFO|PIPELINE|WORKDIR|PROCESS|SUCCESS|FAILED)\b)/\n$1/g' "$1" 2>/dev/null \ + | grep -vE '^\[(WARN|ERROR|INFO|PIPELINE|WORKDIR|PROCESS|SUCCESS|FAILED)\b' \ + | grep -vE '^(N E X T F L O W|Launching|executor >|Staging foreign file:|Pulling |Uploading |Downloading |Monitor)' \ + | sed 's/[[:space:]]*$//' | grep -vE '^$' + } + + printf '\n%-24s' 'EXAMPLE (-resume)'; for m in $MODES; do printf '%-34s' "$(echo "$m" | tr a-z A-Z)"; done; echo + for ex in "${EXAMPLES[@]}"; do + printf '%-24s' "$ex" + for mode in $MODES; do + dir=$RESULTS/$mode/$ex + rc2=$(cat "$dir/resume-status" 2>/dev/null || echo '?') + sum=$(grep -o 'completed=[0-9]* failed=[0-9]* cached=[0-9]*' "$dir/resume.log" 2>/dev/null | tail -1) + c2=$(sed -E 's/completed=([0-9]+).*/\1/' <<<"$sum"); k2=$(sed -E 's/.*cached=([0-9]+).*/\1/' <<<"$sum") + answer_of "$dir/console.log" > "$dir/answer-fresh.txt" + answer_of "$dir/resume.log" > "$dir/answer-resume.txt" + # Independent agents' view() output interleaves in a nondeterministic ORDER, so compare + # order-insensitively. And when nothing executes there are no progress lines to break + # two view outputs apart, so one whose text lacks a trailing newline abuts the next -- + # same content, different layout. Label that `same*` rather than hide it. + if diff -q <(sort "$dir/answer-fresh.txt") <(sort "$dir/answer-resume.txt") >/dev/null 2>&1; then + same=same + elif [[ "$(tr -d '[:space:]' < "$dir/answer-fresh.txt")" == "$(tr -d '[:space:]' < "$dir/answer-resume.txt")" ]]; then + same='same*' + else + same=DIFFER + fi + ok=1 + [[ $rc2 == 0 ]] || ok=0 + [[ -n $sum && ${c2:-1} == 0 ]] || ok=0 # nothing may re-execute + [[ ${k2:-0} -gt 0 ]] || ok=0 # something must come from cache + [[ $same == same || $same == 'same*' ]] || ok=0 + (( ok )) || failed=$((failed+1)) + printf '%-34s' "$( (( ok )) && echo ok || echo FAIL) completed=${c2:-?} cached=${k2:-?} $same" + done + echo + done + echo + echo " completed=0 is the no-model-call signal; 'same*' means the answers match ignoring whitespace" +fi + +# The broker moved from nextflow core into nf-agent-pi; the old core logger +# reappearing means a stale class is being resolved. +stale=$(grep -rl 'DEBUG nextflow\.agent\.AgentRpcBroker' "$RESULTS" 2>/dev/null | wc -l | tr -d ' ') +[[ $stale == 0 ]] || { echo "WARNING: $stale run(s) logged the pre-move core AgentRpcBroker"; failed=$((failed+1)); } + +echo +echo "results: $RESULTS" +if [[ $failed -gt 0 ]]; then echo "FAILED: $failed"; exit 1; fi +echo "all runs passed" diff --git a/examples/data/.gitignore b/examples/data/.gitignore new file mode 100644 index 0000000000..551b9b762d --- /dev/null +++ b/examples/data/.gitignore @@ -0,0 +1,8 @@ +# The fixtures themselves are fetched, not committed: the sarscov2 read set is 5.5 MB +# gzipped and 28 MB raw, and it is upstream test data with a canonical home (see +# README.md). The DIRECTORY is committed, though -- `examples/agents/*/data` are symlinks +# into it, and a symlink to a path that does not exist reads as a broken example rather +# than as a missing download. +* +!.gitignore +!README.md diff --git a/examples/data/README.md b/examples/data/README.md new file mode 100644 index 0000000000..114d2974bf --- /dev/null +++ b/examples/data/README.md @@ -0,0 +1,42 @@ +# Shared example fixtures + +Input data shared by the examples, kept here once instead of per example. The examples that +need it reach it through a `data` symlink pointing at this directory, so all of them read the +same bytes and one fetch serves every example: + +``` +examples/agents/07_module-as-tool/data -> ../../data +examples/agents/09_goal-directed/data -> ../../data +examples/agents/11_contig-filter/data -> ../../data +examples/agents/12_isolate-triage/data -> ../../data +``` + +The files are not committed — see `.gitignore` for why. Fetch them once, from the repository +root: + +```bash +mkdir -p examples/data +curl -sL https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/genomics/sarscov2/illumina/fastq/sarscov2_mus-musculus.fastq.gz \ + -o examples/data/sample.fastq.gz +gunzip -c examples/data/sample.fastq.gz > examples/data/sample.fastq +``` + +That produces both forms, and both are needed: + +| File | Used by | Why this form | +|---|---|---| +| `sample.fastq` | `07_module-as-tool`, `09_goal-directed`, `12_isolate-triage` | uncompressed | +| `sample.fastq.gz` | `11_contig-filter` | `SEQTK_SAMPLE` names its output after its input and emits `*.fastq.gz`, so the input must be gzipped | + +## What the data is + +The sarscov2 Illumina read set from [nf-core/test-datasets](https://github.com/nf-core/test-datasets) +(`modules` branch), 136,166 single-end reads. It is a small **viral** read set, which is why +`09_goal-directed` and `12_isolate-triage` document a *failing* QC verdict as their expected +outcome: it assembles to roughly N50=310 over ~6 contigs, below the N50>=500 bar those examples +gate on. Substituting a different dataset invalidates the expected results recorded in those +examples and in `examples/agents/README.md`. + +`19_shell-tools/` is the exception to all of this: it ships its own 2.9 kB FASTA in its own +`data/` directory, because the statistics quoted in its README only reproduce with those exact +bytes. diff --git a/modules/nextflow/build.gradle b/modules/nextflow/build.gradle index 198d6106b1..8e4924d167 100644 --- a/modules/nextflow/build.gradle +++ b/modules/nextflow/build.gradle @@ -74,3 +74,11 @@ dependencies { testFixturesApi 'com.google.jimfs:jimfs:1.2' } +// The agent AST and its generated classes make this the heaviest Groovy compilation in the +// build, and the compiler daemon's 384m default is not enough for it. Kept per-module rather +// than raised globally: no other subproject needs it. The matching TEST heap bump this branch +// used to declare here is gone -- `Separate CLI from runtime` (#5971) sets minHeapSize and +// maxHeapSize for every subproject in the root build.gradle, at the same values. +tasks.withType(GroovyCompile).configureEach { + groovyOptions.forkOptions.memoryMaximumSize = '2048m' +} diff --git a/modules/nextflow/src/main/groovy/nextflow/Session.groovy b/modules/nextflow/src/main/groovy/nextflow/Session.groovy index 34df841e17..ce3c6826e2 100644 --- a/modules/nextflow/src/main/groovy/nextflow/Session.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/Session.groovy @@ -86,6 +86,7 @@ import nextflow.trace.event.TaskEvent import nextflow.trace.event.WorkflowOutputEvent import nextflow.util.Barrier import nextflow.util.ClassLoaderFactory +import nextflow.util.CustomThreadFactory import nextflow.util.HistoryFile import nextflow.util.LoggerHelper import nextflow.util.NameGenerator @@ -111,6 +112,14 @@ class Session implements ISession { final List igniters = new ArrayList<>(20) + /** + * True once the initial dataflow graph has been released. Components created + * afterwards (for example request-scoped tool invocations) must start + * immediately instead of being appended to an ignition list that has already + * been consumed. + */ + private volatile boolean dataflowNetworkFired + final Map outputs = [:] /** @@ -269,6 +278,9 @@ class Session implements ISession { private volatile ExecutorService execService + /** Orchestration pool for agent tasks; lazily created -- see {@link #getAgentExecService}. */ + private volatile ExecutorService agentExecService + private volatile TaskFault fault private volatile Throwable error @@ -548,6 +560,20 @@ class Session implements ISession { igniters.add(action) } + /** + * Schedule a dataflow processor during graph construction, or start it + * immediately when it was created after network ignition. + */ + void addProcessorIgniter( Closure action ) { + synchronized( igniters ) { + if( !dataflowNetworkFired ) { + igniters.add(action) + return + } + } + action.call() + } + void fireDataflowNetwork(boolean preview=false) { checkConfig() notifyFlowBegin() @@ -564,6 +590,9 @@ class Session implements ISession { } private void callIgniters() { + synchronized( igniters ) { + dataflowNetworkFired = true + } log.debug "Igniting dataflow network (${igniters.size()})" for( Closure action : igniters ) { try { @@ -610,6 +639,52 @@ class Session implements ISession { return this } + /** + * The ORCHESTRATION pool, distinct from {@link #execService}. + * + *

{@code execService} is the local executor's RUN pool, and every population that draws from + * it is already bounded by {@link nextflow.processor.LocalPollingMonitor}'s cpu gate -- which is + * why sizing it to the core count has always been correct. + * + *

An agent breaks that invariant by construction. It is not compute but an ORCHESTRATOR: its + * body blocks waiting on the tool sub-tasks it dispatches, and it is admitted by + * {@link nextflow.processor.AgentPollingMonitor}, which deliberately applies no cpu or capacity + * throttle. Sharing one pool therefore creates a dependency edge from a pool member to another + * member of the SAME pool -- the textbook deadlock: enough blocked orchestrators and no thread + * is left to run the sub-tasks that would release them. No size fixes it, because the number of + * concurrent agents is unbounded. + * + *

So orchestration gets its own pool, and the invariant that makes this deadlock-free at ANY + * size is directional: + * + *

an orchestration thread may block on the execution pool; + * an execution thread must never block on the orchestration pool.
+ * + *

The dependency crosses a pool boundary in one direction only, so there is no cycle to + * close. + * + *

A CACHED pool, deliberately: threads are created on demand, reused while warm and reaped + * after 60s, with no ceiling. The JDK recommends exactly this shape here -- direct handoff + * "avoids lockups when handling sets of requests that might have internal dependencies", and + * "generally require unbounded maximumPoolSizes to avoid rejection". A ceiling would not make + * the pool safer: at the ceiling the excess QUEUES, and a queued task that a blocked thread is + * waiting for is the same deadlock in a different costume. What bounds agent concurrency is + * {@code maxForks} on the agent itself (see {@link nextflow.agent.AgentConfig#DEFAULT_MAX_FORKS}), + * which caps admission BEFORE any thread is demanded -- the right layer, and a knob that also + * answers the provider's rate limit. + * + *

Created lazily: a run with no agents allocates nothing. + */ + synchronized ExecutorService getAgentExecService() { + if( agentExecService == null ) { + log.debug "Creating agent orchestration pool" + agentExecService = Threads.useVirtual() + ? Executors.newVirtualThreadPerTaskExecutor() + : Executors.newCachedThreadPool(new CustomThreadFactory('nf-agent')) + } + return agentExecService + } + ScriptBinding getBinding() { binding } @Memoized @@ -727,6 +802,8 @@ class Session implements ISession { // shutdown executor service execService?.shutdown() execService = null + agentExecService?.shutdown() + agentExecService = null log.trace "Session > executor shutdown" // -- close db @@ -859,6 +936,7 @@ class Session implements ISession { allOperators *. terminate() execService?.shutdownNow() + agentExecService?.shutdownNow() GParsConfig.shutdown() } @@ -894,8 +972,11 @@ class Session implements ISession { final enabled = config.navigate('nextflow.enable.configProcessNamesValidation', true) as boolean if( enabled ) { final names = ScriptMeta.allProcessNames() + final agents = ScriptMeta.allAgentNames() log.debug "Process names: ${names.join(', ')}" - validateConfig(names) + if( agents ) + log.debug "Agent names: ${agents.join(', ')}" + validateConfig(names, agents) } else { log.debug "Config process names validation disabled as requested" @@ -930,32 +1011,33 @@ class Session implements ISession { * Validate the config file * * @param processNames The list of process names defined in the pipeline script + * @param agentNames The list of agent names defined in the pipeline script */ - void validateConfig(Collection processNames) { - def warns = validateConfig0(processNames) + void validateConfig(Collection processNames, Collection agentNames=[]) { + def warns = validateConfig0(processNames, agentNames) for( String str : warns ) log.warn str } - protected List validateConfig0(Collection processNames) { + protected List validateConfig0(Collection processNames, Collection agentNames=[]) { List result = [] - if( !(config.process instanceof Map) ) - return result - - // verifies that all process config names have a match with a defined process - def keys = (config.process as Map).keySet() - for(String key : keys) { - String name = null - if( key.startsWith('withName:') ) { - name = key.substring('withName:'.length()) + if( config.process instanceof Map ) { + // verifies that all process config names have a match with a defined process + def keys = (config.process as Map).keySet() + for(String key : keys) { + if( key.startsWith('withName:') ) + checkValidProcessName(processNames, key.substring('withName:'.length()), result) } - else if( key.startsWith('$') ) { - name = key.substring(1) - log.warn1 "Process config \$${name} is deprecated, use withName:'${name}' instead" + } + + // same check for the `agent` scope -- an agent is never matched by a `process` + // selector, so the two name sets are validated independently + if( config.agent instanceof Map ) { + for( String key : (config.agent as Map).keySet() ) { + if( key.startsWith('withName:') ) + checkValidProcessName(agentNames, key.substring('withName:'.length()), result, 'agent') } - if( name ) - checkValidProcessName(processNames, name, result) } return result @@ -967,15 +1049,16 @@ class Session implements ISession { * @param selector The process name to check * @param processNames The list of processes declared in the workflow script * @param errorMessage A list of strings used to return the error message to the caller + * @param kind The noun used in the warning message ({@code process} or {@code agent}) * @return {@code true} if the name specified belongs to the list of process names or {@code false} otherwise */ - protected boolean checkValidProcessName(Collection processNames, String selector, List errorMessage) { + protected boolean checkValidProcessName(Collection processNames, String selector, List errorMessage, String kind='process') { final matches = processNames.any { name -> ProcessConfigBuilder.matchesSelector(name, selector) } if( matches ) return true def suggestion = processNames.closest(selector) - def message = "There's no process matching config selector: $selector" + def message = "There's no $kind matching config selector: $selector" if( suggestion ) message += " -- Did you mean: ${suggestion.first()}?" errorMessage << message.toString() diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentCallInfo.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentCallInfo.groovy new file mode 100644 index 0000000000..992f5deb39 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentCallInfo.groovy @@ -0,0 +1,59 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic + +/** + * Core-owned seam for carrying the concrete model snapshot resolved by an + * {@link AgentRunner} back to the core task body, without violating the + * core↔plugin boundary (the plugin still returns only a {@code String} from + * {@code run}). Mirrors the {@code ModuleToolBridge.CONTEXT} ThreadLocal + * pattern: the plugin writes via {@link #setResolvedModel} on the same + * exec-service body thread that then reads via {@link #consumeResolvedModel}. + * + *

Used for resume drift observability (design §9.5/D6): after + * {@code model.chat(...)} the plugin stashes {@code response.metadata().modelName()}; + * the core body reads it once and stores it in the task context under + * {@code $agentResolvedModel} so it is persisted/replayed by the cache. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentCallInfo { + + private static final ThreadLocal RESOLVED_MODEL = new ThreadLocal() + + /** Set the concrete model snapshot resolved on the current thread. Called by the plugin after {@code model.chat}. */ + static void setResolvedModel(String modelName) { + RESOLVED_MODEL.set(modelName) + } + + /** + * Read and clear the resolved model snapshot for the current thread. + * Returns {@code null} when none was set (e.g. no fresh call happened). + */ + static String consumeResolvedModel() { + final value = RESOLVED_MODEL.get() + RESOLVED_MODEL.remove() + return value + } + + /** Clear any resolved model snapshot for the current thread. */ + static void clear() { + RESOLVED_MODEL.remove() + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentConfig.groovy new file mode 100644 index 0000000000..2a7660ba39 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentConfig.groovy @@ -0,0 +1,670 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.Session +import nextflow.SysEnv +import nextflow.config.spec.ConfigOption +import nextflow.config.spec.ConfigScope +import nextflow.config.spec.ScopeName +import nextflow.config.spec.SpecNode +import nextflow.exception.AbortOperationException +import nextflow.script.dsl.ConfigSelectorResolver +import nextflow.script.dsl.Description +import nextflow.script.dsl.ProcessConfigBuilder +import nextflow.util.Duration +import nextflow.util.MemoryUnit +import nextflow.agent.rpc.AgentRpcConfig + +/** + * Model the agent-only options of the `agent` configuration scope: the runner, the endpoint and + * credential of the LLM provider, and the model/iteration/timeout/trace settings that are + * DEFAULTS for agent declarations. + * + *

The rest of the scope is the task-directive axis (the same directives a process + * accepts, e.g. {@code executor}, {@code cpus}, {@code container}) applied to the agent + * task by {@link nextflow.script.dsl.ProcessConfigBuilder}, independently from the + * {@code process} scope. Both axes are resolved with the same selector ladder — see + * {@link #resolveOptions} and {@link nextflow.script.AgentDef#buildAgentTask}. + * + * @author Paolo Di Tommaso + */ +@ScopeName("agent") +@Description(""" + The `agent` scope controls the default settings applied to agents. +""") +@Slf4j +@CompileStatic +class AgentConfig implements ConfigScope { + + static final String DEFAULT_EXECUTOR = 'local' + + /** + * Default cap on concurrently running agent tasks, applied when the agent declares no + * {@code maxForks} of its own. + * + *

Unlike a process, an agent is admitted by {@link nextflow.processor.AgentPollingMonitor}, + * which applies no cpu or capacity throttle -- so without this an agent fans out as wide as its + * input channel. That is a THROUGHPUT concern rather than a correctness one (orchestration runs + * on its own pool, see {@link nextflow.Session#getAgentExecService}), but an uncapped fan-out + * of concurrent LLM calls invites provider rate-limiting. + * + *

Deliberately a constant rather than a function of the core count: an agent waits on a + * remote model, so the machine's cpus say nothing about how many calls a provider tolerates. + * Conservative by intent and meant to be tuned -- raise it with {@code maxForks} on the agent, + * or in the {@code agent} scope. + */ + static final int DEFAULT_MAX_FORKS = 10 + + /** + * The provider prefix that names the OpenAI WIRE PROTOCOL (design D1). It no longer gates the + * endpoint/credential ladder -- {@link #apiProviderFor} answers "whose key and whose endpoint" + * for every provider -- but it still answers "which client speaks to it", the one question the + * model-id prefix is authoritative for. + */ + static final String OPENAI_PROVIDER = 'openai' + + /** + * The provider tokens the PROVIDER tier of the ladder knows, each mapped to the environment + * variables read for it, in order -- first TRUTHY hit wins (design D2). A provider is not + * always 1:1 with a variable ({@code GOOGLE_API_KEY} / {@code GEMINI_API_KEY}), hence a list. + * + *

The namespace is CLOSED on purpose. The provider is pipeline-supplied text (the + * {@code agent.apiProvider} option, or the model-id prefix) and the endpoint that would receive + * the credential is pipeline-supplied config too, so uppercasing an arbitrary token into + * {@code _API_KEY} would let a pipeline name ANY variable in the driver's environment and + * have it delivered to an address of its choosing. An unlisted token resolves nothing; written + * as {@code agent.apiProvider} it is rejected outright (see {@link #resolveApiProvider}). + * + *

The variables match {@code AgentSecretMasker.SECRET_ENV_KEYS}, which already redacts + * exactly these from captured runner output: the redaction backstop and the resolution contract + * must not disagree about what counts as a credential. + */ + private static final Map> PROVIDER_API_KEY_VARS = Collections.unmodifiableMap([ + anthropic : List.of('ANTHROPIC_API_KEY'), + azure : List.of('AZURE_OPENAI_API_KEY'), + gemini : List.of('GEMINI_API_KEY', 'GOOGLE_API_KEY'), + google : List.of('GOOGLE_API_KEY', 'GEMINI_API_KEY'), + mistral : List.of('MISTRAL_API_KEY'), + openai : List.of('OPENAI_API_KEY'), + openrouter: List.of('OPENROUTER_API_KEY') ] as Map>) + + /** + * The endpoint half of {@link #PROVIDER_API_KEY_VARS}, deliberately NOT the same key set: + * {@code OPENAI_BASE_URL} and {@code ANTHROPIC_BASE_URL} are the official SDK spellings and + * {@code AZURE_OPENAI_ENDPOINT} is Azure's, while Google, Mistral and OpenRouter define no such + * variable at all. Reading a {@code MISTRAL_BASE_URL} would be Nextflow inventing a convention + * under a vendor's name and then owing it forever; retargeting one of those providers is what + * {@code agent.baseUrl} and {@code NXF_AGENT_BASE_URL} are for. + */ + private static final Map> PROVIDER_BASE_URL_VARS = Collections.unmodifiableMap([ + anthropic: List.of('ANTHROPIC_BASE_URL'), + azure : List.of('AZURE_OPENAI_ENDPOINT'), + openai : List.of('OPENAI_BASE_URL') ] as Map>) + + /** + * Hosts whose provider is unambiguous, used to infer the credential namespace from a + * provider-neutral endpoint (design D3). Deliberately short: every entry is a compatibility + * commitment, and a wrong one misroutes a credential. Matched on the HOST only -- exactly or as + * a dot-suffix -- never as a substring, so neither {@code https://evil.example/openai/v1} nor + * {@code https://api.openai.com.evil.example} matches. + */ + private static final Map PROVIDER_HOSTS = Collections.unmodifiableMap([ + 'api.openai.com' : 'openai', + 'api.anthropic.com': 'anthropic', + 'openrouter.ai' : 'openrouter', + 'api.mistral.ai' : 'mistral' ] as Map) + + /** + * The `agent` scope options that are NOT task directives, derived from the + * {@code @ConfigOption} fields and the nested {@link ConfigScope} fields declared by this + * class. Task directives come from {@link nextflow.script.dsl.ProcessDsl.DirectiveDsl} + * instead, so a new agent-only option can never be mistaken for a process directive. + * + * NOTE: a nested {@code agent.} scope is declared as a field whose type implements + * {@link ConfigScope} and which is NOT annotated {@code @ConfigOption} (see {@code rpc}) -- + * {@link nextflow.config.spec.SpecNode.Scope#of} recurses into it, so every option inside is + * individually validated. Such a field MUST also be picked up here, otherwise + * {@code agent { x { ... } }} is applied as a bogus task directive. + * + * A plugin CANNOT contribute a nested scope: {@link nextflow.config.ConfigValidator} registers a + * {@code @ScopeName} as a single top-level key, so a dotted {@code @ScopeName('agent.x')} is + * unreachable for the segment-by-segment spec lookup. Nested agent scopes therefore live here. + */ + static final Set AGENT_ONLY_OPTIONS = declaredOptions() + + private static Set declaredOptions() { + final names = SpecNode.Scope.of(AgentConfig, '').children().keySet() + return Collections.unmodifiableSet(new LinkedHashSet(names)) + } + + @ConfigOption + @Description(""" + The agent runner extension to use (for example `pi` or `langchain4j`). Required when more than one runner plugin is enabled. + """) + final String runner + + @ConfigOption + @Description(""" + The default model (`provider/model`) used by an agent that does not declare a `model` directive. + """) + final String model + + @ConfigOption + @Description(""" + The provider namespace the API key and base URL are taken from when they come from the environment, one of `anthropic`, `azure`, `gemini`, `google`, `mistral`, `openai`, `openrouter`. When not specified, it is inferred from the host of `baseUrl` when that names a well-known provider, and otherwise from the model id prefix. It does NOT select the wire protocol, which is always the model id prefix. + """) + final String apiProvider + + @ConfigOption + @Description(""" + The API key used to authenticate with the LLM provider. When not specified, the `NXF_AGENT_API_KEY` environment variable is used, then the API provider's own variable (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) when the endpoint it would be sent to belongs to that provider. + """) + final String apiKey + + @ConfigOption + @Description(""" + The base URL of the endpoint serving the model, e.g. a local vLLM or Ollama server or a gateway. When not specified, the `NXF_AGENT_BASE_URL` environment variable is used, then the API provider's own variable (`OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL`, `AZURE_OPENAI_ENDPOINT`), then the provider default. + """) + final String baseUrl + + @ConfigOption + @Description(""" + The default maximum number of LLM iterations used by an agent that does not declare a `maxIterations` directive (default: `20`). + """) + final Integer maxIterations + + @ConfigOption + @Description(""" + The amount of time to wait for an LLM chat request to complete before failing (default: `120 sec`). + """) + final Duration requestTimeout + + @ConfigOption + @Description(""" + The maximum size of a structured tool-output file whose contents are passed to the LLM; larger outputs are returned as a path handle (default: `32 KB`). + """) + final MemoryUnit maxToolOutputInlineSize + + @ConfigOption + @Description(""" + When `true`, log a readable trace of each agent's execution - the turns, the model reasoning and the tool invocations - at INFO level; the tool inputs and outputs are logged at DEBUG. Enabled by the `-with-agent-trace` run option (default: `false`). + """) + final Boolean trace + + @Description(""" + Settings for the agent RPC broker that a canonical agent task connects back to. + """) + final AgentRpcConfig rpc + + /** + * The environment the PROVIDER tier of the ladder reads. Held rather than re-read because that + * tier is resolved per MODEL, long after construction, and an object whose answers depend on + * when they are asked cannot be reasoned about (or swapped in a test). + * + * NOTE: must stay un-annotated and NOT a {@link ConfigScope}, or {@link #declaredOptions} + * would publish it as a bogus {@code agent} option. + */ + private final Map sysEnv + + /** + * The provider inferred from the provider-NEUTRAL endpoint (design D3), or {@code null} when + * there is none or its host is not recognized. Eager because it depends only on + * {@link #baseUrl}; the third rung of the provider ladder (the model prefix) cannot be, see + * {@link #apiProviderFor}. + */ + private final String inferredProvider + + /* required by extension point -- do not remove */ + AgentConfig() {} + + AgentConfig(Map opts) { + this(opts, SysEnv.get()) + } + + /** + * The same, with the environment passed in rather than read: a test swaps a map instead of the + * process environment, and the deferred provider tier is guaranteed to see the SAME environment + * the neutral tiers were resolved from. + */ + AgentConfig(Map opts, Map env) { + sysEnv = env + runner = opts.runner as String + model = opts.model as String + // The endpoint and the credential resolve on ONE ladder for every provider (design D2): + // the config option, then the provider-neutral `NXF_AGENT_*` variable, then the provider's + // own `_*` variable. Only the first two can be resolved HERE -- the third needs + // the provider, which needs the effective model, which this class never sees (the `model` + // directive is joined with the `agent.model` default one layer up, in AgentDef). So these + // fields carry the NEUTRAL tiers and the provider tier lives in #apiKeyFor/#baseUrlFor, + // the accessors a runner is actually handed. + apiKey = resolveNeutralApiKey(env, opts) + baseUrl = resolveNeutralBaseUrl(env, opts) + apiProvider = resolveApiProvider(opts) + // inference reads the NEUTRAL endpoint only: `_BASE_URL` cannot feed the + // inference that decides which provider's variable to read in the first place (D3) + inferredProvider = inferProviderFromUrl(baseUrl) + // D1 lets an explicit provider win, but one pointed at ANOTHER provider's own host is far + // likelier a mistake than an intention -- and the mistake ships a credential to a third + // party. Say so here, once per agent; #apiKeyFor withholds the provider tier in that case. + if( apiProvider && inferredProvider && apiProvider != inferredProvider ) + log.warn "Agent config sets `agent.apiProvider = '${apiProvider}'` but the endpoint ${baseUrl} is a known `${inferredProvider}` endpoint - no `${apiProvider}` credential will be sent to it; set `agent.apiKey` to the credential this endpoint accepts" + // null casts to null for object types, so the coercion doubles as the null-guard + maxIterations = opts.maxIterations as Integer + requestTimeout = opts.requestTimeout as Duration + maxToolOutputInlineSize = opts.maxToolOutputInlineSize as MemoryUnit + trace = opts.trace as Boolean + rpc = new AgentRpcConfig(opts.rpc instanceof Map ? (Map)opts.rpc : Collections.emptyMap()) + } + + /** + * Resolve the agent-only options for one agent, applying the same selector ladder as + * {@link nextflow.script.dsl.ProcessConfigBuilder#applyConfig}: plain scope (weakest), then + * `withLabel:`, then `withName:` on the base name, the alias, and the fully-qualified name + * (strongest). Last write wins; unlike the directive axis there is no `ext` merge, no + * repeatable directive and no built-in default to preserve, so a flat overwrite is enough. + * + * @param scope the raw `agent` config scope + * @param labels the agent's declared labels (may be null/empty) + * @param baseName the agent's declared name + * @param simpleName the agent's (possibly aliased) simple name + * @param fqName the agent's fully-qualified name + */ + static Map resolveOptions(Map scope, List labels, + String baseName, String simpleName, String fqName) { + final result = new LinkedHashMap() + copyOptions(scope, result) + final targets = labels ?: List.of('') + for( final settings : ConfigSelectorResolver.matchingSettings(scope, targets, baseName, simpleName, fqName) ) + copyOptions(settings, result) + return result + } + + /** + * Copy the agent-only options out of a plain scope or a selector body. A non-map selector + * body is skipped, NOT cast: this method runs before {@code applyConfig}, which reports it + * with the canonical {@code ConfigParseException} ("Unknown config settings for agent with + * name: ..."). + */ + private static void copyOptions(Object source, Map target) { + if( !(source instanceof Map) ) + return + for( final entry : ((Map) source).entrySet() ) { + final key = entry.key.toString() + if( AGENT_ONLY_OPTIONS.contains(key) ) + target.put(key, entry.value) + } + } + + /** + * The credential tiers that are PROVIDER-NEUTRAL: the {@code agent.apiKey} option + * (selector-aware, already merged into {@code opts} by {@link #resolveOptions}) and + * {@code NXF_AGENT_API_KEY}. Both are named by the user for THIS agent's provider, whichever + * that is, so they may be handed to any runner and to any endpoint; the provider tier may not. + * See {@link #apiKeyFor}. + * + *

Returns {@code null} when nothing resolves: whether a credential is REQUIRED is a runner + * concern (a local endpoint needs none), so this must never throw -- it is reached from the + * constructor for every agent, including those driven by a test runner. + * + *

Every tier is tested for TRUTHINESS, not for null, so an empty value falls through + * instead of shadowing the tiers below it: {@code export NXF_AGENT_API_KEY=} and + * {@code agent.apiKey = params.key} with {@code params.key} unset both yield {@code ''}, and + * neither is a credential. Same rule as the config tier of {@code AwsConfig.getAwsProfile0}. + * The user-visible consequence: an explicitly EMPTY {@code agent.apiKey} does not mean "this + * endpoint needs no credential" -- it falls through to the environment. The no-credential path + * of design D8 is reached by leaving the option UNSET while setting a {@code baseUrl} that is + * not a known provider's host (design D5, {@link AgentRunnerRequest#credentialFor}). + * + * @param env the environment to read, always {@link SysEnv#get()} in production + * @param opts the resolved agent-only options + */ + static protected String resolveNeutralApiKey(Map env, Map opts) { + return (opts?.apiKey as String) + ?: (env?.get('NXF_AGENT_API_KEY') as String) + ?: null + } + + /** The endpoint tiers that are PROVIDER-NEUTRAL -- see {@link #resolveNeutralApiKey}. */ + static protected String resolveNeutralBaseUrl(Map env, Map opts) { + return (opts?.baseUrl as String) + ?: (env?.get('NXF_AGENT_BASE_URL') as String) + ?: null + } + + /** + * The {@code agent.apiProvider} option, normalized to the token the provider tables are keyed + * by, or {@code null} when unset (empty is unset, by the same truthiness rule as every other + * tier). + * + *

An unknown value is REJECTED rather than uppercased into a variable name: the closed + * namespace is what keeps {@code _API_KEY} from naming arbitrary environment + * variables (see {@link #PROVIDER_API_KEY_VARS}), and an unrecognized token would otherwise + * land as a silent tier-3 miss -- the failure mode a typo produces, diagnosed nowhere. + */ + static protected String resolveApiProvider(Map opts) { + final value = (opts?.apiProvider as String)?.trim()?.toLowerCase() + if( !value ) + return null + if( !isKnownProvider(value) ) + throw new AbortOperationException("Invalid `agent.apiProvider` value `${opts.apiProvider}` - expected one of: ${knownProviders().join(', ')}") + return value + } + + /** The provider tokens the {@code _*} tier knows. */ + static Set knownProviders() { + return PROVIDER_API_KEY_VARS.keySet() + } + + static boolean isKnownProvider(String provider) { + return provider != null && PROVIDER_API_KEY_VARS.containsKey(provider) + } + + /** The ordered {@code _API_KEY} candidates of a provider; empty when unknown. */ + static List apiKeyVarsFor(String provider) { + return PROVIDER_API_KEY_VARS.getOrDefault(provider, Collections.emptyList()) + } + + /** The ordered endpoint candidates of a provider; empty when it defines no such variable. */ + static List baseUrlVarsFor(String provider) { + return PROVIDER_BASE_URL_VARS.getOrDefault(provider, Collections.emptyList()) + } + + /** The name of the first {@code _API_KEY} candidate SET in {@code env}, or null. */ + static protected String providerApiKeyVar(Map env, String provider) { + return firstSetVar(env, apiKeyVarsFor(provider)) + } + + /** The PROVIDER tier of the credential ladder, ungated -- {@link #apiKeyFor} owns the gate. */ + static protected String resolveProviderApiKey(Map env, String provider) { + final name = providerApiKeyVar(env, provider) + return name != null ? env.get(name) as String : null + } + + /** The PROVIDER tier of the endpoint ladder. */ + static protected String resolveProviderBaseUrl(Map env, String provider) { + final name = firstSetVar(env, baseUrlVarsFor(provider)) + return name != null ? env.get(name) as String : null + } + + private static String firstSetVar(Map env, List names) { + if( env == null ) + return null + for( final name : names ) + if( env.get(name) ) // TRUTHINESS, so an exported-but-empty variable falls through + return name + return null + } + + /** + * The provider that owns the given endpoint, or {@code null} when its host is not one this + * table recognizes (design D3). Never guesses and never partial-matches: no match means the + * caller falls back to the model prefix, which is the conservative answer. + */ + static String inferProviderFromUrl(String url) { + final host = hostOf(url) + if( !host ) + return null + for( final entry : PROVIDER_HOSTS.entrySet() ) { + if( host == entry.key || host.endsWith('.' + entry.key) ) + return entry.value + } + return null + } + + /** + * The lower-case host of a URL, or {@code null} when it has none (a relative or malformed + * value). A trailing dot -- the absolute-FQDN spelling {@code api.openai.com.} -- is stripped + * so it cannot slip past an exact match. + */ + private static String hostOf(String url) { + if( !url ) + return null + try { + String host = new URI(url.trim()).getHost() + if( host == null ) + return null + host = host.toLowerCase() + return host.endsWith('.') ? host.substring(0, host.length()-1) : host + } + catch( URISyntaxException e ) { + return null + } + } + + /** + * Whether a {@code provider/model} identifier targets the OpenAI wire protocol. Protocol only: + * the credential and the endpoint follow {@link #apiProviderFor} for every provider. + */ + static boolean isOpenAiProtocol(String modelId) { + return providerPrefixOf(modelId) == OPENAI_PROVIDER + } + + /** The lower-case provider prefix of a {@code provider/model} id, or {@code null}. */ + static String providerPrefixOf(String modelId) { + if( !modelId ) + return null + final i = modelId.indexOf('/') + return i > 0 ? modelId.substring(0, i).toLowerCase() : null + } + + /** + * The provider whose namespace the credential and the endpoint are resolved from for the given + * {@code provider/model} id (design D1): the explicit {@code agent.apiProvider}, else the + * provider inferred from the provider-neutral endpoint, else the model-id prefix -- today's + * answer, and the right one for a runner (pi) whose prefix already names a real provider. + * + *

Inference outranks the prefix because the prefix names a PROTOCOL: {@code openai/} with a + * {@code baseUrl} of {@code https://openrouter.ai/api/v1} is the documented way to reach + * OpenRouter, and the credential that endpoint wants is OpenRouter's. + * + *

A method rather than a field because the effective model is the per-agent {@code model} + * directive joined with the {@code agent.model} default one layer up (see + * {@link nextflow.script.AgentDef#buildAgentTask}) -- this class never sees it. The two rungs + * that do not need it ARE fields. + */ + String apiProviderFor(String modelId) { + return apiProvider ?: inferredProvider ?: providerPrefixOf(modelId) + } + + /** + * The credential to hand to a runner for the given {@code provider/model} id: the neutral + * tiers, else the provider's own variable WHEN the endpoint it would travel to belongs to that + * provider. + * + *

The neutral tiers carry no assumption -- the user named that value for THIS agent, + * whatever it targets -- so they always win. + * + *

The provider tier is different: it is a variable exported for a PROVIDER, not for this + * endpoint, and a runner installs whatever it is handed as the credential OF THE MODEL'S + * PROVIDER, ahead of everything it could resolve itself (pi's {@code setRuntimeApiKey} override + * is read before its credential store and before the ambient environment). Sending it to an + * arbitrary address is therefore a credential disclosure the pipeline chooses, so it travels + * only when the endpoint agrees: no endpoint at all AND a provider that is the model prefix's + * own (so the default endpoint dialled is that provider's), the provider's own host, the + * {@code _BASE_URL} that same namespace supplied, or an endpoint vouched for by an + * explicit {@code agent.apiProvider}. A corporate gateway or another provider's host gets + * nothing -- and is told what to set. + */ + String apiKeyFor(String modelId) { + if( apiKey ) + return apiKey + final provider = apiProviderFor(modelId) + // D3 observability: the prefix is the answer a reader assumes, so name the other one when + // it wins -- otherwise which variable gets read is invisible + final prefix = providerPrefixOf(modelId) + if( provider && prefix && provider != prefix ) + log.debug "Resolved API provider `${provider}` for agent model `${modelId}` from ${apiProvider ? '`agent.apiProvider`' : 'the endpoint ' + baseUrl} (the model prefix is `${prefix}`)" + final resolved = resolveProviderApiKey(sysEnv, provider) + if( !resolved ) + return null + if( !providerTierWithheld(modelId) ) + return resolved + final endpoint = baseUrlFor(modelId) + log.warn withheldCredentialWarning(modelId, provider, prefix, endpoint) + return null + } + + /** + * The WARN {@link #apiKeyFor} emits when the provider tier resolved a credential and the + * endpoint gate then withheld it: which variable was read, why its value is not being sent, + * and the two ways to proceed. + * + *

Not {@code static}: the variable name is the one {@code sysEnv} actually set (a provider + * with an alias pair can be read through either), so the message cannot be derived from + * {@code provider} alone. + * + * @param modelId the {@code provider/model} id the credential was resolved for + * @param provider the namespace the credential came from, per {@link #apiProviderFor} + * @param prefix the model id's own {@code provider/} prefix, or null when it has none + * @param endpoint the resolved endpoint, or null when none resolved + */ + private String withheldCredentialWarning(String modelId, String provider, String prefix, String endpoint) { + final String because + if( endpoint ) + because = "the resolved endpoint ${endpoint} is not a known `${provider}` endpoint" + else if( prefix ) + because = "no endpoint resolved, so the request goes to the default endpoint of the model's own `${prefix}` provider and not to `${provider}`" + else + // a model id with no `provider/` prefix names no default endpoint either, so there is + // nothing this credential can be shown to belong to + because = "no endpoint resolved and the model id names no provider, so there is no endpoint this credential is known to belong to" + final remedy = endpoint + ? "`agent.apiProvider = '${provider}'` to confirm it accepts `${provider}` credentials" + : "`agent.baseUrl` to the `${provider}` endpoint this credential belongs to" + return "Not using the ${providerApiKeyVar(sysEnv, provider)} credential for agent model `${modelId}`: ${because} - set `agent.apiKey` to the credential that endpoint accepts, or ${remedy}" + } + + /** + * Whether a {@code _API_KEY} DID resolve for this model and was then withheld by the + * endpoint gate, as opposed to nothing resolving at all. The two look identical through + * {@link #apiKeyFor}, which answers {@code null} to both, yet they are opposite situations: the + * second is the ordinary "no credential here" that a local endpoint (or a runner with sources + * of its own) handles perfectly well, while the first is a MISCONFIGURATION whose credential is + * sitting right there, unusable. Only the first must be reported as an error by a runner that + * has no other source, and neither may be turned into + * {@link AgentRunnerRequest#PLACEHOLDER_API_KEY} -- a placeholder buys an opaque 401 in place of + * a diagnosis that is available. + * + *

Silent by design: {@link #apiKeyFor} already emits the WARN, and this is queried beside it + * for the same model. + */ + boolean credentialWithheldFor(String modelId) { + return providerTierWithheld(modelId) + } + + /** + * The gate decision, without the logging: {@code true} when the provider tier resolved a value + * that {@link #providerCredentialApplies} refuses to send. Shared by {@link #apiKeyFor} and + * {@link #credentialWithheldFor} so the credential and the signal describing it can never + * disagree. + */ + private boolean providerTierWithheld(String modelId) { + if( apiKey ) + return false + final provider = apiProviderFor(modelId) + if( !resolveProviderApiKey(sysEnv, provider) ) + return false + return !providerCredentialApplies(provider, baseUrlFor(modelId), modelId) + } + + /** + * Whether the {@code _API_KEY} tier may travel to the endpoint resolved for this + * agent -- see {@link #apiKeyFor} for why an ambient provider variable needs a matching + * endpoint and the neutral tiers do not. + */ + private boolean providerCredentialApplies(String provider, String endpoint, String modelId) { + if( !endpoint ) + // No endpoint resolved does NOT mean "the resolved provider's own default". The runner + // dials the default endpoint of the provider named by the MODEL PREFIX -- the + // langchain4j client hardcodes https://api.openai.com/v1, and pi dials the provider its + // prefix names -- so a credential resolved in a DIFFERENT namespace would be sent to a + // third party. Concretely: `agent.apiProvider = 'openrouter'` with `openai/gpt-4o` and + // no `agent.baseUrl` used to ship OPENROUTER_API_KEY to OpenAI. + return provider == providerPrefixOf(modelId) + final endpointProvider = inferProviderFromUrl(endpoint) + if( endpointProvider != null ) + // a host this table knows: only its own provider's credential belongs there, whatever + // the config says -- this is also what stops OPENAI_BASE_URL=https://openrouter.ai/... + // from resolving the openai credential by way of the model prefix + return endpointProvider == provider + // an unrecognized host (a gateway, a local server): the variable was not exported for it, + // so it travels only with a statement that it may + return apiProvider != null || endpoint == resolveProviderBaseUrl(sysEnv, provider) + } + + /** + * The endpoint to hand to a runner for the given {@code provider/model} id: the neutral tiers, + * else the provider's own {@code _BASE_URL}. NOT gated like {@link #apiKeyFor} -- an + * endpoint is not a secret, and a variable named for a provider, used for that provider's + * model, is the intent rather than an accident. + * + *

NOTE: the resolved value enters the resume cache key (see + * {@link nextflow.script.AgentDef#canonicalAgentSource}), so a run that already exports one of + * these variables sees a one-time cache invalidation for the agents it now resolves for. + */ + String baseUrlFor(String modelId) { + return baseUrl ?: resolveProviderBaseUrl(sysEnv, apiProviderFor(modelId)) + } + + /** + * The remedy to name when no credential resolved for a provider: the variables the ladder + * ACTUALLY consults for it. A message naming {@code OPENAI_API_KEY} while the run resolved an + * {@code anthropic} provider is worse than no message. + */ + static String missingCredentialHint(String provider) { + final names = new ArrayList() + names.add('NXF_AGENT_API_KEY') + names.addAll(apiKeyVarsFor(provider)) + final vars = names.collect { "`${it}`" }.join(' or ') + final hint = "set `agent.apiKey` in the Nextflow configuration, or the ${vars} environment variable" + return isKnownProvider(provider) ? hint : hint + ", and name the credential namespace with `agent.apiProvider`" + } + + String getRunner() { runner } + + String getModel() { model } + + String getApiProvider() { apiProvider } + + /** The PROVIDER-NEUTRAL credential; what a runner gets is {@link #apiKeyFor}. */ + String getApiKey() { apiKey } + + /** The PROVIDER-NEUTRAL endpoint; what a runner gets is {@link #baseUrlFor}. */ + String getBaseUrl() { baseUrl } + + Integer getMaxIterations() { maxIterations } + + Duration getRequestTimeout() { requestTimeout } + + MemoryUnit getMaxToolOutputInlineSize() { maxToolOutputInlineSize } + + Boolean getTrace() { trace } + + AgentRpcConfig getRpc() { rpc } + + /** + * The effective maximum size, in bytes, of a structured tool-output file whose contents + * are inlined for the LLM; defaults to 32 KB when not configured. + */ + long maxToolOutputInlineBytes() { maxToolOutputInlineSize != null ? maxToolOutputInlineSize.toBytes() : ToolOutputReader.DEFAULT_INLINE_BYTES } + + /** Whether execution tracing is enabled (defaults to {@code false} when not configured). */ + boolean traceEnabled() { trace != null && trace } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentLaunchConditions.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentLaunchConditions.groovy new file mode 100644 index 0000000000..0fac981c56 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentLaunchConditions.groovy @@ -0,0 +1,248 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j + +import nextflow.Session +import nextflow.container.ContainerConfig +import nextflow.exception.ScriptRuntimeException +import nextflow.executor.Executor +import nextflow.agent.rpc.AgentRpcHost +import nextflow.agent.rpc.AgentRpcHostResolver +import nextflow.agent.rpc.AgentRpcConfig + +/** + * The container and executor conditions a canonical agent launch depends on: whether the resolved + * configuration will actually run the agent task in a container, which address the task can dial + * the driver's RPC broker on, and which run options the driver has to add for it. + * + *

Pure statics that read no agent state -- they change when container engines and executors + * change, not when agents do -- so they live beside the rest of the agent runtime rather than in + * {@link nextflow.script.AgentDef}, which merely calls them while lowering an agent to a task. + * + *

Only three of them are that caller's surface: {@link #requireCanonicalLaunch}, + * {@link #withDockerHostGateway} and {@link #requireTaskContainer}. The rest are the steps those + * three are composed of and stay {@code @PackageScope} -- visible to this package's tests, which + * exercise each rung directly, but not to {@code nextflow.script}, which must go through the + * composed guards so it cannot admit a launch on a subset of the conditions. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class AgentLaunchConditions { + + private AgentLaunchConditions() {} + + /** A configured container image excludes both an absent value and the explicit opt-out. */ + @PackageScope + static boolean hasContainer(Object container) { + return container != null && container != false + } + + /** + * Whether the resolved configuration containerizes the agent task: an image PLUS either an + * executor that manages containers itself or an enabled container engine. This mirrors + * {@link nextflow.processor.TaskRun#isContainerEnabled} except that the engine is the one the + * EXECUTOR asks for ({@link nextflow.executor.Executor#containerConfigEngine}), which is also + * what the task itself will use -- the engine-agnostic {@code session.getContainerConfig()} + * disagrees with the task's own view for a container-native executor. + */ + @PackageScope + static boolean willContainerize(Object container, boolean containerNative, boolean engineEnabled) { + return hasContainer(container) && (containerNative || engineEnabled) + } + + /** + * Resolve the container facts a canonical agent launch depends on -- whether the executor manages + * containers itself, and whether the engine that executor asks for is enabled -- and reject the + * run when they do not add up. + * + *

The resolved {@link nextflow.executor.Executor} INSTANCE is the oracle, not its name: + * {@link nextflow.executor.Executor#isContainerNative} can depend on the session (the local + * executor is container-native under Fusion) and + * {@link nextflow.executor.Executor#containerConfigEngine} decides WHICH engine block must be + * enabled. It is the same cached instance {@code createTaskProcessorResolved} reuses. The + * executor is resolved only once an image is present, so a missing {@code agent.container} is + * still reported for an executor that cannot be instantiated here. + * + *

Kept out of {@link nextflow.script.AgentDef#buildAgentTask} so this resolution -- which + * executor is interrogated and which engine block is read -- is directly unit-testable. + * + * @return the container engine that will run the agent task ON THE DRIVER HOST -- or {@code null} + * when the container is launched elsewhere (or no image is declared) and the driver's run + * options therefore do not apply, see {@link #withDockerHostGateway} -- together with the + * resolved broker address, which the caller needs to decide those same run options + */ + static CanonicalLaunch requireCanonicalLaunch(String agentName, String runner, String executor, + Object container, Object containerOptions, AgentRpcConfig rpc, Session session, + boolean runnerAutoSelected = false) { + boolean containerNative = false + boolean engineEnabled = false + String containerEngine = null + // hoisted out of the block below because the ladder needs BOTH: the executor INSTANCE for + // its `instanceof AbstractGridExecutor` row, and the engine config for its run options. They + // are resolved only when an image is present, which is safe only because requireContainerized + // throws first when it is absent + Executor agentExecutor = null + ContainerConfig containerConfig = null + if( hasContainer(container) ) { + agentExecutor = session.getExecutorFactory().getExecutorByName(executor, session) + containerNative = agentExecutor.isContainerNative() + containerConfig = session.getContainerConfig(agentExecutor.containerConfigEngine()) + engineEnabled = containerConfig != null && containerConfig.isEnabled() + containerEngine = containerConfig?.getEngine() + } + requireContainerized(agentName, runner, executor, container, containerNative, engineEnabled, runnerAutoSelected) + final brokerHost = requireBrokerHost(agentName, executor, agentExecutor, containerConfig, containerOptions, rpc, session) + return new CanonicalLaunch(isDriverHostEngine(executor) ? containerEngine : null, brokerHost) + } + + /** + * What {@link #requireCanonicalLaunch} establishes: which engine runs the agent task on the + * driver host, and which address the task will dial the driver's broker on. The two travel + * together because the run options depend on both -- the docker host-gateway mapping is needed + * only when the RESOLVED address is the docker alias. + */ + static class CanonicalLaunch { + final String containerEngine + final AgentRpcHost brokerHost + + CanonicalLaunch(String containerEngine, AgentRpcHost brokerHost) { + this.containerEngine = containerEngine + this.brokerHost = brokerHost + } + } + + /** + * Whether the container engine that launches the agent task runs on the DRIVER host, which is + * the only case in which an engine host alias names the driver. True for the local executor + * (whatever engine it drives, and whether or not Fusion makes it container-native) and false for + * every other executor: a grid executor's docker daemon runs on a compute node, so its + * {@code host.docker.internal} names that node's host, not the driver. + * + *

Delegates to the resolver so the guard and the ladder cannot disagree about what "local" + * means. It is a NECESSARY but not sufficient condition there: the executor name says nothing + * about a docker CLI pointed at another machine, which is error row E3. + */ + @PackageScope + static boolean isDriverHostEngine(String executor) { + return AgentRpcHostResolver.isDriverHostExecutor(executor) + } + + /** + * Reject a canonical agent whose resolved configuration would NOT run the task in a container: + * the launch command is built from paths that exist only inside the runner image, so a + * non-containerized task would fail with `No such file`. Generalises the former + * offload-only check to every executor -- a grid executor with no container engine is + * rejected as loudly as a missing image. + */ + @PackageScope + static void requireContainerized(String agentName, String runner, String executor, + Object container, boolean containerNative, boolean engineEnabled, boolean runnerAutoSelected = false) { + if( willContainerize(container, containerNative, engineEnabled) ) + return + final hint = runnerAutoSelected ? autoSelectedRunnerHint(runner) : '' + // reachable only for a runner that has no image of its own ({@link AgentRunner#getDefaultContainer} + // is null) or for the explicit `agent.container = false` opt-out: a runner that DOES declare + // an image has already had it defaulted into the config by AgentDef.resolveLaunch + if( !hasContainer(container) ) + throw new ScriptRuntimeException("Agent `${agentName}` must declare a container - set `agent.container` to the `${runner}` runner image, which carries the agent proxy and harness the task is launched from${hint}") + // the image may have come from the runner rather than from the user, so this must NOT say + // the user declared `agent.container` -- for the runner this feature exists for, an image + // is always present and this is the message the most common first run gets + throw new ScriptRuntimeException("Agent `${agentName}` has a container image (`agent.container`, or the `${runner}` runner's own image when that is unset) but executor `${executor}` would not run it in a container - enable a container engine (e.g. `docker.enabled = true`) so the `${runner}` runner image is used to run the agent task${hint}") + } + + /** + * Names the runner the message just blamed, for the case where the user never chose it: + * {@link nextflow.agent.AgentRunnerProvider#get} selects the sole installed runner when + * {@code agent.runner} is unset, so a container requirement can otherwise read as coming from + * nowhere. Points at the escape hatch the user is usually reaching for -- a different runner + * plugin, not a different config key. + */ + private static String autoSelectedRunnerHint(String runner) { + return ". Runner `${runner}` was selected automatically as the only agent runner plugin installed - set `agent.runner` to choose another, e.g. `agent.runner = 'langchain4j'` to call the model from the driver process, which needs no container" + } + + /** + * Resolve the address a containerized agent task will reach the driver's RPC broker on, and + * reject the run when the ladder says there is none -- a THIN CALLER of + * {@link AgentRpcHostResolver}, which owns every row and every rejection, so this guard and the + * address the endpoint actually advertises are the same decision rather than two that agree by + * inspection. + * + *

The rejection stays PRE-IGNITION for the rows that cannot work (E1..E7). That timing is a + * requirement, not a nicety: a capability is only ever released by its one-hour + * {@code agent.rpc.capabilityTimeout}, so a configuration allowed to submit would fail only once + * the job had queued and an instance had booted, holding its request the whole time. + */ + @PackageScope + static AgentRpcHost requireBrokerHost(String agentName, String executor, Executor agentExecutor, + ContainerConfig containerConfig, Object containerOptions, AgentRpcConfig rpc, Session session) { + final resolved = AgentRpcHostResolver.resolve(agentExecutor, executor, containerConfig, containerOptions, rpc, session) + if( resolved.resolved ) + return resolved + throw new ScriptRuntimeException("Agent `${agentName}` on executor `${executor ?: AgentConfig.DEFAULT_EXECUTOR}` cannot determine an address for the driver's agent RPC broker - ${resolved.error}") + } + + /** + * The container run options an agent task needs on top of those it declares, for the engine that + * will run it: Linux Docker (>= 20.10) resolves {@code host.docker.internal} -- the address the + * task dials the driver's broker on when {@code agent.rpc.remoteHost} is not set -- ONLY when the + * container is run with {@code --add-host=host.docker.internal:host-gateway}. Docker Desktop, + * where the name is built in, accepts the same flag, so add it unconditionally for docker rather + * than leaving the most common local path to fail with a connection timeout. Podman needs + * nothing: it provides {@code host.containers.internal} itself. + * + *

What matters is the NAME the task resolves, not whether {@code agent.rpc.remoteHost} was + * set: spelling the docker alias out explicitly -- as a migrated config, or an overlay + * inheriting it from a Kubernetes profile, naturally does -- needs the same mapping as leaving + * it to the default. Only a different address makes the option pointless. + * + *

A dynamic (closure) or map-valued {@code containerOptions} is resolved per task by + * {@link nextflow.processor.TaskConfig} and cannot be appended to here, so it is returned + * unchanged and the flag stays the user's to add. + */ + static Object withDockerHostGateway(Object containerOptions, String engine, String remoteHost) { + if( engine != 'docker' ) + return containerOptions + if( remoteHost && remoteHost != AgentRpcConfig.hostAliasFor('docker') ) + return containerOptions + if( containerOptions == null ) + return DOCKER_HOST_GATEWAY + if( containerOptions instanceof CharSequence ) + return "${containerOptions} ${DOCKER_HOST_GATEWAY}".toString() + log.warn "Agent `containerOptions` is not a plain string, so `${DOCKER_HOST_GATEWAY}` cannot be appended to it - add it yourself, or set `agent.rpc.remoteHost`, if the agent task cannot reach the driver on Linux Docker" + return containerOptions + } + + private static final String DOCKER_HOST_GATEWAY = '--add-host=host.docker.internal:host-gateway' + + /** + * Per-task re-check of the image the canonical launch command needs. {@code agent.container} may + * be a lazy value (a closure or a GString) that is truthy when {@link #requireContainerized} + * runs pre-ignition yet resolves to null for the task, so the build-time guard alone cannot + * keep the in-image paths from being run on the host. + */ + static void requireTaskContainer(String agentName, String runner, Object container) { + if( !hasContainer(container) ) + throw new ScriptRuntimeException("Agent `${agentName}` resolved no container image - set `agent.container` to the `${runner}` runner image, which carries the agent proxy and harness the task is launched from") + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentLaunchSpec.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentLaunchSpec.groovy new file mode 100644 index 0000000000..0b69a80c28 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentLaunchSpec.groovy @@ -0,0 +1,69 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.Canonical +import groovy.transform.CompileStatic + +/** + * Portable command description for a runner that supports canonical executor tasks. + * + *

A runner describes only HOW to launch itself, with the ABSOLUTE paths its proxy and harness + * have INSIDE its runner image: a canonical agent task always runs in a container, so there is a + * single command pair and no driver-local variant. The image itself is NOT part of this spec: it + * comes from the `agent.container` directive when set -- exactly as `process.container` does for a + * process -- and otherwise from {@link AgentRunner#getDefaultContainer}, which lets a runner that + * ships its runtime in an image name that image itself. + * {@link nextflow.agent.AgentLaunchConditions#requireContainerized} still rejects a configuration that would + * not containerize the task, whichever of the two supplied the image, so these paths are never + * used on the driver. + */ +@Canonical +@CompileStatic +class AgentLaunchSpec { + List containerProxyCommand + List containerHarnessCommand + + /** + * Compose a launch command with arguments passed to the proxy before the proxy/harness + * separator. Keeping the composition here avoids callers having to build and then parse the + * command to find the separator. + */ + List command(List proxyArgs = Collections.emptyList()) { + if( !containerProxyCommand || !containerHarnessCommand ) + throw new IllegalStateException("Agent runner does not provide a container launch command") + final result = new ArrayList(containerProxyCommand) + result.addAll(proxyArgs) + result.add('--') + result.addAll(containerHarnessCommand) + return result + } + + /** + * The same command as a single POSIX shell line -- what a canonical agent task's script IS. + * Kept here so the object that composes the command also owns how it is spelled, rather than + * leaving each caller to quote the arguments it produced. + */ + String shellCommand(List proxyArgs) { + return 'exec ' + command(proxyArgs).collect { quote(it) }.join(' ') + } + + /** POSIX shell quoting for generated canonical agent commands. */ + private static String quote(Object value) { + final String text = value?.toString() ?: '' + return "'${text.replace("'", "'\"'\"'")}'".toString() + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentOutputMode.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentOutputMode.groovy new file mode 100644 index 0000000000..23ce3b2330 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentOutputMode.groovy @@ -0,0 +1,35 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic + +/** + * How an agent's answer comes back: as the model's plain text, as a scalar wrapped in the + * declared output name, as a record, or as an object holding one entry per declared output. + * + *

The single fact {@link AgentOutputPlan} is built around -- both the decoding of a canonical + * task's terminal frame and the binding of an in-JVM runner's result are functions of it. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +enum AgentOutputMode { + TEXT, + SCALAR_CONTRACT, + RECORD, + WRAPPED +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentOutputPlan.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentOutputPlan.groovy new file mode 100644 index 0000000000..44c4d91567 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentOutputPlan.groovy @@ -0,0 +1,146 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.json.JsonSlurper +import groovy.transform.CompileStatic + +import nextflow.exception.ScriptRuntimeException +import nextflow.script.AgentBuilder.AgentOutput +import nextflow.util.TypeHelper + +/** + * Output schema and decoding strategy for an agent invocation: the mode the answer comes back in, + * the JSON schema the model is given for it (null when there is none), and the two ways that + * answer is turned back into the declared outputs -- {@link #decode} for a canonical task's + * terminal frame, {@link #bind} for an in-JVM runner's return value. + * + *

Owning both keeps the mode and its consumers together: the plan is decided once while + * lowering an agent ({@code AgentDef.resolveOutputPlan}) and then travels to the task body, where + * it is the only thing that knows how to read the answer. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentOutputPlan { + + final AgentOutputMode mode + final Map schema + + AgentOutputPlan(AgentOutputMode mode, Map schema) { + this.mode = mode + this.schema = schema + } + + boolean isStructured() { mode == AgentOutputMode.RECORD || mode == AgentOutputMode.WRAPPED } + boolean isWrapped() { mode == AgentOutputMode.WRAPPED } + + /** + * Decode a canonical terminal frame, including a scalar final_answer wrapper. + * + *

Split where the error messages divide: reading the ANSWER out of the task's last stdout + * frame fails with a frame-level message, and interpreting that answer per {@link #mode} fails + * with an output-level one. + */ + Object decode(Object stdout, String outputName, Class outputType) { + final String answer = terminalAnswer(stdout) + if( mode == AgentOutputMode.TEXT ) + return TypeHelper.asType(answer, outputType) + final Object value = new JsonSlurper().parseText(stripFences(answer)) + if( mode == AgentOutputMode.SCALAR_CONTRACT ) { + if( !(value instanceof Map) || !((Map)value).containsKey(outputName) ) + throw new ScriptRuntimeException('Canonical agent scalar output must be a JSON object containing the declared output') + return TypeHelper.asType(((Map)value).get(outputName), outputType) + } + if( mode == AgentOutputMode.WRAPPED ) + return TypeHelper.asType(requireJsonObject(value, 'structured').get(outputName), outputType) + return TypeHelper.asRecordType(requireJsonObject(value, 'record'), outputType) + } + + /** + * The {@code output} string of the {@code complete} frame a canonical task prints last on stdout. + * Blank stdout is one failure; a last frame that is not an object, is not {@code complete}, or + * carries no {@code output} is the other -- all three yield the same message, because from the + * caller's side they are the same thing: no answer came back. + */ + private static String terminalAnswer(Object stdout) { + final String text = stdout?.toString()?.trim() + if( !text ) + throw new ScriptRuntimeException('Canonical agent task completed without a result frame on stdout') + final List lines = text.readLines().findAll { it?.trim() } + final Object frame = new JsonSlurper().parseText(lines.last()) + final Object answer = frame instanceof Map && ((Map)frame).get('type') == 'complete' + ? ((Map)frame).get('output') + : null + if( answer == null ) + throw new ScriptRuntimeException('Canonical agent task returned an invalid terminal result frame') + return answer.toString() + } + + /** The decoded answer as a JSON object, or the {@code kind}-specific failure. */ + private static Map requireJsonObject(Object value, String kind) { + if( !(value instanceof Map) ) + throw new ScriptRuntimeException("Canonical agent ${kind} output must be a JSON object") + return (Map) value + } + + /** + * Bind an in-JVM runner's result into the task context. + * + *

Note the asymmetry with {@link #decode}: this tests {@code isStructured()}, and + * {@code SCALAR_CONTRACT} is not structured -- so the in-JVM path binds the runner's raw JSON + * string verbatim where the canonical path unwraps {@code {outputName: value}}. + */ + void bind(Map ctx, Object result, List outputs) { + // no model-answered output: the model's text is EXPLICITLY discarded, and the agent's + // result is whatever it wrote into the work dir + if( !outputs ) + return + if( !isStructured() ) { + ctx.put(outputs[0].name, result) + return + } + final map = new JsonSlurper().parseText(stripFences(result as String)) as Map + if( !isWrapped() ) { + ctx.put(outputs[0].name, TypeHelper.asRecordType(map, outputs[0].type as Class)) + return + } + for( final output : outputs ) + ctx.put(output.name, TypeHelper.asType(map[output.name], output.type as Class)) + } + + /** + * Strip a leading ```json (or ```) fence and a trailing ``` fence from the + * given text, returning the inner content. If no fences are present the + * original text is returned unchanged. + */ + private static String stripFences(String text) { + if( text == null ) + return null + final String s = text.trim() + if( !s.startsWith('```') ) + return text + // the opening fence line (``` or ```json) ends at the first newline; without one there is + // no fenced block to strip + final int open = s.indexOf('\n') + if( open < 0 ) + return text + // the LAST closing fence, so a fenced block that itself contains ``` keeps it; an + // unterminated fence (no closing run at all) keeps everything after the opening line + final int close = s.lastIndexOf('```') + return (close > open ? s.substring(open + 1, close) : s.substring(open + 1)).trim() + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentProtocolSpec.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentProtocolSpec.groovy new file mode 100644 index 0000000000..17ed1127d5 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentProtocolSpec.groovy @@ -0,0 +1,66 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import groovy.transform.CompileStatic + +/** + * Build the portable request payload shared by agent protocol transports. + * + *

INVARIANT: this payload carries NO credential. It is the PORTABLE half of a start frame -- + * relayed verbatim by the agent proxy, and the shape a transport is free to log or persist -- so + * the credential resolved for the request travels BESIDE it, as a top-level frame field the RPC + * broker adds only when the link is TLS-protected (see {@code AgentRpcBroker#startFrame}), and + * {@link AgentRunnerRequest#apiKey} stays deliberately omitted here. {@code baseUrl} is not a + * secret and does travel, because the remote runner must target the same endpoint the driver + * resolved. + * + *

The agent's tools cross in TWO fields, and the split is load-bearing: {@code toolSpecs} + * carries the brokered descriptors the runner calls back into the driver for, while + * {@code nativeToolNames} carries bare names the runner is told to enable from its OWN tool set + * (on pi, the SDK builtins added to the session allowlist). This is the single point at which both + * halves are handed to a runner, so it is where the partition between them is checked. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +final class AgentProtocolSpec { + + private AgentProtocolSpec() {} + + static Map fromRequest(AgentRunnerRequest request) { + // A native name that also appears among the brokered descriptors would reach the runner as + // a tool it must call the driver for -- the exact confusion the split exists to prevent. + request.checkToolPartition() + return [ + model: request.model, + instruction: request.instruction, + goal: request.goal, + prompt: request.prompt, + inputJson: request.inputJson, + outputSchema: request.outputSchema, + toolSpecs: request.toolSpecs, + nativeToolNames: request.nativeToolNames, + skills: request.skills, + maxIterations: request.maxIterations > 0 ? request.maxIterations : 20, + trace: request.trace, + temperature: request.temperature, + workDir: request.workDir, + // NOTE: `apiKey` is intentionally NOT part of this payload -- see the class javadoc + baseUrl: request.baseUrl ] + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunner.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunner.groovy new file mode 100644 index 0000000000..61909d950b --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunner.groovy @@ -0,0 +1,99 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import nextflow.agent.rpc.AgentRpcHostResolver +import nextflow.agent.rpc.AgentRpcRegistration +import nextflow.agent.rpc.AgentRpcConfig + +/** + * SPI implemented by an agent runner plugin (e.g. nf-agent). Given a resolved + * {@link AgentRunnerRequest}, drive the LLM and return the final assistant text. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +interface AgentRunner { + + /** + * Stable user-facing runner identifier. Implementations supplied by plugins + * should override this value (for example {@code pi} or {@code langchain4j}). + * The default keeps this interface single-abstract-method compatible with + * closure-coerced runners used by tests and embedding applications. + */ + default String getName() { getClass().getSimpleName() } + + /** + * Return a canonical task launch description, or {@code null} for a legacy + * in-JVM runner. The default preserves closure-coerced test runners. + */ + default AgentLaunchSpec getLaunchSpec() { null } + + /** + * The container image a canonical task of this runner runs in when the user declares no + * {@code agent.container}, or {@code null} when the runner has no image of its own. + * + * A runner that ships its runtime IN an image (see nf-agent-pi) generates this from its own + * VERSION at build time, so the jar and the image it needs cannot drift: the coordinate the + * jar asks for is by construction the tag the release publishes. + * + * The default keeps this interface single-abstract-method compatible with closure-coerced + * runners used by tests and embedding applications, and leaves a runner that has no image + * of its own -- an in-JVM one, for instance -- requiring an explicit {@code agent.container}. + */ + default String getDefaultContainer() { null } + + /** + * Issue the connection material a canonical agent task needs to call back into + * the driver, i.e. the broker endpoint plus a single-use capability token. + * + * A runner that returns a {@link #getLaunchSpec()} MUST implement this, because + * the launch command is built from the returned registration. The broker + * implementation and its transport dependencies live with the runner plugin so + * that a Nextflow distribution carrying no agent runner carries no RPC stack. + * + *

CONTRACT for {@code remote=true}, which is how a canonical (launch-spec) task is + * always registered: the HOST part of the returned endpoint MUST be + * {@link AgentRunnerRequest#brokerHost} -- the address {@link AgentRpcHostResolver} + * resolved for THIS agent definition -- falling back to + * {@link AgentRpcConfig#resolveBrokerHost} only when the request carries none. It is + * per definition and not per run because a script may declare one agent on the local + * docker engine and another on {@code k8s}, which resolve different addresses. Core + * resolves and validates it on the runner's behalf before the run starts -- see + * {@link nextflow.agent.AgentLaunchConditions#requireBrokerHost} -- so a runner that + * derives the driver address by some other means would advertise one address while the + * run was admitted on another, and the cases core rejects pre-ignition would fail an + * hour later instead, when the capability finally expires. + * + *

CONTRACT on transport security: the returned registration MUST carry either the SHA-256 + * fingerprint of the broker's certificate, or {@code insecure = true} to declare that the broker + * deliberately serves cleartext. There is no third state. A registration that carries neither is + * rejected by {@link AgentRpcRegistration#transportArgs()} at SCRIPT-GENERATION time, before any + * job is submitted -- deliberately, because the alternative is a proxy told to dial cleartext at + * a TLS listener, which surfaces inside the task as an unrelated connection failure. A runner + * that predates transport security, and returns only invocation id, token and endpoint, fails + * here and must be updated rather than configured around. + * + * The default keeps this interface single-abstract-method compatible with + * closure-coerced runners used by tests and embedding applications. + */ + default AgentRpcRegistration register(AgentRunnerRequest request, boolean remote) { + throw new UnsupportedOperationException("Agent runner `${getName()}` provides a launch spec but no RPC broker") + } + + String run(AgentRunnerRequest request) +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunnerProvider.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunnerProvider.groovy new file mode 100644 index 0000000000..3a67f477b2 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunnerProvider.groovy @@ -0,0 +1,66 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import nextflow.exception.AbortOperationException +import nextflow.plugin.Plugins + +/** + * Resolves the active {@link AgentRunner} from the loaded plugins. A package-scope + * {@code testRunner} seam allows unit tests to inject a runner without a plugin. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentRunnerProvider { + + @PackageScope + static AgentRunner testRunner + + @PackageScope + static List testRunners + + /** + * Resolve a runner by its stable extension name. When no name is supplied a + * single installed runner is selected for backwards compatibility; multiple + * runners are deliberately treated as ambiguous instead of depending on PF4J + * extension ordering. + */ + static AgentRunner get(String name = null) { + if( testRunner != null ) + return testRunner + final all = testRunners != null ? testRunners : Plugins.getPriorityExtensions(AgentRunner) + if( !all ) + throw new AbortOperationException("No agent runner available - enable an agent runner plugin (for example `nf-agent` or `nf-agent-pi`)") + if( name ) { + final matches = all.findAll { it.getName() == name } + if( matches.size() == 1 ) + return matches.first() + if( matches.size() > 1 ) + throw new AbortOperationException("Multiple agent runner extensions use the name `${name}` - disable the duplicate plugin") + throw new AbortOperationException("Unknown agent runner `${name}` - available runners: ${availableNames(all)}") + } + if( all.size() == 1 ) + return all.first() + throw new AbortOperationException("Multiple agent runners are available (${availableNames(all)}) - select one with `agent.runner` in nextflow.config") + } + + private static String availableNames(List all) { + all.collect { it.getName() }.toSorted().join(', ') + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunnerRequest.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunnerRequest.groovy new file mode 100644 index 0000000000..766f8afe47 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentRunnerRequest.groovy @@ -0,0 +1,263 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import groovy.transform.ToString +import nextflow.exception.AbortOperationException +import nextflow.agent.rpc.AgentRpcHost +import nextflow.agent.rpc.AgentRpcConfig + +/** + * Immutable request passed to an {@link AgentRunner}: the resolved model, the + * system instruction, the rendered user prompt, the iteration cap, the + * (currently unused) tool list for forward compatibility, the JSON schema + * describing the expected structured output, the input record serialized as + * JSON, the descriptors of the tools the LLM may call, the callback used to + * execute them, and the optional high-level goal. + * + * The {@code toolSpecs} and {@code dispatch} fields are in-JVM only (they carry + * a live {@link ToolDispatcher} callback) and are never serialized. + * + * The agent's tools are PARTITIONED across two fields by who executes them: + * {@code toolSpecs} carries the BROKERED ones (a descriptor plus a + * {@code dispatch} callback into the driver), {@code nativeToolNames} the + * RUNNER-NATIVE ones (bare names the runner serves itself). The two sets are + * disjoint, and {@link #checkToolPartition} is what makes that structural. + * + * Being {@code @Canonical}, the positional constructor order follows the field + * declaration order below: + * {@code (model, instruction, prompt, maxIterations, tools, outputSchema, inputJson, toolSpecs, dispatch, requestTimeoutSeconds, goal, agentName, trace, skills, temperature, workDir, apiKey, baseUrl, apiProvider, credentialWithheld, nativeToolNames, brokerHost)}. + * In practice the request is built with named arguments, so the order is not + * relied upon at the call site. A NEW field must therefore be APPENDED last, so a + * positional caller keeps binding the same arguments. + * + * The {@code requestTimeoutSeconds} carries the configured per-request LLM chat + * timeout (from the {@code agent.requestTimeout} config option); when {@code 0} + * the runner applies its own built-in default. + * + * The {@code apiKey} is a CREDENTIAL: it is excluded from the generated + * {@code toString()} so an interpolated request can never leak it into the log, + * and it must never be copied into a serialized payload + * ({@link AgentProtocolSpec#fromRequest} deliberately omits it). + * + * @author Paolo Di Tommaso + */ +@Canonical +@ToString(excludes='apiKey') +@CompileStatic +class AgentRunnerRequest { + String model + String instruction + String prompt + int maxIterations + List tools + Map outputSchema + String inputJson + List toolSpecs + ToolDispatcher dispatch + int requestTimeoutSeconds + String goal + /** The agent name, used only to label the execution trace. */ + String agentName + /** When {@code true}, the runner logs a readable execution trace (turns, model + * reasoning, tool invocations with inputs/outputs) at INFO level. */ + boolean trace + /** Portable descriptors of the agent's declared skills. A runner may expose + * these through its native skill/tool mechanism. Empty/null when no + * {@code skills} directive is declared. */ + List skills + /** Sampling temperature applied to the LLM chat, or {@code null} to leave the + * provider default. Left UNSET (null) on BOTH the tool-free task path and the + * legacy tool/skill path; an explicit value is applied only when a caller opts in + * ({@link ChatModelFactory} calls {@code .temperature(...)} only when non-null). + * It is deliberately NOT pinned to {@code 0.0}: reasoning models (e.g. gpt-5-mini, + * used by every committed example) reject an explicit temperature with HTTP 400, + * and resume is pure input-keyed memoization (a hit replays the stored generation + * without calling the model), so replay correctness does not depend on temperature=0. + * Declared LAST to preserve the positional {@code @Canonical} constructor order + * used by existing callers (a trailing omitted arg defaults to {@code null}). */ + Double temperature + /** Portable string form of the invocation task work directory. */ + String workDir + /** The LLM provider credential resolved by the core ladder and scoped to this + * model's provider ({@link AgentConfig#apiKeyFor}), or {@code null} when nothing + * resolves. A runner MUST NOT read the environment itself, and MUST present + * {@link #credential()} rather than this field. Declared LAST-BUT-ONE to + * preserve the positional {@code @Canonical} constructor order. */ + String apiKey + /** The OpenAI-compatible endpoint resolved by the core ladder + * ({@link AgentConfig#baseUrlFor}), or {@code null} to leave the provider + * default. Not a secret: unlike {@code apiKey} it travels on the protocol spec + * and enters the resume cache key. */ + String baseUrl + /** The provider NAMESPACE the core ladder resolved the pair above from + * ({@link AgentConfig#apiProviderFor}) -- {@code agent.apiProvider}, else the provider of a + * well-known endpoint host, else the model-id prefix. Not the wire protocol, and not a secret. + * Carried so a runner that must report a missing credential can name the variables the ladder + * ACTUALLY consulted instead of asserting the OpenAI ones. */ + String apiProvider + /** {@code true} when a {@code _API_KEY} DID resolve for this model and the endpoint + * gate withheld it ({@link AgentConfig#credentialWithheldFor}); {@code false} both when a + * credential travels and when none exists at all. + * + *

It is what separates a diagnosable misconfiguration from the ordinary no-credential case, + * and {@link #credential()} refuses to paper over it with {@link #PLACEHOLDER_API_KEY}. */ + boolean credentialWithheld + /** Wire names of the RUNNER-NATIVE tools the agent selected -- the {@code fs:} and + * {@code shell:} leaves of the tool grammar, as the bare names the model sees + * ({@code read}, {@code write}, {@code edit}, {@code ls}, {@code grep}, {@code find}, + * {@code bash}). + * + *

They travel BESIDE {@code toolSpecs} and never inside it. A native tool is served by the + * RUNNER -- a pi SDK builtin enabled through the session allowlist, an in-JVM implementation on + * langchain4j -- so it has no descriptor, no schema of ours and no dispatcher, and there is + * nothing for the driver to execute on its behalf. The separation is the safety property: the + * broker authorizes exactly the brokered names ({@link #brokeredToolNames}), so a name that + * stays out of {@code toolSpecs} cannot be called back into the driver JVM at all. + * + *

A runner that serves none of them (there is no {@code shell:} on langchain4j) simply + * ignores the field; core has already refused the refs that runner cannot honour. + * + *

Declared LAST to preserve the positional {@code @Canonical} constructor order. */ + List nativeToolNames + + /** The address a CONTAINERIZED agent task must dial to reach the driver's RPC broker, carried + * with the ladder row that produced it ({@link AgentRpcHost}). + * + *

It rides on the request because it is resolved PER AGENT DEFINITION, pre-ignition, from + * context the broker does not have -- the executor instance, the engine config, the task's + * container options. A run may legitimately hold several: one agent on the local docker engine + * resolves the engine host alias while another on {@code k8s} resolves the driver's pod address, + * and each task must be told ITS OWN or it dials an address it cannot route to and the driver + * holds its capability for the full {@code agent.rpc.capabilityTimeout}. + * + *

{@code null} on the in-JVM runner path, where nothing dials anything, and on a runner that + * registers without the guard -- the broker falls back to + * {@link AgentRpcConfig#resolveBrokerHost} there. Never part of the resume cache key: it is a + * property of the machine, not of the agent. Declared LAST to preserve the positional + * {@code @Canonical} constructor order. */ + AgentRpcHost brokerHost + + /** + * Stand-in credential presented when an endpoint is declared but no credential resolved + * (design D8). A local vLLM or Ollama needs none, yet both consumers require SOMETHING: the + * langchain4j OpenAI client rejects an empty key, and the pi runner fails the run outright + * (verified: {@code No API key found for openai} from the session, or + * {@code Provider is not configured} from {@code prepareRequest}) -- which is exactly the + * local-first case this feature exists to unblock. Cosmetic on langchain4j, load-bearing on pi. + */ + static final String PLACEHOLDER_API_KEY = 'nxf-no-credential' + + /** + * Fail unless the brokered and the runner-native halves are DISJOINT. + * + *

This is enforcement, not a sanity check. Both halves come from one resolved selection, so + * an overlap is a bug in whoever assembled the request -- but it is the one bug whose + * consequence is a boundary crossing rather than a wrong answer: a native name reaching + * {@code toolSpecs} enters the broker's authorization set, and a tool the model believes runs + * inside the runner container would then be executed in the DRIVER JVM. Better a run that + * aborts before a job is submitted than one that silently relocates {@code bash}. + * + *

Called from the two chokepoints every runner passes through -- the portable payload + * builder ({@link AgentProtocolSpec#fromRequest}) and the broker registration that mints the + * allowlist -- so the invariant holds by construction rather than by convention. + */ + void checkToolPartition() { + if( !nativeToolNames || !toolSpecs ) + return + final Set brokered = brokeredNames() + final List overlap = nativeToolNames.findAll { String it -> brokered.contains(it) } + if( overlap ) + throw new IllegalStateException("Agent tool partition violated: ${overlap.join(', ')} - a runner-native tool is served by the runner itself and must never be carried as a brokered tool descriptor") + } + + /** + * The tool names a runner is authorized to call BACK into the driver with -- the brokered ones, + * and only those. Validated: see {@link #checkToolPartition}. + */ + Set brokeredToolNames() { + checkToolPartition() + return brokeredNames() + } + + private Set brokeredNames() { + final Set names = new LinkedHashSet() + for( ToolDescriptor it : (toolSpecs ?: Collections.emptyList()) ) + names.add(it.name) + return names + } + + /** + * The credential the runner must actually present for this request: the resolved one, or the + * D8 placeholder when an endpoint is declared and nothing resolved, or {@code null} when + * neither is set. + * + *

The placeholder is confined to the OpenAI protocol on purpose. A runner installs what it + * is given as the credential OF THE MODEL'S PROVIDER and that ownership beats the ambient + * environment (see {@link AgentConfig#apiKeyFor}), so a placeholder sent for, say, + * {@code anthropic/claude-*} would MASK an exported {@code ANTHROPIC_API_KEY} the runner can + * resolve by itself, turning a working run into a 401. An openai-protocol endpoint is also the + * only shape the no-credential case has: it is a local vLLM/Ollama/llama.cpp server. + * + *

{@code null} is NOT an error here: a runner may have credential sources core cannot see + * (pi reads its own store and provider-specific variables), so rejecting the request is the + * runner's decision, not this class'. + */ + String credential() { + // A WITHHELD credential is not a missing one. The endpoint gate refused to send a key that + // resolved (AgentConfig.credentialWithheldFor), which is a misconfiguration a runner can + // name precisely; substituting the placeholder here would convert that diagnosis into the + // opaque 401 the placeholder rule exists to avoid, and on the pi path it would additionally + // shadow whatever the container was given out of band. The placeholder is ONLY for a + // genuine no-credential local endpoint. + if( credentialWithheld ) + return null + return AgentConfig.isOpenAiProtocol(model) + ? credentialFor(apiKey, baseUrl) + : apiKey + } + + /** + * The same rule for a caller that holds the resolved pair but not a request -- the langchain4j + * {@code ChatModelFactory}, which has already established that the model is OpenAI-protocol. + * Kept here so the two runners cannot drift into two placeholder rules. + * + *

This is the CORE-IS-THE-ONLY-SOURCE path: it answers "what must I present", so it is also + * where design D5 lives. The placeholder assumes the endpoint needs no credential, which is + * true of a local vLLM/Ollama and false of a provider's own API, so a {@code baseUrl} whose + * host is a KNOWN provider endpoint fails here with a diagnosis instead of buying an opaque + * 401 one request later. A runner with credential sources of its own must read + * {@link #apiKey} and never reach this: core resolving nothing is not an error for it (a + * containerized pi task may be given the key by {@code env}/{@code secret} or a Kubernetes + * Secret the driver cannot see), so this must not become a run-aborting check at agent build + * time. + */ + static String credentialFor(String apiKey, String baseUrl) { + if( apiKey ) + return apiKey + if( !baseUrl ) + return null + final provider = AgentConfig.inferProviderFromUrl(baseUrl) + if( provider ) + // D5 names all three ways out. `agent.apiProvider` is the third because the namespace + // this message reports was INFERRED from the endpoint host, and an inference is exactly + // the kind of answer a user may need to override. + throw new AbortOperationException("Missing `${provider}` credential for the agent endpoint ${baseUrl} - ${AgentConfig.missingCredentialHint(provider)}; the `${provider}` namespace was inferred from the endpoint host, so set `agent.apiProvider` to name a different one") + return PLACEHOLDER_API_KEY + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentTaskInfo.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentTaskInfo.groovy new file mode 100644 index 0000000000..d961c05939 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentTaskInfo.groovy @@ -0,0 +1,70 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import groovy.transform.CompileStatic +import groovy.transform.Immutable + +/** + * Resolved identity of an agent task, attached to the synthetic process config by + * {@code AgentDef.buildAgentTask} so an observer can tell an agent task from an ordinary + * process task and record what the agent actually was. + * + *

The carrier is a plain immutable object, NOT a Map or Closure, on purpose: + * {@code TaskConfig} extends {@code LazyMap}, whose {@code get()} deep-copies Map values + * and invokes Closure values on every access (and a Closure value would flip + * {@code LazyMap.dynamic}, short-circuiting {@code TaskRun.hasCacheableValues()}). An + * immutable POJO is returned by identity and is inert. + * + *

This carries only the values resolved at task-build time. The two runtime-only facts + * live elsewhere: the concrete model reported by the provider travels through + * {@link AgentCallInfo} into the task context key {@code $agentResolvedModel}, and the + * rendered prompt is a body-closure local that is deliberately never persisted. + * + * @author Paolo Di Tommaso + */ +@Immutable +@CompileStatic +class AgentTaskInfo { + + /** + * Process-config key under which the info object is attached. Read back with an + * {@code instanceof} guard, never a truthiness check, so that a user-supplied + * {@code agentInfo} directive in a {@code withName:} selector is inert rather than a + * way to forge an agent lineage record. + */ + public static final String CONFIG_KEY = 'agentInfo' + + /** Name of the {@code AgentRunner} implementation selected for the run */ + String runner + /** Effective model id, after the `agent.defaultModel` config fallback */ + String model + /** Resolved `instruction:` directive */ + String instruction + /** Resolved `goal:` directive */ + String goal + /** Source text of the `prompt:` template (NOT the per-task rendered prompt) */ + String promptTemplate + /** Effective iteration ceiling, after the `agent.maxIterationsDefault` fallback */ + int maxIterations + /** Key-sorted JSON of the synthesized output schema; null for a free-text agent */ + String outputSchema + /** Names of the resolved tools the agent may call; null when the agent declares none */ + List tools + /** Names of the resolved skills available to the agent; null when the agent declares none */ + List skills +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentTaskScript.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentTaskScript.groovy new file mode 100644 index 0000000000..53b80377d0 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentTaskScript.groovy @@ -0,0 +1,102 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import java.util.regex.Pattern + +import groovy.transform.CompileStatic + +/** + * The agent task script as it may be recorded, as opposed to as it must be executed. + * + *

A canonical agent task's script is the proxy launch command built by + * {@code AgentDef.createCanonicalBody}, and it carries the invocation's capability token on argv: + * {@code exec --endpoint ... --token }. That token is a BEARER CREDENTIAL. It + * authorizes one connection to the driver's RPC broker, and since the driver answers a + * {@code connect} with the start frame -- which now carries the provider API key resolved for the + * agent -- whoever holds a live token can obtain that key. + * + *

The work directory is the accepted blast radius for it: the same text lands in + * {@code .command.sh}, which is a residual documented in {@code docs/agent.mdx}. What is NOT + * accepted is the token leaving that directory. {@link nextflow.trace.TraceRecord#script} is + * persisted in the resume cache database and POSTed to Seqera Platform by {@code nf-tower}, so a + * verbatim script there turns a work-dir-local secret into one that is stored and transmitted + * off-box, for the life of the cache rather than for the capability's window. + * + *

{@code LinObserver.newAgentRun} already omits {@code task.script} from the lineage record for + * exactly this reason; this is the same decision applied to the trace record, which cannot simply + * omit the field. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentTaskScript { + + /** Stands in for the capability token wherever the script is recorded rather than executed. */ + static final String REDACTED = '[REDACTED]' + + /** + * The launch command quotes every argument ({@code AgentLaunchSpec.shellCommand}), so this is the shape + * the driver actually produces. The token is generated by the broker from + * {@link java.security.SecureRandom} and rendered URL-safe, so it never contains a quote. + */ + private static final Pattern QUOTED_TOKEN = ~/('--token'[ \t]+)'[^']*'/ + + /** + * The unquoted spellings, matched as a backstop so a future call site that composes the command + * differently cannot silently reintroduce the leak. + */ + private static final Pattern PLAIN_TOKEN = ~/(--token[=\s]+)\S+/ + + private AgentTaskScript() {} + + /** + * Whether the given task config belongs to an agent task. Guarded with {@code instanceof}, like + * {@code LinObserver}, so a user-supplied {@code agentInfo} directive is inert rather than a way + * to influence what gets recorded. + * + * @param config the task config, i.e. {@code TaskRun.config} + */ + static boolean isAgentTask(Map config) { + return config != null && config.get(AgentTaskInfo.CONFIG_KEY) instanceof AgentTaskInfo + } + + /** + * The given script with the RPC capability token replaced by {@link #REDACTED}. Everything else + * -- the proxy path, the endpoint, the invocation id, the certificate fingerprint -- is kept: + * none is a secret (a fingerprint is a public commitment) and all of it is what makes a + * recorded script worth having. + * + *

Returns the input unchanged when there is no token to redact, so a non-RPC agent script is + * untouched rather than reformatted. + */ + static String redactCapabilityToken(String script) { + if( !script || !script.contains('--token') ) + return script + String result = QUOTED_TOKEN.matcher(script).replaceAll("\$1'${REDACTED}'") + return PLAIN_TOKEN.matcher(result).replaceAll("\$1${REDACTED}") + } + + /** + * The recordable form of a task script: redacted for an agent task, byte-identical for every + * other task. The identity branch is the contract -- this sits on the path taken by EVERY task + * ({@code TaskRun.getTraceScript}), so an ordinary process must be able to prove it is untouched. + */ + static String forTrace(Map config, String script) { + return isAgentTask(config) ? redactCapabilityToken(script) : script + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/AgentToolFatalError.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/AgentToolFatalError.groovy new file mode 100644 index 0000000000..19d2222700 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/AgentToolFatalError.groovy @@ -0,0 +1,49 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic + +/** + * Fatal, non-recoverable error raised by {@link ModuleToolBridge#call} when the underlying + * tool process task hard-fails (exit ≠ 0) and the session aborts the dataflow network, + * interrupting the agent task-body thread that is blocked on the tool's output channel. + * + *

Why an {@link Error} and not an {@link Exception}. The agent dispatch runs inside a + * langchain4j {@code AiServices} tool-execution loop. langchain4j wraps every {@code ToolExecutor} + * call in a {@code try/catch(java.lang.Exception)} ({@code ToolService.executeWithErrorHandling}): + * any thrown {@link Exception} — including a {@link RuntimeException} — is caught and converted + * into an error tool result fed back to the model, which then keeps looping until the + * iteration cap. To make a hard task failure abort the run cleanly (rather than loop to + * {@code maxIterations}) the bridge must throw something OUTSIDE that {@code Exception} catch. + * An {@link Error} propagates out of {@code agent.chat(...)} on the agent task-body thread, out of + * the runner call in the agent's {@code exec} body, and through {@code TaskProcessor}'s exec-body + * failure handling — which fails the task and aborts the session; the shared tool bridge is then + * poisoned when the agent processor terminates. + * + *

This type deliberately carries NO langchain4j reference so the plugin boundary (no langchain4j + * types under {@code modules/nextflow/src}) stays clean. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentToolFatalError extends Error { + + AgentToolFatalError(String message, Throwable cause) { + super(message, cause) + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/DispatchContext.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/DispatchContext.groovy new file mode 100644 index 0000000000..4eb323c9c1 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/DispatchContext.groovy @@ -0,0 +1,55 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap + +import groovy.transform.CompileStatic + +/** + * Per-agent-invocation dispatch context: the sandbox work dir and the set of + * paths the filesystem tool may read — the work dir, the SOURCES of the task's staged + * {@code Path} inputs (the guard resolves the stage-in symlink, so the source is what the + * containment test actually sees), and the outputs of modules run during this invocation. + * Created per input record by the agent operator and + * threaded to {@link ModuleToolBridge} via a ThreadLocal, so the shared, + * pre-ignition bridge holds no per-record state. + * + *

An entry may be a FILE or a directory: containment is by path prefix, so a file + * entry grants exactly that file while a directory entry grants its whole subtree. + * Module outputs are added as files precisely so that reading one does not also grant + * its siblings — see {@link ModuleToolBridge#collectPathsFromValue}. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class DispatchContext { + final Path workDir + final Set readablePaths + + DispatchContext(Path workDir) { + this.workDir = workDir + this.readablePaths = ConcurrentHashMap.newKeySet() + if( workDir != null ) + this.readablePaths.add(workDir) + } + + void addReadablePath(Path path) { + if( path != null ) + readablePaths.add(path) + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/FilesystemTools.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/FilesystemTools.groovy new file mode 100644 index 0000000000..ce9af4220c --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/FilesystemTools.groovy @@ -0,0 +1,758 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.charset.StandardCharsets +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.PathMatcher +import java.util.regex.Pattern +import java.util.regex.PatternSyntaxException +import java.util.stream.Stream + +import groovy.json.JsonOutput +import groovy.transform.CompileStatic +import groovy.transform.TupleConstructor +import groovy.util.logging.Slf4j + +/** + * The {@code fs:} tool family: the six separately-named tools {@code read}, {@code write}, + * {@code edit}, {@code ls}, {@code grep} and {@code find} — both their wire-level definition + * (the descriptors the model reads) and their driver-JVM implementation. + * + *

These are the Pi-baseline names, deliberately: a tool ref is a contract and the wire + * name is the coordination point, so the model sees the same six names whichever runner + * executes them — the runner's own builtins in a container, or the Groovy implementation + * below in the driver JVM. There is no {@code exists} tool: it had no + * counterpart on the other runner and {@code ls}/{@code read} cover it. + * + *

A tool DESCRIPTION is the only documentation the model ever reads, so each one states + * the sandbox boundary explicitly — a model that does not know where the boundary is spends + * turns probing it. The caps below are quoted verbatim in the descriptions for the same + * reason: the model must be able to predict a truncated result rather than discover it. + * + *

The work dir is bound per call from the {@link DispatchContext} and is never supplied + * by the LLM; every argument here is a path relative to it (or an absolute path that must + * still fall inside the sandbox). + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class FilesystemTools { + + static final String READ = 'read' + static final String WRITE = 'write' + static final String EDIT = 'edit' + static final String LS = 'ls' + static final String GREP = 'grep' + static final String FIND = 'find' + + /** + * The six wire names, in canonical order. Sourced from the grammar inventory rather than + * restated here so the set a directive can SELECT and the set the bridge can SERVE cannot + * drift apart; {@link #descriptor} throws for a name it does not know, which turns any + * future drift into a build-time failure rather than a tool the model can call but nothing + * implements. + */ + static final List NAMES = ToolRefResolver.FS_TOOLS + + /** Default and hard cap on the number of results {@code grep}/{@code find} return. */ + static final int DEFAULT_MAX_RESULTS = 200 + static final int MAX_MAX_RESULTS = 1000 + + /** Default and hard cap on the directory depth {@code grep}/{@code find} walk. */ + static final int DEFAULT_MAX_DEPTH = 20 + static final int MAX_MAX_DEPTH = 50 + + /** Files larger than this are skipped by {@code grep}: a match in a bulk data file is noise. */ + static final long MAX_GREP_FILE_BYTES = 2 * 1024 * 1024L + + /** + * Hard cap on the number of directory entries one {@code grep}/{@code find} call may VISIT. + * + *

{@code max_results} bounds the answer, not the work: a pattern that matches nothing walks + * the whole tree, and every visited entry costs a {@code stat} plus a {@code toRealPath()} + * sandbox check — on the agent dispatch thread, with no deadline. This bounds the walk itself, + * and tripping it sets {@code truncated} with a {@code truncated_reason} that distinguishes + * "you saw the first N matches" from "the search stopped early", which are opposite + * instructions for the model: narrow the pattern versus narrow the search root. + */ + static final int MAX_VISITED_ENTRIES = 20_000 + + /** Hard cap on the number of files one {@code grep} call opens and reads; see above. */ + static final int MAX_GREP_FILES = 2_000 + + /** {@code truncated_reason} when the RESULT limit ({@code max_results}) stopped the search. */ + static final String TRUNCATED_RESULTS = 'max_results' + + /** {@code truncated_reason} when the SEARCH budget stopped it: the result set is incomplete. */ + static final String TRUNCATED_SEARCH = 'search_budget' + + /** Matched lines are truncated to this many characters before being returned. */ + static final int MAX_LINE_CHARS = 300 + + /** + * The sandbox sentence shared by every description. Repeated in each tool rather than stated + * once in the system prompt because a tool description is the only text guaranteed to travel + * with the tool through every runner and every provider. + */ + private static final String SANDBOX = + 'Confined to the agent sandbox: the task work dir plus any module-output files returned by an earlier tool call. A path outside it is refused.' + + /** + * The descriptor of one {@code fs:} tool by its wire name. + * + * @throws IllegalArgumentException for a name outside {@link #NAMES} + */ + static ToolDescriptor descriptor(String name) { + switch( name ) { + case READ: return readDescriptor() + case WRITE: return writeDescriptor() + case EDIT: return editDescriptor() + case LS: return lsDescriptor() + case GREP: return grepDescriptor() + case FIND: return findDescriptor() + default: + throw new IllegalArgumentException("Unknown filesystem tool `${name}` - known tools: ${NAMES}") + } + } + + /** + * The descriptors of the given wire names, in the order given. An EMPTY collection means + * exactly that — no fs: tool was selected — never the whole family; the only caller + * ({@code ModuleToolBridge.filesystemDescriptors}) holds a non-null set the constructor + * already defaulted, so there is no null case to absorb here. + */ + static List descriptors(Collection names) { + return names.collect { descriptor(it) } + } + + // ------------------------------------------------------------------------- + // the six descriptors + // ------------------------------------------------------------------------- + + private static ToolDescriptor readDescriptor() { + final input = ToolSchema.object( + [ + path: [type: 'string', description: 'File to read, relative to the agent work dir (or an absolute path inside the sandbox).'], + ] as Map, + ['path'] ) + final desc = 'Read the contents of a single file. ' + + 'Small text-like files (.txt .md .json .yaml .yml .csv .tsv .tab .log) are returned inline under `content`; ' + + 'every other file — binary, bulk data, or over the inline size limit — is returned as an opaque absolute `path` handle you can pass to another tool but cannot see the bytes of. ' + + "Use `${LS}` first if you are unsure the file exists. " + SANDBOX + return new ToolDescriptor(READ, desc, input, null) + } + + private static ToolDescriptor writeDescriptor() { + final input = ToolSchema.object( + [ + path : [type: 'string', description: 'File to write, relative to the agent work dir. Missing parent directories are created.'], + content: [type: 'string', description: 'The full new contents of the file. The file is overwritten, not appended to.'], + ] as Map, + ['path','content'] ) + final desc = 'Create a file or replace its entire contents. Missing parent directories are created. ' + + "To change part of an existing file use `${EDIT}` instead — this tool overwrites everything. " + + 'Writes are confined to the agent work dir only: unlike reads, a module-output path outside the work dir is NOT writable.' + return new ToolDescriptor(WRITE, desc, input, null) + } + + private static ToolDescriptor editDescriptor() { + final input = ToolSchema.object( + [ + path : [type: 'string', description: 'File to edit, relative to the agent work dir.'], + old_string : [type: 'string', description: 'The exact text to replace, including whitespace and indentation. Must occur exactly once in the file unless `replace_all` is true.'], + new_string : [type: 'string', description: 'The text to put in its place. May be empty to delete the matched text.'], + replace_all: [type: 'boolean', description: 'Replace every occurrence instead of requiring a unique one. Defaults to false.'], + ] as Map, + ['path','old_string','new_string'] ) + final desc = 'Replace an exact literal string in an existing file, leaving the rest untouched. ' + + '`old_string` is matched literally, never as a regular expression. ' + + 'If it occurs more than once the edit is REFUSED rather than applied to the first match: either extend `old_string` with surrounding lines until it is unique, or pass `replace_all: true` when you really mean every occurrence. ' + + 'The number of replacements made is reported back. ' + + 'Edits are confined to the agent work dir only, like writes.' + return new ToolDescriptor(EDIT, desc, input, null) + } + + private static ToolDescriptor lsDescriptor() { + final input = ToolSchema.object( + [ + path: [type: 'string', description: 'Directory to list, relative to the agent work dir. Defaults to the work dir itself.'], + ] as Map, + [] ) + final desc = 'List the immediate entries of a directory (not recursive). ' + + 'Each entry reports its `name`, its `type` (`file` or `dir`) and, for a file, its `size` in bytes — so you can tell whether reading it is worthwhile. ' + + "Use `${FIND}` to search a directory tree instead. " + SANDBOX + return new ToolDescriptor(LS, desc, input, null) + } + + private static ToolDescriptor grepDescriptor() { + final input = ToolSchema.object( + [ + pattern : [type: 'string', description: 'Regular expression (Java/PCRE syntax) matched against each line. A plain substring is a valid pattern.'], + path : [type: 'string', description: 'File or directory to search, relative to the agent work dir. Defaults to the work dir itself.'], + include : [type: 'string', description: 'Optional glob restricting which files are searched, e.g. `*.tsv`. Matched against the file name, or against the path relative to the search root when it contains a `/`.'], + case_insensitive: [type: 'boolean', description: 'Match case-insensitively. Defaults to false.'], + max_results : [type: 'integer', description: "Maximum number of matching lines to return. Defaults to ${DEFAULT_MAX_RESULTS}, capped at ${MAX_MAX_RESULTS}.".toString()], + max_depth : [type: 'integer', description: "Maximum directory depth to descend. Defaults to ${DEFAULT_MAX_DEPTH}, capped at ${MAX_MAX_DEPTH}.".toString()], + ] as Map, + ['pattern'] ) + final desc = 'Search file contents line by line for a regular expression, recursively. ' + + 'Returns one entry per matching line with its absolute `file`, 1-based `line` number and the matched line `text` ' + + "(truncated to ${MAX_LINE_CHARS} characters). " + + "Binary files and files larger than ${MAX_GREP_FILE_BYTES.intdiv(1024 * 1024)} MB are skipped. " + + 'When more lines match than the limit allows the result sets `truncated: true` with `truncated_reason: "' + TRUNCATED_RESULTS + '"` and reports the `limit` used — narrow the pattern or the `include` glob rather than assuming you saw everything. ' + + "The search itself is also bounded (at most ${MAX_VISITED_ENTRIES} entries visited and ${MAX_GREP_FILES} files read): when THAT stops it, `truncated_reason` is `\"${TRUNCATED_SEARCH}\"` and the tree was NOT searched to the end — narrow the search root or the depth. " + SANDBOX + return new ToolDescriptor(GREP, desc, input, null) + } + + private static ToolDescriptor findDescriptor() { + final input = ToolSchema.object( + [ + pattern : [type: 'string', description: 'Glob matched against the file NAME, e.g. `*.fastq.gz`. A pattern containing a `/` is matched against the path relative to the search root instead, e.g. `**/results/*.json`.'], + path : [type: 'string', description: 'Directory to search, relative to the agent work dir. Defaults to the work dir itself.'], + type : [type: 'string', enum: ['file','dir','any'], description: 'Restrict results to regular files or to directories. Defaults to `any`.'], + max_results: [type: 'integer', description: "Maximum number of paths to return. Defaults to ${DEFAULT_MAX_RESULTS}, capped at ${MAX_MAX_RESULTS}.".toString()], + max_depth : [type: 'integer', description: "Maximum directory depth to descend. Defaults to ${DEFAULT_MAX_DEPTH}, capped at ${MAX_MAX_DEPTH}.".toString()], + ] as Map, + ['pattern'] ) + final desc = 'Find files and directories by name, recursively, returning their absolute paths. ' + + "This searches NAMES only — use `${GREP}` to search file contents. " + + 'When more paths match than the limit allows the result sets `truncated: true` with `truncated_reason: "' + TRUNCATED_RESULTS + '"` and reports the `limit` used. ' + + "The search itself is also bounded (at most ${MAX_VISITED_ENTRIES} entries visited): when THAT stops it, `truncated_reason` is `\"${TRUNCATED_SEARCH}\"` and the tree was NOT searched to the end — narrow the search root or the depth. " + SANDBOX + return new ToolDescriptor(FIND, desc, input, null) + } + + // ------------------------------------------------------------------------- + // The `fs:` family (read / write / edit / ls / grep / find). + // + // These are the driver-JVM implementation of the runner-native fs: tools: an in-container + // runner serves the same six wire names with its own builtins, so what changes between + // runners is the implementation and the sandbox mechanism, never the name or the contract. + // Every one of them is gated by SandboxGuard and returns {"error": ...} instead of throwing, + // because a refused path is something the model must be able to see and correct. + // ------------------------------------------------------------------------- + + /** + * Dispatch one {@code fs:} tool call. Resolves the LLM-supplied {@code path} against the + * dispatch context's work dir when relative, gates it through {@link SandboxGuard}, then + * routes to the per-tool implementation. + * + *

Returns {@code {"error": "..."}} when: + *

    + *
  • no dispatch context is active on the current thread (called outside a sandboxed invocation)
  • + *
  • the context work dir is not a local {@code file:} path
  • + *
  • the resolved path is outside the sandbox (workDir or whitelisted readable paths)
  • + *
+ * + *

Threading note: filesystem calls carry their own ThreadLocal invocation context + * and do not need the module request gateway. The ThreadLocal itself belongs to + * {@link ModuleToolBridge}, whose {@code setContext}/{@code clearContext} are the public API + * the agent task brackets an invocation with; the context is passed IN here. + * + * @param tool the wire name of the tool being called, one of {@link #NAMES} + * @param args the parsed LLM arguments + * @param ctx the per-invocation dispatch context, or {@code null} when called + * outside a sandboxed invocation + * @param maxInlineBytes the cap under which {@code read} inlines a file's content rather than + * returning an opaque path handle + * @return a JSON object string with the tool result or an {@code {"error": "..."}} JSON + */ + static String call(String tool, Map args, DispatchContext ctx, long maxInlineBytes) { + final String unavailable = requireSandbox(ctx, tool) + if( unavailable != null ) + return unavailable + final Path workDir = ctx.workDir + + final PathArg arg = resolvePath(tool, args, workDir) + if( arg.error != null ) + return arg.error + final Path resolved = arg.resolved + final String pathStr = arg.pathStr + + final String refused = requireAllowed(tool, resolved, workDir, ctx, pathStr) + if( refused != null ) + return refused + + switch( tool ) { + case READ: return fsRead(resolved, pathStr, maxInlineBytes) + case WRITE: return fsWrite(resolved, args) + case EDIT: return fsEdit(resolved, pathStr, args) + case LS: return fsList(resolved, pathStr, ctx) + case GREP: return fsGrep(resolved, pathStr, args, ctx) + case FIND: return fsFind(resolved, pathStr, args, ctx) + default: + // unreachable: `call` only routes names in `filesystemTools` + return error("unknown filesystem tool `${tool}`") + } + } + + /** + * The sandbox must exist before a path can be resolved against it: a dispatch context, a work + * dir, and a work dir that is a local {@code file:} path. + * + * @return the {@code {"error": …}} result to hand back verbatim, or {@code null} when usable + */ + private static String requireSandbox(DispatchContext ctx, String tool) { + if( ctx == null ) + return error("`${tool}` tool unavailable: no sandbox context") + + final Path workDir = ctx.workDir + if( workDir == null ) + return error("`${tool}` tool unavailable: no work dir in sandbox context") + + // Only local file: paths are supported; cloud/remote work dirs are not supported yet + try { workDir.toUri() } catch( UnsupportedOperationException e ) { + log.debug("Agent `${tool}` tool: work dir `${workDir}` has no URI (non-local scheme): ${e.message}") + return error("`${tool}` tool unavailable: work dir scheme is not a local file path") + } + final scheme = workDir.toUri()?.getScheme() + if( scheme != null && scheme != 'file' ) + return error("`${tool}` tool unavailable: work dir scheme '${scheme}' is not supported (only local file: paths)") + return null + } + + /** + * The {@code path} argument, resolved — or the error result that says it could not be. + * + *

Both halves travel: the normalized {@code resolved} path is what the tool acts on, while + * the RAW {@code pathStr} is what the six-way dispatch hands to the per-tool "not found" + * messages and what a sandbox refusal quotes. Reconstructing the latter from the former would + * change those messages. + */ + @TupleConstructor + private static class PathArg { + final String error + final Path resolved + final String pathStr + } + + private static PathArg resolvePath(String tool, Map args, Path workDir) { + // `path` addresses the single target for read/write/edit and the search root for + // ls/grep/find, where it defaults to the work dir itself + final boolean pathRequired = tool==READ || tool==WRITE || tool==EDIT + final String pathStr = args.path?.toString() ?: (pathRequired ? null : '.') + if( !pathStr ) + return new PathArg(error("`${tool}` tool: missing required argument: path"), null, null) + + // Resolve path: relative paths are resolved against the work dir; normalize for defensive correctness + Path resolved = Path.of(pathStr) + if( !resolved.isAbsolute() ) + resolved = workDir.resolve(pathStr).normalize() + else + resolved = resolved.normalize() + return new PathArg(null, resolved, pathStr) + } + + /** + * The sandbox gate. Whether the call MUTATES is derived from the tool name here and nowhere + * else, so the read/write asymmetry has exactly one definition. + * + * @return the {@code {"error": …}} result to hand back verbatim, or {@code null} when allowed + */ + private static String requireAllowed(String tool, Path resolved, Path workDir, DispatchContext ctx, String pathStr) { + // write and edit are the only mutating tools, and the guard confines them to the work dir + // (a module-output path whitelisted for reading is NOT writable) + final boolean isWrite = tool==WRITE || tool==EDIT + if( !SandboxGuard.isAllowed(resolved, workDir, ctx.readablePaths, isWrite) ) + return error("path outside sandbox: ${pathStr}") + return null + } + + /** A tool-result error object. Every fs: failure is a RESULT the model can read and retry from. */ + private static String error(Object message) { + return JsonOutput.toJson([error: message.toString()]) + } + + /** + * Read one file, applying the same inline-vs-handle policy as a module tool output + * ({@link ToolOutputReader#readOrHandle} under the {@code agent.maxToolOutputInlineSize} cap). + * + *

The result SHAPE says which of the two happened: {@code content} is only ever real file + * content, and a file that stayed an opaque handle comes back as {@code path} + {@code note}. + * Putting a path string under {@code content} — as the aggregate tool this replaces did — + * reads to the model as "the file contains this path", which is a lie it cannot detect. + */ + private static String fsRead(Path resolved, String pathStr, long maxInlineBytes) { + if( !Files.exists(resolved) ) + return error("file not found: ${pathStr}") + if( Files.isDirectory(resolved) ) + return error("path is a directory, not a file: ${pathStr}") + // a non text-like format is bulk/binary data: chainable as a handle, never inlined + final ext = ToolOutputReader.extensionOf(resolved) + if( !ToolOutputReader.TEXT_EXTENSIONS.contains(ext) ) + return JsonOutput.toJson([ + path: resolved.toAbsolutePath().toString(), + note: "content not inlined: `${ext ?: '(no extension)'}` is not a text-like format; pass this path to another tool".toString() ]) + final Object readResult = ToolOutputReader.readOrHandle(resolved, maxInlineBytes) + // an oversized inline candidate arrives as an annotated [path, note] map + if( readResult instanceof Map ) + return JsonOutput.toJson(readResult) + // readOrHandle returns a bare String for BOTH the inlined content and its binary-content + // safety net, so re-run the (8 KB) sniff to tell the two apart rather than labelling a + // path handle as content + if( ToolOutputReader.looksBinary(resolved) ) + return JsonOutput.toJson([ + path: resolved.toAbsolutePath().toString(), + note: 'content not inlined: the file contains binary data' ]) + return JsonOutput.toJson([content: readResult]) + } + + /** + * Replace a file's entire contents. + * + *

A missing {@code content} is an ERROR, not an empty write: {@code content} is a required + * property of the schema, and treating its absence as {@code ''} would TRUNCATE an existing + * file on a malformed call the model never intended. Empty is still writable — it just has to + * be asked for explicitly, exactly as {@code edit} requires of {@code new_string}. + */ + private static String fsWrite(Path resolved, Map args) { + if( !args.containsKey('content') || args.content == null ) + return error('`write` tool: missing required argument: content (pass an empty string to create an empty file)') + final byte[] bytes = args.content.toString().getBytes(StandardCharsets.UTF_8) + final parent = resolved.getParent() + if( parent != null ) + Files.createDirectories(parent) + Files.write(resolved, bytes) + return JsonOutput.toJson([path: resolved.toAbsolutePath().toString(), bytes: bytes.length]) + } + + /** + * Literal old-string/new-string replacement. + * + *

A non-unique {@code old_string} without {@code replace_all} is an ERROR, never a + * silent first-match edit: the model asked to change "the" occurrence, and if there are + * three of them it does not yet know which one it changed. Reporting the count back lets it + * either extend the match with surrounding context or opt into replacing all of them. + */ + private static String fsEdit(Path resolved, String pathStr, Map args) { + if( !Files.exists(resolved) ) + return error("file not found: ${pathStr}") + if( Files.isDirectory(resolved) ) + return error("path is a directory, not a file: ${pathStr}") + final String oldStr = args.old_string?.toString() + if( !oldStr ) + return error('`edit` tool: missing required argument: old_string (it must be a non-empty literal string)') + // new_string may legitimately be empty (a deletion), so it is checked for presence only + if( !args.containsKey('new_string') || args.new_string == null ) + return error('`edit` tool: missing required argument: new_string (pass an empty string to delete the matched text)') + final String newStr = args.new_string.toString() + if( oldStr == newStr ) + return error('`edit` tool: old_string and new_string are identical - nothing to change') + final boolean replaceAll = asBoolean(args.replace_all) + + final String text = new String(Files.readAllBytes(resolved), StandardCharsets.UTF_8) + final int occurrences = countOccurrences(text, oldStr) + if( occurrences == 0 ) + return error("`edit` tool: no match for old_string in ${pathStr} - it must match the file contents exactly, including whitespace and indentation") + if( occurrences > 1 && !replaceAll ) + return error("`edit` tool: old_string occurs ${occurrences} times in ${pathStr} - extend it with surrounding context to make it unique, or pass replace_all: true to change all ${occurrences} occurrences") + + // literal replacement: replace() (unlike replaceAll()) treats both arguments as literals + final String updated = replaceAll + ? text.replace(oldStr, newStr) + : replaceFirstLiteral(text, oldStr, newStr) + final byte[] bytes = updated.getBytes(StandardCharsets.UTF_8) + Files.write(resolved, bytes) + return JsonOutput.toJson([ + path : resolved.toAbsolutePath().toString(), + replacements: replaceAll ? occurrences : 1, + bytes : bytes.length ]) + } + + /** Count the NON-OVERLAPPING occurrences of a literal string, i.e. as many as replace() makes. */ + private static int countOccurrences(String text, String literal) { + int count = 0 + int from = 0 + while( true ) { + final int at = text.indexOf(literal, from) + if( at < 0 ) + return count + count++ + from = at + literal.length() + } + } + + private static String replaceFirstLiteral(String text, String oldStr, String newStr) { + final int at = text.indexOf(oldStr) + return text.substring(0, at) + newStr + text.substring(at + oldStr.length()) + } + + /** + * List one directory's immediate entries. + * + *

Every entry is re-checked against the sandbox for the same reason {@link #fsGrep} and + * {@link #fsFind} re-check theirs: {@code Files.isDirectory}/{@code Files.size} FOLLOW + * symlinks, so a link pointing outside the sandbox would report the kind and the exact byte + * size of a file the model is not allowed to open. Such an entry is reported as + * {@code type: 'link'} with no size — its name is sandbox-internal and hiding it would only + * make the refusal that {@code read} then returns inexplicable. + */ + private static String fsList(Path resolved, String pathStr, DispatchContext ctx) { + if( !Files.exists(resolved) ) + return error("directory not found: ${pathStr}") + if( !Files.isDirectory(resolved) ) + return error("path is not a directory: ${pathStr}") + final entries = new ArrayList() + final Stream stream = Files.list(resolved) + try { + for( final Iterator it=stream.sorted().iterator(); it.hasNext(); ) { + final Path p = it.next() + final entry = new LinkedHashMap() + entry.put('name', p.getFileName().toString()) + if( !SandboxGuard.isAllowed(p, ctx.workDir, ctx.readablePaths, false) ) { + entry.put('type', 'link') + entries.add(entry) + continue + } + final boolean dir = Files.isDirectory(p) + entry.put('type', dir ? 'dir' : 'file') + // the size lets the model decide whether reading the file is worthwhile + if( !dir ) + entry.put('size', safeSize(p)) + entries.add(entry) + } + } + finally { + stream.close() + } + return JsonOutput.toJson([path: resolved.toAbsolutePath().toString(), entries: entries]) + } + + /** + * Content search, pure JVM. Deliberately NOT a shell-out to {@code grep}: the driver may be + * macOS, where BSD grep takes different flags from the GNU one an LLM will have been trained + * to write, and a flag mismatch surfaces as an unexplainable empty result. + * + *

Every visited file is re-checked against the sandbox even though the search ROOT was + * already checked: {@code Files.walk} does not follow directory symlinks, but it does report + * a symlink to a regular file, and reading through it would leak a file outside the sandbox. + * Such an entry is skipped silently — it is not the model's error to correct. + */ + private static String fsGrep(Path root, String pathStr, Map args, DispatchContext ctx) { + final String patternStr = args.pattern?.toString() + if( !patternStr ) + return error('`grep` tool: missing required argument: pattern') + Pattern regex + try { + regex = Pattern.compile(patternStr, asBoolean(args.case_insensitive) ? Pattern.CASE_INSENSITIVE : 0) + } + catch( PatternSyntaxException e ) { + return error("`grep` tool: invalid regular expression `${patternStr}` - ${e.description}") + } + if( !Files.exists(root) ) + return error("path not found: ${pathStr}") + PathMatcher include + try { + include = globMatcher(args.include?.toString()) + } + catch( Exception e ) { + return error("`grep` tool: invalid include glob `${args.include}` - ${e.message}") + } + final int limit = boundedArg(args.max_results, DEFAULT_MAX_RESULTS, MAX_MAX_RESULTS) + final int depth = boundedArg(args.max_depth, DEFAULT_MAX_DEPTH, MAX_MAX_DEPTH) + + final matches = new ArrayList() + int filesScanned = 0 + int visited = 0 + String truncatedReason = null + final Stream stream = Files.walk(root, depth) + try { + for( final Iterator it=stream.iterator(); it.hasNext() && truncatedReason == null; ) { + final Path p = it.next() + // bound the WALK, not just the answer: a pattern matching nothing would otherwise + // stat + realpath + sniff + read every entry of an arbitrarily large tree on the + // agent dispatch thread. Reported distinctly so an incomplete search is not read + // by the model as an exhaustive one that found nothing. + if( ++visited > MAX_VISITED_ENTRIES ) { + truncatedReason = TRUNCATED_SEARCH + break + } + if( !Files.isRegularFile(p) ) + continue + if( !SandboxGuard.isAllowed(p, ctx.workDir, ctx.readablePaths, false) ) + continue + if( include != null && !matchesGlob(include, root, p) ) + continue + // a match inside a multi-gigabyte data file is noise, and reading it into the + // driver heap to find out would be worse than useless + if( safeSize(p) > MAX_GREP_FILE_BYTES || ToolOutputReader.looksBinary(p) ) + continue + if( filesScanned >= MAX_GREP_FILES ) { + truncatedReason = TRUNCATED_SEARCH + break + } + filesScanned++ + // decode with replacement rather than readAllLines, which throws on a malformed + // byte sequence and would abort the whole search over one bad file + final String text = new String(Files.readAllBytes(p), StandardCharsets.UTF_8) + final String[] lines = text.split('\n', -1) + for( int i=0; i= limit ) { + truncatedReason = TRUNCATED_RESULTS + break + } + matches.add([ + file: p.toAbsolutePath().toString(), + line: i + 1, + text: truncate(lines[i], MAX_LINE_CHARS) ] as Map) + } + } + } + finally { + stream.close() + } + final result = new LinkedHashMap() + result.put('matches', matches) + result.put('count', matches.size()) + result.put('files_scanned', filesScanned) + result.put('truncated', truncatedReason != null) + if( truncatedReason != null ) + result.put('truncated_reason', truncatedReason) + result.put('limit', limit) + result.put('max_depth', depth) + return JsonOutput.toJson(result) + } + + /** + * Name search, pure JVM {@link PathMatcher} — same reasoning as {@link #fsGrep}: no shelling + * out to {@code find}, whose predicate syntax differs between BSD and GNU. + */ + private static String fsFind(Path root, String pathStr, Map args, DispatchContext ctx) { + final String patternStr = args.pattern?.toString() + if( !patternStr ) + return error('`find` tool: missing required argument: pattern') + PathMatcher matcher + try { + matcher = globMatcher(patternStr) + } + catch( Exception e ) { + return error("`find` tool: invalid glob pattern `${patternStr}` - ${e.message}") + } + if( !Files.exists(root) ) + return error("path not found: ${pathStr}") + if( !Files.isDirectory(root) ) + return error("path is not a directory: ${pathStr}") + final String type = args.type?.toString() ?: 'any' + if( !(type in ['file','dir','any']) ) + return error("`find` tool: unknown type '${type}'; supported: file, dir, any") + final int limit = boundedArg(args.max_results, DEFAULT_MAX_RESULTS, MAX_MAX_RESULTS) + final int depth = boundedArg(args.max_depth, DEFAULT_MAX_DEPTH, MAX_MAX_DEPTH) + + final paths = new ArrayList() + int visited = 0 + String truncatedReason = null + final Stream stream = Files.walk(root, depth) + try { + for( final Iterator it=stream.iterator(); it.hasNext(); ) { + final Path p = it.next() + // bound the WALK as well as the answer -- see the same guard in fsGrep + if( ++visited > MAX_VISITED_ENTRIES ) { + truncatedReason = TRUNCATED_SEARCH + break + } + // the root itself is the thing being searched, never a result + if( p == root ) + continue + final boolean dir = Files.isDirectory(p) + if( type=='file' && dir ) + continue + if( type=='dir' && !dir ) + continue + // as in grep: a symlink to a target outside the sandbox must not be reported + if( !SandboxGuard.isAllowed(p, ctx.workDir, ctx.readablePaths, false) ) + continue + if( !matchesGlob(matcher, root, p) ) + continue + if( paths.size() >= limit ) { + truncatedReason = TRUNCATED_RESULTS + break + } + paths.add(p.toAbsolutePath().toString()) + } + } + finally { + stream.close() + } + Collections.sort(paths) + final result = new LinkedHashMap() + result.put('paths', paths) + result.put('count', paths.size()) + result.put('truncated', truncatedReason != null) + if( truncatedReason != null ) + result.put('truncated_reason', truncatedReason) + result.put('limit', limit) + result.put('max_depth', depth) + return JsonOutput.toJson(result) + } + + /** Compile a glob into a matcher; {@code null} for a null/blank pattern (i.e. no filtering). */ + private static PathMatcher globMatcher(String glob) { + if( !glob ) + return null + return FileSystems.getDefault().getPathMatcher("glob:${glob}") + } + + /** + * A path-shaped glob (one containing a {@code /}) is matched against the path RELATIVE to the + * search root, so a pattern is portable between search roots; a bare glob is matched against + * the file name alone, which is what {@code *.json} is expected to mean. + */ + private static boolean matchesGlob(PathMatcher matcher, Path root, Path candidate) { + final Path name = candidate.getFileName() + if( name != null && matcher.matches(name) ) + return true + try { + return matcher.matches(root.relativize(candidate)) + } + catch( IllegalArgumentException e ) { + return false + } + } + + /** An LLM-supplied numeric bound, defaulted when absent/unparseable and clamped to {@code [1,max]}. */ + private static int boundedArg(Object value, int defaultValue, int max) { + int result = defaultValue + if( value instanceof Number ) + result = ((Number) value).intValue() + else if( value != null ) { + try { result = Integer.parseInt(value.toString().trim()) } + catch( NumberFormatException e ) { result = defaultValue } + } + return Math.max(1, Math.min(result, max)) + } + + /** JSON booleans arrive as Boolean, but a model that stringifies them should still be understood. */ + private static boolean asBoolean(Object value) { + if( value instanceof Boolean ) + return ((Boolean) value).booleanValue() + return value != null && Boolean.parseBoolean(value.toString().trim()) + } + + private static String truncate(String line, int max) { + return line.length() <= max ? line : line.substring(0, max) + '...' + } + + /** File size, or {@code -1} when it cannot be read (a broken symlink, a vanished file). */ + private static long safeSize(Path path) { + try { return Files.size(path) } + catch( IOException e ) { return -1 } + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ModuleMetadataToolSchema.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ModuleMetadataToolSchema.groovy new file mode 100644 index 0000000000..78fd831738 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ModuleMetadataToolSchema.groovy @@ -0,0 +1,270 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import io.seqera.npr.api.schema.v1.ModuleChannel +import io.seqera.npr.api.schema.v1.ModuleChannelItem +import io.seqera.npr.api.schema.v1.ModuleMetadata +import io.seqera.npr.api.schema.v1.ModuleTool + +/** + * Derives a portable JSON-schema {@link Map} describing the inputs of a module from the + * registry {@link ModuleMetadata} (fetched from {@code GET /api/v1/modules/{name}}), and a + * human-readable tool description from the module + tool + output metadata. + * + * This is the registry-sourced counterpart of {@link ModuleSpecToolSchema} (which works from + * a sibling {@code meta.yml} {@link nextflow.module.ModuleSpec}). It exists because the registry + * metadata is richer (per-field descriptions, patterns, enums, tool homepages/documentation) and + * is the canonical, always-available source when the module is resolved from the registry. + * + * The schema is FLATTENED: each {@link ModuleChannel} item contributes one top-level property + * keyed by its name (so the LLM passes {@code {"meta": {...}, "reads": "/abs/path"}} rather than + * a nested array), carrying its {@code description} (plus any {@code pattern}/{@code enum}). + * + *

nf-core {@code meta.id} convention: for an nf-core module, a {@code map} item named + * {@code meta} is exposed as a nested object schema with an {@code id} string property — mirroring + * the hardcoded {@code --meta.id } convention in {@code CmdModuleView.inferNfCoreParam}. There + * is no sub-schema for {@code meta} in the registry; the {@code id} property is the convention. + * + *

All getters on the npr DTOs can return {@code null}; everything is null-guarded. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ModuleMetadataToolSchema { + + /** + * Build the flattened JSON-schema map for the module inputs from the registry metadata. + * Every item of every input channel becomes a top-level property keyed by its name; all + * are marked {@code required}. + * + * @param metadata the registry module metadata + * @param nfCore whether the module reference scope is {@code nf-core} (enables the + * {@code meta.id} convention) + * @throws IllegalArgumentException if an input item has no name or two flattened + * properties share a name + */ + static Map inputSchema(ModuleMetadata metadata, boolean nfCore) { + final properties = new LinkedHashMap() + final required = new ArrayList() + + final inputs = metadata?.getInput() ?: Collections.emptyList() + for( final ModuleChannel channel : inputs ) { + if( channel == null ) + continue + final items = channel.getItems() ?: Collections.emptyList() + for( final ModuleChannelItem item : items ) { + if( item == null ) + continue + final name = item.getName() + if( name == null || name.isEmpty() ) + throw new IllegalArgumentException("Module metadata declares an input item with no name - cannot expose it as an agent tool property") + if( properties.containsKey(name) ) + throw new IllegalArgumentException("Module metadata declares duplicate input name `${name}` across its input channels - agent tool properties must be unique") + properties.put(name, fragmentFor(item, nfCore)) + required.add(name) + } + } + + return ToolSchema.object(properties, required) + } + + /** + * The flattened input property names derived from the metadata, in declaration order. + * Used by the bridge to cross-check against the executable {@code ModuleSpec} (drift guard). + */ + static List inputPropertyNames(ModuleMetadata metadata) { + final names = new ArrayList() + final inputs = metadata?.getInput() ?: Collections.emptyList() + for( final ModuleChannel channel : inputs ) { + if( channel == null ) + continue + final items = channel.getItems() ?: Collections.emptyList() + for( final ModuleChannelItem item : items ) { + final name = item?.getName() + if( name ) + names.add(name) + } + } + return names + } + + /** + * Build a JSON-schema fragment for a single input item. The mapping mirrors + * {@link ModuleSpecToolSchema}, folding in {@code description}, {@code pattern} (appended to the + * description) and {@code enum}. The nf-core {@code meta} map item gets a nested {@code id} + * property (the {@code meta.id} convention). + */ + private static Map fragmentFor(ModuleChannelItem item, boolean nfCore) { + return buildFragment( + item.getName(), + item.getType(), + item.getDescription(), + item.getPattern(), + item.get_enum(), + nfCore ) + } + + /** + * The per-item mapping seam (primitive inputs) - keeps the npr-DTO coupling at the edge and + * lets the mapping logic be unit-tested without constructing a {@link ModuleMetadata}. + */ + protected static Map buildFragment(String name, String rawType, String description, String pattern, List enumValues, boolean nfCore) { + final type = rawType?.toLowerCase() + + // -- nf-core `meta.id` convention: a `map` item named `meta` for an nf-core module + // is exposed as a nested object with an `id` string property (no sub-schema exists + // in the registry; `id` is the hardcoded convention mirrored from CmdModuleView). + if( nfCore && type == 'map' && 'meta'.equalsIgnoreCase(name) ) + return ToolSchema.metaIdFragment(composeDescription(description ?: 'sample metadata', pattern)) + + final Map fragment = new LinkedHashMap() + if( type == 'map' ) { + fragment.put('type', 'object') + final desc = composeDescription(description, pattern) + if( desc ) + fragment.put('description', desc) + fragment.put('additionalProperties', true) + return fragment + } + + if( ToolSchema.isFileType(type) ) { + fragment.put('type', 'string') + final base = description ? "${description} (file path)".toString() : 'file path' + fragment.put('description', composeDescription(base, pattern)) + } + else if( ToolSchema.isIntegerType(type) ) { + fragment.put('type', 'integer') + putDescriptionIfPresent(fragment, description, pattern) + } + else if( ToolSchema.isNumberType(type) ) { + fragment.put('type', 'number') + putDescriptionIfPresent(fragment, description, pattern) + } + else if( type == 'boolean' ) { + fragment.put('type', 'boolean') + putDescriptionIfPresent(fragment, description, pattern) + } + else { + // string / val / unknown -> lenient string + fragment.put('type', 'string') + putDescriptionIfPresent(fragment, description, pattern) + } + + if( enumValues != null && !enumValues.isEmpty() ) + fragment.put('enum', new ArrayList(enumValues)) + + return fragment + } + + private static void putDescriptionIfPresent(Map fragment, String description, String pattern) { + final desc = composeDescription(description, pattern) + if( desc ) + fragment.put('description', desc) + } + + /** Append a {@code (pattern: ...)} hint to the description when a pattern is present. */ + private static String composeDescription(String description, String pattern) { + if( !pattern ) + return description + return description + ? "${description} (pattern: ${pattern})".toString() + : "pattern: ${pattern}".toString() + } + + /** + * A human-readable tool description: the module description, the wrapped tools (name + + * homepage/documentation URIs) and the output shape (the {@code output} Map keys are the + * emit names, each with its component items). Everything is null-guarded. + */ + static String description(ModuleMetadata metadata) { + final sb = new StringBuilder() + final desc = metadata?.getDescription() + sb.append(desc ?: 'module tool') + + final tools = metadata?.getTools() + if( tools ) { + for( final ModuleTool tool : tools ) { + if( tool == null ) + continue + final name = tool.getName() + if( !name ) + continue + sb.append('\nTool `').append(name) + if( tool.getVersion() ) + sb.append("` v").append(tool.getVersion()) + else + sb.append('`') + final links = new ArrayList() + if( tool.getHomepage() ) + links.add("homepage: ${tool.getHomepage()}".toString()) + if( tool.getDocumentation() ) + links.add("documentation: ${tool.getDocumentation()}".toString()) + if( links ) + sb.append(' (').append(links.join(', ')).append(')') + } + } + + sb.append('\n\n').append(outputDescription(metadata)) + return sb.toString() + } + + /** + * A human-readable description of the module outputs (emit names + component shapes) to + * append to the tool description. The registry {@code output} is a Map keyed by emit name. + */ + static String outputDescription(ModuleMetadata metadata) { + final outputs = metadata?.getOutput() ?: Collections.emptyMap() + if( outputs.isEmpty() ) + return 'Returns a JSON object (no declared outputs).' + + final sb = new StringBuilder() + sb.append('Returns a JSON object with the following output(s):') + for( final Map.Entry entry : outputs.entrySet() ) { + final emit = entry.key ?: 'result' + final channel = entry.value + sb.append('\n- `').append(emit).append('`: ') + final items = channel?.getItems() ?: Collections.emptyList() + if( items.isEmpty() ) { + sb.append('a value') + continue + } + if( items.size() > 1 || Boolean.TRUE.equals(channel?.getTuple()) ) { + final parts = new ArrayList() + for( final ModuleChannelItem item : items ) + parts.add(describeItem(item)) + sb.append('an object with ').append(parts.join(', ')) + } + else { + sb.append(describeItem(items[0])) + } + } + sb.append('\nFile/path outputs are returned as absolute path strings (never file contents).') + return sb.toString() + } + + /** + * Every registry DTO getter can return {@code null}, so the item is null-navigated HERE and + * the shared renderer only ever sees extracted strings. The {@code true} pins the number + * rung ON - the registry declares {@code float}/{@code double}/{@code number} types that a + * meta.yml does not. + */ + private static String describeItem(ModuleChannelItem item) { + return ToolSchema.describeComponent(item?.getName(), item?.getType(), item?.getDescription(), true) + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ModuleSpecToolSchema.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ModuleSpecToolSchema.groovy new file mode 100644 index 0000000000..000fe7ae94 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ModuleSpecToolSchema.groovy @@ -0,0 +1,150 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import nextflow.module.ModuleSpec +import nextflow.module.ModuleSpec.ModuleParam + +/** + * Derives a portable JSON-schema {@link Map} describing the inputs of a module + * {@link ModuleSpec} (typically loaded from a sibling {@code meta.yml}), and a + * human-readable description of its outputs. + * + * The schema is FLATTENED: a tuple input channel (e.g. {@code tuple(meta, reads)}) + * contributes one top-level property per component (so the LLM passes + * {@code {"meta": {...}, "reads": "/abs/path"}} rather than a nested array), while + * a scalar input channel contributes a single property. Each property carries the + * {@code description} declared in the meta.yml so the LLM has the full context the + * spec provides "for free". + * + * Type mapping is intentionally lenient (meta.yml types vary): {@code map} → object, + * {@code file}/{@code path}/{@code directory} → string (path handle), numeric → + * integer, {@code boolean} → boolean, everything else → string. + * + * langchain4j tools carry no output schema, so the LLM learns the output shape from + * the tool description + the JSON it gets back; {@link #outputDescription} renders + * that shape as prose to append to the tool description. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ModuleSpecToolSchema { + + /** + * Build the flattened JSON-schema map for the module inputs. Every component of + * every input channel becomes a top-level property keyed by its name; all are + * marked {@code required} (basic meta.yml carries no optionality). + * + * @throws IllegalArgumentException if two flattened properties share a name + */ + static Map inputSchema(ModuleSpec spec) { + final properties = new LinkedHashMap() + final required = new ArrayList() + + final inputs = spec.inputs ?: Collections.emptyList() + for( final param : inputs ) { + final components = param.isTuple() ? param.components : Collections.singletonList(param) + for( final comp : components ) { + final name = comp.name + if( name == null || name.isEmpty() ) + throw new IllegalArgumentException("Module spec `${spec.name}` declares an input with no name - cannot expose it as an agent tool property") + if( properties.containsKey(name) ) + throw new IllegalArgumentException("Module spec `${spec.name}` declares duplicate input name `${name}` across its input channels - agent tool properties must be unique") + properties.put(name, fragmentFor(comp)) + required.add(name) + } + } + + return ToolSchema.object(properties, required) + } + + /** + * A human-readable description of the module outputs (names + component shapes) + * to append to the tool description, so the LLM understands the JSON it gets back. + * Files are described as absolute path strings (the opaque-path contract). + */ + static String outputDescription(ModuleSpec spec) { + final outputs = spec.outputs ?: Collections.emptyList() + if( outputs.isEmpty() ) + return 'Returns a JSON object (no declared outputs).' + + final sb = new StringBuilder() + sb.append('Returns a JSON object with the following output(s):') + for( final param : outputs ) { + final name = param.name ?: 'result' + sb.append('\n- `').append(name).append('`: ') + if( param.isTuple() ) { + final parts = new ArrayList() + for( final comp : param.components ) + parts.add(describeComponent(comp)) + sb.append('an object with ').append(parts.join(', ')) + } + else { + sb.append(describeComponent(param)) + } + } + sb.append('\nFile/path outputs are returned as absolute path strings (never file contents).') + return sb.toString() + } + + /** + * A meta.yml component is never null-navigated: the spec loader materialises every + * declared component. The {@code false} pins the number rung OFF - a {@code float} here + * renders as {@code a string}, unlike the registry-sourced ladder. + */ + private static String describeComponent(ModuleParam comp) { + return ToolSchema.describeComponent(comp.name, comp.type, comp.description, false) + } + + private static Map fragmentFor(ModuleParam param) { + final type = param.type?.toLowerCase() + final desc = param.description + if( type == 'map' && 'meta'.equalsIgnoreCase(param.name) ) + return ToolSchema.metaIdFragment(desc ?: 'sample metadata') + + final Map fragment = new LinkedHashMap() + if( type == 'map' ) { + fragment.put('type', 'object') + fragment.put('additionalProperties', true) + if( desc ) + fragment.put('description', desc) + } + else if( ToolSchema.isFileType(type) ) { + fragment.put('type', 'string') + // make the path-handle contract explicit in the description + fragment.put('description', desc ? "${desc} (file path)".toString() : 'file path') + } + else if( ToolSchema.isIntegerType(type) ) { + fragment.put('type', 'integer') + if( desc ) + fragment.put('description', desc) + } + else if( type == 'boolean' ) { + fragment.put('type', 'boolean') + if( desc ) + fragment.put('description', desc) + } + else { + // val / string / unknown -> lenient string + fragment.put('type', 'string') + if( desc ) + fragment.put('description', desc) + } + return fragment + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ModuleToolBridge.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ModuleToolBridge.groovy new file mode 100644 index 0000000000..e31bbd9448 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ModuleToolBridge.groovy @@ -0,0 +1,836 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.channels.ClosedByInterruptException +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicBoolean + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import groovy.transform.TupleConstructor +import groovy.util.logging.Slf4j +import groovyx.gpars.dataflow.DataflowQueue +import groovyx.gpars.dataflow.DataflowReadChannel +import groovyx.gpars.dataflow.DataflowVariable +import groovyx.gpars.dataflow.operator.DataflowProcessor +import groovyx.gpars.dataflow.operator.PoisonPill +import io.seqera.npr.api.schema.v1.ModuleMetadata +import nextflow.Global +import nextflow.Nextflow +import nextflow.Session +import nextflow.extension.CH +import nextflow.extension.DataflowHelper +import nextflow.extension.FilesEx +import nextflow.module.ModuleSpec +import nextflow.module.ModuleSpec.ModuleParam +import nextflow.script.ChannelOut +import nextflow.script.ProcessConfigV1 +import nextflow.script.ProcessConfigV2 +import nextflow.script.ProcessDef +import nextflow.script.ProcessEntryHandler +import nextflow.script.params.BaseOutParam +import nextflow.script.params.OutParam +import nextflow.script.params.v2.ProcessInput +import nextflow.script.params.v2.ProcessOutput + +/** + * Bridges Nextflow processes/modules to the agent as LLM tools that execute as + * real, request-scoped dataflow nodes. + * + * The bridge is built in the workflow body (inside {@link nextflow.script.AgentDef#run}, + * before the dataflow network is ignited). For every tool it derives a portable + * {@link ToolDescriptor} and starts a dataflow gateway over a request queue. Each tool call + * carries its own reply variable; the gateway creates fresh input/output channels and invokes + * a cloned {@link ProcessDef}. Correlation is therefore represented by dataflow variables + * rather than by ordering on a shared process lane. + * + * Two marshalling modes are supported: + *

    + *
  • scalar (no {@link ModuleSpec}): each typed scalar input param maps to one + * request argument; the single scalar output is serialized under its name. Schema from + * {@link ProcessToolSchema} (Phase 2 / 3.1).
  • + *
  • spec-driven (a {@link ModuleSpec}, e.g. from a sibling {@code meta.yml}): one + * input queue per spec input channel. The LLM passes FLATTENED args; at dispatch the + * components of a tuple input are reassembled IN ORDER into a {@code List} + * (file/path → {@link Nextflow#file}, map → the arg Map, scalar → the value) and bound + * to that channel. Outputs (tuple or scalar) are serialized back to a JSON object keyed + * by emit name; file/path values become absolute path strings (the opaque-path + * contract). Schema from {@link ModuleSpecToolSchema} (Phase 3.2).
  • + *
+ * + * At tool-call time (post-ignition, on the agent task thread) {@link #call} submits a + * {@link ToolCall} and waits on its reply. The gateway can service independent requests + * concurrently; each resulting process still uses the normal executor, cache, work directory, + * retry and tracing machinery. When the agent's input source is exhausted {@link #close} + * poisons the request queue so the gateway terminates. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class ModuleToolBridge implements ToolDispatcher { + + /** + * Holds the immutable definition and marshalling metadata for a tool. + */ + private static class Tool { + String name + // scalar mode: the ordered input param names + List inputParamNames + String outputParamName + // spec mode: the module spec + ModuleSpec spec + // process template cloned for every request + ProcessDef processDef + } + + @TupleConstructor + private static class ToolCall { + Tool tool + Map arguments + DataflowVariable reply + } + + /** + * Per-agent-invocation dispatch context: a sandbox work dir and the readable dirs that the + * {@code fs:} tools may access. Stored in a {@link ThreadLocal} so the shared, + * pre-ignition bridge instance is stateless across records: per-record state lives ONLY here, + * never as an instance field. + * + *

Threading invariant: AiServices dispatches tool calls sequentially on the calling + * (agent operator) thread — {@code executeToolsConcurrently} is never enabled — so the + * ThreadLocal value set by {@link #setContext} before a {@code runner.run(req)} call is + * always the correct context when {@code call()} executes inside that run, regardless of the + * number of tool calls within a single invocation. + */ + private static final ThreadLocal CONTEXT = new ThreadLocal() + + /** Set the per-invocation dispatch context on the current thread. Call before {@code runner.run}. */ + static void setContext(DispatchContext ctx) { CONTEXT.set(ctx) } + + /** Clear the per-invocation dispatch context from the current thread. Call in a finally block after {@code runner.run}. */ + static void clearContext() { CONTEXT.remove() } + + /** Retrieve the per-invocation dispatch context for the current thread. May return {@code null} when called outside a dispatched invocation. */ + private static DispatchContext context() { return CONTEXT.get() } + + private final Map tools = new LinkedHashMap() + + private final List descriptors = new ArrayList() + + private final DataflowQueue requests = new DataflowQueue() + + private DataflowProcessor gateway + + private final AtomicBoolean closed = new AtomicBoolean() + + /** The wire names of the {@code fs:} tools this bridge serves in the driver JVM, a subset of + * {@link FilesystemTools#NAMES} — exactly the leaves the agent's {@code tools} directive + * selected. A name in here is routed to {@link FilesystemTools#call} and has a descriptor; + * a name NOT in here is not a filesystem tool as far as this bridge is concerned, so a + * process called {@code read} in an agent that never declared {@code fs:read} still reaches + * its own module dispatch rather than being hijacked. */ + private final Set filesystemTools + + /** True when the agent selected any {@code fs:} tool, i.e. when the task needs a sandbox + * {@link DispatchContext}. Read by {@code AgentDef} to decide whether to bind one. */ + boolean isFilesystemEnabled() { !filesystemTools.isEmpty() } + + /** The maximum size (in bytes) of a structured file output whose content is inlined to the + * LLM; larger or non text-like/binary outputs stay opaque path handles. Defaults to 32 KB. */ + private long maxInlineBytes = ToolOutputReader.DEFAULT_INLINE_BYTES + + /** Set the cap (in bytes) for inlining structured file outputs; non-positive values reset + * to the 32 KB default. Wired from the {@code agent.maxToolOutputInlineSize} config scope. */ + void setMaxInlineBytes(long bytes) { this.maxInlineBytes = bytes > 0 ? bytes : ToolOutputReader.DEFAULT_INLINE_BYTES } + + /** + * Build the bridge and its request gateway in the workflow body, before ignition. A tool with a + * {@link ModuleSpec} (e.g. a sibling {@code meta.yml}) uses spec-driven marshalling; one without + * uses the scalar typed-I/O path. When a {@link WiredModuleTool} carries a public registry + * {@link ModuleMetadata}, that metadata is the descriptor source (richer schema); marshalling + * stays spec-driven. Every wired module is advertised as its OWN tool whose {@code parameters} + * schema IS that module's flattened input schema, so OpenAI function-calling enforces field + * names/required-ness per module (an aggregate tool could only use a generic + * {@code additionalProperties:true} object, which it cannot enforce). + * + *

The default-valued arg generates the convenience overload (tools-only) via Groovy; the + * {@code fs:} tools named in {@code filesystemTools} have their calls routed to + * {@link FilesystemTools#call}. They deliberately do NOT enter {@link #descriptors()} — see + * {@link #filesystemDescriptors()}. + * + * @param wired the brokered tools the agent declared, in the order they are advertised + * @param filesystemTools the wire names of the {@code fs:} leaves this bridge must SERVE in the + * driver JVM, a subset of {@link FilesystemTools#NAMES}. Empty for an + * agent that declared no {@code fs:} ref AND for one running on a + * containerized runner, which serves them with its own builtins + */ + ModuleToolBridge(List wired, + Collection filesystemTools = Collections.emptyList()) { + // preserve the canonical order of FilesystemTools.NAMES rather than the caller's, so the + // descriptor list (and hence the tools fingerprint) does not depend on declaration order + final Collection selectedFs = filesystemTools != null ? filesystemTools : Collections.emptyList() + this.filesystemTools = new LinkedHashSet(FilesystemTools.NAMES.findAll { selectedFs.contains(it) }) + for( final tool : wired ) { + if( tool.spec != null ) + wireSpec(tool.name, tool.proc, tool.spec, tool.metadata, tool.nfCore) + else + wireScalar(tool.name, tool.proc) + } + if( !tools.isEmpty() ) + startGateway() + } + + private void startGateway() { + final session = Global.session as Session + gateway = DataflowHelper.newOperator( + [inputs: [requests]], + { ToolCall request -> startInvocation(session, request) } ) + } + + /** + * Construct each request-scoped graph on the single gateway operator. Script and DAG + * construction are mutable, whereas the resulting channels are independent and can be + * collected concurrently. + */ + private void startInvocation(Session session, ToolCall request) { + try { + if( request.tool.spec != null ) + startSpecInvocation(session, request) + else + startScalarInvocation(session, request) + } + catch( Throwable e ) { + request.reply.bindError(e) + } + } + + /** + * Pull outputs away from the GPars operator so it remains free to construct the next + * request and to run the TaskProcessor operators that produce those outputs. + */ + private void collectAsync(Session session, ToolCall request, Closure collector) { + // ORCHESTRATION: this thread blocks reading the tool task's output, so it must not + // come from the execution pool that same tool task needs to run on + session.getAgentExecService().submit { + try { + request.reply.bind(collector.call()) + } + catch( Throwable e ) { + request.reply.bindError(e) + } + } + } + + // ------------------------------------------------------------------------- + // SCALAR mode (Phase 2 / 3.1) - typed process, one queue per scalar input + // ------------------------------------------------------------------------- + + private void wireScalar(String name, ProcessDef proc) { + final config = proc.getProcessConfig() + if( !(config instanceof ProcessConfigV2) ) + throw new IllegalArgumentException("Agent tool `${name}` must be a typed process to be used as an agent tool") + final cfg = (ProcessConfigV2) config + + // -- ordered input param names + final inputParams = cfg.getInputs().getParams() + final inputParamNames = new ArrayList(inputParams.size()) + for( final p : inputParams ) + inputParamNames.add(((ProcessInput) p).getName()) + + // -- single output param (Phase 2 assumption) + final outputParams = cfg.getOutputs().getParams() + if( outputParams.size() != 1 ) + throw new IllegalArgumentException("Agent tool `${name}` must declare exactly one output to be used as an agent tool (got ${outputParams.size()})") + final outputParamName = outputKey((ProcessOutput) outputParams[0]) + + // -- portable schema descriptor. The tool name IS the process name: Nextflow process + // identifiers are valid OpenAI function-name identifiers (alphanumeric + underscore), + // so no sanitization is needed; uniqueness is guaranteed by the in-scope process + // namespace (and the `tools` map / `descriptors` keying dedups by name). + final descriptor = new ToolDescriptor( + name, + name, + ProcessToolSchema.inputSchema(proc), + ProcessToolSchema.outputSchema(proc) ) + + final tool = new Tool( + name: name, + inputParamNames: inputParamNames, + outputParamName: outputParamName, + processDef: proc ) + tools.put(name, tool) + // each wired module is advertised as its OWN tool whose parameters schema is the + // module's flattened input schema, so OpenAI function-calling enforces the field names + descriptors.add(descriptor) + } + + // ------------------------------------------------------------------------- + // SPEC-driven mode (Phase 3.2) - meta.yml describes the tuple/path/map I/O + // ------------------------------------------------------------------------- + + private void wireSpec(String name, ProcessDef proc, ModuleSpec spec, ModuleMetadata metadata = null, boolean nfCore = false) { + final inputs = spec.inputs ?: Collections.emptyList() + + // -- portable schema descriptor (flattened inputs + prose output shape). When the public + // registry ModuleMetadata is available (Task 3) it is the canonical, richer source + // (per-field descriptions, patterns, enums, the nf-core meta.id convention, tool + // homepages); otherwise fall back to the sibling meta.yml ModuleSpec (offline / + // local-file / in-scope tools). Marshalling/output is UNCHANGED (always spec-driven). + final Map inputSchema + final String description + if( metadata != null ) { + inputSchema = ModuleMetadataToolSchema.inputSchema(metadata, nfCore) + description = ModuleMetadataToolSchema.description(metadata) + // consistency / silent-drift guard: the descriptor (from the registry `.latest` + // metadata) and the executor (the installed meta.yml ModuleSpec used for marshalling) + // are two views of the same module - warn if their flattened input names differ. + warnOnInputDrift(name, metadata, spec) + } + else { + inputSchema = ModuleSpecToolSchema.inputSchema(spec) + description = buildDescription(spec) + } + // The tool name IS the process/module name (e.g. SKESA): Nextflow process identifiers are + // valid OpenAI function-name identifiers (alphanumeric + underscore), so no sanitization is + // needed; uniqueness is guaranteed by the in-scope process namespace (and the `tools` map / + // `descriptors` keying dedups by name). + final descriptor = new ToolDescriptor(name, description, inputSchema, null) + + // -- the number of input queues = the PROCESS's declared input-channel count; + // `ProcessEntryHandler.getProcessArguments` returns one value per declared input + // channel, so the queues must match that, NOT the spec input count. They should be + // equal (descriptor and executor are two views of the same meta.yml) -- warn on drift. + final int nInputs = declaredInputChannelCount(proc) + if( nInputs != inputs.size() ) + log.warn("Agent tool `${name}`: module spec declares ${inputs.size()} input(s) but the process declares ${nInputs} input channel(s) - using the process input count for marshalling") + + final tool = new Tool( + name: name, + spec: spec, + processDef: proc ) + tools.put(name, tool) + // each wired module is advertised as its OWN tool whose parameters schema is the + // module's flattened input schema (required fields + additionalProperties:false), so + // OpenAI function-calling validates the call against it and the model cannot omit/rename + // fields — the per-module enforcement that an aggregate module_run tool cannot provide + descriptors.add(descriptor) + } + + /** + * The number of declared input channels of a process: each {@code val}/{@code path}/... is one + * channel and a {@code tuple} counts as a single channel. Matches the per-input-channel arity + * that {@link nextflow.script.ProcessEntryHandler#getProcessArguments} returns. + */ + private static int declaredInputChannelCount(ProcessDef proc) { + final config = proc.getProcessConfig() + if( config instanceof ProcessConfigV2 ) + return ((ProcessConfigV2) config).getInputs().getParams().size() + return ((ProcessConfigV1) config).getInputs().size() + } + + /** + * The ProcessDef's declared output params, in declaration order. These are positionally + * aligned with the captured {@link ChannelOut} (the ProcessDef builds the {@code ChannelOut} + * from these same outputs, in order), so {@code outParams[i]} corresponds to + * {@code channelOut[i]}. Used to detect topic-routed outputs authoritatively. + */ + private static List declaredOutputParams(ProcessDef proc) { + final config = proc.getProcessConfig() + if( config instanceof ProcessConfigV2 ) + return new ArrayList(((ProcessConfigV2) config).getOutputs().getParams()) + return new ArrayList(((ProcessConfigV1) config).getOutputs()) + } + + /** + * True when the ProcessDef's declared output at position {@code i} is routed to a + * {@code topic:} (nf-core {@code versions}-style bookkeeping). Such outputs use a + * topic-source channel that never binds a readable per-invocation value, so the dispatcher + * must not block reading them. This is authoritative -- the meta.yml {@code type} is + * unreliable: some modules type the eval/version component as {@code string}, not + * {@code eval} (e.g. nf-core/assemblyscan vs nf-core/skesa), so {@link #isEvalOutput} alone + * would miss it and the dispatch would hang. + */ + private static boolean isTopicOutput(List outParams, int i) { + if( outParams == null || i >= outParams.size() ) + return false + final p = outParams[i] + // Only classic-DSL2 (V1) outputs carry a `topic:` channel name on the param itself. + // The typed (V2) ProcessOutput.getChannelTopicName() throws UnsupportedOperationException, + // and V2 topics are a SEPARATE collection (not in getOutputs().getParams()), so a V2 data + // output is never a topic-source here -- guard on BaseOutParam to avoid the throw. + return (p instanceof BaseOutParam) && ((BaseOutParam) p).getChannelTopicName() != null + } + + private static String buildDescription(ModuleSpec spec) { + final base = spec.description ?: spec.name ?: 'module tool' + return "${base}\n\n${ModuleSpecToolSchema.outputDescription(spec)}".toString() + } + + /** + * Consistency check: warn when the flattened input property names derived from the registry + * {@link ModuleMetadata} (the descriptor source) differ from those of the executable + * {@link ModuleSpec} (the marshalling source) - a silent-drift guard between the registry + * {@code .latest} descriptor and the installed spec the tool actually runs as. + */ + private static void warnOnInputDrift(String name, ModuleMetadata metadata, ModuleSpec spec) { + final fromMeta = ModuleMetadataToolSchema.inputPropertyNames(metadata) + final fromSpec = specInputNames(spec) + if( fromMeta != fromSpec ) + log.warn("Agent tool `${name}`: registry metadata declares inputs ${fromMeta} but the installed module spec declares ${fromSpec} - the tool descriptor (from the registry) and its marshalling (from the local spec) may be out of sync") + } + + /** The flattened input property names of a {@link ModuleSpec}, in declaration order. */ + private static List specInputNames(ModuleSpec spec) { + final List inputs = spec?.inputs ?: Collections.emptyList() + return inputs + .collectMany { ModuleParam param -> (param.isTuple() ? param.components : [param]) as List } + .findResults { ModuleParam comp -> comp?.name } + .toList() as List + } + + /** + * The descriptors of the BROKERED tools — the {@code nf:module_run} processes the driver + * executes as real Nextflow tasks — and nothing else. This list becomes + * {@code AgentRunnerRequest.toolSpecs}, whose partition invariant is that a runner-native + * tool never appears in it: a name in {@code toolSpecs} is a name the runner is authorized to + * call BACK into the driver with, and a {@code fs:}/{@code shell:} tool must never be that. + * The {@code fs:} leaves this bridge serves in-JVM are exposed separately by + * {@link #filesystemDescriptors()}. + */ + List descriptors() { + return descriptors + } + + /** + * The descriptors of the {@code fs:} leaves this bridge serves in the driver JVM, in the + * canonical {@link FilesystemTools#NAMES} order. Empty unless the bridge was built for an + * in-JVM runner with an {@code fs:} selection. + * + *

Kept OUT of {@link #descriptors()} on purpose: an in-JVM runner advertises these to the + * model itself (from {@code AgentRunnerRequest.nativeToolNames}) and dispatches them straight + * back here, so they never travel as brokered descriptors and never enter a broker allowlist. + */ + List filesystemDescriptors() { + return FilesystemTools.descriptors(filesystemTools) + } + + /** + * A tool name -> backing process script source map, i.e. the {@code BodyDef.source} that + * {@code TaskHasher} folds into that process' own task hash. Feeds + * {@code AgentDef.toolsFingerprint} so that editing a tool's script invalidates the resume + * cache entry of every agent that can call it. The {@code fs:} tools have no backing + * process and so do not appear here. + */ + Map toolSources() { + final result = new LinkedHashMap() + for( final entry : tools.entrySet() ) + result.put(entry.key, entry.value.processDef?.getTaskBody()?.source) + return result + } + + /** + * Execute a tool call. Parse the JSON args, submit a correlated request to the gateway, + * then block on that request's reply variable. + * + *

Dispatch-level errors are returned as a tool result, not thrown. An unknown + * tool name, unparseable {@code argsJson}, or a malformed/mis-shaped argument all yield a + * well-formed {@code {"error": ""}} JSON object that names the tool and what went + * wrong, so the LLM can see the failure and retry rather than the agent loop being aborted + * by an exception escaping the dispatcher. + * + *

Task failure is fatal, not recoverable. When the underlying tool process task + * (the Nextflow task the tool runs as) hard-fails (exit ≠ 0), the session aborts the dataflow + * network and interrupts the agent operator thread that is blocked on the tool's output channel + * {@code .val} ({@link groovyx.gpars.dataflow.expression.DataflowExpression#getVal} throws + * {@link InterruptedException}). This is NOT recoverable by the LLM: the run is already being + * torn down. Such an {@link InterruptedException} is caught here, the thread's interrupt flag is + * restored, and it is re-thrown as an {@link AgentToolFatalError} (an {@link Error}, NOT an + * {@link Exception}) so it escapes langchain4j's tool-execution {@code try/catch(Exception)} and + * propagates out of {@code agent.chat(...)} on the agent task-body thread and through + * {@code TaskProcessor}'s exec-body failure handling, which aborts the run cleanly — instead of + * being fed back to the model as an error tool result that loops to the iteration cap. + * + * @param toolName the name of the tool to invoke + * @param argsJson the LLM-supplied arguments as a JSON object string + * @return the tool result serialized as a JSON object string; on a dispatch-level failure + * a {@code {"error": "..."}} JSON object so the LLM can recover + * @throws AgentToolFatalError when the underlying process task fails and the session aborts + * (the blocking output pull is interrupted) — the run is torn down, not retried + */ + @Override + String call(String toolName, String argsJson) { + try { + // Route the selected fs: tools BEFORE the module lookup so that a bridge with no + // wired modules still dispatches them. Only the SELECTED names are routed here: + // an unselected `read`/`find`/... is not a filesystem tool at all and falls through + // to the module lookup, so a process of that name stays reachable. + if( filesystemTools.contains(toolName) ) + return FilesystemTools.call(toolName, parseArgs(toolName, argsJson), context(), maxInlineBytes) + + final tool = tools.get(toolName) + if( tool == null ) + throw new IllegalArgumentException("Unknown agent tool `${toolName}` - available tools: ${tools.keySet()}") + + final DataflowVariable reply = new DataflowVariable() + if( closed.get() ) + throw new IllegalStateException("Agent tool bridge is closed") + final parsed = parseArgs(toolName, argsJson) + requests.bind(new ToolCall(tool, parsed, reply)) + final result = reply.val + // after the module task completes, scan the result for file path strings and whitelist + // their parent dirs in the dispatch context so the `fs:` tools can read module outputs. + // ONLY whitelist on a non-error result: a failed module returns {"error":"..."}, and if + // that error message contains an absolute path the parent dir must NOT be whitelisted — + // that would silently widen the sandbox with data from an error string, not an output. + final resultParsed = parseResultJson(result) + if( !isErrorResult(resultParsed) ) + whitelistOutputDirs(resultParsed) + return result + } + catch( Exception e ) { + // A genuine underlying-task failure / session abort surfaces as an InterruptedException — + // which GPars WRAPS in a RuntimeException (e.g. RuntimeException(cause=InterruptedException) + // from DataflowStreamReadAdapter.getVal), so a bare `catch(InterruptedException)` misses it. + // That case is FATAL: the run is being torn down — do NOT swallow it into a {"error":...} + // tool result (that feeds the failure back to the model and loops to maxIterations). + // Detect it via the cause chain, restore the interrupt flag, and re-throw as an Error so it + // escapes langchain4j's tool-execution catch(Exception) and aborts the run cleanly. + if( isInterrupted(e) ) { + Thread.currentThread().interrupt() + log.debug("Agent tool `${toolName}` aborted by session interrupt (underlying task failure)", e) + throw new AgentToolFatalError("Agent tool `${toolName}` aborted - the underlying process task failed", e) + } + // dispatch-level failure (unknown tool / arg parsing / arg marshalling): return it + // as a tool result so the LLM can recover, rather than letting it abort the loop + final message = "Agent tool `${toolName}` failed - ${e.message ?: e.toString()}".toString() + log.warn(message, e) + return JsonOutput.toJson([error: message]) + } + } + + /** + * True when {@code t} is, or wraps anywhere in its cause chain, an {@link InterruptedException}. + * A failed underlying task makes the session abort and interrupt this operator's blocking output + * pull; GPars surfaces that as a {@link RuntimeException} wrapping the {@link InterruptedException} + * (e.g. {@code DataflowStreamReadAdapter.getVal}), so a bare {@code instanceof} check misses it. + */ + private static boolean isInterrupted(Throwable t) { + for( Throwable c = t; c != null; c = c.getCause() ) { + // ClosedByInterruptException is the IOException form raised when an abort interrupts + // in-flight (interruptible) file I/O — also fatal, also outside InterruptedException + if( c instanceof InterruptedException || c instanceof ClosedByInterruptException ) + return true + } + return false + } + + /** + * Predicate: true when the parsed result is an error-shaped object (a Map containing + * an {@code "error"} key). Used to guard the {@code whitelistOutputDirs} call in + * {@link #call} so that absolute paths appearing only in error messages + * are not added to the filesystem sandbox whitelist. + * + * @param parsed the already-parsed result value (Map, List, String, null, …) + * @return true when {@code parsed} is a Map with an {@code "error"} key, false otherwise + */ + @PackageScope + static boolean isErrorResult(Object parsed) { + return parsed instanceof Map && ((Map) parsed).containsKey('error') + } + + /** + * Parse a JSON result string silently. Returns the parsed object (Map, List, String, …) + * or {@code null} if the string is null/blank/unparseable. + */ + private static Object parseResultJson(String resultJson) { + if( resultJson == null || resultJson.isBlank() ) + return null + try { + return new JsonSlurper().parseText(resultJson) + } + catch( Exception e ) { + log.trace("Agent tool result is not parsable JSON, treating as no result: ${e.message}") + return null + } + } + + /** + * Scan an already-parsed JSON result value for absolute file path strings and add each + * file's parent directory to the dispatch context's readable-dirs whitelist. Called after a + * module tool task completes successfully (callers must skip error results). + * No-op when no dispatch context is active or the parsed value is null. + * + *

Package-visible for unit testing; not part of the public API.

+ */ + @PackageScope + static void whitelistOutputDirs(Object parsed) { + final DispatchContext ctx = context() + if( ctx == null || parsed == null ) + return + collectPathsFromValue(parsed, ctx) + } + + private static void collectPathsFromValue(Object value, DispatchContext ctx) { + if( value instanceof Map ) { + ((Map) value).values().each { collectPathsFromValue(it, ctx) } + } + else if( value instanceof List ) { + ((List) value).each { collectPathsFromValue(it, ctx) } + } + else if( value instanceof String ) { + final s = (String) value + if( s.startsWith('/') ) { + try { + final p = Path.of(s) + // Whitelist the produced path ITSELF, never its parent. The parent is every + // sibling of the output too, and for an output that lands outside the work tree + // — a `publishDir` target, a path on a shared filesystem — that is a directory + // of content no cache key covers, which is what forces `cache false` on a + // filesystem agent. A file entry matches only that file; a directory output + // still admits its contents, since containment is by path prefix. + // Guarded on a REAL produced path, so an arbitrary path-shaped string in the + // tool result grants nothing, and on a non-empty name count, so `/` cannot be + // whitelisted. + if( p.getNameCount() > 0 && Files.exists(p) ) + ctx.addReadablePath(p) + } + catch( Exception e ) { + log.trace("Agent filesystem whitelist: skipping non-path output value `${s}`: ${e.message}") + } + } + } + } + + /** + * Parse the LLM-supplied arguments into a {@code Map}. An empty/blank payload is treated as + * no arguments; an unparseable payload, or one that is not a JSON object, raises an error + * that names the offending tool so it round-trips to the LLM as a clear tool-result error. + */ + private static Map parseArgs(String toolName, String argsJson) { + if( argsJson == null || argsJson.trim().isEmpty() ) + return Collections.emptyMap() + final Object parsed + try { + parsed = new JsonSlurper().parseText(argsJson) + } + catch( Exception e ) { + throw new IllegalArgumentException("could not parse the tool arguments as JSON (${e.message}); arguments must be a JSON object") + } + if( !(parsed instanceof Map) ) + throw new IllegalArgumentException("the tool arguments must be a JSON object, got ${parsed?.getClass()?.simpleName ?: 'null'}") + return (Map) parsed + } + + /** + * Return {@code args} without the file/path entries whose value is an empty (or blank) string. + * + *

A spec-derived tool schema marks EVERY module input {@code required} - neither a + * {@code meta.yml} nor the registry metadata declares optionality - so a model with nothing + * to supply for an input that the module treats as optional tends to send {@code ""} rather + * than omit the key. Skipping an optional path input means supplying NOTHING, the way a CLI + * tool behaves: the binding in {@link ProcessEntryHandler} defaults an ABSENT path arg to an + * empty list and does not accept an empty value as a stand-in. So the empty value is dropped + * HERE, at the producer, instead of being interpreted downstream. + */ + @PackageScope + static Map dropEmptyPathArgs(Map args, ModuleSpec spec) { + if( !args || spec == null ) + return args + final Map result = new LinkedHashMap(args) + final inputs = spec.inputs ?: Collections.emptyList() + for( final param : inputs ) { + final List components = param.isTuple() ? param.components : Collections.singletonList(param) + for( final comp : components ) { + if( !ToolSchema.isFileType(comp.type?.toLowerCase()) ) + continue + final value = result.get(comp.name) + if( value instanceof CharSequence && value.toString().trim().isEmpty() ) { + log.debug "Agent tool arg `${comp.name}` is empty - omitting it (optional path input not provided)" + result.remove(comp.name) + } + } + } + return result + } + + private void startScalarInvocation(Session session, ToolCall request) { + final tool = request.tool + final parsed = request.arguments + final args = tool.inputParamNames.collect { parsed.get(it) } + final invocation = tool.processDef.clone() + final ChannelOut out = (ChannelOut) invocation.run(args as Object[]) + final DataflowReadChannel resultChannel = CH.getReadChannel(out[0]) + + collectAsync(session, request) { + final result = resultChannel.val + final key = (tool.outputParamName == '$out') ? 'result' : tool.outputParamName + return JsonOutput.toJson([(key): result]) + } + } + + private void startSpecInvocation(Session session, ToolCall request) { + final tool = request.tool + final spec = tool.spec + final parsed = dropEmptyPathArgs(request.arguments, spec) + + // -- marshal the flattened LLM args into one channel value per input channel using the + // SAME logic as `nextflow module run` (ProcessEntryHandler.getProcessArguments): the + // args map is treated as the params map (dot-notation folded into nested maps), each + // declared input is looked up by name, coerced per the module spec types (file -> + // Nextflow.file, map -> Map, integer -> Integer, ...) and a tuple input is assembled + // into a List of its components (e.g. [[id:'s1'], file(reads)]). + // A missing/invalid arg throws IllegalArgumentException which the dispatcher in `call` + // turns into a {"error":...} tool result so the LLM can recover. + final List args = ProcessEntryHandler.getProcessArguments(tool.processDef, parsed, spec) + final ProcessDef invocation = tool.processDef.clone() + final ChannelOut channelOut = (ChannelOut) invocation.run(args as Object[]) + // Capture all readers before pulling any value. This matters if a future component + // invocation returns broadcast outputs rather than singleton variables. + final List outReadChannels = (0..() + final outputs = spec.outputs ?: Collections.emptyList() + final emitNames = emitNamesByPosition(channelOut) + final int nOut = channelOut.size() + // The ProcessDef's declared output params are positionally aligned with ChannelOut. + final List outParams = declaredOutputParams(invocation) + for( int i=0; i= nOut ) + continue + final key = outputKey(param, emitNames, i) + final value = outReadChannels[i].val + result.put(key, serializeOutput(param, value)) + } + return JsonOutput.toJson(result) + } + } + + /** + * True for an nf-core `versions`-style output: a tuple whose components include an + * {@code eval} (or the param itself is an {@code eval}). Such outputs are computed + * command captures routed to a `topic` for pipeline bookkeeping -- they are not a + * per-invocation tool result and their channel does not bind a readable value, so + * the dispatcher must not block reading them. + */ + private static boolean isEvalOutput(ModuleParam param) { + if( param == null ) + return false + return 'eval'.equalsIgnoreCase(param.type) || + (param.components?.any { it != null && 'eval'.equalsIgnoreCase(it.type) } ?: false) + } + + /** + * Serialize an output channel value per the spec param shape: a tuple value (a List) + * becomes an object keyed by component name; a scalar value is serialized directly. + * File/path values become absolute path strings, except small structured (text-like) + * outputs whose contents are inlined for the LLM (see {@link ToolOutputReader}). + */ + private Object serializeOutput(ModuleParam param, Object value) { + if( param.isTuple() ) { + final record = new LinkedHashMap() + final list = (value instanceof List) ? (List) value : [value] + final comps = param.components + for( int i=0; i emitNamesByPosition(ChannelOut out) { + final result = new LinkedHashMap() + for( final emitName : out.getNames() ) { + final ch = out.getProperty(emitName) + for( int i=0; i emitNames, int index) { + if( param.name ) + return param.name + final emit = emitNames.get(index) + if( emit ) + return emit + return "out${index}".toString() + } + + /** + * Poison the gateway request queue so its operator terminates. Idempotent. + */ + void close() { + // close() is triggered only after the agent output completes, so no valid call can + // race with the poison pill. The atomic flag provides idempotence and visibility. + if( !closed.compareAndSet(false, true) ) + return + if( gateway != null ) + requests.bind(PoisonPill.instance) + } + + /** + * The output property key: a named output uses its declared name; a bare typed value + * lowers to the synthetic name {@code $out}. + */ + private static String outputKey(ProcessOutput param) { + final name = param.getName() + return ( name == null ) ? '$out' : name + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ModuleToolResolver.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ModuleToolResolver.groovy new file mode 100644 index 0000000000..7e2ec8e77e --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ModuleToolResolver.groovy @@ -0,0 +1,270 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.seqera.npr.api.schema.v1.ModuleMetadata +import io.seqera.npr.client.RegistryClient +import nextflow.Global +import nextflow.Session +import nextflow.config.RegistryConfig +import nextflow.module.ModuleInfo +import nextflow.module.ModuleReference +import nextflow.module.ModuleSpec +import nextflow.module.ModuleSpecFactory +import nextflow.module.RegistryClientFactory +import nextflow.script.BaseScript +import nextflow.script.ProcessDef +import nextflow.script.ScriptMeta + +/** + * Turns the brokered half of an agent's resolved {@code tools} selection into a + * {@link ModuleToolBridge}: for every {@code nf:module_run} process in scope it finds the sibling + * {@code meta.yml} spec and, when the module came from the registry, its public + * {@link ModuleMetadata} — the three facts a tool is wired from, collected into one + * {@link WiredModuleTool} each. + * + *

Owning this keeps the lookup chain (module dir -> registry install marker -> registry client + * -> metadata) out of the agent definition, which only needs the bridge that comes out of it. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class ModuleToolResolver { + + /** The agent the tools are wired for; named by the debug/warn messages below. */ + private final String agentName + + /** The script the agent is defined in, i.e. the scope the processes are looked up in. */ + private final BaseScript owner + + ModuleToolResolver(String agentName, BaseScript owner) { + this.agentName = agentName + this.owner = owner + } + + /** + * Wire the brokered half of the resolved selection — the {@code nf:module_run} + * processes the driver executes as real Nextflow tasks — into a {@link ModuleToolBridge}, + * and, for an in-JVM runner only, the {@code fs:} leaves it must serve itself. + * + *

Which {@code fs:} leaves the bridge serves is decided by WHO EXECUTES the agent, not by + * whether any were declared: + *

    + *
  • containerized runner — none. The {@code fs:} tools are the runner's own + * builtins, rooted at the session cwd inside the container, enabled by the names on + * {@code AgentRunnerRequest.nativeToolNames}. Handing them to the bridge would relocate + * a container-side tool into the driver JVM, where the sandbox is rooted at a + * driver-side work-dir path that on a cloud executor is not even a local file.
  • + *
  • in-JVM runner — exactly the selected leaves, never the whole family: the + * resolver already expanded {@code fs:*} where that is what was written, so collapsing + * a partial selection here would hand a read-only agent {@code write} and {@code edit}. + * They are served through {@link ModuleToolBridge#call} but stay OUT of + * {@code toolSpecs} (§5); the runner advertises them from the same native names.
  • + *
+ * + * @param selection the resolved {@code tools} selection, already partitioned into its brokered + * and runner-native halves; {@code null} or empty when the agent declared no tools + * @param containerized whether the selected runner executes the agent in its own container + * @return a {@link ModuleToolBridge}, or {@code null} when nothing was declared + */ + ModuleToolBridge createToolBridge(ToolRefResolver.Selection selection, boolean containerized) { + if( selection == null || selection.isEmpty() ) + return null + final meta = ScriptMeta.get(owner) + final List wired = wireModuleRunTools(meta, selection.brokeredNames) + final List inJvmFsTools = containerized + ? Collections.emptyList() + : selection.runnerNative + .findAll { it.ref.startsWith(ToolRefResolver.FS_FAMILY + ':') } + .collect { it.name } + return new ModuleToolBridge(wired, inJvmFsTools) + } + + /** + * Wire each selected in-scope process as its own tool with an enforced per-module schema. + * The descriptor is sourced from the public registry + * {@link io.seqera.npr.api.schema.v1.ModuleMetadata} when the module is a registry install + * (richer), else from the sibling {@code meta.yml} {@link nextflow.module.ModuleSpec}. One + * registry client is built for the whole selection, and only when at least one module needs it, + * so a purely-local selection never constructs one. + * + * @param procNames the process names selected through {@code nf:module_run}, already + * de-duplicated and verified to exist by the resolver + * @return one {@link WiredModuleTool} per resolvable process, in {@code procNames} order + */ + private List wireModuleRunTools(ScriptMeta meta, Collection procNames) { + if( meta == null || !procNames ) + return new ArrayList() + // resolve the module refs FIRST: that is what decides whether a registry client is needed + // at all, so a purely-local selection never builds one, and a mixed selection builds one + final Map procs = resolvableProcesses(meta, procNames) + final Map refs = procs.collectEntries { name, proc -> + [(name): recoverModuleRef(resolveIncludedModuleDir(proc))] + } as Map + final RegistryClient client = refs.values().any { it != null } + ? newRegistryClient(Global.session as Session) + : null + return procs.collect { name, proc -> wireOne(name, proc, refs.get(name), client) } + } + + /** The selected names that name a process in scope, in selection order. */ + private static Map resolvableProcesses(ScriptMeta meta, Collection procNames) { + final Map result = new LinkedHashMap() + for( final name : procNames ) { + final proc = meta.getProcess(name) + if( proc != null ) + result.put(name, proc) + } + return result + } + + /** + * One process, wired as one tool: its sibling {@code meta.yml} spec plus — when the module came + * from the registry and the metadata could be fetched — the richer registry descriptor source. + */ + private WiredModuleTool wireOne(String procName, ProcessDef proc, ModuleReference moduleRef, RegistryClient client) { + // sibling meta.yml for spec-driven marshalling; null for locally-defined processes + final spec = loadSiblingSpec(proc) + final metadata = moduleRef != null ? registryMetadata(client, moduleRef) : null + // the nf-core meta.id convention is a property of the SOURCE, so it only applies when the + // registry metadata is what the descriptor is built from + final nfCore = metadata != null && moduleRef.scope == 'nf-core' + return new WiredModuleTool(procName, proc, spec, metadata, nfCore) + } + + /** + * The registry metadata for an installed module, or {@code null} when it cannot be had — the + * descriptor then falls back to the sibling {@code meta.yml} spec, which is always present for + * a registry install. + */ + private ModuleMetadata registryMetadata(RegistryClient client, ModuleReference moduleRef) { + try { + return fetchModuleMetadata(client, moduleRef, null) + } + catch( Exception e ) { + log.debug("Agent `${agentName}` nf:module_run: could not fetch registry metadata for `${moduleRef.fullName}` (${e.message}) — falling back to meta.yml spec") + return null + } + } + + /** Build a {@link io.seqera.npr.client.RegistryClient} from the session's {@code registry} config scope. */ + private static RegistryClient newRegistryClient(Session session) { + final registryConfig = new RegistryConfig((session?.config?.registry as Map) ?: Collections.emptyMap()) + return RegistryClientFactory.forConfig(registryConfig) + } + + /** + * Look for a sibling {@code meta.yml} / {@code meta.yaml} in the module dir of the script + * that defines the process and, when present, load it into a {@link nextflow.module.ModuleSpec} + * to drive spec-driven tool schema and tuple/path/map marshalling (Phase 3.2). Returns + * {@code null} when no sibling spec is found. + */ + private static ModuleSpec loadSiblingSpec(ProcessDef proc) { + final dir = resolveIncludedModuleDir(proc) + if( dir == null ) + return null + for( final candidate : ['meta.yml', 'meta.yaml'] ) { + final specPath = dir.resolve(candidate) + if( specPath.toFile().exists() ) + return ModuleSpecFactory.fromYaml(specPath) + } + return null + } + + /** + * The module dir of an included process (brought in via {@code include { X } from '...'}), + * derived from the {@link ScriptMeta} of the script that defines it. Returns {@code null} + * when the owner, its ScriptMeta, or its module dir cannot be resolved. + */ + private static Path resolveIncludedModuleDir(ProcessDef proc) { + final BaseScript owner = proc.getOwner() + if( owner == null ) + return null + final ScriptMeta scriptMeta = ScriptMeta.get(owner) + if( scriptMeta == null ) + return null + return scriptMeta.getModuleDir() + } + + /** + * Recover a {@link nextflow.module.ModuleReference} from an included module's install dir + * when it is a registry install. A registry install is identified by the presence of the + * {@link nextflow.module.ModuleInfo#MODULE_INFO_FILE} marker ({@code .module-info}) inside the + * module dir, AND the directory layout {@code /modules//} (depth ≥ 2 relative + * to the markers parent's parent so both {@code scope} and {@code name} components exist). + * + *

Returns {@code null} for any case where recovery is infeasible or unsafe: + *

    + *
  • the {@code moduleDir} is null;
  • + *
  • the {@code .module-info} marker is not present;
  • + *
  • the parent chain does not have a grandparent named {@code modules};
  • + *
  • any exception during path inspection.
  • + *
+ * Local-file {@code include} statements (no marker file) fall through to {@code null}. + */ + private static ModuleReference recoverModuleRef(Path moduleDir) { + if( moduleDir == null ) + return null + try { + // must have the .module-info marker to be a registry install + if( !Files.exists(moduleDir.resolve(ModuleInfo.MODULE_INFO_FILE)) ) + return null + // layout: /modules// + // dir.fileName = , dir.parent.fileName = , dir.parent.parent.fileName = modules + final nameComp = moduleDir.fileName + final scopeDir = moduleDir.parent + if( nameComp == null || scopeDir == null ) + return null + final scopeComp = scopeDir.fileName + final modulesDir = scopeDir.parent + if( scopeComp == null || modulesDir == null ) + return null + if( modulesDir.fileName?.toString() != 'modules' ) + return null + return new ModuleReference(scopeComp.toString(), nameComp.toString()) + } + catch( Exception e ) { + log.debug("recoverModuleRef: could not recover module reference from `${moduleDir}`: ${e.message}") + return null + } + } + + /** + * Fetch the public {@link io.seqera.npr.api.schema.v1.ModuleMetadata} for a resolved registry + * module via the SAME {@link io.seqera.npr.client.RegistryClient} already built to resolve the + * module ({@code GET /api/v1/modules/{name}} is anonymous/public). Any failure (network hiccup, + * private module without metadata, missing release) is logged and yields {@code null} so the + * caller degrades gracefully to the sibling {@code meta.yml} {@link nextflow.module.ModuleSpec}. + */ + private ModuleMetadata fetchModuleMetadata(RegistryClient client, ModuleReference moduleRef, String version) { + try { + if( version ) + return client.getModuleRelease(moduleRef.fullName, version)?.metadata + return client.getModule(moduleRef.fullName)?.latest?.metadata + } + catch( Exception e ) { + log.warn("Agent `${agentName}` tool `${moduleRef.fullName}`: could not fetch registry metadata (${e.message}) - falling back to the local module spec for the tool descriptor") + return null + } + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ProcessToolSchema.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ProcessToolSchema.groovy new file mode 100644 index 0000000000..31efc1ba4b --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ProcessToolSchema.groovy @@ -0,0 +1,116 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import nextflow.script.ProcessConfigV2 +import nextflow.script.ProcessDef +import nextflow.script.params.v2.ProcessInput +import nextflow.script.params.v2.ProcessOutput +import nextflow.script.params.v2.ProcessTupleInput + +/** + * Derives portable JSON-schema {@link Map}s describing the declared typed inputs + * and outputs of a {@link ProcessDef}, so an in-scope process can be exposed to + * the LLM as a tool. The resulting maps share the shape produced by + * {@link RecordSchema#of} (e.g. + * {@code [type:'object', properties:[name:[type:'string']], required:['name'], additionalProperties:false]}). + * + * For Phase 2 only SCALAR inputs/outputs are supported (String, integer, number, + * boolean); any other declared kind (tuple, path/file, map/meta, record) raises + * a loud {@link IllegalArgumentException} so unsupported processes fail fast + * rather than silently producing a wrong schema. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ProcessToolSchema { + + /** + * Build the JSON-schema map for the process inputs. Each declared input + * contributes a {@code name -> {type: ...}} property; non-optional inputs are + * listed under {@code required}. + */ + static Map inputSchema(ProcessDef proc) { + final config = configOf(proc) + final properties = new LinkedHashMap() + final required = new ArrayList() + + for( final param : config.getInputs().getParams() ) { + if( param instanceof ProcessTupleInput ) + throw unsupported(proc.getName(), 'input', '(tuple)', 'Tuple') + + final name = param.getName() + final type = param.getType() + final fragment = RecordSchema.scalarFragment(type) + if( fragment == null ) + throw unsupported(proc.getName(), 'input', name, type) + + properties.put(name, fragment) + if( !((ProcessInput) param).isOptional() ) + required.add(name) + } + + return ToolSchema.object(properties, required) + } + + /** + * Build the JSON-schema map for the process outputs. For Phase 2 a single + * scalar output is supported; a bare typed value with no declared name is + * exposed under the key {@code result}. + */ + static Map outputSchema(ProcessDef proc) { + final config = configOf(proc) + final properties = new LinkedHashMap() + final required = new ArrayList() + + for( final param : config.getOutputs().getParams() ) { + final name = outputKey(param) + final type = param.getType() + final fragment = RecordSchema.scalarFragment(type) + if( fragment == null ) + throw unsupported(proc.getName(), 'output', name, type) + + properties.put(name, fragment) + required.add(name) + } + + return ToolSchema.object(properties, required) + } + + private static ProcessConfigV2 configOf(ProcessDef proc) { + final config = proc.getProcessConfig() + if( !(config instanceof ProcessConfigV2) ) + throw new IllegalArgumentException("Tool `${proc.getName()}` must be a typed process to be used as an agent tool") + return (ProcessConfigV2) config + } + + /** + * The key used for an output property. A named output (e.g. {@code result: String}) + * uses its declared name; a bare typed value lowers to the synthetic name + * {@code $out}, for which the key {@code result} is used instead. + */ + private static String outputKey(ProcessOutput param) { + final name = param.getName() + return ( name == null || name == '$out' ) ? 'result' : name + } + + private static IllegalArgumentException unsupported(String proc, String kind, String name, Object type) { + final typeName = type instanceof Class ? ((Class) type).getSimpleName() : type + return new IllegalArgumentException("Tool `${proc}` ${kind} `${name}` of type ${typeName} is not yet supported as an agent tool (Phase 3)") + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/RecordSchema.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/RecordSchema.groovy new file mode 100644 index 0000000000..6a02efa2c0 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/RecordSchema.groovy @@ -0,0 +1,129 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.lang.reflect.Field +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.nio.file.Path + +import groovy.transform.CompileStatic +import nextflow.script.dsl.Nullable +import nextflow.script.types.Record + +/** + * Derives a portable JSON-schema {@link Map} by reflecting on an agent output + * record {@link Class}. The resulting map mirrors the subset of JSON Schema used + * as the LLM structured-output contract (see the nf-agent plugin which maps it + * onto langchain4j's {@code JsonSchema}). + * + * A named record type used as an agent output lowers to a concrete runtime class + * whose {@code getDeclaredFields()} returns the declared fields; optional fields + * (declared with the {@code ?} suffix) carry a {@link Nullable} annotation and are + * omitted from the {@code required} list. + * + * Supported field types (v1): String, integer/long, floating point/decimal, + * boolean, nested record types, and List/Collection/Set of those. {@link Path} + * and any other unmapped type are rejected with an {@link IllegalArgumentException}. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class RecordSchema { + + /** + * Reflect the given record type into a JSON-schema map. + * + * @param recordType the compiled record class (e.g. an agent output type) + * @return an ordered map with {@code type}, {@code properties}, {@code required} + * and {@code additionalProperties} entries + */ + static Map of(Class recordType) { + final properties = new LinkedHashMap() + final required = new ArrayList() + + for( final field : recordType.getDeclaredFields() ) { + if( field.isSynthetic() ) + continue + final name = field.getName() + properties.put(name, fragmentFor(name, field.getGenericType())) + if( !field.isAnnotationPresent(Nullable.class) ) + required.add(name) + } + + return ToolSchema.object(properties, required) + } + + /** + * Map a scalar type to its JSON-schema fragment, or {@code null} if the type + * is not a supported scalar (String / integer / number / boolean). Shared + * with {@link ProcessToolSchema} so the scalar mapping stays in one place. + */ + static Map scalarFragment(Class raw) { + if( raw == String ) + return [type: 'string'] + + if( raw in [Integer, int, Long, long, Short, short, Byte, byte, BigInteger] ) + return [type: 'integer'] + + if( raw in [Double, double, Float, float, BigDecimal, Number] ) + return [type: 'number'] + + if( raw in [Boolean, boolean] ) + return [type: 'boolean'] + + return null + } + + private static Map fragmentFor(String fieldName, Type type) { + final raw = rawClass(type) + + final scalar = scalarFragment(raw) + if( scalar != null ) + return scalar + + if( raw == Path || Path.isAssignableFrom(raw) ) + throw new IllegalArgumentException("Unsupported agent output field `${fieldName}` of type ${raw.getName()} - `Path` is not allowed in agent outputs") + + if( Record.isAssignableFrom(raw) ) + return of(raw) + + if( Collection.isAssignableFrom(raw) ) { + final elementType = elementType(type) + return [type: 'array', items: fragmentFor("${fieldName}[]".toString(), elementType)] + } + + throw new IllegalArgumentException("Unsupported agent output field `${fieldName}` of type ${raw.getName()} - supported types are String, integer, number, boolean, nested record and list of those") + } + + private static Class rawClass(Type type) { + if( type instanceof Class ) + return type + if( type instanceof ParameterizedType ) + return (Class) type.getRawType() + return Object + } + + private static Type elementType(Type type) { + if( type instanceof ParameterizedType ) { + final args = type.getActualTypeArguments() + if( args.length > 0 ) + return args[0] + } + return String + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/SandboxGuard.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/SandboxGuard.groovy new file mode 100644 index 0000000000..51880fbf77 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/SandboxGuard.groovy @@ -0,0 +1,91 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.transform.CompileStatic + +/** + * Pure path-containment checks for the agent {@code filesystem} tool. Writes are + * confined to the agent work dir; reads are allowed within the work dir or any + * of the per-invocation readable paths (the task's staged input sources, plus the + * outputs of modules run during this invocation). Resolves symlinks and normalizes + * so that {@code ..} traversal and symlink targets that escape the sandbox are rejected. + * + *

Because symlinks ARE resolved, a file merely LINKED into the work dir is not readable + * by virtue of sitting there — which is why the staged inputs are whitelisted explicitly + * (see {@code AgentDef.createSandboxContext}) rather than left to the work-dir test. + * + *

A readable entry may be a FILE as well as a directory -- containment is a path + * prefix test, so a file entry admits exactly that file. Module outputs are registered + * as files for that reason: whitelisting their parent would admit every sibling too. + * + *

TOCTOU note: path resolution is not atomic. The sandbox guarantee holds only if the + * filesystem beneath the checked paths is not concurrently mutated between this check and + * the subsequent file operation. + */ +@CompileStatic +class SandboxGuard { + + static boolean isAllowed(Path candidate, Path workDir, Collection readablePaths, boolean write) { + if( candidate == null || workDir == null ) + return false + final real = realOf(candidate) + final root = realOf(workDir) + if( isInside(real, root) ) + return true + if( write ) + return false + for( final Path allowed : (readablePaths ?: Collections.emptyList()) ) { + if( allowed == null ) + continue + if( isInside(real, realOf(allowed)) ) + return true + } + return false + } + + /** + * Real path of an existing target, or the normalized absolute path of the + * nearest existing ancestor joined with the remaining (non-existent) tail — + * so a not-yet-created write target is checked against its real parent (which + * defeats symlink escape) rather than its literal lexical path. + */ + private static Path realOf(Path p) { + Path abs = p.toAbsolutePath().normalize() + if( Files.exists(abs) ) + return abs.toRealPath() + // walk up to the nearest existing ancestor, realpath it, re-append the tail + Path existing = abs + final List tail = new ArrayList() + while( existing != null && !Files.exists(existing) ) { + tail.add(0, existing.getFileName().toString()) + existing = existing.getParent() + } + if( existing == null ) + return abs // safe fallback: unresolved normalized abs path won't match any real workDir + Path base = existing.toRealPath() + for( final String seg : tail ) + base = base.resolve(seg) + return base.normalize() + } + + private static boolean isInside(Path child, Path root) { + return child.startsWith(root) + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/SkillDescriptor.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/SkillDescriptor.groovy new file mode 100644 index 0000000000..7811b8041b --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/SkillDescriptor.groovy @@ -0,0 +1,42 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.Canonical +import groovy.transform.CompileStatic + +/** + * Portable, langchain4j-free descriptor of an Anthropic-style agent skill + * (a {@code SKILL.md} folder). Mirrors {@link ToolDescriptor}: core resolves and + * parses the skill (locally or after a remote fetch) into this plain DTO, and the + * {@code nf-agent} plugin maps it onto a langchain4j {@code Skill} — so core stays + * free of any LLM client dependency and the plugin never touches a filesystem path. + * + *

{@code name}/{@code description} come from the {@code SKILL.md} YAML frontmatter + * (the model sees them in the available-skills catalog); {@code content} is the + * markdown body returned by the {@code activate_skill} tool; {@code resources} are + * the bundled files returned by {@code read_skill_resource}. + * + * @author Paolo Di Tommaso + */ +@Canonical +@CompileStatic +class SkillDescriptor { + String name + String description + String content + List resources +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/SkillResolver.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/SkillResolver.groovy new file mode 100644 index 0000000000..45a66f889b --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/SkillResolver.groovy @@ -0,0 +1,434 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.charset.StandardCharsets +import java.nio.file.DirectoryNotEmptyException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.regex.Matcher +import java.util.stream.Stream + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.exception.ScriptRuntimeException +import org.eclipse.jgit.api.Git +import org.yaml.snakeyaml.Yaml + +/** + * Resolves an agent {@code skills} directive entry into one or more portable + * {@link SkillDescriptor}s. An entry is either a local skill (a + * {@code /skills//} directory containing a {@code SKILL.md}) or a + * remote GitHub reference (cloned and cached into that same {@code skills/} + * directory — see {@code loadRemote}). + * + *

All filesystem and SCM work lives here in core; the {@code nf-agent} plugin only + * receives the portable descriptors. {@code SKILL.md} is parsed with a hand-rolled + * YAML-frontmatter split (snakeyaml has no frontmatter support): the leading + * {@code ---}…{@code ---} block is the metadata ({@code name}, {@code description}), + * the remainder is the skill {@code content}. Bundled files (other than {@code SKILL.md}) + * are loaded as resources, skipping {@code .git/} and symlinks, rejecting path escapes, + * and capped for safety. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class SkillResolver { + + static final String SKILL_FILE = 'SKILL.md' + static final String SKILLS_DIR = 'skills' + + /** Reserved sub-directory of the skills root holding the remote-skill clone cache, kept + * separate from the hand-authored skill directories that live beside it (see {@link + * #cacheDirFor(java.nio.file.Path, String, String)}). */ + static final String REMOTE_CACHE_DIR = '.remote' + + /** The {@code ---} delimiter line that fences the YAML frontmatter block. */ + private static final String FENCE = '---' + + private static final int MAX_RESOURCE_FILES = 64 + private static final long MAX_RESOURCE_BYTES = 256 * 1024 + + /** + * Parse {@code SKILL.md} text into {@code [name, description, content]}: normalize the text, + * locate the {@code ---}…{@code ---} frontmatter fences, then extract and validate the fields. + * Tolerates a leading BOM, CRLF line endings and leading blank lines. Throws when the frontmatter + * is missing/unterminated or {@code name}/{@code description}/body are absent. + */ + static Map parseFrontmatter(String raw) { + final List lines = normalizedLines(raw) + final int open = skipBlankLines(lines, 0) + if( open >= lines.size() || lines[open].trim() != FENCE ) + throw new ScriptRuntimeException("Invalid SKILL.md: missing YAML frontmatter (expected a leading '${FENCE}' line)") + final int close = indexOfFence(lines, open + 1) + if( close < 0 ) + throw new ScriptRuntimeException("Invalid SKILL.md: unterminated YAML frontmatter (missing closing '${FENCE}')") + final String yaml = lines.subList(open + 1, close).join('\n') + final String body = joinFrom(lines, close + 1).trim() + return frontmatterFields(yaml, body) + } + + /** Strip a leading BOM, normalize CRLF/CR to LF, and split into lines. */ + private static List normalizedLines(String raw) { + if( raw == null ) + throw new ScriptRuntimeException("Invalid SKILL.md: empty content") + final String text = (raw.startsWith('') ? raw.substring(1) : raw) + .replace('\r\n', '\n').replace('\r', '\n') + return text.split('\n', -1) as List + } + + /** Index of the first non-blank line at or after {@code from}. */ + private static int skipBlankLines(List lines, int from) { + int i = from + while( i < lines.size() && lines[i].trim().isEmpty() ) + i++ + return i + } + + /** Index of the next {@code ---} fence line at or after {@code from}, or {@code -1} if none. */ + private static int indexOfFence(List lines, int from) { + for( int i = from; i < lines.size(); i++ ) + if( lines[i].trim() == FENCE ) + return i + return -1 + } + + /** Join lines from {@code from} to the end with LF, or empty when {@code from} is past the end. */ + private static String joinFrom(List lines, int from) { + return from < lines.size() ? lines.subList(from, lines.size()).join('\n') : '' + } + + /** + * Extract and validate the frontmatter fields. {@code name}/{@code description} come from the YAML + * block; the {@code body} is the skill content. Validating the body here (core, langchain4j-free) + * makes an empty SKILL.md fail with a clear Nextflow error naming the skill, rather than a raw + * langchain4j {@code IllegalArgumentException} later. + */ + private static Map frontmatterFields(String yaml, String body) { + final Object loaded = new Yaml().load(yaml) + final Map data = loaded instanceof Map ? (Map) loaded : [:] + final String name = data.get('name')?.toString()?.trim() + final String description = data.get('description')?.toString()?.trim() + if( !name ) + throw new ScriptRuntimeException("Invalid SKILL.md: missing 'name' in frontmatter") + if( !description ) + throw new ScriptRuntimeException("Invalid SKILL.md: missing 'description' in frontmatter") + if( !body ) + throw new ScriptRuntimeException("Invalid SKILL.md: skill `${name}` has an empty body (no instructions)") + return [name: name, description: description, content: body] + } + + /** + * Parse a single skill directory (one containing a {@code SKILL.md}) into a descriptor. + */ + static SkillDescriptor parseSkillDir(Path skillDir) { + final Path md = skillDir.resolve(SKILL_FILE) + if( !Files.exists(md) ) + throw new ScriptRuntimeException("Skill directory `${skillDir}` has no ${SKILL_FILE}") + final Map meta = parseFrontmatter(new String(Files.readAllBytes(md), StandardCharsets.UTF_8)) + final List resources = loadResources(skillDir) + return new SkillDescriptor(meta.name as String, meta.description as String, meta.content as String, resources) + } + + /** + * Load the bundled resource files under a skill directory: every regular, non-symlink + * file other than the top-level {@code SKILL.md}, excluding anything under a {@code .git} + * directory, rejecting paths that escape the skill dir, capped at {@value #MAX_RESOURCE_FILES} + * files / {@value #MAX_RESOURCE_BYTES} bytes total. + */ + static List loadResources(Path skillDir) { + return readUnderCaps(skillDir, eligibleResourceFiles(skillDir)) + } + + /** + * The bundled files eligible to be skill resources, in deterministic (lexicographic-by-relative-path) + * order so the same skill yields the same resource set on every host. Excludes the top-level + * {@code SKILL.md}, symlinks, anything under {@code .git/}, and any path escaping the skill dir. + */ + private static List eligibleResourceFiles(Path skillDir) { + final Path root = skillDir.toRealPath() + final List files = new ArrayList<>() + final Stream walk = Files.walk(skillDir) + try { + final Iterator it = walk.iterator() + while( it.hasNext() ) { + final Path p = it.next() + if( isEligibleResource(p, skillDir, root) ) + files.add(p) + } + } + finally { + walk.close() + } + files.sort(byRelativePath(skillDir)) + return files + } + + private static boolean isEligibleResource(Path p, Path skillDir, Path root) { + if( !Files.isRegularFile(p) || Files.isSymbolicLink(p) ) + return false + if( p.fileName?.toString() == SKILL_FILE && skillDir == p.parent ) + return false + if( hasGitSegment(skillDir.relativize(p)) ) + return false + return p.toRealPath().startsWith(root) + } + + private static Comparator byRelativePath(Path skillDir) { + return new Comparator() { + int compare(Path a, Path b) { + return skillDir.relativize(a).toString() <=> skillDir.relativize(b).toString() + } + } + } + + /** + * Read the given files into resources, enforcing the file-count and total-byte caps. Each file's + * size is stat'd BEFORE reading so an oversized file is never loaded into memory (DoS-safe); an + * over-budget file is skipped (not a hard stop) so smaller later files are still included. + */ + private static List readUnderCaps(Path skillDir, List files) { + final List result = new ArrayList<>() + long totalBytes = 0 + for( final Path p : files ) { + if( result.size() >= MAX_RESOURCE_FILES ) { + log.warn("Skill `${skillDir.fileName}`: more than ${MAX_RESOURCE_FILES} resource files - ignoring the rest") + break + } + final long size = Files.size(p) + if( totalBytes + size > MAX_RESOURCE_BYTES ) { + log.warn("Skill `${skillDir.fileName}`: skipping resource `${skillDir.relativize(p)}` - would exceed the ${MAX_RESOURCE_BYTES}-byte resource budget") + continue + } + totalBytes += size + result.add(new SkillResource(skillDir.relativize(p).toString(), new String(Files.readAllBytes(p), StandardCharsets.UTF_8))) + } + return result + } + + private static boolean hasGitSegment(Path relative) { + for( final Path seg : relative ) { + if( seg.toString() == '.git' ) + return true + } + return false + } + + /** + * Resolve a local skill by name under the given skills-root directory ({@code //}). + * That directory itself may be a single skill (has a {@code SKILL.md}) or hold multiple skills in + * subdirectories. + */ + static List loadLocal(Path skillsRoot, String name) { + final Path dir = skillsRoot.resolve(name) + if( !Files.isDirectory(dir) ) + throw new ScriptRuntimeException("Agent skill `${name}` not found: no directory `${dir}`") + return scanSkillRoot(dir, name) + } + + /** + * Treat {@code dir} as a single skill when it has a {@code SKILL.md}, otherwise scan its + * immediate subdirectories for skills. Throws when no {@code SKILL.md} is found anywhere. + */ + static List scanSkillRoot(Path dir, String label) { + if( Files.exists(dir.resolve(SKILL_FILE)) ) + return [ parseSkillDir(dir) ] + final List result = new ArrayList<>() + final Stream kids = Files.list(dir) + try { + final Iterator it = kids.iterator() + while( it.hasNext() ) { + final Path sub = it.next() + if( Files.isDirectory(sub) && Files.exists(sub.resolve(SKILL_FILE)) ) + result.add(parseSkillDir(sub)) + } + } + finally { + kids.close() + } + if( result.isEmpty() ) + throw new ScriptRuntimeException("Agent skill `${label}` has no ${SKILL_FILE} (looked in `${dir}` and its subdirectories)") + return result + } + + // -- remote (GitHub) resolution -- + + private static final java.util.regex.Pattern REMOTE_REF = + ~/^(https:\/\/github\.com\/|git@github\.com:|github\.com\/)([^\/@:]+)\/([^\/@:]+?)(?:@(.+))?$/ + + /** + * Whether a {@code skills} entry is a remote GitHub reference. Only explicit GitHub + * forms qualify ({@code https://github.com//}, {@code git@github.com:/}, + * {@code github.com//}, each optionally {@code @rev}); a bare {@code /} + * is NOT remote (that shape is reserved for registry-style module refs) — anything that is + * not remote is treated as a local skill name. + */ + static boolean isRemoteRef(String ref) { + if( !ref ) + return false + return REMOTE_REF.matcher(ref).matches() + } + + /** Parse a remote GitHub ref into {@code [url, repo, rev]} (rev may be null). */ + static Map parseRemoteRef(String ref) { + final Matcher m = REMOTE_REF.matcher(ref) + if( !m.matches() ) + throw new ScriptRuntimeException("Invalid remote skill reference `${ref}` - expected github.com//[@rev]") + final String prefix = m.group(1) + final String org = m.group(2) + String repo = m.group(3) + if( repo.endsWith('.git') ) + repo = repo.substring(0, repo.length() - 4) + final String rev = m.group(4) + final String url = prefix == 'git@github.com:' + ? "git@github.com:${org}/${repo}.git".toString() + : "https://github.com/${org}/${repo}.git".toString() + return [url: url, repo: repo, rev: rev] + } + + /** + * Resolve a remote GitHub skill reference: clone (and cache) the repo into the given + * skills-root directory, then load the skill(s) it contains. + */ + static List loadRemote(Path skillsRoot, String ref) { + final Map parsed = parseRemoteRef(ref) + if( !parsed.rev ) + log.warn("Agent skill `${ref}` is not pinned to a commit - its instructions may change between runs; pin a commit SHA (e.g. `${ref}@`) for reproducibility") + return loadRemoteUrl(skillsRoot, parsed.url as String, parsed.repo as String, parsed.rev as String) + } + + /** + * Low-level remote fetch: clone {@code cloneUrl} (checking out {@code rev} when given) into a + * rev-keyed cache dir under {@code skillsRoot}, reusing it when present, then scan it for skills. + * The cache dir is keyed by repo name and {@code @rev} so a pinned revision is never silently + * served from a cache populated for a different revision. + */ + static List loadRemoteUrl(Path skillsRoot, String cloneUrl, String repoName, String rev) { + final Path cacheDir = cacheDirFor(skillsRoot, repoName, rev) + if( !Files.isDirectory(cacheDir) ) + cloneInto(cloneUrl, rev, cacheDir) + return scanSkillRoot(cacheDir, repoName) + } + + /** + * The rev-keyed cache dir under {@code skillsRoot}, so a pinned revision is never served from a + * cache populated for a different one. The rev is sanitized for the on-disk NAME only (it may + * contain {@code /} or {@code ..}, e.g. {@code feature/x}, {@code refs/tags/v1}) — keeping the + * cache a single flat segment and preventing path traversal out of {@code skillsRoot}; the real + * rev still drives the git checkout. + * + *

Clones land in the reserved {@value #REMOTE_CACHE_DIR} sub-directory, NOT directly under + * {@code skillsRoot}: for a module agent {@code skillsRoot} is {@code /skills}, which + * is also where the module author keeps hand-written skills, and the cache is reused whenever the + * directory merely exists. Without the reserved segment a local {@code skills//} would + * silently satisfy the cache check, so a declared remote skill would never be fetched and the + * local content would be served (and fingerprinted) in its place. Nothing resolves a skill under + * the reserved segment: {@code skillsRoot} itself is never scanned (every entry is looked up by + * name), so reaching it would require a {@code skills} entry spelled {@code .remote}. + */ + private static Path cacheDirFor(Path skillsRoot, String repoName, String rev) { + final String safeRev = rev ? rev.replaceAll(/[^A-Za-z0-9._-]/, '_') : null + final String cacheName = safeRev ? "${repoName}@${safeRev}".toString() : repoName + final Path remoteRoot = skillsRoot.resolve(REMOTE_CACHE_DIR).normalize() + final Path cacheDir = remoteRoot.resolve(cacheName).normalize() + // the cache dir must be a single flat segment DIRECTLY under the reserved dir. Asserting the + // parent (rather than merely containment in skillsRoot) is what rejects a traversing repo + // name: `repoName` is unsanitized and the ref regex accepts `..`, so `.remote/..` normalizes + // back to skillsRoot itself -- which is contained in skillsRoot, would be seen as an already + // populated cache, and would serve every hand-authored module skill as the remote's content. + if( cacheDir.parent != remoteRoot ) + throw new ScriptRuntimeException("Invalid skills cache path `${cacheDir}` for skill repo `${repoName}`") + return cacheDir + } + + /** + * Clone into a sibling temp dir then atomically rename into {@code cacheDir}, so an + * interrupted/failed clone never leaves a half-populated cache. A shallow clone is used only + * for a moving (default-branch) remote ref; a pinned {@code rev} or a local {@code file://} + * source is full-cloned so the requested commit is reachable. + */ + private static void cloneInto(String cloneUrl, String rev, Path cacheDir) { + Files.createDirectories(cacheDir.parent) + final Path tmp = Files.createTempDirectory(cacheDir.parent, '.skill-clone-') + Files.delete(tmp) // JGit creates the directory itself + try { + cloneAndCheckout(cloneUrl, rev, tmp) + publishClone(tmp, cacheDir) + } + catch( Exception e ) { + deleteQuietly(tmp) + throw new ScriptRuntimeException("Unable to fetch remote skill from `${redactUrl(cloneUrl)}`${rev ? " (@${rev})" : ''}: ${e.message}", e) + } + } + + /** + * Clone {@code cloneUrl} into {@code target} and check out {@code rev} when given. A shallow clone + * is used only for a moving (default-branch) remote ref; a pinned {@code rev} or a {@code file://} + * source is full-cloned so the requested commit is reachable. + */ + private static void cloneAndCheckout(String cloneUrl, String rev, Path target) { + final clone = Git.cloneRepository().setURI(cloneUrl).setDirectory(target.toFile()) + if( !rev && !cloneUrl.startsWith('file:') ) + clone.setDepth(1) + final Git git = clone.call() + try { + if( rev ) + checkoutRev(git, rev) + } + finally { + git.close() + } + } + + /** + * Check out a rev so a SHA, a tag, OR a branch all work: a fresh clone exposes branches only as + * remote-tracking refs ({@code origin/}), so a bare {@code checkout(branch)} would fail — + * resolve the rev to a concrete {@code ObjectId} and check that out. + */ + private static void checkoutRev(Git git, String rev) { + org.eclipse.jgit.lib.ObjectId id = git.repository.resolve(rev) + if( id == null ) + id = git.repository.resolve("origin/${rev}".toString()) + if( id == null ) + throw new ScriptRuntimeException("Revision `${rev}` not found in the remote repository") + git.checkout().setName(id.name()).call() + } + + /** + * Atomically rename the temp clone into the cache dir. If a concurrent run populated the cache + * first, discard our clone and use theirs (the atomic move guarantees no half-populated cache). + */ + private static void publishClone(Path tmp, Path cacheDir) { + try { + Files.move(tmp, cacheDir, StandardCopyOption.ATOMIC_MOVE) + } + catch( FileAlreadyExistsException | DirectoryNotEmptyException raced ) { + deleteQuietly(tmp) + } + } + + private static void deleteQuietly(Path dir) { + try { dir.toFile().deleteDir() } catch( Exception ignore ) {} + } + + /** Redact any {@code user:token@} userinfo from a URL before it appears in an error/log message. */ + private static String redactUrl(String url) { + return url?.replaceAll('//[^@/]+@', '//***@') + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/SkillResource.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/SkillResource.groovy new file mode 100644 index 0000000000..aac98c769d --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/SkillResource.groovy @@ -0,0 +1,37 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.Canonical +import groovy.transform.CompileStatic + +/** + * Portable, langchain4j-free descriptor of a single file bundled with an agent + * {@link SkillDescriptor skill} (e.g. a file under the skill's {@code references/} + * directory). The content is loaded eagerly by core so the {@code nf-agent} plugin + * can map it onto a langchain4j {@code SkillResource} without touching the + * filesystem (the LLM reads it on demand via the {@code read_skill_resource} tool). + * + * @author Paolo Di Tommaso + */ +@Canonical +@CompileStatic +class SkillResource { + /** Path of the resource relative to the skill directory (e.g. {@code references/guide.md}). */ + String relativePath + /** The resource content, loaded eagerly by core. */ + String content +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ToolDescriptor.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ToolDescriptor.groovy new file mode 100644 index 0000000000..0ff3ef1e86 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ToolDescriptor.groovy @@ -0,0 +1,37 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.Canonical +import groovy.transform.CompileStatic + +/** + * Portable, langchain4j-free descriptor of a module/process exposed to the LLM + * as a tool. The input and output schemas are plain JSON-schema {@link Map}s + * (same shape produced by {@link RecordSchema} and {@link ProcessToolSchema}) so + * that core stays free of any LLM client dependency; the nf-agent plugin maps + * them onto langchain4j's {@code ToolSpecification}. + * + * @author Paolo Di Tommaso + */ +@Canonical +@CompileStatic +class ToolDescriptor { + String name + String description + Map inputSchema + Map outputSchema +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ToolDispatcher.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ToolDispatcher.groovy new file mode 100644 index 0000000000..5e8f379563 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ToolDispatcher.groovy @@ -0,0 +1,33 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic + +/** + * Callback the nf-agent plugin invokes to execute a tool: given the tool name + * and the LLM-supplied arguments as a JSON string, it runs the corresponding + * module/process and returns the tool result as a JSON string. + * + * Being a single-abstract-method interface, it can be supplied from Groovy as a + * closure coerced {@code as ToolDispatcher}. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +interface ToolDispatcher { + String call(String toolName, String argsJson) +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ToolOutputReader.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ToolOutputReader.groovy new file mode 100644 index 0000000000..481d150f9d --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ToolOutputReader.groovy @@ -0,0 +1,126 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path + +import groovy.transform.CompileStatic +import nextflow.extension.FilesEx +import nextflow.util.MemoryUnit + +/** + * Decides how a module tool's file output is serialized back to the LLM. + * + *

By default a file output is returned as an opaque, scheme-preserving absolute path + * string (the + * "opaque-path contract"): the LLM gets a handle it can chain to the next tool but + * never sees file CONTENTS. That is correct for bulk/binary data (FASTA contigs, + * BAMs) the LLM only chains between tools. + * + *

Some tools, however, emit small, structured outputs the LLM must actually + * REASON OVER (e.g. assembly statistics as a small {@code .json} with N50 / #contigs). + * For those, the file CONTENTS are inlined so the LLM can read the numbers. The + * decision is made per file output at serialization time: + * + *

+ * ext = lowercased file extension (after the last '.' in the file name, '' if none)
+ * if ext not in TEXT_EXTENSIONS         -> the absolute path/URI String   (data/binary, chainable)
+ * else if size(file) > maxBytes         -> [path: <abs>, note: "content not inlined: ..."]
+ * else if looksBinary(file)             -> the absolute path/URI String   (safety net)
+ * else                                  -> the file UTF-8 content as a String   (the LLM reads it)
+ * 
+ * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ToolOutputReader { + + /** + * File extensions whose (small) content is inlined for the LLM to reason over. + * Everything else stays an opaque path handle. + */ + static final Set TEXT_EXTENSIONS = (['json','tsv','csv','txt','tab','yaml','yml','log','md'] as Set).asImmutable() + + /** Number of leading bytes inspected by the binary-content sniff. */ + private static final int SNIFF_BYTES = 8192 + + /** Default inline size cap (32 KB) applied when {@code agent.maxToolOutputInlineSize} is not set. */ + static final long DEFAULT_INLINE_BYTES = 32 * 1024L + + /** + * Decide whether to inline the file's content or return it as an opaque path handle. + * + * @param file the tool-output file + * @param maxBytes the maximum size (in bytes) of a structured file whose content is inlined + * @return the file UTF-8 content as a String (inlined), the absolute path/URI String (opaque + * handle), or a {@code [path: ..., note: ...]} Map when an inline candidate is too large + */ + static Object readOrHandle(Path file, long maxBytes) { + final ext = extensionOf(file) + // unknown / non text-like format -> opaque path handle (data/binary, chainable) + if( !TEXT_EXTENSIONS.contains(ext) ) + return pathString(file) + // an inline candidate that is too large -> a path handle annotated with the reason + final size = Files.size(file) + if( size > maxBytes ) + return [path: pathString(file), note: "content not inlined: ${new MemoryUnit(size).toString()} exceeds ${new MemoryUnit(maxBytes).toString()} limit".toString()] + // safety net: a text-like extension that nonetheless carries binary content -> path handle + if( looksBinary(file) ) + return pathString(file) + // small structured text -> inline the content so the LLM can reason over it + return new String(Files.readAllBytes(file), StandardCharsets.UTF_8) + } + + /** + * The lowercased file extension: the part after the LAST '.' in the FILE NAME only. + * Returns the empty string when the file name has no '.' (e.g. {@code README}, or + * {@code a.b/c} whose last segment {@code c} has no dot). + */ + static String extensionOf(Path file) { + final name = file.getFileName()?.toString() ?: '' + final dot = name.lastIndexOf('.') + if( dot < 0 ) + return '' + return name.substring(dot + 1).toLowerCase() + } + + /** + * Whether the file looks binary: read up to the first {@value #SNIFF_BYTES} bytes and + * return {@code true} if any byte is a NUL (0x00). + */ + static boolean looksBinary(Path file) { + InputStream inStream = null + try { + inStream = Files.newInputStream(file) + final buf = new byte[SNIFF_BYTES] + final n = inStream.read(buf) + for( int i=0; iThis type is purely syntactic. It knows nothing about which families exist or + * which tools they contain — that is {@link ToolRefResolver}'s job — so a ref that parses + * here may still be rejected later as unknown or as matching nothing. Keeping the two apart + * is what lets the grammar be unit-tested without a session, and what makes the five + * zero-match failures distinguishable in the error message the user sees. + * + *

The rules enforced here, in the order they are checked: + *

    + *
  • G6 — a leading {@code !} (negation) is rejected outright: there is no exclude + * operator, the safety boundary sits on the {@code shell:} family line instead;
  • + *
  • G5 — a glob must be anchored to a family, so a bare {@code *} is rejected;
  • + *
  • G1/G2 — at least two colon-separated segments, none of them empty. The value is + * taken verbatim: it is never trimmed, so {@code 'fs:read '} is an error rather + * than a silently repaired {@code fs:read};
  • + *
  • G4 — {@code *} may appear only in the last segment; the family and every + * intermediate segment must name a node exactly.
  • + *
+ * + *

Matching is case-sensitive in every segment (see {@link #matches}), so + * {@code nf:module_run:samtools_*} does not select a process named {@code SAMTOOLS_SORT}. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +@EqualsAndHashCode(includes = 'ref', includeFields = true) +class ToolRef { + + /** The one wildcard character the grammar defines; legal in the last segment only (G4). */ + static final String GLOB = '*' + + /** + * Legal characters of a non-terminal segment. Deliberately narrower than a Nextflow + * identifier: the terminal segment becomes a wire name, which OpenAI restricts to + * {@code [a-zA-Z0-9_-]} (§4), and there is no reason for the declaration namespace to + * admit characters the wire namespace cannot carry. + */ + private static final Pattern PLAIN_SEGMENT = ~/[A-Za-z0-9_-]+/ + + /** Same, plus the wildcard — the terminal segment only. */ + private static final Pattern GLOB_SEGMENT = ~/[A-Za-z0-9_*-]+/ + + private final String ref + + private final List segments + + private ToolRef(String ref, List segments) { + this.ref = ref + this.segments = segments + } + + /** The entry exactly as declared. */ + String getRef() { ref } + + /** The colon-separated segments; always at least two, none empty. */ + List getSegments() { segments } + + /** The family this ref is anchored to, i.e. the first segment. */ + String getFamily() { segments[0] } + + /** The terminal segment — the only one that may carry a glob. */ + private String getLeafPattern() { segments[segments.size() - 1] } + + /** Whether the terminal segment is a pattern rather than an exact name. */ + boolean isGlobbed() { leafPattern.contains(GLOB) } + + @Override + String toString() { ref } + + /** + * Parse and validate a declared entry. The argument is the resolved value of the + * directive entry ({@code entry?.toString()}), not the source literal, so an interpolated + * entry is validated per invocation (G1). + * + * @throws ScriptRuntimeException when the value is not a well-formed ref + */ + static ToolRef parse(String value) { + if( !value ) + throw invalid(value, 'a tool reference cannot be empty') + // -- G6: there is no exclude operator. Caught before the shape checks so the user is + // told the operator does not exist rather than that `!fs` is an odd family name. + if( value.startsWith('!') ) + throw invalid(value, 'exclusions are not supported - drop the leading `!` and declare only the tools the agent may use') + + final String[] parts = value.split(':', -1) + if( parts.length < 2 ) { + // an unanchored glob is its own failure (G5): with no family it means "every tool + // that exists", including ones a later release adds + if( value.contains(GLOB) ) + throw invalid(value, 'a glob must be anchored to a tool family, e.g. `fs:*` or `nf:module_run:*`') + throw invalid(value, 'a tool reference must be namespaced as `family[:group]:name`, e.g. `nf:module_run:MY_PROCESS`, `fs:*` or `shell:bash` - to expose a module, `include` it and name its process') + } + + for( int i = 0; i < parts.length; i++ ) { + final seg = parts[i] + final last = i == parts.length - 1 + if( !seg ) + throw invalid(value, "segment ${i + 1} is empty - segments are separated by a single colon" as String) + // -- G4: the family and every intermediate segment must name a node exactly, so the + // ref always states which family (and which group within it) it selects from + if( !last && seg.contains(GLOB) ) + throw invalid(value, "`${seg}` cannot contain a glob - only the last segment may" as String) + final ok = last + ? GLOB_SEGMENT.matcher(seg).matches() + : PLAIN_SEGMENT.matcher(seg).matches() + if( !ok ) + throw invalid(value, "`${seg}` is not a legal segment - only letters, digits, `_` and `-` are allowed (plus `*` in the last segment), and an entry is never trimmed" as String) + } + + return new ToolRef(value, List.copyOf(Arrays.asList(parts))) + } + + private static ScriptRuntimeException invalid(String value, String reason) { + return new ScriptRuntimeException("Invalid tool reference `${value}` - ${reason}") + } + + /** + * Match one segment pattern against one node name, case-sensitively. A pattern without a + * glob must be equal to the name; a pattern with one or more globs matches any run of + * characters in their place (so {@code RE*AD} is legal in shape and simply matches nothing). + */ + static boolean matches(String pattern, String name) { + if( !pattern.contains(GLOB) ) + return pattern == name + return toPattern(pattern).matcher(name).matches() + } + + private static Pattern toPattern(String glob) { + final sb = new StringBuilder() + final String[] parts = glob.split(Pattern.quote(GLOB), -1) + for( int i = 0; i < parts.length; i++ ) { + if( i > 0 ) + sb.append('.*') + if( parts[i] ) + sb.append(Pattern.quote(parts[i])) + } + return Pattern.compile(sb.toString()) + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ToolRefResolver.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ToolRefResolver.groovy new file mode 100644 index 0000000000..d2e51af5fd --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ToolRefResolver.groovy @@ -0,0 +1,365 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode +import nextflow.exception.ScriptRuntimeException + +/** + * Expands the declared {@code tools} entries of an agent into the concrete set of tools the + * agent may use. + * + *

The resolver is a pure function of (declared refs, available members): the members + * of every family are supplied by the caller — the in-scope process names for + * {@code nf:module_run}, the release-fixed leaf lists for {@code fs:} and {@code shell:} — so + * the whole grammar can be exercised without a {@link nextflow.Session}, a script, or a runner. + * A runner constraint (the {@code shell:} family needs a container boundary) likewise enters as + * a reason string rather than as a runner name compared inside here. + * + *

Semantics, following the grammar: + *

    + *
  • G3 — a ref names a node in a hierarchy: a leaf denotes one tool, a non-leaf its + * entire subtree, so {@code nf:module_run} is exactly {@code nf:module_run:*};
  • + *
  • G8 — selecting nothing is always an error, in five distinguishable flavours: + * malformed (raised by {@link ToolRef}), unknown family, missing explicit leaf, glob + * matching nothing, and non-leaf over an empty subtree. A directive is a declaration, + * not a filter;
  • + *
  • G9 — the result is an order-independent union. Overlapping refs are legal and + * idempotent, and the resolved list is emitted in inventory order (families in + * declaration-of-inventory order, members in the order the caller supplied them), never + * in the order the entries happen to appear in the directive.
  • + *
+ * + *

The resolved tools carry the split the runner mapping needs: {@link ToolKind#BROKERED} + * tools ({@code nf:module_run:X}) are executed by the driver as real Nextflow tasks and become + * tool descriptors, whereas {@link ToolKind#NATIVE} tools ({@code fs:}, {@code shell:}) are + * served by the runner itself and travel to it as bare names. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ToolRefResolver { + + // -- the standard inventory (§3). The names are the declaration-side ones; a leaf name is + // also its wire name (§4), which is why they are the bare Pi-baseline names. + + static final String NF_FAMILY = 'nf' + + static final String MODULE_RUN = 'module_run' + + static final String FS_FAMILY = 'fs' + + static final String SHELL_FAMILY = 'shell' + + /** The `fs:` leaves, in canonical order. Note there is no `exists`: `ls`/`read` cover it. */ + static final List FS_TOOLS = List.of('read', 'write', 'edit', 'ls', 'grep', 'find') + + /** The `shell:` leaves. Its own family because it escapes the filesystem sandbox. */ + static final List SHELL_TOOLS = List.of('bash') + + /** Who executes a resolved tool. */ + static enum ToolKind { + /** Executed by the driver as a Nextflow task, wherever the agent runs. */ + BROKERED, + /** Served by the runner's own tool of that name. */ + NATIVE + } + + /** + * A node of the tool hierarchy. A node with {@code null} members is a leaf, i.e. one tool; + * a node with members is a group denoting its subtree. An empty member list is a + * group with nothing in it — legal to model (a script with no processes in scope) and a + * G8(e) error to select. + */ + @CompileStatic + static class ToolNode { + final String name + final List members + + private ToolNode(String name, List members) { + this.name = name + this.members = members + } + + static ToolNode leaf(String name) { new ToolNode(name, null) } + + static ToolNode group(String name, List members) { new ToolNode(name, members) } + + static List leaves(Collection names) { + return names.collect { leaf(it) } + } + + boolean isLeaf() { members == null } + } + + /** A family: the root of one namespace, everything under it executed the same way. */ + @CompileStatic + static class ToolFamily { + final String name + final ToolKind kind + final List members + /** Why this family cannot be served here; {@code null} when it can. */ + final String unavailable + /** Extra guidance appended to the G8(e) error when the family resolves to nothing. */ + final String emptyHint + + ToolFamily(String name, ToolKind kind, List members, String unavailable = null, String emptyHint = null) { + this.name = name + this.kind = kind + this.members = members + this.unavailable = unavailable + this.emptyHint = emptyHint + } + } + + /** One tool selected by the directive. */ + @CompileStatic + @EqualsAndHashCode + static class ResolvedTool { + /** The canonical fully-qualified ref, e.g. {@code nf:module_run:GREET} or {@code fs:read}. */ + final String ref + /** The wire name the model sees — the leaf name, never colon-bearing (§4). */ + final String name + final ToolKind kind + + ResolvedTool(String ref, String name, ToolKind kind) { + this.ref = ref + this.name = name + this.kind = kind + } + + @Override + String toString() { "${ref} -> ${name} (${kind})" } + } + + /** + * The resolved selection: every tool the directive selected, in inventory order, split by + * who executes it. The two views are what the phases downstream consume — the brokered + * names are wired into the tool bridge as descriptors, the native ones travel to the runner + * beside them as bare names and never enter {@code toolSpecs}. + */ + @CompileStatic + static class Selection { + private final List tools + + Selection(List tools) { + this.tools = List.copyOf(tools) + } + + List getTools() { tools } + + boolean isEmpty() { tools.isEmpty() } + + private List getBrokered() { tools.findAll { it.kind == ToolKind.BROKERED } } + + /** Named {@code runnerNative} because {@code native} is a reserved word. */ + List getRunnerNative() { tools.findAll { it.kind == ToolKind.NATIVE } } + + /** The in-scope process names selected through {@code nf:module_run}. */ + List getBrokeredNames() { getBrokered().collect { it.name } } + + /** The wire names of the runner-native tools, e.g. {@code [read, write, bash]}. */ + List getNativeNames() { getRunnerNative().collect { it.name } } + + /** + * The canonical refs of the runner-native tools, e.g. {@code [fs:read, shell:bash]}. + * These are what the resume key folds in (a native tool has no descriptor to hash). + */ + List getNativeRefs() { getRunnerNative().collect { it.ref } } + + @Override + String toString() { tools.toString() } + } + + /** Per-ref bookkeeping, so a ref that selects nothing can say WHY it selected nothing. */ + private static class MatchStats { + /** leaves contributed to the selection */ + int leaves + /** + * Whether the ref reached anything at all: a target node it matched, or a group along the + * way that turned out to be empty. Both mean "the ref names something real that holds no + * tools" — G8(e) — as opposed to naming nothing, so one flag serves where the two were + * only ever read together. + */ + boolean reachedNode + } + + /** Family name -> family, iteration order = the order tools are emitted in. */ + private final Map families + + /** Prefixed to every error so the message names the agent; may be {@code null}. */ + private final String subject + + ToolRefResolver(String subject, Map families) { + this.subject = subject + this.families = families + } + + /** + * Build a resolver over the standard inventory: {@code nf:module_run} over the in-scope + * processes, plus the release-fixed {@code fs:} and {@code shell:} leaves. + * + * @param subject label prefixed to every error, e.g. {@code Agent `qc`} + * @param processNames the in-scope process names, i.e. the members of {@code nf:module_run} + * @param shellUnavailable when non-{@code null}, the reason the {@code shell:} family cannot + * be served by the selected runner; declaring any {@code shell:} ref + * then fails with it. The runner is never named in here. + */ + static ToolRefResolver standard(String subject, Collection processNames, String shellUnavailable = null) { + final families = new LinkedHashMap() + families.put(NF_FAMILY, new ToolFamily( + NF_FAMILY, + ToolKind.BROKERED, + List.of(ToolNode.group(MODULE_RUN, ToolNode.leaves(processNames ?: List. of()))), + null, + 'declare or `include` the processes the agent may run')) + families.put(FS_FAMILY, new ToolFamily(FS_FAMILY, ToolKind.NATIVE, ToolNode.leaves(FS_TOOLS))) + families.put(SHELL_FAMILY, new ToolFamily(SHELL_FAMILY, ToolKind.NATIVE, ToolNode.leaves(SHELL_TOOLS), shellUnavailable)) + return new ToolRefResolver(subject, families) + } + + /** + * Expand the declared entries into the resolved selection. + * + * @param declared the raw directive entries; each is resolved with {@code toString()} so an + * interpolated entry is validated by its value (G1) + * @return the resolved selection, empty only when nothing was declared (G7) + * @throws ScriptRuntimeException on any malformed or zero-match entry (G8) + */ + Selection resolve(Collection declared) { + // the canonical refs selected so far; a Set is what makes an overlapping ref idempotent + // and the union order-independent (G9) + final Set selected = new HashSet() + if( declared ) { + for( final entry : declared ) + select(entry?.toString(), selected) + } + // emit in INVENTORY order, not declaration order: the resolved set is a property of what + // was selected, never of how it was written + final List result = new ArrayList() + for( final family : families.values() ) + emit(family.members, family.name, family.kind, selected, result) + return new Selection(result) + } + + /** Resolve one declared entry into {@code selected}, or throw explaining why it matched nothing. */ + private void select(String value, Set selected) { + ToolRef ref + try { + ref = ToolRef.parse(value) + } + catch( ScriptRuntimeException e ) { + // G8(a) - re-thrown so the message names the agent as well as the ref + throw fail(e.message) + } + final family = families.get(ref.family) + // G8(b) + if( family == null ) + throw fail("Tool `${ref}` belongs to the unknown family `${ref.family}` - known families are ${quoted(families.keySet())}") + if( family.unavailable ) + throw fail("Tool `${ref}` is not available - ${family.unavailable}") + + final stats = new MatchStats() + descend(family.members, ref, 1, family.name, selected, stats) + if( stats.leaves > 0 ) + return + // G8(c)/(d)/(e) - three distinct failures, told apart by how far the walk got. + // An empty group anywhere along the way is reported as (e) whether the ref named it + // directly or globbed below it, so `nf:module_run` and `nf:module_run:*` -- the same + // selection by G3 -- also fail the same way. + if( stats.reachedNode ) + throw fail("Tool `${ref}` selects nothing - it has no members${family.emptyHint ? '; ' + family.emptyHint : ''}") + if( ref.globbed ) + throw fail("Tool pattern `${ref}` matches no tool - available: ${quoted(inventory(family))}; matching is case-sensitive") + throw fail("Tool `${ref}` does not exist - available: ${quoted(inventory(family))}") + } + + /** + * Walk one level of the hierarchy matching {@code segments[idx]}. On reaching the terminal + * segment the matched node is the target: a leaf contributes itself, a group its entire + * subtree (G3). A ref deeper than the tree simply matches nothing. + */ + private void descend(List members, ToolRef ref, int idx, String prefix, Set selected, MatchStats stats) { + final segments = ref.segments + final pattern = segments[idx] + final terminal = idx == segments.size() - 1 + for( final node : members ) { + if( !ToolRef.matches(pattern, node.name) ) + continue + final path = "${prefix}:${node.name}".toString() + if( terminal ) { + stats.reachedNode = true + collect(node, path, selected, stats) + } + else if( !node.isLeaf() ) { + if( node.members.isEmpty() ) + stats.reachedNode = true + descend(node.members, ref, idx + 1, path, selected, stats) + } + } + } + + /** Add every leaf of the target node's subtree; the node itself when it is a leaf. */ + private void collect(ToolNode node, String path, Set selected, MatchStats stats) { + if( node.isLeaf() ) { + selected.add(path) + stats.leaves++ + return + } + for( final child : node.members ) + collect(child, "${path}:${child.name}".toString(), selected, stats) + } + + /** Emit the selected leaves of a subtree, preserving the order the members were declared in. */ + private static void emit(List members, String prefix, ToolKind kind, Set selected, List out) { + for( final node : members ) { + final path = "${prefix}:${node.name}".toString() + if( node.isLeaf() ) { + if( selected.contains(path) ) + out.add(new ResolvedTool(path, node.name, kind)) + } + else { + emit(node.members, path, kind, selected, out) + } + } + } + + /** Every selectable ref of a family, for the "available:" part of a zero-match error. */ + private static List inventory(ToolFamily family) { + final out = new LinkedHashSet() + collectAll(family.members, family.name, out) + return new ArrayList(out) + } + + private static void collectAll(List members, String prefix, Set out) { + for( final node : members ) { + final path = "${prefix}:${node.name}".toString() + if( node.isLeaf() ) + out.add(path) + else + collectAll(node.members, path, out) + } + } + + private static String quoted(Collection values) { + return values ? values.collect { "`${it}`" }.join(', ') : '(none)' + } + + private ScriptRuntimeException fail(String message) { + return new ScriptRuntimeException(subject ? "${subject}: ${message}".toString() : message) + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/ToolSchema.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/ToolSchema.groovy new file mode 100644 index 0000000000..6c55508406 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/ToolSchema.groovy @@ -0,0 +1,128 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic + +/** + * The pieces of tool-schema construction that every schema source shares: the JSON-schema + * object envelope, the lenient type predicates, and the prose renderers that turn a declared + * type into the words the model reads. + * + *

The sources themselves stay separate — {@link RecordSchema} reflects a record class, + * {@link ProcessToolSchema} reads a typed process, {@link ModuleSpecToolSchema} reads a + * sibling {@code meta.yml} and {@link ModuleMetadataToolSchema} reads the registry metadata — + * because their input models and their type ladders genuinely differ. What they must NOT do is + * spell the shared parts differently: every byte below is hashed into a tool's + * {@code ToolDescriptor}, and from there into {@code AgentDef.toolsFingerprint}, the agent + * {@code BodyDef.source} and the task hash. A cosmetic divergence here is a resume-cache + * invalidation for every user wiring an affected module. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ToolSchema { + + /** + * The JSON-schema object envelope, in a fresh mutable {@link LinkedHashMap}. + * + *

All four keys are written unconditionally and in this order — an empty + * {@code properties} or {@code required} is emitted as an empty collection, never dropped. + * A zero-input module and an all-optional record both produce empty accumulators, and the + * {@code ls} tool declares a literal empty {@code required}; omitting the key would change + * the schema JSON that {@code AgentDef.toolsFingerprint} hashes. + * + *

The returned map is fresh and mutable, so callers may hand it on as a nested fragment that + * later code reads or extends. Only the OUTER map is fresh, though — {@code properties} and + * {@code required} are stored by reference, not copied, so a caller that keeps hold of either + * accumulator can still mutate the schema after the fact. Every caller today hands over an + * accumulator it never touches again; a caller that wants to keep one must copy it itself. + */ + static Map object(Map properties, List required) { + // a Groovy map literal IS a LinkedHashMap, so these four keys keep this order + return [type: 'object', properties: properties, required: required, additionalProperties: false] + } + + /** + * The nf-core {@code meta.id} fragment: an open object carrying the single {@code id} + * string property. There is no sub-schema for {@code meta} anywhere; {@code id} is the + * convention mirrored from {@code CmdModuleView.inferNfCoreParam}. + * + *

WHEN it applies is the caller's business and the two callers disagree on purpose — + * {@link ModuleSpecToolSchema} applies it to any {@code map} named {@code meta}, while + * {@link ModuleMetadataToolSchema} gates it on the module being nf-core. + */ + static Map metaIdFragment(String description) { + return [ + type: 'object', + description: description, + properties: [id: [type: 'string', description: 'sample identifier']], + additionalProperties: true ] + } + + /** {@code file}/{@code path}/{@code directory} all denote an opaque path handle. */ + static boolean isFileType(String type) { + return type == 'file' || type == 'path' || type == 'directory' + } + + static boolean isIntegerType(String type) { + return type == 'integer' || type == 'int' || type == 'long' + } + + static boolean isNumberType(String type) { + return type == 'float' || type == 'double' || type == 'number' + } + + /** + * Render a declared type as the phrase the model reads in a tool description. + * + *

{@code numberIsANumber} has NO default on purpose. The registry metadata maps + * {@code float}/{@code double}/{@code number} onto their own rung; a {@code meta.yml} + * spec does not, and lets them fall through to {@code 'a string'}. That divergence is + * deliberate and out of scope here — it is on the description path, which is hashed into + * the task key exactly as the schema path is, so unifying it silently re-runs every + * affected agent on {@code -resume}. A default value is how the wrong caller would get the + * wrong ladder without anyone choosing it. + */ + static String describeKind(String type, boolean numberIsANumber) { + final t = type?.toLowerCase() + if( isFileType(t) ) + return 'a file path string' + if( t == 'map' ) + return 'an object' + if( isIntegerType(t) ) + return 'an integer' + if( numberIsANumber && isNumberType(t) ) + return 'a number' + if( t == 'boolean' ) + return 'a boolean' + return 'a string' + } + + /** + * Render one output component as {@code `name` (kind)(description)}. + * + *

It takes already-extracted strings rather than a DTO so the two callers can keep their + * own null handling: the registry items are null-navigated, the meta.yml components are not. + */ + static String describeComponent(String name, String type, String description, boolean numberIsANumber) { + final label = name ?: 'value' + final kind = describeKind(type, numberIsANumber) + final desc = description ? " (${description})" : '' + return "`${label}` (${kind})${desc}".toString() + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/WiredModuleTool.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/WiredModuleTool.groovy new file mode 100644 index 0000000000..b5a9fa77dc --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/WiredModuleTool.groovy @@ -0,0 +1,61 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import groovy.transform.TupleConstructor +import io.seqera.npr.api.schema.v1.ModuleMetadata +import nextflow.module.ModuleSpec +import nextflow.script.ProcessDef + +/** + * Everything known about ONE brokered {@code nf:module_run} tool, gathered while the agent is + * lowered by {@link ModuleToolResolver} and handed to {@link ModuleToolBridge} to wire. + * + *

The facts travel together rather than in parallel name-keyed maps because they are only ever + * read together: the bridge asks "does this tool have a spec, and is there registry metadata to + * describe it with" once per tool, in list order. + * + * @author Paolo Di Tommaso + */ +@TupleConstructor +@CompileStatic +class WiredModuleTool { + + /** The wire name the model sees, i.e. the process name selected through {@code nf:module_run}. */ + final String name + + /** The process template cloned for every tool call. */ + final ProcessDef proc + + /** + * The sibling {@code meta.yml} spec, or {@code null} for a locally-defined process. Its + * presence is what selects spec-driven marshalling over the scalar typed-I/O path. + */ + final ModuleSpec spec + + /** + * The public registry {@link ModuleMetadata}, or {@code null} when the module is not a + * registry install or its metadata could not be fetched. When present it is the descriptor + * source (description + input schema); marshalling stays on the spec + ProcessDef. + */ + final ModuleMetadata metadata + + /** Whether the metadata's module is nf-core scoped (for the {@code meta.id} convention); + * always {@code false} when there is no {@link #metadata}. */ + final boolean nfCore + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcConfig.groovy new file mode 100644 index 0000000000..653d880af3 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcConfig.groovy @@ -0,0 +1,203 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent.rpc + +import groovy.transform.CompileStatic +import nextflow.Session +import nextflow.SysEnv +import nextflow.config.spec.ConfigOption +import nextflow.config.spec.ConfigScope +import nextflow.script.dsl.Description +import nextflow.util.Duration +import nextflow.agent.AgentConfig +import nextflow.agent.AgentRunnerRequest + +/** + * Model the nested `agent.rpc` scope: the driver-side broker a canonical agent task dials back to. + * + *

Declared as a nested {@link ConfigScope} rather than a plain map option so each key is + * individually validated -- see {@link AgentConfig#AGENT_ONLY_OPTIONS}. Read once per session by + * the runner plugin's {@code AgentRpcBroker} (no longer a core class), so a value written inside a + * selector block resolves but has no effect. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentRpcConfig implements ConfigScope { + + static final int DEFAULT_PORT = 0 + + /** + * The name a container gets, per container ENGINE, for the host its driver runs on. Only these + * two engines synthesize such a name, which is why the alias is one rung of the ladder and not + * the whole of it: every other engine, and every executor that launches the task somewhere other + * than the driver host, is answered by inference instead -- see + * {@link AgentRpcHostResolver}, which owns the ladder, and which rejects the cases that cannot + * work before the run starts rather than advertising an address the task cannot reach. + * + *

The alias is also WRONG, not merely absent, when the driver is ITSELF containerized: it + * names the host, while the broker listens inside the driver container. That case is row R3 of + * the ladder and is why the engine name alone is not enough to answer this question. + * + *

NOTE for {@code docker}: the name is built in on Docker Desktop, while Linux Docker + * (>= 20.10) resolves it only when the container is run with + * {@code --add-host=host.docker.internal:host-gateway}; + * {@link nextflow.agent.AgentLaunchConditions#withDockerHostGateway} adds that run option + * to the agent task so both behave the same. + */ + private static final Map ENGINE_HOST_ALIASES = Collections.unmodifiableMap( + [ docker: 'host.docker.internal', podman: 'host.containers.internal' ] as Map ) + + /** The built-in host alias for a container engine, or {@code null} when the engine has none. */ + static String hostAliasFor(String engine) { + return engine != null ? ENGINE_HOST_ALIASES.get(engine) : null + } + + @ConfigOption + @Description(""" + The port the driver's agent RPC broker binds to. `0` (the default) picks an ephemeral port; pin it when the driver sits behind a firewall that must be opened in advance. + """) + final Integer port + + /** + * Environment fallback for {@link #remoteHost}. The driver's address is a property of WHERE the + * pipeline runs, not of the pipeline, so it belongs in the deployment's environment as readily + * as in its config: the same script can then run on a laptop and in a cluster without editing + * a config file that is otherwise portable. + */ + static final String REMOTE_HOST_ENV = 'NXF_AGENT_RPC_REMOTE_HOST' + + @ConfigOption + @Description(""" + The host name a containerized agent task uses to reach the driver's RPC broker. Falls back to the `NXF_AGENT_RPC_REMOTE_HOST` environment variable, then to inference from the container engine and the executor: the engine's built-in host alias (`host.docker.internal` for Docker, `host.containers.internal` for Podman) when that engine runs on the driver host, `127.0.0.1` when the container shares the driver's network namespace, and otherwise the driver's own address on its default route. Set it explicitly where the driver does not share a network with its tasks: a Kubernetes driver outside the cluster the pods run in, a cloud batch driver on an instance in a different VPC from the compute environment, or a multi-homed submit node whose default route is not the compute fabric. Give a routable driver address or an in-cluster service name. + """) + final String remoteHost + + /** + * Which of the two explicit rungs answered {@link #remoteHost} -- {@code agent.rpc.remoteHost} + * or {@code NXF_AGENT_RPC_REMOTE_HOST} -- or {@code null} when neither did and the address is + * therefore inferred. Kept because the source is what the registration line reports (see + * {@link AgentRpcHost#describe}): the expensive failure of an inferred address is that it looks + * exactly like a configured one, so an operator must be able to see which they got. + */ + final String remoteHostSource + + /** + * How long an unconsumed agent RPC capability stays valid. This clock starts when the task + * SCRIPT is generated, not when the job runs, so it has to absorb the executor's queueing + * latency: one hour covers essentially any real queue wait, while a capability remains + * single-use and unguessable for that window. + */ + static final Duration DEFAULT_CAPABILITY_TIMEOUT = Duration.of('1h') + + @ConfigOption + @Description(""" + How long an agent RPC capability remains valid while its task waits to start, i.e. the maximum queueing delay tolerated between the task script being generated and the agent connecting back to the driver (default: `1 h`). + """) + final Duration capabilityTimeout + + @ConfigOption + @Description(""" + When `true` (the default), the driver's agent RPC broker serves TLS with a certificate generated for the run, whose fingerprint the agent task pins. Set it to `false` only to inspect the stream while debugging: the prompt, inputs, tool arguments and results then cross the network in cleartext, and the task cannot tell the real driver from an impostor. + """) + final Boolean tls + + /* required by the spec reflection -- do not remove */ + AgentRpcConfig() {} + + AgentRpcConfig(Map opts) { + port = opts.port != null ? opts.port as Integer : DEFAULT_PORT + // NOT defaulted here: the remaining rungs need the container engine, the executor and facts + // about the driver host that this object cannot see -- see AgentRpcHostResolver + remoteHost = resolveConfiguredHost(opts, SysEnv.get()) + remoteHostSource = remoteHost == null ? null + : (opts?.remoteHost?.toString() ? 'agent.rpc.remoteHost' : REMOTE_HOST_ENV) + capabilityTimeout = opts.capabilityTimeout != null ? opts.capabilityTimeout as Duration : DEFAULT_CAPABILITY_TIMEOUT + tls = opts.tls != null ? opts.tls as Boolean : Boolean.TRUE + } + + /** + * The first two rungs of the ladder -- {@code agent.rpc.remoteHost}, then + * {@code NXF_AGENT_RPC_REMOTE_HOST} -- resolved against an explicit environment so tests can + * swap it instead of mutating the process. Config wins: a value written for this pipeline is + * more specific than one exported for whatever else shares the shell. + * + *

Each rung is tested for TRUTHINESS, so an empty value (an exported-but-unset variable, or + * {@code remoteHost = params.host} with {@code params.host} absent) falls through instead of + * shadowing the rungs below it and advertising an empty host. + */ + protected static String resolveConfiguredHost(Map opts, Map env) { + final configured = opts?.remoteHost?.toString() + if( configured ) + return configured + final fromEnv = env?.get(REMOTE_HOST_ENV)?.toString() + return fromEnv ?: null + } + + Integer getPort() { port } + + String getRemoteHost() { remoteHost } + + String getRemoteHostSource() { remoteHostSource } + + Duration getCapabilityTimeout() { capabilityTimeout } + + Boolean getTls() { tls } + + /** + * Whether the broker serves TLS; on unless explicitly disabled, so the no-arg constructor used + * by the spec reflection also reports the secure default. + */ + boolean tlsEnabled() { tls == null || tls } + + /** + * The host name to advertise to a containerized agent task launched by the given engine ON THE + * DRIVER HOST -- a THIN CALLER of {@link AgentRpcHostResolver}, which owns the ladder, so this + * and {@link nextflow.agent.AgentLaunchConditions#requireBrokerHost} cannot drift apart. + * {@code null} when no row applies, which the guard has already rejected before the run + * started. + * + *

Only the LOCAL rows can be answered from an engine name alone, which is exactly what this + * signature carries; the broker resolves through {@link #resolveBrokerHost} instead, because a + * session-level engine name says nothing about an executor that runs the task elsewhere. + */ + String resolveRemoteHost(String engine, Session session = null) { + final result = AgentRpcHostResolver.of(session) + .resolve(null, AgentConfig.DEFAULT_EXECUTOR, engine, session?.getContainerConfig(engine), null, this) + return result.resolved ? result.host : null + } + + /** + * The address the broker falls back to when the registration carries none of its own. + * + *

The address that is actually advertised rides on {@link AgentRunnerRequest#brokerHost}: the + * pre-ignition guard resolves it PER AGENT DEFINITION, with the full context (the executor + * instance, the engine config, the task's container options), and a run may legitimately hold + * several -- one agent on the local docker engine and another on {@code k8s} resolve different + * addresses, and each task must be told its own. None of that context is recoverable here. + * + *

This is therefore only for a runner that registers WITHOUT the guard, or a spec: it answers + * from what the session alone knows, which is exactly what shipped behaviour did -- the local + * rows, keyed off the enabled engine. It can be an error row, and {@code register()} rejects with + * that row's message rather than advertising {@code null:}. + */ + AgentRpcHost resolveBrokerHost(Session session) { + final engine = session?.getContainerConfig() + return AgentRpcHostResolver.of(session) + .resolve(null, AgentConfig.DEFAULT_EXECUTOR, engine?.getEngine(), engine, null, this) + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcHost.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcHost.groovy new file mode 100644 index 0000000000..cd0a7b5b42 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcHost.groovy @@ -0,0 +1,82 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent.rpc + +import groovy.transform.CompileStatic + +/** + * The address a containerized agent task dials to reach the driver's RPC broker, and where it + * came from. + * + *

Carrying the SOURCE alongside the host is not decoration: the expensive failure mode of the + * whole feature is a plausible-but-unroutable address, and the only cheap way to tell an operator + * that the driver GUESSED is to name the rung that answered. {@link #warnings} carries the cases + * the ladder resolves but is not certain about (a multi-homed driver, a containerized driver whose + * task may land on another docker network), which the broker prints at the registration line. + * + *

An unresolved instance is an ERROR ROW, not a null: {@link #error} is the message the + * pre-ignition guard rejects the run with, already naming what was tried and what to set. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentRpcHost { + + final String host + final String source + final List warnings + /** The error-row code -- {@code E1}..{@code E7} -- or {@code null} when resolved. */ + final String code + /** The rejection message, or {@code null} when resolved. */ + final String error + + private AgentRpcHost(String host, String source, List warnings, String code, String error) { + this.host = host + this.source = source + this.warnings = warnings != null ? Collections.unmodifiableList(warnings) : Collections.emptyList() + this.code = code + this.error = error + } + + static AgentRpcHost of(String host, String source, List warnings = null) { + return new AgentRpcHost(host, source, warnings, null, null) + } + + static AgentRpcHost error(String code, String message) { + return new AgentRpcHost(null, null, null, code, message) + } + + boolean isResolved() { host != null } + + String getHost() { host } + + String getSource() { source } + + List getWarnings() { warnings } + + String getCode() { code } + + String getError() { error } + + /** The address with the rung that produced it, e.g. {@code 10.0.3.17 (inferred from default route)}. */ + String describe() { + return resolved ? "${host} (${source})".toString() : "unresolved (${code}: ${error})".toString() + } + + @Override + String toString() { describe() } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcHostResolver.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcHostResolver.groovy new file mode 100644 index 0000000000..7d25a07839 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcHostResolver.groovy @@ -0,0 +1,441 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent.rpc + +import java.lang.ref.WeakReference +import java.util.regex.Pattern +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.Session +import nextflow.container.ContainerConfig +import nextflow.container.SmolVmConfig +import nextflow.executor.AbstractGridExecutor +import nextflow.executor.Executor +import nextflow.agent.AgentConfig + +/** + * The single owner of the ladder that decides which address a containerized agent task uses to + * reach the driver's RPC broker. + * + *

Every caller -- the pre-ignition guard + * {@link nextflow.agent.AgentLaunchConditions#requireBrokerHost}, the broker that advertises the + * endpoint, and {@link AgentRpcConfig#resolveRemoteHost} -- goes through here, so there is one + * implementation to keep in agreement rather than three. The rows are evaluated in STRICT order, + * first hit wins: + * + *

+ *  R1  agent.rpc.remoteHost set                            -> that value
+ *  R2  NXF_AGENT_RPC_REMOTE_HOST set                       -> that value
+ *  R3  local + docker/podman + local daemon + containerized driver (not rootless) -> outbound address
+ *  R4  local + the container shares the host network namespace, and that namespace
+ *      belongs to THIS kernel (not to a Docker Desktop / podman machine VM)       -> 127.0.0.1
+ *  R5  local + docker/podman + local daemon                -> the engine host alias
+ *  R6  local + a network-isolated engine with no alias     -> outbound address
+ *  R7  the executor is an AbstractGridExecutor             -> outbound address
+ *  R8  k8s, with the client config resolved FROM the cluster                      -> outbound address
+ *  R9  a cloud batch executor, with a positive cloud-membership probe             -> outbound address
+ *  R10 otherwise                                           -> error row E7
+ * 
+ * + *

The "outbound address" is the local address the kernel picks for the default route -- a + * routing-table lookup, no packet sent. It was measured reachable from every container engine that + * could be tested on the probe host, including the two VM-isolated ones, which is why a single + * address is advertised and never a candidate list: the capability token may only be offered to a + * driver whose TLS fingerprint has already verified, so trying N candidates would offer it to N + * hosts. + * + *

The cases that CANNOT work are error rows E1..E7 rather than a guess. Each carries its own + * message naming what was tried and what to set, and each is raised BEFORE ignition: a capability + * is only ever released by its one-hour {@code agent.rpc.capabilityTimeout}, so letting a doomed + * configuration submit trades a loud failure for an hour-long silent one. + * + *

Everything the ladder observes about the host -- the routing table, whether the driver is + * itself containerized, whether it runs rootless, whether it is a cloud instance -- comes through + * {@link Probes}, injected so every row is unit-testable with no network, no container and no + * cluster, and memoized so the probes run at most ONCE per session however many agent definitions + * the script declares. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class AgentRpcHostResolver { + + /** The address a container in the driver's own network namespace reaches the driver on. */ + static final String LOOPBACK = '127.0.0.1' + + /** Engines with a daemon of their own, hence an alias for the daemon host and a remote mode. */ + private static final List DAEMON_ENGINES = ['docker', 'podman'] + + /** + * Engines that create NO network namespace by default -- the container simply shares the + * driver's -- but which accept a run option that makes them create one. Inspected in the + * OPPOSITE direction from docker/podman: the option is what disqualifies the row (E6). + */ + private static final String SMOLVM = 'smolvm' + + private static final String APPLE_CONTAINER = 'apple-container' + + /** Engines that create no network namespace at all, with no switch to make them. */ + private static final List HOST_NAMESPACE_ENGINES = ['apptainer', 'singularity'] + + /** Executors that run the agent task on an instance of a cloud compute environment. */ + + + /** + * The conventional default-bridge range per engine. A containerized driver whose own address + * falls OUTSIDE it is very likely on a user-defined network (a {@code docker compose} run, or + * {@code docker run --network }), while Nextflow emits no {@code --network} for the agent + * task, so the task lands on the default bridge -- and docker's own DOCKER-ISOLATION rules DROP + * traffic between bridges. That is engine behaviour, not a site firewall, so it earns a warning. + */ + private static final Map DEFAULT_BRIDGE_PREFIX = [docker: '172.17.', podman: '10.88.'] + + + private final Map sessionConfig + private final Probes probes + + // -- memoized probe results; each is computed at most once for the life of the resolver, which + // is at most once per session (see #of). `outboundResolved` distinguishes "not looked up yet" + // from "looked up, and the host has no default route". + private boolean outboundResolved + private String outboundValue + private List interfacesValue + private Boolean containerizedValue + private Boolean rootlessValue + private Boolean cloudValue + + AgentRpcHostResolver(Map sessionConfig, Probes probes) { + this.sessionConfig = sessionConfig + this.probes = probes + } + + // --------------------------------------------------------------------------------------- + // per-session instance + // --------------------------------------------------------------------------------------- + + private static final Object LOCK = new Object() + /** + * WEAK, deliberately: this is a static field on a class that lives for the JVM, so a strong + * reference would pin the last {@link Session} -- and transitively its whole config map -- past + * the end of its run. An embedded host that executes several pipelines in one JVM would retain + * every session but the current one. Identity is all the cache key needs, and a cleared + * reference simply misses. + */ + private static WeakReference cachedSession + private static AgentRpcHostResolver cachedResolver + + /** + * The resolver for this session, created once. Memoization has to outlive a single call because + * {@code AgentLaunchConditions.requireCanonicalLaunch} runs once per agent DEFINITION while + * every probe answers a question about the HOST, so a script with ten agents would otherwise + * open ten sockets and read {@code /proc} ten times for ten identical answers. + * + *

The {@link AgentRpcConfig} is deliberately NOT part of the cached state: it is rebuilt per + * agent definition ({@code AgentDef.agentConfig()}) and a selector can give two agents different + * {@code agent.rpc.remoteHost} values, so it is passed per call while only the host facts -- the + * ones this cache exists for -- are shared. With no session there is nothing to key on, so each + * call gets its own resolver rather than inheriting another caller's memo. + */ + static AgentRpcHostResolver of(Session session) { + if( session == null ) + return new AgentRpcHostResolver(null, new SystemProbes()) + synchronized (LOCK) { + if( cachedResolver == null || cachedSession?.get() !== session ) { + cachedSession = new WeakReference(session) + cachedResolver = new AgentRpcHostResolver(session.getConfig(), new SystemProbes()) + } + return cachedResolver + } + } + + /** + * Resolve the address for one agent definition. The {@code executor} INSTANCE and its NAME are + * both required and neither is redundant: R7 keys off {@code instanceof AbstractGridExecutor}, + * because the executor registry carries names a hand-written list would miss, while R8/R9 key + * off the {@code @ServiceName} string. + */ + static AgentRpcHost resolve(Executor executor, String executorName, ContainerConfig containerConfig, + Object containerOptions, AgentRpcConfig rpc, Session session) { + return of(session).resolve(executor, executorName, containerConfig, containerOptions, rpc) + } + + /** Discard the per-session instance; for tests, which run many sessions in one JVM. */ + static void reset() { + synchronized (LOCK) { + cachedSession = null + cachedResolver = null + } + } + + /** + * Seed the per-session instance with alternative {@link Probes}, so a test can drive the ladder + * through the callers that reach it statically -- the pre-ignition guard and the broker -- and + * still assert which RUNG answered rather than which machine the suite happens to run on. Without + * it a suite running in a container would see {@code /.dockerenv} and take R3 for every alias row. + * Paired with {@link #reset}. + */ + static AgentRpcHostResolver install(Session session, Probes probes) { + synchronized (LOCK) { + cachedSession = session != null ? new WeakReference(session) : null + cachedResolver = new AgentRpcHostResolver(session?.getConfig(), probes) + return cachedResolver + } + } + + // --------------------------------------------------------------------------------------- + // the ladder + // --------------------------------------------------------------------------------------- + + /** + * Whether the container engine that launches the agent task runs on the DRIVER host, which is a + * NECESSARY but not sufficient condition for R3..R6: it proves the EXECUTOR is local, not that + * the container daemon is (see E3). + */ + static boolean isDriverHostExecutor(String executor) { + return executor == null || executor == AgentConfig.DEFAULT_EXECUTOR + } + + AgentRpcHost resolve(Executor executor, String executorName, ContainerConfig containerConfig, + Object containerOptions, AgentRpcConfig rpc) { + return resolve(executor, executorName, containerConfig?.getEngine(), containerConfig, containerOptions, rpc) + } + + /** + * The engine NAME is taken separately from the {@link ContainerConfig} so a caller that has only + * the name -- {@link AgentRpcConfig#resolveRemoteHost}, which is the shape the runner SPI has + * always had -- goes through the same ladder rather than around it. Such a caller gets no engine + * {@code runOptions} and no {@code smolvm.network}, so it can only be answered by the rows that + * do not need them; that is a property of what it knows, not a second ladder. + * + *

{@code synchronized} because the memo fields below are written here: the pre-ignition guard + * calls this from the SCRIPT thread while the broker's fallback ({@link + * AgentRpcConfig#resolveBrokerHost}) can call it from a task thread, and there is no other + * happens-before edge between the two. The probes are idempotent, so the lock only ever costs a + * repeated probe it also makes unnecessary. + */ + synchronized AgentRpcHost resolve(Executor executor, String executorName, String engine, ContainerConfig containerConfig, + Object containerOptions, AgentRpcConfig rpc) { + return ladder(executor, executorName, engine, containerConfig, containerOptions, rpc) + } + + private AgentRpcHost ladder(Executor executor, String executorName, String engine, + ContainerConfig containerConfig, Object containerOptions, AgentRpcConfig rpc) { + final List tried = [] + + // -- R1/R2: an explicit value, from the config or from the environment. AgentRpcConfig has + // already merged the two rungs, and remembers which one answered so the log line can say. + if( rpc?.getRemoteHost() ) + return AgentRpcHost.of(rpc.getRemoteHost(), rpc.getRemoteHostSource()) + tried << '`agent.rpc.remoteHost` (not set)' + tried << "`${AgentRpcConfig.REMOTE_HOST_ENV}` (not set)".toString() + + // -- The engine decides the answer ONLY when it runs on the driver's own machine. Anywhere + // else, where the task runs is the executor's business and the engine knows nothing + // about how the task reaches back. + if( isDriverHostExecutor(executorName) ) { + // -- R3: docker and podman synthesize a name for the host their containers run on. + if( engine in DAEMON_ENGINES ) + return AgentRpcHost.of(AgentRpcConfig.hostAliasFor(engine), "${engine} host alias".toString()) + + // -- R4: these engines create no network namespace, so the driver's loopback IS the + // container's loopback. A property of the engine, not a guess about the deployment. + if( engine in HOST_NAMESPACE_ENGINES ) + return AgentRpcHost.of(LOOPBACK, 'host network namespace') + + // -- R5: a microVM has its own kernel, so loopback is the GUEST's. With networking + // disabled there is no address at all and no rung below can help. + if( engine == SMOLVM ) { + if( !smolvmNetwork(containerConfig) ) + return smolvmNetworkError() + return outbound('inferred from default route', tried) + } + + // -- R6: apple-container is VM-isolated and synthesizes no host name of its own. The + // outbound address was measured reachable from it. + if( engine == APPLE_CONTAINER ) + return outbound('inferred from default route', tried) + + // No engine at all means nothing is containerized here, so there is no task to reach + // back and no address to infer. An UNKNOWN engine is different: it containerizes, it + // simply has no rung of its own, so it takes the routable address below like any + // remote deployment. + if( !engine ) { + tried << 'the container engine (none is enabled, so no agent task is containerized)' + return unresolvedError(tried) + } + tried << "the container engine (`${engine}` names no address for the driver host)".toString() + } + + // -- R7: everything else -- Kubernetes, a grid/HPC cluster, a cloud batch service, or a + // driver-host engine with no rung of its own. They share one shape: the task runs + // somewhere that must route back to the driver. Deliberately NOT split per executor: + // the answer is identical for all of them, so a per-provider rung would be a branch + // with no different behaviour behind it, and a provider probe we cannot test. + return outbound('inferred from default route', tried) + } + + // --------------------------------------------------------------------------------------- + // the outbound address + // --------------------------------------------------------------------------------------- + + /** + * The default-route address, checked for usability. A plausible-but-unroutable address is the + * expensive failure this whole design exists to avoid, so loopback, wildcard, link-local and + * "no address at all" are error row E7 rather than something to advertise. + */ + private AgentRpcHost outbound(String source, List tried, List warnings = null) { + String address = outboundAddress() + String label = source + if( !usableAddress(address) ) { + // A host with NO default route is not necessarily offline: an air-gapped cluster and an + // IPv6-only fabric both route perfectly well on the interface the compute nodes share, + // and that address is already enumerated one line down. Only an UNAMBIGUOUS answer is + // taken -- with several usable addresses there is no evidence which one the agent task + // can reach, and advertising a guess is the failure this design exists to avoid. + final candidates = interfaceAddresses().findAll { usableAddress(it) } + // A non-local deployment may legitimately have to be reached on a PUBLIC address, so a + // single globally-routable candidate settles an otherwise ambiguous set. Deliberately + // read off the local interfaces rather than asked of a provider metadata service: no + // per-cloud probe, nothing to test against, and a NAT-assigned public address is not + // on the interface anyway -- for which `agent.rpc.remoteHost` is the answer. + final publics = candidates.findAll { publicAddress(it) } + if( publics.size() == 1 ) { + final all = warnings != null ? new ArrayList(warnings) : new ArrayList() + all << "the driver host has no usable default route, so its public address `${publics[0]}` was advertised - set `agent.rpc.remoteHost` if the agent tasks reach the driver on a different one".toString() + return AgentRpcHost.of(publics[0], "${source}; the host has no default route, so its only public interface address was used".toString(), all) + } + if( candidates.size() != 1 ) { + tried << "the host's default route (the routing table returned ${address ? '`' + address + '`, which is not routable from another host' : 'no address at all'})".toString() + tried << (candidates + ? "the host's interfaces (${candidates.collect { '`' + it + '`' }.join(', ')} are all routable, so none of them is unambiguously the one to advertise)".toString() + : 'the host\'s interfaces (none carries a routable address, so the driver appears to be offline)') + return unresolvedError(tried) + } + address = candidates[0] + label = "${source}; the host has no default route, so its only routable interface address was used".toString() + } + final List all = warnings != null ? new ArrayList(warnings) : new ArrayList() + // Same ADDRESS FAMILY only. A dual-stack NIC reports its IPv4 and its global IPv6 address + // separately, so comparing across families would fire this on essentially every ordinary + // Linux driver and train operators to ignore the one warning §4 makes load-bearing. + final others = interfaceAddresses().findAll { it != address && sameFamily(it, address) } + if( others ) + all << "the driver host is multi-homed: the default route selected `${address}`, but ${others.collect { '`' + it + '`' }.join(', ')} also exist - set `agent.rpc.remoteHost` if the agent tasks reach the driver on one of those instead".toString() + return AgentRpcHost.of(address, label, all) + } + + /** + * Whether two address LITERALS belong to the same family. Every value here comes from + * {@code InetAddress.getHostAddress}, which spells IPv6 with colons and IPv4 without and never + * emits the mapped {@code ::ffff:} form, so the test needs no parsing and no lookup. + */ + protected static boolean sameFamily(String a, String b) { + return a?.contains(':') == b?.contains(':') + } + + /** + * The address must be usable FROM ANOTHER HOST. {@code InetAddress} answers all three questions + * without a lookup, because every value here is already a literal from {@code getHostAddress}. + */ + protected static boolean usableAddress(String address) { + if( !address ) + return false + try { + final inet = InetAddress.getByName(address) + return !inet.isLoopbackAddress() && !inet.isAnyLocalAddress() && !inet.isLinkLocalAddress() + } + catch( Exception e ) { + log.debug "Unable to interpret `${address}` as an address for the driver's agent RPC broker - ${e.message}" + return false + } + } + + /** + * Whether an address is routable from OUTSIDE the driver's own network, i.e. not RFC1918 / + * unique-local. {@code isSiteLocalAddress} covers 10/8, 172.16/12 and 192.168/16 for IPv4 and + * {@code fec0::/10} for IPv6; {@code fc00::/7} unique-local is checked separately because + * {@link InetAddress} has no predicate for it. + */ + protected static boolean publicAddress(String address) { + if( !usableAddress(address) ) + return false + try { + final inet = InetAddress.getByName(address) + if( inet.isSiteLocalAddress() ) + return false + final b = inet.address + // fc00::/7 - unique local, the IPv6 analogue of RFC1918 + return !(b.length == 16 && (b[0] & 0xFE) == 0xFC) + } + catch( Exception e ) { + return false + } + } + + private String outboundAddress() { + if( !outboundResolved ) { + outboundValue = probes.outboundAddress() + outboundResolved = true + } + return outboundValue + } + + private List interfaceAddresses() { + if( interfacesValue == null ) + interfacesValue = probes.interfaceAddresses() ?: Collections.emptyList() + return interfacesValue + } + + // --------------------------------------------------------------------------------------- + // row conditions + // --------------------------------------------------------------------------------------- + + /** An IPv4 dotted quad, the only literal shape that needs a pattern to be told from a NAME. */ + private static final Pattern IPV4_LITERAL = Pattern.compile(/^\d{1,3}(?:\.\d{1,3}){3}$/) + + /** {@code --network host}, {@code --network=host}, {@code --net host}, {@code --net=host}. */ + private static final Pattern HOST_NETWORK = Pattern.compile(/(?:^|\s)--net(?:work)?[= ]host(?:\s|$)/) + + /** + * {@code smolvm.network} defaults to true and {@code SmolVmBuilder} emits {@code --net} only + * when it is set, so a microVM created with it false has no routes at all. + */ + protected static boolean smolvmNetwork(ContainerConfig containerConfig) { + // Read the typed field off the smolvm config rather than reflecting a property name off + // the generic interface: `ContainerConfig` does not declare `network` (it is engine + // specific), and anything that is not a SmolVmConfig gets SmolVmBuilder's default, true. + return !(containerConfig instanceof SmolVmConfig) || ((SmolVmConfig) containerConfig).network + } + + // --------------------------------------------------------------------------------------- + // error rows -- one message per row, each naming what was tried and what to set + // --------------------------------------------------------------------------------------- + + private static AgentRpcHost smolvmNetworkError() { + return AgentRpcHost.error('E2', 'the `smolvm` microVM is created with no network at all (`smolvm.network = false`), so the agent task cannot dial the driver on any address - set `smolvm.network = true`') + } + + private static AgentRpcHost unresolvedError(List tried) { + return AgentRpcHost.error('E7', "no address the agent task could reach the driver on could be determined. Tried, in order: ${tried.join('; ')}. Set `agent.rpc.remoteHost` to a host the agent task can reach the driver on - a routable driver address, an in-cluster service name, or `${LOOPBACK}` when the container shares the driver's network namespace".toString()) + } + + // --------------------------------------------------------------------------------------- + // the real probes + // --------------------------------------------------------------------------------------- + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcRegistration.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcRegistration.groovy new file mode 100644 index 0000000000..a9c14841f1 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/AgentRpcRegistration.groovy @@ -0,0 +1,75 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.rpc + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import groovy.transform.ToString +import nextflow.agent.AgentRunner + +/** Connection material issued for one canonical agent task attempt. */ +@Canonical +/* + * @Canonical is a meta-annotation that implies @ToString over EVERY property, so the default render + * puts the capability token in whatever consumed it -- a `log.debug "$registration"`, a Spock failure + * message, an exception built by string interpolation. The broker goes out of its way never to log + * the token, and one such line would undo that silently; an explicit @ToString wins over the one + * @Canonical implies, so this is the whole fix. `includeNames` is not cosmetic either: without it the + * render is four bare positional values with one of them missing, which reads as a malformed object + * rather than a redacted one. + */ +@ToString(excludes='token', includeNames=true) +@CompileStatic +class AgentRpcRegistration { + String invocationId + String token + String endpoint + /** + * SHA-256 of the broker's certificate, lowercase hex, which the task pins to authenticate the + * driver; {@code null} when the broker serves cleartext (`agent.rpc.tls = false`). Unlike the + * token this is a public commitment, not a secret, so putting it in the task script is harmless. + */ + String fingerprint + /** + * Set only by a broker that deliberately serves cleartext (`agent.rpc.tls = false`). Cleartext is + * carried as its own flag rather than inferred from a missing {@link #fingerprint} so that the + * two cannot be confused: a runner that simply forgets the digest has a bug, not a licence to + * dial unencrypted. + */ + boolean insecure + + /** + * The transport flags the proxy needs to dial the broker: pin the driver's certificate, or opt + * out of transport security explicitly. An absent digest is never read as "unpinned" -- a + * registration that carries neither fails here, on the driver, with a message naming the runner + * contract, rather than sending the proxy to dial cleartext against a TLS listener and surfacing + * as an unrelated connection failure inside the task. + * + *

When a registration somehow carries BOTH -- which the in-tree broker cannot produce, since it + * derives {@link #insecure} from the same {@code agent.rpc.tls} switch that decides whether a + * fingerprint exists at all -- the pin wins. Do not "fix" this into a throw for symmetry with the + * proxy, which does reject the pair: there the two flags are its own argv and disagreeing about + * them is a bug in the caller, whereas here the only tiebreak that cannot silently downgrade a + * confused out-of-tree runner to cleartext is the secure one. + */ + List transportArgs() { + if( fingerprint ) + return List.of('--fingerprint', fingerprint) + if( insecure ) + return List.of('--insecure') + throw new IllegalStateException("Agent RPC registration ${invocationId} carries no certificate fingerprint and does not opt out of transport security -- an AgentRunner serving TLS must return the broker's fingerprint".toString()) + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/rpc/Probes.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/Probes.groovy new file mode 100644 index 0000000000..ae4bd12653 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/Probes.groovy @@ -0,0 +1,34 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent.rpc + +/** + * Everything about the DRIVER HOST the ladder needs to observe, behind an interface so the rows + * can be driven deterministically from a test. Nothing here takes an argument that varies per + * agent definition: each answer is a property of the host, which is what makes the memoization + * below sound. + */ +interface Probes { + /** + * The local address the kernel selects for the default route, or {@code null} when the host + * has no route at all. A routing-table lookup -- no packet is sent. + */ + String outboundAddress() + + /** Every non-loopback, non-link-local interface address, for the multi-homed warning. */ + List interfaceAddresses() +} diff --git a/modules/nextflow/src/main/groovy/nextflow/agent/rpc/SystemProbes.groovy b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/SystemProbes.groovy new file mode 100644 index 0000000000..5967f8a036 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/agent/rpc/SystemProbes.groovy @@ -0,0 +1,108 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent.rpc + +import java.util.regex.Pattern + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +/** + * The production {@link Probes}: the only place in this file that touches the network, the + * filesystem or the process environment. Every method answers {@code null}/{@code false} rather + * than throwing, so a hostile environment degrades to an error row with a message instead of an + * exception with a stack trace. + */ +@Slf4j +@CompileStatic +class SystemProbes implements Probes { + + /** + * A connected {@code DatagramSocket} performs a routing-table lookup and nothing else -- no + * packet leaves the host -- so this names the address the kernel would SOURCE traffic from, + * which is the address a remote peer would see. On a cloud instance that is already the + * private address the metadata service would report, which is why IMDS is never consulted + * for the address itself. + */ + /** + * The IPv4 and IPv6 literals the route lookup is performed against. Nothing is sent to + * either -- a connected {@code DatagramSocket} only consults the routing table -- so these + * name well-known GLOBAL addresses purely to select the default route. IPv6 is tried second + * so a dual-stack host keeps answering with its IPv4 address, while an IPv6-only fabric + * (where the IPv4 lookup raises {@code ENETUNREACH}) still resolves. + */ + private static final List ROUTE_PROBE_ADDRESSES = ['1.1.1.1', '2606:4700:4700::1111'] + + @Override + String outboundAddress() { + for( final target : ROUTE_PROBE_ADDRESSES ) { + final address = routeLookup(target) + if( address ) + return address + } + return null + } + + private static String routeLookup(String target) { + DatagramSocket socket = null + try { + socket = new DatagramSocket() + socket.connect(InetAddress.getByName(target), 53) + final local = socket.getLocalAddress() + return local != null ? local.getHostAddress() : null + } + catch( Exception e ) { + log.debug "Unable to determine the driver's outbound address towards ${target} - ${e.message}" + return null + } + finally { + socket?.close() + } + } + + /** + * The interfaces a CONTAINER BRIDGE owns are skipped by name. Every Linux host with docker + * installed carries {@code docker0} at 172.17.0.1 (plus a {@code br-*} per user-defined + * network), and counting those as alternative driver addresses would make the multi-homed + * warning fire on every such host -- including the plain single-NIC submit node the warning + * is meant to distinguish from the multi-homed one. + */ + private static final Pattern VIRTUAL_INTERFACE = Pattern.compile(/^(?:docker\d*|br-.*|podman\d*|cni-podman\d*|cni\d*|virbr\d*|veth.*|tun\d*|utun\d*)$/) + + @Override + List interfaceAddresses() { + final List result = [] + try { + for( final nic : Collections.list(NetworkInterface.getNetworkInterfaces()) ) { + if( !nic.isUp() || nic.isLoopback() ) + continue + if( VIRTUAL_INTERFACE.matcher(nic.getName()).matches() ) + continue + for( final addr : Collections.list(nic.getInetAddresses()) ) { + if( addr.isLoopbackAddress() || addr.isLinkLocalAddress() || addr.isAnyLocalAddress() ) + continue + result << addr.getHostAddress() + } + } + } + catch( Exception e ) { + log.debug "Unable to enumerate the driver's network interfaces - ${e.message}" + } + return result + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/config/parser/v2/ConfigDsl.groovy b/modules/nextflow/src/main/groovy/nextflow/config/parser/v2/ConfigDsl.groovy index a94fe2e197..d608e01e3c 100644 --- a/modules/nextflow/src/main/groovy/nextflow/config/parser/v2/ConfigDsl.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/config/parser/v2/ConfigDsl.groovy @@ -37,6 +37,13 @@ import nextflow.file.FileHelper @CompileStatic class ConfigDsl extends Script { + /** + * The config scopes accepting `withName:`/`withLabel:` selectors. Agents are + * configured with the same selector semantics as processes, in their own + * independent {@code agent} scope. + */ + private static final List SELECTOR_SCOPES = List.of('process', 'agent') + private boolean ignoreIncludes private boolean renderClosureAsString @@ -215,8 +222,8 @@ class ConfigDsl extends Script { ? List.of(names.last()) : names - if( relativeNames.size() == 1 && relativeNames.last() == 'process' ) - return new ProcessDsl(this, names) + if( relativeNames.size() == 1 && relativeNames.last() in SELECTOR_SCOPES ) + return new SelectorBlockDsl(this, names) if( names.size() == 1 && names.first() == 'profiles' ) return new ProfilesDsl(this, profiles) @@ -305,11 +312,11 @@ class ConfigDsl extends Script { } void withLabel(String label, Closure closure) { - throw new ConfigParseException("Process selectors are only allowed in the `process` scope (offending scope: `${scope.join('.')}`)") + throw new ConfigParseException("Config selectors are only allowed in the `process` and `agent` scopes (offending scope: `${scope.join('.')}`)") } void withName(String selector, Closure closure) { - throw new ConfigParseException("Process selectors are only allowed in the `process` scope (offending scope: `${scope.join('.')}`)") + throw new ConfigParseException("Config selectors are only allowed in the `process` and `agent` scopes (offending scope: `${scope.join('.')}`)") } void includeConfig(String includeFile) { @@ -337,8 +344,13 @@ class ConfigDsl extends Script { } } - private static class ProcessDsl extends ConfigBlockDsl { - ProcessDsl(ConfigDsl dsl, List scope) { + /** + * The block DSL of a scope accepting `withName:`/`withLabel:` selectors + * (see {@link #SELECTOR_SCOPES}). A selector is rewritten into a nested + * block whose key carries the selector prefix. + */ + private static class SelectorBlockDsl extends ConfigBlockDsl { + SelectorBlockDsl(ConfigDsl dsl, List scope) { super(dsl, scope) } diff --git a/modules/nextflow/src/main/groovy/nextflow/executor/Executor.groovy b/modules/nextflow/src/main/groovy/nextflow/executor/Executor.groovy index faae075739..0b915a2a6c 100644 --- a/modules/nextflow/src/main/groovy/nextflow/executor/Executor.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/executor/Executor.groovy @@ -18,6 +18,8 @@ package nextflow.executor import java.nio.file.Path +import java.util.concurrent.ExecutorService + import groovy.transform.CompileStatic import groovy.transform.Memoized import groovy.util.logging.Slf4j @@ -227,6 +229,19 @@ abstract class Executor { return false } + /** + * The thread pool this executor's task handlers run their work on. + * + *

Defaults to the session's execution pool, sized against + * {@link nextflow.processor.LocalPollingMonitor}'s cpu gate. An executor whose tasks BLOCK on + * other tasks -- an orchestrator such as {@link nextflow.executor.local.AgentExecutor} -- must + * override this with a pool of its own, or its blocked tasks will starve the very sub-tasks + * they are waiting for. + */ + ExecutorService getExecService() { + return session.execService + } + /** * Allow graceful termination of executor resources */ diff --git a/modules/nextflow/src/main/groovy/nextflow/executor/ExecutorFactory.groovy b/modules/nextflow/src/main/groovy/nextflow/executor/ExecutorFactory.groovy index 3df051a0b8..f99620c26e 100644 --- a/modules/nextflow/src/main/groovy/nextflow/executor/ExecutorFactory.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/executor/ExecutorFactory.groovy @@ -21,6 +21,7 @@ import groovy.transform.CompileStatic import groovy.transform.PackageScope import groovy.util.logging.Slf4j import nextflow.Session +import nextflow.executor.local.AgentExecutor import nextflow.executor.local.LocalExecutor import nextflow.script.BodyDef import nextflow.script.ProcessConfig @@ -44,6 +45,7 @@ class ExecutorFactory { final static Map> BUILT_IN_EXECUTORS = [ 'nope': NopeExecutor, 'local': LocalExecutor, + 'agent': AgentExecutor, 'flux': FluxExecutor, 'sge': SgeExecutor, 'oge': SgeExecutor, @@ -202,6 +204,33 @@ class ExecutorFactory { return result } + /** + * Resolve (and cache) the {@link Executor} instance for the given executor name WITHOUT + * requiring a task body, so an executor CAPABILITY can be interrogated before the body exists. + * + *

An instance is unavoidable: {@link Executor#isContainerNative()} and + * {@link Executor#containerConfigEngine()} are instance methods and can depend on the session + * (e.g. {@link LocalExecutor#isContainerNative()} is true when Fusion is enabled), so the + * executor class alone is not enough. The instance is stored in the same per-class cache + * {@link #getExecutor} uses, hence the executor is created at most once per run. + * + *

Unlike {@link #getExecutor} this performs NO {@code SupportedScriptTypes} check and never + * falls back to the local executor: an unknown name raises instead of being silently downgraded. + * The instance is returned fully initialised ({@code init()} has run, so its task monitor is + * started) and is the SAME one {@link #getExecutor} later hands the task, so asking an executor + * about itself here commits the run to it. See {@link nextflow.script.AgentDef#buildAgentTask}, + * which interrogates exactly the executor the agent task is about to run on. + */ + Executor getExecutorByName(String executorName, Session session) { + final clazz = getExecutorClass(executorName) + def result = executors.get(clazz) + if( result ) + return result + result = createExecutor(clazz, executorName ?: DEFAULT_EXECUTOR, session) + executors.put(clazz, result) + return result + } + protected Executor createExecutor( Class clazz, String name, Session session) { def result = clazz.newInstance() result.session = session diff --git a/modules/nextflow/src/main/groovy/nextflow/executor/local/AgentExecutor.groovy b/modules/nextflow/src/main/groovy/nextflow/executor/local/AgentExecutor.groovy new file mode 100644 index 0000000000..86be98842e --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/executor/local/AgentExecutor.groovy @@ -0,0 +1,87 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.executor.local + +import java.util.concurrent.ExecutorService + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.executor.SupportedScriptTypes +import nextflow.processor.AgentPollingMonitor +import nextflow.processor.TaskHandler +import nextflow.processor.TaskMonitor +import nextflow.processor.TaskRun +import nextflow.script.ScriptType + +/** + * Local executor for {@code agent} tasks. Agents are in-JVM ORCHESTRATORS: their (native) body + * blocks waiting for the tool sub-tasks it dispatches, which run on the normal {@link LocalExecutor}. + * Running agents on the standard local executor deadlocks on a small machine because a blocked agent + * holds a cpu/capacity slot in {@link nextflow.processor.LocalPollingMonitor} that its own sub-task + * needs. + * + * The separation is on TWO axes, and both are required: + * + *

    + *
  • Admission — an {@link AgentPollingMonitor} that does not resource-throttle, so a + * blocked agent never occupies a cpu slot its tool sub-tasks require. + *
  • Threads — its own orchestration pool ({@link Session#getAgentExecService}) rather + * than the session's execution pool. Sharing one pool puts a dependency edge between two + * members of the same pool, so enough blocked agents leave no thread to run the sub-tasks + * that would release them. No pool SIZE fixes that, because agents are admitted without a + * capacity cap; only a separate pool does. + *
+ * + * Real compute (the sub-tasks) stays throttled on the standard local executor, and the dependency + * crosses the pool boundary in one direction only — orchestration waits on execution, never the + * reverse — so there is no cycle to close. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +@SupportedScriptTypes( [ScriptType.GROOVY] ) +class AgentExecutor extends LocalExecutor { + + @Override + protected TaskMonitor createTaskMonitor() { + return AgentPollingMonitor.create(session, config, name) + } + + /** + * Agents run on the orchestration pool, never on the execution pool they dispatch into. + */ + @Override + ExecutorService getExecService() { + return session.getAgentExecService() + } + + /** + * An in-JVM agent runs no wrapper script, so nothing would stage its declared input files. + * {@link AgentTaskHandler} materializes them into the work dir before the body runs, which is + * what makes a typed `Path` input mean the same thing here as it does under a container. + */ + @Override + TaskHandler createTaskHandler(TaskRun task) { + assert task + assert task.workDir + + if( task.type == ScriptType.GROOVY ) + return new AgentTaskHandler(task, this) + return super.createTaskHandler(task) + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/executor/local/AgentTaskHandler.groovy b/modules/nextflow/src/main/groovy/nextflow/executor/local/AgentTaskHandler.groovy new file mode 100644 index 0000000000..84b3ee9fbd --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/executor/local/AgentTaskHandler.groovy @@ -0,0 +1,133 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.executor.local + +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path + +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j +import nextflow.executor.Executor +import nextflow.extension.FilesEx +import nextflow.processor.TaskRun + +/** + * Native task handler for an in-JVM agent, which MATERIALIZES the task's declared input files + * into its work directory before the body runs. + * + * A typed `Path` input means the same thing for an agent as for a process: the value the agent + * sees is a work-dir-relative name. For a canonical (containerized) agent the stage-in script + * generated by {@code BashWrapperBuilder} makes that true; an in-JVM agent runs no wrapper and no + * shell at all, so the file has to be linked in here or the name would resolve to nothing. + * + * Confined to the agent executor on purpose: native `exec` processes have the identical hole, but + * fixing them means changing every native task and is a separate change. + */ +@Slf4j +@CompileStatic +class AgentTaskHandler extends NativeTaskHandler { + + protected AgentTaskHandler(TaskRun task, Executor executor) { + super(task, executor) + } + + @Override + void submit() { + materializeInputs(task) + super.submit() + } + + /** + * Link (or copy) each declared input file into the task work directory under its stage name. + * The work dir is created by {@code TaskProcessor} before the task is submitted, so it exists. + * + * Honours {@code stageInMode} with the same four strategies a scriptlet task gets from + * {@link nextflow.executor.SimpleFileCopyStrategy}, since an agent resolves the full task + * directive ladder from the `agent` config scope and a user may well set it there — including + * the same hard failure on an unrecognized one, so a typo means the same thing in both. + */ + @PackageScope + static void materializeInputs(TaskRun task) { + final inputs = task.getInputFilesMap() + if( !inputs ) + return + final workDir = task.workDir + // Linking is a local-filesystem operation: a remote provider (S3, Azure, ...) throws + // UnsupportedOperationException out of createSymbolicLink/createLink, which would escape + // submit() and fail every agent task that declares a Path input. An agent whose work dir + // is not local cannot use the staged names anyway -- its `fs:` tools refuse a non-file + // scheme (ModuleToolBridge) -- so skip rather than turn a degraded case into a hard error. + if( workDir.getFileSystem() != FileSystems.getDefault() ) { + log.warn1 "Agent input files are not staged when the work directory is not a local path -- offending work dir: ${workDir}" + return + } + final mode = task.config?.getStageInMode() + for( final entry : inputs.entrySet() ) { + final target = workDir.resolve(entry.key) + // a stage name may carry a sub-directory (e.g. `dir/file.txt`) + if( target.parent != null ) + Files.createDirectories(target.parent) + Files.deleteIfExists(target) + stageInput(entry.value, target, mode) + } + } + + private static void stageInput(Path source, Path target, String mode) { + switch( mode ) { + case 'copy': + // via FilesEx so a directory input is copied recursively, as `cp -fRL` would + FilesEx.copyTo(source, target) + break + case 'link': + Files.createLink(target, source) + break + case 'rellink': + Files.createSymbolicLink(target, relativize(source, target)) + break + case null: + case 'symlink': + Files.createSymbolicLink(target, source.toAbsolutePath()) + break + default: + // same rejection SimpleFileCopyStrategy.stageInCommand makes, so a typo'd + // `stageInMode` fails an agent exactly as loudly as it fails a process + throw new IllegalArgumentException("Unknown stage-in strategy: $mode") + } + } + + /** + * The link target for `rellink`. Both ends are canonicalized first, because relativizing a + * path that traverses a symlinked ancestor (on macOS `/var` -> `/private/var`) yields a `..` + * chain the kernel resolves physically, i.e. a dangling link. Degrades to the absolute path + * when the two ends are not comparable — a relative link is an optimization, never a contract. + */ + private static Path relativize(Path source, Path target) { + final abs = source.toAbsolutePath() + if( source.fileSystem != target.fileSystem ) + return abs + try { + return target.parent.toRealPath().relativize(abs.toRealPath()) + } + catch( IOException e ) { + log.debug "Unable to build a relative stage-in link for `${source}` - ${e.message}" + return abs + } + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/executor/local/LocalTaskHandler.groovy b/modules/nextflow/src/main/groovy/nextflow/executor/local/LocalTaskHandler.groovy index 2ff7aacde9..cd9df3a1ac 100644 --- a/modules/nextflow/src/main/groovy/nextflow/executor/local/LocalTaskHandler.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/executor/local/LocalTaskHandler.groovy @@ -105,7 +105,7 @@ class LocalTaskHandler extends TaskHandler implements FusionAwareTask { final logFile = builder.redirectOutput().file() // run async via thread pool - session.getExecService().submit( { + executor.getExecService().submit( { try { // start the execution and notify the event to the monitor process = builder.start() diff --git a/modules/nextflow/src/main/groovy/nextflow/executor/local/NativeTaskHandler.groovy b/modules/nextflow/src/main/groovy/nextflow/executor/local/NativeTaskHandler.groovy index 4426e847c8..5fd1242c1a 100644 --- a/modules/nextflow/src/main/groovy/nextflow/executor/local/NativeTaskHandler.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/executor/local/NativeTaskHandler.groovy @@ -73,7 +73,7 @@ class NativeTaskHandler extends TaskHandler { // submit for execution by using session executor service // it returns an error when everything is OK // of the exception throw in case of error - result = session.getExecService().submit(new TaskSubmit(task)) + result = executor.getExecService().submit(new TaskSubmit(task)) status = TaskStatus.SUBMITTED } diff --git a/modules/nextflow/src/main/groovy/nextflow/extension/CH.groovy b/modules/nextflow/src/main/groovy/nextflow/extension/CH.groovy index f4169e55bc..8451e181b8 100644 --- a/modules/nextflow/src/main/groovy/nextflow/extension/CH.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/extension/CH.groovy @@ -122,7 +122,7 @@ class CH { } } - static void init() { bridges.clear() } + static void init() { bridges.clear(); allTopics.clear() } @PackageScope static DataflowWriteChannel close0(DataflowWriteChannel source) { diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/AgentPollingMonitor.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/AgentPollingMonitor.groovy new file mode 100644 index 0000000000..f0be250036 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/processor/AgentPollingMonitor.groovy @@ -0,0 +1,64 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.processor + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.Session +import nextflow.executor.ExecutorConfig +import nextflow.util.Duration + +/** + * Task monitor for the {@code agent} executor. An agent task is an ORCHESTRATOR: its body blocks + * waiting for the tool sub-tasks it dispatches, which run on the normal (throttled) compute + * executor. If agents were throttled by the same cpu/capacity budget as those sub-tasks, a set of + * blocked agents would hold every slot their own sub-tasks need and the run would deadlock. + * + * So this monitor does NOT resource-throttle: no cpu/memory accounting (unlike + * {@link LocalPollingMonitor}) and no queue-capacity cap. Agents are admitted as soon as they are + * ready; real compute stays bounded by its own executor's monitor. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class AgentPollingMonitor extends TaskPollingMonitor { + + protected AgentPollingMonitor(Map params) { + super(params) + } + + static AgentPollingMonitor create(Session session, ExecutorConfig config, String name) { + assert session + assert config + assert name + final pollInterval = config.getPollInterval(name, Duration.of('100ms')) + final dumpInterval = config.getMonitorDumpInterval(name) + log.debug "Creating agent task monitor for executor '$name' > unbounded (no cpu/capacity throttle); pollInterval: $pollInterval" + // capacity omitted => 0 => no queue-capacity cap (see TaskPollingMonitor.canSubmit) + new AgentPollingMonitor(name: name, session: session, config: config, pollInterval: pollInterval, dumpInterval: dumpInterval) + } + + /** + * Admit an agent task as soon as it is ready — no cpu/capacity throttling. maxForks + * ({@code canForkProcess}) is still honoured so an explicit user cap on the agent still applies. + */ + @Override + protected boolean canSubmit(TaskHandler handler) { + handler.canForkProcess() && handler.isReady() + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy index b6bc4d5daa..80643cb517 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy @@ -23,6 +23,7 @@ import java.nio.file.Path import groovy.transform.CompileStatic import groovy.transform.Memoized import groovy.util.logging.Slf4j +import nextflow.agent.AgentTaskScript import nextflow.exception.FailedGuardException import nextflow.exception.ProcessEvalException import nextflow.exception.ShowOnlyExceptionMessage @@ -110,8 +111,24 @@ class TaskErrorFormatter { // task with `script:` block if( task.script ) { // -- print the executed command + // + // Redacted the same way TaskRun.getTraceScript() redacts the trace record, and for the + // same reason: an agent task's script is the RPC proxy launch command, so it carries the + // invocation's capability token on argv, and this report does not stay local. It is + // logged (TaskProcessor.formatTaskError -> log.error), so it reaches .nextflow.log, and + // it becomes session.fault.report -> WorkflowMetadata.errorReport, which nf-tower POSTs + // to Seqera Platform on completion. A failing agent task is the COMMON case for this + // path -- an unpullable image, an unroutable agent.rpc.remoteHost, a proxy startup + // timeout -- so leaving it verbatim would publish a live credential exactly when things + // go wrong. + // + // forTrace(config, script) rather than task.getTraceScript(): the latter substitutes the + // template source when there is one, and this block deliberately prints the EXECUTED + // command (labelled with the template name). forTrace returns every non-agent script + // unchanged, so template and ordinary tasks are byte-identical to before. + final script = AgentTaskScript.forTrace(task.config, task.script) message << "Command executed${task.template ? " [$task.template]": ''}:\n".toString() - for( final line : task.script.stripIndent(true).trim().readLines() ) + for( final line : script.stripIndent(true).trim().readLines() ) message << " ${line}".toString() // -- the exit status diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy index 0af682b7a2..b2f109f9c8 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy @@ -584,7 +584,7 @@ class TaskProcessor { } private start(DataflowProcessor op) { - session.addIgniter { + session.addProcessorIgniter { log.debug "Starting process > $name" op.start() } diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy index c5f9c3731b..8fe7c0b24c 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy @@ -26,6 +26,7 @@ import com.google.common.hash.HashCode import groovy.transform.Memoized import groovy.util.logging.Slf4j import nextflow.Session +import nextflow.agent.AgentTaskScript import nextflow.conda.CondaCache import nextflow.conda.CondaConfig import nextflow.container.ContainerConfig @@ -417,10 +418,25 @@ class TaskRun implements Cloneable { } } + /** + * The script as RECORDED, which is not always the script as executed. The value lands in + * {@link nextflow.trace.TraceRecord#script}, i.e. in the resume cache database and in whatever + * a trace observer forwards -- {@code nf-tower} POSTs it to Seqera Platform. + * + *

For every ordinary task this is exactly {@link #getScript()} (or the template source), + * unchanged. The single exception is an AGENT task, whose script is the RPC proxy launch + * command and therefore carries the invocation's capability token on argv -- a bearer + * credential for the provider API key the driver sends on the start frame. That token is + * work-directory-local by design and must not be persisted or transmitted, so it is redacted + * here; {@link nextflow.lineage.LinObserver} omits {@code task.script} from the lineage record + * for the same reason. {@link nextflow.processor.TaskBean}, which writes {@code .command.sh}, + * keeps reading {@link #getScript()} and so keeps the real token. + */ String getTraceScript() { - return template!=null && body?.source + final text = template!=null && body?.source ? body.source : getScript() + return AgentTaskScript.forTrace(config, text) } boolean hasTypedInputsOutputs() { @@ -437,9 +453,9 @@ class TaskRun implements Cloneable { // An `exec` task is the only kind whose context must be persisted: the process body *is* // the task execution, therefore whatever it computed lives only in `task.context` and - // cannot be re-derived on a cache hit. A script task instead re-evaluates its body -- and - // therefore rebuilds its context -- in TaskProcessor.invokeTask, before the cache is - // consulted. + // cannot be re-derived on a cache hit (this is the in-JVM `agent` body too). A script task + // instead re-evaluates its body -- and therefore rebuilds its context -- in + // TaskProcessor.invokeTask, before the cache is consulted. if( type == ScriptType.GROOVY ) return true diff --git a/modules/nextflow/src/main/groovy/nextflow/script/AgentBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/script/AgentBuilder.groovy new file mode 100644 index 0000000000..9edae2ef1e --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/script/AgentBuilder.groovy @@ -0,0 +1,146 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script + +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j +import nextflow.script.params.v2.ProcessFileInput +import nextflow.script.params.v2.ProcessFileOutput + +/** + * Runtime delegate that captures an agent body when the lowered agent closure + * runs. Directives are captured via {@link #methodMissing} against a known set; + * inputs/outputs via {@code _input_}/{@code _output_}; the implicit file + * stagers/unstagers the compiler infers from the declared types via + * {@code stageAs}/{@code _unstage_files}; the prompt via {@link #withPrompt}. + * {@link #build} produces the populated {@link AgentDef}. + * + * The staging methods record the SAME value types {@code ProcessDslV2} records, + * because an agent's typed I/O is a process's typed I/O. + * + * Distinct from {@code nextflow.script.dsl.AgentDsl} (the compile-time + * resolution scope). + */ +@Slf4j +@CompileStatic +class AgentBuilder { + + static final List DIRECTIVES = ['model', 'instruction', 'goal', 'tools', 'skills', 'maxIterations', 'label'] + + private BaseScript ownerScript + private String agentName + + private final Map directives = new LinkedHashMap<>() + private final List inputs = new ArrayList<>() + private final List outputs = new ArrayList<>() + private final List fileInputs = new ArrayList<>() + private final Map fileOutputs = new LinkedHashMap<>() + private PromptDef prompt + + AgentBuilder(BaseScript ownerScript, String agentName) { + this.ownerScript = ownerScript + this.agentName = agentName + } + + protected void checkName(String name) { + if( !DIRECTIVES.contains(name) ) + throw new IllegalArgumentException("Unknown agent directive `${name}`") + } + + // NOTE: replace with internal DSL -- as done for inputs/outputs and config options + // -- once the v1 parser is removed. The agent primitive is only supported by the v2 + // parser, so this dynamic dispatch is provisional and carries no v1 requirement. + @PackageScope + Object methodMissing(String name, Object args) { + checkName(name) + final values = args instanceof Object[] ? (args as List) : [args] + // `label` is repeatable, exactly as for a process: accumulate instead of overwriting + if( name == 'label' ) { + final labels = (List) directives.computeIfAbsent('label', { k -> new ArrayList() }) + labels.addAll(values) + } + else + directives.put(name, values.size() == 1 ? values[0] : values) + return null + } + + void _input_(String name, Class type, boolean optional = false) { + inputs.add(new AgentInput(name, type, optional)) + } + + /** A null `value` is a bare output the model answers; an explicit RHS is the value itself. */ + void _output_(String name, Class type, Closure value = null) { + outputs.add(new AgentOutput(name, type, value)) + } + + /** + * Declare a file (or collection of files) to be staged into the task directory. + * Generated by the compiler for every Path-typed input, exactly as for a process. + */ + void stageAs(Object value) { + fileInputs.add(new ProcessFileInput(null, value)) + } + + void stageAs(Object value, Object filePattern) { + fileInputs.add(new ProcessFileInput(filePattern, value)) + } + + /** + * Declare a file (or collection of files) to be unstaged from the task directory. + * Generated by the compiler for a `file(...)`/`files(...)` call in an output expression. + */ + void _unstage_files(String key, Object pattern) { + fileOutputs.put(key, new ProcessFileOutput(pattern)) + } + + AgentBuilder withPrompt(PromptDef prompt) { + this.prompt = prompt + return this + } + + AgentDef build() { + if( prompt == null ) + throw new IllegalStateException("Missing prompt in agent `${agentName}` definition") + return new AgentDef(ownerScript, agentName, directives, inputs, outputs, prompt, fileInputs, fileOutputs) + } + + @CompileStatic + static class AgentInput { + final String name + final Class type + /** Declared with a trailing `?`; a null value is then admitted and stages nothing. */ + final boolean optional + AgentInput(String name, Class type, boolean optional = false) { + this.name = name; this.type = type; this.optional = optional + } + } + + @CompileStatic + static class AgentOutput { + final String name + final Class type + /** + * The declared right-hand side, or null for a bare output. A non-null value takes the + * output OUT of the model-answered set: its value is the expression, exactly as for a + * process output. + */ + final Closure value + AgentOutput(String name, Class type, Closure value = null) { + this.name = name; this.type = type; this.value = value + } + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/script/AgentDef.groovy b/modules/nextflow/src/main/groovy/nextflow/script/AgentDef.groovy new file mode 100644 index 0000000000..bb601cf0e5 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/script/AgentDef.groovy @@ -0,0 +1,1371 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script + +import java.nio.file.Path +import java.util.regex.Pattern + +import groovy.json.JsonOutput +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import groovyx.gpars.dataflow.DataflowBroadcast +import groovyx.gpars.dataflow.DataflowReadChannel +import groovyx.gpars.dataflow.DataflowWriteChannel + +import nextflow.Global +import nextflow.Session +import nextflow.agent.AgentCallInfo +import nextflow.agent.AgentConfig +import nextflow.agent.AgentLaunchConditions +import nextflow.agent.AgentLaunchSpec +import nextflow.agent.AgentOutputMode +import nextflow.agent.AgentOutputPlan +import nextflow.agent.rpc.AgentRpcHost +import nextflow.agent.rpc.AgentRpcRegistration +import nextflow.agent.AgentRunner +import nextflow.agent.AgentRunnerProvider +import nextflow.agent.AgentRunnerRequest +import nextflow.agent.AgentTaskInfo +import nextflow.agent.DispatchContext +import nextflow.agent.ModuleToolBridge +import nextflow.agent.ModuleToolResolver +import nextflow.agent.RecordSchema +import nextflow.agent.SkillDescriptor +import nextflow.agent.SkillResolver +import nextflow.agent.SkillResource +import nextflow.agent.ToolDescriptor +import nextflow.agent.ToolDispatcher +import nextflow.agent.ToolRefResolver +import nextflow.agent.ToolSchema +import nextflow.exception.ScriptRuntimeException +import nextflow.extension.FilesEx +import nextflow.plugin.Plugins +import nextflow.processor.TaskProcessor +import nextflow.processor.TaskConfig +import nextflow.util.CacheHelper +import nextflow.extension.CH +import nextflow.extension.DataflowHelper +import nextflow.processor.TaskPath +import nextflow.script.AgentBuilder.AgentInput +import nextflow.script.AgentBuilder.AgentOutput +import nextflow.script.dsl.ProcessConfigBuilder +import nextflow.script.params.v2.ProcessFileInput +import nextflow.script.params.v2.ProcessFileOutput +import nextflow.script.types.Record +import org.pf4j.PluginWrapper + +/** + * Runtime model for an agent definition. Holds the captured directives, + * inputs, outputs and prompt. {@link #run} executes the agent as a dataflow + * operator, rendering the prompt per input record and delegating the LLM work + * to an {@link nextflow.agent.AgentRunner} resolved from the active plugins + * (e.g. nf-agent). + */ +@Slf4j +@CompileStatic +class AgentDef extends BindableDef implements ChainableDef { + + static final String TYPE = 'agent' + + /** The runner named in the error raised when {@code shell:} is declared against a runner + * that cannot serve it. The family needs the runner's own container boundary, which only a + * canonical launch-spec runner provides. */ + private static final String SHELL_FAMILY_RUNNER = 'pi' + + /** + * What a wire name — {@code ToolDescriptor.name}, i.e. the name the LLM sees — must match: + * the OpenAI function-name charset (§4). Deliberately checked rather than sanitized, because + * rewriting `my$proc` to `my_proc` would silently merge it with a process already called + * that; see {@link #checkWireNames}. + */ + private static final Pattern WIRE_NAME = ~/[a-zA-Z0-9_-]{1,64}/ + + /** Max length of a wire name, restated so the error can quote the number it enforces. */ + private static final int WIRE_NAME_MAX = 64 + + /** Tool the runner injects when the agent declares `skills` (both runners). */ + private static final String SKILL_ACTIVATE_TOOL = 'activate_skill' + + /** Its companion, injected under the same condition. */ + private static final String SKILL_RESOURCE_TOOL = 'read_skill_resource' + + /** Tool the canonical runner injects when the agent declares a structured output. */ + private static final String FINAL_ANSWER_TOOL = 'final_answer' + + /** Iteration cap applied when neither the agent nor the `agent` scope declares one. Restated + * in {@link nextflow.agent.AgentConfig}'s `maxIterations` description as the user-facing default. */ + private static final int DEFAULT_MAX_ITERATIONS = 20 + + /** Per-request timeout, in seconds, applied when the `agent` scope declares none. Restated in + * {@link nextflow.agent.AgentConfig}'s `requestTimeout` description as the user-facing default. */ + private static final long DEFAULT_REQUEST_TIMEOUT_SECONDS = 120L + + private BaseScript owner + private String name + private String simpleName + /** + * The name the agent was declared with in its own script or module. Unlike {@link #name} + * and {@link #simpleName} it is stable and cannot be changed by aliasing the include, so + * a `withName:''` config selector matches an aliased agent — mirroring + * {@link ProcessDef#baseName}. + */ + private String baseName + private Map directives + private List inputs + private List outputs + private PromptDef prompt + /** + * The implicit file stagers the compiler inferred from the declared input types, replayed + * into every invocation's {@link ProcessConfigV2}. A {@link ProcessFileInput} is stateless + * under {@code resolve} (it clones the closure per task), so holding one here has exactly + * the lifetime a process's has. + */ + private List fileInputs + /** The unstagers inferred from `file(...)`/`files(...)` in the output expressions. */ + private Map fileOutputs + + /** Immutable result of lowering an agent before the processor is started. */ + private static class BuiltAgentTask { + final TaskProcessor processor + final ModuleToolBridge bridge + + BuiltAgentTask(TaskProcessor processor, ModuleToolBridge bridge) { + this.processor = processor + this.bridge = bridge + } + } + + /** The runner picked for an agent, with the two facts read off it once. */ + private static class SelectedRunner { + final AgentRunner runner + /** Its stable user-facing identifier, which is part of the cache identity. */ + final String name + /** Its canonical launch description, or {@code null} for a legacy in-JVM runner. */ + final AgentLaunchSpec launchSpec + + SelectedRunner(AgentRunner runner, String name, AgentLaunchSpec launchSpec) { + this.runner = runner + this.name = name + this.launchSpec = launchSpec + } + } + + /** The agent's process config, with the executor it was resolved to. */ + private static class ResolvedProcessConfig { + final ProcessConfigV2 config + /** + * The RESOLVED executor, read off the config ONCE. It travels beside the config rather than + * being re-read downstream so the admission check ({@link AgentDef#buildProcessConfig}, which + * decides whether this runner may be offloaded at all) and the launch check + * ({@link AgentDef#resolveLaunch}) cannot come to disagree about which executor was admitted + * -- which is what a step inserted between them that touched `executor` would otherwise do. + */ + final String executor + + ResolvedProcessConfig(ProcessConfigV2 config, String executor) { + this.config = config + this.executor = executor + } + } + + /** The resolved tool selection, and everything lowered from it before ignition. */ + private static class ResolvedTools { + /** The declared `tools` refs expanded into the two halves of the §5 partition. */ + final ToolRefResolver.Selection selection + /** The brokered tools' dataflow request gateway; {@code null} when nothing is brokered. */ + final ModuleToolBridge bridge + /** The BROKERED half of the partition, as descriptors. */ + final List toolSpecs + /** The RUNNER-NATIVE half of the partition, as bare wire names. */ + final List nativeToolNames + /** Whether the body must build a per-task sandbox context for the in-JVM `fs:` tools. */ + final boolean needsSandbox + + ResolvedTools(ToolRefResolver.Selection selection, ModuleToolBridge bridge, + List toolSpecs, List nativeToolNames, boolean needsSandbox) { + this.selection = selection + this.bridge = bridge + this.toolSpecs = toolSpecs + this.nativeToolNames = nativeToolNames + this.needsSandbox = needsSandbox + } + } + + /** Effective, immutable values shared by the canonical and in-JVM request paths. */ + private static class ResolvedAgentSettings { + final String model + final String instruction + final String goal + final int maxIterations + final int requestTimeoutSeconds + final boolean trace + final String agentName + final List tools + final List toolSpecs + /** The RUNNER-NATIVE half of the resolved selection (§5): the bare wire names of the + * {@code fs:}/{@code shell:} leaves, travelling BESIDE {@code toolSpecs} and never inside + * it. A containerized runner enables its own builtins from these; an in-JVM runner turns + * them into descriptors itself and dispatches them back through the bridge. */ + final List nativeToolNames + final List skills + final Map outputSchema + /** LLM provider credential resolved by the core ladder; NEVER folded into the cache + * key and never written to the canonical source or the task info. */ + final String apiKey + /** OpenAI-compatible endpoint resolved by the core ladder; part of the agent's + * identity, so it DOES enter the cache key. */ + final String baseUrl + /** Provider namespace the pair above was resolved from; carried so a runner can name the + * variables the ladder actually consulted when it has to report a missing credential. */ + final String apiProvider + /** Whether a provider credential resolved and was withheld by the endpoint gate -- NOT the + * same as none resolving; see {@link nextflow.agent.AgentConfig#credentialWithheldFor}. */ + final boolean credentialWithheld + /** The address THIS agent's task dials the driver's broker on, resolved by the pre-ignition + * guard. Null on the in-JVM path, which dials nothing. */ + final AgentRpcHost brokerHost + + ResolvedAgentSettings(String model, String instruction, String goal, int maxIterations, + int requestTimeoutSeconds, boolean trace, String agentName, List tools, + List toolSpecs, List nativeToolNames, + List skills, Map outputSchema, + String apiKey, String baseUrl, String apiProvider, boolean credentialWithheld, + AgentRpcHost brokerHost = null) { + this.model = model + this.instruction = instruction + this.goal = goal + this.maxIterations = maxIterations + this.requestTimeoutSeconds = requestTimeoutSeconds + this.trace = trace + this.agentName = agentName + this.tools = tools + this.toolSpecs = toolSpecs + this.nativeToolNames = nativeToolNames + this.skills = skills + this.outputSchema = outputSchema + this.apiKey = apiKey + this.baseUrl = baseUrl + this.apiProvider = apiProvider + this.credentialWithheld = credentialWithheld + this.brokerHost = brokerHost + } + + AgentRunnerRequest createRequest(String prompt, String inputJson, ToolDispatcher dispatch, String workDir) { + return new AgentRunnerRequest( + model: model, + instruction: instruction, + prompt: prompt, + maxIterations: maxIterations, + outputSchema: outputSchema, + inputJson: inputJson, + requestTimeoutSeconds: requestTimeoutSeconds, + goal: goal, + agentName: agentName, + trace: trace, + tools: tools, + toolSpecs: toolSpecs, + // §5: the two halves of the selection are PARTITIONED across two fields, and the + // partition is what keeps a runner-native tool out of the broker's allowlist + nativeToolNames: nativeToolNames, + dispatch: dispatch, + skills: skills, + // Reasoning models can reject an explicit temperature. Resume remains input-keyed. + temperature: null, + workDir: workDir, + apiKey: apiKey, + baseUrl: baseUrl, + apiProvider: apiProvider, + credentialWithheld: credentialWithheld, + // each agent definition dials the address the guard resolved for IT, so a run that + // mixes a local-docker agent with, say, a k8s one advertises each task its own + brokerHost: brokerHost) + } + } + + AgentDef(BaseScript owner, String name, Map directives, List inputs, + List outputs, PromptDef prompt, + List fileInputs = Collections.emptyList(), + Map fileOutputs = Collections.emptyMap()) { + this.owner = owner + this.name = name + this.simpleName = name + this.baseName = name + this.directives = directives + this.inputs = inputs + this.outputs = outputs + this.prompt = prompt + this.fileInputs = fileInputs + this.fileOutputs = fileOutputs + } + + @Override String getType() { TYPE } + @Override String getName() { name } + String getSimpleName() { simpleName } + String getBaseName() { baseName } + BaseScript getOwner() { owner } + + String getModel() { directives.get('model') as String } + String getInstruction() { directives.get('instruction') as String } + String getGoal() { directives.get('goal') as String } + private List directiveList(String key) { + final value = directives.get(key) + return value == null ? [] : (value instanceof List ? (List) value : [value]) + } + List getTools() { directiveList('tools') } + List getSkills() { directiveList('skills') } + /** The agent's declared `label` values, matched by `agent { withLabel: ... }` selectors. */ + List getLabels() { directiveList('label') } + Integer getMaxIterations() { directives.get('maxIterations') as Integer } + List getInputs() { inputs } + List getOutputs() { outputs } + PromptDef getPrompt() { prompt } + + @Override + ComponentDef cloneWithName(String name) { + // register the alias and the workflow-scoped name so an `agent` scope `withName:` selector + // targeting them is not reported as unmatched by Session#checkConfig (mirrors ProcessDef); + // the agent name set is kept separate because a `process` selector never matches an agent + ScriptMeta.addResolvedAgentName(name) + def copy = (AgentDef) this.clone() + copy.@name = name + copy.@simpleName = ProcessDef.stripScope(name) + // NOTE: the shallow clone deliberately preserves `owner` (so module-local skills and + // relative tool paths keep resolving from the DEFINING module dir under an alias) and + // `baseName` (so declared-name config selectors keep matching) + return copy + } + + /** + * The raw {@code agent} config scope of the live {@link nextflow.Session}, i.e. both the + * agent-only options and the task directives, plus any `withName:`/`withLabel:` selector + * blocks. Empty when there is no active session or the scope is not defined. + */ + private Map agentScope() { + final session = Global.session as Session + return (session?.config?.get('agent') as Map) ?: Collections.emptyMap() + } + + /** + * Resolve the AGENT-ONLY options of the {@code agent} config scope for THIS agent, applying + * the same selector ladder as the task directives (see {@link AgentConfig#resolveOptions}). + * Returns an empty (all-null) {@link AgentConfig} when there is no active session or the + * scope is not defined, so callers can always read defaults. + */ + protected AgentConfig agentConfig() { + return new AgentConfig(AgentConfig.resolveOptions(agentScope(), getLabels() as List, baseName, simpleName, name)) + } + + private DataflowReadChannel createSourceChannel(Object value) { + if( value instanceof DataflowReadChannel || value instanceof DataflowBroadcast ) + return CH.getReadChannel(value) + final result = CH.value() + result.bind(value) + return result + } + + /** + * Single lowering path (design §4.9, M-Tools): ALL agents — free-text, structured, + * skills-only AND tool agents — lower to a real {@link TaskProcessor}/{@link TaskRun} + * on the task path ({@link #runAsTask}) so every agent inherits work dir, + * parallelism, progress table and lineage natively. A tool agent's shared + * {@link ModuleToolBridge} gateway is created in {@link #buildAgentTask} (before ignition) + * and invoked from the task-body thread; the legacy serial GPars operator path has + * been removed. + */ + @Override + Object run(Object[] args0) { + final args = ChannelOut.spread(args0) + return runAsTask(args) + } + + /** + * Task path (design §4.1/§4.5, M-Tools): lower ANY agent — free-text, structured, + * skills-only or tool — to a {@link ProcessConfigV2} + GROOVY ({@code exec}) {@link BodyDef} + * and drive it through the unmodified {@link TaskProcessor} pipeline, mirroring + * {@link ProcessDef#runV2}. The LLM call is the task body and stays in-JVM behind the + * {@link AgentRunner} SPI; a tool agent's shared {@link ModuleToolBridge} is invoked from + * that same task-body thread. Supports multiple structured inputs and multiple named + * structured outputs; a queue input runs the agent per-item (map) while a value/singleton + * input runs it once (fan-in). + */ + private Object runAsTask(List args) { + final built = buildAgentTaskWithBridge(args) + final processor = built.processor + final bridge = built.bridge + // start the processor (progress table, lineage, events, work dir all for free) + processor.run() + final channels = outputChannels(processor) + // A tool agent's request gateway consumes a persistent queue that stays alive until + // poisoned; if never closed, session.await() hangs. Subscribe to the agent's + // (first) output channel — captured pre-ignition so it sees the terminal poison — + // and close the shared bridge once the agent's map completes (poisoning the request + // queue so its operator terminates). On abort the session terminates all operators + // directly (and afterStop still fires here), so close() is reached on both paths. + if( bridge != null ) { + final firstOut = channels.values().iterator().next() + DataflowHelper.subscribeImpl(CH.getReadChannel(firstOut), [onComplete: { bridge.close() }]) + } + return new ChannelOut(channels) + } + + /** + * The processor's declared output channels, keyed by param name, in declaration order. + * + *

The one dynamic hop in {@link #runAsTask}, isolated here so the rest of that method stays + * statically checked: {@code TaskProcessor.getConfig()} is declared as the V1 + * {@link ProcessConfig}, which has no typed output-param accessor, and an agent's config is + * always a {@link ProcessConfigV2}. + */ + @CompileDynamic + private static Map outputChannels(TaskProcessor processor) { + final channels = new LinkedHashMap() + for( final param : processor.getConfig().getOutputs().getParams() ) + channels.put(param.getName(), param.getChannel()) + return channels + } + + /** Render the prompt closure against the task context (delegate-first). */ + private static String renderPrompt(PromptDef promptDef, Object ctx) { + final Closure pc = (Closure) promptDef.closure.clone() + pc.setDelegate(ctx) + pc.setResolveStrategy(Closure.DELEGATE_FIRST) + return pc.call()?.toString() + } + + /** Bare single value for 1 input (byte-identical to the legacy path); {name:value} for N>1. */ + private static String buildInputJson(List ins, Map ctx) { + return ins.size() == 1 + ? toJson(ctx.get(ins[0].name)) + : toJson(ins.collectEntries { [(it.name): ctx.get(it.name)] }) + } + + /** + * @param outputs the MODEL-ANSWERED outputs only (see the {@code modelOuts} partition in + * {@link #buildAgentTaskWithBridge}); an output with an explicit right-hand side is + * not part of the contract the model is given. + */ + private static AgentOutputPlan resolveOutputPlan(String agentName, List outputs, List tools) { + // an agent whose every output is a work-dir collection asks the model for nothing: its + // observable result is the files it wrote, and its final text is discarded + if( !outputs ) + return new AgentOutputPlan(AgentOutputMode.TEXT, null) + if( outputs.size() > 1 ) + return new AgentOutputPlan(AgentOutputMode.WRAPPED, buildWrapperSchema(agentName, outputs)) + final output = outputs[0] + if( Record.isAssignableFrom(output.type as Class) ) + return new AgentOutputPlan(AgentOutputMode.RECORD, RecordSchema.of(output.type as Class)) + if( tools ) + return new AgentOutputPlan(AgentOutputMode.SCALAR_CONTRACT, scalarOutputSchema(output)) + return new AgentOutputPlan(AgentOutputMode.TEXT, null) + } + + @CompileDynamic + private static Closure createCanonicalBody(PromptDef promptDef, List inputs, + ResolvedAgentSettings settings, AgentLaunchSpec launchSpec, + AgentRunner runner, ModuleToolBridge bridge, boolean needsSandbox) { + final String runnerName = runner.getName() + return { -> + final ctx = getDelegate() + final String promptText = AgentDef.renderPrompt(promptDef, ctx) + final String inputJson = AgentDef.buildInputJson(inputs, ctx) + final TaskConfig taskConfig = ctx.get('task') as TaskConfig + final Path taskWorkDir = taskConfig?.workDir as Path + final DispatchContext dispatchContext = needsSandbox ? new DispatchContext(taskWorkDir) : null + final ToolDispatcher contextualDispatch = bridge == null ? null : ({ String toolName, String argsJson -> + if( dispatchContext != null ) + ModuleToolBridge.setContext(dispatchContext) + try { + return bridge.call(toolName, argsJson) + } + finally { + if( dispatchContext != null ) + ModuleToolBridge.clearContext() + } + } as ToolDispatcher) + final request = settings.createRequest(promptText, inputJson, contextualDispatch, '.') + // A canonical launch command is made of paths that exist ONLY inside the runner image, + // so the task always runs in a container (validated pre-ignition by + // AgentLaunchConditions.requireContainerized) and the broker endpoint is therefore ALWAYS dialled from + // outside the driver's network namespace -- hence remote=true unconditionally. + // The image is re-checked HERE because `container` may be a lazy value (a closure or a + // GString) that is truthy pre-ignition yet resolves to null per task. + AgentLaunchConditions.requireTaskContainer(settings.agentName, runnerName, taskConfig?.getContainer()) + final AgentRpcRegistration registration = runner.register(request, true) + return launchSpec.shellCommand([ + '--endpoint', registration.endpoint, + '--invocation', registration.invocationId, + *registration.transportArgs(), + '--token', registration.token ]) + } + } + + @CompileDynamic + private static Closure createInJvmBody(PromptDef promptDef, List inputs, + List outputs, ResolvedAgentSettings settings, AgentRunner runner, + ModuleToolBridge bridge, boolean needsSandbox, ProcessConfigV2 config, + AgentOutputPlan outputPlan) { + return { -> + final ctx = getDelegate() + final String promptText = AgentDef.renderPrompt(promptDef, ctx) + final String inputJson = AgentDef.buildInputJson(inputs, ctx) + final String workDir = (ctx.get('task')?.workDir as Path)?.toString() + final request = settings.createRequest(promptText, inputJson, bridge as ToolDispatcher, workDir) + // Clear stale snapshots before invoking a runner on this pooled task thread. + AgentCallInfo.clear() + if( needsSandbox ) + ModuleToolBridge.setContext(AgentDef.createSandboxContext(inputs, ctx)) + final Object result + try { + result = runner.run(request) + } + catch( Throwable error ) { + // Consume a fatal tool abort's interrupt so it cannot leak to the next pooled task. + Thread.interrupted() + throw error + } + finally { + if( needsSandbox ) + ModuleToolBridge.clearContext() + } + final resolvedModel = AgentCallInfo.consumeResolvedModel() + if( config.isCacheable() && resolvedModel != null ) + ctx.put('$agentResolvedModel', resolvedModel) + outputPlan.bind(ctx, result, outputs) + return null + } + } + + /** + * The sandbox context for an in-JVM agent: the task work dir, plus the SOURCE of every input + * that was staged into it. + * + *

Staging materializes an input as a symlink in the work dir ({@link + * nextflow.executor.local.AgentTaskHandler}), while {@link nextflow.agent.SandboxGuard} + * resolves symlinks before testing containment — so without this the agent would be handed a + * name the {@code fs:} tools then refuse as "path outside sandbox", and `ls` would report it + * as an opaque link. Whitelisting the staged source is the in-JVM analogue of the read-only + * bind mount a canonical (containerized) agent gets for the same input: a readable entry is a + * path-prefix test, so a file entry admits exactly that file and nothing beside it. + */ + @CompileDynamic + private static DispatchContext createSandboxContext(List inputs, Object ctx) { + final sandbox = new DispatchContext(ctx.get('task')?.workDir as Path) + for( final inp : inputs ) + addStagedSources(sandbox, ctx.get(inp.name)) + return sandbox + } + + /** + * Walk an input value for staged paths. The shapes are exactly the ones + * {@code TaskInputResolver.normalizeValue} produces: a bare {@link TaskPath}, a collection of + * them, or a record (a {@code RecordMap} at runtime) holding them. + */ + private static void addStagedSources(DispatchContext sandbox, Object value) { + // toRealPath() on a TaskPath is a pure accessor for the store path -- no file I/O + if( value instanceof TaskPath ) + sandbox.addReadablePath(value.toRealPath()) + else if( value instanceof Map ) + ((Map) value).values().each { addStagedSources(sandbox, it) } + else if( value instanceof Collection ) + ((Collection) value).each { addStagedSources(sandbox, it) } + } + + /** + * Build (but do NOT run) the agent {@link TaskProcessor}: synthesize the + * {@link ProcessConfigV2}, the canonical GROOVY {@link BodyDef}, create the tool bridge + * (for a tool agent), wire the input/output channels and create the processor via + * {@link ProcessDef#createTaskProcessorResolved}. Extracted from {@link #runAsTask} so resume tests + * can inspect the built artifacts (canonical {@code BodyDef.source}, folded prompt + * {@code valRefs}) and drive {@code checkCachedOutput} without igniting the dataflow network. + */ + protected TaskProcessor buildAgentTask(List args) { + return buildAgentTaskWithBridge(args).processor + } + + private BuiltAgentTask buildAgentTaskWithBridge(List args) { + requireInvocable(args) + + // -- resolve effective directives (identical to the legacy path) + final agentConfig = agentConfig() + final SelectedRunner selected = resolveRunner(agentConfig) + final AgentLaunchSpec launchSpec = selected.launchSpec + + // -- build the V2 process config and resolve it from the `agent` scope BEFORE any + // skill/module resolution, so a misconfiguration fails fast (no git clone, no + // registry download, no gateway operator). + final resolvedConfig = buildProcessConfig(selected) + final config = resolvedConfig.config + // the address THIS definition's task dials the driver's broker on; it travels on the + // request so the broker advertises the address resolved for this agent rather than one + // resolved for whichever definition happened to be built first + final AgentRpcHost brokerHost = resolveLaunch(resolvedConfig, agentConfig, selected) + + final promptDef = this.prompt + final List agentTools = this.tools + // -- the output partition (two independent facts, not one rule): + // an output with an explicit right-hand side takes its value FROM that expression, so + // the model is neither asked for it (no schema entry) nor allowed to bind it; a + // `file(...)`/`files(...)` call inside that expression ADDITIONALLY registered an + // unstager, which is what makes it a work-dir collection + final List modelOuts = outputs.findAll { it.value == null } + final AgentOutputPlan outputPlan = resolveOutputPlan(name, modelOuts, agentTools) + + // -- capture read-only locals for the body closure (resolve lexically under + // DELEGATE_ONLY; do NOT reference `this.name`/`this.inputs`/etc. in the body) + final String agentName = this.name + final List ins = this.inputs + final List outs = this.outputs + final Session session = Global.session as Session + // resolve declared skills ONCE, pre-ignition (portable descriptors; no dataflow + // coupling). Null when no skills are declared, so a tool-free/skill-free agent + // carries a null `skills` on the request exactly as before. + final List skillDescriptors = resolveSkills() + + final ResolvedTools resolvedTools = resolveTools(agentConfig, launchSpec, agentName, outputPlan, skillDescriptors) + final settings = resolveSettings(agentConfig, agentName, agentTools, resolvedTools, skillDescriptors, + outputPlan, brokerHost) + + declareParams(config, ins, outs, launchSpec, outputPlan) + + final body = createBody(promptDef, ins, modelOuts, settings, config, outputPlan, skillDescriptors, + selected, resolvedTools, agentConfig) + + attachTaskInfo(config, selected, settings, promptDef, resolvedTools, skillDescriptors) + + wireChannels(config, args) + + applyExecutionPolicy(config, launchSpec) + + // build the processor (progress table, lineage, events, work dir all for free); + // The config is fully resolved from the `agent` scope above. Use the canonical + // processor/executor pipeline without applying the unrelated `process` scope. + final processor = ProcessDef.createTaskProcessorResolved(session, owner, name, config, body) + return new BuiltAgentTask(processor, resolvedTools.bridge) + } + + /** Arity + zero-output guards (generalized; mirror ProcessDef.runV2). */ + private void requireInvocable(List args) { + if( args.size() != inputs.size() ) + throw new ScriptRuntimeException("Agent `${name}` expects ${inputs.size()} input channel(s) but received ${args.size()}") + if( !outputs ) + throw new ScriptRuntimeException("Agent `${name}` must declare exactly one output - zero outputs are not yet supported") + } + + /** + * Resolve the concrete runner before constructing the task. This makes the + * selected implementation part of the cache identity even when the user + * relies on the backwards-compatible single-runner default. + */ + private static SelectedRunner resolveRunner(AgentConfig agentConfig) { + final selectedRunner = AgentRunnerProvider.get(agentConfig.runner) + return new SelectedRunner(selectedRunner, selectedRunner.getName(), selectedRunner.getLaunchSpec()) + } + + /** + * Synthesize the agent's {@link ProcessConfigV2} and resolve it from the `agent` scope. + * Called before any skill or module resolution, so a misconfiguration fails fast. + */ + private ResolvedProcessConfig buildProcessConfig(SelectedRunner selected) { + final config = new ProcessConfigV2(owner, name) + final builder = new ProcessConfigBuilder(config, AgentDef.TYPE, AgentConfig.AGENT_ONLY_OPTIONS) + // declared labels must reach the config BEFORE applyConfig so `withLabel:` can match; + // route through the builder method so the value is a validated, de-duplicated ConfigList + for( final lbl : getLabels() ) + builder.label(lbl as String) + // Agent task placement and resources live in the `agent` scope and use the same + // selector semantics as `process`. The `process` scope is never applied: process and + // agent tasks are configured independently even though both reuse ProcessConfigV2. + builder.applyConfig(agentScope(), baseName, simpleName, name) + if( !config.containsKey('executor') ) + config.put('executor', AgentConfig.DEFAULT_EXECUTOR) + // an agent is admitted without a cpu/capacity throttle, so absent a cap it fans out as wide + // as its input channel -- bound concurrent LLM calls unless the user asked for more + if( !config.containsKey('maxForks') ) + config.put('maxForks', AgentConfig.DEFAULT_MAX_FORKS) + // legacy in-JVM runners cannot be offloaded; read the RESOLVED executor so a + // selector-provided value is rejected loudly instead of silently downgraded below + final resolvedExecutor = config.get('executor')?.toString() + if( selected.launchSpec == null && resolvedExecutor != AgentConfig.DEFAULT_EXECUTOR ) + throw new ScriptRuntimeException("Agent runner `${selected.name}` does not support executor `${resolvedExecutor}`; only canonical launch-spec runners can be offloaded") + return new ResolvedProcessConfig(config, resolvedExecutor) + } + + /** + * Admit a canonical launch and return the broker address this definition's task dials, + * or {@code null} for an in-JVM runner, which dials nothing. + */ + private AgentRpcHost resolveLaunch(ResolvedProcessConfig resolved, AgentConfig agentConfig, SelectedRunner selected) { + if( selected.launchSpec == null ) + return null + final config = resolved.config + // a runner whose runtime lives IN an image knows which image that is -- it generates the + // coordinate from its own VERSION -- so `agent.container` is optional for it. Put the value + // in the config exactly as the user would have, so everything downstream (the guard below, + // the per-task re-check in createCanonicalBody, the container fingerprint TaskHasher adds) + // sees one kind of value and needs to know nothing about the default. This must stay AHEAD + // of every `config.get('container')` read below. + // `containsKey` and NOT truthiness: `agent.container = false` is the documented opt-out + // (see AgentLaunchConditions.hasContainer) and must keep meaning "no container", i.e. must + // keep failing below. + if( !config.containsKey('container') ) { + final runnerImage = selected.runner.getDefaultContainer() + if( runnerImage ) + config.put('container', runnerImage) + } + // the RESOLVED executor, i.e. the same value buildProcessConfig admitted this runner on -- + // carried over from there rather than re-read, see ResolvedProcessConfig#executor + final resolvedExecutor = resolved.executor + // a canonical agent MUST be containerized on every executor: its launch command is built + // from the absolute paths the runner's proxy and harness have INSIDE the runner image, which + // exist nowhere else. Fail here rather than let the task fail with `No such file`. + // `agent.runner` unset means the runner above was picked for the user, so a failure + // naming it has to say where it came from + // the task's `containerOptions` go IN, because the ladder reads them to decide whether + // the container shares the driver's network namespace -- and they must be read BEFORE + // AgentLaunchConditions.withDockerHostGateway appends to them, or that append would be inspected as if the + // user had written it + final launch = AgentLaunchConditions.requireCanonicalLaunch(name, selected.name, resolvedExecutor, + config.get('container'), config.get('containerOptions'), agentConfig.rpc, + Global.session as Session, !agentConfig.runner) + // the task dials the broker back, so it may need a run option to be able to resolve the + // driver's host at all -- see AgentLaunchConditions.withDockerHostGateway. Keyed off the RESOLVED address, not + // the configured one, which is null for every inferred row + final containerOptions = AgentLaunchConditions.withDockerHostGateway(config.get('containerOptions'), + launch.containerEngine, launch.brokerHost?.host) + if( containerOptions != null ) + config.put('containerOptions', containerOptions) + return launch.brokerHost + } + + /** + * Expand the declared `tools` refs and lower them into the artifacts the request and the + * cache key are built from: the bridge, the brokered descriptors, the runner-native names. + */ + private ResolvedTools resolveTools(AgentConfig agentConfig, AgentLaunchSpec launchSpec, String agentName, + AgentOutputPlan outputPlan, List skillDescriptors) { + // -- expand the declared `tools` refs into the resolved selection ONCE: the brokered + // `nf:module_run:X` processes wired into the bridge below, and the runner-native + // `fs:`/`shell:` names the runner serves itself. A canonical launch-spec runner owns a + // container, which is what the `shell:` family requires. + final ToolRefResolver.Selection toolSelection = resolveToolSelection(launchSpec != null) + // -- §4: the model sees ONE flat namespace, so every selected tool must carry a legal wire + // name and no two sources may claim the same one. Checked HERE, before anything is + // wired: this is the only place that can see the whole namespace at once — the declared + // tools AND the names the runner injects for `skills` and structured output. + checkWireNames(agentName, toolSelection, skillDescriptors != null && !skillDescriptors.isEmpty(), + outputPlan.schema != null, launchSpec != null) + // -- create the brokered tools' dataflow request gateway HERE (buildAgentTask runs before + // ignition), so the shared bridge is a captured final local the body closure can invoke + // from the task-body thread. Null for a tool-free/skills-only agent. + final ModuleToolBridge bridge = new ModuleToolResolver(agentName, owner) + .createToolBridge(toolSelection, launchSpec != null) + if( bridge != null ) + bridge.setMaxInlineBytes(agentConfig.maxToolOutputInlineBytes()) + // §5: `toolSpecs` carries the BROKERED half only. The runner-native names travel beside it + // on their own field, so a `fs:`/`shell:` name never enters the broker's allowlist and can + // never be called back into the driver JVM. + final List toolSpecs = bridge?.descriptors() + final List nativeToolNames = toolSelection?.nativeNames ?: null + // the in-JVM fs: tools need a per-task sandbox context (the real task work dir). A + // containerized runner serves them itself, so its bridge has none and needs no context. + final boolean needsSandbox = bridge != null && bridge.filesystemEnabled + return new ResolvedTools(toolSelection, bridge, toolSpecs, nativeToolNames, needsSandbox) + } + + /** The effective, immutable values both request paths are built from. */ + private ResolvedAgentSettings resolveSettings(AgentConfig agentConfig, String agentName, List agentTools, + ResolvedTools resolvedTools, List skillDescriptors, AgentOutputPlan outputPlan, + AgentRpcHost brokerHost) { + // the effective model, resolved BEFORE the settings because the endpoint and the + // credential are scoped to its provider (see below) + final String effectiveModel = this.model ?: agentConfig.model + return new ResolvedAgentSettings( + effectiveModel, + this.instruction, + this.goal, + (this.maxIterations != null + ? this.maxIterations + : (agentConfig.maxIterations != null ? agentConfig.maxIterations : DEFAULT_MAX_ITERATIONS)) as int, + (agentConfig.requestTimeout != null ? agentConfig.requestTimeout.seconds : DEFAULT_REQUEST_TIMEOUT_SECONDS) as int, + agentConfig.traceEnabled(), + agentName, + agentTools, + resolvedTools.toolSpecs, + resolvedTools.nativeToolNames, + skillDescriptors, + outputPlan.schema, + // endpoint and credential are resolved ONCE, in core, by the AgentConfig ladder + // (config, then NXF_AGENT_*, then the provider's own variable) so no runner reads the + // environment. Both are SCOPED to the model's API provider, and the provider tier of + // the credential additionally requires an endpoint that provider owns: a runner + // installs what it is given as the credential of the model's provider, ahead of + // anything it could resolve itself -- see AgentConfig.apiKeyFor. + agentConfig.apiKeyFor(effectiveModel), + agentConfig.baseUrlFor(effectiveModel), + agentConfig.apiProviderFor(effectiveModel), + // "a key resolved but the gate refused to send it" is carried SEPARATELY from the null + // credential above: only that case may be reported as an error by a runner with no + // other source, and neither case may become the no-credential placeholder. + agentConfig.credentialWithheldFor(effectiveModel), + brokerHost) + } + + /** + * Complete the V2 process config (already resolved from the `agent` scope): one ProcessInput + * per AgentInput, one ProcessOutput per AgentOutput with a synthetic lazy value closure that + * reads the context slot the body writes. + */ + @CompileDynamic + private void declareParams(ProcessConfigV2 config, List ins, List outs, + AgentLaunchSpec launchSpec, AgentOutputPlan outputPlan) { + for( final inp : ins ) + config.getInputs().addParam(inp.name, inp.type as Class, inp.optional) + // replay the compiler-inferred stagers: this is what puts the declared Path inputs into + // `task.inputFiles`, hence into the stage-in script AND the container bind mounts + for( final f : fileInputs ) + config.getInputs().addFile(f) + for( final out : outs ) { + final String outName = out.name // capture per-iteration (avoid loop-var capture) + final Class outType = out.type as Class + if( out.value != null ) { + // the compiler's RHS closure -- for a file output `{ _file([:], '$path0') }`, + // which resolves against TaskOutputResolver like any process output would + config.getOutputs().addParam(outName, outType, out.value) + } + else if( launchSpec != null ) + config.getOutputs().addParam(outName, outType, { + outputPlan.decode(stdout(), outName, outType) + }) + else + config.getOutputs().addParam(outName, outType, { getProperty(outName) }) + } + // replay the compiler-inferred unstagers, so `_file`/`_files` can resolve their key + for( final entry : fileOutputs ) + config.getOutputs().addFile(entry.key, entry.value) + // Collected outputs make `storeDir` newly valid for an agent (TaskProcessor.isInvalidStoreDir + // tests the file-output set), and a task with a store dir has its outputs collected FROM it + // -- which only works when something copies them there. That something is the wrapper's + // unstage step, which an in-JVM agent does not have, so the task would fail with a missing + // output on every run. Refuse the combination instead of failing per task. + if( launchSpec == null && fileOutputs && config.get('storeDir') ) + throw new ScriptRuntimeException("Agent `${name}` cannot combine `storeDir` with a collected `path` output on an in-JVM runner - use `publishDir`, or select a containerized runner") + } + + /** The task body closure, wrapped in the {@link BodyDef} that carries the cache identity. */ + private BodyDef createBody(PromptDef promptDef, List ins, List modelOuts, + ResolvedAgentSettings settings, ProcessConfigV2 config, AgentOutputPlan outputPlan, + List skillDescriptors, SelectedRunner selected, ResolvedTools resolvedTools, + AgentConfig agentConfig) { + final AgentLaunchSpec launchSpec = selected.launchSpec + final Closure bodyClosure = launchSpec != null + ? createCanonicalBody(promptDef, ins, settings, launchSpec, selected.runner, resolvedTools.bridge, resolvedTools.needsSandbox) + : createInJvmBody(promptDef, ins, modelOuts, settings, selected.runner, resolvedTools.bridge, resolvedTools.needsSandbox, config, outputPlan) + + // -- §7: a runner-native tool has no descriptor to hash, so the ONLY thing that can stand + // for its behaviour in the cache key is the runner that implements it. Resolved lazily + // (the plugin lookup is skipped entirely for an agent with no native tools), which also + // keeps the fingerprint of a brokered-only agent byte-identical to before. + final List nativeToolRefs = resolvedTools.selection?.nativeRefs + final String runnerIdentity = nativeToolRefs ? runnerIdentity(selected.runner) : null + + // canonical BodyDef.source built from the EFFECTIVE resolved values (design §7.2/D2) + // so a config-default model enters the cache key; fold the prompt's free-variable + // refs into the synthetic BodyDef so params.* prompt-globals also enter the key (D3). + return new BodyDef( + bodyClosure, + canonicalAgentSource(settings.model, settings.maxIterations, outputPlan.schema, skillDescriptors, selected.name, + toolsFingerprint(resolvedTools.toolSpecs, resolvedTools.bridge?.toolSources(), nativeToolRefs, runnerIdentity), + settings.baseUrl, agentConfig.apiProvider), + launchSpec != null ? 'script' : 'exec', + promptDef.valRefs) + } + + /** + * Attach the resolved agent identity to the config so an observer (lineage) can tell an + * agent task from a process task and record what the agent actually was. + */ + private void attachTaskInfo(ProcessConfigV2 config, SelectedRunner selected, ResolvedAgentSettings settings, + PromptDef promptDef, ResolvedTools resolvedTools, List skillDescriptors) { + // Immutable POJO, never a Map/Closure: TaskConfig is a LazyMap and would deep-copy/invoke + // those on every read. Not one of the hashed directive names, so it stays out of the task hash. + final agentInfo = new AgentTaskInfo( + selected.name, + settings.model, + settings.instruction, + settings.goal, + promptDef?.source, + settings.maxIterations, + settings.outputSchema != null ? canonicalJson(settings.outputSchema) : null, + // §7: lineage records the RESOLVED WIRE NAMES, so it must span both halves of the + // partition -- a runner-native tool has no descriptor, and recording only `toolSpecs` + // would silently drop every `fs:`/`shell:` tool from the record + resolvedWireNames(resolvedTools.toolSpecs, resolvedTools.nativeToolNames), + skillDescriptors ? skillDescriptors.collect { it.name } : null ) + config.put(AgentTaskInfo.CONFIG_KEY, agentInfo) + } + + /** Invocation wiring (mirror ProcessDef.runV2). */ + private void wireChannels(ProcessConfigV2 config, List args) { + final declaredInputs = config.getInputs().getParams() + for( int i = 0; i < declaredInputs.size(); i++ ) + declaredInputs[i].setChannel(createSourceChannel(args[i])) + final singleton = config.getInputs().isSingleton() + for( final param : config.getOutputs().getParams() ) + param.setChannel(CH.create(singleton)) + } + + /** + * Run agents on the dedicated `agent` executor: an agent body is an in-JVM orchestrator that + * BLOCKS on its tool sub-tasks (and the LLM call), so it must not consume the compute + * executor's cpu/capacity slot or a bounded worker thread — otherwise concurrent tool agents + * deadlock the run. The agent executor uses an unthrottled monitor + unbounded thread pool; + * the tool sub-tasks it dispatches still run throttled on the standard local executor. + */ + private static void applyExecutionPolicy(ProcessConfigV2 config, AgentLaunchSpec launchSpec) { + if( launchSpec == null ) + config.put('executor', 'agent') + } + + /** + * The complete set of wire names the model is offered, across BOTH halves of the §5 + * partition: the brokered descriptors plus the runner-native names. Order is descriptors + * first, then natives in inventory order — the same order the model is given them in. + * + * @return the names, or {@code null} for a tool-free agent (so the lineage record keeps + * carrying {@code null} rather than an empty list, as it always has) + */ + private static List resolvedWireNames(List toolSpecs, List nativeToolNames) { + final List names = new ArrayList() + if( toolSpecs ) + for( final ToolDescriptor d : toolSpecs ) + names.add(d.name) + if( nativeToolNames ) + names.addAll(nativeToolNames) + return names ?: null + } + + /** + * Canonical, deterministic identity string of the agent, set as the synthetic + * {@link BodyDef#source} so {@link nextflow.processor.TaskHasher} folds the static agent + * identity into the resume cache key with no hashing-code change (design §7.2/D2). Built + * from the EFFECTIVE resolved values (so a config-default model invalidates the cache when + * changed). The temperature line is a stable literal (`temperature=default`) because the + * task path deliberately leaves temperature UNSET (M2 reconciliation). + * + *

Everything after {@code schema} defaults to {@code null}, and each of those five is + * APPEND-ONLY: a null one contributes no line at all, so the string an agent that does not use + * it produces is BYTE-FOR-BYTE what the narrower call always produced. That is what lets a + * capability be added here without invalidating any existing agent's stored runs, and it is + * pinned by {@code AgentDefTest}'s byte-for-byte equalities between adjacent arities. + * + * @param skills folds the declared skills' identity in, so a changed {@code SKILL.md} (or + * bundled resource) invalidates the cache instead of replaying a stale + * generation (M-Skills correctness item). Non-empty appends a trailing + * {@code skills=} line. The fingerprint is order-independent: + * the descriptors are sorted by name, then hashed over a stable list of + * their identity fields (name/description/content/resources) via + * {@link CacheHelper} + * @param runner the explicitly selected runner; prepends a leading {@code agentRunner=} + * line, the one value written BEFORE the model + * @param tools the declared tools' fingerprint ({@link #toolsFingerprint}); appends a + * trailing {@code tools=} line + * @param baseUrl the RESOLVED endpoint: a different endpoint serves a different model under + * the same id, so a replay must not be shared across endpoints (design D5). + * Folding in the resolved value matches the rule that this string is built + * from effective values -- so the same pipeline run with a different + * {@code NXF_AGENT_BASE_URL} has a different key. The credential is + * deliberately NOT folded in: it is not part of an agent's identity, and + * hashing it would invalidate every entry on key rotation + * @param apiProvider an EXPLICIT {@code agent.apiProvider}, which selects which environment + * variables the endpoint and the credential come from (design D1/D6), so it + * is part of how this agent was configured. Only the explicit value is folded + * in -- an INFERRED provider is a pure function of {@code baseUrl}, which is + * already in the key, so it adds nothing, and leaving it out means a later + * addition to the inference table cannot silently invalidate anyone's stored + * runs. Setting it -- even redundantly to the value that was already inferred + * or taken from the model prefix -- appends a trailing + * {@code apiProvider=} line and invalidates that agent's entries once + */ + protected String canonicalAgentSource(String model, int maxIter, Map schema, List skills = null, String runner = null, String tools = null, String baseUrl = null, String apiProvider = null) { + final sb = new StringBuilder() + if( runner ) + sb.append('agentRunner=').append(runner).append('\n') + sb.append('agentModel=').append(model ?: '').append('\n') + sb.append('temperature=default').append('\n') + sb.append('instruction=').append(this.instruction ?: '').append('\n') + sb.append('goal=').append(this.goal ?: '').append('\n') + sb.append('maxIterations=').append(maxIter).append('\n') + sb.append('prompt=').append(this.prompt?.source ?: '').append('\n') + sb.append('outputSchema=').append(canonicalJson(schema)) + if( skills ) + sb.append('\n').append('skills=').append(skillsFingerprint(skills)) + if( tools ) + sb.append('\n').append('tools=').append(tools) + if( baseUrl ) + sb.append('\n').append('baseUrl=').append(baseUrl) + if( apiProvider ) + sb.append('\n').append('apiProvider=').append(apiProvider) + return sb.toString() + } + + /** + * Deterministic, order-independent fingerprint of the declared tools' identity, so an agent + * that can call a tool resumes only while that tool is unchanged. Sort the descriptors by + * name, then hash each tool's identity: the descriptor the LLM actually sees (name, + * description, input/output schema) plus the backing process' {@code BodyDef.source} — the + * very string {@link nextflow.processor.TaskHasher} folds into that process' own task hash, so + * the agent's key is exactly as sensitive to a tool edit as the tool task itself is. + * + *

Returns {@code null} for no tools, which keeps the canonical source byte-identical to the + * tool-free form. + * + *

A {@code fs:}/{@code shell:} tool is served by the runner itself and therefore has NO + * descriptor to hash — it would contribute nothing here, so an agent declaring + * {@code fs:*} would share a cache key with the same agent declaring no tools at all, and a + * runner upgrade that changes what {@code edit} or {@code grep} does would replay a stale + * generation. What is folded in for those tools is the resolved ref plus the runner + * identity and version ({@link #runnerIdentity}), not a schema: a native tool's behaviour + * changes with the runner image, which is the granularity the plugin version already pins. It + * also makes the key correctly runner-dependent — the same {@code fs:read} is a different + * implementation on each runner (§7). + * + *

INVARIANT: with {@code nativeRefs} null or empty this returns exactly what it returned + * before the runner-native pair existed (including {@code null} for an agent with no tools at + * all), so no existing agent's cache key moves. + * + * @param nativeRefs the canonical refs of the runner-native tools, e.g. {@code [fs:read, shell:bash]}; + * order-independent, they are sorted before hashing + * @param runnerId the runner identity the native tools are served by, e.g. {@code pi@0.5.0} + */ + static String toolsFingerprint(List tools, Map sources, List nativeRefs = null, String runnerId = null) { + if( !tools && !nativeRefs ) + return null + final List canonical = new ArrayList<>() + for( final ToolDescriptor d : (tools ?: Collections.emptyList()).toSorted { it.name } ) + canonical.add([d.name, d.description, canonicalJson(d.inputSchema), canonicalJson(d.outputSchema), sources?.get(d.name)]) + if( nativeRefs ) + canonical.add(['runner-native', runnerId, new ArrayList(new TreeSet(nativeRefs))]) + return CacheHelper.hasher(canonical).hash().toString() + } + + /** + * The identity a runner-native tool's behaviour hangs off: the runner's stable name plus the + * version of the plugin that supplies it — for {@code pi} the {@code nf-agent-pi} version, + * which pins the runner image, which pins the SDK that implements the tool. + * + *

The version is recovered from the plugin that owns the runner class rather than from the + * SPI, so no runner has to remember to report it. It is absent for a runner injected without a + * plugin (the test seam of {@link AgentRunnerProvider}) and for an embedded distribution with + * no plugin manager, in which case the name alone is used: a missing version must never make + * the key non-deterministic within one installation. + */ + static String runnerIdentity(AgentRunner runner) { + if( runner == null ) + return null + final String version = runnerVersion(runner) + return version ? "${runner.getName()}@${version}".toString() : runner.getName() + } + + private static String runnerVersion(AgentRunner runner) { + try { + final PluginWrapper wrapper = Plugins.getManager()?.whichPlugin(runner.getClass()) + return wrapper?.getDescriptor()?.getVersion() + } + catch( Exception e ) { + log.debug("Unable to resolve the plugin version of agent runner `${runner.getName()}` - ${e.message}") + return null + } + } + + /** + * Deterministic, order-independent fingerprint of the declared skills' identity: sort the + * descriptors by name, then hash a stable list of each skill's identity fields + * (name, description, content, and each bundled resource's relativePath + content) via + * {@link CacheHelper}. A changed {@code SKILL.md} body or bundled resource changes the hash. + */ + protected static String skillsFingerprint(List skills) { + final List canonical = new ArrayList<>() + for( final SkillDescriptor d : skills.toSorted { it.name } ) { + final List res = new ArrayList<>() + for( final SkillResource r : (d.resources ?: Collections.emptyList()) ) + res.add([r.relativePath, r.content]) + canonical.add([d.name, d.description, d.content, res]) + } + return CacheHelper.hasher(canonical).hash().toString() + } + + /** + * Deterministic key-sorted JSON serialization of a (possibly nested) schema Map, used for + * the output-schema fingerprint in {@link #canonicalAgentSource}. Recursively sorts Map + * keys (TreeMap) so insertion order does not affect the fingerprint; List order is preserved + * (it is semantically meaningful and built deterministically). Uses {@code JsonOutput.toJson} + * rather than {@code Map.toString()} to avoid hash-order variance (design §7.2/D2). + */ + protected static String canonicalJson(Object obj) { + return JsonOutput.toJson(canonicalize(obj)) + } + + private static Object canonicalize(Object obj) { + if( obj instanceof Map ) { + final sorted = new TreeMap() + for( final e : (obj as Map).entrySet() ) + // coerce a null key to '' so the TreeMap's natural ordering never NPEs + // (defensive: RecordSchema.of/buildWrapperSchema only produce String keys) + sorted.put(e.key != null ? e.key.toString() : '', canonicalize(e.value)) + return sorted + } + if( obj instanceof Collection ) + return (obj as Collection).collect { canonicalize(it) } + return obj + } + + /** + * Synthesize the wrapper object schema for a multi-output agent (design §4.5/§5.3b): + * one object whose {@code properties[out.name]} is the record schema (for record + * outputs) or the scalar fragment (for supported scalar outputs), all names + * {@code required}, {@code additionalProperties:false}. A top-level output whose + * type is neither a record nor a supported scalar (e.g. {@code Path}, a top-level + * collection) is rejected with a clear message. + */ + static Map buildWrapperSchema(String agentName, List outs) { + final props = new LinkedHashMap() + final required = new ArrayList() + for( final o : outs ) { + final Class t = o.type as Class + final Map frag = (t != null && Record.isAssignableFrom(t)) + ? RecordSchema.of(t) + : RecordSchema.scalarFragment(t) + if( frag == null ) + throw new ScriptRuntimeException("Agent `${agentName}` output `${o.name}` has unsupported type ${t?.name} - supported: String, integer, number, boolean, or a record type") + props.put(o.name, frag) + required.add(o.name) + } + return ToolSchema.object(props, required) + } + + /** Machine-readable wrapper for a single scalar output from a tool agent. */ + static Map scalarOutputSchema(AgentOutput out) { + final Class type = out.type as Class + final Map fragment = type != null && Path.isAssignableFrom(type) + ? [type: 'string', description: 'Absolute path returned by the tool'] + : RecordSchema.scalarFragment(type) + if( fragment == null ) + throw new ScriptRuntimeException("Agent output `${out.name}` has unsupported tool-result type ${type?.name}") + final Map props = new LinkedHashMap() + props.put(out.name, fragment) + return ToolSchema.object(props, [out.name]) + } + + /** + * Resolve the declared {@code skills} entries to portable {@link SkillDescriptor}s once, + * pre-ignition. Each entry is either a remote GitHub reference (cloned + cached) or a local + * skill name resolved under the {@code skills/} directory beside the script. Returns {@code null} + * when no skills are declared; rejects duplicate skill names across all entries. + */ + private List resolveSkills() { + final declared = this.skills + if( !declared ) + return null + final session = Global.session as Session + final meta = ScriptMeta.get(owner) + final Path skillsRoot = ownerBaseDir(meta, session).resolve(SkillResolver.SKILLS_DIR) + final List result = new ArrayList<>() + final Set seen = new HashSet<>() + for( final entry : declared ) { + final ref = entry?.toString() + if( !ref ) + continue + final List resolved = SkillResolver.isRemoteRef(ref) + ? SkillResolver.loadRemote(skillsRoot, ref) + : SkillResolver.loadLocal(skillsRoot, ref) + for( final SkillDescriptor d : resolved ) { + if( !seen.add(d.name) ) + throw new ScriptRuntimeException("Agent `${name}`: duplicate skill name `${d.name}` - skills must have unique names") + result.add(d) + } + } + return result + } + + /** + * Expand the declared {@code tools} entries into the resolved selection, applying the + * declaration grammar. Every entry is a namespaced ref — {@code nf:module_run[:PROCESS]}, + * {@code fs:}, {@code shell:bash} — with no fallthrough: a bare process name, a module + * path and a registry reference are all errors, and the capability they used to carry is + * expressed by {@code include}ing the module and naming its process under {@code nf:module_run}. + * + *

The members of {@code nf:module_run} are the processes in scope for the owner script, + * which is the same enumeration {@link nextflow.agent.ModuleToolResolver} then wires; the + * {@code fs:} and {@code shell:} members are fixed by the release. Resolution is deliberately kept in + * {@link nextflow.agent.ToolRefResolver}, a pure function of (refs, available members), so + * the grammar is testable without a session. + * + * @param containerized whether the selected runner executes the agent inside its own + * container. The {@code shell:} family needs that boundary: with an + * in-JVM runner a shell tool would run LLM-authored commands on the + * driver host, so the family is refused rather than served unsafely. + * @return the resolved selection, or {@code null} when no tools are declared + */ + private ToolRefResolver.Selection resolveToolSelection(boolean containerized) { + final declared = this.tools + if( !declared ) + return null + final meta = ScriptMeta.get(owner) + final procNames = meta != null ? meta.getProcessNames() : Collections.emptySet() + final shellUnavailable = containerized + ? null + : "the `${SHELL_FAMILY_RUNNER}` runner is required - an in-JVM runner would execute LLM-authored commands on the driver host with no container boundary (set `agent.runner = '${SHELL_FAMILY_RUNNER}'`)".toString() + return ToolRefResolver.standard("Agent `${name}`".toString(), procNames, shellUnavailable).resolve(declared) + } + + /** + * Check the wire namespace — the flat list of tool names the LLM actually sees (§4) — + * against its two requirements. Both are checked in this one pass because this is the only + * point that can see the whole namespace at once: the resolved selection plus the names the + * runner injects on the agent's behalf, which the {@link ModuleToolBridge} constructor cannot + * know about. + * + *

    + *
  1. Validate, never sanitize. Every wire name must match the OpenAI function-name + * charset {@code [a-zA-Z0-9_-]{1,64}}. A Nextflow process name is a + * {@code JavaLetter JavaLetterOrDigit*} (ScriptLexer.g4), so {@code process my$proc}, + * {@code process Σ_SORT} and names over 64 characters are all legal Nextflow and illegal + * on the wire — reachable here through a glob or a bare {@code nf:module_run}, since an + * explicitly-named ref could not carry those characters through the declaration grammar. + * Such a process is a hard error rather than a silent rename, because rewriting + * {@code my$proc} to {@code my_proc} would merge it with a process already called that, + * and the model would then call one and get the other.
  2. + *
  3. One wire name, one source. Two different sources claiming the same name + * is a hard error naming both. Same-source duplicates are impossible by construction + * (the resolver returns a set, G9). The sources are sorted, never listed in declaration + * order, so the message is stable however the directive was written.
  4. + *
+ * + *

The injected names are not optional extras: the model cannot tell them apart from a tool, + * so a process named {@code activate_skill} in a skills agent, or {@code final_answer} in a + * structured-output agent on the canonical runner, is exactly the collision this rejects. + * + * @param agentName label the errors are raised against + * @param selection the resolved tools, or {@code null} when none are declared + * @param skillsDeclared whether the agent declares {@code skills}, which makes the runner + * inject {@code activate_skill}/{@code read_skill_resource} + * @param structuredOutput whether the agent declares a structured output + * @param containerized whether the selected runner is a canonical launch-spec one; only that + * one injects {@code final_answer} (the in-JVM runner decodes the + * structured answer without a tool) + */ + static void checkWireNames(String agentName, ToolRefResolver.Selection selection, + boolean skillsDeclared, boolean structuredOutput, boolean containerized) { + // wire name -> the sources claiming it. A sorted set is what makes the message + // deterministic under declaration order, and collapses a same-source duplicate. + final Map> claims = new LinkedHashMap>() + if( selection != null ) { + for( final tool : selection.getTools() ) { + checkWireName(agentName, tool) + claim(claims, tool.name, "`${tool.ref}`".toString()) + } + } + if( skillsDeclared ) { + claim(claims, SKILL_ACTIVATE_TOOL, 'the `skills` directive') + claim(claims, SKILL_RESOURCE_TOOL, 'the `skills` directive') + } + if( structuredOutput && containerized ) + claim(claims, FINAL_ANSWER_TOOL, 'the agent output declaration') + for( final entry : claims.entrySet() ) { + if( entry.value.size() < 2 ) + continue + throw new ScriptRuntimeException("Agent `${agentName}`: the tool name `${entry.key}` is claimed by ${entry.value.size()} different sources: ${entry.value.join(' and ')} - the model sees a single flat namespace, so rename the process or drop one of the refs") + } + } + + private static void claim(Map> claims, String name, String source) { + // TreeSet: the sources of a collision are reported in their own sorted order, so the + // message does not change when the directive entries are reordered + Set sources = claims.get(name) + if( sources == null ) + claims.put(name, sources = new TreeSet()) + sources.add(source) + } + + /** Reject a resolved tool whose wire name the LLM API cannot carry; see {@link #checkWireNames}. */ + private static void checkWireName(String agentName, ToolRefResolver.ResolvedTool tool) { + final String name = tool.name + if( WIRE_NAME.matcher(name).matches() ) + return + final List reasons = new ArrayList() + final illegal = illegalWireChars(name) + if( illegal ) + reasons.add("the illegal character(s) ${illegal.collect { "`${it}`" }.join(', ')}".toString()) + if( name.length() > WIRE_NAME_MAX ) + reasons.add("${name.length()} characters (the limit is ${WIRE_NAME_MAX})".toString()) + if( reasons.isEmpty() ) + reasons.add('a shape the wire namespace cannot carry') + throw new ScriptRuntimeException("Agent `${agentName}`: tool `${tool.ref}` cannot be exposed to the model as `${name}` - the name has ${reasons.join(' and ')}, and a tool name must match `[a-zA-Z0-9_-]` with at most ${WIRE_NAME_MAX} characters. Rename it: the name is never rewritten automatically, because a rewrite could silently merge it with another tool") + } + + /** The distinct characters of a name that the wire charset forbids, in order of first use. */ + private static List illegalWireChars(String name) { + final out = new LinkedHashSet() + for( int i = 0; i < name.length(); i++ ) { + final String ch = name.substring(i, i + 1) + if( !WIRE_NAME.matcher(ch).matches() ) + out.add(ch) + } + return new ArrayList(out) + } + + /** + * The directory relative paths declared by the agent (e.g. its {@code skills/} root) are + * resolved against: the owner script's directory when known, otherwise the session base dir, + * otherwise the launch (current) directory. + */ + private static Path ownerBaseDir(ScriptMeta meta, Session session) { + final moduleDir = meta?.getModuleDir() + if( moduleDir != null ) + return moduleDir + if( session?.baseDir != null ) + return session.baseDir + return Path.of('.').toAbsolutePath().normalize() + } + + /** + * Serialize the input record (a Map at runtime) to JSON, rendering any + * {@link Path} value as an absolute, scheme-preserving string so the model receives + * a portable representation (for example, {@code s3://bucket/key}). + */ + protected static String toJson(Object item) { + return JsonOutput.toJson(normalizeForJson(item)) + } + + private static Object normalizeForJson(Object value) { + // A STAGED input is a TaskPath, whose alias is its identity inside the task dir. Render it + // by toString() so the JSON agrees with the prompt's `${x}` interpolation of the same + // input -- and so the model is given a name it can actually open in its runner. A TaskPath + // is precisely and only the shape staging produces, so this check IS "was this staged"; + // it also cannot take toAbsolutePath(), which it throws on by design. + if( value instanceof TaskPath ) + return value.toString() + if( value instanceof Path ) + return FilesEx.toUriString(value.toAbsolutePath()) + if( value instanceof Map ) + return value.collectEntries { k, v -> [(k): normalizeForJson(v)] } + if( value instanceof Collection ) + return value.collect { normalizeForJson(it) } + return value + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy b/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy index 6aaaa213b6..2f87c6b17a 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy @@ -128,6 +128,28 @@ abstract class BaseScript extends Script implements ExecutionContext { this.paramsDef = new ParamsDef(clazz, body) } + /** + * Define an agent. + * + * Mirrors {@link #processV2(String, Closure)} — the lowered agent closure runs + * against an {@link AgentBuilder} delegate that captures directives, inputs, + * outputs and the prompt, then builds the populated {@link AgentDef}. + * The agent executes via {@link AgentDef#run} (see the nf-agent plugin runner). + * + * @param name + * @param body + */ + protected void agent(String name, Closure body) { + log.warn1 "Agents are a preview feature -- syntax and behavior may change in future releases" + final builder = new AgentBuilder(this, name) + final cl = (Closure) body.clone() + cl.setDelegate(builder) + cl.setResolveStrategy(Closure.DELEGATE_FIRST) + final prompt = cl.call() + final agent = builder.withPrompt(prompt).build() + meta.addDefinition(agent) + } + /** * Define a legacy process. * diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessDef.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessDef.groovy index 7cadc1fb2f..471e6d5f68 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessDef.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessDef.groovy @@ -89,6 +89,14 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef { this.taskBody = taskBody } + /** + * The process implementation, i.e. its {@code script}/{@code exec} body. Its + * {@link BodyDef#source} is the process identity {@code TaskHasher} folds into every task + * hash, so it is also what identifies this process when it is referenced from elsewhere + * (see {@code AgentDef.toolsFingerprint}). + */ + BodyDef getTaskBody() { taskBody } + static String stripScope(String str) { str.split(Const.SCOPE_SEP).last() } @@ -251,23 +259,50 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef { } TaskProcessor createTaskProcessor() { + return createTaskProcessor(session, owner, processName, simpleName, baseName, processConfig, taskBody) + } + + protected void applyConfig() { + final configProcessScope = (Map)session.config.process + new ProcessConfigBuilder(processConfig).applyConfig(configProcessScope, baseName, simpleName, processName) + } + + /** + * Build a {@link TaskProcessor} from a process config + body. Extracted as a + * static helper so non-{@code ProcessDef} lowerings (e.g. {@code AgentDef} on + * the tool-free task path) can drive the standard {@link TaskProcessor} pipeline + * without duplicating the executor/factory wiring. + * + * NOTE: these static helpers overlap with {@link ProcessFactory}, whose only + * remaining role is to be overridden by tests. Both should be consolidated into + * a single static factory; deferred to keep the agent changeset small. + */ + static TaskProcessor createTaskProcessor(Session session, BaseScript owner, + String processName, String simpleName, String baseName, + ProcessConfig config, BodyDef body) { // apply process directives from config settings - applyConfig() + new ProcessConfigBuilder(config).applyConfig((Map)session.config.process, baseName, simpleName, processName) + + return createTaskProcessorResolved(session, owner, processName, config, body) + } + + /** + * Build a task processor from an already resolved config. This allows non-process + * components such as agents to use the canonical task/executor pipeline without + * inheriting the {@code process} configuration scope. + */ + static TaskProcessor createTaskProcessorResolved(Session session, BaseScript owner, + String processName, ProcessConfig config, BodyDef body) { // create executor for process final executor = session .executorFactory - .getExecutor(processName, processConfig, taskBody, session) + .getExecutor(processName, config, body, session) // create task processor for process return session .newProcessFactory(owner) - .newTaskProcessor(processName, executor, processConfig, taskBody) - } - - protected void applyConfig() { - final configProcessScope = (Map)session.config.process - new ProcessConfigBuilder(processConfig).applyConfig(configProcessScope, baseName, simpleName, processName) + .newTaskProcessor(processName, executor, config, body) } } diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy index 335eb22fac..bf704ccb2f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy @@ -24,6 +24,7 @@ import nextflow.Nextflow import nextflow.module.ModuleSpec import nextflow.module.ModuleSpecFactory import nextflow.module.ModuleStorage +import nextflow.script.params.DefaultInParam import nextflow.script.params.EnvInParam import nextflow.script.params.FileInParam import nextflow.script.params.InParam @@ -153,17 +154,63 @@ class ProcessEntryHandler { * Gets the input arguments for a process by mapping the session params to * declared process inputs. * + *

Delegates to the reusable static {@link #getProcessArguments(ProcessDef, Map, Path)} + * binding, passing the sibling {@code meta.yml} resolved from the running script path so + * the existing {@code module run} behavior (dot-params, type coercion from {@code meta.yml}, + * tuple assembly) is preserved exactly. + * * @param processDef The ProcessDef object containing the process definition * @return List of parameter values to pass to the process */ - private List getProcessArguments(ProcessDef processDef, Map params) { + protected List getProcessArguments(ProcessDef processDef, Map params) { + final scriptPath = script?.getBinding()?.getScriptPath() + final moduleSpecPath = scriptPath?.resolveSibling(ModuleStorage.MODULE_MANIFEST_FILE) + return getProcessArguments(processDef, params, moduleSpecPath) + } + + /** + * Maps {@code params} onto the declared inputs of {@code processDef}, returning one element + * per input channel (a tuple input becomes a {@code List} of its component values, e.g. + * {@code [[id:'s1'], file(reads)]}). This is the reusable, instance-free form of the + * {@code module run} param→channel binding: dot-notation params are folded into nested maps, + * legacy (V1) inputs are coerced using the input TYPES declared in the sibling module spec + * ({@code meta.yml}), and typed (V2) inputs are coerced from their declared + * {@link nextflow.script.params.v2.ProcessInput} type. + * + * @param processDef the process whose inputs are bound + * @param params the (possibly dotted) param map to bind by input name + * @param moduleSpecPath the sibling {@code meta.yml} path used to load input types for the + * legacy (V1) path; may be {@code null}/missing, in which case an empty + * type map is used (same as when the spec cannot be loaded) + * @return list of values to pass to {@link ProcessDef#run}, one per input channel + */ + static List getProcessArguments(ProcessDef processDef, Map params, Path moduleSpecPath) { + return bindProcessArguments(processDef, params, getModuleSpecInputTypes(moduleSpecPath)) + } + + /** + * Variant of {@link #getProcessArguments(ProcessDef, Map, Path)} that takes an already-loaded + * {@link ModuleSpec} (no path round-trip), used by callers that already hold the spec (e.g. + * the agent tool bridge). + * + * @param processDef the process whose inputs are bound + * @param params the (possibly dotted) param map to bind by input name + * @param spec the module spec providing input types for the legacy (V1) path; may be + * {@code null}, in which case an empty type map is used + * @return list of values to pass to {@link ProcessDef#run}, one per input channel + */ + static List getProcessArguments(ProcessDef processDef, Map params, ModuleSpec spec) { + return bindProcessArguments(processDef, params, spec != null ? moduleSpecInputTypes(spec) : Collections.emptyMap()) + } + + private static List bindProcessArguments(ProcessDef processDef, Map params, Map paramTypes) { try { log.debug "Getting input arguments for process: ${processDef.name}" log.debug "Session params: ${params}" final config = processDef.getProcessConfig() final inputArgs = config instanceof ProcessConfigV1 - ? getProcessArgumentsV1(config, params) + ? getProcessArgumentsV1(config, params, paramTypes) : getProcessArgumentsV2((ProcessConfigV2) config, params) log.debug "Final input arguments: ${inputArgs}" @@ -175,20 +222,23 @@ class ProcessEntryHandler { } } - private List getProcessArgumentsV1(ProcessConfigV1 config, Map params) { + private static List getProcessArgumentsV1(ProcessConfigV1 config, Map params, Map paramTypes) { final declaredInputs = config.getInputs() if( declaredInputs.isEmpty() ) { return [] } - // Load parameter types from module spec (if available) - final scriptPath = script.getBinding().getScriptPath() - final paramTypes = getModuleSpecInputTypes(scriptPath) - // Map declared inputs to command-line arguments List arguments = [] for( final param : declaredInputs ) { + // Skip the synthetic `$` control input that a process gains once it has been + // `run()` (DefaultInParam): it is a termination-control channel, never a + // user-supplied value. It is absent in the typical `module run` path (which binds + // BEFORE run) and present when binding a process that was pre-wired/run earlier + // (e.g. the agent tool bridge) - skipping it makes both paths produce the same args. + if( param instanceof DefaultInParam ) + continue if( param instanceof TupleInParam ) { List tupleElements = [] for( final innerParam : param.inner ) { @@ -257,24 +307,31 @@ class ProcessEntryHandler { * @param paramTypes Map of input types from module spec * @return Properly typed value for the input */ - private Object getValueForInputV1(InParam param, Map namedArgs, Map paramTypes) { + private static Object getValueForInputV1(InParam param, Map namedArgs, Map paramTypes) { final name = param.getName() final type = paramTypes.get(name) final value = namedArgs.get(name) - if( value == null ) { - if( param instanceof FileInParam ) { + // File/path inputs: an ABSENT value means "not provided". nf-core path inputs are + // optional by convention and default to an empty list (the process script handles the + // empty case). An empty value is NOT a stand-in for an absent one: as with most CLI + // tools, an optional path input is skipped by supplying nothing at all, not by + // supplying the option with an empty value. An empty value therefore falls through to + // `file('')`, which fails loudly. + if( param instanceof FileInParam ) { + if( value == null ) { log.warn "Path input '--${name}' not provided, defaulting to empty list" return [] } - throw new IllegalArgumentException("Missing required parameter: --${name}") + return parseFileInput(value.toString()) } - // handle file, path, env, stdin inputs - switch( param ) { - case FileInParam: - return parseFileInput(value.toString()) + // non-file inputs: a missing value is a hard error (required) + if( value == null ) + throw new IllegalArgumentException("Missing required parameter: --${name}") + // handle env, stdin inputs + switch( param ) { case EnvInParam: throw new IllegalArgumentException("Process `env` input qualifier is not supported by implicit process entry") @@ -318,7 +375,7 @@ class ProcessEntryHandler { return str } - private List getProcessArgumentsV2(ProcessConfigV2 config, Map params) { + private static List getProcessArgumentsV2(ProcessConfigV2 config, Map params) { final declaredInputs = config.getInputs().getParams() if( declaredInputs.isEmpty() ) { @@ -360,7 +417,7 @@ class ProcessEntryHandler { * @param namedArgs Map of command-line arguments * @return Properly typed value for the input */ - private Object getValueForInputV2(ProcessInput param, Map namedArgs) { + private static Object getValueForInputV2(ProcessInput param, Map namedArgs) { final name = param.getName() final type = param.getType() final value = namedArgs.get(name) @@ -421,7 +478,7 @@ class ProcessEntryHandler { * @param fileInput String representation of file path(s) * @return Single file or list of files */ - protected Object parseFileInput(String fileInput) { + protected static Object parseFileInput(String fileInput) { if( fileInput.contains(',') ) { // Split by comma, trim whitespace, and convert each to a file return fileInput.tokenize(',') diff --git a/modules/nextflow/src/main/groovy/nextflow/script/PromptDef.groovy b/modules/nextflow/src/main/groovy/nextflow/script/PromptDef.groovy new file mode 100644 index 0000000000..6c805f979c --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/script/PromptDef.groovy @@ -0,0 +1,61 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script + +import groovy.transform.CompileStatic + +/** + * Models the `prompt:` block of an agent definition. Mirrors {@link BodyDef} + * but minimal: the prompt template is captured as a closure (evaluated per + * invocation with the agent inputs in scope) plus its source text. + */ +@CompileStatic +class PromptDef implements Cloneable { + + final Closure closure + final String source + + /** + * The free-variable references (e.g. {@code params.*}, {@code task.ext.*}) + * captured from the prompt closure at parse time. These are folded into the + * synthetic task {@link BodyDef#valRefs} so a prompt that closes over a + * workflow-global enters the resume cache key (design §7.2/D3). + */ + final List valRefs + + PromptDef(Closure closure, String source) { + this(closure, source, Collections.emptyList()) + } + + PromptDef(Closure closure, String source, List valRefs) { + this.closure = closure + this.source = source + this.valRefs = valRefs != null ? valRefs : Collections.emptyList() + } + + /** + * The names of the prompt's free-variable references (mirrors + * {@link BodyDef#getValNames}). + */ + List getValNames() { + valRefs*.name + } + + @Override + PromptDef clone() { + (PromptDef) super.clone() + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ScriptMeta.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ScriptMeta.groovy index 49b4c3deb4..9525f50856 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ScriptMeta.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ScriptMeta.groovy @@ -52,11 +52,14 @@ class ScriptMeta { static private Set resolvedProcessNames = new HashSet<>(20) + static private Set resolvedAgentNames = new HashSet<>(20) + @TestOnly static void reset() { REGISTRY.clear() scriptsByPath.clear() resolvedProcessNames.clear() + resolvedAgentNames.clear() } static ScriptMeta get(BaseScript script) { @@ -78,6 +81,15 @@ class ScriptMeta { return result } + static Set allAgentNames() { + def result = new HashSet() + for( ScriptMeta entry : REGISTRY.values() ) + result.addAll( entry.getAgentNames() ) + // add all resolved names + result.addAll(resolvedAgentNames) + return result + } + static Set allProcesses() { final result = new HashSet() for( final entry : REGISTRY.values() ) { @@ -91,6 +103,10 @@ class ScriptMeta { resolvedProcessNames.add(name) } + static void addResolvedAgentName(String name) { + resolvedAgentNames.add(name) + } + static Map allScriptNames() { def result = new HashMap(REGISTRY.size()) for( ScriptMeta entry : REGISTRY.values() ) @@ -284,6 +300,22 @@ class ScriptMeta { return result } + Set getAgentNames() { + def result = new HashSet(definitions.size() + imports.size()) + // local definitions + for( def item : definitions.values() ) { + if( item instanceof AgentDef ) + result.add(item.name) + } + // agents from imports -- an aliased include is a clone carrying the alias as its name, + // so both the declared name and the alias are valid `withName:` targets + for( def item: imports.values() ) { + if( item instanceof AgentDef ) + result.add(item.name) + } + return result + } + Set getLocalProcessNames() { def result = new HashSet(definitions.size() + imports.size()) // local definitions diff --git a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ConfigSelectorResolver.groovy b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ConfigSelectorResolver.groovy new file mode 100644 index 0000000000..c61ca5ac2d --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ConfigSelectorResolver.groovy @@ -0,0 +1,117 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.script.dsl + +import java.util.regex.Pattern + +import groovy.transform.CompileStatic + +/** + * Stateless selector matching shared by process directives and agent-only options. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +final class ConfigSelectorResolver { + + private static final String LABEL_PREFIX = 'withLabel:' + private static final String NAME_PREFIX = 'withName:' + + static class SelectorMatch { + final String rule + final Object settings + + SelectorMatch(String rule, Object settings) { + this.rule = rule + this.settings = settings + } + } + + private ConfigSelectorResolver() {} + + /** + * Return matching selector bodies in precedence order: labels first, followed by each + * distinct process/agent name from least to most specific. + */ + static List matchingSettings(Map scope, List labels, + String baseName, String simpleName, String fullyQualifiedName) { + final result = new ArrayList() + result.addAll(matchingLabelSettings(scope, labels)) + for( final name : distinctNames(baseName, simpleName, fullyQualifiedName) ) + result.addAll(matchingNameSettings(scope, name)) + return result + } + + static List matchingLabelSettings(Map scope, List labels) { + return matchingLabelSelectors(scope, labels).collect { it.settings } + } + + static List matchingLabelSelectors(Map scope, List labels) { + final result = new ArrayList() + for( final entry : scope.entrySet() ) { + final rule = entry.key.toString() + if( rule.startsWith(LABEL_PREFIX) + && matchesLabels(labels, rule.substring(LABEL_PREFIX.length()).trim()) ) + result.add(new SelectorMatch(rule, entry.value)) + } + return result + } + + static List matchingNameSettings(Map scope, String name) { + return matchingNameSelectors(scope, name).collect { it.settings } + } + + static List matchingNameSelectors(Map scope, String name) { + final result = new ArrayList() + for( final entry : scope.entrySet() ) { + final rule = entry.key.toString() + if( rule.startsWith(NAME_PREFIX) + && matchesName(name, rule.substring(NAME_PREFIX.length()).trim()) ) + result.add(new SelectorMatch(rule, entry.value)) + } + return result + } + + static List distinctNames(String baseName, String simpleName, String fullyQualifiedName) { + final result = new ArrayList(3) + for( final name : [baseName, simpleName, fullyQualifiedName] ) { + if( name && !result.contains(name) ) + result.add(name) + } + return result + } + + static boolean matchesLabels(List labels, String pattern) { + final isNegated = pattern.startsWith('!') + if( isNegated ) + pattern = pattern.substring(1).trim() + + final regex = Pattern.compile(pattern) + for( final label : labels ) { + if( regex.matcher(label).matches() ) + return !isNegated + } + return isNegated + } + + static boolean matchesName(String name, String pattern) { + final isNegated = pattern.startsWith('!') + if( isNegated ) + pattern = pattern.substring(1).trim() + return Pattern.compile(pattern).matcher(name).matches() ^ isNegated + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy index c8312bbbd4..971fdec9c3 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy @@ -99,6 +99,13 @@ class ProcessBuilder { this.config = config } + /** + * The noun used in the user-visible messages, i.e. what is being configured. Always + * {@code process} here; {@link ProcessConfigBuilder} overrides it because agent tasks + * reuse this builder through their own {@code agent} config scope. + */ + protected String getKind() { 'process' } + // NOTE: replace with internal DSL after v1 parser is removed def methodMissing( String name, def args ) { if( DIRECTIVES.contains(name) || name == 'when' || name == 'stub' ) { @@ -260,7 +267,7 @@ class ProcessBuilder { // -- check that label has a valid syntax if( !isValidLabel(value) ) - throw new IllegalConfigException("Not a valid process label: $value -- Label must consist of alphanumeric characters or '_', must start with an alphabetic character and must end with an alphanumeric character") + throw new IllegalConfigException("Not a valid ${getKind()} label: $value -- Label must consist of alphanumeric characters or '_', must start with an alphabetic character and must end with an alphanumeric character") // -- get the current label, it must be a list def allLabels = (List)config.get('label') diff --git a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessConfigBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessConfigBuilder.groovy index 131e6ee649..90da6a2ade 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessConfigBuilder.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessConfigBuilder.groovy @@ -16,8 +16,6 @@ package nextflow.script.dsl -import java.util.regex.Pattern - import groovy.transform.TypeChecked import groovy.util.logging.Slf4j import nextflow.exception.ConfigParseException @@ -32,10 +30,32 @@ import nextflow.script.ProcessConfig @TypeChecked class ProcessConfigBuilder extends ProcessBuilder { - ProcessConfigBuilder(ProcessConfig config) { + /** + * The noun used in the user-visible messages, i.e. what is being configured + * ({@code process} or {@code agent}). + */ + private final String kind + + /** + * The keys of the config scope that are NOT task directives and must therefore be + * skipped instead of applied (e.g. the agent-only options of the {@code agent} + * scope). Empty for the {@code process} scope, whose behaviour is unchanged. + */ + private final Set ignoredKeys + + ProcessConfigBuilder(ProcessConfig config, String kind='process', Set ignoredKeys=Collections.emptySet()) { super(config) + this.kind = kind + this.ignoredKeys = ignoredKeys } + /** + * Report the configured noun to the inherited directive methods too, so that e.g. an + * invalid {@code label} value is reported as an agent label when configuring an agent. + */ + @Override + protected String getKind() { kind } + /** * Apply process config settings from the config file to a process. * @@ -49,23 +69,16 @@ class ProcessConfigBuilder extends ProcessBuilder { final processLabels = config.getLabels() ?: [''] applyConfigSelectorWithLabels(configProcessScope, processLabels) - // -- apply settings defined in the config file using the process base name - applyConfigSelectorWithName(configProcessScope, baseName) - - // -- apply settings defined in the config file using the process simple name - if( simpleName && simpleName!=baseName ) - applyConfigSelectorWithName(configProcessScope, simpleName) - - // -- apply settings defined in the config file using the process fully qualified name (ie. with the execution scope) - if( fullyQualifiedName && (fullyQualifiedName!=simpleName || fullyQualifiedName!=baseName) ) - applyConfigSelectorWithName(configProcessScope, fullyQualifiedName) + // -- apply settings by name, from the declared name to the fully-qualified name + for( final name : ConfigSelectorResolver.distinctNames(baseName, simpleName, fullyQualifiedName) ) + applyConfigSelectorWithName(configProcessScope, name) // -- apply defaults applyConfigDefaults(configProcessScope) // -- check for conflicting settings if( config.scratch && config.stageInMode == 'rellink' ) { - log.warn("Directives `scratch` and `stageInMode=rellink` conflict with each other -- Enforcing default stageInMode for process `$simpleName`") + log.warn("Directives `scratch` and `stageInMode=rellink` conflict with each other -- Enforcing default stageInMode for $kind `$simpleName`") config.remove('stageInMode') } } @@ -86,21 +99,14 @@ class ProcessConfigBuilder extends ProcessBuilder { * @param labels */ protected void applyConfigSelectorWithLabels(Map configDirectives, List labels) { - final prefix = 'withLabel:' - for( String rule : configDirectives.keySet() ) { - if( !rule.startsWith(prefix) ) - continue - final pattern = rule.substring(prefix.size()).trim() - if( !matchesLabels(labels, pattern) ) - continue - - log.debug "Config settings `$rule` matches labels `${labels.join(',')}` for process with name $processName" - final settings = configDirectives.get(rule) + for( final match : ConfigSelectorResolver.matchingLabelSelectors(configDirectives, labels) ) { + log.debug "Config settings `${match.rule}` matches labels `${labels.join(',')}` for process with name $processName" + final settings = match.settings if( settings instanceof Map ) { applyConfigSettings(settings) } else if( settings != null ) { - throw new ConfigParseException("Unknown config settings for process labeled ${labels.join(',')} -- settings=$settings ") + throw new ConfigParseException("Unknown config settings for $kind labeled ${labels.join(',')} -- settings=$settings ") } } } @@ -113,18 +119,7 @@ class ProcessConfigBuilder extends ProcessBuilder { * @param pattern */ static boolean matchesLabels(List labels, String pattern) { - final isNegated = pattern.startsWith('!') - if( isNegated ) - pattern = pattern.substring(1).trim() - - final regex = Pattern.compile(pattern) - for (label in labels) { - if (regex.matcher(label).matches()) { - return !isNegated - } - } - - return isNegated + return ConfigSelectorResolver.matchesLabels(labels, pattern) } /** @@ -143,21 +138,14 @@ class ProcessConfigBuilder extends ProcessBuilder { * @param target */ protected void applyConfigSelectorWithName(Map configDirectives, String target) { - final prefix = 'withName:' - for( String rule : configDirectives.keySet() ) { - if( !rule.startsWith(prefix) ) - continue - final pattern = rule.substring(prefix.size()).trim() - if( !matchesSelector(target, pattern) ) - continue - - log.debug "Config settings `$rule` matches process $processName" - def settings = configDirectives.get(rule) + for( final match : ConfigSelectorResolver.matchingNameSelectors(configDirectives, target) ) { + log.debug "Config settings `${match.rule}` matches process $processName" + final settings = match.settings if( settings instanceof Map ) { applyConfigSettings(settings) } else if( settings != null ) { - throw new ConfigParseException("Unknown config settings for process with name: $target -- settings=$settings ") + throw new ConfigParseException("Unknown config settings for $kind with name: $target -- settings=$settings ") } } } @@ -170,10 +158,7 @@ class ProcessConfigBuilder extends ProcessBuilder { * @param pattern */ static boolean matchesSelector(String name, String pattern) { - final isNegated = pattern.startsWith('!') - if( isNegated ) - pattern = pattern.substring(1).trim() - return Pattern.compile(pattern).matcher(name).matches() ^ isNegated + return ConfigSelectorResolver.matchesName(name, pattern) } /** @@ -189,8 +174,11 @@ class ProcessConfigBuilder extends ProcessBuilder { if( entry.key.startsWith("withLabel:") || entry.key.startsWith("withName:")) continue + if( entry.key in ignoredKeys ) // e.g. the agent-only options of the `agent` scope + continue + if( !DIRECTIVES.contains(entry.key) ) - log.warn "Unknown directive `$entry.key` for process `$processName`" + log.warn "Unknown directive `$entry.key` for $kind `$processName`" if( entry.key == 'params' ) // <-- patch issue #242 continue @@ -218,7 +206,7 @@ class ProcessConfigBuilder extends ProcessBuilder { */ protected void applyConfigDefaults( Map defaults ) { for( String key : defaults.keySet() ) { - if( key == 'params' ) + if( key == 'params' || key in ignoredKeys ) continue final value = defaults.get(key) final current = config.getProperty(key) diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutput.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutput.groovy index b3c6d7f195..f354fe3d80 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutput.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutput.groovy @@ -63,6 +63,10 @@ class ProcessOutput implements OutParam { return name } + Class getType() { + return type + } + Object getLazyValue() { return value } diff --git a/modules/nextflow/src/main/groovy/nextflow/script/parser/v2/ScriptCompiler.java b/modules/nextflow/src/main/groovy/nextflow/script/parser/v2/ScriptCompiler.java index 09ea5390c6..84d46d2e2a 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/parser/v2/ScriptCompiler.java +++ b/modules/nextflow/src/main/groovy/nextflow/script/parser/v2/ScriptCompiler.java @@ -162,8 +162,8 @@ private CompileResult compile0(GroovyCodeSource codeSource) throws IOException { .get(); var modules = collectModules(unit, classes); - var processNames = new ProcessNameResolver(unit.getCallSites()).resolve(su); - return new CompileResult(main, modules, processNames); + var names = new ProcessNameResolver(unit.getCallSites()).resolve(su); + return new CompileResult(main, modules, names.processes(), names.agents()); } private Map collectModules(ScriptCompilationUnit unit, List classes) { @@ -185,7 +185,8 @@ private Map collectModules(ScriptCompilationUnit unit, List c public static record CompileResult( Class main, Map modules, - Set processNames + Set processNames, + Set agentNames ) {} private static class ScriptClassLoader extends GroovyClassLoader { diff --git a/modules/nextflow/src/main/groovy/nextflow/script/parser/v2/ScriptLoaderV2.groovy b/modules/nextflow/src/main/groovy/nextflow/script/parser/v2/ScriptLoaderV2.groovy index 94255f5b3b..2f3888d312 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/parser/v2/ScriptLoaderV2.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/parser/v2/ScriptLoaderV2.groovy @@ -122,6 +122,9 @@ class ScriptLoaderV2 implements ScriptLoader { for( final name : compileResult.processNames() ) ScriptMeta.addResolvedName(name) + + for( final name : compileResult.agentNames() ) + ScriptMeta.addResolvedAgentName(name) } catch( CompilationFailedException e ) { if( scriptPath ) diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/TraceRecord.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/TraceRecord.groovy index 810200e9ed..9ba5475514 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/TraceRecord.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/TraceRecord.groovy @@ -45,7 +45,12 @@ import nextflow.util.MemoryUnit class TraceRecord implements Serializable { // note: ?i stands for ignore case - ?m stands for multiline - static public final Pattern SECRET_REGEX = ~/(?im)(^AWS[^=]*|.*TOKEN[^=]*|.*SECRET[^=]*)=(.*)$/ + // NOTE: `API_?KEY` is the twin of the same alternative in {@code SecretHelper.SECRET_REGEX} and + // of `api_?key` in {@code SecretHelper.SECRET_KEYS}. This one masks the `env` field of a trace + // record, which is where an out-of-band provider credential (`env { OPENAI_API_KEY = ... }`, + // `agent.containerOptions = '-e OPENAI_API_KEY'`) shows up -- and a trace record is persisted + // in the resume cache and forwarded to Seqera Platform. + static public final Pattern SECRET_REGEX = ~/(?im)(^AWS[^=]*|.*TOKEN[^=]*|.*SECRET[^=]*|.*API_?KEY[^=]*)=(.*)$/ TraceRecord() { this.store = new LinkedHashMap<>(FIELDS.size()) diff --git a/modules/nextflow/src/main/groovy/nextflow/util/SecretHelper.groovy b/modules/nextflow/src/main/groovy/nextflow/util/SecretHelper.groovy index 19c8cd63c4..28b27b17e8 100644 --- a/modules/nextflow/src/main/groovy/nextflow/util/SecretHelper.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/util/SecretHelper.groovy @@ -28,10 +28,19 @@ import groovy.transform.CompileStatic @CompileStatic class SecretHelper { - static public final Pattern SECRET_KEYS = ~/(?im)^AWS.+|.*TOKEN.*|.*PASSWORD.*|.*SECRET.*|.*accessKey.*/ + // NOTE: `api_?key` covers `agent.apiKey` and the `*_API_KEY` environment spellings; without it + // a resolved provider credential is persisted verbatim by the lineage observer and shipped as + // `workflow.configText` (`accessKey` does NOT match `apiKey`) + static public final Pattern SECRET_KEYS = ~/(?im)^AWS.+|.*TOKEN.*|.*PASSWORD.*|.*SECRET.*|.*accessKey.*|.*api_?key.*/ // note: ?i stands for ignore case - ?m stands for multiline - static public final Pattern SECRET_REGEX = ~/(?im)(^AWS[^=]*|.*TOKEN[^=]*|.*SECRET[^=]*)=(.*)$/ + // NOTE: `api_?key` mirrors SECRET_KEYS above. The two must agree: SECRET_KEYS masks a config + // MAP entry while this masks a `NAME=value` environment line, and the out-of-band credential + // channel the agent docs still recommend (`env { OPENAI_API_KEY = ... }`, + // `agent.containerOptions = '-e OPENAI_API_KEY'`) is delivered as exactly such a line -- so a + // pattern that covers only one of the two masks the credential in only half the places it + // appears. Kept in sync with the identical twin in {@code nextflow.trace.TraceRecord}. + static public final Pattern SECRET_REGEX = ~/(?im)(^AWS[^=]*|.*TOKEN[^=]*|.*SECRET[^=]*|.*API_?KEY[^=]*)=(.*)$/ static String secureEnvString( String str ) { str.replaceAll(SECRET_REGEX, '$1=[secure]') diff --git a/modules/nextflow/src/main/resources/META-INF/extensions.idx b/modules/nextflow/src/main/resources/META-INF/extensions.idx index c574e01efe..af8f7e1d90 100644 --- a/modules/nextflow/src/main/resources/META-INF/extensions.idx +++ b/modules/nextflow/src/main/resources/META-INF/extensions.idx @@ -14,6 +14,7 @@ # limitations under the License. # +nextflow.agent.AgentConfig nextflow.cache.DefaultCacheFactory nextflow.conda.CondaConfig nextflow.config.ConfigMap diff --git a/modules/nextflow/src/main/resources/META-INF/plugins-info.txt b/modules/nextflow/src/main/resources/META-INF/plugins-info.txt index 35fca4a217..e84dc3b2ad 100644 --- a/modules/nextflow/src/main/resources/META-INF/plugins-info.txt +++ b/modules/nextflow/src/main/resources/META-INF/plugins-info.txt @@ -1,3 +1,5 @@ +nf-agent-pi@0.5.0 +nf-agent@0.1.0 nf-amazon@3.10.1 nf-azure@1.23.1 nf-cloudcache@0.6.0 diff --git a/modules/nextflow/src/test/groovy/nextflow/SessionTest.groovy b/modules/nextflow/src/test/groovy/nextflow/SessionTest.groovy index 370b210d92..48ec2f64b3 100644 --- a/modules/nextflow/src/test/groovy/nextflow/SessionTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/SessionTest.groovy @@ -19,6 +19,9 @@ package nextflow import java.nio.file.Files import java.nio.file.Paths import java.nio.file.attribute.PosixFilePermission +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit import nextflow.config.Manifest import nextflow.container.ContainerConfig @@ -236,15 +239,71 @@ class SessionTest extends Specification { } + def 'the agent orchestration pool is separate from the execution pool and grows on demand' () { + given: + def session = new Session([poolSize: 2]) + session.start() + + expect: 'the execution pool keeps its historical fixed shape, sized to poolSize' + ((ThreadPoolExecutor) session.execService).getMaximumPoolSize() == 2 + + and: 'orchestration draws from a DIFFERENT pool, so an agent cannot consume an execution thread' + !session.getAgentExecService().is(session.execService) + + and: 'and holds no threads until an agent actually runs' + ((ThreadPoolExecutor) session.getAgentExecService()).getPoolSize() == 0 + + and: 'it is unbounded, so a blocked orchestrator can always be given a thread' + ((ThreadPoolExecutor) session.getAgentExecService()).getMaximumPoolSize() == Integer.MAX_VALUE + + when: 'far more blocked orchestrators than the execution pool could ever admit' + def pool = (ThreadPoolExecutor) session.getAgentExecService() + int n = 50 + def started = new CountDownLatch(n) + def release = new CountDownLatch(1) + n.times { pool.submit({ started.countDown(); release.await() } as Runnable) } + + // sharing one fixed pool would admit only poolSize of these, and the tool sub-tasks they + // block on would never get a thread -- the deadlock this partition removes + then: 'all of them run concurrently' + started.await(30, TimeUnit.SECONDS) + pool.getPoolSize() >= n + + and: 'none of it consumed the execution pool' + ((ThreadPoolExecutor) session.execService).getPoolSize() == 0 + + cleanup: + release.countDown() + pool.shutdownNow() + session.execService.shutdownNow() + } + def 'should get a warning message' () { given: - def session = new Session([process: ['$foo': [cpus:1], '$bar':[mem:'10GB']]]) + def session = new Session([process: ['withName:foo': [cpus:1], 'withName:bar':[mem:'10GB']]]) expect: session.validateConfig0(['foo','bar','baz']) == [] session.validateConfig0(['foo','baz']) == ["There's no process matching config selector: bar -- Did you mean: baz?"] } + def 'should validate agent selectors against the agent names' () { + given: + def session = new Session([agent: [ + model: 'openai/gpt-5-mini', + 'withName:critic': [cpus: 2], + 'withName:planer': [cpus: 4], + 'withLabel:reasoning': [cpus: 8] ]]) + + expect: 'a matched agent selector is silent; the typo is reported as an agent, not a process' + session.validateConfig0([], ['critic','planner']) == ["There's no agent matching config selector: planer -- Did you mean: planner?"] + + and: 'agent names never satisfy a `process` selector, and vice versa' + new Session([process: ['withName:critic': [cpus:2]]]).validateConfig0([], ['critic']) + == ["There's no process matching config selector: critic"] + session.validateConfig0(['critic','planner'], []).size() == 2 + } + @Unroll def 'should return engine type' () { given: diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentAsTaskIntegrationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentAsTaskIntegrationTest.groovy new file mode 100644 index 0000000000..4779c24681 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentAsTaskIntegrationTest.groovy @@ -0,0 +1,1161 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicInteger + +import groovy.json.JsonSlurper +import nextflow.Global +import nextflow.Session +import nextflow.SysEnv +import nextflow.processor.TaskConfig +import nextflow.processor.TaskProcessor +import nextflow.script.AgentBuilder.AgentInput +import nextflow.script.AgentBuilder.AgentOutput +import nextflow.script.AgentDef +import nextflow.script.BaseScript +import nextflow.script.PromptDef +import nextflow.script.ScriptBinding +import nextflow.script.ScriptFile +import nextflow.script.ScriptLoaderFactory +import nextflow.trace.TraceObserverV2 +import nextflow.trace.event.TaskEvent +import spock.lang.TempDir +import spock.lang.Timeout +import test.Dsl2Spec +import test.MockSession + +import static test.ScriptHelper.runScript +import nextflow.agent.rpc.AgentRpcRegistration + +/** + * Integration tests for the tool-free agent lowered to a real {@code TaskProcessor} + * (design M1). The LLM is stubbed via {@link AgentRunnerProvider#testRunner}; each + * test drives a workflow through {@code runScript} (task path via the mock executor). + * + * @author Paolo Di Tommaso + */ +@Timeout(30) +class AgentAsTaskIntegrationTest extends Dsl2Spec { + + @TempDir + Path tempDir + + def cleanup() { + AgentRunnerProvider.testRunner = null + Thread.interrupted() // clear any leaked interrupt flag from a fatal-tool abort (Test N) + } + + // -- Test A [LINCHPIN]: N-way output split via getDelegate().put under DELEGATE_ONLY + def 'should split a wrapper response into N named output channels'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + '{"plan":{"title":"t","shards":[{"id":"s1","question":"q1"}]},"count":3}' + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + record Shard { id: String; question: String } + record Plan { title: String; shards: List } + + agent planner { + model 'openai/gpt-4o' + tools() + input: + brief: String + output: + plan: Plan + count: Long + prompt: + """ + Break: ${brief} + """ + } + + workflow { + def r = planner(channel.of('brief')) + [ r.plan, r.count ] + } + ''') + + then: 'plan channel (agent.out.plan) yields the Plan record slice' + def plan = result[0].val + plan instanceof Map + plan.title == 't' + plan.shards instanceof List + plan.shards[0].id == 's1' + plan.shards[0].question == 'q1' + and: 'count channel (agent.out.count) yields the coerced Long slice' + def count = result[1].val + count == 3 + count instanceof Long + } + + // -- Test B: single-record output stays UNWRAPPED (bare RecordSchema.of) + def 'should keep a single-record output unwrapped (no wrapper key)'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req; '{"answer":"ok","confidence":0.9}' + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + record Answer { answer: String; confidence: Double } + + agent qa { + model 'openai/gpt-4o' + tools() + input: + q: String + output: + a: Answer + prompt: + """ + Q: ${q} + """ + } + + workflow { + qa(channel.of('hello')) + } + ''') + + then: 'the schema is the BARE record schema - properties at the root, no wrapper key `a`' + captured.outputSchema.type == 'object' + captured.outputSchema.properties.containsKey('answer') + captured.outputSchema.properties.containsKey('confidence') + !captured.outputSchema.properties.containsKey('a') + and: 'the bare record schema carries required + additionalProperties:false (byte shape unchanged)' + (captured.outputSchema.required as Set) == ['answer', 'confidence'] as Set + captured.outputSchema.additionalProperties == false + and: 'inputJson is the bare single value (not a {q:...} wrapper)' + captured.inputJson == '"hello"' + and: 'the channel yields the parsed record' + def out = result.val + out instanceof Map + out.answer == 'ok' + out.confidence == 0.9d + } + + // -- Test C: free-text passthrough (single scalar output) + def 'should pass through free text for a single scalar output'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req; 'hello world' + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + tools() + input: + q: String + output: + answer: String + prompt: + """ + Q: ${q} + """ + } + + workflow { + qa(channel.of('hi')) + } + ''') + + then: + result.val == 'hello world' + captured.outputSchema == null + } + + // -- Test D: multiple inputs combine natively + def 'should combine multiple inputs and render both in the prompt + inputJson map'() { + given: + def prompts = Collections.synchronizedList([]) + def jsons = Collections.synchronizedList([]) + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + prompts.add(req.prompt); jsons.add(req.inputJson); 'ok' + } as AgentRunner + + when: + runScript(''' + nextflow.enable.types = true + + agent combiner { + model 'openai/gpt-4o' + tools() + input: + a: String + b: String + output: + r: String + prompt: + """ + A=${a} B=${b} + """ + } + + workflow { + combiner(channel.of('x1','x2'), channel.of('y1','y2')) + } + ''') + + then: 'one invocation per position (2), each rendering both inputs' + prompts.size() == 2 + prompts.every { it.contains('A=') && it.contains('B=') } + and: 'for N>1 inputs the inputJson is a {a:..,b:..} map' + jsons.every { it.contains('"a"') && it.contains('"b"') } + (prompts.join(' ')).contains('x1') + (prompts.join(' ')).contains('y1') + } + + // -- Test E1: queue input runs per-item + def 'should run once per item for a queue input'() { + given: + def count = new AtomicInteger() + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + count.incrementAndGet(); "r-${req.inputJson}".toString() + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + tools() + input: + q: String + output: + answer: String + prompt: "Q: ${q}" + } + + workflow { + qa(channel.of('x','y','z')) + } + ''') + + then: + count.get() == 3 + and: + def ch = result + def vals = [ch.val, ch.val, ch.val] + vals.size() == 3 + } + + // -- Test E2 [FAN-IN]: value/singleton input (collect()) runs exactly once + def 'should run exactly once for a value/singleton input (fan-in)'() { + given: + def count = new AtomicInteger() + Object capturedInput = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + count.incrementAndGet(); capturedInput = req.inputJson; 'summary' + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + agent reducer { + model 'openai/gpt-4o' + tools() + input: + items: String + output: + report: String + prompt: "Reduce: ${items}" + } + + workflow { + reducer(channel.of('a','b','c').collect()) + } + ''') + + then: 'the agent fired exactly once with the whole collected bag' + count.get() == 1 + capturedInput.contains('a') && capturedInput.contains('b') && capturedInput.contains('c') + and: + result.val == 'summary' + } + + // -- Test F: wrapper schema shape (multi-output) + def 'should synthesize an object-root wrapper schema for multiple outputs'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req; '{"rec":{"title":"t","count":1},"n":2}' + } as AgentRunner + + when: + runScript(''' + nextflow.enable.types = true + + record Rec { title: String; count: Long } + + agent multi { + model 'openai/gpt-4o' + tools() + input: + q: String + output: + rec: Rec + n: Long + prompt: "Q: ${q}" + } + + workflow { + multi(channel.of('x')) + } + ''') + + then: + captured.outputSchema.type == 'object' + (captured.outputSchema.properties.keySet() as List) == ['rec', 'n'] + captured.outputSchema.required == ['rec', 'n'] + captured.outputSchema.additionalProperties == false + and: 'nested record recursion is intact' + captured.outputSchema.properties.rec.type == 'object' + captured.outputSchema.properties.rec.properties.containsKey('title') + } + + // -- Test G: top-level scalar coercion (multi-output) + def 'should coerce top-level scalar outputs to the declared Java type'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + '{"count":3,"score":0.5}' + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + agent nums { + model 'openai/gpt-4o' + tools() + input: + q: String + output: + count: Long + score: Double + prompt: "Q: ${q}" + } + + workflow { + def r = nums(channel.of('x')) + [ r.count, r.score ] + } + ''') + + then: + def count = result[0].val + def score = result[1].val + count == 3 + count instanceof Long + score == 0.5d + score instanceof Double + } + + // -- Test H: unsupported top-level output type rejected with a clear message + def 'should reject an unsupported top-level output type'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'ignored' } as AgentRunner + + when: + runScript(''' + nextflow.enable.types = true + + agent bad { + model 'openai/gpt-4o' + tools() + input: + q: String + output: + label: String + items: List + prompt: "Q: ${q}" + } + + workflow { + bad(channel.of('x')) + } + ''') + + then: + def e = thrown(Exception) + allMessages(e).contains('unsupported type') + allMessages(e).contains('items') + and: 'the message names the supported output set (plan §4.5/§9)' + allMessages(e).contains('supported:') + allMessages(e).contains('record type') + } + + // -- Test I: parity smoke - the agent runs as a genuine TaskProcessor + def 'should run as a real TaskProcessor firing process-create and task-lifecycle events with a per-task work dir (zero new observer code)'() { + given: + def ran = new java.util.concurrent.atomic.AtomicBoolean(false) + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> ran.set(true); 'done' } as AgentRunner + def createdProcesses = Collections.synchronizedList([]) + def submitted = Collections.synchronizedList([]) + def completed = Collections.synchronizedList([]) + def probe = new TraceObserverV2() { + @Override void onProcessCreate(TaskProcessor process) { createdProcesses.add(process.name) } + @Override void onTaskSubmit(TaskEvent event) { submitted.add(event) } + @Override void onTaskComplete(TaskEvent event) { completed.add(event) } + } + + when: + def session = runWithObserver(probe, ''' + nextflow.enable.types = true + + agent probe_agent { + model 'openai/gpt-4o' + tools() + input: + q: String + output: + answer: String + prompt: "Q: ${q}" + } + + workflow { + probe_agent(channel.of('hi')) + } + ''') + + then: 'the agent lowered to a TaskProcessor that emitted the standard process-create event' + createdProcesses.contains('probe_agent') + and: 'the task body actually executed (LLM call ran in-JVM)' + ran.get() + and: 'task-lifecycle events fired for the agent-named task (falls out for free from the monitor)' + def submit = submitted.find { it.trace.get('process') == 'probe_agent' } + submit != null + def complete = completed.find { it.trace.get('process') == 'probe_agent' } + complete != null + and: 'the handler carries a per-task work dir under the session work dir' + def workDir = complete.handler.task.workDir + workDir != null + Files.exists(workDir) + workDir.startsWith(session.workDir) + } + + // -- Test J: parallel map with explicit executor.local.cpus (count/independence only) + def 'should map over a queue producing one output per item (parallelism config, no timing assertion)'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + "out-${req.inputJson}".toString() + } as AgentRunner + + when: + def result = runScript( + config: [executor: [local: [cpus: 4]]], + ''' + nextflow.enable.types = true + + agent mapper { + model 'openai/gpt-4o' + tools() + input: + n: Integer + output: + r: String + prompt: "N: ${n}" + } + + workflow { + mapper(channel.of(1,2,3,4,5,6,7,8)) + } + ''') + + then: 'all 8 tasks complete with 8 distinct outputs' + def vals = (1..8).collect { result.val } + vals.size() == 8 + (vals as Set).size() == 8 + } + + // -- Test K [M-Skills]: a skills-ONLY agent (declares `skills`, NO `tools`) lowers to the + // TASK path - proven by the process-create event firing for the agent-named task and the + // map fan-out invoking the runner once per queue item. On CURRENT source a skills agent is + // grouped with tools onto the legacy operator path, so NO TaskProcessor is created and + // `createdProcesses` never contains the agent name (RED). + def 'should run a skills-only agent on the task path (process-create + map fan-out)'() { + given: 'a local skill fixture beside the script (skills//SKILL.md with YAML frontmatter)' + def skillDir = Files.createDirectories(tempDir.resolve('skills').resolve('classifier')) + Files.writeString(skillDir.resolve('SKILL.md'), + "---\nname: classifier\ndescription: labels an item\n---\nClassify the given item into a label.") + def script = tempDir.resolve('main.nf') + Files.writeString(script, ''' + nextflow.enable.types = true + + agent classify { + model 'openai/gpt-4o' + skills 'classifier' + input: + item: String + output: + label: String + prompt: "Classify: ${item}" + } + + workflow { + classify(channel.of('a','b')) + } + ''') + + and: 'a runner stub that records how many times it fires and what skills each request carries' + def calls = new AtomicInteger() + def capturedSkills = Collections.synchronizedList([]) + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + calls.incrementAndGet() + capturedSkills.add(req.skills) + 'ok' + } as AgentRunner + + and: 'a probe observer to capture the process-create event (task-path-only signal)' + def createdProcesses = Collections.synchronizedList([]) + def probe = new TraceObserverV2() { + @Override void onProcessCreate(TaskProcessor process) { createdProcesses.add(process.name) } + } + + when: + runWithObserver(probe, script) + + then: 'the skills-only agent lowered to a real TaskProcessor (NOT the legacy operator)' + createdProcesses.contains('classify') + and: 'the map fanned out: one runner invocation per queue item (2 inputs -> 2 calls)' + calls.get() == 2 + and: 'every request carried the resolved skill descriptor' + capturedSkills.size() == 2 + capturedSkills.every { it != null && (it*.name as Set) == ['classifier'] as Set } + } + + // -- Test L [M-Tools (a)]: a TOOLS agent (declares `tools 'nf:module_run:greet'`, NO skills) now RUNS ON THE + // TASK path. Proven by the process-create event firing for the agent-named task, the map + // fanning out (2 inputs -> 2 runner invocations), the tool call round-tripping through the + // REAL bridge (req.toolSpecs/req.dispatch populated -> drives the real `greet` process), AND + // session.await() COMPLETING (reaching the `then` block proves the bridge was closed and the + // run did not hang). On CURRENT source a tools agent is routed to the legacy GPars operator + // (runLegacy), so NO TaskProcessor is created and `createdProcesses` never contains the agent + // name (RED). + def 'should run a tools agent on the task path (process-create + map fan-out + tool round-trip + no hang)'() { + given: 'a runner stub that drives the REAL bridge and records the fan-out' + def calls = new AtomicInteger() + def dispatchPresent = Collections.synchronizedList([]) + def toolNames = Collections.synchronizedList([]) + def dispatchResults = Collections.synchronizedList([]) + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + calls.incrementAndGet() + dispatchPresent.add(req.dispatch != null) + toolNames.addAll(req.toolSpecs*.name) + final name = new JsonSlurper().parseText(req.inputJson).toString() + // Each agent request invokes the same tool. Request-scoped channels keep the + // reversed/concurrent completions correlated with the correct caller. + final result = req.dispatch.call('greet', """{"name":"${name}"}""") + dispatchResults.add([name: name, result: result]) + 'answer' + } as AgentRunner + + and: 'a probe observer to capture the process-create event (task-path-only signal)' + def createdProcesses = Collections.synchronizedList([]) + def activeTools = new AtomicInteger() + def peakTools = new AtomicInteger() + def probe = new TraceObserverV2() { + @Override void onProcessCreate(TaskProcessor process) { createdProcesses.add(process.name) } + @Override void onTaskStart(TaskEvent event) { + if( event.handler.task.processor.name == 'greet' ) { + final count = activeTools.incrementAndGet() + peakTools.updateAndGet { int peak -> Math.max(peak, count) } + } + } + @Override void onTaskComplete(TaskEvent event) { + if( event.handler.task.processor.name == 'greet' ) + activeTools.decrementAndGet() + } + } + + when: + runWithObserver(probe, ''' + nextflow.enable.types = true + + process greet { + input: + name: String + + output: + greeting: String + + exec: + Thread.sleep(name == 'a' ? 300 : 100) + greeting = "Hello ${name}!" + } + + agent assistant { + model 'openai/gpt-4o' + tools 'nf:module_run:greet' + input: + request: String + output: + answer: String + prompt: "Handle: ${request}" + } + + workflow { + assistant(channel.of('a','b')) + } + ''') + + then: 'the tools agent lowered to a real TaskProcessor (NOT the legacy operator)' + createdProcesses.contains('assistant') + and: 'the map fanned out: one runner invocation per queue item (2 inputs -> 2 calls)' + calls.get() == 2 + and: 'each request carried the live tool descriptors + dispatch callback' + dispatchPresent.every { it } + (toolNames as Set) == ['greet'] as Set + and: 'same-tool calls executed concurrently and each reply stayed correlated' + peakTools.get() == 2 + dispatchResults.collectEntries { + [(it.name): new JsonSlurper().parseText(it.result).greeting] + } == [a: 'Hello a!', b: 'Hello b!'] + // reaching this point proves session.await() COMPLETED (the bridge was poisoned/closed); + // a leaked, un-poisoned tool queue would hang await() and trip the class @Timeout instead. + } + + // -- Test M [M-Tools (b)]: agents are cacheable, with or without tools. Uses the white-box + // buildAgentTask path (the same seam AgentResumeIntegrationTest drives) to assert the + // isCacheable() gate directly. + def 'an agent processor is cacheable with tools, without tools, and under a launch spec'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'x' } as AgentRunner + newSession() + + when: 'an fs:-tool agent (the family needs no in-scope process) is lowered' + def toolsProc = newAgent([model: 'openai/gpt-4o', tools: ['fs:*']]).buildAgentTask(['hello']) + + then: + toolsProc.getConfig().isCacheable() == true + + when: 'a tool-free agent is lowered' + def freeProc = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: 'the tool-free agent keeps the default cacheable behaviour (unchanged)' + freeProc.getConfig().isCacheable() == true + + when: 'a tool-free external runner with a canonical launch spec is lowered' + AgentRunnerProvider.testRunner = new AgentRunner() { + @Override + String getName() { 'external' } + + @Override + AgentLaunchSpec getLaunchSpec() { + new AgentLaunchSpec( + containerProxyCommand: ['/opt/proxy'], + containerHarnessCommand: ['/opt/harness']) + } + + @Override + String run(AgentRunnerRequest request) { 'x' } + } + newSession(containerized()) + def externalProc = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: 'using a launch spec does not disable resume for a tool-free agent' + externalProc.getConfig().isCacheable() == true + } + + // -- Test M3 [canonical launch path]: the broker lives in the runner plugin, so core asks the + // RESOLVED runner for the registration (AgentRunner.register) and splices it into the launch + // command at the `--` separator. Drives the task body directly with a stub runner: the whole + // core-side contract is the registration call plus the flag names, and neither is covered by + // the plugin-side broker test. + def 'should ask the resolved runner to register and splice the result into the launch command'() { + given: + def registered = [] + AgentRunnerProvider.testRunner = canonicalRunner(registered) + newSession(containerized()) + + when: 'a canonical agent task body runs' + def script = runTaskBody(newAgent([model: 'openai/gpt-4o']), new TaskConfig([container: 'agent-image:test'])) + + then: 'a canonical task is ALWAYS containerized, so the endpoint is ALWAYS dialled remotely' + registered == [[prompt: 'Q', remote: true]] + + and: 'the connection flags are spliced before the `--`, leaving the harness command last' + // the pinned certificate digest must be there: it is what stops a silent regression to an + // unpinned, cleartext connection, and unlike the token it is public so the script is fine + script == "exec '/opt/agent-rpc' '--log' 'debug' " + + "'--endpoint' 'host.docker.internal:9999' '--invocation' 'inv-1' '--fingerprint' 'abc123' '--token' 'tok-1' " + + "'--' 'node' '/opt/runner.mjs'" + } + + // -- the build-time containerization guard cannot see a LAZY `agent.container` (a closure or a + // GString resolving per task), so the body re-checks the resolved image. Without this the + // in-image proxy path would be exec'd on the host as `No such file or directory`. + def 'a canonical task whose container resolves to nothing fails loudly, never exec-ing host paths'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner([]) + newSession(containerized()) + + when: 'the per-task container resolves to null even though the build-time value was truthy' + runTaskBody(newAgent([model: 'openai/gpt-4o']), new TaskConfig([:])) + + then: + def err = thrown(nextflow.exception.ScriptRuntimeException) + err.message.contains('agent.container') + } + + // -- an agent whose configuration would NOT run the task in a container is rejected BEFORE the + // run starts: the launch command is made of in-image paths only. + def 'a canonical agent is rejected when the resolved configuration would not containerize it'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner([]) + + when: 'no `agent.container` is declared' + newSession([docker: [enabled: true]]) + newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: + def missing = thrown(nextflow.exception.ScriptRuntimeException) + missing.message.contains('must declare a container') + + when: 'an image is declared but no container engine is enabled' + newSession([agent: [container: 'agent-image:test']]) + newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: + def disabled = thrown(nextflow.exception.ScriptRuntimeException) + disabled.message.contains('would not run it in a container') + } + + // -- the agent task dials the driver's broker back at `host.docker.internal` when no explicit + // address is configured; on Linux Docker that name resolves ONLY when the container is run + // with `--add-host=...:host-gateway`, so the driver adds it to the agent task itself instead + // of leaving the most common local setup to fail with a connection timeout. + def 'a locally containerized agent task is given the docker host-gateway run option'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner([]) + + when: 'docker runs the container and no `agent.rpc.remoteHost` is set' + newSession(containerized()) + def proc = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: + proc.getConfig().get('containerOptions') == '--add-host=host.docker.internal:host-gateway' + + when: 'an explicit driver address is configured instead' + newSession(containerized([agent: [rpc: [remoteHost: '127.0.0.1']]])) + proc = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: 'nothing is added - the alias is not what the task uses' + proc.getConfig().get('containerOptions') == null + + when: 'the agent declares container options of its own' + newSession(containerized([agent: [containerOptions: '--cpus 2']])) + proc = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: 'they are preserved, never replaced' + proc.getConfig().get('containerOptions') == '--cpus 2 --add-host=host.docker.internal:host-gateway' + } + + // -- Test M4: a runner that publishes a launch spec but no register() override fails loudly + // (the AgentRunner default), instead of silently launching a proxy with no endpoint. + def 'should fail with a clear error when a launch-spec runner does not implement register'() { + given: + AgentRunnerProvider.testRunner = new AgentRunner() { + @Override + String getName() { 'no-broker' } + + @Override + AgentLaunchSpec getLaunchSpec() { + new AgentLaunchSpec(containerProxyCommand: ['/opt/proxy'], containerHarnessCommand: ['/opt/harness']) + } + + @Override + String run(AgentRunnerRequest request) { 'x' } + } + newSession(containerized()) + + when: + runTaskBody(newAgent([model: 'openai/gpt-4o']), new TaskConfig([container: 'agent-image:test'])) + + then: + def err = thrown(UnsupportedOperationException) + err.message == 'Agent runner `no-broker` provides a launch spec but no RPC broker' + } + + // -- Test M2: process selectors do not configure agents. + // -- an agent is admitted without a cpu/capacity throttle, so absent a cap it fans out as wide + // as its input channel. A default bounds concurrent LLM calls; the user's value still wins. + def 'an agent gets a default maxForks that an explicit value overrides'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'x' } as AgentRunner + + when: 'nothing is declared' + newSession() + def defaulted = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: + defaulted.getMaxForks() == AgentConfig.DEFAULT_MAX_FORKS + + when: 'the agent scope raises it' + newSession([agent: [maxForks: 42]]) + def raised = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: 'the user value wins over the default' + raised.getMaxForks() == 42 + } + + def 'an agent ignores a process selector that sets cache false'() { + given: 'a process selector that would have matched the agent under the old shared scope' + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'x' } as AgentRunner + newSession([process: ['withName:qa': [cache: false]]]) + + when: 'a tools agent named qa is lowered from the independent agent scope' + def toolsProc = newAgent([model: 'openai/gpt-4o', tools: ['fs:*']]).buildAgentTask(['hello']) + + then: 'the process selector is irrelevant and the agent keeps its default cacheability' + toolsProc.getConfig().isCacheable() == true + } + + // -- the counterpart: the same setting in the `agent` scope IS honoured. + def 'an agent honours an agent selector that sets cache false'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'x' } as AgentRunner + newSession([agent: ['withName:qa': [cache: false]]]) + + when: + def toolsProc = newAgent([model: 'openai/gpt-4o', tools: ['fs:*']]).buildAgentTask(['hello']) + + then: + toolsProc.getConfig().isCacheable() == false + } + + // -- the RESOLVED Executor is the oracle for agent placement, not the config map: the + // TaskProcessor holds the Executor instance and never re-derives it from the config, so + // an `executor` write that lands AFTER createTaskProcessorResolved silently does nothing. + // Asserting only config.executor would keep passing if the ordering regressed, while every + // in-JVM agent quietly ran on the compute executor -- and an agent body blocks on its own + // tool sub-tasks, so that deadlocks concurrent tool agents. + def 'an in-JVM agent is resolved onto the agent executor, not merely configured for it'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'x' } as AgentRunner + newSession() + + when: + def proc = newAgent([model: 'openai/gpt-4o', tools: ['fs:*']]).buildAgentTask(['hello']) + + then: 'the Executor the processor actually holds' + proc.getExecutor().getName() == 'agent' + and: 'and the config it was resolved from' + proc.getConfig().get('executor') == 'agent' + + when: 'a launch-spec runner is used instead -- it is NOT pinned to the agent executor' + // a FRESH session is required: ExecutorFactory caches the resolved Executor by class and + // MockExecutorFactory maps every executor name to MockExecutor, so reusing the session + // above would hand back the instance already named `agent` and the check would be vacuous + newSession(containerized()) + AgentRunnerProvider.testRunner = new AgentRunner() { + @Override + String getName() { 'external' } + + @Override + AgentLaunchSpec getLaunchSpec() { + new AgentLaunchSpec( + containerProxyCommand: ['/opt/proxy'], + containerHarnessCommand: ['/opt/harness']) + } + + @Override + String run(AgentRunnerRequest request) { 'x' } + } + def offloadable = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + + then: 'it resolves to the standard agent default, proving the assertion above discriminates' + offloadable.getExecutor().getName() == AgentConfig.DEFAULT_EXECUTOR + } + + // -- Test N [M-Tools (c)]: a hard-failing tool aborts the run cleanly (the AgentToolFatalError + // path) rather than hanging. Deterministically reproduces the session-abort interrupt by + // swapping the wired tool's captured output channel with a stub whose getVal() throws the + // GPars-wrapped InterruptedException (== a session abort on task failure), exactly as + // ModuleToolBridgeTaskFailureTest does. Here the AgentToolFatalError is left UNCAUGHT so it + // propagates out of the runner and must abort the run. On CURRENT source the agent runs on + // the legacy operator, so the process-create event never fires for the agent (RED); the run + // must still abort (not hang), surfacing an AgentToolFatalError. + def 'a hard-failing request-scoped tool aborts the run cleanly on the task path'() { + given: 'a runner stub that swaps the tool output for one that throws the abort interrupt, then calls the tool (uncaught)' + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + // do NOT catch: the failed process aborts the session, interrupts the + // correlated reply pull and must surface as AgentToolFatalError. + req.dispatch.call('greet', '{"name":"Ada"}') + } as AgentRunner + + and: 'a probe observer to capture the process-create event (task-path-only signal)' + def createdProcesses = Collections.synchronizedList([]) + def probe = new TraceObserverV2() { + @Override void onProcessCreate(TaskProcessor process) { createdProcesses.add(process.name) } + } + + when: + Throwable err = null + try { + runWithObserver(probe, ''' + nextflow.enable.types = true + + process greet { + input: + name: String + + output: + greeting: String + + exec: + throw new IllegalStateException('tool failed') + } + + agent assistant { + model 'openai/gpt-4o' + tools 'nf:module_run:greet' + input: + request: String + output: + answer: String + prompt: "Handle: ${request}" + } + + workflow { + assistant(channel.of('hi')) + } + ''') + } + catch( Throwable t ) { + err = t + } + + then: 'the tools agent lowered to a real TaskProcessor (NOT the legacy operator)' + createdProcesses.contains('assistant') + createdProcesses.contains('greet') + and: 'the fatal tool error aborted the run (it did not hang and did not silently succeed)' + err != null + } + + // -- Test P: endpoint/credential plumbing (design D4). The ladder is resolved ONCE, in core, + // and handed to whichever runner is selected -- no runner reads the environment. + def 'should hand the resolved endpoint and credential to the runner without leaking the key'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'ok' } as AgentRunner + and: 'an empty environment, so only the config can supply a value' + SysEnv.push([:]) + + when: + def result = runScript(config: [agent: [apiKey: 'sk-canary-51ad', baseUrl: 'http://localhost:8000/v1']], ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + tools() + input: + q: String + output: + answer: String + prompt: "Q: ${q}" + } + + workflow { + qa(channel.of('hello')) + } + ''') + + then: 'both reach the runner through the request' + result.val == 'ok' + captured.apiKey == 'sk-canary-51ad' + captured.baseUrl == 'http://localhost:8000/v1' + + and: 'but the portable payload that crosses the plaintext RPC link carries only the endpoint' + AgentProtocolSpec.fromRequest(captured).baseUrl == 'http://localhost:8000/v1' + !AgentProtocolSpec.fromRequest(captured).containsKey('apiKey') + + and: 'and an interpolated request cannot leak the credential into the log' + !"$captured".toString().contains('sk-canary-51ad') + + cleanup: + SysEnv.pop() + } + + // ----------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------- + + /** + * The minimal configuration that CONTAINERIZES a canonical agent task -- an enabled container + * engine plus an `agent.container` image -- which every launch-spec runner now requires, since + * its launch command is built from paths that exist only inside the runner image. + */ + private static Map containerized(Map config = [:]) { + final result = new LinkedHashMap(config) + result.docker = [enabled: true] + final agentScope = new LinkedHashMap((Map) (config.agent ?: [:])) + agentScope.container = 'agent-image:test' + result.agent = agentScope + return result + } + + /** + * A launch-spec runner publishing only in-image paths, recording each registration into + * {@code registered} so a test can assert what core asked the broker for. + */ + private static AgentRunner canonicalRunner(List registered) { + return new AgentRunner() { + @Override + String getName() { 'external' } + + @Override + AgentLaunchSpec getLaunchSpec() { + new AgentLaunchSpec( + containerProxyCommand: ['/opt/agent-rpc', '--log', 'debug'], + containerHarnessCommand: ['node', '/opt/runner.mjs']) + } + + @Override + AgentRpcRegistration register(AgentRunnerRequest request, boolean remote) { + registered << [prompt: request.prompt, remote: remote] + // a fingerprint (or the explicit --insecure opt-out) is mandatory: transportArgs() + // rejects a registration carrying neither rather than dialling unpinned + return new AgentRpcRegistration('inv-1', 'tok-1', 'host.docker.internal:9999', 'abc123') + } + + @Override + String run(AgentRunnerRequest request) { throw new UnsupportedOperationException('canonical task path') } + } + } + + /** Spin a MockSession (MockExecutorFactory) and make it the global session (white-box build path). */ + private Session newSession(Map config = null) { + def session = config ? new MockSession(config) : new MockSession() + session.setBinding(new ScriptBinding()) + session.init(null, null, null, null) + session.start() + Global.session = session + return session + } + + /** + * Lower the agent and invoke the synthesized task body against a stand-in task context, so the + * canonical launch command can be asserted without igniting the dataflow network. The body is + * DELEGATE_ONLY over the task context: a plain map supplying the declared input and `task` is + * all it reads. + */ + private static String runTaskBody(AgentDef agent, TaskConfig taskConfig) { + final body = (Closure) agent.buildAgentTask(['hello']).getTaskBody().closure.clone() + body.setDelegate([q: 'hello', task: taskConfig]) + body.setResolveStrategy(Closure.DELEGATE_ONLY) + return body.call() + } + + /** Construct an {@link AgentDef} directly (single String in/out, trivial prompt) for white-box builds. */ + private AgentDef newAgent(Map directives = [model: 'openai/gpt-4o']) { + final owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + return new AgentDef(owner, 'qa', directives as Map, + [new AgentInput('q', String)], [new AgentOutput('answer', String)], + new PromptDef({ -> 'Q' }, 'Q')) + } + + /** + * Path-based variant of {@link #runWithObserver(TraceObserverV2, String)}: initializes the real + * {@link Session} from a {@link ScriptFile} so the script's base/module dir is set and a local + * {@code skills/} directory beside the script resolves. Injects the probe observer into the + * private {@code observersV2} list before ignition (no public registration API), then runs. + */ + private static Session runWithObserver(TraceObserverV2 probe, Path script) { + final workDir = Files.createTempDirectory('nxf-agent-skills-test') + def session = new Session([workDir: workDir.toString()]) + session.setBinding(new ScriptBinding()) + session.init(new ScriptFile(script), null, null, null) + final f = Session.getDeclaredField('observersV2') + f.setAccessible(true) + final list = new ArrayList((List) f.get(session)) + list.add(probe) + f.set(session, list) + session.start() + + def loader = ScriptLoaderFactory.create(session) + loader.parse(script) + loader.runScript() + + session.fireDataflowNetwork() + session.await() + session.destroy() + if( session.error ) + throw session.error + return session + } + + /** Concatenate the message of a throwable and its cause chain. */ + private static String allMessages(Throwable t) { + final sb = new StringBuilder() + while( t != null ) { + if( t.message ) sb.append(t.message).append(' | ') + t = t.cause + } + return sb.toString() + } + + /** + * Drive the workflow through a *real* {@link Session} (real {@code ExecutorFactory}, + * {@code LocalExecutor}, {@code TaskPollingMonitor} and {@code NativeTaskHandler}) so that + * the task-lifecycle observer notifications ({@code notifyTaskSubmit/Start/Complete}) and a + * real per-task work dir actually fall out of the run — the MockSession harness used by the + * other tests short-circuits the monitor and never fires those events. A probe + * {@link TraceObserverV2} is injected into the private {@code observersV2} list before + * ignition (there is no public observer-registration API). Uses an isolated temp work dir + * and returns the live {@link Session} so callers can assert on {@code session.workDir}. + */ + private static Session runWithObserver(TraceObserverV2 probe, String text) { + final workDir = Files.createTempDirectory('nxf-agent-test') + def session = new Session([workDir: workDir.toString()]) + session.setBinding(new ScriptBinding()) + session.init(null, null, null, null) + // inject the probe into the private observersV2 list before ignition + final f = Session.getDeclaredField('observersV2') + f.setAccessible(true) + final list = new ArrayList((List) f.get(session)) + list.add(probe) + f.set(session, list) + session.start() + + def loader = ScriptLoaderFactory.create(session) + loader.parse(text) + loader.runScript() + + session.fireDataflowNetwork() + session.await() + session.destroy() + if( session.error ) + throw session.error + return session + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentCallInfoTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentCallInfoTest.groovy new file mode 100644 index 0000000000..efd20df314 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentCallInfoTest.groovy @@ -0,0 +1,55 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import spock.lang.Specification + +/** + * Tests for the core-owned {@link AgentCallInfo} ThreadLocal seam (design §9.5/D6). + */ +class AgentCallInfoTest extends Specification { + + def cleanup() { + AgentCallInfo.clear() + } + + def 'should set and consume the resolved model round-trip'() { + when: + AgentCallInfo.setResolvedModel('gpt-4o-2024-08-06') + then: + AgentCallInfo.consumeResolvedModel() == 'gpt-4o-2024-08-06' + } + + def 'consume should clear the value (second consume returns null)'() { + given: + AgentCallInfo.setResolvedModel('m1') + expect: + AgentCallInfo.consumeResolvedModel() == 'm1' + AgentCallInfo.consumeResolvedModel() == null + } + + def 'consume should be null-safe when nothing was set'() { + expect: + AgentCallInfo.consumeResolvedModel() == null + } + + def 'should tolerate a null resolved model'() { + when: + AgentCallInfo.setResolvedModel(null) + then: + AgentCallInfo.consumeResolvedModel() == null + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentConfigSelectorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentConfigSelectorTest.groovy new file mode 100644 index 0000000000..90fd3425eb --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentConfigSelectorTest.groovy @@ -0,0 +1,578 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.Global +import nextflow.Session +import nextflow.SysEnv +import nextflow.exception.ConfigParseException +import nextflow.exception.IllegalConfigException +import nextflow.exception.ScriptRuntimeException +import nextflow.processor.ConfigList +import nextflow.script.AgentBuilder.AgentInput +import nextflow.script.AgentBuilder.AgentOutput +import nextflow.script.AgentDef +import nextflow.script.BaseScript +import nextflow.script.PromptDef +import nextflow.script.ScriptBinding +import org.junit.Rule +import spock.lang.Timeout +import test.Dsl2Spec +import test.MockSession +import test.OutputCapture + +/** + * The `agent` config scope resolves task directives with the SAME semantics as the + * `process` scope (selectors, precedence, `ext` merge, repeatable directives) while + * staying fully independent from it. + * + * The selector matchers themselves ({@code matchesSelector}/{@code matchesLabels}) and + * the repeat/ext machinery are reused verbatim from + * {@link nextflow.script.dsl.ProcessConfigBuilder} and are pinned by + * {@code ProcessConfigBuilderTest}; this spec pins the AGENT wiring of that machinery. + * + * @author Paolo Di Tommaso + */ +@Timeout(30) +class AgentConfigSelectorTest extends Dsl2Spec { + + @Rule + OutputCapture capture = new OutputCapture() + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + /** + * Spin a MockSession (MockExecutorFactory) and make it the global session, adding the + * configuration that CONTAINERIZES a canonical agent task -- an enabled container engine plus an + * `agent.container` image, which every launch-spec runner now requires because its launch command + * is built from paths that exist only inside the runner image. This spec is about selector + * RESOLUTION, so the containerization is boilerplate here; the tests that deliberately exercise a + * missing or disabled image use {@link #bareSession} instead. + * + *

An `agent.container` already present in the PLAIN scope is preserved, and a selector-provided + * one still wins over the injected plain value by the normal precedence ladder. + */ + private Session newSession(Map config = null) { + final Map merged = new LinkedHashMap(config ?: [:]) + merged.docker = [enabled: true] + final Map agentScope = new LinkedHashMap((Map) (merged.agent ?: [:])) + agentScope.container = agentScope.container ?: 'agent-image:test' + merged.agent = agentScope + return bareSession(merged) + } + + /** Spin a MockSession with EXACTLY the given config (no containerization defaults). */ + private Session bareSession(Map config = null) { + def session = config ? new MockSession(config) : new MockSession() + session.setBinding(new ScriptBinding()) + session.init(null, null, null, null) + session.start() + Global.session = session + return session + } + + private AgentDef newAgent(Map directives = [model: 'openai/gpt-4o']) { + final owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + return new AgentDef(owner, 'qa', directives as Map, + [new AgentInput('q', String)], [new AgentOutput('answer', String)], + new PromptDef({ -> 'Q' }, 'Q')) + } + + /** + * A launch-spec runner. With no argument it declares NO image of its own, which is the shape + * every containerization spec below assumes; pass a coordinate for the pi-shaped runner that + * generates one from its own VERSION. + */ + private static AgentRunner canonicalRunner(String defaultContainer = null) { + return new AgentRunner() { + @Override String getName() { 'canonical-test' } + @Override AgentLaunchSpec getLaunchSpec() { + new AgentLaunchSpec(['/agent-rpc'], ['node']) + } + @Override String getDefaultContainer() { defaultContainer } + @Override String run(AgentRunnerRequest req) { 'x' } + } + } + + def 'a withName selector beats the plain agent scope and only matches its target'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + + when: + newSession([agent: [cpus: 1, 'withName:qa': [cpus: 2]]]) + then: + newAgent().buildAgentTask(['hello']).config.cpus == 2 + + when: 'the selector names a different agent' + newSession([agent: [cpus: 1, 'withName:other': [cpus: 2]]]) + then: + newAgent().buildAgentTask(['hello']).config.cpus == 1 + } + + def 'a withLabel selector matches an agent declaring that label'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + + when: 'the agent declares the label' + newSession([agent: [cpus: 1, 'withLabel:reasoning': [cpus: 4]]]) + then: + newAgent([model: 'openai/gpt-4o', label: ['reasoning']]).buildAgentTask(['hello']).config.cpus == 4 + + when: 'the agent declares no label' + newSession([agent: [cpus: 1, 'withLabel:reasoning': [cpus: 4]]]) + then: + newAgent().buildAgentTask(['hello']).config.cpus == 1 + + when: 'a negated label selector faces a label-less agent' + newSession([agent: [cpus: 1, 'withLabel:!reasoning': [cpus: 4]]]) + then: + newAgent().buildAgentTask(['hello']).config.cpus == 4 + } + + def 'the selector precedence ladder resolves fully-qualified over alias, base, label and plain'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + def scope = [ + cpus: 1, + 'withLabel:reasoning': [cpus: 2], + 'withName:qa': [cpus: 3], + 'withName:WF:qa': [cpus: 4], + ] + def agent = { -> (AgentDef) newAgent([model: 'openai/gpt-4o', label: ['reasoning']]).cloneWithName('WF:qa') } + + when: + newSession([agent: scope]) + then: 'the fully-qualified rule wins' + agent().buildAgentTask(['hello']).config.cpus == 4 + + when: 'the fully-qualified rule is removed' + newSession([agent: scope.findAll { it.key != 'withName:WF:qa' }]) + then: 'the base-name rule wins' + agent().buildAgentTask(['hello']).config.cpus == 3 + + when: 'the name rules are removed' + newSession([agent: [cpus: 1, 'withLabel:reasoning': [cpus: 2]]]) + then: 'the label rule wins' + agent().buildAgentTask(['hello']).config.cpus == 2 + + when: 'no selector is left' + newSession([agent: [cpus: 1]]) + then: 'the plain scope applies' + agent().buildAgentTask(['hello']).config.cpus == 1 + } + + def 'an aliased include still matches the declared name and the alias'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + + when: 'the selector names the DECLARED agent (baseName is preserved by cloneWithName)' + newSession([agent: ['withName:qa': [cpus: 3]]]) + then: + newAgent().cloneWithName('reviewer').buildAgentTask(['hello']).config.cpus == 3 + + when: 'the selector names the alias' + newSession([agent: ['withName:reviewer': [cpus: 5]]]) + then: + newAgent().cloneWithName('reviewer').buildAgentTask(['hello']).config.cpus == 5 + } + + def 'ext maps merge instead of replacing'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + + when: 'plain ext and a selector ext declare different keys' + newSession([agent: [ext: [args: '--verbose'], 'withName:qa': [ext: [opts: '--fast']]]]) + def config = newAgent().buildAgentTask(['hello']).config + then: 'both survive' + config.ext == [args: '--verbose', opts: '--fast'] + + when: 'the selector overwrites the same key' + newSession([agent: [ext: [args: '--verbose'], 'withName:qa': [ext: [args: '--fast']]]]) + then: 'the selector wins and the plain value does not come back' + newAgent().buildAgentTask(['hello']).config.ext == [args: '--fast'] + } + + def 'the label directive goes through the repeatable-directive path'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + + when: 'only a selector sets the label' + newSession([agent: ['withName:qa': [label: 'x']]]) + def config = newAgent().buildAgentTask(['hello']).config + then: 'the value is a validated ConfigList, as for a process' + config.label instanceof ConfigList + config.getLabels() == ['x'] + + when: 'the body declares a label and a matching withLabel selector sets another' + newSession([agent: ['withLabel:reasoning': [label: 'fast']]]) + def config2 = newAgent([model: 'openai/gpt-4o', label: ['reasoning']]).buildAgentTask(['hello']).config + then: 'exact `process` parity: ProcessConfigBuilder.putWithRepeat REPLACES the declared list,' + // and the selector still matched, because applyConfig reads the DECLARED labels on entry + config2.getLabels() == ['fast'] + + when: 'a body label is declared and no selector touches `label`' + newSession([agent: [cpus: 1]]) + then: 'the declared labels reach the config' + newAgent([model: 'openai/gpt-4o', label: ['reasoning', 'fast']]).buildAgentTask(['hello']).config.getLabels() == ['reasoning', 'fast'] + + when: 'the declared label is not a valid label' + newSession([agent: [cpus: 1]]) + newAgent([model: 'openai/gpt-4o', label: ['a-b']]).buildAgentTask(['hello']) + then: 'the diagnostic names the AGENT: `label` is the one directive method agents share' + def error = thrown(IllegalConfigException) + error.message.startsWith('Not a valid agent label: a-b') + } + + def 'agent-only options never reach the task config and never warn'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + // `rpc` is a NESTED ConfigScope field, not a @ConfigOption: it must still be excluded from + // the directive axis, or it lands in every agent's config as a phantom `rpc` directive + // (silently in the plain scope, as `Unknown directive` inside a selector) + def agentOnly = ['runner', 'model', 'apiProvider', 'apiKey', 'baseUrl', 'maxIterations', 'requestTimeout', + 'maxToolOutputInlineSize', 'trace', 'rpc'] + + when: 'the agent-only options are set in the plain scope' + newSession([agent: [runner: 'test', model: 'm', apiProvider: 'openai', apiKey: 'sk-x', baseUrl: 'http://x/v1', maxIterations: 7, rpc: [port: 1234], cpus: 1]]) + def config = newAgent().buildAgentTask(['hello']).config + then: + agentOnly.every { !config.containsKey(it) } + config.cpus == 1 + !capture.toString().contains('Unknown directive') + + when: 'the same options are set inside a selector' + newSession([agent: ['withName:qa': [runner: 'test', model: 'm', apiProvider: 'openai', apiKey: 'sk-x', baseUrl: 'http://x/v1', maxIterations: 7, rpc: [port: 1234], cpus: 2]]]) + def config2 = newAgent().buildAgentTask(['hello']).config + then: + agentOnly.every { !config2.containsKey(it) } + config2.cpus == 2 + !capture.toString().contains('Unknown directive') + } + + def 'an unknown directive is reported with the agent noun'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + newSession([agent: ['withName:qa': [fooBar: 1]]]) + + when: + newAgent().buildAgentTask(['hello']) + + then: + capture.toString().contains('Unknown directive `fooBar` for agent `qa`') + } + + def 'the local executor default does not shadow a selector'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + + when: 'no executor is configured' + newSession([agent: [cpus: 1]]) + then: + newAgent().buildAgentTask(['hello']).config.executor == AgentConfig.DEFAULT_EXECUTOR + + // an offloaded agent must also declare a container, and -- because its container is + // launched off the driver host -- an address the container can reach the driver on + when: 'a selector sets the executor' + newSession([agent: [ + rpc: [remoteHost: 'driver.internal'], + 'withName:qa': [executor: 'k8s', container: 'agent-image:1'] ]]) + then: + newAgent().buildAgentTask(['hello']).config.executor == 'k8s' + } + + def 'an explicitly declared agent.container resolves through the ladder'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + + when: 'the plain scope sets a container' + bareSession([docker: [enabled: true], agent: [container: 'x']]) + then: + newAgent().buildAgentTask(['hello']).config.container == 'x' + + when: 'a selector sets a container' + bareSession([docker: [enabled: true], agent: ['withName:qa': [container: 'y']]]) + then: + newAgent().buildAgentTask(['hello']).config.container == 'y' + + when: 'a selector overrides the plain scope' + bareSession([docker: [enabled: true], agent: [container: 'x', 'withName:qa': [container: 'y']]]) + then: + newAgent().buildAgentTask(['hello']).config.container == 'y' + } + + // -- a runner whose runtime lives IN an image knows which image that is (nf-agent-pi generates + // the coordinate from its own VERSION), so `agent.container` is optional for it. The value + // lands in the config exactly where an explicit one would, so nothing downstream -- the + // containerization guard, the per-task re-check, the container fingerprint in the task hash + // -- has to know it was defaulted. + def 'a runner that declares an image of its own defaults agent.container to it'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner('registry.io/nf-agent-pi:1.2.3') + + when: 'nothing in the config declares a container' + bareSession([docker: [enabled: true], agent: [:]]) + then: + newAgent().buildAgentTask(['hello']).config.container == 'registry.io/nf-agent-pi:1.2.3' + + when: 'and with the agent offloaded to a remote executor' + bareSession([docker: [enabled: true], agent: [executor: 'k8s', rpc: [remoteHost: 'driver.internal']]]) + then: + newAgent().buildAgentTask(['hello']).config.container == 'registry.io/nf-agent-pi:1.2.3' + } + + def 'an explicit agent.container beats the runner image everywhere on the ladder'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner('registry.io/nf-agent-pi:1.2.3') + + when: 'the plain scope declares one' + bareSession([docker: [enabled: true], agent: [container: 'mine:1']]) + then: + newAgent().buildAgentTask(['hello']).config.container == 'mine:1' + + when: 'a `withLabel:` selector declares one -- the default must not pre-empt the ladder' + bareSession([docker: [enabled: true], agent: ['withLabel:reasoning': [container: 'mine:2']]]) + then: + newAgent([model: 'openai/gpt-4o', label: ['reasoning']]).buildAgentTask(['hello']).config.container == 'mine:2' + } + + def 'the explicit container opt-out survives a runner that declares an image'() { + given: '`agent.container = false` is present-with-value-false, not absent' + AgentRunnerProvider.testRunner = canonicalRunner('registry.io/nf-agent-pi:1.2.3') + bareSession([docker: [enabled: true], agent: [container: false]]) + + when: + newAgent().buildAgentTask(['hello']) + + then: 'the opt-out keeps meaning "no container", so the ORIGINAL message still fires' + def e = thrown(ScriptRuntimeException) + e.message.contains('must declare a container') + } + + // -- the most common first run of a pi agent: the plugin is installed, no `agent.container` is + // declared and no container engine is enabled. With the image now defaulted, the `!hasContainer` + // branch is unreachable and this is the message the user gets, so it must not tell them they + // declared something they did not. + def 'a defaulted image with no engine enabled is rejected without blaming the user for it'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner('registry.io/nf-agent-pi:1.2.3') + bareSession([agent: [:]]) + + when: + newAgent().buildAgentTask(['hello']) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('would not run it in a container') + e.message.contains('docker.enabled') + and: 'the user declared no `agent.container`, so the message must not say they did' + !e.message.contains('declares `agent.container`') + } + + def 'a canonical agent without a container fails fast on EVERY executor'() { + given: 'the launch command is built from paths that exist only inside the runner image' + AgentRunnerProvider.testRunner = canonicalRunner() + + when: 'nothing is configured -- and this runner declares no image of its own' + bareSession([docker: [enabled: true], agent: [:]]) + newAgent().buildAgentTask(['hello']) + + then: + def local = thrown(ScriptRuntimeException) + local.message.contains('must declare a container') + + when: 'the agent is offloaded to a remote executor with no image' + bareSession([docker: [enabled: true], agent: [executor: 'k8s']]) + newAgent().buildAgentTask(['hello']) + + then: + def offloaded = thrown(ScriptRuntimeException) + offloaded.message.contains('must declare a container') + + when: 'the container is explicitly disabled (present-with-value-false)' + bareSession([docker: [enabled: true], agent: [executor: 'k8s', container: false]]) + newAgent().buildAgentTask(['hello']) + + then: 'the opt-out is rejected exactly like an absent image' + def disabledError = thrown(ScriptRuntimeException) + disabledError.message.contains('must declare a container') + + // `rpc.remoteHost` joins the image as a requirement once the executor is remote: the + // container is launched off the driver host, so no engine alias can name the driver + when: 'a container is declared' + bareSession([docker: [enabled: true], agent: [ + executor: 'k8s', container: 'agent-image:1', rpc: [remoteHost: 'driver.internal'] ]]) + then: + newAgent().buildAgentTask(['hello']).config.container == 'agent-image:1' + } + + def 'a canonical agent with an image but no container engine fails fast'() { + given: 'a non-container-native executor with every engine disabled would run the in-image paths on the host' + AgentRunnerProvider.testRunner = canonicalRunner() + bareSession([agent: [container: 'agent-image:1']]) + + when: + newAgent().buildAgentTask(['hello']) + + then: + def error = thrown(ScriptRuntimeException) + error.message.contains('would not run it in a container') + error.message.contains('docker.enabled') + } + + def 'a process selector never configures an agent'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + newSession([process: ['withName:qa': [cpus: 9]], agent: [cpus: 1]]) + + expect: + newAgent().buildAgentTask(['hello']).config.cpus == 1 + } + + def 'a plain value beats a matching selector when the selector value equals the built-in default'() { + given: 'the inherited `process` quirk: applyConfigDefaults re-applies a plain value when the' + // current value still EQUALS ProcessConfig.DEFAULT_CONFIG (ProcessConfigBuilder.applyConfigDefaults), + // and DEFAULT_CONFIG.maxRetries == 1. This is exact `process` parity, deliberately not fixed. + AgentRunnerProvider.testRunner = canonicalRunner() + newSession([agent: [maxRetries: 5, 'withName:qa': [maxRetries: 1]]]) + + expect: + newAgent().buildAgentTask(['hello']).config.maxRetries == 5 + } + + def 'the agent-only axis is selector-resolved end to end'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + + when: + newSession([agent: [model: 'm0', 'withName:qa': [model: 'openai/gpt-5', maxIterations: 40]]]) + def config = newAgent([:]).agentConfig() + then: + config.model == 'openai/gpt-5' + config.maxIterations == 40 + + when: 'an agent declaring neither `model` nor `maxIterations` is built' + // the EFFECTIVE resolved values enter the canonical task identity (BodyDef.source), + // so this reads what buildAgentTask actually consumed + def defaulted = newAgent([:]).buildAgentTask(['hello']).getTaskBody().source + then: 'the selector-resolved defaults apply' + defaulted.contains('agentModel=openai/gpt-5\n') + defaulted.contains('maxIterations=40\n') + + when: 'the agent declares its own model and maxIterations' + def declared = newAgent([model: 'openai/gpt-4o', maxIterations: 3]).buildAgentTask(['hello']).getTaskBody().source + then: 'the body directives still win -- the config options are DEFAULTS on every rung' + declared.contains('agentModel=openai/gpt-4o\n') + declared.contains('maxIterations=3\n') + } + + def 'a selector setting only apiKey leaves the outer baseUrl intact (the per-key merge)'() { + given: 'this is WHY the endpoint and the credential are FLAT options (design D3):' + // AgentConfig.copyOptions merges key by key, so a selector overriding one leaf cannot + // silently drop its sibling. A nested `agent.config { }` container would make the whole + // container the unit of copy, and a selector setting one leaf would lose the other. + AgentRunnerProvider.testRunner = canonicalRunner() + and: 'an empty environment, so an exported OPENAI_* on the dev machine cannot supply a tier' + SysEnv.push([:]) + + when: 'the selector overrides only the credential' + newSession([agent: [apiKey: 'sk-outer', baseUrl: 'http://outer/v1', 'withName:qa': [apiKey: 'sk-inner']]]) + def merged = newAgent([:]).agentConfig() + then: 'the selector wins for `apiKey` and the outer `baseUrl` SURVIVES' + merged.apiKey == 'sk-inner' + merged.baseUrl == 'http://outer/v1' + + when: 'the selector overrides only the endpoint' + newSession([agent: [apiKey: 'sk-outer', baseUrl: 'http://outer/v1', 'withName:qa': [baseUrl: 'http://inner/v1']]]) + def swapped = newAgent([:]).agentConfig() + then: 'symmetrically, the outer credential survives' + swapped.apiKey == 'sk-outer' + swapped.baseUrl == 'http://inner/v1' + + when: 'the selector names a DIFFERENT agent' + newSession([agent: [apiKey: 'sk-outer', baseUrl: 'http://outer/v1', 'withName:other': [apiKey: 'sk-inner']]]) + def untouched = newAgent([:]).agentConfig() + then: 'the plain scope applies to both keys' + untouched.apiKey == 'sk-outer' + untouched.baseUrl == 'http://outer/v1' + + when: 'only a selector declares them (nothing in the plain scope)' + newSession([agent: ['withName:qa': [apiKey: 'sk-inner', baseUrl: 'http://inner/v1']]]) + def selectorOnly = newAgent([:]).agentConfig() + then: + selectorOnly.apiKey == 'sk-inner' + selectorOnly.baseUrl == 'http://inner/v1' + + cleanup: + SysEnv.pop() + } + + def 'a selector setting only apiProvider names the credential namespace for that agent alone'() { + given: 'the namespace is per-agent like every other agent-only option (design D1): one' + // pipeline can reach OpenRouter through the openai protocol for one agent and OpenAI proper + // for another, and `apiProvider` is the only thing that tells the two credentials apart + AgentRunnerProvider.testRunner = canonicalRunner() + SysEnv.push([OPENAI_API_KEY: 'sk-openai', OPENROUTER_API_KEY: 'sk-openrouter']) + + when: 'only the selector names the namespace, over a plain-scope endpoint' + newSession([agent: [baseUrl: 'https://gw.corp/v1', 'withName:qa': [apiProvider: 'openrouter']]]) + def selected = newAgent([:]).agentConfig() + then: 'the sibling endpoint survives the per-key merge, and the namespace redirects the credential' + selected.apiProvider == 'openrouter' + selected.baseUrl == 'https://gw.corp/v1' + selected.apiKeyFor('openai/gpt-4o') == 'sk-openrouter' + + when: 'the selector names a DIFFERENT agent' + newSession([agent: [baseUrl: 'https://gw.corp/v1', 'withName:other': [apiProvider: 'openrouter']]]) + def untouched = newAgent([:]).agentConfig() + then: 'nothing vouches for the gateway, so the ambient provider key is withheld from it' + untouched.apiProvider == null + untouched.apiKeyFor('openai/gpt-4o') == null + + cleanup: + SysEnv.pop() + } + + def 'a dynamic directive resolves against the task context'() { + given: 'the nf-lang `isProcessScope` fix makes `task` resolvable in an agent-scope closure;' + // this pins that the closure survives applyConfig into the agent's ProcessConfigV2 + // and is evaluated per task, not stored as a literal + AgentRunnerProvider.testRunner = canonicalRunner() + newSession([agent: ['withName:qa': [cpus: { task.attempt * 2 }]]]) + + when: + def taskConfig = newAgent().buildAgentTask(['hello']).config.createTaskConfig() + taskConfig.setContext([:]) + then: 'attempt defaults to 1' + taskConfig.getCpus() == 2 + } + + def 'a malformed selector body is reported as a config parse error'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + newSession([agent: ['withName:qa': 'oops']]) + + when: + newAgent().buildAgentTask(['hello']) + + then: + thrown(ConfigParseException) + } + + // NOTE: the legacy-runner guard against a SELECTOR-provided remote executor lives in + // AgentResumeIntegrationTest, next to its plain-scope sibling. +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentConfigTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentConfigTest.groovy new file mode 100644 index 0000000000..067de1bea3 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentConfigTest.groovy @@ -0,0 +1,822 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import nextflow.SysEnv +import nextflow.config.ConfigValidator +import nextflow.exception.AbortOperationException +import nextflow.script.dsl.ProcessBuilder +import nextflow.util.Duration +import org.junit.Rule +import spock.lang.Specification +import test.OutputCapture +import nextflow.agent.rpc.AgentRpcHostResolver +import nextflow.agent.rpc.AgentRpcConfig + +/** + * + * @author Paolo Di Tommaso + */ +class AgentConfigTest extends Specification { + + @Rule + OutputCapture capture = new OutputCapture() + + def 'should build from a config map'() { + when: + def config = new AgentConfig([runner: 'pi', model: 'openai/gpt-5-mini', maxIterations: 7, requestTimeout: '90s', maxToolOutputInlineSize: '64 KB', rpc: [port: 1234, remoteHost: 'driver.internal']]) + then: + config.runner == 'pi' + config.model == 'openai/gpt-5-mini' + config.maxIterations == 7 + config.requestTimeout == Duration.of('90s') + config.maxToolOutputInlineBytes() == 64 * 1024 + config.rpc.port == 1234 + config.rpc.remoteHost == 'driver.internal' + } + + def 'should default the RPC capability timeout to one hour'() { + expect: 'the default absorbs an executor queueing delay -- the clock starts when the task SCRIPT is generated' + AgentRpcConfig.DEFAULT_CAPABILITY_TIMEOUT == Duration.of('1h') + new AgentConfig([:]).rpc.capabilityTimeout == Duration.of('1h') + + and: 'an operator can widen or tighten it, from a string or a Duration' + new AgentConfig([rpc: [capabilityTimeout: '10m']]).rpc.capabilityTimeout == Duration.of('10m') + new AgentConfig([rpc: [capabilityTimeout: Duration.of('2h')]]).rpc.capabilityTimeout == Duration.of('2h') + } + + def 'should accept agent.rpc.capabilityTimeout as a declared config option'() { + when: + new ConfigValidator().validate([agent: [rpc: [capabilityTimeout: '10m']]]) + then: + !capture.toString().contains("Unrecognized config option 'agent") + } + + def 'should enable RPC transport security unless it is explicitly disabled'() { + expect: 'secure by default, including for the no-arg form used by the spec reflection' + new AgentConfig([:]).rpc.tlsEnabled() + new AgentRpcConfig().tlsEnabled() + + and: 'only an explicit false opts out' + !new AgentConfig([rpc: [tls: false]]).rpc.tlsEnabled() + new AgentConfig([rpc: [tls: true]]).rpc.tlsEnabled() + + when: + new ConfigValidator().validate([agent: [rpc: [tls: false]]]) + then: + !capture.toString().contains("Unrecognized config option 'agent") + } + + def 'should build from an empty config map'() { + when: + def config = new AgentConfig([:]) + then: + AgentConfig.DEFAULT_EXECUTOR == 'local' + config.model == null + config.maxIterations == null + config.requestTimeout == null + config.maxToolOutputInlineBytes() == 32768 + and: + config.trace == null + !config.traceEnabled() + config.rpc.port == AgentRpcConfig.DEFAULT_PORT + and: 'the broker host is NOT defaulted: only the container engine can stand in for it' + config.rpc.remoteHost == null + } + + def 'the broker host is inferred from the engine when it is not configured'() { + given: + def unset = new AgentRpcConfig([:]) + def explicit = new AgentRpcConfig([remoteHost: 'driver.internal']) + + expect: 'docker and podman name the container host themselves' + unset.resolveRemoteHost('docker') == 'host.docker.internal' + unset.resolveRemoteHost('podman') == 'host.containers.internal' + + and: 'an engine that creates no network namespace shares the driver`s, so loopback reaches it' + unset.resolveRemoteHost('singularity') == '127.0.0.1' + + and: 'no engine at all is still nothing to go on -- see AgentRpcHostResolver error row E7' + unset.resolveRemoteHost(null) == null + + and: 'an explicit value always wins over every inferred row' + explicit.resolveRemoteHost('docker') == 'driver.internal' + explicit.resolveRemoteHost(null) == 'driver.internal' + } + + def 'the broker host resolves config, then the environment, then the engine alias'() { + expect: 'each rung is reached only when the ones above it are absent' + AgentRpcConfig.resolveConfiguredHost(opts, env) == expected + + where: + opts | env || expected + [remoteHost: 'from.config'] | [:] || 'from.config' + [:] | [NXF_AGENT_RPC_REMOTE_HOST: 'from.env'] || 'from.env' + // config wins: it is written for THIS pipeline, the variable for whatever shares the shell + [remoteHost: 'from.config'] | [NXF_AGENT_RPC_REMOTE_HOST: 'from.env'] || 'from.config' + // neither set leaves the engine alias to answer, via resolveRemoteHost + [:] | [:] || null + // an empty value falls THROUGH rather than shadowing the rung below and advertising '' + [remoteHost: ''] | [NXF_AGENT_RPC_REMOTE_HOST: 'from.env'] || 'from.env' + [:] | [NXF_AGENT_RPC_REMOTE_HOST: ''] || null + [remoteHost: ''] | [NXF_AGENT_RPC_REMOTE_HOST: ''] || null + } + + def 'the environment reaches the config object, and the engine alias still backstops it'() { + given: + SysEnv.push([NXF_AGENT_RPC_REMOTE_HOST: 'driver.from.env']) + + when: 'nothing is configured' + def fromEnv = new AgentRpcConfig([:]) + + then: 'the variable is what both AgentDef and the broker read' + fromEnv.remoteHost == 'driver.from.env' + and: 'and it outranks the engine alias, being explicit about the driver' + fromEnv.resolveRemoteHost('docker') == 'driver.from.env' + + when: 'the pipeline configures its own' + def fromConfig = new AgentRpcConfig([remoteHost: 'driver.from.config']) + + then: + fromConfig.remoteHost == 'driver.from.config' + + cleanup: + SysEnv.pop() + } + + def 'only docker and podman name the host their containers run on'() { + expect: 'the alias table, which is ONE row of the ladder AgentRpcHostResolver owns' + AgentRpcConfig.hostAliasFor('docker') == 'host.docker.internal' + AgentRpcConfig.hostAliasFor('podman') == 'host.containers.internal' + + and: 'no other engine has one -- which is now a reason to infer, not a reason to reject' + for( final engine : ['singularity', 'apptainer', 'sarus', 'shifter', 'charliecloud', 'apple-container', 'smolvm'] ) + assert AgentRpcConfig.hostAliasFor(engine) == null + + and: 'an unknown or absent engine is not an error, just no alias' + AgentRpcConfig.hostAliasFor('nonesuch') == null + AgentRpcConfig.hostAliasFor(null) == null + } + + def 'should read the trace option'() { + expect: + new AgentConfig([trace: true]).traceEnabled() + and: + !new AgentConfig([trace: false]).traceEnabled() + } + + def 'AGENT_ONLY_OPTIONS is derived from the declared options and disjoint from process directives'() { + expect: 'the agent-only axis is the @ConfigOption fields PLUS the nested config scopes' + AgentConfig.AGENT_ONLY_OPTIONS == [ + 'runner', 'model', 'apiProvider', 'apiKey', 'baseUrl', 'maxIterations', 'requestTimeout', + 'maxToolOutputInlineSize', 'trace', 'rpc' ] as Set + + and: 'drift guard: re-adding a task directive as an @ConfigOption would break the two axes apart' + AgentConfig.AGENT_ONLY_OPTIONS.intersect(ProcessBuilder.DIRECTIVES as Set).isEmpty() + } + + def 'resolveOptions applies the plain scope only'() { + when: + def opts = AgentConfig.resolveOptions([model: 'm0', cpus: 4], [], 'qa', 'qa', 'qa') + then: 'agent-only options are copied; task directives are not' + opts == [model: 'm0'] + } + + def 'resolveOptions applies the selector ladder, weakest to strongest'() { + given: + def scope = [ + model: 'plain', + 'withLabel:big': [model: 'label'], + 'withName:qa': [model: 'base'], + 'withName:reviewer': [model: 'alias'], + 'withName:WF:qa': [model: 'fq'], + ] + + and: + def without = { String... keys -> scope.findAll { e -> !(e.key in (keys as List)) } } + + expect: 'fully-qualified wins over alias, base, label and plain' + AgentConfig.resolveOptions(scope, ['big'], 'qa', 'reviewer', 'WF:qa').model == 'fq' + and: 'without the fq rule the alias wins' + AgentConfig.resolveOptions(without('withName:WF:qa'), ['big'], 'qa', 'reviewer', 'WF:qa').model == 'alias' + and: 'without the alias rule the base name wins' + AgentConfig.resolveOptions(without('withName:WF:qa', 'withName:reviewer'), ['big'], 'qa', 'reviewer', 'WF:qa').model == 'base' + and: 'without any name rule the label rule wins' + AgentConfig.resolveOptions([model: 'plain', 'withLabel:big': [model: 'label']], ['big'], 'qa', 'qa', 'qa').model == 'label' + and: 'with no matching selector the plain scope applies' + AgentConfig.resolveOptions(scope, [], 'other', 'other', 'other').model == 'plain' + } + + def 'resolveOptions matches regex and negated selector targets'() { + expect: 'a regex withName target' + AgentConfig.resolveOptions(['withName:shard.+': [model: 'm']], [], 'shard_1', 'shard_1', 'shard_1').model == 'm' + and: 'a negated withName target does not match the excluded agent' + AgentConfig.resolveOptions(['withName:!critic': [model: 'm']], [], 'critic', 'critic', 'critic').isEmpty() + and: 'but does match any other agent' + AgentConfig.resolveOptions(['withName:!critic': [model: 'm']], [], 'qa', 'qa', 'qa').model == 'm' + and: 'a negated withLabel target matches a label-less agent' + AgentConfig.resolveOptions(['withLabel:!big': [model: 'm']], [], 'qa', 'qa', 'qa').model == 'm' + } + + def 'resolveOptions ignores task directives and tolerates a malformed selector body'() { + expect: 'a task directive inside a selector is NOT copied onto the agent-only axis' + AgentConfig.resolveOptions(['withName:qa': [cpus: 8, model: 'm']], [], 'qa', 'qa', 'qa') == [model: 'm'] + + and: 'a selector-scoped nested scope IS copied (a documented no-op: the broker reads the session config)' + AgentConfig.resolveOptions(['withName:qa': [rpc: [port: 1234]]], [], 'qa', 'qa', 'qa') == [rpc: [port: 1234]] + + and: 'a non-map selector body is skipped without a ClassCastException (applyConfig reports it)' + AgentConfig.resolveOptions(['withName:qa': 'oops'], [], 'qa', 'qa', 'qa').isEmpty() + } + + def 'should be a recognized config scope'() { + when: + new ConfigValidator().validate([ + agent: [ + runner: 'pi', + executor: 'k8s', + container: 'runner:1', + arch: 'arm64', + cpus: 2, + memory: '1 GB', + time: '1h', + queue: 'agents', + errorStrategy: 'retry', + maxRetries: 2, + cache: false, + model: 'openai/gpt-5-mini', + apiProvider: 'openai', + apiKey: 'sk-xxx', + baseUrl: 'http://localhost:8000/v1', + maxIterations: 7, + requestTimeout: '90s', + maxToolOutputInlineSize: '64 KB', + trace: true, + rpc: [port: 1234, remoteHost: 'driver.internal'] + ] + ]) + then: + !capture.toString().contains("Unrecognized config option 'agent") + } + + // ----------------------------------------------------------------------- + // Endpoint and credential resolution (design D1/D2/D3): ONE ladder for every provider -- + // config, then the provider-neutral NXF_AGENT_*, then the PROVIDER's own variable -- resolved + // here in core so neither runner reads the environment. Driven with a FAKE env map (the + // two-arg constructor, the house pattern of `AwsConfig.getAwsRegion`) so the real process + // environment is never mutated. + // ----------------------------------------------------------------------- + + def 'resolveNeutralApiKey applies the config -> NXF_AGENT_API_KEY tiers'() { + expect: 'nothing set resolves to null: whether a credential is REQUIRED is a runner concern' + AgentConfig.resolveNeutralApiKey([:], [:]) == null + + and: 'tier 1 is the config option (already selector-merged into opts by resolveOptions)' + AgentConfig.resolveNeutralApiKey([:], [apiKey: 'sk-config']) == 'sk-config' + + and: 'the config option beats the environment' + AgentConfig.resolveNeutralApiKey([NXF_AGENT_API_KEY: 'sk-nxf'], [apiKey: 'sk-config']) == 'sk-config' + + and: 'tier 2 is the provider-neutral variable' + AgentConfig.resolveNeutralApiKey([NXF_AGENT_API_KEY: 'sk-nxf'], [:]) == 'sk-nxf' + + and: 'the PROVIDER tier is not reachable from here: it needs the model, which this does not see' + AgentConfig.resolveNeutralApiKey([OPENAI_API_KEY: 'sk-openai'], [:]) == null + + and: 'a null env or a null opts map is tolerated (the no-arg extension-point ctor)' + AgentConfig.resolveNeutralApiKey(null, null) == null + AgentConfig.resolveNeutralApiKey(null, [apiKey: 'sk-config']) == 'sk-config' + AgentConfig.resolveNeutralApiKey([NXF_AGENT_API_KEY: 'sk-nxf'], null) == 'sk-nxf' + } + + def 'resolveNeutralBaseUrl applies the config -> NXF_AGENT_BASE_URL tiers'() { + expect: 'nothing set resolves to null, which means "use the provider default"' + AgentConfig.resolveNeutralBaseUrl([:], [:]) == null + + and: 'tier 1 is the config option, and it beats the environment' + AgentConfig.resolveNeutralBaseUrl([:], [baseUrl: 'http://config:8000/v1']) == 'http://config:8000/v1' + AgentConfig.resolveNeutralBaseUrl([NXF_AGENT_BASE_URL: 'http://nxf/v1'], [baseUrl: 'http://config:8000/v1']) == 'http://config:8000/v1' + + and: 'tier 2 is the provider-neutral variable' + AgentConfig.resolveNeutralBaseUrl([NXF_AGENT_BASE_URL: 'http://nxf/v1'], [:]) == 'http://nxf/v1' + + and: 'and the provider tier is deliberately absent here -- it is what the inference feeds on (D3)' + AgentConfig.resolveNeutralBaseUrl([OPENAI_BASE_URL: 'http://openai/v1'], [:]) == null + + and: 'a null env or a null opts map is tolerated' + AgentConfig.resolveNeutralBaseUrl(null, null) == null + } + + def 'the ladder is truthiness-based so an empty value does not shadow the tiers below it'() { + given: 'an EMPTY value is not a credential: `agent.apiKey = params.key` with `params.key`' + // unset, or `export NXF_AGENT_API_KEY=`, both yield ''. A null-check ladder would let that + // win its tier, shadow everything below and then trip the runner's "missing credential" + // branch with a misleading message. Consequence worth knowing: an explicitly empty + // `agent.apiKey` does NOT mean "no credential" -- the D8 no-credential path is reached by + // leaving the option unset while setting a baseUrl, not by setting it empty. + expect: + AgentConfig.resolveNeutralApiKey([NXF_AGENT_API_KEY: 'sk-nxf'], [apiKey: '']) == 'sk-nxf' + AgentConfig.resolveNeutralBaseUrl([NXF_AGENT_BASE_URL: 'http://nxf/v1'], [baseUrl: '']) == 'http://nxf/v1' + + and: 'with nothing to fall back on an empty value normalizes to null, not to ""' + AgentConfig.resolveNeutralApiKey([:], [apiKey: '']) == null + AgentConfig.resolveNeutralBaseUrl([:], [baseUrl: '']) == null + + and: 'the PROVIDER tier applies the same rule: an exported-but-empty variable falls through' + AgentConfig.resolveProviderApiKey([GEMINI_API_KEY: '', GOOGLE_API_KEY: 'sk-goo'], 'gemini') == 'sk-goo' + AgentConfig.resolveProviderApiKey([OPENAI_API_KEY: ''], 'openai') == null + AgentConfig.resolveProviderBaseUrl([OPENAI_BASE_URL: ''], 'openai') == null + + and: 'and an empty value reaches the provider tier through the whole ladder' + new AgentConfig([apiKey: ''], [NXF_AGENT_API_KEY: '', OPENAI_API_KEY: 'sk-openai']) + .apiKeyFor('openai/gpt-5-mini') == 'sk-openai' + } + + def 'the constructor resolves the NEUTRAL tiers through SysEnv'() { + given: 'resolution happens in the CONSTRUCTOR (as in AwsConfig), reading SysEnv and never System.getenv' + SysEnv.push(ENV) + and: + def config = new AgentConfig(OPTS) + + expect: 'the FIELDS carry the provider-neutral tiers only -- what `nextflow config` shows' + // The provider tier is per-MODEL (which variable is read depends on the effective model), + // so it cannot be a field; folding it in here would also make `nextflow config` MORE + // revealing about the ambient environment than it is today. + config.apiKey == API_KEY + config.baseUrl == BASE_URL + + cleanup: + SysEnv.pop() + + where: + ENV | OPTS || API_KEY | BASE_URL + [:] | [:] || null | null + [:] | [apiKey: 'sk-config', baseUrl: 'http://cfg/v1'] || 'sk-config' | 'http://cfg/v1' + [NXF_AGENT_API_KEY: 'sk-nxf', NXF_AGENT_BASE_URL: 'http://nxf/v1'] | [:] || 'sk-nxf' | 'http://nxf/v1' + [NXF_AGENT_API_KEY: 'sk-nxf', OPENAI_API_KEY: 'sk-oai'] | [:] || 'sk-nxf' | null + [OPENAI_API_KEY: 'sk-oai'] | [apiKey: 'sk-config'] || 'sk-config' | null + and: 'the provider tier is NOT a field: it is resolved per model by apiKeyFor/baseUrlFor' + [OPENAI_API_KEY: 'sk-oai', OPENAI_BASE_URL: 'http://oai/v1'] | [:] || null | null + and: 'the two options resolve INDEPENDENTLY: a local endpoint from config, the key from the env' + [NXF_AGENT_API_KEY: 'sk-nxf'] | [baseUrl: 'http://localhost:8000/v1'] || 'sk-nxf' | 'http://localhost:8000/v1' + and: 'a local endpoint and NO credential at all is a valid combination (design D8)' + [:] | [baseUrl: 'http://localhost:11434/v1'] || null | 'http://localhost:11434/v1' + } + + def 'the two-arg constructor pins the environment the deferred provider tier reads'() { + given: 'the provider tier resolves per MODEL, long after construction, so the environment is' + // HELD rather than re-read: an object whose answers depend on when they are asked cannot be + // reasoned about. The explicit form is also what lets a spec swap a map instead of SysEnv. + SysEnv.push([OPENAI_API_KEY: 'sk-from-sysenv']) + and: + def config = new AgentConfig([:], [OPENAI_API_KEY: 'sk-injected']) + + expect: + config.apiKeyFor('openai/gpt-5-mini') == 'sk-injected' + + cleanup: + SysEnv.pop() + } + + def 'the provider-neutral tiers reach every provider and are never endpoint-gated'() { + given: 'the config option and NXF_AGENT_* are named by the user FOR THIS AGENT, whichever' + // provider it targets and whatever it points at, so unlike an ambient `_API_KEY` + // they carry no assumption that has to be checked against the endpoint + def env = [NXF_AGENT_API_KEY: 'sk-nxf', NXF_AGENT_BASE_URL: 'http://nxf/v1', OPENAI_API_KEY: 'sk-openai'] + + expect: 'tier 2 applies to a non-openai model' + with(new AgentConfig([:], env)) { + apiKeyFor('anthropic/claude-sonnet-4') == 'sk-nxf' + baseUrlFor('anthropic/claude-sonnet-4') == 'http://nxf/v1' + } + + and: 'and so does tier 1, which also beats it' + with(new AgentConfig([apiKey: 'sk-config', baseUrl: 'http://config/v1'], env)) { + apiKeyFor('anthropic/claude-sonnet-4') == 'sk-config' + baseUrlFor('anthropic/claude-sonnet-4') == 'http://config/v1' + apiKeyFor('openai/gpt-5-mini') == 'sk-config' + } + + and: 'a neutral credential travels to ANOTHER provider\'s own host without a murmur' + new AgentConfig([apiKey: 'sk-config', baseUrl: 'https://api.anthropic.com/v1'], [:]) + .apiKeyFor('openai/gpt-5-mini') == 'sk-config' + } + + // ----------------------------------------------------------------------- + // D1: which provider namespace the credential and the endpoint come from. Three rungs, and + // NONE of them selects the wire protocol -- that stays the model-id prefix. + // ----------------------------------------------------------------------- + + def 'apiProviderFor resolves explicit, then the inferred endpoint, then the model prefix'() { + expect: 'the prefix is the historical answer, and still the last word' + new AgentConfig([:], [:]).apiProviderFor('anthropic/claude-sonnet-4') == 'anthropic' + + and: 'a provider-neutral endpoint on a well-known host OUTRANKS it -- the prefix names a' + // PROTOCOL, so `openai/` + https://openrouter.ai/api/v1 is the documented way to reach + // OpenRouter and the credential that endpoint wants is OpenRouter's + new AgentConfig([baseUrl: 'https://openrouter.ai/api/v1'], [:]).apiProviderFor('openai/gpt-4o') == 'openrouter' + new AgentConfig([:], [NXF_AGENT_BASE_URL: 'https://api.mistral.ai/v1']).apiProviderFor('openai/x') == 'mistral' + + and: 'an explicit `agent.apiProvider` outranks both' + new AgentConfig([apiProvider: 'azure', baseUrl: 'https://openrouter.ai/api/v1'], [:]) + .apiProviderFor('openai/gpt-4o') == 'azure' + + and: 'an unrecognized endpoint host does not fire, so the prefix answers' + new AgentConfig([baseUrl: 'https://gateway.corp/v1'], [:]).apiProviderFor('openai/gpt-4o') == 'openai' + + and: 'inference reads the NEUTRAL endpoint only: `_BASE_URL` cannot feed the' + // inference that decides which provider's variable to read in the first place (D3 circularity) + new AgentConfig([:], [OPENAI_BASE_URL: 'https://openrouter.ai/api/v1']).apiProviderFor('openai/x') == 'openai' + + and: 'with no prefix and nothing to infer from there is no provider at all' + new AgentConfig([:], [:]).apiProviderFor('gpt-5-mini') == null + new AgentConfig([:], [:]).apiProviderFor(null) == null + } + + def 'providerPrefixOf reads the model-id prefix and isOpenAiProtocol is exactly that prefix'() { + expect: + AgentConfig.providerPrefixOf('openai/gpt-5-mini') == 'openai' + AgentConfig.providerPrefixOf('Anthropic/Claude-Sonnet-4') == 'anthropic' + AgentConfig.providerPrefixOf('openrouter/openai/gpt-4o') == 'openrouter' + and: 'no prefix is not an error, just no answer' + AgentConfig.providerPrefixOf('gpt-5-mini') == null + AgentConfig.providerPrefixOf('/gpt-5-mini') == null + AgentConfig.providerPrefixOf('') == null + AgentConfig.providerPrefixOf(null) == null + + and: 'the protocol predicate is the prefix and nothing else -- it no longer gates the ladder' + AgentConfig.isOpenAiProtocol('openai/gpt-5-mini') + AgentConfig.isOpenAiProtocol('openai/llama-3.3-70b') + !AgentConfig.isOpenAiProtocol('anthropic/claude-sonnet-4') + !AgentConfig.isOpenAiProtocol('openrouter/openai/gpt-4o') + !AgentConfig.isOpenAiProtocol('gpt-5-mini') + !AgentConfig.isOpenAiProtocol(null) + } + + def 'agent.apiProvider is normalized, and an unknown value is rejected rather than uppercased'() { + expect: 'unset (or empty, by the same truthiness rule as every other tier) is null' + AgentConfig.resolveApiProvider([:]) == null + AgentConfig.resolveApiProvider([apiProvider: '']) == null + AgentConfig.resolveApiProvider([apiProvider: ' ']) == null + AgentConfig.resolveApiProvider(null) == null + + and: 'trimmed and lower-cased, but never otherwise mangled' + AgentConfig.resolveApiProvider([apiProvider: ' OpenAI ']) == 'openai' + AgentConfig.resolveApiProvider([apiProvider: 'ANTHROPIC']) == 'anthropic' + + when: 'an unrecognized token is written' + new AgentConfig([apiProvider: 'stripe'], [:]) + + then: 'it fails with the accepted values, rather than silently reading no variable at all' + // the closed namespace is what keeps `_API_KEY` from naming an arbitrary variable + // in the driver's environment; an unrecognized token would otherwise be a typo diagnosed nowhere + def error = thrown(AbortOperationException) + error.message.contains('`agent.apiProvider`') + error.message.contains('stripe') + error.message.contains('anthropic, azure, gemini, google, mistral, openai, openrouter') + } + + def 'the provider namespace is a closed, explicit table'() { + expect: 'every token the ladder knows, and the variables it reads for each, in order' + // NOTE: the key names must stay equal to `AgentSecretMasker.SECRET_ENV_KEYS` minus the + // neutral NXF_AGENT_API_KEY -- the redaction backstop and the resolution contract must not + // disagree about what counts as a credential. + AgentConfig.knownProviders() == ['anthropic', 'azure', 'gemini', 'google', 'mistral', 'openai', 'openrouter'] as Set + AgentConfig.apiKeyVarsFor('anthropic') == ['ANTHROPIC_API_KEY'] + AgentConfig.apiKeyVarsFor('azure') == ['AZURE_OPENAI_API_KEY'] + AgentConfig.apiKeyVarsFor('gemini') == ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] + AgentConfig.apiKeyVarsFor('google') == ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] + AgentConfig.apiKeyVarsFor('mistral') == ['MISTRAL_API_KEY'] + AgentConfig.apiKeyVarsFor('openai') == ['OPENAI_API_KEY'] + AgentConfig.apiKeyVarsFor('openrouter') == ['OPENROUTER_API_KEY'] + + and: 'the endpoint half is NOT the same key set: only these three variables actually exist' + // reading a MISTRAL_BASE_URL would be Nextflow inventing a convention under a vendor's name + AgentConfig.baseUrlVarsFor('openai') == ['OPENAI_BASE_URL'] + AgentConfig.baseUrlVarsFor('anthropic') == ['ANTHROPIC_BASE_URL'] + AgentConfig.baseUrlVarsFor('azure') == ['AZURE_OPENAI_ENDPOINT'] + AgentConfig.baseUrlVarsFor('gemini') == [] + AgentConfig.baseUrlVarsFor('google') == [] + AgentConfig.baseUrlVarsFor('mistral') == [] + AgentConfig.baseUrlVarsFor('openrouter') == [] + + and: 'an unknown token names NO variable, so a model prefix can never reach one' + !AgentConfig.isKnownProvider('stripe') + !AgentConfig.isKnownProvider(null) + AgentConfig.apiKeyVarsFor('stripe') == [] + AgentConfig.baseUrlVarsFor('stripe') == [] + + and: 'the candidate list is ordered: the first TRUTHY hit wins, not the last' + AgentConfig.providerApiKeyVar([GEMINI_API_KEY: 'a', GOOGLE_API_KEY: 'b'], 'gemini') == 'GEMINI_API_KEY' + AgentConfig.providerApiKeyVar([GEMINI_API_KEY: 'a', GOOGLE_API_KEY: 'b'], 'google') == 'GOOGLE_API_KEY' + and: 'and the alias answers when the preferred spelling is absent' + AgentConfig.providerApiKeyVar([GOOGLE_API_KEY: 'b'], 'gemini') == 'GOOGLE_API_KEY' + AgentConfig.providerApiKeyVar([GEMINI_API_KEY: 'a'], 'google') == 'GEMINI_API_KEY' + AgentConfig.providerApiKeyVar([:], 'gemini') == null + AgentConfig.providerApiKeyVar(null, 'gemini') == null + } + + // ----------------------------------------------------------------------- + // D3: inference matches on the HOST, exactly or as a dot-suffix. A `contains('openai')` rule + // would ship a credential to https://evil.example.com/openai/v1. + // ----------------------------------------------------------------------- + + def 'inferProviderFromUrl matches a well-known host and nothing else'() { + expect: + AgentConfig.inferProviderFromUrl(ENDPOINT) == PROVIDER + + where: + ENDPOINT || PROVIDER + 'https://api.openai.com/v1' || 'openai' + 'https://api.anthropic.com' || 'anthropic' + 'https://openrouter.ai/api/v1' || 'openrouter' + 'https://api.mistral.ai/v1' || 'mistral' + and: 'a dot-suffix is the same provider (a regional or versioned subdomain)' + 'https://eu.openrouter.ai/api/v1' || 'openrouter' + 'https://a.b.api.openai.com/v1' || 'openai' + and: 'case, port, path, query and userinfo are all ignored' + 'https://API.OpenAI.COM/v1' || 'openai' + 'https://api.openai.com:8443/v1' || 'openai' + 'https://user:pw@api.openai.com/v1?x=1' || 'openai' + ' https://api.openai.com/v1 ' || 'openai' + and: 'the absolute-FQDN spelling cannot slip past an exact match' + 'https://api.openai.com./v1' || 'openai' + and: '-- REJECTED -- the provider name in the PATH is not the host' + 'https://evil.example.com/openai/v1' || null + 'https://gateway.corp/openrouter.ai/v1' || null + and: '-- REJECTED -- a SUBSTRING of the host is not the host' + 'https://notopenrouter.ai/api/v1' || null + 'https://openrouter.ai.evil.example/api/v1' || null + 'https://api.openai.com.evil.example/v1' || null + and: '-- REJECTED -- a lookalike that only shares a suffix boundary the table does not own' + 'https://openai.com/v1' || null + 'https://myapi.mistral.ai.co/v1' || null + and: '-- REJECTED -- the host in the USERINFO is not the host' + 'https://api.openai.com@evil.example/v1' || null + and: 'no host, no answer -- never a guess' + 'http://localhost:8000/v1' || null + 'not a url at all' || null + '/v1/chat' || null + '' || null + null || null + } + + // ----------------------------------------------------------------------- + // D2 tier 3, and the gate on it: `_API_KEY` is a variable exported for a PROVIDER, + // not for this endpoint, and a runner installs what it is handed ahead of anything it could + // resolve itself -- so it travels only when the endpoint agrees. + // ----------------------------------------------------------------------- + + def 'apiKeyFor resolves the provider tier only for an endpoint that provider owns'() { + expect: + new AgentConfig(OPTS, ENV).apiKeyFor(MODEL) == KEY + + where: + ENV | OPTS | MODEL || KEY + // -- the neutral tiers are never gated + [:] | [apiKey: 'sk-cfg', baseUrl: 'https://api.anthropic.com/v1'] | 'openai/gpt-5-mini' || 'sk-cfg' + [NXF_AGENT_API_KEY: 'sk-nxf'] | [baseUrl: 'https://api.anthropic.com/v1'] | 'openai/gpt-5-mini' || 'sk-nxf' + // -- tier 3, no endpoint at all: the request goes to the provider's own default + [OPENAI_API_KEY: 'sk-oai'] | [:] | 'openai/gpt-5-mini' || 'sk-oai' + [ANTHROPIC_API_KEY: 'sk-ant'] | [:] | 'anthropic/claude-sonnet-4' || 'sk-ant' + [MISTRAL_API_KEY: 'sk-mis'] | [:] | 'mistral/mistral-large' || 'sk-mis' + [OPENROUTER_API_KEY: 'sk-or'] | [:] | 'openrouter/openai/gpt-4o' || 'sk-or' + [AZURE_OPENAI_API_KEY: 'sk-az'] | [:] | 'azure/gpt-4o' || 'sk-az' + [GOOGLE_API_KEY: 'sk-goo'] | [:] | 'gemini/gemini-2.0-flash' || 'sk-goo' + [GEMINI_API_KEY: 'sk-gem'] | [:] | 'google/gemini-2.0-flash' || 'sk-gem' + // -- tier 3 to the provider's OWN host + [ANTHROPIC_API_KEY: 'sk-ant'] | [baseUrl: 'https://api.anthropic.com/v1'] | 'anthropic/claude-sonnet-4' || 'sk-ant' + and: 'the headline D1 case: openai PROTOCOL, openrouter CREDENTIAL, inferred from the host' + [OPENROUTER_API_KEY: 'sk-or', OPENAI_API_KEY: 'sk-oai'] | [baseUrl: 'https://openrouter.ai/api/v1'] | 'openai/gpt-4o' || 'sk-or' + and: 'tier 3 to the endpoint that same namespace supplied' + [OPENAI_API_KEY: 'sk-oai', OPENAI_BASE_URL: 'http://mirror:8000/v1'] | [:] | 'openai/gpt-5-mini' || 'sk-oai' + and: 'tier 3 to an unrecognized gateway, VOUCHED FOR by an explicit apiProvider' + [OPENAI_API_KEY: 'sk-oai'] | [baseUrl: 'https://gw.corp/v1', apiProvider: 'openai'] | 'openai/gpt-5-mini' || 'sk-oai' + and: '-- WITHHELD -- an unrecognized gateway nobody vouched for (the pre-existing misroute)' + [OPENAI_API_KEY: 'sk-oai'] | [baseUrl: 'https://gw.corp/v1'] | 'openai/gpt-5-mini' || null + and: '-- WITHHELD -- another provider\'s own host, even with an explicit apiProvider' + [OPENAI_API_KEY: 'sk-oai'] | [baseUrl: 'https://api.anthropic.com/v1', apiProvider: 'openai'] | 'openai/gpt-5-mini' || null + and: '-- WITHHELD -- OPENAI_BASE_URL pointed at somebody else closes the same hole' + [OPENAI_API_KEY: 'sk-oai', OPENAI_BASE_URL: 'https://openrouter.ai/api/v1'] | [:] | 'openai/gpt-5-mini' || null + and: '-- NOTHING TO READ -- the wrong provider\'s variable is never consulted' + [OPENAI_API_KEY: 'sk-oai'] | [:] | 'anthropic/claude-sonnet-4' || null + [ANTHROPIC_API_KEY: 'sk-ant'] | [:] | 'openai/gpt-5-mini' || null + and: '-- NOTHING TO READ -- an unknown namespace names no variable at all' + [STRIPE_API_KEY: 'sk-stripe'] | [:] | 'stripe/whatever' || null + and: '-- NOTHING TO READ -- no prefix means no provider' + [OPENAI_API_KEY: 'sk-oai'] | [:] | 'gpt-5-mini' || null + [OPENAI_API_KEY: 'sk-oai'] | [:] | null || null + [:] | [:] | 'openai/gpt-5-mini' || null + } + + def 'baseUrlFor resolves the provider tier ungated -- an endpoint is not a secret'() { + expect: + new AgentConfig(OPTS, ENV).baseUrlFor(MODEL) == ENDPOINT + + where: + ENV | OPTS | MODEL || ENDPOINT + [:] | [baseUrl: 'http://cfg/v1'] | 'openai/gpt-4o' || 'http://cfg/v1' + [NXF_AGENT_BASE_URL: 'http://nxf/v1'] | [:] | 'anthropic/claude-sonnet-4' || 'http://nxf/v1' + [NXF_AGENT_BASE_URL: 'http://nxf/v1', OPENAI_BASE_URL: 'http://o/v1']| [:] | 'openai/gpt-4o' || 'http://nxf/v1' + and: 'tier 3, per provider, on the three variables that actually exist' + [OPENAI_BASE_URL: 'http://oai/v1'] | [:] | 'openai/gpt-4o' || 'http://oai/v1' + [ANTHROPIC_BASE_URL: 'http://ant/v1'] | [:] | 'anthropic/claude-sonnet-4' || 'http://ant/v1' + [AZURE_OPENAI_ENDPOINT: 'https://x.openai.azure.com'] | [:] | 'azure/gpt-4o' || 'https://x.openai.azure.com' + and: 'the variable is scoped to ITS provider, so another model does not pick it up' + [OPENAI_BASE_URL: 'http://oai/v1'] | [:] | 'anthropic/claude-sonnet-4' || null + and: 'an explicit apiProvider redirects which variable is read' + [OPENAI_BASE_URL: 'http://oai/v1'] | [apiProvider: 'anthropic'] | 'openai/gpt-4o' || null + [ANTHROPIC_BASE_URL: 'http://ant/v1'] | [apiProvider: 'anthropic'] | 'openai/gpt-4o' || 'http://ant/v1' + and: 'no invented conventions: these variables are read by nobody' + [MISTRAL_BASE_URL: 'http://mis/v1'] | [:] | 'mistral/mistral-large' || null + [OPENROUTER_BASE_URL: 'http://or/v1'] | [:] | 'openrouter/x' || null + [GOOGLE_BASE_URL: 'http://goo/v1'] | [:] | 'google/gemini-2.0-flash' || null + and: 'nothing set means "use the provider default"' + [:] | [:] | 'openai/gpt-4o' || null + [OPENAI_BASE_URL: 'http://oai/v1'] | [:] | null || null + } + + def 'a withheld provider credential is named, with both remedies'() { + given: 'silence would leave an opaque 401 as the only evidence that a key was resolved and dropped' + def config = new AgentConfig([baseUrl: 'https://gw.corp/v1'], [OPENAI_API_KEY: 'sk-oai']) + + when: + def resolved = config.apiKeyFor('openai/gpt-5-mini') + + then: + resolved == null + and: 'the WARN names the variable that was read, the endpoint, and the two ways to proceed' + capture.toString().contains('Not using the OPENAI_API_KEY credential') + capture.toString().contains('https://gw.corp/v1') + capture.toString().contains('`agent.apiKey`') + capture.toString().contains("`agent.apiProvider = 'openai'`") + } + + def 'an explicit apiProvider contradicting a well-known endpoint host is called out at build time'() { + when: 'the endpoint is Anthropic\'s own API but the config claims an openai credential namespace' + // far likelier a mistake than an intention -- and the mistake would ship a credential to a + // third party, so it is said ONCE per agent here rather than only when a key is withheld + new AgentConfig([apiProvider: 'openai', baseUrl: 'https://api.anthropic.com/v1'], [:]) + + then: + capture.toString().contains("agent.apiProvider = 'openai'") + capture.toString().contains('is a known `anthropic` endpoint') + + when: 'the two agree, or the host is not one the table recognizes' + def before = capture.toString().length() + new AgentConfig([apiProvider: 'anthropic', baseUrl: 'https://api.anthropic.com/v1'], [:]) + new AgentConfig([apiProvider: 'openai', baseUrl: 'https://gw.corp/v1'], [:]) + new AgentConfig([apiProvider: 'openai'], [:]) + + then: 'there is nothing to contradict, so nothing is said' + !capture.toString().substring(before).contains('is a known') + } + + def 'missingCredentialHint names the variables the ladder ACTUALLY consults'() { + expect: 'a message naming OPENAI_API_KEY for an anthropic run is worse than no message' + AgentConfig.missingCredentialHint('anthropic').contains('`NXF_AGENT_API_KEY` or `ANTHROPIC_API_KEY`') + AgentConfig.missingCredentialHint('anthropic').contains('`agent.apiKey`') + + and: 'a provider with an alias pair names both, in resolution order' + AgentConfig.missingCredentialHint('gemini').contains('`NXF_AGENT_API_KEY` or `GEMINI_API_KEY` or `GOOGLE_API_KEY`') + + and: 'a known provider needs no `agent.apiProvider` advice -- the namespace is already right' + !AgentConfig.missingCredentialHint('openai').contains('agent.apiProvider') + + and: 'while an unresolvable namespace has only the neutral variable, and IS told to name one' + AgentConfig.missingCredentialHint('stripe').contains('`NXF_AGENT_API_KEY` environment variable') + AgentConfig.missingCredentialHint('stripe').contains('`agent.apiProvider`') + AgentConfig.missingCredentialHint(null).contains('`agent.apiProvider`') + } + + def 'with no endpoint the provider tier travels only when it IS the model prefix provider'() { + given: 'no endpoint does not mean "the resolved provider\'s default". The runner dials the' + // default endpoint of the MODEL PREFIX provider -- the langchain4j client hardcodes + // https://api.openai.com/v1 -- so `agent.apiProvider = 'openrouter'` with `openai/…` and no + // `agent.baseUrl` would ship OPENROUTER_API_KEY to OpenAI. It is withheld instead. + def misrouted = new AgentConfig([apiProvider: 'openrouter'], [OPENROUTER_API_KEY: 'sk-or']) + + expect: + misrouted.apiKeyFor('openai/gpt-4o') == null + misrouted.baseUrlFor('openai/gpt-4o') == null + and: 'and it is a WITHHELD credential, not an absent one' + misrouted.credentialWithheldFor('openai/gpt-4o') + + and: 'the warn names the variable, why it is not sent, and the endpoint option that fixes it' + capture.toString().contains('Not using the OPENROUTER_API_KEY credential') + capture.toString().contains('no endpoint resolved') + capture.toString().contains('`agent.baseUrl`') + + when: 'the provider IS the prefix, so the default endpoint dialled is that provider\'s own' + def aligned = new AgentConfig([apiProvider: 'openrouter'], [OPENROUTER_API_KEY: 'sk-or']) + + then: + aligned.apiKeyFor('openrouter/openai/gpt-4o') == 'sk-or' + !aligned.credentialWithheldFor('openrouter/openai/gpt-4o') + + when: 'naming the endpoint is what unblocks the redirected namespace' + def routed = new AgentConfig([apiProvider: 'openrouter', baseUrl: 'https://openrouter.ai/api/v1'], + [OPENROUTER_API_KEY: 'sk-or']) + + then: + routed.apiKeyFor('openai/gpt-4o') == 'sk-or' + !routed.credentialWithheldFor('openai/gpt-4o') + } + + def 'credentialWithheldFor separates "resolved but refused" from "nothing resolved"'() { + given: 'the two are indistinguishable through apiKeyFor, which answers null to both -- yet' + // only the first is a misconfiguration a runner can name, and neither may become the D8 + // placeholder. See AgentRunnerRequest.credential(). + expect: + new AgentConfig(OPTS, ENV).apiKeyFor(MODEL) == null + new AgentConfig(OPTS, ENV).credentialWithheldFor(MODEL) == WITHHELD + + where: + ENV | OPTS | MODEL || WITHHELD + // -- RESOLVED AND REFUSED: the key is right there, and the endpoint is not its owner's + [OPENAI_API_KEY: 'sk-oai'] | [baseUrl: 'https://gw.corp/v1'] | 'openai/gpt-5-mini' || true + [OPENAI_API_KEY: 'sk-oai'] | [baseUrl: 'https://api.anthropic.com/v1', apiProvider: 'openai'] | 'openai/gpt-5-mini' || true + [OPENAI_API_KEY: 'sk-oai', OPENAI_BASE_URL: 'https://openrouter.ai/api/v1']| [:] | 'openai/gpt-5-mini' || true + [OPENROUTER_API_KEY: 'sk-or'] | [apiProvider: 'openrouter'] | 'openai/gpt-4o' || true + and: 'a model id with no provider prefix names no default endpoint either, so nothing is sent' + [OPENAI_API_KEY: 'sk-oai'] | [apiProvider: 'openai'] | 'gpt-5-mini' || true + and: '-- NOTHING RESOLVED: no variable was set for the namespace at all' + [:] | [baseUrl: 'https://gw.corp/v1'] | 'openai/gpt-5-mini' || false + [ANTHROPIC_API_KEY: 'sk-ant'] | [baseUrl: 'https://gw.corp/v1'] | 'openai/gpt-5-mini' || false + [STRIPE_API_KEY: 'sk-stripe'] | [:] | 'stripe/whatever' || false + } + + def 'a resolved or neutral credential is never reported as withheld'() { + expect: 'tiers 1 and 2 are not gated at all, so there is nothing to withhold' + !new AgentConfig([apiKey: 'sk-cfg', baseUrl: 'https://gw.corp/v1'], [OPENAI_API_KEY: 'sk-oai']) + .credentialWithheldFor('openai/gpt-5-mini') + !new AgentConfig([baseUrl: 'https://gw.corp/v1'], [NXF_AGENT_API_KEY: 'sk-nxf', OPENAI_API_KEY: 'sk-oai']) + .credentialWithheldFor('openai/gpt-5-mini') + + and: 'nor is a provider tier the gate lets through' + !new AgentConfig([:], [OPENAI_API_KEY: 'sk-oai']).credentialWithheldFor('openai/gpt-5-mini') + !new AgentConfig([baseUrl: 'https://api.openai.com/v1'], [OPENAI_API_KEY: 'sk-oai']) + .credentialWithheldFor('openai/gpt-5-mini') + } + + def 'the D3 inference that outranks the model prefix is logged, naming both'() { + given: 'inference is invisible magic otherwise: which variable was read cannot be told from' + // the config, and reading the WRONG one is a credential misroute. Logged at debug on the + // resolution path, so it appears exactly when a credential was actually resolved. + when: 'the endpoint host names the provider and the prefix names a different one' + def fromHost = new AgentConfig([baseUrl: 'https://openrouter.ai/api/v1'], [OPENROUTER_API_KEY: 'sk-or']) + .apiKeyFor('openai/gpt-4o') + + then: + fromHost == 'sk-or' + capture.toString().contains('Resolved API provider `openrouter` for agent model `openai/gpt-4o`') + capture.toString().contains('from the endpoint https://openrouter.ai/api/v1') + capture.toString().contains('(the model prefix is `openai`)') + + when: 'an explicit option is what redirected the namespace, the line says so instead' + def before = capture.toString().length() + def fromOption = new AgentConfig([apiProvider: 'openrouter', baseUrl: 'https://openrouter.ai/api/v1'], + [OPENROUTER_API_KEY: 'sk-or']).apiKeyFor('openai/gpt-4o') + + then: + fromOption == 'sk-or' + capture.toString().substring(before).contains('from `agent.apiProvider`') + + when: 'the provider and the prefix agree, there is no surprise to report' + def quiet = capture.toString().length() + new AgentConfig([:], [OPENAI_API_KEY: 'sk-oai']).apiKeyFor('openai/gpt-5-mini') + + then: + !capture.toString().substring(quiet).contains('Resolved API provider') + } + + def 'a typo inside the nested rpc scope is reported'() { + given: 'the whole point of declaring `rpc` as a nested ConfigScope rather than a Map option:' + // a Map option short-circuits ConfigValidator.isMapOption and the sub-map is never walked, + // so every key under it -- typo or not -- was silently accepted + when: + new ConfigValidator().validate([agent: [rpc: [remoteHostt: 'driver.internal']]]) + then: + capture.toString().contains("Unrecognized config option 'agent.rpc.remoteHostt'") + } + + + // NOTE: the positive diagnostic for an agent BODY directive name written in the config + // (`agent.model`, `agent.maxIterations`) lives in ConfigValidatorTest. `log.warn1` caches + // by message for the whole JVM, so asserting the same warning twice across specs is + // inherently order-dependent -- see the existing `wokDir`/`wokDir2` split there. +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentExternalToolTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentExternalToolTest.groovy new file mode 100644 index 0000000000..4b87fcf1f6 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentExternalToolTest.groovy @@ -0,0 +1,198 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonSlurper +import nextflow.exception.ScriptRuntimeException +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * End-to-end test of an agent using an EXTERNAL module FILE as a tool (Phase 3.1). + * + * The module is {@code include}d like any other, and the agent names its process under + * {@code nf:module_run}; the resulting {@link nextflow.script.ProcessDef} is pre-wired through + * the {@link ModuleToolBridge}. A mock runner invokes the dispatch callback, proving the + * external module's process actually executes through the standard dataflow/executor + * machinery and its output is serialized back to the caller as JSON. The {@code @Timeout} + * fails if the tool input queues are not poisoned on completion. + * + *

The second test is the counterpart: the three reference shapes the directive used to + * resolve by trial — a module path, a registry reference and a bare process name — are now + * rejected by the grammar itself (G1), so the only way to a module tool is the {@code include} + * the first test uses. + */ +@Timeout(60) +class AgentExternalToolTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + private Path writeScripts(String mainScript, String moduleScript) { + final dir = Files.createTempDirectory('test') + dir.resolve('mod.nf').text = moduleScript.stripIndent() + final main = dir.resolve('main.nf') + main.text = mainScript.stripIndent() + return main + } + + def 'should run an included external module file as an agent tool and terminate'() { + given: + AgentRunnerRequest captured = null + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // the bridge exposes a `shout` tool with a scalar `text:String` input + assert req.toolSpecs.size() == 1 + assert req.toolSpecs[0].name == 'shout' + assert req.toolSpecs[0].inputSchema.properties.text.type == 'string' + // invoke the tool: this drives the REAL external `shout` process through the executor + dispatchResult = req.dispatch.call('shout', '{"text":"ada"}') + // the returned JSON proves the external module's process actually ran + assert new JsonSlurper().parseText(dispatchResult) == [result: 'ADA'] + // the agent's final answer + return dispatchResult + } as AgentRunner + + and: + def main = writeScripts( + ''' + nextflow.enable.types = true + + include { shout } from './mod.nf' + + agent a { + model 'm' + instruction 'i' + tools 'nf:module_run:shout' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + a(channel.of('hi')).view { it } + } + ''', + ''' + nextflow.enable.types = true + + process shout { + input: + text: String + + output: + result: String + + exec: + result = text.toUpperCase() + } + ''') + + when: + def result = runScript(main) + + then: + // the workflow emits the runner's final answer (the dispatch result) + new JsonSlurper().parseText(result.val) == [result: 'ADA'] + and: + // the dispatch went through the bridge and returned the real external process output + captured != null + new JsonSlurper().parseText(dispatchResult) == [result: 'ADA'] + } + + def 'should reject the legacy #WHAT entry - the directive resolves no reference shape but a namespaced ref'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> throw new IllegalStateException('runner should not be invoked') } as AgentRunner + + and: + // NOTE the module file the harness writes DOES define `shout`, and `./missing.nf` does + // NOT exist: neither fact matters any more. There is no resolution attempt to succeed or + // fail, because the entry never gets past the grammar (G1) — which is precisely what + // removing the fallthrough bought. + def main = writeScripts( + """ + nextflow.enable.types = true + + include { shout } from './mod.nf' + + agent a { + model 'm' + instruction 'i' + tools '${ENTRY}' + + input: + request: String + + output: + answer: String + + prompt: + \"\"\" + \${request} + \"\"\" + } + + workflow { + a(channel.of('hi')).view { it } + } + """, + ''' + nextflow.enable.types = true + + process shout { + input: + text: String + + output: + result: String + + exec: + result = text.toUpperCase() + } + ''') + + when: + runScript(main) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains("Invalid tool reference `${ENTRY}`") + and: 'the message points at the replacement rather than just refusing' + e.message.contains('a tool reference must be namespaced as `family[:group]:name`') + e.message.contains('`include` it and name its process') + + where: + WHAT | ENTRY + 'registry ref' | 'acme-bogus/does-not-exist' + 'module path' | './missing.nf' + 'bare process name' | 'shout' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentLaunchConditionsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentLaunchConditionsTest.groovy new file mode 100644 index 0000000000..d1685610c0 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentLaunchConditionsTest.groovy @@ -0,0 +1,404 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.Session +import nextflow.SysEnv +import nextflow.agent.rpc.AgentRpcHostResolverTest.TestProbes +import nextflow.container.DockerConfig +import nextflow.container.PodmanConfig +import nextflow.container.SingularityConfig +import nextflow.container.SmolVmConfig +import nextflow.exception.ScriptRuntimeException +import nextflow.executor.AbstractGridExecutor +import nextflow.executor.Executor +import nextflow.executor.ExecutorFactory +import spock.lang.Specification +import nextflow.agent.rpc.AgentRpcHostResolver +import nextflow.agent.rpc.AgentRpcConfig + +class AgentLaunchConditionsTest extends Specification { + + def setup() { + // AgentRpcConfig reads the environment in its constructor, and a NXF_AGENT_RPC_REMOTE_HOST + // exported in the developer's shell would silently answer the second rung of the ladder for + // every feature below, hiding the rung actually under test + SysEnv.push([:]) + } + + def cleanup() { + SysEnv.pop() + AgentRpcHostResolver.reset() + } + + /** + * Place the driver: the host facts the address ladder observes, injected for the session the + * guard will resolve against. These features assert which RUNG the guard takes, so they must not + * depend on the machine the suite runs on -- a suite running in a container would otherwise see + * {@code /.dockerenv} and take the containerized-driver row for every alias case below. + */ + private static Session withDriverHost(Session session, Map probes = [:]) { + AgentRpcHostResolver.install(session, new TestProbes([outbound: '10.0.3.17', interfaces: ['10.0.3.17']] + probes)) + return session + } + + /** The `agent.rpc` scope, whose only key any of these features cares about is `remoteHost`. */ + private static AgentRpcConfig rpc(String remoteHost = null) { + return new AgentRpcConfig(remoteHost != null ? [remoteHost: remoteHost] : [:]) + } + + def 'containerization requires an image AND a container-native executor or an enabled engine'() { + expect: + AgentLaunchConditions.willContainerize(container, containerNative, engineEnabled) == expected + + where: + container | containerNative | engineEnabled || expected + null | false | true || false + false | false | true || false + null | true | false || false + 'image' | false | false || false + 'image' | false | true || true + 'image' | true | false || true + } + + def 'a canonical launch is rejected whenever the configuration would not containerize it'() { + when: + AgentLaunchConditions.requireContainerized('qa', 'pi', executor, container, containerNative, engineEnabled) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('agent.container') + and: 'the message names the runner image, so it is actionable' + e.message.contains('`pi` runner image') + and: + e.message.contains(reason) + + where: + executor | container | containerNative | engineEnabled || reason + // no image at all, whether local or offloaded, and whether or not a container would run + 'local' | null | false | false || 'must declare a container' + 'local' | null | false | true || 'must declare a container' + 'k8s' | false | true | false || 'must declare a container' + // an image, but nothing that would actually run it in a container: a non-local executor is + // NOT container-native by itself (the former isOffloaded assumption), and a local executor + // with no engine enabled would run the in-image paths on the host + 'k8s' | 'image' | false | false || 'would not run it in a container' + 'slurm' | 'image' | false | false || 'would not run it in a container' + 'local' | 'image' | false | false || 'would not run it in a container' + } + + def 'a rejection says where an unchosen runner came from'() { + when: 'the user never set `agent.runner`, so the sole installed one was picked for them' + AgentLaunchConditions.requireContainerized('qa', 'pi', 'local', container, false, false, true) + + then: 'the message names the runner AND accounts for it' + def err = thrown(ScriptRuntimeException) + err.message.contains(expected) + err.message.contains('selected automatically as the only agent runner plugin installed') + err.message.contains("agent.runner = 'langchain4j'") + + when: 'the runner was chosen explicitly there is nothing to explain' + AgentLaunchConditions.requireContainerized('qa', 'pi', 'local', container, false, false) + + then: + def chosen = thrown(ScriptRuntimeException) + chosen.message.contains(expected) + !chosen.message.contains('selected automatically') + + where: + container || expected + null || 'must declare a container' + 'image' || 'would not run it in a container' + } + + def 'a containerized launch is accepted when the executor or the engine provides the container'() { + when: + AgentLaunchConditions.requireContainerized('qa', 'pi', executor, 'image', containerNative, engineEnabled) + + then: + noExceptionThrown() + + where: + executor | containerNative | engineEnabled + 'local' | false | true + 'local' | true | false + 'k8s' | true | false + } + + def 'only the local executor drives a container engine on the driver host'() { + expect: + AgentLaunchConditions.isDriverHostEngine(executor) == expected + + where: + executor | expected + null | true + 'local' | true + 'slurm' | false + 'k8s' | false + } + + def 'the broker host is resolved from the executor, the engine and the driver host itself'() { + given: 'the executor INSTANCE, which is what the grid row keys off - never a name list' + def session = withDriverHost(new Session()) + def agentExecutor = grid ? Mock(AbstractGridExecutor) : null + + expect: 'the guard returns the address it resolved, so the caller decides run options from it' + def host = AgentLaunchConditions.requireBrokerHost('qa', executor, agentExecutor, containerConfig, null, rpc(remoteHost), session) + host.host == expected + host.source == source + + where: + executor | grid | containerConfig | remoteHost || expected | source + // an explicit value always suffices, whatever the executor or engine + 'k8s' | false | new DockerConfig([:]) | 'driver.svc' || 'driver.svc' | 'agent.rpc.remoteHost' + // docker and podman synthesize a name for the host their containers run on + 'local' | false | new DockerConfig([:]) | null || 'host.docker.internal' | 'docker host alias' + 'local' | false | new PodmanConfig([:]) | null || 'host.containers.internal' | 'podman host alias' + // singularity creates no network namespace, so the task IS in the driver's + 'local' | false | new SingularityConfig([:]) | null || '127.0.0.1' | 'host network namespace' + // and where the task runs elsewhere -- grid, k8s, or an executor with no rung of its + // own -- the driver's own address on its default route, on one shared row + 'slurm' | true | new DockerConfig([:]) | null || '10.0.3.17' | 'inferred from default route' + 'k8s' | false | new DockerConfig([:]) | null || '10.0.3.17' | 'inferred from default route' + 'nonesuch'| false| new DockerConfig([:]) | null || '10.0.3.17' | 'inferred from default route' + } + + def 'the broker host is rejected, before ignition, for the configurations no address can serve'() { + given: 'a driver placed where the ladder has an error row for it' + def session = withDriverHost(new Session(sessionConfig), probes) + + when: + AgentLaunchConditions.requireBrokerHost('qa', executor, null, containerConfig, containerOptions, rpc(), session) + + // pre-ignition, because a capability is only ever released by its one-hour timeout: a + // configuration allowed to submit would fail only once the job had queued, an hour later + then: 'the run is rejected, and by name' + def e = thrown(ScriptRuntimeException) + e.message.contains('`qa`') + e.message.contains(executor) + and: 'and the message says which row rejected it, not merely that something did' + e.message.contains(reason) + + where: + executor | sessionConfig | containerConfig | containerOptions | probes || reason + // E2: a microVM created with no network at all can dial no address whatsoever + 'local' | [:] | new SmolVmConfig([network: false]) | null | [:] || '`smolvm.network = true`' + // E7: nothing routable to advertise - a guess here costs an hour of silence + 'k8s' | [:] | null | null | [outbound: null, interfaces: []] || 'no address the agent task could reach the driver on' + } + + def 'the docker host-gateway run option is added exactly when the docker alias is what the task uses'() { + expect: + AgentLaunchConditions.withDockerHostGateway(options, engine, remoteHost) == expected + + where: + options | engine | remoteHost || expected + // Linux Docker resolves `host.docker.internal` only with this option; Docker Desktop, + // where the name is built in, accepts it too - so it is added unconditionally for docker + null | 'docker' | null || '--add-host=host.docker.internal:host-gateway' + // an option the agent declares is preserved, never replaced + '--cpus 2' | 'docker' | null || '--cpus 2 --add-host=host.docker.internal:host-gateway' + // spelling the docker alias out explicitly needs the mapping just as much as the default + // does - a migrated config, or an overlay inheriting the value from a k8s profile, does + null | 'docker' | 'host.docker.internal' || '--add-host=host.docker.internal:host-gateway' + // nothing to add: the task was given an explicit address for the driver + null | 'docker' | 'driver.svc' || null + '--cpus 2' | 'docker' | 'driver.svc' || '--cpus 2' + // podman provides `host.containers.internal` itself, and no other engine takes the option + null | 'podman' | null || null + null | 'singularity' | null || null + // a container-native executor: no local engine runs the container at all + '--cpus 2' | null | 'driver.svc' || '--cpus 2' + } + + def 'a dynamic containerOptions value is left untouched rather than stringified'() { + given: 'a closure is resolved per task by TaskConfig, so it cannot be appended to here' + final dynamic = { -> '--cpus 2' } + + expect: + AgentLaunchConditions.withDockerHostGateway(dynamic, 'docker', null).is(dynamic) + } + + /** + * A session whose executor answers the three questions the guard asks it -- does it manage + * containers itself, which engine block must be enabled, and (through its TYPE) where it runs + * the task. Stubbing the EXECUTOR rather than passing the answers in is what puts the resolution + * under test: which executor is interrogated, and which engine block is read for it. + * + *

The driver host is placed too, so the address rung a feature exercises is the one it asked + * for and not one the machine running the suite happens to supply. + */ + private Session sessionWith(Map config, boolean containerNative, String configEngine, + Class executorType = Executor, Map probes = [:]) { + final executor = Stub(executorType) { + isContainerNative() >> containerNative + containerConfigEngine() >> configEngine + } + final session = new Session(config) + session.executorFactory = Stub(ExecutorFactory) { + getExecutorByName(_, _) >> executor + } + return withDriverHost(session, probes) + } + + def 'an agent with no container is rejected before the executor is even resolved'() { + given: 'a factory that blows up if it is asked for an executor at all' + def session = new Session([docker: [enabled: true]]) + session.executorFactory = Stub(ExecutorFactory) { + getExecutorByName(_, _) >> { throw new IllegalStateException('the executor must not be resolved') } + } + + when: 'no image is declared - not even an executor that cannot be instantiated hides that' + AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'local', container, null, rpc('driver.internal'), session) + + then: 'the message names the directive to set AND what the runner image is for' + def e = thrown(ScriptRuntimeException) + e.message.contains('must declare a container') + e.message.contains('agent.container') + e.message.contains('`pi` runner image') + + where: 'both an absent image and the explicit opt-out' + container << [ null, false ] + } + + def 'an agent with a container is rejected when nothing would run it in one'() { + given: 'a grid executor: not container-native, and no engine enabled in the config' + def session = sessionWith([:], false, null) + + when: + AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'slurm', 'agent-image:test', null, rpc('driver.internal'), session) + + then: 'the in-image paths would be exec-ed on the host, so fail here instead' + def e = thrown(ScriptRuntimeException) + e.message.contains('would not run it in a container') + e.message.contains('agent.container') + e.message.contains('`pi` runner image') + } + + def 'a container-native executor containerizes the agent with no engine enabled'() { + given: 'k8s: container-native, and it reads the docker config block' + def session = sessionWith([:], true, 'docker') + + when: 'no `docker.enabled` anywhere - the executor manages containers itself' + def launch = AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'k8s', 'agent-image:test', null, rpc('driver.svc'), session) + + then: + noExceptionThrown() + and: 'no engine is reported: the container is launched in the cluster, not by the driver' + launch.containerEngine == null + launch.brokerHost.host == 'driver.svc' + + when: 'and with no address given, the driver advertises the address it routes out on' + def inferred = AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'k8s', 'agent-image:test', null, rpc(), session) + + then: 'the docker host alias names nothing a pod can resolve, so it must not stand in' + inferred.brokerHost.host == '10.0.3.17' + inferred.brokerHost.source == 'inferred from default route' + } + + def 'a local executor with an enabled engine containerizes the agent'() { + given: 'the local executor is not container-native and pins no engine of its own' + def session = sessionWith([docker: [enabled: true]], false, null) + + when: 'no explicit `agent.rpc.remoteHost` - docker names the container host itself' + def launch = AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'local', 'agent-image:test', null, rpc(), session) + + then: + noExceptionThrown() + and: 'the engine is reported back, because it runs on the driver host and takes run options' + launch.containerEngine == 'docker' + and: 'together with the address it resolved, which is what decides those run options' + launch.brokerHost.host == 'host.docker.internal' + launch.brokerHost.source == 'docker host alias' + } + + def 'a Fusion-enabled local executor still reaches the driver at the docker host alias'() { + given: 'Fusion makes the LOCAL executor container-native, but docker still runs on the driver host' + def session = sessionWith([docker: [enabled: true]], true, null) + + when: 'no explicit `agent.rpc.remoteHost`' + def launch = AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'local', 'agent-image:test', null, rpc(), session) + + then: 'container-native does not mean remote - the alias applies and no address is demanded' + noExceptionThrown() + launch.containerEngine == 'docker' + launch.brokerHost.host == 'host.docker.internal' + } + + def 'a remote executor driving an engine of its own reaches the driver at its own address'() { + given: 'a grid executor with docker enabled: the daemon runs on the compute node, not the driver' + def session = sessionWith([docker: [enabled: true]], false, null, AbstractGridExecutor) + + when: + def launch = AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'slurm', 'agent-image:test', null, rpc(), session) + + // the node's `host.docker.internal` names that node's host, never the driver, so what the + // job dials back on is the driver's own address on its default route + then: 'the address is inferred rather than demanded' + launch.brokerHost.host == '10.0.3.17' + launch.brokerHost.source == 'inferred from default route' + and: 'and no engine is reported: the driver adds no run options to a remote launch' + launch.containerEngine == null + + when: 'the driver address is declared, it still wins over what was inferred' + def explicit = AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'slurm', 'agent-image:test', null, rpc('driver.internal'), session) + + then: + explicit.brokerHost.host == 'driver.internal' + explicit.containerEngine == null + } + + def 'the enabled engine is discovered through the executor and names the container host'() { + given: 'podman rather than docker, discovered because the local executor pins no engine' + def session = sessionWith([podman: [enabled: true]], false, null) + + when: 'no explicit `agent.rpc.remoteHost` - podman has a host alias of its own' + def launch = AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'local', 'agent-image:test', null, rpc(), session) + + then: + noExceptionThrown() + launch.containerEngine == 'podman' + launch.brokerHost.host == 'host.containers.internal' + } + + def 'the engine block consulted is the one the EXECUTOR asks for, not whichever is enabled'() { + given: 'an executor that pins the docker block (as k8s does) while podman is the enabled engine' + def session = sessionWith([podman: [enabled: true]], false, 'docker') + + when: + AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'custom', 'agent-image:test', null, rpc('driver.internal'), session) + + then: 'an engine the executor does not use cannot satisfy the guard - the task would not see it' + // the engine-agnostic session.getContainerConfig() would accept this, which is exactly the + // disagreement with the task's own view (TaskRun.getContainerConfig asks the executor too) + def e = thrown(ScriptRuntimeException) + e.message.contains('would not run it in a container') + } + + def 'a containerizing engine with no host alias reaches the driver in its own network namespace'() { + given: 'singularity DOES containerize the task, it just has no name for the container host' + def session = sessionWith([singularity: [enabled: true]], false, null) + + when: 'no address is given: singularity creates no network namespace of its own' + def launch = AgentLaunchConditions.requireCanonicalLaunch('qa', 'pi', 'local', 'agent-image:test', null, rpc(), session) + + then: 'the task is IN the driver`s namespace, so the driver`s loopback is what it dials' + launch.brokerHost.host == '127.0.0.1' + launch.brokerHost.source == 'host network namespace' + launch.containerEngine == 'singularity' + + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentLineageE2ETest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentLineageE2ETest.groovy new file mode 100644 index 0000000000..67604e3bea --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentLineageE2ETest.groovy @@ -0,0 +1,324 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonSlurper +import nextflow.Session +import nextflow.lineage.DefaultLinStore +import nextflow.lineage.LinObserver +import nextflow.lineage.LinPropertyValidator +import nextflow.lineage.config.LineageConfig +import nextflow.lineage.model.v1beta1.AgentRun +import nextflow.lineage.model.v1beta1.TaskOutput +import nextflow.lineage.model.v1beta1.TaskRun +import nextflow.script.ScriptBinding +import nextflow.script.ScriptFile +import nextflow.script.ScriptLoaderFactory +import spock.lang.TempDir +import spock.lang.Timeout +import test.Dsl2Spec + +/** + * End-to-end test of lineage capture for the {@code agent} primitive: runs a real workflow + * through a real {@link Session} (real executor, monitor and task handlers, so the task + * lifecycle notifications actually fire) with a {@link LinObserver} writing to a local + * lineage store, then asserts on what landed on disk. + * + *

The LLM is stubbed via {@link AgentRunnerProvider#testRunner} — this test is in package + * {@code nextflow.agent} so that package-scoped assignment is a plain field write. + * + * @author Paolo Di Tommaso + */ +@Timeout(120) +class AgentLineageE2ETest extends Dsl2Spec { + + @TempDir + Path tempDir + + def cleanup() { + AgentRunnerProvider.testRunner = null + AgentCallInfo.clear() + } + + def 'should record an agent run as an AgentRun lineage record'() { + given: 'a stubbed LLM that also reports a concrete resolved model' + AgentRunnerProvider.testRunner = new AgentRunner() { + @Override String getName() { 'stub-runner' } + @Override String run(AgentRunnerRequest req) { + AgentCallInfo.setResolvedModel('gpt-4o-2024-11-20') + return 'the answer is 42' + } + } + + and: 'a workflow with one agent and one ordinary process' + final script = tempDir.resolve('main.nf') + script.text = ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + instruction 'You are terse.' + maxIterations 7 + input: + q: String + output: + answer: String + prompt: + """ + Question: ${q} + """ + } + + process plain { + input: + x: String + + output: + stdout() + + script: + """ + echo ${x} + """ + } + + workflow { + qa(channel.of('what is the meaning of life')) + plain(channel.of('hello')) + } + ''' + + when: + final store = runWithLineage(script) + + then: 'the agent task is stored as an AgentRun, not a TaskRun' + final agentRun = loadOnly(store, AgentRun) + agentRun.name.startsWith('qa') + agentRun.sessionId + agentRun.codeChecksum + + and: 'it records the resolved agent identity' + agentRun.runner == 'stub-runner' + agentRun.model == 'openai/gpt-4o' + agentRun.instruction == 'You are terse.' + agentRun.maxIterations == 7 + agentRun.promptTemplate.contains('Question:') + agentRun.tools == null + agentRun.skills == null + agentRun.workflowRun.startsWith('lid://') + + and: 'the concrete model reported by the provider is captured' + agentRun.resolvedModel == 'gpt-4o-2024-11-20' + + and: 'the typed input is recorded as a value parameter, not as the literal class name' + agentRun.input.size() == 1 + agentRun.input[0].name == 'q' + agentRun.input[0].type == 'val' + agentRun.input[0].value == 'what is the meaning of life' + + and: 'the agent output is recorded as a TaskOutput, exactly like a process' + final outputs = loadAll(store, TaskOutput) + final agentOutput = outputs.find { it.taskRun == "lid://${agentHash(store)}".toString() } + agentOutput + agentOutput.output[0].name == 'answer' + agentOutput.output[0].value == 'the answer is 42' + + and: 'the ordinary process in the same run is still stored as a plain TaskRun' + final taskRuns = loadAll(store, TaskRun) + taskRuns.size() == 1 + taskRuns[0].name.startsWith('plain') + taskRuns[0].script.contains('echo') + + and: 'the record is queryable by its agent-specific fields, i.e. `lineage find type=AgentRun model=...`' + new LinPropertyValidator().validateQueryParams(['type', 'model', 'runner'] as Set) + store.search([type: ['AgentRun'], model: ['openai/gpt-4o']]).toList() == [agentHash(store)] + + and: 'the stored agent record carries no script field at all -- on the RPC runner path a\n rendered agent command embeds a per-invocation capability token' + final json = rawJson(store, agentHash(store)) + json.kind == 'AgentRun' + json.version == 'lineage/v1beta1' + !json.spec.containsKey('script') + } + + def 'should record the tools and skills an agent was allowed to use'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + // report a snapshot: asserting it is NOT recorded below then proves the + // non-cacheable suppression, rather than passing because nothing set it + AgentCallInfo.setResolvedModel('gpt-4o-2024-11-20') + return 'done' + } as AgentRunner + + and: + final script = tempDir.resolve('main.nf') + script.text = ''' + nextflow.enable.types = true + + process upper { + input: + s: String + + output: + shouted: String + + exec: + shouted = s.toUpperCase() + } + + agent shouty { + model 'openai/gpt-4o' + tools 'nf:module_run:upper' + input: + word: String + output: + result: String + prompt: + """ + Shout: ${word} + """ + } + + workflow { + shouty(channel.of('hi')) + } + ''' + + when: + final store = runWithLineage(script) + + then: + final agentRun = loadOnly(store, AgentRun) + agentRun.tools == ['upper'] + agentRun.skills == null + // a module-tool agent IS cacheable (its tools are folded into the cache key by + // AgentDef.toolsFingerprint), so the snapshot the runner reported is persisted for + // drift detection just like a tool-free agent's + agentRun.model == 'openai/gpt-4o' + agentRun.resolvedModel == 'gpt-4o-2024-11-20' + } + + def 'should not let a process selector override the agent marker'() { + // createTaskProcessor applies the process config scope onto the SAME config object, so + // without the re-assert in buildAgentTask a selector would silently downgrade the record + given: 'a config that tries to replace the agent identity through a withName: selector' + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'ok' } as AgentRunner + + and: + final script = tempDir.resolve('main.nf') + script.text = ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + q: String + output: + answer: String + prompt: "Q: ${q}" + } + + workflow { + qa(channel.of('hello')) + } + ''' + + when: + final store = runWithLineage(script, [process: ['withName:qa': [agentInfo: 'forged']]]) + + then: 'the run is still recorded as a genuine AgentRun, not as a plain TaskRun' + final agentRun = loadOnly(store, AgentRun) + agentRun.model == 'openai/gpt-4o' + and: 'and the forged value never reaches the store' + loadAll(store, TaskRun).isEmpty() + } + + // ---- helpers ------------------------------------------------------------------------- + + /** + * Run the script through a real {@link Session} with a {@link LinObserver} writing to a + * local lineage store under the temp dir, and return the opened store. + * + *

The observer is constructed directly and injected into the private {@code observersV2} + * list rather than discovered through the plugin system: this keeps the test independent of + * plugin-manager lifecycle state leaking in from other tests in the same JVM. + */ + private DefaultLinStore runWithLineage(Path script, Map extraConfig = [:]) { + final workDir = Files.createTempDirectory('nxf-agent-lineage-test') + final storeDir = tempDir.resolve('lineage') + final config = [ + workDir: workDir.toString(), + lineage: [enabled: true, store: [location: storeDir.toString()]] ] + extraConfig + + final session = new Session(config) + session.setBinding(new ScriptBinding()) + // MUST init from a real ScriptFile: LinObserver.collectScriptDataPaths dereferences + // workflowMetadata.scriptFile unguarded, which is null when initialized from null + session.init(new ScriptFile(script), null, null, null) + + final store = new DefaultLinStore() + store.open(LineageConfig.create(session)) + injectObserver(session, new LinObserver(session, store)) + + session.start() + final loader = ScriptLoaderFactory.create(session) + loader.parse(script) + loader.runScript() + session.fireDataflowNetwork() + session.await() + session.destroy() + if( session.error ) + throw session.error + return store + } + + private static void injectObserver(Session session, Object probe) { + final f = Session.getDeclaredField('observersV2') + f.setAccessible(true) + final list = new ArrayList((List) f.get(session)) + list.add(probe) + f.set(session, list) + } + + /** The store keys are the record directories holding a `.data.json` file. */ + private List recordKeys(DefaultLinStore store) { + return Files.list(tempDir.resolve('lineage')) + .filter { Files.exists(it.resolve('.data.json')) } + .map { it.fileName.toString() } + .sorted() + .toList() + } + + private List loadAll(DefaultLinStore store, Class type) { + return recordKeys(store).collect { store.load(it) }.findAll { type.isInstance(it) } as List + } + + private T loadOnly(DefaultLinStore store, Class type) { + final found = loadAll(store, type) + assert found.size() == 1, "Expected exactly one ${type.simpleName} record, found ${found.size()}" + return found[0] + } + + private String agentHash(DefaultLinStore store) { + return recordKeys(store).find { store.load(it) instanceof AgentRun } + } + + private Map rawJson(DefaultLinStore store, String key) { + return new JsonSlurper().parse(tempDir.resolve("lineage/${key}/.data.json")) as Map + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentMapReduceE2ETest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentMapReduceE2ETest.groovy new file mode 100644 index 0000000000..5ad7de7ed7 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentMapReduceE2ETest.groovy @@ -0,0 +1,257 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.util.concurrent.atomic.AtomicInteger + +import nextflow.Session +import nextflow.script.ScriptBinding +import nextflow.script.ScriptLoaderFactory +import nextflow.trace.TraceObserverV2 +import nextflow.trace.event.TaskEvent +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * End-to-end CI guard for the fully-agentic map-reduce graph (design M6, spec §13): + * {@code planner -> mapper (parallel map) -> reducer (collect() fan-in)}, every phase an + * {@code agent} lowered to a real {@code TaskProcessor} (M1). The whole graph is driven + * INLINE (no dependency on {@code examples/agents/map-reduce/main.nf}) with the LLM stubbed + * via {@link AgentRunnerProvider#testRunner}. The stub is prompt-aware: it distinguishes the + * three agents by a marker in {@code req.prompt} and echoes correlating shard ids so findings + * are matched by KEY, not by completion order (order-independence, spec §6.6/§10). + * + * All assertions are deterministic: per-role invocation counts, terminal record shape and + * order-independent set membership. NO wall-clock / timing assertions (spec §4.3, §12 risk #4). + * + * @author Paolo Di Tommaso + */ +@Timeout(120) +class AgentMapReduceE2ETest extends Dsl2Spec { + + /** + * The spec §13 map-reduce workflow, inlined so the test does not depend on the example + * file. Single-output agents auto-unwrap to their channel, so {@code planner(...)} IS the + * {@code Plan} channel; {@code plan.flatMap{ it.shards }} yields a deterministic shard queue, + * {@code mapper(shards)} maps one TaskRun per shard, and {@code reducer(findings.collect())} + * fans in the whole bag as a single value item (one TaskRun). + */ + private static final String MAP_REDUCE_SCRIPT = ''' + nextflow.enable.types = true + + record Shard { id: String; question: String } + record Plan { title: String; shards: List } + record Finding { shardId: String; summary: String } + record Report { title: String; body: String } + + agent planner { + model 'openai/gpt-4o-2024-08-06' + instruction 'You decompose a research brief into independent shards.' + input: + brief: String + output: + plan: Plan + prompt: + """ + Break this brief into independent research shards: + + ${brief} + """ + } + + agent mapper { + model 'openai/gpt-4o-2024-08-06' + instruction 'You are a focused researcher; echo the shard id you were given.' + input: + shard: Shard + output: + finding: Finding + prompt: + """ + Shard id: ${shard.id} + Answer this shard question: + + ${shard.question} + """ + } + + agent reducer { + model 'openai/gpt-4o-2024-08-06' + instruction 'You synthesise many independent findings into one coherent report.' + input: + findings: Bag + output: + report: Report + prompt: + """ + Synthesise ONE report from these findings: + + ${findings.collect { "- (${it.shardId}) ${it.summary}" }.join('\\n')} + """ + } + + workflow { + def plan = planner(channel.of('a research brief')) + def shards = plan.flatMap { it.shards } + def findings = mapper(shards) + reducer(findings.collect()) + } + ''' + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + // -- The whole graph: planner once, mapper per-shard, reducer fan-in once, one Report. + def 'should run the agentic map-reduce graph end-to-end with deterministic per-role counts and fan-in'() { + given: 'a prompt-aware stub that counts calls per role and echoes shard keys' + def plannerCalls = new AtomicInteger() + def mapperCalls = new AtomicInteger() + def reducerCalls = new AtomicInteger() + def reducerReqs = Collections.synchronizedList([]) + AgentRunnerProvider.testRunner = stubRunner(plannerCalls, mapperCalls, reducerCalls, reducerReqs) + + when: 'the §13 workflow runs with an explicit cpu gate so the map fan-out is admitted' + def result = runScript(config: [executor: [local: [cpus: 8]]], MAP_REDUCE_SCRIPT) + + then: '(1) planner fired exactly once (single value input -> fan-in of one)' + plannerCalls.get() == 1 + + and: '(2) mapper fired once per shard - flatMap produced a 3-item queue, each shard its own TaskRun' + mapperCalls.get() == 3 + + and: '(3) reducer fired exactly once on the whole collected bag (collect() -> one value item)' + reducerCalls.get() == 1 + + and: '(3) the reducer input carried ALL three findings - assert the SET of shard ids, order-independent' + def reducerReq = reducerReqs[0] as AgentRunnerRequest + def idsInPrompt = (reducerReq.prompt =~ /\((s\d+)\)/).collect { it[1] } as Set + idsInPrompt == ['s1', 's2', 's3'] as Set + and: 'each shard summary is present in the reducer prompt and its input json' + ['sum-s1', 'sum-s2', 'sum-s3'].every { reducerReq.prompt.contains(it) } + (['s1', 's2', 's3'] as Set).every { reducerReq.inputJson.contains(it) } + + and: '(1)+(4) the graph yields exactly one terminal Report with the expected title/body' + def report = result.val + report instanceof Map + report.title == 'R' + report.body == 'combined' + } + + // -- Zero-new-observer proof: the three agents are real TaskProcessors, so the stock + // task-lifecycle + process-create events fall out for free (spec §4.7). Driven through a + // REAL Session (the MockSession used above short-circuits the monitor and fires no events), + // with only an anonymous stock TraceObserverV2 probe - no agent-specific observer type. + def 'should fire process-create and task-lifecycle events for planner, mapper and reducer with zero new observer code'() { + given: + def plannerCalls = new AtomicInteger() + def mapperCalls = new AtomicInteger() + def reducerCalls = new AtomicInteger() + AgentRunnerProvider.testRunner = stubRunner(plannerCalls, mapperCalls, reducerCalls, null) + def createdProcesses = Collections.synchronizedList([]) + def completed = Collections.synchronizedList([]) + def probe = new TraceObserverV2() { + @Override void onProcessCreate(nextflow.processor.TaskProcessor process) { createdProcesses.add(process.name) } + @Override void onTaskComplete(TaskEvent event) { completed.add(event) } + } + + when: + runWithObserver(probe, MAP_REDUCE_SCRIPT) + + then: 'the stock process-create event fired for every agent (each lowered to a TaskProcessor)' + createdProcesses.containsAll(['planner', 'mapper', 'reducer']) + + and: 'the stub is a plain TraceObserverV2 - no new agent-specific observer type' + probe instanceof TraceObserverV2 + + and: 'task-complete events fired 1 (planner) + 3 (mapper) + 1 (reducer) = 5 across the three names' + def byProc = completed.groupBy { it.trace.get('process') } + byProc['planner']?.size() == 1 + byProc['mapper']?.size() == 3 + byProc['reducer']?.size() == 1 + completed.size() == 5 + } + + // ----------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------- + + /** + * Prompt-aware deterministic LLM stub. Each agent output is a SINGLE record, so the stub + * returns BARE record JSON (no wrapper key) - matching the unwrapped single-record wire shape + * (spec §5.3b/§9.3). The mapper echoes the shard id it was handed, so findings correlate by + * key regardless of the (non-deterministic) map completion order. + */ + private AgentRunner stubRunner(AtomicInteger planner, AtomicInteger mapper, AtomicInteger reducer, List reducerReqs) { + return { AgentRunnerRequest req -> + final p = req.prompt + if( p.contains('Break this brief') ) { + planner.incrementAndGet() + return '{"title":"T","shards":[' + + '{"id":"s1","question":"q1"},{"id":"s2","question":"q2"},{"id":"s3","question":"q3"}]}' + } + if( p.contains('Answer this shard') ) { + mapper.incrementAndGet() + final m = (p =~ /Shard id:\s*(\S+)/) + final id = m.find() ? m.group(1) : 's?' + return "{\"shardId\":\"${id}\",\"summary\":\"sum-${id}\"}".toString() + } + if( p.contains('Synthesise ONE report') ) { + reducer.incrementAndGet() + if( reducerReqs != null ) reducerReqs.add(req) + return '{"title":"R","body":"combined"}' + } + throw new IllegalStateException("unexpected agent prompt: " + (p?.take(60))) + } as AgentRunner + } + + /** + * Drive the workflow through a REAL {@link Session} (real {@code ExecutorFactory}, + * {@code LocalExecutor}, monitor and {@code NativeTaskHandler}) so that the task-lifecycle + * observer notifications and process-create events actually fire - the {@code MockSession} + * harness used by {@code runScript} short-circuits the monitor and never emits them. A probe + * {@link TraceObserverV2} is injected into the private {@code observersV2} list before ignition + * (no public observer-registration API). Mirrors the proven pattern in + * {@code AgentAsTaskIntegrationTest} (Test I); introduces NO production/observer code. + */ + private static Session runWithObserver(TraceObserverV2 probe, String text) { + final workDir = Files.createTempDirectory('nxf-agent-mapreduce') + def session = new Session([workDir: workDir.toString()]) + session.setBinding(new ScriptBinding()) + session.init(null, null, null, null) + // inject the probe into the private observersV2 list before ignition + final f = Session.getDeclaredField('observersV2') + f.setAccessible(true) + final list = new ArrayList((List) f.get(session)) + list.add(probe) + f.set(session, list) + session.start() + + def loader = ScriptLoaderFactory.create(session) + loader.parse(text) + loader.runScript() + + session.fireDataflowNetwork() + session.await() + session.destroy() + if( session.error ) + throw session.error + return session + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleDirIsolationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleDirIsolationTest.groovy new file mode 100644 index 0000000000..14ed1c19e1 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleDirIsolationTest.groovy @@ -0,0 +1,178 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonSlurper +import nextflow.script.ScriptFile +import nextflow.script.ScriptRunner +import spock.lang.Timeout +import test.Dsl2Spec + +/** + * Regression test for the per-tool {@code moduleDir} bleed (Phase 3.x). + * + *

When an agent selects MULTIPLE processes from separate modules under + * {@code nf:module_run}, each tool's {@code main.nf} must resolve {@code moduleDir} to ITS + * OWN module directory. The agent used to compile a module path entry itself, as the + * {@code mainScript} on the SHARED {@code session.binding}; {@code BaseScript.setup()} + * overwrites {@code binding.moduleDir} per module, so the LAST-compiled module's dir won + * (container/conda bleed across tools). That resolution form is gone — a module tool is now + * {@code include}d like any other module, which is what gives each one its own isolated + * {@link nextflow.script.ScriptBinding} — and this test pins that the property survived the + * move: a bridge dispatching a tool must not disturb the module dir another tool resolves. + * + *

Three module tools live in SEPARATE temp dirs; each {@code exec}-only process + * emits {@code moduleDir.toString()}. The test dispatches ALL THREE and asserts each tool + * reports its OWN module dir and that they all DIFFER. + * + *

The {@code @Timeout} fails if the tool input queues are not poisoned on completion. + */ +@Timeout(90) +class AgentModuleDirIsolationTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + def 'should resolve each external file-module tool to its own moduleDir'() { + given: + final root = Files.createTempDirectory('test') + final work = root.resolve('work'); Files.createDirectories(work) + + // -- two distinct module dirs, each with a single scalar-`val`/`exec` process that + // emits its OWN moduleDir + final modA = root.resolve('modA'); Files.createDirectories(modA) + modA.resolve('main.nf').text = ''' + nextflow.enable.types = true + + process tool_a { + input: + x: String + + output: + out: String + + exec: + out = moduleDir.toString() + } + '''.stripIndent() + final modADir = modA.toAbsolutePath().toString() + + final modB = root.resolve('modB'); Files.createDirectories(modB) + modB.resolve('main.nf').text = ''' + nextflow.enable.types = true + + process tool_b { + input: + x: String + + output: + out: String + + exec: + out = moduleDir.toString() + } + '''.stripIndent() + final modBDir = modB.toAbsolutePath().toString() + + final modC = root.resolve('modC'); Files.createDirectories(modC) + modC.resolve('main.nf').text = ''' + nextflow.enable.types = true + + process tool_c { + input: + x: String + + output: + out: String + + exec: + out = moduleDir.toString() + } + '''.stripIndent() + final modCDir = modC.toAbsolutePath().toString() + + and: + final main = root.resolve('main.nf') + main.text = """ + include { tool_a } from '${modA.resolve('main.nf').toAbsolutePath()}' + include { tool_b } from '${modB.resolve('main.nf').toAbsolutePath()}' + include { tool_c } from '${modC.resolve('main.nf').toAbsolutePath()}' + + agent a { + model 'm' + instruction 'i' + tools 'nf:module_run:tool_a', 'nf:module_run:tool_b', 'nf:module_run:tool_c' + + input: + request: String + + output: + answer: String + + prompt: + \"\"\" + \${request} + \"\"\" + } + + workflow { + a(channel.of('go')).view { it } + } + """.stripIndent() + + and: + String resultA = null + String resultB = null + String resultC = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + // all three module tools are advertised + assert (req.toolSpecs*.name as Set) == ['tool_a', 'tool_b', 'tool_c'] as Set + // dispatch each tool: each runs its REAL process and emits its OWN moduleDir + resultA = req.dispatch.call('tool_a', '{"x":"go"}') + resultB = req.dispatch.call('tool_b', '{"x":"go"}') + resultC = req.dispatch.call('tool_c', '{"x":"go"}') + return resultA + } as AgentRunner + + when: + final runner = new ScriptRunner([process: [executor: 'local'], workDir: work.toString()]) + runner.setScript(new ScriptFile(main)) + runner.execute() + + then: + resultA != null + resultB != null + resultC != null + and: + final outA = new JsonSlurper().parseText(resultA).out as String + final outB = new JsonSlurper().parseText(resultB).out as String + final outC = new JsonSlurper().parseText(resultC).out as String + and: + // each tool resolved its OWN module directory ... + outA == modADir + outB == modBDir + outC == modCDir + and: + // ... and they are all distinct (no shared/last-compiled-module bleed) + outA != outB + outB != outC + outA != outC + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleIncludeTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleIncludeTest.groovy new file mode 100644 index 0000000000..056c244b4a --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleIncludeTest.groovy @@ -0,0 +1,353 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import nextflow.script.ScriptMeta +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * Characterization tests for an {@code agent} authored as a MODULE and consumed with the + * ordinary include statement, exactly like a process (design "agent module include" §3, §4.1). + * + *

The runtime include path ({@code IncludeDef.load0} -> {@code ScriptMeta.addModule} -> + * {@code BaseScript.getComponent} -> {@code BindableDef.invoke_a}) is generic over + * {@code ComponentDef} and never casts to {@code ProcessDef}, so an {@code AgentDef} rides it + * unchanged. That behaviour was entirely unprotected by tests; this class locks it down. + * + *

The LLM is stubbed through {@link AgentRunnerProvider#testRunner} with a capturing runner, + * so every assertion is deterministic and NO model call is made. + * + * @author Paolo Di Tommaso + */ +@Timeout(120) +class AgentModuleIncludeTest extends Dsl2Spec { + + /** An agent module: no `nextflow.enable.types` needed for a typed `agent` block. */ + private static String agentModule(String name) { + return """\ + agent ${name} { + model 'openai/gpt-4o' + instruction 'You write QA reports.' + + input: + sample: String + + output: + report: String + + prompt: + \"\"\" + Report on \${sample}. + \"\"\" + } + """.stripIndent() + } + + private List captured + + def setup() { + captured = Collections.synchronizedList(new ArrayList()) + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured.add(req) + return "REPORT-${req.agentName}".toString() + } as AgentRunner + } + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + private static Path write(Path path, String text) { + Files.createDirectories(path.parent) + path.text = text + return path + } + + def 'should include an agent from a module file'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter.nf'), agentModule('reporter')) + final main = write(root.resolve('main.nf'), '''\ + include { reporter } from './mods/reporter.nf' + + workflow { + reporter(channel.of('s1')).view() + } + '''.stripIndent()) + + when: + final result = runScript(main) + + then: + captured.size() == 1 + captured[0].agentName == 'reporter' + result.val == 'REPORT-reporter' + } + + // -- design §7.1/OQ-1: unlike a process, an agent has NO duplicate-invocation guard, and that + // was a DELIBERATE rejection (§15) -- AgentDef builds a fresh ProcessConfigV2 per call, so a + // second invocation binds its own channels and nothing is corrupted. Without this test the + // rejection lives only in a spec file, and a later refactor lifting the + // `this instanceof ProcessDef` gate in BindableDef.invoke_a up to BindableDef would silently + // turn a supported pattern into a DuplicateProcessInvocation error. + def 'should allow two invocations of one included agent in the same scope'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter.nf'), agentModule('reporter')) + final main = write(root.resolve('main.nf'), '''\ + include { reporter } from './mods/reporter.nf' + + workflow { + reporter(channel.of('s1')).view() + reporter(channel.of('s2')).view() + } + '''.stripIndent()) + + when: + runScript(main) + + then: 'both calls run -- no DuplicateProcessInvocation' + noExceptionThrown() + captured.size() == 2 + captured.collect { it.agentName } == ['reporter', 'reporter'] + } + + def 'should include an agent from a module directory via main.nf'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter/main.nf'), agentModule('reporter')) + final main = write(root.resolve('main.nf'), '''\ + include { reporter } from './mods/reporter' + + workflow { + reporter(channel.of('s1')).view() + } + '''.stripIndent()) + + when: + final result = runScript(main) + + then: + captured.size() == 1 + captured[0].agentName == 'reporter' + result.val == 'REPORT-reporter' + } + + def 'should include an agent through a parent-relative path'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter/main.nf'), agentModule('reporter')) + final main = write(root.resolve('pipeline/main.nf'), '''\ + include { reporter } from '../mods/reporter' + + workflow { + reporter(channel.of('s1')).view() + } + '''.stripIndent()) + + when: + final result = runScript(main) + + then: + captured[0].agentName == 'reporter' + result.val == 'REPORT-reporter' + } + + def 'should include an agent through an absolute path'() { + given: + final root = Files.createTempDirectory('test') + final module = write(root.resolve('mods/reporter/main.nf'), agentModule('reporter')) + final main = write(root.resolve('main.nf'), """\ + include { reporter } from '${module.toAbsolutePath()}' + + workflow { + reporter(channel.of('s1')).view() + } + """.stripIndent()) + + when: + final result = runScript(main) + + then: + captured[0].agentName == 'reporter' + result.val == 'REPORT-reporter' + } + + // -- ALIAS: cloneWithName renames the component, so the task/processor -- and therefore the + // work dir, the progress table and the trace -- carry the ALIAS, not the declared name. + def 'should name the task after the alias when the include is aliased'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter/main.nf'), agentModule('reporter')) + final main = write(root.resolve('main.nf'), '''\ + include { reporter as qc } from './mods/reporter' + + workflow { + qc(channel.of('s1')).view() + } + '''.stripIndent()) + + when: + final result = runScript(main) + + then: + captured[0].agentName == 'qc' + result.val == 'REPORT-qc' + and: 'both the declared name and the alias reach the config-selector registry, so a' + // `withName:` block targeting either is not reported as unmatched by Session#checkConfig + ScriptMeta.allAgentNames().contains('reporter') + ScriptMeta.allAgentNames().contains('qc') + } + + def 'should include two agents selectively from one module, one of them aliased'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/agents.nf'), agentModule('alpha') + '\n' + agentModule('beta')) + final main = write(root.resolve('main.nf'), '''\ + include { alpha ; beta as gamma } from './mods/agents.nf' + + workflow { + alpha(channel.of('s1')).view() + gamma(channel.of('s2')).view() + } + '''.stripIndent()) + + when: + runScript(main) + + then: + (captured*.agentName as Set) == ['alpha', 'gamma'] as Set + } + + // -- a module including ANOTHER module's agent resolves the path against its OWN location, + // and an invocation inside a named workflow qualifies the name with the workflow scope. + def 'should resolve a nested module-to-module include and qualify the name inside a named workflow'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter/main.nf'), agentModule('qa')) + write(root.resolve('mods/wrapper/main.nf'), '''\ + include { qa } from '../reporter' + + workflow wrapped { + take: + samples + + main: + qa(samples).view() + } + '''.stripIndent()) + final main = write(root.resolve('main.nf'), '''\ + include { wrapped } from './mods/wrapper' + + workflow { + wrapped(channel.of('s1')) + } + '''.stripIndent()) + + when: + runScript(main) + + then: 'the agent task is scoped by the enclosing named workflow' + captured.size() == 1 + captured[0].agentName == 'wrapped:qa' + } + + def 'should report a compile error for an unknown included agent name'() { + given: + final root = Files.createTempDirectory('test') + final module = write(root.resolve('mods/reporter/main.nf'), agentModule('reporter')) + final main = write(root.resolve('main.nf'), '''\ + include { nope } from './mods/reporter' + + workflow { + nope(channel.of('s1')).view() + } + '''.stripIndent()) + + when: + runScript(main) + + then: + final e = thrown(Exception) + // note: the reported path is the REAL path, so match on the module-relative tail + rootMessages(e).contains("Included name 'nope' is not defined in module '") + rootMessages(e).contains(module.subpath(module.nameCount - 3, module.nameCount).toString()) + and: 'the agent is never run' + captured.isEmpty() + } + + def 'should report a compile error when the same agent name is included twice'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/a/main.nf'), agentModule('reporter')) + write(root.resolve('mods/b/main.nf'), agentModule('reporter')) + final main = write(root.resolve('main.nf'), '''\ + include { reporter } from './mods/a' + include { reporter } from './mods/b' + + workflow { + reporter(channel.of('s1')).view() + } + '''.stripIndent()) + + when: + runScript(main) + + then: + final e = thrown(Exception) + rootMessages(e).contains('`reporter` is already included') + and: + captured.isEmpty() + } + + def 'should report an error when the module directory has no main.nf'() { + given: + final root = Files.createTempDirectory('test') + Files.createDirectories(root.resolve('mods/reporter')) + final main = write(root.resolve('main.nf'), '''\ + include { reporter } from './mods/reporter' + + workflow { + reporter(channel.of('s1')).view() + } + '''.stripIndent()) + + when: + runScript(main) + + then: + thrown(Exception) + } + + /** Flatten an exception chain (plus any suppressed causes) into one searchable string. */ + private static String rootMessages(Throwable e) { + final sb = new StringBuilder() + Throwable t = e + while( t != null ) { + sb.append(t.message ?: '').append('\n') + for( final s : t.getSuppressed() ) + sb.append(s.message ?: '').append('\n') + t = t.cause === t ? null : t.cause + } + return sb.toString() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleRunScopeTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleRunScopeTest.groovy new file mode 100644 index 0000000000..9111f75de2 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleRunScopeTest.groovy @@ -0,0 +1,213 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonSlurper +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * The tool scope of a MODULE agent is MODULE-LEXICAL (design §5): an agent sees only the + * processes defined or included by the script that DECLARES it. The including script's processes + * are not visible, exactly as a process body can only call what its own script defines or + * includes. That keeps the LLM's tool surface -- and therefore the model's behaviour -- independent + * of who imported the agent, which is what makes {@code main.nf} + {@code skills/} + {@code tools/} + * a shippable unit. + * + * @author Paolo Di Tommaso + */ +@Timeout(120) +class AgentModuleRunScopeTest extends Dsl2Spec { + + private AgentRunnerRequest captured + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + private static Path write(Path path, String text) { + Files.createDirectories(path.parent) + path.text = text + return path + } + + private static String agentModule(String toolsDirective, String extra = '') { + return """\ + ${extra} + agent reporter { + model 'openai/gpt-4o' + instruction 'You write QA reports.' + ${toolsDirective} + + input: + sample: String + + output: + report: String + + prompt: + \"\"\" + Report on \${sample}. + \"\"\" + } + """.stripIndent() + } + + /** A typed `exec` process module; `nextflow.enable.types` is per-FILE, hence the flag. */ + private static String toolModule(String name, String prefix) { + return """\ + nextflow.enable.types = true + + process ${name} { + input: + text: String + + output: + out: String + + exec: + out = '${prefix}' + text + } + """.stripIndent() + } + + def 'nf:module_run advertises the process the MODULE includes, not one defined only in the caller'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter/tools/shouter.nf'), toolModule('shouter', 'MOD:')) + write(root.resolve('mods/reporter/main.nf'), agentModule( + "tools 'nf:module_run'", + "include { shouter } from './tools/shouter.nf'\n")) + final main = write(root.resolve('main.nf'), '''\ + nextflow.enable.types = true + + include { reporter } from './mods/reporter' + + process caller_only { + input: + text: String + + output: + out: String + + exec: + out = 'CALLER:' + text + } + + workflow { + reporter(channel.of('s1')).view() + } + '''.stripIndent()) + + and: + String dispatched = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + dispatched = req.dispatch.call('shouter', '{"text":"hi"}') + return 'ok' + } as AgentRunner + + when: + runScript(main) + + then: 'the module-included process IS advertised' + captured.toolSpecs*.name == ['shouter'] + and: 'the process defined only in the INCLUDING script is NOT' + !captured.toolSpecs*.name.contains('caller_only') + and: 'a full round trip through the module`s own tool works' + new JsonSlurper().parseText(dispatched) == [out: 'MOD:hi'] + } + + def 'a process included only by the CALLER is not a member of the module agent`s nf:module_run'() { + given: 'the module includes `whisper`, the entry script includes `shouter`' + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter/tools/whisper.nf'), toolModule('whisper', 'MOD:')) + write(root.resolve('mods/reporter/main.nf'), agentModule( + "tools 'nf:module_run:shouter'", + "include { whisper } from './tools/whisper.nf'\n")) + write(root.resolve('tools/shouter.nf'), toolModule('shouter', 'CALLER:')) + final main = write(root.resolve('main.nf'), '''\ + include { reporter } from './mods/reporter' + include { shouter } from './tools/shouter.nf' + + workflow { + reporter(channel.of('s1')).view() + } + '''.stripIndent()) + + and: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'ok' } as AgentRunner + + when: 'the named tool is included ONLY by the entry script' + runScript(main) + + then: 'G8(c) - an explicit leaf that does not exist is a hard error' + final e = thrown(Exception) + final msg = messages(e) + msg.contains('Agent `reporter`: Tool `nf:module_run:shouter` does not exist') + and: 'the available list is the MODULE`s scope alone, which is what makes the rule visible' + msg.contains('available: `nf:module_run:whisper`') + } + + def 'a module agent relative include resolves against the module directory'() { + given: + final root = Files.createTempDirectory('test') + write(root.resolve('mods/reporter/tools/wordcount.nf'), toolModule('wordcount', 'MOD:')) + and: 'a DECOY of the same relative path beside the entry script' + write(root.resolve('tools/wordcount.nf'), toolModule('wordcount', 'DECOY:')) + write(root.resolve('mods/reporter/main.nf'), agentModule( + "tools 'nf:module_run:wordcount'", + "include { wordcount } from './tools/wordcount.nf'\n")) + final main = write(root.resolve('main.nf'), '''\ + include { reporter } from './mods/reporter' + + workflow { + reporter(channel.of('s1')).view() + } + '''.stripIndent()) + + and: + String dispatched = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + dispatched = req.dispatch.call('wordcount', '{"text":"hi"}') + return 'ok' + } as AgentRunner + + when: + runScript(main) + + then: + captured.toolSpecs*.name == ['wordcount'] + and: 'the MODULE`s copy ran, not the decoy beside the entry script' + new JsonSlurper().parseText(dispatched) == [out: 'MOD:hi'] + } + + private static String messages(Throwable e) { + final sb = new StringBuilder() + Throwable t = e + while( t != null ) { + sb.append(t.message ?: '').append('\n') + t = t.cause === t ? null : t.cause + } + return sb.toString() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleRunToolTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleRunToolTest.groovy new file mode 100644 index 0000000000..6ba592541a --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleRunToolTest.groovy @@ -0,0 +1,482 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import nextflow.script.ScriptFile +import nextflow.script.ScriptRunner +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * Integration test for the {@code nf:module_run} family: an agent declares + * {@code tools 'nf:module_run'} and every in-scope / included process is exposed to the LLM + * as its OWN tool whose function {@code parameters} schema IS that module's flattened input + * schema (required fields, {@code additionalProperties:false}, the nf-core {@code meta.id} + * convention). This per-module enforcement is what lets OpenAI function-calling validate the + * field names against the module schema — a single aggregate {@code module_run} tool could only + * use a generic {@code args:{additionalProperties:true}} object, which OpenAI cannot enforce. + * + * A mock runner drives the dispatch callback to verify end-to-end wiring and termination. + */ +@Timeout(60) +class AgentModuleRunToolTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + def 'should expose an in-scope process as its own per-module tool (not module_run)'() { + given: + AgentRunnerRequest captured = null + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // exactly one tool, NAMED after the module — NOT an aggregate 'module_run' + assert req.toolSpecs.size() == 1 + assert req.toolSpecs[0].name == 'greet' + assert req.toolSpecs*.name.every { it != 'module_run' } + // the tool's parameters schema IS the module's flattened input schema + assert req.toolSpecs[0].inputSchema.properties.name != null + // dispatch by the MODULE tool name (not 'module_run'): drives the REAL greet process + dispatchResult = req.dispatch.call('greet', '{"name":"Ada"}') + assert new JsonSlurper().parseText(dispatchResult) == [greeting: 'Hello Ada!'] + return dispatchResult + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + process greet { + input: + name: String + + output: + greeting: String + + exec: + greeting = "Hello ${name}!" + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + // the workflow emits the runner's final answer (the dispatch result) + new JsonSlurper().parseText(result.val) == [greeting: 'Hello Ada!'] + and: + captured != null + new JsonSlurper().parseText(dispatchResult) == [greeting: 'Hello Ada!'] + } + + def 'should return error for an unknown tool name'() { + given: + String dispatchError = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + assert req.toolSpecs.size() == 1 + assert req.toolSpecs[0].name == 'greet' + // call with an unknown tool name + final result = req.dispatch.call('nope', '{}') + dispatchError = result + assert new JsonSlurper().parseText(result).error != null + assert new JsonSlurper().parseText(result).error.contains('nope') + return 'done' + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + process greet { + input: + name: String + + output: + greeting: String + + exec: + greeting = "Hello ${name}!" + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + result.val == 'done' + and: + dispatchError != null + new JsonSlurper().parseText(dispatchError).error.contains('nope') + } + + @Timeout(120) + def 'should expose an included classic module (meta.yml-driven) as its own tool with an ENFORCED flattened schema, run it, and whitelist its output dir'() { + given: + final dir = Files.createTempDirectory('test') + final work = dir.resolve('work'); Files.createDirectories(work) + final moduleDir = dir.resolve('module'); Files.createDirectories(moduleDir) + final reads = dir.resolve('reads.txt'); reads.text = 'hello' + final readsAbs = reads.toAbsolutePath().toString() + + // a CLASSIC untyped module mimicking the nf-core tuple shape; the input file param is + // named `fastq` (the convention the model must NOT rename/omit). The script `cat`s the + // staged input into out.dat (a NON text-like extension, so the output stays an opaque + // absolute path handle — exercising the output-dir whitelist relocation). + moduleDir.resolve('main.nf').text = '''\ + process FOO { + input: + tuple val(meta), path(fastq) + + output: + tuple val(meta), path('out.dat'), emit: report + + script: + """ + cat ${fastq} > out.dat + """ + } + '''.stripIndent() + + // sibling meta.yml describing the tuple I/O (nf-core style); `fastq` is a required file + moduleDir.resolve('meta.yml').text = '''\ + name: FOO + description: Classic nf-core style module + input: + - - name: meta + type: map + description: sample meta + - name: fastq + type: file + description: input reads + output: + - - name: meta + type: map + description: sample meta + - name: outfile + type: file + description: output file + '''.stripIndent() + + final main = dir.resolve('main.nf') + main.text = """\ + include { FOO } from './module/main.nf' + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run', 'fs:*' + + input: + request: String + + output: + answer: String + + prompt: + \"\"\" + \${request} + \"\"\" + } + + workflow { + assistant(channel.of('hi')).view { it } + } + """.stripIndent() + + and: + AgentRunnerRequest captured = null + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // §5 partition: `toolSpecs` is the BROKERED half only -- the per-module tool FOO, + // never an aggregate 'module_run'. The six `fs:*` leaves are runner-native and travel + // beside it as bare names, never as descriptors (and never as one 'filesystem' tool). + assert req.toolSpecs.size() == 1 + assert req.toolSpecs*.name == ['FOO'] + assert req.nativeToolNames == ['read','write','edit','ls','grep','find'] + final fooSpec = req.toolSpecs.find { it.name == 'FOO' } + + // the tool's parameters schema IS the module's flattened input schema, ENFORCED: + // `fastq` is a property, `required` includes the file input, and + // additionalProperties is false; `meta` is a nested object with the meta.id convention + final schema = fooSpec.inputSchema + final props = schema.properties as Map + assert props.containsKey('fastq') + assert props.containsKey('meta') + // file input flattened to a string property + assert (props.fastq as Map).type == 'string' + // the file input is required (the enforcement the LLM cannot omit) + assert (schema.required as List).contains('fastq') + // OpenAI function-calling forbids extra/renamed fields + assert schema.additionalProperties == false + // nf-core meta.id convention: meta is a nested object carrying an `id` + final meta = props.meta as Map + assert meta.type == 'object' + assert (meta.properties as Map).containsKey('id') + + // (c) dispatch by the module's OWN tool name runs the REAL process and returns output + dispatchResult = req.dispatch.call('FOO', JsonOutput.toJson([meta: [id: 's1'], fastq: readsAbs])) + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + assert parsed.containsKey('report') + final report = parsed.report as Map + assert report.meta == [id: 's1'] + final outfile = report.outfile as String + assert outfile.startsWith('/') + + // (d) after the run, the dispatch context's readable paths include the module output + // FILE itself -- so a subsequent filesystem read of it is allowed, while its + // siblings stay outside the sandbox + final ctx = getDispatchContext() + assert ctx != null + final outPath = Path.of(outfile) + assert ctx.readablePaths.contains(outPath) + assert !ctx.readablePaths.contains(outPath.getParent()) + + return dispatchResult + } as AgentRunner + + when: + final runner = new ScriptRunner([process: [executor: 'local'], workDir: work.toString()]) + runner.setScript(new ScriptFile(main)) + runner.execute() + + then: + noExceptionThrown() + and: + captured != null + dispatchResult != null + } + + private static DispatchContext getDispatchContext() { + // read the per-thread dispatch context the bridge sets before runner.run; the testRunner + // closure executes on the SAME thread, so the context is visible there + final f = ModuleToolBridge.getDeclaredField('CONTEXT') + f.setAccessible(true) + final ThreadLocal tl = (ThreadLocal) f.get(null) + return tl.get() + } + + def 'registry fetch failure falls back to meta.yml spec, still a per-module tool with enforced schema'() { + given: 'a temp dir with a registry-installed module layout (has .module-info marker) + meta.yml' + final dir = Files.createTempDirectory('test') + final work = dir.resolve('work'); Files.createDirectories(work) + + // pre-install a module in the registry layout so recoverModuleRef succeeds + final moduleDir = dir.resolve('modules').resolve('offline-test').resolve('mymod') + Files.createDirectories(moduleDir) + // write the .module-info marker so recoverModuleRef recognises it as a registry install + moduleDir.resolve('.module-info').text = 'checksum=dummy' + + // classic untyped module with meta.yml (the fallback schema source) + moduleDir.resolve('main.nf').text = '''\ + process MYMOD { + input: + tuple val(meta), path(fastq) + + output: + tuple val(meta), path('out.txt') + + script: + """ + touch out.txt + """ + } + '''.stripIndent() + + moduleDir.resolve('meta.yml').text = '''\ + name: MYMOD + description: Offline test module + input: + - - name: meta + type: map + description: sample meta + - name: fastq + type: file + description: input reads + output: + - - name: meta + type: map + description: sample meta + - name: outfile + type: file + description: output file + '''.stripIndent() + + final main = dir.resolve('main.nf') + main.text = """\ + include { MYMOD } from './modules/offline-test/mymod/main.nf' + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run' + + input: + request: String + + output: + answer: String + + prompt: + \"\"\" + \${request} + \"\"\" + } + + workflow { + assistant(channel.of('hi')).view { it } + } + """.stripIndent() + + and: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // per-module tool NAMED after the module even though registry fetch fails + assert req.toolSpecs.size() == 1 + assert req.toolSpecs[0].name == 'MYMOD' + assert req.toolSpecs*.name.every { it != 'module_run' } + // the meta.yml fallback still yields an ENFORCED flattened schema + final schema = req.toolSpecs[0].inputSchema + assert (schema.properties as Map).containsKey('fastq') + assert (schema.properties as Map).containsKey('meta') + assert (schema.required as List).contains('fastq') + assert schema.additionalProperties == false + return 'done' + } as AgentRunner + + when: 'the registry URL is unreachable so fetchModuleMetadata throws; fallback to meta.yml must happen' + // configure an unreachable registry URL to force the fetch to throw an exception + final runner = new ScriptRunner([ + process: [executor: 'local'], + workDir: work.toString(), + registry: [url: 'http://localhost:0'] // guaranteed to fail immediately + ]) + runner.setScript(new ScriptFile(main)) + runner.execute() + + then: 'bridge is built using meta.yml spec without throwing' + noExceptionThrown() + and: + captured != null + } + + def 'nf:module_run and fs:* together produce a per-module tool plus the six fs leaves'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + // the brokered per-module tool (greet) in `toolSpecs`, the six `fs:*` leaves beside + // it in `nativeToolNames` -- the two halves of the §5 partition, never mixed + assert req.toolSpecs.size() == 1 + assert req.toolSpecs*.name == ['greet'] + assert req.toolSpecs*.name.every { it != 'module_run' } + assert req.nativeToolNames == ['read','write','edit','ls','grep','find'] + // the partition holds structurally, not just by inspection + req.checkToolPartition() + assert req.brokeredToolNames() == (['greet'] as Set) + // invoke the per-module tool to verify dispatch still works + final result = req.dispatch.call('greet', '{"name":"World"}') + assert new JsonSlurper().parseText(result) == [greeting: 'Hello World!'] + // an in-JVM runner still serves the fs: leaves itself, through the same dispatcher + final listed = new JsonSlurper().parseText(req.dispatch.call('ls', '{}')) as Map + assert listed.entries != null + return result + } as AgentRunner + + when: + runScript(''' + nextflow.enable.types = true + + process greet { + input: + name: String + + output: + greeting: String + + exec: + greeting = "Hello ${name}!" + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run', 'fs:*' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + noExceptionThrown() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleSkillsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleSkillsTest.groovy new file mode 100644 index 0000000000..005ca106f5 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleSkillsTest.groovy @@ -0,0 +1,286 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * The KEY requirement of the agent-module work: a module agent's {@code skills} resolve from the + * dir of the module that DECLARES the agent ({@code /skills//SKILL.md}), never + * from the including script's directory -- and that holds under an ALIAS too, because + * {@code AgentDef.cloneWithName} is a shallow clone that preserves {@code owner}, and + * {@code AgentDef.ownerBaseDir} prefers {@code ScriptMeta.getModuleDir()} over + * {@code session.baseDir}. + * + *

Oracle note: skills do NOT travel in the prompt. Core renders the prompt from the + * {@code PromptDef} alone; skills reach the runner only as {@code AgentRunnerRequest.skills} + * (system-message injection happens in the nf-agent plugin), so the assertions here read + * {@code captured.skills} from a capturing stub runner -- no model call. + * + * @author Paolo Di Tommaso + */ +@Timeout(120) +class AgentModuleSkillsTest extends Dsl2Spec { + + private static final String MODULE_MARKER = 'MODULE-SKILL-BODY' + private static final String STYLE_MARKER = 'MODULE-STYLE-BODY' + private static final String DECOY_MARKER = 'DECOY-SKILL-BODY' + + private AgentRunnerRequest captured + + def setup() { + captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + return 'ok' + } as AgentRunner + } + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + private static Path write(Path path, String text) { + Files.createDirectories(path.parent) + path.text = text + return path + } + + /** A `

/SKILL.md` with YAML front matter, the shape SkillResolver.parseSkillDir expects. */ + private static void skill(Path skillDir, String name, String body) { + write(skillDir.resolve('SKILL.md'), """\ + --- + name: ${name} + description: The ${name} skill + --- + + ${body} + """.stripIndent()) + } + + private static String agentModule(String name, String skillsDirective) { + return """\ + agent ${name} { + model 'openai/gpt-4o' + instruction 'You write QA reports.' + ${skillsDirective} + + input: + sample: String + + output: + report: String + + prompt: + \"\"\" + Report on \${sample}. + \"\"\" + } + """.stripIndent() + } + + private static String entryScript(String includeStmt, String call) { + return """\ + ${includeStmt} + + workflow { + ${call}(channel.of('s1')).view() + } + """.stripIndent() + } + + def 'should resolve a module agent skill from the module directory'() { + given: + final root = Files.createTempDirectory('test') + final mod = root.resolve('mods/reporter') + write(mod.resolve('main.nf'), agentModule('reporter', "skills 'qa-report'")) + skill(mod.resolve('skills/qa-report'), 'qa-report', MODULE_MARKER) + final main = write(root.resolve('main.nf'), + entryScript("include { reporter } from './mods/reporter'", 'reporter')) + + when: + runScript(main) + + then: + captured != null + captured.skills*.name == ['qa-report'] + captured.skills[0].content.contains(MODULE_MARKER) + and: 'the skill is NOT smuggled through the prompt' + !captured.prompt.contains(MODULE_MARKER) + } + + // -- the alias-safety case: cloneWithName preserves `owner`, so the skills root does not move + def 'should resolve module skills from the module dir even when the agent is aliased'() { + given: + final root = Files.createTempDirectory('test') + final mod = root.resolve('mods/reporter') + write(mod.resolve('main.nf'), agentModule('reporter', "skills 'qa-report'")) + skill(mod.resolve('skills/qa-report'), 'qa-report', MODULE_MARKER) + final main = write(root.resolve('main.nf'), + entryScript("include { reporter as qc } from './mods/reporter'", 'qc')) + + when: + runScript(main) + + then: + captured.agentName == 'qc' + captured.skills*.name == ['qa-report'] + captured.skills[0].content.contains(MODULE_MARKER) + } + + // -- "one or more skills", way 1: several entries under the same module skills root + def 'should resolve two skills declared as two entries in the module directory'() { + given: + final root = Files.createTempDirectory('test') + final mod = root.resolve('mods/reporter') + write(mod.resolve('main.nf'), agentModule('reporter', "skills 'qa-report', 'style'")) + skill(mod.resolve('skills/qa-report'), 'qa-report', MODULE_MARKER) + skill(mod.resolve('skills/style'), 'style', STYLE_MARKER) + final main = write(root.resolve('main.nf'), + entryScript("include { reporter as qc } from './mods/reporter'", 'qc')) + + when: + runScript(main) + + then: + captured.skills*.name == ['qa-report', 'style'] + captured.skills.find { it.name == 'qa-report' }.content.contains(MODULE_MARKER) + captured.skills.find { it.name == 'style' }.content.contains(STYLE_MARKER) + } + + // -- "one or more skills", way 2: ONE entry whose dir has no SKILL.md expands to every + // immediate subdirectory that has one (SkillResolver.scanSkillRoot) + def 'should expand a single skills entry whose directory holds several skills'() { + given: + final root = Files.createTempDirectory('test') + final mod = root.resolve('mods/reporter') + write(mod.resolve('main.nf'), agentModule('reporter', "skills 'bundle'")) + skill(mod.resolve('skills/bundle/qa-report'), 'qa-report', MODULE_MARKER) + skill(mod.resolve('skills/bundle/style'), 'style', STYLE_MARKER) + final main = write(root.resolve('main.nf'), + entryScript("include { reporter } from './mods/reporter'", 'reporter')) + + when: + runScript(main) + + then: + (captured.skills*.name as Set) == ['qa-report', 'style'] as Set + captured.skills*.content.join('').contains(MODULE_MARKER) + captured.skills*.content.join('').contains(STYLE_MARKER) + } + + // -- THE negative case: a same-named `skills/` beside the ENTRY script must be IGNORED. + // There is no fallback chain: the lookup is a single lexical hit under the module dir. + def 'should ignore a same-named skill beside the including script'() { + given: + final root = Files.createTempDirectory('test') + final mod = root.resolve('mods/reporter') + write(mod.resolve('main.nf'), agentModule('reporter', "skills 'qa-report'")) + skill(mod.resolve('skills/qa-report'), 'qa-report', MODULE_MARKER) + and: 'a decoy skill of the SAME name next to the entry script' + skill(root.resolve('skills/qa-report'), 'qa-report', DECOY_MARKER) + final main = write(root.resolve('main.nf'), + entryScript("include { reporter as qc } from './mods/reporter'", 'qc')) + + when: + runScript(main) + + then: + captured.skills*.name == ['qa-report'] + captured.skills[0].content.contains(MODULE_MARKER) + !captured.skills*.content.join('').contains(DECOY_MARKER) + } + + // -- two DIFFERENT modules may each ship a skill of the same name: no conflict, and each + // agent's request carries its OWN module's content (the duplicate-name rejection is + // within one agent only) + def 'should give each module agent its own copy of a same-named skill'() { + given: + final root = Files.createTempDirectory('test') + final modA = root.resolve('mods/alpha') + write(modA.resolve('main.nf'), agentModule('alpha', "skills 'qa-report'")) + skill(modA.resolve('skills/qa-report'), 'qa-report', 'ALPHA-BODY') + final modB = root.resolve('mods/beta') + write(modB.resolve('main.nf'), agentModule('beta', "skills 'qa-report'")) + skill(modB.resolve('skills/qa-report'), 'qa-report', 'BETA-BODY') + final main = write(root.resolve('main.nf'), '''\ + include { alpha } from './mods/alpha' + include { beta } from './mods/beta' + + workflow { + alpha(channel.of('s1')).view() + beta(channel.of('s2')).view() + } + '''.stripIndent()) + + and: 'capture every request by agent name' + final Map byName = Collections.synchronizedMap([:]) + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + byName.put(req.agentName, req) + return 'ok' + } as AgentRunner + + when: + runScript(main) + + then: + byName.keySet() == ['alpha', 'beta'] as Set + byName.alpha.skills*.name == ['qa-report'] + byName.alpha.skills[0].content.contains('ALPHA-BODY') + !byName.alpha.skills[0].content.contains('BETA-BODY') + byName.beta.skills[0].content.contains('BETA-BODY') + !byName.beta.skills[0].content.contains('ALPHA-BODY') + } + + // -- the failure names the resolved MODULE dir, so the user can see where it looked + def 'should fail naming the module directory when a declared skill is missing'() { + given: + final root = Files.createTempDirectory('test') + final mod = root.resolve('mods/reporter') + write(mod.resolve('main.nf'), agentModule('reporter', "skills 'nope'")) + and: 'a skill of that name exists ONLY beside the entry script' + skill(root.resolve('skills/nope'), 'nope', DECOY_MARKER) + final main = write(root.resolve('main.nf'), + entryScript("include { reporter } from './mods/reporter'", 'reporter')) + + when: + runScript(main) + + then: + final e = thrown(Exception) + final msg = messages(e) + msg.contains('Agent skill `nope` not found') + and: 'the reported directory is the MODULE skills dir, not the launch dir' + msg.contains('mods/reporter/skills/nope') + !msg.contains("${root.fileName}/skills/nope".toString()) + } + + private static String messages(Throwable e) { + final sb = new StringBuilder() + Throwable t = e + while( t != null ) { + sb.append(t.message ?: '').append('\n') + t = t.cause === t ? null : t.cause + } + return sb.toString() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleSpecToolTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleSpecToolTest.groovy new file mode 100644 index 0000000000..aee762d008 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentModuleSpecToolTest.groovy @@ -0,0 +1,480 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import nextflow.script.ScriptFile +import nextflow.script.ScriptRunner +import spock.lang.Timeout +import test.Dsl2Spec + +/** + * End-to-end test of a spec-driven module tool (Phase 3.2) using a CLASSIC DSL2 + * tuple module + a sibling {@code meta.yml}, faithful to the nf-core shape. + * + * The agent {@code include}s the module and declares {@code tools 'nf:module_run:echo_tool'}; + * the bridge finds the sibling + * {@code meta.yml}, derives a FLATTENED tool input schema ({@code meta}→object, + * {@code reads}→string), and at dispatch time reassembles the flattened JSON args + * into the {@code [meta, path]} tuple the process expects. The module runs through + * the REAL local executor (it {@code cat}s the staged input into {@code out.dat}), + * proving real staging + execution; the tuple/file output is serialized back to JSON + * with the output file as an ABSOLUTE PATH STRING (the opaque-path contract). + * + * The {@code @Timeout} fails if the tool input queues are not poisoned on completion. + */ +@Timeout(90) +class AgentModuleSpecToolTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + def 'should run a classic DSL2 tuple module (meta.yml-driven) as an agent tool and terminate'() { + given: + final dir = Files.createTempDirectory('test') + final work = dir.resolve('work'); Files.createDirectories(work) + final reads = dir.resolve('reads.txt'); reads.text = 'hello' + final readsAbs = reads.toAbsolutePath().toString() + + // a CLASSIC DSL2 module mimicking the nf-core tuple shape (NOT typed records); + // the script body uses `cat`, which runs via the LocalExecutor shell (no container). + // The output is a `.dat` (NOT a text-like extension) so it stays an opaque path handle. + dir.resolve('mod.nf').text = ''' + process echo_tool { + input: + tuple val(meta), path(reads) + + output: + tuple val(meta), path("out.dat"), emit: report + + script: + """ + cat ${reads} > out.dat + """ + } + '''.stripIndent() + + // sibling meta.yml describing the tuple I/O + dir.resolve('meta.yml').text = '''\ + name: echo_tool + description: Copy the input reads to an output file + input: + - - name: meta + type: map + description: sample meta + - name: reads + type: file + description: input reads + output: + - - name: meta + type: map + description: sample meta + - name: outfile + type: file + description: the output + '''.stripIndent() + + final main = dir.resolve('main.nf') + main.text = ''' + include { echo_tool } from './mod.nf' + + agent a { + model 'm' + instruction 'i' + tools 'nf:module_run:echo_tool' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + a(channel.of('go')).view { it } + } + '''.stripIndent() + + and: + AgentRunnerRequest captured = null + String dispatchResult = null + Map outAssert = [:] + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // the bridge exposes the spec-driven `echo_tool` with a FLATTENED schema + assert req.toolSpecs.size() == 1 + assert req.toolSpecs[0].name == 'echo_tool' + assert req.toolSpecs[0].inputSchema.properties.meta.type == 'object' + assert req.toolSpecs[0].inputSchema.properties.reads.type == 'string' + // invoke the tool: this drives the REAL echo_tool process through the executor, + // staging `reads.txt` and producing `out.dat` + dispatchResult = req.dispatch.call('echo_tool', JsonOutput.toJson([meta: [id: 's1'], reads: readsAbs])) + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + // the result is keyed by the emit name `report`; its record carries `outfile` + // as an ABSOLUTE PATH STRING and `meta` as the round-tripped object + assert parsed.containsKey('report') + final report = parsed.report as Map + assert report.meta == [id: 's1'] + final outfile = report.outfile as String + assert outfile.startsWith('/') + final outPath = Path.of(outfile) + assert Files.exists(outPath) + assert outPath.text == 'hello' + outAssert = [outfile: outfile, content: outPath.text] + // the agent's final answer + return dispatchResult + } as AgentRunner + + when: + final runner = new ScriptRunner([process: [executor: 'local'], workDir: work.toString()]) + runner.setScript(new ScriptFile(main)) + runner.execute() + + then: + // the dispatch went through the bridge and returned the real process output + captured != null + dispatchResult != null + and: + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + parsed.containsKey('report') + (parsed.report as Map).meta == [id: 's1'] + ((parsed.report as Map).outfile as String).startsWith('/') + and: + // the file the real process produced exists and has the staged content + outAssert.content == 'hello' + Files.exists(Path.of(outAssert.outfile as String)) + } + + def 'should inline a small structured json tool output to the LLM instead of a path handle'() { + given: + final dir = Files.createTempDirectory('test') + final work = dir.resolve('work'); Files.createDirectories(work) + final reads = dir.resolve('reads.txt'); reads.text = 'hello' + final readsAbs = reads.toAbsolutePath().toString() + + // a CLASSIC DSL2 module producing a SMALL .json stats output (the assemblyscan + // shape): the script body writes a tiny JSON that the LLM must reason over + dir.resolve('mod.nf').text = ''' + process echo_tool { + input: + tuple val(meta), path(reads) + + output: + tuple val(meta), path("stats.json"), emit: report + + script: + """ + echo '{"n50":54321,"contigs":12}' > stats.json + """ + } + '''.stripIndent() + + // sibling meta.yml describing the tuple I/O; the file output is a .json + dir.resolve('meta.yml').text = '''\ + name: echo_tool + description: Compute assembly statistics + input: + - - name: meta + type: map + description: sample meta + - name: reads + type: file + description: input reads + output: + - - name: meta + type: map + description: sample meta + - name: outfile + type: file + description: the stats json + pattern: "*.json" + '''.stripIndent() + + final main = dir.resolve('main.nf') + main.text = ''' + include { echo_tool } from './mod.nf' + + agent a { + model 'm' + instruction 'i' + tools 'nf:module_run:echo_tool' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + a(channel.of('go')).view { it } + } + '''.stripIndent() + + and: + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + dispatchResult = req.dispatch.call('echo_tool', JsonOutput.toJson([meta: [id: 's1'], reads: readsAbs])) + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + // the result is keyed by the emit name `report`; its `outfile` carries the + // FILE CONTENTS as a String (inlined) -- NOT an absolute path handle + assert parsed.containsKey('report') + final report = parsed.report as Map + assert report.meta == [id: 's1'] + final outfile = report.outfile as String + assert !outfile.startsWith('/') + assert outfile.contains('"n50"') + assert outfile.contains('54321') + return dispatchResult + } as AgentRunner + + when: + final runner = new ScriptRunner([process: [executor: 'local'], workDir: work.toString()]) + runner.setScript(new ScriptFile(main)) + runner.execute() + + then: + dispatchResult != null + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + parsed.containsKey('report') + (parsed.report as Map).meta == [id: 's1'] + and: + // the file contents were inlined: the value is the JSON content, not a /abs/path + final outfile = (parsed.report as Map).outfile as String + !outfile.startsWith('/') + outfile.contains('"n50"') + outfile.contains('54321') + } + + def 'should read ALL outputs of a multi-output tool (not block on the 2nd)'() { + given: + final dir = Files.createTempDirectory('test') + final work = dir.resolve('work'); Files.createDirectories(work) + final reads = dir.resolve('reads.txt'); reads.text = 'hello' + final readsAbs = reads.toAbsolutePath().toString() + + // a module with TWO data outputs (the prokka shape). Regression guard for the read-channel + // timing bug: the bridge used to obtain each output read channel lazily at dispatch time, + // so on broadcast-style outputs only the FIRST (subscribed while the task still ran) was + // received and the SECOND blocked forever. Single-output modules never exposed it. + dir.resolve('mod.nf').text = ''' + process two_out { + input: + tuple val(meta), path(reads) + + output: + tuple val(meta), path("a.json"), emit: first + tuple val(meta), path("b.json"), emit: second + + script: + """ + echo '{"a":1}' > a.json + echo '{"b":2}' > b.json + """ + } + '''.stripIndent() + + dir.resolve('meta.yml').text = '''\ + name: two_out + description: Emit two small JSON outputs + input: + - - name: meta + type: map + - name: reads + type: file + output: + - - name: meta + type: map + - name: first + type: file + pattern: "*.json" + - - name: meta + type: map + - name: second + type: file + pattern: "*.json" + '''.stripIndent() + + dir.resolve('main.nf').text = ''' + include { two_out } from './mod.nf' + + agent a { + model 'm' + instruction 'i' + tools 'nf:module_run:two_out' + + input: + request: String + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + a(channel.of('go')).view { it } + } + '''.stripIndent() + + and: + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + dispatchResult = req.dispatch.call('two_out', JsonOutput.toJson([meta: [id: 's1'], reads: readsAbs])) + return dispatchResult + } as AgentRunner + + when: + final runner = new ScriptRunner([process: [executor: 'local'], workDir: work.toString()]) + runner.setScript(new ScriptFile(dir.resolve('main.nf'))) + runner.execute() + + then: + // BOTH outputs were read (the 2nd did NOT block -- the run terminated within @Timeout) + dispatchResult != null + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + parsed.containsKey('first') + parsed.containsKey('second') + and: + // both small .json are inlined as contents + ((parsed.first as Map).first as String).contains('"a"') + ((parsed.second as Map).second as String).contains('"b"') + } + + def 'should skip an nf-core versions (eval/topic) output and not block the dispatch'() { + given: + final dir = Files.createTempDirectory('test') + final work = dir.resolve('work'); Files.createDirectories(work) + final reads = dir.resolve('reads.txt'); reads.text = 'hello' + final readsAbs = reads.toAbsolutePath().toString() + + // a module with a DATA output (`report`) AND an nf-core `versions` output: + // a tuple carrying an `eval` component routed to a `topic` -- exactly the + // skesa shape that previously blocked the dispatcher forever on `.val`. + // The data output is a `.dat` (NOT text-like) so it stays an opaque path handle. + dir.resolve('mod.nf').text = ''' + process echo_tool { + input: + tuple val(meta), path(reads) + + output: + tuple val(meta), path("out.dat"), emit: report + tuple val("${task.process}"), val('echo'), eval('echo 1.0'), topic: versions, emit: versions_echo + + script: + """ + cat ${reads} > out.dat + """ + } + '''.stripIndent() + + // sibling meta.yml: a `report` data output + a versions output whose components + // include an `eval` (plus the matching `versions` topic) + dir.resolve('meta.yml').text = '''\ + name: echo_tool + description: Copy reads to an output file + input: + - - name: meta + type: map + - name: reads + type: file + output: + - - name: meta + type: map + - name: outfile + type: file + - - name: proc + type: string + - name: tool + type: string + - name: version + type: eval + topics: + - - name: proc + type: string + - name: tool + type: string + - name: version + type: eval + '''.stripIndent() + + dir.resolve('main.nf').text = ''' + include { echo_tool } from './mod.nf' + + agent a { + model 'm' + instruction 'i' + tools 'nf:module_run:echo_tool' + + input: + request: String + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + a(channel.of('go')).view { it } + } + '''.stripIndent() + + and: + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + dispatchResult = req.dispatch.call('echo_tool', JsonOutput.toJson([meta: [id: 's1'], reads: readsAbs])) + return dispatchResult + } as AgentRunner + + when: + final runner = new ScriptRunner([process: [executor: 'local'], workDir: work.toString()]) + runner.setScript(new ScriptFile(dir.resolve('main.nf'))) + runner.execute() + + then: + // the dispatch returned (it did NOT block forever on the eval/versions output's `.val`) + // and the whole run terminated within @Timeout + dispatchResult != null + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + // the data output IS collected ... + parsed.containsKey('report') + ((parsed.report as Map).outfile as String).startsWith('/') + // ... and the eval/versions output is NOT (skipped, not blocked on) + !parsed.containsKey('versions_echo') + parsed.size() == 1 + } + +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentMultiToolBridgeIntegrationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentMultiToolBridgeIntegrationTest.groovy new file mode 100644 index 0000000000..9a9213f7ea --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentMultiToolBridgeIntegrationTest.groovy @@ -0,0 +1,317 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +import groovy.json.JsonSlurper +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * End-to-end tests (WITHOUT a real LLM) of MULTIPLE tools per agent and of + * dispatch-level error handling. + * + *

The first test declares two in-scope processes — {@code shout} (uppercases) + * and {@code whisper} (lowercases) — as tools. A mock runner asserts that BOTH + * descriptors are advertised and then dispatches a call to EACH tool, proving the + * two distinct processes ran (uppercase vs lowercase) and that the bridge routes + * each call to the correct request-scoped process. + * + *

The second test exercises the dispatch-level error path: an unknown tool name + * and an unparseable {@code argsJson} are each returned to the caller as a + * {@code {"error": ...}} JSON string (so the LLM could recover) without the agent + * loop crashing, and a subsequent valid call still runs the real process. + * + *

The {@code @Timeout} guards against the request gateway not being poisoned + * on completion (which would hang {@code session.await()}). + */ +@Timeout(60) +class AgentMultiToolBridgeIntegrationTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + def 'should run multiple in-scope processes as agent tools and terminate'() { + given: + AgentRunnerRequest captured = null + String shoutResult = null + String whisperResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // BOTH tool descriptors must be advertised to the LLM + assert req.toolSpecs.size() == 2 + final names = req.toolSpecs*.name as Set + assert names == ['shout', 'whisper'] as Set + assert req.toolSpecs.find { it.name == 'shout' }.inputSchema.properties.text.type == 'string' + assert req.toolSpecs.find { it.name == 'whisper' }.inputSchema.properties.text.type == 'string' + // dispatch to EACH tool: this drives the two REAL processes through the executor + shoutResult = req.dispatch.call('shout', '{"text":"Hi"}') + whisperResult = req.dispatch.call('whisper', '{"text":"Hi"}') + // distinct results prove the two distinct processes ran + assert new JsonSlurper().parseText(shoutResult) == [loud: 'HI'] + assert new JsonSlurper().parseText(whisperResult) == [quiet: 'hi'] + return shoutResult + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + process shout { + input: + text: String + + output: + loud: String + + exec: + loud = text.toUpperCase() + } + + process whisper { + input: + text: String + + output: + quiet: String + + exec: + quiet = text.toLowerCase() + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run:shout', 'nf:module_run:whisper' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + // the workflow emits the runner's final answer (the shout dispatch result) + new JsonSlurper().parseText(result.val) == [loud: 'HI'] + and: + captured != null + // both distinct processes ran and produced their distinct (upper/lower) outputs + new JsonSlurper().parseText(shoutResult) == [loud: 'HI'] + new JsonSlurper().parseText(whisperResult) == [quiet: 'hi'] + } + + def 'should return dispatch-level errors as JSON tool results without crashing the agent'() { + given: + AgentRunnerRequest captured = null + String unknownToolResult = null + String badJsonResult = null + String okResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // an unknown tool name -> a {"error": ...} naming the tool, NOT a thrown exception + unknownToolResult = req.dispatch.call('nope', '{}') + // unparseable args -> a {"error": ...} mentioning a parse failure + badJsonResult = req.dispatch.call('shout', '{not json') + // the bridge is still usable afterwards (the loop did not crash) + okResult = req.dispatch.call('shout', '{"text":"Hi"}') + return okResult + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + process shout { + input: + text: String + + output: + loud: String + + exec: + loud = text.toUpperCase() + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run:shout' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + captured != null + and: + // unknown-tool error is a well-formed JSON string naming the missing tool + unknownToolResult instanceof String + def unknown = new JsonSlurper().parseText(unknownToolResult) + unknown.error != null + unknown.error.contains('nope') + and: + // parse-failure error is a well-formed JSON string naming the tool + the parse problem + badJsonResult instanceof String + def bad = new JsonSlurper().parseText(badJsonResult) + bad.error != null + bad.error.contains('shout') + bad.error.toLowerCase().contains('parse') + and: + // the agent loop did NOT crash: a subsequent valid call still runs the real process + new JsonSlurper().parseText(okResult) == [loud: 'HI'] + new JsonSlurper().parseText(result.val) == [loud: 'HI'] + } + + def 'should correlate concurrent calls to the same tool when they complete out of order'() { + given: + final completionOrder = Collections.synchronizedList([]) + String slowResult + String fastResult + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + final pool = Executors.newFixedThreadPool(2) + try { + final slow = CompletableFuture.supplyAsync({ -> + final value = req.dispatch.call('delayed', '{"text":"slow","delay":1200}') + completionOrder.add('slow') + return value + }, pool) + Thread.sleep(100) + final fast = CompletableFuture.supplyAsync({ -> + final value = req.dispatch.call('delayed', '{"text":"fast","delay":0}') + completionOrder.add('fast') + return value + }, pool) + fastResult = fast.get(1, TimeUnit.SECONDS) + slowResult = slow.get(5, TimeUnit.SECONDS) + return fastResult + } + finally { + pool.shutdownNow() + } + } as AgentRunner + + when: + def result = runScript([config: [poolSize: 1]], ''' + nextflow.enable.types = true + + process delayed { + input: + text: String + delay: Integer + output: + value: String + exec: + Thread.sleep(delay) + value = text + } + + agent assistant { + model 'm' + tools 'nf:module_run:delayed' + input: + request: String + output: + answer: String + prompt: "${request}" + } + + workflow { + assistant(channel.of('run')).view { it } + } + ''') + + then: + completionOrder == ['fast', 'slow'] + new JsonSlurper().parseText(fastResult) == [value: 'fast'] + new JsonSlurper().parseText(slowResult) == [value: 'slow'] + new JsonSlurper().parseText(result.val) == [value: 'fast'] + } + + def 'should keep a request-scoped invocation correlated across a process retry'() { + given: + String toolResult + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + toolResult = req.dispatch.call('flaky', '{"text":"recovered"}') + return toolResult + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + process flaky { + errorStrategy 'retry' + maxRetries 1 + input: + text: String + output: + value: String + exec: + if( task.attempt == 1 ) + throw new nextflow.exception.ProcessFailedException('fail first attempt') + value = text + } + + agent assistant { + model 'm' + tools 'nf:module_run:flaky' + input: + request: String + output: + answer: String + prompt: "${request}" + } + + workflow { + assistant(channel.of('run')).view { it } + } + ''') + + then: + new JsonSlurper().parseText(toolResult) == [value: 'recovered'] + new JsonSlurper().parseText(result.val) == [value: 'recovered'] + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentOutputPlanTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentOutputPlanTest.groovy new file mode 100644 index 0000000000..d5c20abc45 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentOutputPlanTest.groovy @@ -0,0 +1,174 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import nextflow.exception.ScriptRuntimeException +import spock.lang.Specification + +/** + * {@link AgentOutputPlan#decode} and the fence stripping it depends on. + * + *

Both were previously untested: {@code decode} was only ever exercised end-to-end through a + * running agent task, and {@code stripFences} not at all. Every branch below is one the MODEL can + * put the driver into by answering in a slightly different shape, so the failure they guard is a + * pipeline that dies decoding a correct answer. + * + *

{@code stripFences} is private and is deliberately tested THROUGH {@code decode} rather than + * reflectively: what matters is that a fenced answer decodes, not how the fence is removed. + * + * @author Paolo Di Tommaso + */ +class AgentOutputPlanTest extends Specification { + + /** The fence marker, spelled once so no formatter can reflow it inside a literal. */ + static private final String F = '```' + + static private String frame(String output) { + return '{"type":"complete","output":' + groovy.json.JsonOutput.toJson(output) + '}' + } + + static private AgentOutputPlan plan(AgentOutputMode mode) { + return new AgentOutputPlan(mode, null) + } + + // --- the terminal frame ------------------------------------------------------------------ + + def 'should read the answer out of the terminal complete frame'() { + expect: + plan(AgentOutputMode.TEXT).decode(frame('hello'), 'answer', String) == 'hello' + } + + def 'should read the LAST non-blank line, ignoring earlier frames and blank lines'() { + given: + final stdout = '{"type":"progress","step":1}\n\n' + frame('final') + '\n\n \n' + + expect: + plan(AgentOutputMode.TEXT).decode(stdout, 'answer', String) == 'final' + } + + def 'should reject stdout that carries no result frame'() { + when: + plan(AgentOutputMode.TEXT).decode(stdout, 'answer', String) + + then: + final e = thrown(ScriptRuntimeException) + e.message == 'Canonical agent task completed without a result frame on stdout' + + where: + stdout << [null, '', ' ', '\n\n'] + } + + def 'should reject a last frame that is not a complete result'() { + when: + plan(AgentOutputMode.TEXT).decode(stdout, 'answer', String) + + then: + final e = thrown(ScriptRuntimeException) + e.message == 'Canonical agent task returned an invalid terminal result frame' + + where: + stdout << [ + '"just a string"', // not an object + '[1,2,3]', // not an object + '{"type":"error","message":"boom"}', // wrong type + '{"type":"complete"}', // no output + '{"type":"complete","output":null}', // null output + '{"type":"progress","output":"x"}', // complete frame never arrived + ] + } + + // --- fence stripping, through decode ----------------------------------------------------- + + def 'should decode a scalar answer whatever fence the model wrapped it in'() { + expect: + plan(AgentOutputMode.SCALAR_CONTRACT).decode(frame(output), 'answer', Integer) == 42 + + where: + output << [ + '{"answer":42}', // no fence + F + 'json\n{"answer":42}\n' + F, // ```json fence + F + '\n{"answer":42}\n' + F, // bare fence + F + 'json\n{"answer":42}', // UNTERMINATED fence + ' ' + F + 'json\n{"answer":42}\n' + F + ' ', + ] + } + + def 'should leave a fence-less answer, and an unsplittable one-line fence, untouched'() { + expect: + // a ``` run with no newline after it is not a fenced block: there is nothing to strip, so + // the text is handed to the JSON parser as-is (and fails there, not silently) + plan(AgentOutputMode.TEXT).decode(frame(F + 'json {"answer":42} ' + F), 'answer', String) == + F + 'json {"answer":42} ' + F + } + + // --- per-mode interpretation ------------------------------------------------------------- + + def 'should unwrap the declared output for a scalar contract'() { + expect: + plan(AgentOutputMode.SCALAR_CONTRACT).decode(frame('{"answer":"yes"}'), 'answer', String) == 'yes' + } + + def 'should unwrap the declared output for a wrapped record'() { + expect: + plan(AgentOutputMode.WRAPPED).decode(frame('{"total":7,"other":1}'), 'total', Integer) == 7 + } + + def 'should reject a scalar contract whose object lacks the declared output'() { + when: + plan(AgentOutputMode.SCALAR_CONTRACT).decode(frame(output), 'answer', String) + + then: + final e = thrown(ScriptRuntimeException) + e.message == 'Canonical agent scalar output must be a JSON object containing the declared output' + + where: + output << ['{"different":1}', '"a bare string"', '[1,2]', '42'] + } + + def 'should reject a wrapped answer that is not a JSON object'() { + when: + plan(AgentOutputMode.WRAPPED).decode(frame('[1,2]'), 'answer', String) + + then: + final e = thrown(ScriptRuntimeException) + e.message == 'Canonical agent structured output must be a JSON object' + } + + def 'should reject a record answer that is not a JSON object'() { + when: + plan(AgentOutputMode.RECORD).decode(frame('"a bare string"'), 'answer', String) + + then: + final e = thrown(ScriptRuntimeException) + e.message == 'Canonical agent record output must be a JSON object' + } + + // --- mode predicates --------------------------------------------------------------------- + + def 'should report which modes are structured and which are wrapped'() { + expect: + plan(mode).isStructured() == structured + plan(mode).isWrapped() == wrapped + + where: + mode | structured | wrapped + AgentOutputMode.TEXT | false | false + AgentOutputMode.SCALAR_CONTRACT | false | false + AgentOutputMode.RECORD | true | false + AgentOutputMode.WRAPPED | true | true + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentPathIoTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentPathIoTest.groovy new file mode 100644 index 0000000000..620cd351b6 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentPathIoTest.groovy @@ -0,0 +1,712 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import nextflow.Session +import nextflow.executor.SimpleFileCopyStrategy +import nextflow.processor.TaskBean +import nextflow.processor.TaskProcessor +import nextflow.processor.TaskRun +import nextflow.script.ScriptBinding +import nextflow.script.ScriptLoaderFactory +import nextflow.trace.TraceObserverV2 +import nextflow.trace.event.TaskEvent +import spock.lang.Specification +import spock.lang.TempDir +import spock.lang.Timeout + +/** + * Parity of an agent's typed `path` I/O with a typed process's: a declared `Path` input is staged + * into the task work dir (and materialized there for an in-JVM agent), and a `path` output is + * collected out of it. + * + * Every test drives a REAL {@link Session} — real {@code ExecutorFactory}, real + * {@link nextflow.executor.local.AgentExecutor}, real work dirs — because the mock harness never + * instantiates the agent executor and never stages anything. The LLM is a stub runner, so no test + * here makes a model call. + * + * @author Paolo Di Tommaso + */ +@Timeout(60) +class AgentPathIoTest extends Specification { + + @TempDir + Path tempDir + + def setup() { + // `errorShown` is a static one-shot: without this, the FIRST test in the class that + // aborts consumes it and every later failure returns a bare ErrorStrategy instead of a + // TaskFault, so the session never aborts and a negative test cannot observe the failure + TaskProcessor.reset() + } + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + // ----------------------------------------------------------------------- + // inputs + // ----------------------------------------------------------------------- + + def 'should stage a typed Path input into the agent task work dir'() { + given: + final input = tempDir.resolve('contigs.fa') + input.text = '>chr1\nACGT\n' + and: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'ok' } as AgentRunner + and: + final tasks = taskProbe() + + when: + runWithObserver(tasks.probe, """ + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + contigs: Path + output: + answer: String + prompt: "Inspect \${contigs}" + } + + workflow { + qa(channel.of(file('${input}'))) + } + """) + + then: 'the declaration registered a file input, exactly as a typed process does' + final task = tasks.completed[0] + task.inputFiles.size() == 1 + task.inputFiles[0].stageName == 'contigs.fa' + Files.isSameFile(task.inputFiles[0].sourcePath, input) + task.getInputFilesMap().keySet() == ['contigs.fa'] as Set + + and: 'an in-JVM agent has no wrapper script, so the handler materialized it into the work dir' + final staged = task.workDir.resolve('contigs.fa') + Files.exists(staged) + Files.isSymbolicLink(staged) + Files.isSameFile(staged, input) + + and: 'the model was given the work-dir-relative name, NOT a driver-side absolute path' + captured.inputJson == '"contigs.fa"' + and: 'and the prompt interpolation agrees with it -- one input, one rendering' + captured.prompt == 'Inspect contigs.fa' + } + + def 'should let an in-JVM agent OPEN its staged input through the fs: tools'() { + given: 'the staging is a symlink, and SandboxGuard resolves symlinks before testing containment' + // a text-like suffix so `read` inlines the content rather than returning an opaque handle + final input = tempDir.resolve('contigs.txt') + input.text = '>chr1\nACGT\n' + and: + String readResult = null + String listResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + readResult = req.dispatch.call('read', '{"path":"contigs.txt"}') + listResult = req.dispatch.call('ls', '{"path":"."}') + 'ok' + } as AgentRunner + + when: + runWithObserver(taskProbe().probe, """ + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + tools 'fs:read', 'fs:ls' + input: + contigs: Path + output: + answer: String + prompt: "Inspect \${contigs}" + } + + workflow { + qa(channel.of(file('${input}'))) + } + """) + + then: 'the name the agent was handed is a name it can actually open' + !readResult.contains('outside sandbox') + readResult.contains('ACGT') + and: 'and one it can SEE -- not an opaque `link` entry with no size' + listResult.contains('"name":"contigs.txt"') + listResult.contains('"type":"file"') + } + + def 'should stage an agent Path input under the same name a process stages it'() { + given: + final input = tempDir.resolve('reads_1.fq') + input.text = 'x' + and: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'ok' } as AgentRunner + and: + final tasks = taskProbe() + + when: 'an agent and a process declare the very same input' + runWithObserver(tasks.probe, """ + nextflow.enable.types = true + + process CHECK { + input: + contigs: Path + output: + answer: String = 'x' + script: + 'true' + } + + agent qa { + model 'openai/gpt-4o' + input: + contigs: Path + output: + answer: String + prompt: "go" + } + + workflow { + CHECK(channel.of(file('${input}'))) + qa(channel.of(file('${input}'))) + } + """) + + then: 'both stage it, and both stage it under the same name' + final names = tasks.completed.collectEntries { [(it.processor.name): it.getStagedInputs()] } + names['CHECK'] == ['reads_1.fq'] + names['qa'] == ['reads_1.fq'] + } + + def 'should produce the same staged-input shape as a process with the same declaration'() { + given: 'one input of every shape the stager inference recognises, plus one it must ignore' + final contigs = tempDir.resolve('contigs.fa'); contigs.text = 'c' + final r1 = tempDir.resolve('r1.fq'); r1.text = '1' + final r2 = tempDir.resolve('r2.fq'); r2.text = '2' + final e1 = tempDir.resolve('e1.txt'); e1.text = 'a' + final e2 = tempDir.resolve('e2.txt'); e2.text = 'b' + and: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'ok' } as AgentRunner + and: + final tasks = taskProbe() + // FileHolder carries the whole staging decision -- source object, store path and stage + // name -- and is @EqualsAndHashCode, so comparing the sorted holders compares the SHAPE, + // not merely the names. Sorted because declaration order is not part of the contract. + final shape = { TaskRun t -> t.inputFiles.toSorted { it.stageName } } + + when: 'an agent and a process declare the very same typed inputs' + runWithObserver(tasks.probe, """ + nextflow.enable.types = true + + record Pair { + id: String + r1: Path + r2: Path + } + + process CHECK { + input: + contigs: Path + pair: Pair + extras: List + tag: String + output: + answer: String = 'x' + script: + 'true' + } + + agent qa { + model 'openai/gpt-4o' + input: + contigs: Path + pair: Pair + extras: List + tag: String + output: + answer: String + prompt: "go" + } + + workflow { + CHECK( + channel.value(file('${contigs}')), + channel.value(record(id: 's1', r1: file('${r1}'), r2: file('${r2}'))), + channel.value([file('${e1}'), file('${e2}')]), + channel.value('t') ) + qa( + channel.value(file('${contigs}')), + channel.value(record(id: 's1', r1: file('${r1}'), r2: file('${r2}'))), + channel.value([file('${e1}'), file('${e2}')]), + channel.value('t') ) + } + """) + + then: 'the agent stages exactly what the process stages -- same files, same stage names' + final byName = tasks.completed.collectEntries { [(it.processor.name): it] } + shape(byName['qa']) == shape(byName['CHECK']) + + and: 'and the equality is not vacuous: the scalar, both record fields and both collection' + // elements are staged, while the non-Path input contributes nothing + shape(byName['CHECK'])*.stageName == ['contigs.fa', 'e1.txt', 'e2.txt', 'r1.fq', 'r2.fq'] + } + + def 'should stage every Path field of a record input'() { + given: + final fa = tempDir.resolve('sample.fa') + fa.text = 'a' + final idx = tempDir.resolve('sample.idx') + idx.text = 'b' + and: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'ok' } as AgentRunner + and: + final tasks = taskProbe() + + when: + runWithObserver(tasks.probe, """ + nextflow.enable.types = true + + record Sample { + id: String + seq: Path + index: Path + } + + agent qa { + model 'openai/gpt-4o' + input: + sample: Sample + output: + answer: String + prompt: "go" + } + + workflow { + qa(channel.of(record(id: 's1', seq: file('${fa}'), index: file('${idx}')))) + } + """) + + then: 'the record recursion registered one stager per Path field' + final task = tasks.completed[0] + task.getStagedInputs().toSet() == ['sample.fa', 'sample.idx'] as Set + + and: 'the model sees the staged names inside the record, not driver-side paths' + captured.inputJson == '{"id":"s1","seq":"sample.fa","index":"sample.idx"}' + } + + def 'should admit a null value for an optional Path input and stage nothing'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'ok' } as AgentRunner + and: + final tasks = taskProbe() + + when: + runWithObserver(tasks.probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + contigs: Path? + output: + answer: String + prompt: "go" + } + + workflow { + qa(channel.of(null)) + } + ''') + + then: 'the task is constructed, and the absent input stages nothing' + tasks.completed.size() == 1 + tasks.completed[0].inputFiles.isEmpty() + captured.inputJson == 'null' + } + + def 'should reject a null value for a non-optional Path input'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'ok' } as AgentRunner + + when: + runWithObserver(taskProbe().probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + contigs: Path + output: + answer: String + prompt: "go" + } + + workflow { + qa(channel.of(null)) + } + ''') + + then: + def e = thrown(Exception) + allMessages(e).contains('cannot be null') + } + + // ----------------------------------------------------------------------- + // outputs + // ----------------------------------------------------------------------- + + def 'should collect a path output from the agent work dir'() { + given: 'a runner that writes the file the prompt asked for' + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + Path.of(req.workDir).resolve('report.md').text = '# done\n' + 'the model chatter that must be discarded' + } as AgentRunner + and: + final tasks = taskProbe() + + when: + final session = runWithObserver(tasks.probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + q: String + output: + report: Path = file('report.md') + prompt: "write the report to report.md" + } + + workflow { + qa(channel.of('hello')) + } + ''') + + then: 'the unstager was registered, so the file is a real task output' + final task = tasks.completed[0] + task.outputFiles.size() == 1 + task.outputFiles[0] == task.workDir.resolve('report.md') + task.outputFiles[0].text == '# done\n' + + and: 'an agent whose ONLY output is a collected file is legal: the model is given no' + // output contract at all, and its final text is explicitly discarded + captured.outputSchema == null + session.error == null + } + + def 'should keep a work-dir output out of the schema the model is asked to fill'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + Path.of(req.workDir).resolve('report.md').text = 'body' + '{"answer":"42","score":7}' + } as AgentRunner + and: + final tasks = taskProbe() + + when: + runWithObserver(tasks.probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + q: String + output: + answer: String + score: Long + report: Path = file('report.md') + prompt: "answer, and write report.md" + } + + workflow { + qa(channel.of('hello')) + } + ''') + + then: 'only the model-answered outputs are in the contract the model is given' + // if the file output reached buildWrapperSchema it would raise "unsupported type Path", + // so this also pins that the partition happens BEFORE the schema is built + captured.outputSchema.properties.keySet() == ['answer', 'score'] as Set + captured.outputSchema.required == ['answer', 'score'] + and: 'the file output is still collected' + final task = tasks.completed[0] + task.outputFiles.toList() == [task.workDir.resolve('report.md')] + } + + def 'should surface an arity error when the agent writes a different file name'() { + given: 'a runner that writes the WRONG name' + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + Path.of(req.workDir).resolve('summary.md').text = 'oops' + 'done' + } as AgentRunner + + when: + runWithObserver(taskProbe().probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + q: String + output: + report: Path = file('report.md') + prompt: "write the report to report.md" + } + + workflow { + qa(channel.of('hello')) + } + ''') + + then: + def e = thrown(Exception) + // the work-dir collector rejects the missing file before the arity check is reached; + // either way a wrong filename fails the task loudly rather than binding nothing + allMessages(e).contains('Missing output file(s) `report.md`') + } + + def 'should collect a files() glob output from the agent work dir'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + Path.of(req.workDir).resolve('a.txt').text = 'a' + Path.of(req.workDir).resolve('b.txt').text = 'b' + 'done' + } as AgentRunner + and: + final tasks = taskProbe() + + when: + runWithObserver(tasks.probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + q: String + output: + notes: Set = files('*.txt') + prompt: "write notes as .txt files" + } + + workflow { + qa(channel.of('hello')) + } + ''') + + then: + final task = tasks.completed[0] + task.outputFiles.collect { it.name }.toSet() == ['a.txt', 'b.txt'] as Set + } + + def 'should admit a missing optional path output'() { + given: 'a runner that writes nothing at all' + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'nothing to report' } as AgentRunner + and: + final tasks = taskProbe() + + when: + runWithObserver(tasks.probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + q: String + output: + report: Path = file(optional: true, 'report.md') + prompt: "write report.md only if there is something to say" + } + + workflow { + qa(channel.of('hello')) + } + ''') + + then: 'the named option reached the collector, so the absent file is not a failure' + tasks.completed.size() == 1 + tasks.completed[0].outputFiles.isEmpty() + } + + def 'should publish a collected path output'() { + given: + final publishDir = Files.createTempDirectory(tempDir, 'published') + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + Path.of(req.workDir).resolve('report.md').text = '# done\n' + 'done' + } as AgentRunner + + when: + // D4's knock-on: a collected output is what TaskProcessor.getPublishFiles reads for a V2 + // config, so publishing an agent's file is a consequence of registering the unstager. + // `publishDir` is not an agent DIRECTIVE (AgentBuilder.DIRECTIVES), so it can only be set + // from the `agent` config scope -- which is the whole task ladder, minus the agent options. + runWithObserver(taskProbe().probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + q: String + output: + report: Path = file('report.md') + prompt: "write the report to report.md" + } + + workflow { + qa(channel.of('hello')) + } + ''', [agent: [publishDir: [path: publishDir.toString(), mode: 'copy']]]) + + then: + publishDir.resolve('report.md').text == '# done\n' + } + + def 'should refuse storeDir together with a collected path output on an in-JVM runner'() { + given: 'nothing copies the work-dir file to the store dir without a wrapper script' + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'done' } as AgentRunner + + when: + runWithObserver(taskProbe().probe, ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + q: String + output: + report: Path = file('report.md') + prompt: "write the report to report.md" + } + + workflow { + qa(channel.of('hello')) + } + ''', [agent: [storeDir: Files.createTempDirectory(tempDir, 'store').toString()]]) + + then: 'refused up front, rather than failing every run with a missing output' + def e = thrown(Exception) + allMessages(e).contains('cannot combine `storeDir` with a collected `path` output') + } + + // ----------------------------------------------------------------------- + // the canonical (wrapper-script) half + // ----------------------------------------------------------------------- + + def 'should hand the staged inputs to the stage-in script a canonical agent runs'() { + given: + final input = tempDir.resolve('contigs.fa') + input.text = 'c' + and: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'ok' } as AgentRunner + and: + final tasks = taskProbe() + + when: + runWithObserver(tasks.probe, """ + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-4o' + input: + contigs: Path + output: + answer: String + prompt: "go" + } + + workflow { + qa(channel.of(file('${input}'))) + } + """) + + then: 'the very inputs BashWrapperBuilder stages in and bind-mounts are populated' + // a canonical agent differs from this one only in its BodyDef type: `task.inputFiles` is + // filled by the task-type-agnostic V2 input resolver and consumed from the TaskBean, so + // pinning the bean and the script it generates pins the containerized path too + final task = tasks.completed[0] + final bean = new TaskBean(task) + bean.inputFiles.keySet() == ['contigs.fa'] as Set + Files.isSameFile(bean.inputFiles['contigs.fa'], input) + and: + final script = new SimpleFileCopyStrategy(bean).getStageInputFilesScript(bean.inputFiles) + script.contains("ln -sfn ${bean.inputFiles['contigs.fa']} contigs.fa".toString()) + } + + // ----------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------- + + /** A probe capturing the completed {@link TaskRun}s, plus the list it fills. */ + private static Map taskProbe() { + final completed = new ArrayList() + final probe = new TraceObserverV2() { + @Override + void onTaskComplete(TaskEvent event) { completed.add(event.handler.task) } + } + return [probe: probe, completed: completed] + } + + /** Concatenate the message of a throwable and its cause chain. */ + private static String allMessages(Throwable t) { + final sb = new StringBuilder() + while( t != null ) { + if( t.message ) sb.append(t.message).append(' | ') + t = t.cause + } + return sb.toString() + } + + /** + * Drive the script through a *real* {@link Session}, so the agent task is dispatched by the + * real {@link nextflow.executor.local.AgentExecutor} into a real per-task work dir. The mock + * harness cannot be used here: it substitutes every executor and never stages anything. + */ + private Session runWithObserver(TraceObserverV2 probe, String text, Map config = [:]) { + final workDir = Files.createTempDirectory('nxf-agent-path-io') + def session = new Session([workDir: workDir.toString()] + config) + session.setBinding(new ScriptBinding()) + session.init(null, null, null, null) + // inject the probe into the private observersV2 list before ignition + final f = Session.getDeclaredField('observersV2') + f.setAccessible(true) + final list = new ArrayList((List) f.get(session)) + list.add(probe) + f.set(session, list) + session.start() + + def loader = ScriptLoaderFactory.create(session) + loader.parse(text) + loader.runScript() + + session.fireDataflowNetwork() + session.await() + session.destroy() + if( session.error ) + throw session.error + return session + } + +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentProtocolSpecTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentProtocolSpecTest.groovy new file mode 100644 index 0000000000..28e8edd902 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentProtocolSpecTest.groovy @@ -0,0 +1,165 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import spock.lang.Specification + +class AgentProtocolSpecTest extends Specification { + + def 'should create the portable protocol payload from a runner request'() { + given: + final request = new AgentRunnerRequest( + model: 'provider/model', + instruction: 'instruction', + goal: 'goal', + prompt: 'prompt', + inputJson: '{"value":1}', + outputSchema: [type: 'string'], + toolSpecs: [], + nativeToolNames: ['read'], + skills: [], + maxIterations: 0, + trace: true, + temperature: null, + workDir: '.') + + expect: + AgentProtocolSpec.fromRequest(request) == [ + model: 'provider/model', + instruction: 'instruction', + goal: 'goal', + prompt: 'prompt', + inputJson: '{"value":1}', + outputSchema: [type: 'string'], + toolSpecs: [], + nativeToolNames: ['read'], + skills: [], + maxIterations: 20, + trace: true, + temperature: null, + workDir: '.', + baseUrl: null ] + } + + // ----------------------------------------------------------------------- + // The runner split. `toolSpecs` carries the BROKERED tools; the runner-native ones travel + // beside it as bare names the runner enables from its own tool set. + // ----------------------------------------------------------------------- + + def 'should carry the runner-native names beside the brokered descriptors, never inside them'() { + given: + final request = new AgentRunnerRequest( + model: 'openai/gpt-4o', + prompt: 'p', + toolSpecs: [new ToolDescriptor('SAMTOOLS_SORT', 'sort it', [type: 'object'], null)], + nativeToolNames: ['read', 'write', 'bash']) + + when: + final payload = AgentProtocolSpec.fromRequest(request) + + then: 'a native tool has no descriptor: it is a name the runner recognises as its own' + payload.toolSpecs*.name == ['SAMTOOLS_SORT'] + payload.nativeToolNames == ['read', 'write', 'bash'] + } + + def 'should refuse to build a payload whose two tool halves overlap'() { + given: 'a name claimed by BOTH halves -- the runner would be told to serve it itself AND' + // to call the driver back for it, and the broker would authorize the callback + final request = new AgentRunnerRequest( + model: 'openai/gpt-4o', + prompt: 'p', + toolSpecs: [new ToolDescriptor('read', 'a process named read', [type: 'object'], null)], + nativeToolNames: ['read', 'bash']) + + when: + AgentProtocolSpec.fromRequest(request) + + then: 'it fails HERE, before any frame is written, rather than in the container' + final err = thrown(IllegalStateException) + err.message.contains('partition violated') + err.message.contains('read') + !err.message.contains('bash') + } + + // ----------------------------------------------------------------------- + // Credential containment (design D6). This payload crosses the plaintext gRPC link to a + // possibly remote agent task, so the endpoint travels and the credential must not. + // ----------------------------------------------------------------------- + + def 'should carry the resolved endpoint but NEVER the credential'() { + given: 'a request as core builds it: both the endpoint and the credential resolved' + final request = new AgentRunnerRequest( + model: 'openai/llama-3.3-70b', + prompt: 'prompt', + apiKey: 'sk-must-not-travel-9f2c', + baseUrl: 'http://localhost:8000/v1') + + when: + final payload = AgentProtocolSpec.fromRequest(request) + + then: 'the endpoint travels -- a remote runner must target the endpoint the DRIVER resolved' + payload.baseUrl == 'http://localhost:8000/v1' + + and: 'the credential does not, under any key' + !payload.containsKey('apiKey') + !payload.values().contains('sk-must-not-travel-9f2c') + !payload.toString().contains('sk-must-not-travel-9f2c') + } + + def 'the payload key set carries nothing credential-shaped, whatever the transport adds beside it'() { + given: 'design D4 now DOES send the credential to a pi task -- as a top-level start-frame' + // field the broker adds under TLS, never inside this map. The invariant therefore has to be + // asserted on the SHAPE, not merely on the one key: this payload is the half a transport is + // free to relay verbatim, log or persist, and it must stay credential-free by construction. + final request = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', + instruction: 'i', goal: 'g', prompt: 'p', + inputJson: '{}', outputSchema: [type: 'string'], + toolSpecs: [], skills: [], maxIterations: 3, trace: true, + temperature: 0.0d, workDir: '/w', + apiKey: 'sk-must-not-travel-1c4b', + baseUrl: 'https://api.openai.com/v1') + + when: + final payload = AgentProtocolSpec.fromRequest(request) + + then: 'no key name suggests a credential, so a later field cannot slip one in unnoticed' + payload.keySet().every { !(it ==~ /(?i).*(apikey|api_key|secret|token|password|credential).*/) } + and: 'and no VALUE is the credential, under any key' + !payload.values().contains('sk-must-not-travel-1c4b') + + and: 'the class declares no credential field either' + AgentProtocolSpec.declaredFields.every { !(it.name ==~ /(?i).*(apikey|api_key|secret|token|credential).*/) } + } + + def 'the credential leaks into neither the request toString nor the persisted task info'() { + given: + final request = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', + prompt: 'p', + apiKey: 'sk-must-not-log-4a71', + baseUrl: 'http://gateway/v1') + + expect: '@ToString(excludes=apiKey): an interpolated request cannot leak the key into .nextflow.log' + !request.toString().contains('sk-must-not-log-4a71') + and: 'the endpoint is not a secret and stays visible for diagnostics' + request.toString().contains('http://gateway/v1') + + and: 'AgentTaskInfo -- persisted by the lineage observer -- declares no credential field at all' + AgentTaskInfo.declaredFields.every { !(it.name ==~ /(?i).*(apikey|api_key|secret|token|password).*/) } + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentRegistryToolTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentRegistryToolTest.groovy new file mode 100644 index 0000000000..b34cc29b73 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentRegistryToolTest.groovy @@ -0,0 +1,198 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import nextflow.module.ModuleChecksum +import nextflow.module.ModuleReference +import nextflow.module.ModuleStorage +import nextflow.script.ScriptFile +import nextflow.script.ScriptRunner +import spock.lang.Timeout +import test.Dsl2Spec + +/** + * End-to-end test of resolving a REGISTRY module reference (Phase 3.3) as an agent tool, + * WITHOUT touching the network. + * + * The module is PRE-INSTALLED on disk in the layout {@link ModuleStorage} recognizes + * ({@code /modules///main.nf} + a sibling {@code meta.yml} manifest with + * a {@code version}, plus a {@code .module-info} checksum so the install passes integrity). The + * script {@code include}s the registry reference — {@code IncludeDef} builds a + * {@link nextflow.module.ModuleResolver} rooted at {@code session.baseDir} and + * {@code resolve(ref, null, autoInstall=true)} finds the local install WITHOUT any registry + * call — and the agent then names the included process under {@code nf:module_run}. A registry + * reference is no longer a {@code tools} entry of its own: the grammar has one way in, and it is + * the same {@code include} every other module goes through. Since a sibling {@code meta.yml} is + * present, schema/marshalling stays spec-driven (Phase 3.2). + * + * The module runs through the REAL local executor (it {@code cat}s the staged input into + * {@code out.dat}), proving real resolution + staging + execution. The + * {@code @Timeout} fails if the tool input queues are not poisoned on completion. + */ +@Timeout(90) +class AgentRegistryToolTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + def 'should resolve a pre-installed registry module as an agent tool and run it (no network)'() { + given: + final dir = Files.createTempDirectory('test') + final work = dir.resolve('work'); Files.createDirectories(work) + final reads = dir.resolve('reads.txt'); reads.text = 'hello' + final readsAbs = reads.toAbsolutePath().toString() + + and: + // -- PRE-INSTALL the module locally so ModuleResolver.resolve returns WITHOUT network. + // Layout recognized by ModuleStorage.getInstalledModule: + // /modules/acme/echo/main.nf + // /modules/acme/echo/meta.yml (manifest; MUST carry a `version`) + // /modules/acme/echo/.module-info (checksum -> integrity VALID) + final reference = ModuleReference.parse('acme/echo') + final storage = new ModuleStorage(dir) + final moduleDir = storage.getModuleDir(reference) + Files.createDirectories(moduleDir) + + // a CLASSIC DSL2 tuple module mimicking the nf-core shape; `cat` runs via the + // LocalExecutor shell (no container) + moduleDir.resolve('main.nf').text = ''' + process echo_tool { + input: + tuple val(meta), path(reads) + + output: + tuple val(meta), path("out.dat"), emit: report + + script: + """ + cat ${reads} > out.dat + """ + } + '''.stripIndent() + + // sibling meta.yml: the registry manifest + the tuple I/O spec (Phase 3.2) + moduleDir.resolve('meta.yml').text = '''\ + name: acme/echo + version: 1.0.0 + description: Copy the input reads to an output file + input: + - - name: meta + type: map + description: sample meta + - name: reads + type: file + description: input reads + output: + - - name: meta + type: map + description: sample meta + - name: outfile + type: file + description: the output + '''.stripIndent() + + // mark the install as integrity-valid (checksum computed AFTER files are written) + ModuleChecksum.save(moduleDir, ModuleChecksum.compute(moduleDir)) + + and: + final main = dir.resolve('main.nf') + main.text = ''' + include { echo_tool } from 'acme/echo' + + agent a { + model 'm' + instruction 'i' + tools 'nf:module_run:echo_tool' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + a(channel.of('go')).view { it } + } + '''.stripIndent() + + and: + AgentRunnerRequest captured = null + String dispatchResult = null + Map outAssert = [:] + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // the included registry module resolves to a single spec-driven tool whose + // LLM-facing name is the PROCESS name — there is no reference-sanitizing step any + // more, because the wire name comes from `nf:module_run:` (§4) + assert req.toolSpecs.size() == 1 + assert req.toolSpecs[0].name == 'echo_tool' + assert req.toolSpecs[0].inputSchema.properties.meta.type == 'object' + assert req.toolSpecs[0].inputSchema.properties.reads.type == 'string' + // invoke the tool: drives the REAL echo_tool process through the executor, + // staging `reads.txt` and producing `out.dat` + dispatchResult = req.dispatch.call('echo_tool', JsonOutput.toJson([meta: [id: 's1'], reads: readsAbs])) + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + // the result is keyed by the emit name `report`; its record carries `outfile` + // as an ABSOLUTE PATH STRING and `meta` as the round-tripped object + assert parsed.containsKey('report') + final report = parsed.report as Map + assert report.meta == [id: 's1'] + final outfile = report.outfile as String + assert outfile.startsWith('/') + final outPath = Path.of(outfile) + assert Files.exists(outPath) + assert outPath.text == 'hello' + outAssert = [outfile: outfile, content: outPath.text] + return dispatchResult + } as AgentRunner + + when: + // an unreachable registry URL keeps the test hermetic: the pre-installed module resolves + // from disk, and the optional registry metadata fetch falls back to the sibling meta.yml + final runner = new ScriptRunner([ + process: [executor: 'local'], + workDir: work.toString(), + registry: [url: 'http://localhost:0'] ]) + runner.setScript(new ScriptFile(main)) + runner.execute() + + then: + // resolution + include + dispatch all succeeded and returned the real process output + captured != null + dispatchResult != null + and: + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + parsed.containsKey('report') + (parsed.report as Map).meta == [id: 's1'] + ((parsed.report as Map).outfile as String).startsWith('/') + and: + // the file the real process produced exists and has the staged content + outAssert.content == 'hello' + Files.exists(Path.of(outAssert.outfile as String)) + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentResumeIntegrationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentResumeIntegrationTest.groovy new file mode 100644 index 0000000000..5a25682ffd --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentResumeIntegrationTest.groovy @@ -0,0 +1,728 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicInteger + +import nextflow.Global +import nextflow.Session +import nextflow.SysEnv +import nextflow.processor.TaskContext +import nextflow.processor.TaskEntry +import nextflow.processor.TaskId +import nextflow.processor.TaskProcessor +import nextflow.processor.TaskStartParams +import nextflow.script.AgentBuilder.AgentInput +import nextflow.script.AgentBuilder.AgentOutput +import nextflow.script.AgentDef +import nextflow.script.BaseScript +import nextflow.script.BodyDef +import nextflow.script.ProcessConfigV2 +import nextflow.script.ProcessDef +import nextflow.script.PromptDef +import nextflow.script.ScriptBinding +import nextflow.script.ScriptMeta +import nextflow.trace.TraceObserverV2 +import nextflow.trace.TraceRecord +import nextflow.trace.event.TaskEvent +import nextflow.util.CacheHelper +import spock.lang.Timeout +import test.Dsl2Spec +import test.MockSession +import nextflow.agent.rpc.AgentRpcRegistration + +/** + * White-box resume tests (design §7.3/§9.6, plan T4d/T7a/T7b/T7c/T7d): a cache hit + * replays the stored generation through {@code collectOutputsV2} WITHOUT calling the + * LLM. Built via the M1 task path ({@link AgentDef#buildAgentTask}) with a counting + * mock runner; the replay is driven directly through {@code checkCachedOutput}. + * + * @author Paolo Di Tommaso + */ +@Timeout(30) +class AgentResumeIntegrationTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + AgentCallInfo.clear() + } + + /** + * The minimal configuration that CONTAINERIZES a canonical agent task -- an enabled container + * engine plus an `agent.container` image -- which every launch-spec runner now requires, since + * its launch command is built from paths that exist only inside the runner image. An image + * already present in the given config is preserved. + */ + private static Map containerized(Map config = [:]) { + final result = new LinkedHashMap(config) + result.docker = [enabled: true] + final agentScope = new LinkedHashMap((Map) (config.agent ?: [:])) + agentScope.container = agentScope.container ?: 'agent-image:test' + result.agent = agentScope + return result + } + + /** Spin a MockSession (MockExecutorFactory) and make it the global session. */ + private Session newSession(Map config = null) { + def session = config ? new MockSession(config) : new MockSession() + session.setBinding(new ScriptBinding()) + session.init(null, null, null, null) + session.start() + Global.session = session + return session + } + + /** A probe observer counting cache-hit notifications. */ + private static TraceObserverV2 cachedProbe(List sink) { + return new TraceObserverV2() { + @Override void onTaskCached(TaskEvent event) { sink.add(event) } + } + } + + private static void injectObserver(Session session, TraceObserverV2 probe) { + final f = Session.getDeclaredField('observersV2') + f.setAccessible(true) + final list = new ArrayList((List) f.get(session)) + list.add(probe) + f.set(session, list) + } + + private AgentDef newAgent(Map directives = [model: 'openai/gpt-4o']) { + final owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + return new AgentDef(owner, 'qa', directives as Map, + [new AgentInput('q', String)], [new AgentOutput('answer', String)], + new PromptDef({ -> 'Q' }, 'Q')) + } + + /** + * An agent owned by a MODULE script: {@code ScriptMeta.register} + {@code setScriptPath} is + * what gives the owner a {@code moduleDir}, which is what makes {@code AgentDef.ownerBaseDir} + * prefer it over {@code session.baseDir}. Without this the "module agent" and the plain local + * agent would be the same object and these tests would prove nothing. + */ + private AgentDef newModuleAgent(Path modDir, Map directives = [model: 'openai/gpt-4o']) { + final owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + final meta = ScriptMeta.register(owner) + meta.setScriptPath(modDir.resolve('main.nf')) + return new AgentDef(owner, 'qa', directives as Map, + [new AgentInput('q', String)], [new AgentOutput('answer', String)], + new PromptDef({ -> 'Q' }, 'Q')) + } + + /** Write `/skills//SKILL.md` with the front matter SkillResolver expects. */ + private static Path moduleSkill(Path modDir, String name, String body) { + final dir = modDir.resolve("skills/${name}") + Files.createDirectories(dir) + dir.resolve('SKILL.md').text = """\ + --- + name: ${name} + description: The ${name} skill + --- + + ${body} + """.stripIndent() + return dir + } + + private static AgentRunner namedRunner(String name = 'test') { + return new AgentRunner() { + @Override String getName() { name } + @Override String run(AgentRunnerRequest req) { 'x' } + } + } + + /** + * A legacy runner that records the requests it is handed. An anonymous class rather than a + * coerced closure: {@code buildAgentTask} calls {@code getName()} on the selected runner, and a + * closure proxy answers every interface method with the same closure body. + */ + private static AgentRunner capturingRunner(List sink) { + return new AgentRunner() { + @Override String getName() { 'test' } + @Override String run(AgentRunnerRequest req) { sink.add(req); 'ANSWER' } + } + } + + private static AgentRunner canonicalRunner(String name = 'canonical-test') { + return new AgentRunner() { + @Override String getName() { name } + @Override AgentLaunchSpec getLaunchSpec() { + new AgentLaunchSpec(['/agent-rpc'], ['node']) + } + // the broker lives in the runner plugin, so a launch-spec runner must issue its own + // registration; a canned one keeps this test off the network (see AgentRunner.register) + @Override AgentRpcRegistration register(AgentRunnerRequest req, boolean remote) { + new AgentRpcRegistration('inv-test', 'tok-test', '127.0.0.1:9999', 'fp-test') + } + @Override String run(AgentRunnerRequest req) { 'x' } + } + } + + def 'canonical agents default to local independently of process executor'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + newSession(containerized([process: [executor: 'k8s']])) + + when: + def processor = newAgent().buildAgentTask(['hello']) + + then: + processor.config.executor == 'local' + } + + def 'canonical agents resolve execution settings exclusively from agent scope'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + // `rpc.remoteHost` is required with a remote executor: the container is launched off the + // driver host, so no container-engine host alias can stand in for the driver's address + newSession(containerized([ + agent: [executor: 'k8s', container: 'agent-image:1', arch: 'arm64', cpus: 2, memory: '1 GB', + rpc: [remoteHost: 'driver.internal']], + process: [executor: 'local', container: 'process-image:1', arch: 'amd64', cpus: 8, 'withName:qa': [executor: 'local']] + ])) + + when: + def processor = newAgent().buildAgentTask(['hello']) + + then: + processor.config.executor == 'k8s' + processor.config.container == 'agent-image:1' + processor.config.arch == 'arm64' + processor.config.cpus == 2 + processor.config.memory.toString() == '1 GB' + } + + def 'canonical agent body resolves launch helpers statically under DELEGATE_ONLY'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + newSession(containerized()) + def processor = newAgent().buildAgentTask(['hello']) + processor.createStateObj() + def task = processor.createTaskRun(new TaskStartParams(TaskId.of(1), 1)) + def body = ((Closure) processor.getTaskBody().closure.clone()) + body.setDelegate(new TaskContext(processor, [q: 'hello', task: task.config])) + + when: + def command = body.call() + + then: + command.startsWith("exec '/agent-rpc' '--endpoint'") + command.contains("'--' 'node'") + } + + def 'legacy runners reject a remote agent executor'() { + given: + AgentRunnerProvider.testRunner = namedRunner('legacy') + newSession([agent: [executor: 'k8s']]) + + when: + newAgent().buildAgentTask(['hello']) + + then: + def error = thrown(nextflow.exception.ScriptRuntimeException) + error.message.contains('does not support executor `k8s`') + } + + def 'legacy runners reject a SELECTOR-provided remote agent executor'() { + given: 'the guard must read the RESOLVED executor, not just the plain scope' + AgentRunnerProvider.testRunner = namedRunner('legacy') + newSession([agent: ['withName:qa': [executor: 'k8s']]]) + + when: + newAgent().buildAgentTask(['hello']) + + then: + def error = thrown(nextflow.exception.ScriptRuntimeException) + error.message.contains('does not support executor `k8s`') + } + + def 'an agent selector can opt a tool-free agent out of resume'() { + given: + AgentRunnerProvider.testRunner = canonicalRunner() + newSession(containerized([agent: ['withName:qa': [cache: false]]])) + + when: + def processor = newAgent().buildAgentTask(['hello']) + + then: + processor.config.isCacheable() == false + } + + /** + * Drive a cache-hit replay: build the (already-created) processor, make a TaskRun, + * feed a TaskEntry whose context holds the output under `outName`, and assert the + * replay binds the stored value with zero runner calls. + */ + private Map replay(TaskProcessor processor, String outName, Object storedValue, AtomicInteger runnerCalls, List cachedEvents) { + processor.createStateObj() + final task = processor.createTaskRun(new TaskStartParams(TaskId.of(1), 1)) + + final ctx = new TaskContext(processor, [(outName): storedValue]) + final entry = new TaskEntry(new TraceRecord(), ctx) + final folder = Files.createTempDirectory('nxf-resume') + final hash = CacheHelper.hasher('agent-resume').hash() + + final hit = processor.checkCachedOutput(task, folder, hash, entry) + final bound = processor.getConfig().getOutputs().getParams()[0].getChannel().val + return [hit: hit, bound: bound, runnerCalls: runnerCalls.get(), cachedEvents: cachedEvents.size(), task: task] + } + + // -- T7a: a cache hit replays the stored generation through collectOutputsV2, + // binds the stored value, and makes ZERO runner (LLM) calls. + def 'agent cache hit replays the stored output without calling the LLM'() { + given: + def runnerCalls = new AtomicInteger() + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> runnerCalls.incrementAndGet(); 'FRESH' } as AgentRunner + def session = newSession() + def cached = [] + injectObserver(session, cachedProbe(cached)) + + and: 'the agent processor built via the M1 task path' + def processor = newAgent().buildAgentTask(['hello']) + + when: + def r = replay(processor, 'answer', 'STORED', runnerCalls, cached) + + then: 'checkCachedOutput reports a hit' + r.hit == true + and: 'the output channel replays the STORED value (collectOutputsV2 routed on the hit)' + r.bound == 'STORED' + and: 'the LLM runner was NEVER called (pure memoization, not a fresh completion)' + r.runnerCalls == 0 + and: 'the cache-hit notification fired' + r.cachedEvents == 1 + r.task.cached == true + } + + // -- T7a (read guard, load-bearing): a cached entry with a NULL context must force a + // cache MISS. This makes the checkCachedOutput read guard `hasCacheableValues() + // && !entry.context` depend on the exec-type hasCacheableValues() branch + // (neutralizing that branch would let this falsely "hit"). + def 'a null cached context forces a cache miss (read guard depends on hasCacheableValues)'() { + given: + def runnerCalls = new AtomicInteger() + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> runnerCalls.incrementAndGet(); 'FRESH' } as AgentRunner + newSession() + + and: 'the agent processor built via the M1 task path' + def processor = newAgent().buildAgentTask(['hello']) + processor.createStateObj() + def task = processor.createTaskRun(new TaskStartParams(TaskId.of(1), 1)) + def folder = Files.createTempDirectory('nxf-resume-miss') + def hash = CacheHelper.hasher('agent-resume-miss').hash() + + when: 'the cached entry has a NULL context (e.g. a pre-fix / cache-false entry)' + def hit = processor.checkCachedOutput(task, folder, hash, new TaskEntry(new TraceRecord(), null)) + + then: 'the exec body makes hasCacheableValues() true, so a missing context forces a miss' + task.hasCacheableValues() == true + hit == false + } + + // -- T7a (write side): a real agent body run WRITES the declared output into the task + // context AND the exec-type hasCacheableValues() branch is true, so CacheDB.writeTaskEntry0's + // `proc.isCacheable() && task.hasCacheableValues()` gate would persist a non-null context. + def 'agent body writes a non-null context and the task is cacheable (write-side gate)'() { + given: + def runnerCalls = new AtomicInteger() + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> runnerCalls.incrementAndGet(); 'ANSWER' } as AgentRunner + newSession() + + and: 'the agent processor built via the M1 task path' + def processor = newAgent().buildAgentTask(['hello']) + processor.createStateObj() + def task = processor.createTaskRun(new TaskStartParams(TaskId.of(1), 1)) + + when: 'the synthetic GROOVY body runs against a task context seeded with the input' + def body = ((Closure) processor.getTaskBody().closure.clone()) + def ctx = new TaskContext(processor, [q: 'hello']) + body.setDelegate(ctx) + body.call() + + then: 'the declared output landed in the context (CacheDB would persist it) and the runner ran once' + ctx.get('answer') == 'ANSWER' + runnerCalls.get() == 1 + and: 'the write-side gate operand hasCacheableValues() is true for the exec agent body' + task.hasCacheableValues() == true + processor.getConfig().isCacheable() == true + } + + // -- T7c: `agent.cache = false` makes the agent non-cacheable, so no + // context is stored/looked up (the resume opt-out, design §7.5/D7). + def 'cache false makes the agent non-cacheable (resume opt-out inherited)'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'x' } as AgentRunner + newSession([agent: [cache: false]]) + + when: + def processor = newAgent().buildAgentTask(['hello']) + + then: 'the applied `cache false` directive disables caching -> no context storage/lookup' + processor.getConfig().isCacheable() == false + } + + // -- T7d: plain-`val` V2 process (NO agent) resumes correctly through the same shared + // cache path — bounds the blast radius of the hasCacheableValues() change. + def 'plain val V2 process resumes (replays the stored value) - blast-radius regression'() { + given: + def session = newSession() + def cached = [] + injectObserver(session, cachedProbe(cached)) + def owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + + and: 'a plain V2 process with a single val output (no agent involved)' + def config = new ProcessConfigV2(owner, 'plain') + config.getOutputs().addParam('x', String, { getProperty('x') }) + config.getOutputs().getParams().each { it.setChannel(nextflow.extension.CH.value()) } + def body = new BodyDef({ -> getDelegate().put('x', 'hello'); return null }, 'plain-src', 'exec') + def processor = ProcessDef.createTaskProcessor(session, owner, 'plain', 'plain', 'plain', config, body) + + when: + def r = replay(processor, 'x', 'hello', new AtomicInteger(), cached) + + then: + r.hit == true + r.bound == 'hello' + r.cachedEvents == 1 + } + + // -- T4d: the synthetic BodyDef carries the canonical source + the prompt's valRefs. + def 'synthetic BodyDef carries canonical source and folds prompt valRefs'() { + given: + newSession() + AgentRunnerProvider.testRunner = namedRunner() + def owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + def refs = [new nextflow.script.TokenValRef('params.threshold')] + def agent = new AgentDef(owner, 'qa', [model: 'openai/gpt-4o'] as Map, + [new AgentInput('q', String)], [new AgentOutput('answer', String)], + new PromptDef({ -> 'Q' }, 'Q', refs)) + + when: + def processor = agent.buildAgentTask(['hello']) + def body = processor.getTaskBody() + + then: 'source equals the canonical identity string built from effective values (single scalar output => null schema, default maxIter=20)' + body.source == agent.canonicalAgentSource('openai/gpt-4o', 20, null, null, 'test') + body.source.startsWith('agentRunner=test\nagentModel=openai/gpt-4o') + and: 'the prompt free-var refs are folded into the BodyDef (so params.* enters the hash)' + (body.getValNames() as Set) == ['params.threshold'] as Set + } + + // -- T7b: two tasks differing ONLY in BodyDef.source produce different task hashes + // (proxy for "changing agent.model invalidates the cache"). + def 'changing the canonical source changes the task hash'() { + given: + newSession() + AgentRunnerProvider.testRunner = namedRunner() + def processor = newAgent().buildAgentTask(['hello']) + processor.createStateObj() + + when: + def t1 = processor.createTaskRun(new TaskStartParams(TaskId.of(1), 1)) + t1.source = 'agentModel=openai/gpt-4o\ntemperature=default' + def t2 = processor.createTaskRun(new TaskStartParams(TaskId.of(2), 2)) + t2.source = 'agentModel=openai/gpt-4o-mini\ntemperature=default' + def h1 = new nextflow.processor.TaskHasher(t1).compute() + def h2 = new nextflow.processor.TaskHasher(t2).compute() + + then: + h1 != h2 + } + + // -- ENDPOINT/CREDENTIAL IDENTITY (design D5). The RESOLVED endpoint is part of what an agent + // IS (a different endpoint serves a different model under the same id); the credential is + // not, so rotating a key must not invalidate a single stored entry -- nor be written into + // the key, which is a hash input persisted in the cache db. + def 'the resolved endpoint enters the cache key; a rotated credential does not'() { + given: + AgentRunnerProvider.testRunner = namedRunner() + and: 'an empty environment, so an exported OPENAI_* cannot supply a tier of its own' + SysEnv.push([:]) + + when: 'neither the endpoint nor the credential resolves' + newSession() + def plain = newAgent().buildAgentTask(['hello']).getTaskBody().source + + then: 'the key is the historical string -- no `baseUrl=` line, so existing entries stay valid' + !plain.contains('baseUrl=') + + when: 'only the credential is configured, then rotated' + newSession([agent: [apiKey: 'sk-first-2b8e']]) + def k1 = newAgent().buildAgentTask(['hello']).getTaskBody().source + newSession([agent: [apiKey: 'sk-second-77c1']]) + def k2 = newAgent().buildAgentTask(['hello']).getTaskBody().source + + then: 'the key is unchanged by either value, and neither value appears in it' + k1 == plain + k2 == plain + !k1.contains('sk-first-2b8e') + + when: 'an endpoint is configured' + newSession([agent: [baseUrl: 'http://localhost:8000/v1']]) + def local = newAgent().buildAgentTask(['hello']).getTaskBody().source + newSession([agent: [baseUrl: 'http://localhost:9000/v1']]) + def other = newAgent().buildAgentTask(['hello']).getTaskBody().source + + then: 'the key changes, so -resume re-executes rather than replaying another endpoint answer' + local != plain + local == plain + '\nbaseUrl=http://localhost:8000/v1' + other != local + + when: 'the endpoint comes from the environment instead of the config' + SysEnv.push([NXF_AGENT_BASE_URL: 'http://localhost:8000/v1']) + newSession() + def fromEnv = newAgent().buildAgentTask(['hello']).getTaskBody().source + SysEnv.pop() + + then: 'the RESOLVED value is folded in, so the env tier keys identically to the config tier' + fromEnv == local + + cleanup: + SysEnv.pop() + } + + // -- PROVIDER IDENTITY (design D6). An EXPLICIT `agent.apiProvider` selects which environment + // variables the endpoint and the credential come from, so it is part of how this agent was + // configured. An INFERRED one is a pure function of `baseUrl`, which is already in the key. + def 'an explicit apiProvider enters the cache key; an inferred one does not'() { + given: + AgentRunnerProvider.testRunner = namedRunner() + and: 'an empty environment, so an exported provider variable cannot supply a tier of its own' + SysEnv.push([:]) + + when: 'neither option is set' + newSession() + def plain = newAgent().buildAgentTask(['hello']).getTaskBody().source + + then: 'the historical string -- no `apiProvider=` line, so existing entries stay valid' + !plain.contains('apiProvider=') + + when: 'the namespace is INFERRED from a well-known endpoint host' + newSession([agent: [baseUrl: 'https://openrouter.ai/api/v1']]) + def inferred = newAgent().buildAgentTask(['hello']).getTaskBody().source + + then: 'only the endpoint enters: the inference adds no information the key does not have,' + // and leaving it out means a later addition to the D3 host table cannot silently invalidate + // a stored run of a pipeline nobody touched + inferred == plain + '\nbaseUrl=https://openrouter.ai/api/v1' + !inferred.contains('apiProvider=') + + when: 'the same endpoint is written together with an EXPLICIT namespace' + newSession([agent: [baseUrl: 'https://openrouter.ai/api/v1', apiProvider: 'openrouter']]) + def explicit = newAgent().buildAgentTask(['hello']).getTaskBody().source + + then: 'the key changes: which variables were read IS part of how this agent was configured' + explicit == inferred + '\napiProvider=openrouter' + + when: 'the namespace alone is set, redundantly to what the model prefix already implied' + newSession([agent: [apiProvider: 'openai']]) + def redundant = newAgent().buildAgentTask(['hello']).getTaskBody().source + + then: 'still the documented one-time invalidation -- the key records the CONFIG, not the resolution' + redundant == plain + '\napiProvider=openai' + + cleanup: + SysEnv.pop() + } + + def 'the widened ladder hands a provider-tier credential to the runner and persists none of it'() { + given: 'an exported ANTHROPIC_API_KEY now resolves in core where the openai carve-out never' + // reached it (design D2). It must reach the runner as a value and nothing else. + def seen = [] + AgentRunnerProvider.testRunner = capturingRunner(seen) + final secret = 'sk-ant-canary-6b12' + SysEnv.push([ANTHROPIC_API_KEY: secret]) + newSession() + + when: + def processor = newAgent([model: 'anthropic/claude-sonnet-4']).buildAgentTask(['hello']) + def body = ((Closure) processor.getTaskBody().closure.clone()) + body.setDelegate(new TaskContext(processor, [q: 'hello'])) + body.call() + + then: 'the runner is handed the credential, already scoped to the model\'s own provider' + seen.size() == 1 + seen[0].apiKey == secret + + and: 'while the cache key holds nothing of it -- rotating the key must replay, not re-run' + !processor.getTaskBody().source.contains(secret) + and: 'nor does the lineage record, nor the config the Platform observer serializes' + !processor.config.get(AgentTaskInfo.CONFIG_KEY).toString().contains(secret) + !processor.config.toString().contains(secret) + + cleanup: + SysEnv.pop() + } + + def 'the resolved credential reaches no persisted agent artifact'() { + given: + AgentRunnerProvider.testRunner = namedRunner() + SysEnv.push([:]) + and: + final secret = 'sk-leak-canary-3d91' + newSession([agent: [apiKey: secret, baseUrl: 'http://localhost:8000/v1']]) + + when: + def processor = newAgent().buildAgentTask(['hello']) + + then: 'the resolved identity attached for the lineage observer holds no credential' + def info = processor.config.get(AgentTaskInfo.CONFIG_KEY) + info instanceof AgentTaskInfo + !info.toString().contains(secret) + + and: 'nor does BodyDef.source, which TaskHasher folds into the hash stored in the cache db' + !processor.getTaskBody().source.contains(secret) + and: 'while the endpoint IS there -- it is not a secret and it IS part of the identity' + processor.getTaskBody().source.contains('baseUrl=http://localhost:8000/v1') + + and: 'nor the resolved task config, which the Platform observer serializes as configText' + !processor.config.toString().contains(secret) + + cleanup: + SysEnv.pop() + } + + // -- MODULE AGENT RESUME (agent-module design §4.5). canonicalAgentSource folds a name-sorted + // content fingerprint of every skill but NO agent name and NO file path, so a module agent's + // cache key tracks what its skills SAY, not where the module lives. + + def 'editing a module skill invalidates the agent cache key, moving the module does not'() { + given: + newSession() + AgentRunnerProvider.testRunner = namedRunner() + final root = Files.createTempDirectory('test') + + and: 'two module dirs holding a byte-identical skill' + final modA = root.resolve('a'); Files.createDirectories(modA) + moduleSkill(modA, 'greeting', 'Always greet the user by name.') + final modB = root.resolve('b'); Files.createDirectories(modB) + moduleSkill(modB, 'greeting', 'Always greet the user by name.') + + when: + final srcA = newModuleAgent(modA, [model: 'openai/gpt-4o', skills: 'greeting']) + .buildAgentTask(['hello']).getTaskBody().source + final srcB = newModuleAgent(modB, [model: 'openai/gpt-4o', skills: 'greeting']) + .buildAgentTask(['hello']).getTaskBody().source + + then: 'the skill fingerprint is in the key ...' + srcA.contains('skills=') + and: '... and a move/rename of the module dir does NOT invalidate it' + srcA == srcB + + when: 'the module skill body is edited' + moduleSkill(modB, 'greeting', 'Always greet the user by name, and sign off politely.') + final srcB2 = newModuleAgent(modB, [model: 'openai/gpt-4o', skills: 'greeting']) + .buildAgentTask(['hello']).getTaskBody().source + + then: 'the key changes, so -resume re-executes the agent' + srcB2 != srcA + } + + // -- MODULE-TOOL AGENT RESUME. A module/process-tool agent is NOT opted out of resume: + // canonicalAgentSource folds a fingerprint of every tool's + // descriptor AND backing script source, so a replay is only ever served for the exact tools + // that produced it. Without the fingerprint this would silently replay a stale generation + // after a tool edit -- which is why the blanket `cache false` existed. + + /** An in-scope typed process usable as an agent tool, owned by the agent's own script. */ + private AgentDef newToolAgent(String toolScript) { + final owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + final meta = ScriptMeta.register(owner) + final config = new ProcessConfigV2(owner, 'uppercase') + config.getInputs().addParam('text', String, false) + config.getOutputs().addParam('result', String, { getProperty('result') }) + meta.addDefinition(new ProcessDef(owner, 'uppercase', config, new BodyDef({ -> toolScript }, toolScript, 'script'))) + return new AgentDef(owner, 'qa', [model: 'openai/gpt-4o', tools: 'nf:module_run:uppercase'] as Map, + [new AgentInput('q', String)], [new AgentOutput('answer', String)], + new PromptDef({ -> 'Q' }, 'Q')) + } + + def 'a module-tool agent is cacheable and its key tracks the tool script'() { + given: + newSession() + AgentRunnerProvider.testRunner = namedRunner() + + when: + def built = newToolAgent('tr a-z A-Z').buildAgentTask(['hello']) + + then: 'a module-tool agent is NOT opted out of resume ...' + built.getConfig().isCacheable() == true + and: '... because the tool identity is folded into the cache key' + built.getTaskBody().source.contains('tools=') + + when: 'the tool process script is edited' + def edited = newToolAgent('tr A-Z a-z').buildAgentTask(['hello']).getTaskBody().source + + then: 'the agent key changes, so -resume re-runs it instead of replaying a stale generation' + edited != built.getTaskBody().source + + when: 'the tool is unchanged' + def same = newToolAgent('tr a-z A-Z').buildAgentTask(['hello']).getTaskBody().source + + then: 'the key is stable, so -resume can replay' + same == built.getTaskBody().source + } + + def 'an fs:-tool agent participates in resume'() { + given: + newSession() + AgentRunnerProvider.testRunner = namedRunner() + + when: + def built = newAgent([model: 'openai/gpt-4o', tools: 'fs:*']).buildAgentTask(['hello']) + + then: + built.getConfig().isCacheable() == true + + and: 'the capability is part of the key, so adding or dropping it re-runs the agent' + def toolFree = newAgent([model: 'openai/gpt-4o']).buildAgentTask(['hello']) + built.getTaskBody().source != toolFree.getTaskBody().source + } + + def 'aliasing a module agent changes the task hash while leaving the canonical source alone'() { + given: + newSession() + AgentRunnerProvider.testRunner = namedRunner() + final root = Files.createTempDirectory('test') + final mod = root.resolve('mod'); Files.createDirectories(mod) + moduleSkill(mod, 'greeting', 'Always greet the user by name.') + + when: + final declared = newModuleAgent(mod, [model: 'openai/gpt-4o', skills: 'greeting']) + final aliased = (AgentDef) newModuleAgent(mod, [model: 'openai/gpt-4o', skills: 'greeting']) + .cloneWithName('qc') + final p1 = declared.buildAgentTask(['hello']) + final p2 = aliased.buildAgentTask(['hello']) + + then: 'no agent name is folded into the canonical source' + p1.getTaskBody().source == p2.getTaskBody().source + and: 'but the alias renames the processor' + p1.getName() == 'qa' + p2.getName() == 'qc' + + when: 'the task hash folds the fully-qualified processor name' + p1.createStateObj(); p2.createStateObj() + final t1 = p1.createTaskRun(new TaskStartParams(TaskId.of(1), 1)) + t1.source = p1.getTaskBody().source + final t2 = p2.createTaskRun(new TaskStartParams(TaskId.of(2), 2)) + t2.source = p2.getTaskBody().source + + then: 'aliasing therefore invalidates the resume cache' + new nextflow.processor.TaskHasher(t1).compute() != new nextflow.processor.TaskHasher(t2).compute() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunIntegrationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunIntegrationTest.groovy new file mode 100644 index 0000000000..1be4b8d8d6 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunIntegrationTest.groovy @@ -0,0 +1,276 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * End-to-end test: a workflow invoking an {@code agent} runs as a dataflow + * operator, rendering the prompt and delegating to the configured + * {@link AgentRunner} (here a mock injected via the test seam). + */ +@Timeout(30) +class AgentRunIntegrationTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + def 'should run a record-typed agent end-to-end against a mock runner'() { + given: + // capture the request the runner receives and echo a canned JSON answer + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; '{"answer":"ok","confidence":0.9}' } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + record Question { text: String } + record Answer { answer: String; confidence: Double } + + agent eval_agent { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + tools() + maxIterations 7 + + input: + q: Question + + output: + a: Answer + + prompt: + """ + Question: ${q.text} + """ + } + + workflow { + eval_agent(channel.of(record(text: 'analyze my reads'))) + } + ''') + + then: + // the emitted value is the bound output record + def out = result.val + out instanceof Map + out.answer == 'ok' + out.confidence == 0.9d + and: + captured.model == 'openai/gpt-5-mini' + captured.instruction == 'You are helpful.' + captured.maxIterations == 7 + captured.prompt.contains('Question: analyze my reads') + and: + // the output schema was derived from the Answer record type + captured.outputSchema.properties.answer.type == 'string' + captured.outputSchema.properties.confidence.type == 'number' + and: + // the input record was serialized to JSON + captured.inputJson.contains('analyze my reads') + } + + def 'should run a val-typed agent and emit the runner text verbatim'() { + given: + // a non-record (scalar) output opts out of structured output: the runner + // returns plain text which is emitted verbatim (no JSON parse) + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'hello world' } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + tools() + + input: + question: String + + output: + answer: String + + prompt: + """ + Answer: ${question} + """ + } + + workflow { + qa(channel.of('what is FASTQ?')) + } + ''') + + then: + // the emitted value is the runner text verbatim, NOT a parsed record + result.val == 'hello world' + and: + // no structured-output schema was derived for a scalar output + captured.outputSchema == null + captured.prompt.contains('Answer: what is FASTQ?') + and: + // the scalar input was still serialized to JSON + captured.inputJson.contains('what is FASTQ?') + and: + // no agent config scope: the request carries the built-in defaults + captured.maxIterations == 20 + captured.requestTimeoutSeconds == 120 + } + + def 'should apply agent config scope defaults when directives are absent'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'hello world' } as AgentRunner + + when: + def result = runScript( + config: [ + agent: [ + model : 'openai/x', + maxIterations: 5, + requestTimeout : '90s' + ] + ], + ''' + nextflow.enable.types = true + + agent qa { + instruction 'You are helpful.' + tools() + + input: + question: String + + output: + answer: String + + prompt: + """ + Answer: ${question} + """ + } + + workflow { + qa(channel.of('what is FASTQ?')) + } + ''') + + then: + result.val == 'hello world' + and: + // the agent declared no model/maxIterations directive -> config defaults apply + captured.model == 'openai/x' + captured.maxIterations == 5 + captured.requestTimeoutSeconds == 90 + } + + def 'should propagate the goal directive into the AgentRunnerRequest'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'result' } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + agent summarise { + model 'openai/gpt-5-mini' + goal 'do the thing' + tools() + + input: + text: String + + output: + summary: String + + prompt: + """ + Summarise: ${text} + """ + } + + workflow { + summarise(channel.of('hello')) + } + ''') + + then: + result.val == 'result' + and: + captured.goal == 'do the thing' + captured.model == 'openai/gpt-5-mini' + captured.maxIterations == 20 + } + + def 'should let agent directives override the config scope defaults'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'hello world' } as AgentRunner + + when: + def result = runScript( + config: [ + agent: [ + model : 'openai/x', + maxIterations: 5, + requestTimeout : '90s' + ] + ], + ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-5-mini' + maxIterations 7 + instruction 'You are helpful.' + tools() + + input: + question: String + + output: + answer: String + + prompt: + """ + Answer: ${question} + """ + } + + workflow { + qa(channel.of('what is FASTQ?')) + } + ''') + + then: + result.val == 'hello world' + and: + // the agent's own directives win over the config defaults ... + captured.model == 'openai/gpt-5-mini' + captured.maxIterations == 7 + and: + // ... while requestTimeout (no directive) still comes from the config scope + captured.requestTimeoutSeconds == 90 + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunnerProviderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunnerProviderTest.groovy new file mode 100644 index 0000000000..4a92d3fcb8 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunnerProviderTest.groovy @@ -0,0 +1,100 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.exception.AbortOperationException +import spock.lang.Specification + +class AgentRunnerProviderTest extends Specification { + + def cleanup() { + AgentRunnerProvider.testRunner = null + AgentRunnerProvider.testRunners = null + } + + def 'should select a runner by stable name'() { + given: + def pi = Stub(AgentRunner) { getName() >> 'pi' } + def langchain = Stub(AgentRunner) { getName() >> 'langchain4j' } + AgentRunnerProvider.testRunners = [langchain, pi] + + expect: + AgentRunnerProvider.get('pi').is(pi) + AgentRunnerProvider.get('langchain4j').is(langchain) + } + + def 'should reject an unknown runner and report available names'() { + given: + AgentRunnerProvider.testRunners = [ + Stub(AgentRunner) { getName() >> 'langchain4j' }, + Stub(AgentRunner) { getName() >> 'pi' } ] + + when: + AgentRunnerProvider.get('other') + + then: + def e = thrown(AbortOperationException) + e.message.contains('Unknown agent runner `other`') + e.message.contains('langchain4j, pi') + } + + def 'should reject implicit selection when multiple runners are installed'() { + given: + AgentRunnerProvider.testRunners = [ + Stub(AgentRunner) { getName() >> 'langchain4j' }, + Stub(AgentRunner) { getName() >> 'pi' } ] + + when: + AgentRunnerProvider.get() + + then: + def e = thrown(AbortOperationException) + e.message.contains('Multiple agent runners are available') + e.message.contains('agent.runner') + } + + def 'should reject duplicate extension names'() { + given: + AgentRunnerProvider.testRunners = [ + Stub(AgentRunner) { getName() >> 'pi' }, + Stub(AgentRunner) { getName() >> 'pi' } ] + + when: + AgentRunnerProvider.get('pi') + + then: + def e = thrown(AbortOperationException) + e.message.contains('Multiple agent runner extensions use the name `pi`') + } + + def 'should return the test runner when set'() { + given: + def runner = { AgentRunnerRequest req -> "echo:${req.prompt}" } as AgentRunner + AgentRunnerProvider.testRunner = runner + + expect: + AgentRunnerProvider.get().is(runner) + } + + def 'should fail with a helpful error when no runner is available'() { + when: + AgentRunnerProvider.get() + + then: + def e = thrown(AbortOperationException) + e.message.contains('nf-agent') + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunnerRequestTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunnerRequestTest.groovy new file mode 100644 index 0000000000..f6cf4447bd --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentRunnerRequestTest.groovy @@ -0,0 +1,274 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.exception.AbortOperationException +import spock.lang.Specification + +class AgentRunnerRequestTest extends Specification { + + def 'should build via named args with goal as the last field'() { + when: + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', + instruction: 'sys', + prompt: 'p', + maxIterations: 7, + tools: [], + outputSchema: null, + inputJson: '{}', + toolSpecs: null, + dispatch: null, + requestTimeoutSeconds: 30, + goal: 'reach the objective', + skills: [new SkillDescriptor('greet', 'a greeting skill', 'say hi', [])]) + + then: + req.model == 'openai/gpt-5-mini' + req.instruction == 'sys' + req.prompt == 'p' + req.maxIterations == 7 + req.inputJson == '{}' + req.requestTimeoutSeconds == 30 + req.goal == 'reach the objective' + req.tools == [] + req.outputSchema == null + req.toolSpecs == null + req.skills.size() == 1 + req.skills[0].name == 'greet' + } + + def 'should default skills to null when omitted'() { + when: + def req = new AgentRunnerRequest(model: 'm', prompt: 'p') + then: + req.skills == null + } + + def 'should default goal to null when omitted'() { + when: + def req = new AgentRunnerRequest(model: 'm', prompt: 'p') + then: + req.goal == null + } + + // ----------------------------------------------------------------------- + // The brokered/runner-native partition. Two fields, disjoint by construction: `toolSpecs` is + // what the runner may call the DRIVER back for, `nativeToolNames` what it serves itself. + // ----------------------------------------------------------------------- + + def 'should authorize the brokered names only'() { + given: + def req = new AgentRunnerRequest( + model: 'm', prompt: 'p', + toolSpecs: [ + new ToolDescriptor('GREET', 'greet', [type: 'object'], null), + new ToolDescriptor('SHOUT', 'shout', [type: 'object'], null) ], + nativeToolNames: ['read', 'grep', 'bash']) + + expect: 'a runner-native tool never becomes callable over the broker' + req.brokeredToolNames() == ['GREET', 'SHOUT'] as Set + } + + def 'should authorize nothing when no tool is declared'() { + expect: + new AgentRunnerRequest(model: 'm', prompt: 'p').brokeredToolNames() == [] as Set + } + + def 'should reject a request claiming one name for both halves'() { + given: 'a process named `find`, alongside the `fs:find` the runner serves' + def req = new AgentRunnerRequest( + model: 'm', prompt: 'p', + toolSpecs: [new ToolDescriptor('find', 'a process', [type: 'object'], null)], + nativeToolNames: ['read', 'find']) + + when: + req.brokeredToolNames() + + then: 'the allowlist is never built: it would relocate a container-side tool into the driver' + def err = thrown(IllegalStateException) + err.message.contains('partition violated') + err.message.contains('find') + } + + // ----------------------------------------------------------------------- + // The credential a runner presents (design D8). ONE rule, here, because both runners consume + // it: langchain4j through `credentialFor`, pi through `credential()` on the start frame. + // ----------------------------------------------------------------------- + + def 'credential returns the resolved key when one resolved'() { + expect: + new AgentRunnerRequest(model: 'openai/gpt-5-mini', apiKey: 'sk-real').credential() == 'sk-real' + and: 'an endpoint does not displace it' + new AgentRunnerRequest(model: 'openai/gpt-5-mini', apiKey: 'sk-real', baseUrl: 'http://local/v1').credential() == 'sk-real' + and: 'nor does a non-openai provider: the key was scoped to it by core before it got here' + new AgentRunnerRequest(model: 'anthropic/claude-sonnet-4', apiKey: 'sk-real').credential() == 'sk-real' + } + + def 'credential falls back to the placeholder for an openai endpoint with no key'() { + given: 'a local vLLM/Ollama needs no credential, but both runners require SOMETHING:' + // langchain4j's OpenAI client rejects an empty key, and pi fails the run with + // `No API key found for openai` -- exactly the local-first case D8 exists to unblock + expect: + new AgentRunnerRequest(model: 'openai/llama-3.3-70b', baseUrl: 'http://localhost:8000/v1').credential() == + AgentRunnerRequest.PLACEHOLDER_API_KEY + + and: 'with no endpoint there is nothing to talk to, so no placeholder is invented' + new AgentRunnerRequest(model: 'openai/gpt-5-mini').credential() == null + } + + def 'the placeholder is never sent for a non-openai provider'() { + given: 'a runner installs what it is given as the credential OF THAT PROVIDER, and that' + // ownership beats the ambient environment (pi: setRuntimeApiKey shadows ANTHROPIC_API_KEY), + // so a placeholder here would MASK a credential the runner can resolve by itself + expect: + new AgentRunnerRequest(model: 'anthropic/claude-sonnet-4', baseUrl: 'http://gateway/v1').credential() == null + and: 'the same holds for a model id with no provider prefix' + new AgentRunnerRequest(model: 'gpt-5-mini', baseUrl: 'http://gateway/v1').credential() == null + } + + def 'credentialFor is the same rule for a caller holding only the pair'() { + expect: 'used by the langchain4j ChatModelFactory, which has already checked the protocol' + AgentRunnerRequest.credentialFor('sk-real', null) == 'sk-real' + AgentRunnerRequest.credentialFor('sk-real', 'http://local/v1') == 'sk-real' + AgentRunnerRequest.credentialFor(null, 'http://local/v1') == AgentRunnerRequest.PLACEHOLDER_API_KEY + AgentRunnerRequest.credentialFor(null, null) == null + + and: 'the placeholder is not a credential-shaped string: it can never be mistaken for one' + !AgentRunnerRequest.PLACEHOLDER_API_KEY.startsWith('sk-') + } + + // ----------------------------------------------------------------------- + // Design D5: the placeholder assumes the endpoint needs no credential, which is true of a + // local vLLM/Ollama and plainly false of a provider's own API. `api.anthropic.com` is not a + // local server, so that combination is diagnosed here instead of buying an opaque 401. + // ----------------------------------------------------------------------- + + def 'a well-known provider endpoint with no credential is an error, not a placeholder'() { + when: + AgentRunnerRequest.credentialFor(null, ENDPOINT) + + then: + def error = thrown(AbortOperationException) + and: 'the message names the provider, the endpoint and the variables the ladder consults' + error.message.contains(PROVIDER) + error.message.contains(ENDPOINT) + error.message.contains('`agent.apiKey`') + error.message.contains('NXF_AGENT_API_KEY') + error.message.contains(VAR) + + where: + ENDPOINT | PROVIDER | VAR + 'https://api.openai.com/v1' | 'openai' | 'OPENAI_API_KEY' + 'https://api.anthropic.com/v1' | 'anthropic' | 'ANTHROPIC_API_KEY' + 'https://openrouter.ai/api/v1' | 'openrouter' | 'OPENROUTER_API_KEY' + 'https://api.mistral.ai/v1' | 'mistral' | 'MISTRAL_API_KEY' + } + + def 'the D5 check keys off the HOST, so an unrecognized endpoint keeps the placeholder'() { + expect: 'a local server, a corporate gateway, a self-hosted mirror -- all still unblocked' + AgentRunnerRequest.credentialFor(null, 'http://localhost:8000/v1') == AgentRunnerRequest.PLACEHOLDER_API_KEY + AgentRunnerRequest.credentialFor(null, 'https://gateway.corp/v1') == AgentRunnerRequest.PLACEHOLDER_API_KEY + + and: 'and a provider name that is only in the PATH is not a provider endpoint' + AgentRunnerRequest.credentialFor(null, 'https://evil.example.com/openai/v1') == AgentRunnerRequest.PLACEHOLDER_API_KEY + AgentRunnerRequest.credentialFor(null, 'https://api.openai.com.evil.example/v1') == AgentRunnerRequest.PLACEHOLDER_API_KEY + + and: 'a resolved credential short-circuits the check entirely -- there is nothing missing' + AgentRunnerRequest.credentialFor('sk-real', 'https://api.anthropic.com/v1') == 'sk-real' + } + + def 'credential() applies D5 only where the placeholder could have been substituted'() { + when: 'an openai-protocol model points at a provider API with nothing resolved' + new AgentRunnerRequest(model: 'openai/gpt-5-mini', baseUrl: 'https://api.openai.com/v1').credential() + + then: + thrown(AbortOperationException) + + when: 'the same endpoint, but the model is not openai-protocol' + // credential() never reaches credentialFor there: the runner may resolve the key itself + // (pi reads its own store and the provider variables), so this is not core's call to abort + def other = new AgentRunnerRequest(model: 'anthropic/claude-sonnet-4', baseUrl: 'https://api.anthropic.com/v1').credential() + + then: + other == null + + when: 'a credential DID resolve' + def resolved = new AgentRunnerRequest(model: 'openai/gpt-4o', apiKey: 'sk-real', baseUrl: 'https://api.openai.com/v1').credential() + + then: + resolved == 'sk-real' + } + + // ----------------------------------------------------------------------- + // A credential the endpoint gate WITHHELD is not a missing one. The placeholder exists for a + // genuine no-credential local endpoint; substituting it for a misroute guarantees an opaque + // 401 where a diagnosis was available (and, on pi, shadows an out-of-band key). + // ----------------------------------------------------------------------- + + def 'a withheld credential never becomes the placeholder'() { + expect: 'the exact shape the gate produces: null apiKey, a baseUrl set, withheld flagged' + new AgentRunnerRequest(model: 'openai/llama-3.3-70b', baseUrl: 'http://gw.corp/v1', + credentialWithheld: true).credential() == null + + and: 'without the flag the very same request DOES get the placeholder -- that is the whole' + // difference the flag carries, and why "nothing resolved" had to stop meaning both things + new AgentRunnerRequest(model: 'openai/llama-3.3-70b', baseUrl: 'http://gw.corp/v1').credential() == + AgentRunnerRequest.PLACEHOLDER_API_KEY + } + + def 'the withheld flag short-circuits D5 too, rather than reporting the wrong failure'() { + when: 'a known provider host with a credential that resolved in ANOTHER namespace' + // D5 would say "missing anthropic credential", which is false: one was found, for openai, + // and refused. The runner that owns the decision reports the real cause. + def result = new AgentRunnerRequest(model: 'openai/gpt-4o', baseUrl: 'https://api.anthropic.com/v1', + credentialWithheld: true).credential() + + then: + noExceptionThrown() + result == null + } + + def 'the withheld flag defaults to false, so an untouched request behaves exactly as before'() { + expect: + !new AgentRunnerRequest(model: 'openai/gpt-5-mini', apiKey: 'sk-real').credentialWithheld + new AgentRunnerRequest(model: 'openai/gpt-5-mini', apiKey: 'sk-real').credential() == 'sk-real' + } + + def 'the D5 error names agent.apiProvider, the third way out'() { + when: 'the namespace this message reports was INFERRED from the host, so it is overridable' + AgentRunnerRequest.credentialFor(null, 'https://api.anthropic.com/v1') + + then: + def error = thrown(AbortOperationException) + error.message.contains('`agent.apiKey`') + error.message.contains('`NXF_AGENT_API_KEY`') + error.message.contains('`agent.apiProvider`') + } + + def 'the provider namespace travels on the request, unlike the credential'() { + given: 'a runner reporting a missing credential must name the variables the ladder ACTUALLY' + // consulted; that is a driver-side resolution, so it has to be carried + def req = new AgentRunnerRequest(model: 'openai/gpt-4o', baseUrl: 'https://openrouter.ai/api/v1', + apiProvider: 'openrouter', apiKey: 'sk-or') + + expect: + req.apiProvider == 'openrouter' + and: 'and it is not a secret, so unlike apiKey it is not excluded from toString()' + req.toString().contains('openrouter') + !req.toString().contains('sk-or') + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentSelectorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentSelectorTest.groovy new file mode 100644 index 0000000000..9218993609 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentSelectorTest.groovy @@ -0,0 +1,111 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.Global +import nextflow.Session +import nextflow.script.AgentBuilder.AgentInput +import nextflow.script.AgentBuilder.AgentOutput +import nextflow.script.AgentDef +import nextflow.script.BaseScript +import nextflow.script.PromptDef +import nextflow.script.ScriptBinding +import nextflow.script.ScriptMeta +import spock.lang.Timeout +import test.Dsl2Spec +import test.MockSession + +/** + * Aliasing side of the agent config-selector story (module-include design §6.1/§6.2, as amended by + * its §16 -- selectors live in the independent {@code agent} scope, not {@code process}). The + * premise of a module is that its CONSUMER cannot edit the module file: placing an included agent + * from the config must therefore work for the DECLARED name as well as the alias -- which is why + * {@link AgentDef} carries a stable {@code baseName} like {@code ProcessDef} does. + * + *

The selector resolution itself (the precedence ladder, the `agent` vs `process` scope split) + * is covered by {@code AgentConfigSelectorTest}; this spec covers what {@code cloneWithName} + * preserves and what it registers. + * + *

Extends {@code Dsl2Spec} because {@code cloneWithName} mutates the static + * {@code ScriptMeta.resolvedAgentNames} and {@code Dsl2Spec.setup()} resets it. + * + * @author Paolo Di Tommaso + */ +@Timeout(60) +class AgentSelectorTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + AgentCallInfo.clear() + } + + private Session newSession(Map config = null) { + final session = config ? new MockSession(config) : new MockSession() + session.setBinding(new ScriptBinding()) + session.init(null, null, null, null) + session.start() + Global.session = session + return session + } + + /** An agent DECLARED as `qa`, as an agent module would declare it. */ + private AgentDef newAgent(Map directives = [model: 'openai/gpt-4o']) { + final owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + return new AgentDef(owner, 'qa', directives as Map, + [new AgentInput('q', String)], [new AgentOutput('answer', String)], + new PromptDef({ -> 'Q' }, 'Q')) + } + + def 'cloneWithName changes the name and the simple name but keeps the base name'() { + given: + final agent = newAgent() + + when: + final alias = (AgentDef) agent.cloneWithName('hi') + + then: + alias.getName() == 'hi' + alias.getSimpleName() == 'hi' + alias.getBaseName() == 'qa' + and: 'the template is untouched' + agent.getName() == 'qa' + agent.getBaseName() == 'qa' + + when: 'the clone name carries a workflow scope prefix' + final scoped = (AgentDef) agent.cloneWithName('wf:a') + + then: + scoped.getName() == 'wf:a' + scoped.getSimpleName() == 'a' + scoped.getBaseName() == 'qa' + } + + // -- R2 (cosmetic): the alias and the workflow-scoped name must reach the AGENT selector + // registry so Session#checkConfig does not warn "no agent matching config selector" for a + // selector that actually applied. + def 'the alias reaches the config-selector registry'() { + given: + final session = newSession([agent: ['withName:hi': [cpus: 2]]]) + + when: + newAgent().cloneWithName('hi') + + then: + ScriptMeta.allAgentNames().contains('hi') + and: 'so the selector is not reported as unmatched' + session.validateConfig0([], ScriptMeta.allAgentNames()) == [] + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentTaskScriptTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentTaskScriptTest.groovy new file mode 100644 index 0000000000..f1320ecc19 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentTaskScriptTest.groovy @@ -0,0 +1,103 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import nextflow.processor.TaskConfig +import spock.lang.Specification + +/** + * The capability token must not leave the work directory. It is a bearer credential FOR the + * provider API key ever since the driver started answering a `connect` with the key on the start + * frame, and {@code TraceRecord.script} is persisted in the resume cache and POSTed to Seqera + * Platform. + * + * @author Paolo Di Tommaso + */ +class AgentTaskScriptTest extends Specification { + + /** The exact shape AgentDef.createCanonicalBody produces: every argument shell-quoted. */ + private static String launchScript(String token) { + return "exec '/opt/nf-agent/agent-rpc' '--endpoint' 'driver.internal:41235' " + + "'--invocation' 'inv-7f3a' '--fingerprint' 'a1b2c3' '--token' '${token}' " + + "'--' '/usr/bin/node' '/opt/nf-agent/runner.mjs'" + } + + private static AgentTaskInfo agentInfo() { + return new AgentTaskInfo('pi', 'openai/gpt-5-mini', 'be helpful', null, 'do ${x}', 20, null, null, null) + } + + def 'the capability token is redacted and nothing else is'() { + given: + def token = 'Zm9vYmFyLXNlY3JldC10b2tlbi12YWx1ZQ' + + when: + def redacted = AgentTaskScript.redactCapabilityToken(launchScript(token)) + + then: 'the token value is gone, in every spelling of itself' + !redacted.contains(token) + redacted.contains("'--token' '${AgentTaskScript.REDACTED}'") + + and: 'everything a recorded script is worth having for survives' + // a fingerprint is a PUBLIC commitment, not a secret -- see docs/agent.mdx + redacted.contains("'--endpoint' 'driver.internal:41235'") + redacted.contains("'--invocation' 'inv-7f3a'") + redacted.contains("'--fingerprint' 'a1b2c3'") + redacted.contains('/opt/nf-agent/runner.mjs') + } + + def 'an unquoted spelling is redacted too, as a backstop against a future call site'() { + expect: + AgentTaskScript.redactCapabilityToken('exec proxy --token abc123 --') == + "exec proxy --token ${AgentTaskScript.REDACTED} --" + AgentTaskScript.redactCapabilityToken('exec proxy --token=abc123') == + "exec proxy --token=${AgentTaskScript.REDACTED}" + } + + def 'a script with no token is returned unchanged, not reformatted'() { + expect: + AgentTaskScript.redactCapabilityToken(SCRIPT) === SCRIPT + + where: + SCRIPT << [ + 'echo hello world', + "exec '/opt/nf-agent/agent-rpc' '--endpoint' 'x' '--insecure'", + '', + null ] + } + + def 'only an agent task is redacted; every other task is byte-identical'() { + given: 'the guard that keeps this off the path of every ordinary process task' + def script = launchScript('a-token-value') + + expect: 'a process task passes through untouched -- same instance, not merely equal' + AgentTaskScript.forTrace(new TaskConfig([tag: 'x']), script) === script + AgentTaskScript.forTrace(null, script) === script + + and: 'a FORGED agentInfo directive is inert: the guard is instanceof, like LinObserver' + AgentTaskScript.forTrace(new TaskConfig([agentInfo: 'not-an-agent']), script) === script + + and: 'while a real agent task is redacted' + !AgentTaskScript.forTrace(new TaskConfig([agentInfo: agentInfo()]), script).contains('a-token-value') + } + + def 'isAgentTask recognizes the carrier the lineage observer uses'() { + expect: + AgentTaskScript.isAgentTask(new TaskConfig([(AgentTaskInfo.CONFIG_KEY): agentInfo()])) + !AgentTaskScript.isAgentTask(new TaskConfig([:])) + !AgentTaskScript.isAgentTask(null) + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentToolBridgeIntegrationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentToolBridgeIntegrationTest.groovy new file mode 100644 index 0000000000..7a2df20210 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentToolBridgeIntegrationTest.groovy @@ -0,0 +1,356 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonSlurper +import spock.lang.TempDir +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * End-to-end test of the {@link ModuleToolBridge}: an agent declares an in-scope + * process ({@code greet}) as a tool, and a mock runner invokes the dispatch + * callback. This proves the headline mechanism — the LLM's tool call marshals + * JSON args into channel values, the real {@code greet} process executes through + * the standard dataflow/executor machinery, and its output is serialized back to + * the caller as JSON — and that the run TERMINATES (the {@code @Timeout} fails if + * the tool input queues are not poisoned on completion). + */ +@Timeout(60) +class AgentToolBridgeIntegrationTest extends Dsl2Spec { + + @TempDir + Path tempDir + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + def 'should run an in-scope process as an agent tool and terminate'() { + given: + AgentRunnerRequest captured = null + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + // the bridge exposes a `greet` tool with a scalar `name:String` input + assert req.toolSpecs.size() == 1 + assert req.toolSpecs[0].name == 'greet' + assert req.toolSpecs[0].inputSchema.properties.name.type == 'string' + // invoke the tool: this drives the REAL greet process through the executor + dispatchResult = req.dispatch.call('greet', '{"name":"Ada"}') + // the returned JSON proves the process actually ran and produced the value + assert new JsonSlurper().parseText(dispatchResult) == [greeting: 'Hello Ada!'] + // the agent's final answer + return dispatchResult + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + process greet { + input: + name: String + + output: + greeting: String + + exec: + greeting = "Hello ${name}!" + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run:greet' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + // the workflow emits the runner's final answer (the dispatch result) + new JsonSlurper().parseText(result.val) == [greeting: 'Hello Ada!'] + and: + // the dispatch went through the bridge and returned the real process output + captured != null + new JsonSlurper().parseText(dispatchResult) == [greeting: 'Hello Ada!'] + } + + def 'should allow tools with a record (structured) output and bind the JSON'() { + given: + // the guard is gone (M5): a tool agent may also declare a structured output. The + // plugin's final structuring turn is what returns schema JSON; here the stub runner + // stands in for it and returns the JSON directly so the shared core bind runs. + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + return '{"greeting":"Hello Ada!"}' + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + record Answer { greeting: String } + + process greet { + input: + name: String + + output: + greeting: String + + exec: + greeting = "Hello ${name}!" + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run:greet' + + input: + request: String + + output: + answer: Answer + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + // the emitted value is a bound record with greeting == 'Hello Ada!' + result.val.greeting == 'Hello Ada!' + and: + // the request carried a structured output schema (drives the plugin final-turn) + captured != null + captured.outputSchema != null + captured.outputSchema.type == 'object' + captured.outputSchema.properties.containsKey('greeting') + } + + def 'a structured runner returning non-JSON aborts the run on the task path'() { + given: + // M-Tools: a tools agent now lowers to the task path, so a malformed structuring + // answer fails the task body's JSON parse and aborts the run (the legacy path's clear + // ScriptRuntimeException wrapper was removed together with runLegacy). The failure + // names the agent/process and carries the JSON parse error in its cause chain. + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> return 'not json' } as AgentRunner + + when: + runScript(''' + nextflow.enable.types = true + + record Answer { greeting: String } + + process greet { + input: + name: String + + output: + greeting: String + + exec: + greeting = "Hello ${name}!" + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run:greet' + + input: + request: String + + output: + answer: Answer + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + // the malformed structuring answer fails the task body's JSON parse and aborts the run + def e = thrown(Exception) + hasCause(e, groovy.json.JsonException) + } + + /** True when {@code type} appears anywhere in the throwable's cause chain. */ + private static boolean hasCause(Throwable t, Class type) { + for( Throwable c = t; c != null; c = c.getCause() ) + if( type.isInstance(c) ) + return true + return false + } + + def 'should split a wrapper into N channels for a multi-output tools agent (PARTIAL)'() { + given: + // PARTIAL (M5): a tools agent with N>1 outputs requests a wrapper-object schema + // and the wrapper is fanned out to one channel per output name. + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + return '{"plan":{"title":"Assembly"},"score":7}' + } as AgentRunner + + when: + def result = runScript(''' + nextflow.enable.types = true + + record Plan { title: String } + + process greet { + input: + name: String + + output: + greeting: String + + exec: + greeting = "Hello ${name}!" + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run:greet' + + input: + request: String + + output: + plan: Plan + score: Integer + + prompt: + """ + ${request} + """ + } + + workflow { + def r = assistant(channel.of('hi')) + [ r.plan, r.score ] + } + ''') + + then: + // the wrapper schema was requested: object root, both output names required + captured != null + captured.outputSchema.type == 'object' + captured.outputSchema.required as Set == ['plan', 'score'] as Set + captured.outputSchema.properties.keySet() as Set == ['plan', 'score'] as Set + + and: + // the wrapper was split: each out. channel received its slice, coerced to type + def plan = result[0].val + plan.title == 'Assembly' + def score = result[1].val + score == 7 + } + + def 'should allow skills with a record (structured) output and bind the JSON'() { + given: + // M5 decision 2: the guard is gone for skills too. A skills agent may declare a + // structured output; the plugin's final structuring turn returns schema JSON (the + // stub runner stands in for it here) and the SHARED core bind must parse+bind it. + // This exercises the skills path through the exact same structured bind as the tools + // case, at the core level (skills resolved on disk; runner stubbed). + def skillDir = Files.createDirectories(tempDir.resolve('skills').resolve('greet')) + Files.writeString(skillDir.resolve('SKILL.md'), "---\nname: greet\ndescription: greets\n---\ninstructions") + def script = tempDir.resolve('main.nf') + Files.writeString(script, ''' + nextflow.enable.types = true + + record Answer { greeting: String } + + agent assistant { + model 'm' + instruction 'i' + skills 'greet' + + input: + request: String + + output: + answer: Answer + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + captured = req + return '{"greeting":"Hello Ada!"}' + } as AgentRunner + + when: + def result = runScript(script) + + then: + // the emitted value is a bound record with greeting == 'Hello Ada!' + result.val.greeting == 'Hello Ada!' + and: + // the skill resolved and the request carried it + the structured output schema + captured != null + captured.skills*.name == ['greet'] + captured.outputSchema != null + captured.outputSchema.type == 'object' + captured.outputSchema.properties.containsKey('greeting') + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentToolPartitionTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentToolPartitionTest.groovy new file mode 100644 index 0000000000..86522a38d6 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentToolPartitionTest.groovy @@ -0,0 +1,198 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.Global +import nextflow.Session +import nextflow.processor.TaskConfig +import nextflow.script.AgentBuilder.AgentInput +import nextflow.script.AgentBuilder.AgentOutput +import nextflow.script.AgentDef +import nextflow.script.BaseScript +import nextflow.script.PromptDef +import nextflow.script.ScriptBinding +import spock.lang.Timeout +import test.Dsl2Spec +import test.MockSession +import nextflow.agent.rpc.AgentRpcRegistration + +/** + * The §5 runner split, end to end through the real agent-build path. + * + *

Every assertion here is about the PARTITION rather than about any single tool: the resolved + * selection leaves {@code AgentDef} in two disjoint halves — the brokered descriptors on + * {@code toolSpecs}, the runner-native wire names on {@code nativeToolNames} — and each half must + * arrive intact at the runner that serves it. The two failure modes this pins down are both + * SILENT, which is why they need their own tests: a native name that never reaches the request + * leaves the model with no tool and no error, and a native name that reaches {@code toolSpecs} + * enters the broker's allowlist and gets executed in the driver JVM instead of the container. + * + * @author Paolo Di Tommaso + */ +@Timeout(30) +class AgentToolPartitionTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + // ----------------------------------------------------------------------- + // §5 invariant — a containerized runner + // ----------------------------------------------------------------------- + + def 'the runner-native names reach a canonical runner on their own field, never as descriptors'() { + given: 'a launch-spec runner, i.e. one that owns a container -- what `shell:` requires' + final List registered = [] + AgentRunnerProvider.testRunner = canonicalRunner(registered) + newSession(containerized()) + + when: 'the agent selects a partial fs: set plus the shell family' + runTaskBody(newAgent([model: 'openai/gpt-4o', tools: ['fs:read', 'fs:grep', 'shell:bash']]), + new TaskConfig([container: 'agent-image:test'])) + + then: 'the native half travels beside toolSpecs, in inventory order' + registered.size() == 1 + registered[0].nativeToolNames == ['read', 'grep', 'bash'] + + and: 'and NEVER inside it: no descriptor is minted for a tool the runner serves itself' + !registered[0].toolSpecs + registered[0].brokeredToolNames().isEmpty() + + and: 'the partition is enforced, not merely observed' + registered[0].checkToolPartition() + } + + def 'a canonical agent selecting only fs: gets no sandbox context in the driver'() { + given: 'the container roots the fs: builtins at its own cwd -- the driver has no business there' + final List registered = [] + AgentRunnerProvider.testRunner = canonicalRunner(registered) + newSession(containerized()) + + when: + runTaskBody(newAgent([model: 'openai/gpt-4o', tools: ['fs:*']]), + new TaskConfig([container: 'agent-image:test'])) + + then: 'nothing is dispatchable back into the driver' + registered[0].nativeToolNames == ['read', 'write', 'edit', 'ls', 'grep', 'find'] + !registered[0].toolSpecs + and: 'a driver-side fs: call is impossible, so a remote work dir cannot break one' + registered[0].dispatch == null || registered[0].dispatch.call('read', '{"path":"x"}').contains('Unknown agent tool') + } + + // ----------------------------------------------------------------------- + // the in-JVM runner serves the SELECTED leaves, and only those + // ----------------------------------------------------------------------- + + def 'an in-JVM agent declaring one fs: leaf is served that leaf and no other'() { + given: + AgentRunnerRequest captured = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> captured = req; 'ok' } as AgentRunner + newSession() + + when: 'read-only access is declared' + runTaskBody(newAgent([model: 'openai/gpt-4o', tools: ['fs:read']]), new TaskConfig([:])) + + then: 'exactly one native name travels -- a partial selection is never widened to fs:*' + captured.nativeToolNames == ['read'] + + and: 'the declared leaf IS served in the driver JVM (it fails on the missing work dir, not on the name)' + captured.dispatch.call('read', '{"path":"x"}').contains('`read` tool unavailable') + + and: 'while the leaves that were NOT declared are not tools at all -- no silent write access' + captured.dispatch.call('write', '{"path":"x","content":"y"}').contains('Unknown agent tool `write`') + captured.dispatch.call('edit', '{"path":"x"}').contains('Unknown agent tool `edit`') + } + + // ----------------------------------------------------------------------- + // `shell:` is pi-only (§5), and the refusal must name the runner that has it + // ----------------------------------------------------------------------- + + def 'shell:bash is refused at agent-build time on an in-JVM runner, naming pi'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'never reached' } as AgentRunner + newSession() + + when: + newAgent([model: 'openai/gpt-4o', tools: ['shell:bash']]).buildAgentTask(['hello']) + + then: 'the error names the ref, the reason and the runner that can serve it' + def e = thrown(Exception) + e.message.contains('`shell:bash`') + e.message.contains('pi') + } + + // ----------------------------------------------------------------------- + // helpers (mirrors AgentAsTaskIntegrationTest) + // ----------------------------------------------------------------------- + + /** The minimal config that containerizes a canonical agent task: an engine plus an image. */ + private static Map containerized(Map config = [:]) { + final result = new LinkedHashMap(config) + result.docker = [enabled: true] + final agentScope = new LinkedHashMap((Map) (config.agent ?: [:])) + agentScope.container = 'agent-image:test' + result.agent = agentScope + return result + } + + /** A launch-spec runner recording every registered request, so the wire contract is assertable. */ + private static AgentRunner canonicalRunner(List registered) { + return new AgentRunner() { + @Override + String getName() { 'external' } + + @Override + AgentLaunchSpec getLaunchSpec() { + new AgentLaunchSpec( + containerProxyCommand: ['/opt/agent-rpc'], + containerHarnessCommand: ['node', '/opt/runner.mjs']) + } + + @Override + AgentRpcRegistration register(AgentRunnerRequest request, boolean remote) { + registered << request + return new AgentRpcRegistration('inv-1', 'tok-1', 'host.docker.internal:9999', 'abc123') + } + + @Override + String run(AgentRunnerRequest request) { throw new UnsupportedOperationException('canonical task path') } + } + } + + private Session newSession(Map config = null) { + def session = config ? new MockSession(config) : new MockSession() + session.setBinding(new ScriptBinding()) + session.init(null, null, null, null) + session.start() + Global.session = session + return session + } + + /** Lower the agent and invoke the synthesized body against a stand-in task context. */ + private static String runTaskBody(AgentDef agent, TaskConfig taskConfig) { + final body = (Closure) agent.buildAgentTask(['hello']).getTaskBody().closure.clone() + body.setDelegate([q: 'hello', task: taskConfig]) + body.setResolveStrategy(Closure.DELEGATE_ONLY) + return body.call() + } + + private AgentDef newAgent(Map directives = [model: 'openai/gpt-4o']) { + final owner = Mock(BaseScript) { getBinding() >> new ScriptBinding() } + return new AgentDef(owner, 'qa', directives as Map, + [new AgentInput('q', String)], [new AgentOutput('answer', String)], + new PromptDef({ -> 'Q' }, 'Q')) + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/AgentTopicOutputToolTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/AgentTopicOutputToolTest.groovy new file mode 100644 index 0000000000..714017c1e7 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/AgentTopicOutputToolTest.groovy @@ -0,0 +1,164 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import nextflow.script.ScriptFile +import nextflow.script.ScriptRunner +import spock.lang.Timeout +import test.Dsl2Spec + +/** + * Regression test for the topic-routed {@code versions} output bug: an nf-core module whose + * registry {@code meta.yml} types the eval/version component as {@code string} (NOT {@code eval}, + * e.g. nf-core/assemblyscan) used to BLOCK the agent dispatch forever — the bridge read the + * topic-source channel's {@code .val}, which never binds a per-invocation value. The fix detects + * topic-routed outputs from the ProcessDef ({@code OutParam.getChannelTopicName()}), which is + * authoritative; the meta.yml {@code type} is unreliable. + * + *

This lives in its OWN spec class on purpose: running two {@code topic}-creating agent sessions + * back-to-back in the same JVM trips a pre-existing GPars cross-session operator-join hang at + * session shutdown (run agent test classes individually). Keeping at most one topic-agent-session + * per class avoids that — {@link AgentModuleSpecToolTest} already carries the {@code eval}-typed + * topic case. + */ +@Timeout(90) +class AgentTopicOutputToolTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + def 'should skip a topic-routed versions output typed as string and not block the dispatch'() { + given: + final dir = Files.createTempDirectory('test') + final work = dir.resolve('work'); Files.createDirectories(work) + final reads = dir.resolve('reads.txt'); reads.text = 'hello' + final readsAbs = reads.toAbsolutePath().toString() + + // The ASSEMBLYSCAN shape: a DATA output (`report`, a small .json that is INLINED) AND + // an nf-core `versions` output routed to a `topic`. Unlike skesa, assemblyscan's + // registry-generated meta.yml types the version-command component as `type: string` + // (NOT `eval`), so the old `isEvalOutput` MISSES it -- the dispatcher used to block + // forever reading the topic-source channel's `.val`. The fix skips it via the + // ProcessDef's topic-routed output param, which is authoritative. + dir.resolve('mod.nf').text = ''' + process echo_tool { + input: + tuple val(meta), path(reads) + + output: + tuple val(meta), path("out.json"), emit: report + tuple val("${task.process}"), val('mytool'), eval('echo 1.0'), topic: versions, emit: versions_mytool + + script: + """ + echo '{"n50":42}' > out.json + """ + } + '''.stripIndent() + + // sibling meta.yml: a `report` data output + a versions output whose components are + // ALL typed `string` (NO `eval` anywhere) -- mirroring assemblyscan's real meta.yml + dir.resolve('meta.yml').text = '''\ + name: echo_tool + description: Compute assembly statistics + input: + - - name: meta + type: map + - name: reads + type: file + output: + - - name: meta + type: map + - name: outfile + type: file + pattern: "*.json" + - - name: proc + type: string + - name: tool + type: string + - name: version + type: string + topics: + - - name: proc + type: string + - name: tool + type: string + - name: version + type: string + '''.stripIndent() + + dir.resolve('main.nf').text = ''' + include { echo_tool } from './mod.nf' + + agent a { + model 'm' + instruction 'i' + tools 'nf:module_run:echo_tool' + + input: + request: String + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + a(channel.of('go')).view { it } + } + '''.stripIndent() + + and: + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + dispatchResult = req.dispatch.call('echo_tool', JsonOutput.toJson([meta: [id: 's1'], reads: readsAbs])) + return dispatchResult + } as AgentRunner + + when: + final runner = new ScriptRunner([process: [executor: 'local'], workDir: work.toString()]) + runner.setScript(new ScriptFile(dir.resolve('main.nf'))) + runner.execute() + + then: + // the dispatch returned (it did NOT block forever on the string-typed topic output's + // `.val`) and the whole run terminated within @Timeout + dispatchResult != null + final parsed = new JsonSlurper().parseText(dispatchResult) as Map + // the data output IS collected ... + parsed.containsKey('report') + and: + // ... and it was INLINED (small .json): the `outfile` carries the JSON CONTENTS + final report = parsed.report as Map + report.meta == [id: 's1'] + final outfile = report.outfile as String + !outfile.startsWith('/') + outfile.contains('n50') + outfile.contains('42') + and: + // ... and the topic-routed versions output is NOT present (skipped, not blocked on) + !parsed.containsKey('versions_mytool') + parsed.size() == 1 + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/FilesystemToolDescriptorPinTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/FilesystemToolDescriptorPinTest.groovy new file mode 100644 index 0000000000..5bb266381f --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/FilesystemToolDescriptorPinTest.groovy @@ -0,0 +1,112 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent + +import groovy.json.JsonOutput +import spock.lang.Specification + +/** + * This is a tripwire, not a unit test. + * + *

The six {@code fs:} descriptors are the CONTRACT the model is shown for the in-JVM filesystem + * tools: the tool name it must call, the prose telling it when to, and the {@code parameters} schema + * that decides which arguments it is allowed to send. {@link FilesystemToolsTest} and + * {@link ModuleToolBridgeFilesystemTest} assert which NAMES are advertised; nothing asserted the + * bytes, so every description and every property schema below could be rewritten by a refactor with + * the whole suite still green. + * + *

This is deliberately NOT a cache-key pin, and that is the reason it has to exist. + * {@code AgentDef.toolsFingerprint} folds runner-native tools in as + * {@code ['runner-native', runnerId, ]} — the NAMES and the runner identity, never + * these descriptors. So a reworded description or a dropped {@code required} entry moves no task + * hash, breaks no {@code -resume}, and fails no other test: the only symptom is a model that is told + * something different about its tools and starts calling them differently. That is precisely the + * class of change nothing else in the suite can see. + * + *

The expected values were obtained by RUNNING {@link FilesystemTools#descriptors}, not by + * reasoning about what it ought to produce, and are asserted with {@code ==} against whole literals + * rather than {@code contains}. The input schemas are pinned as {@code JsonOutput.toJson} — + * insertion order, which is what the model is shown — so a reordered property is a failure too. + * + *

Do not "fix" a failure here by updating an expected value. A failure means the tool + * contract moved. Either revert the change, or — if the rewording is intentional — update the + * literal deliberately, knowing that every agent relying on the old prose now sees the new. + * + * @author Paolo Di Tommaso + */ +class FilesystemToolDescriptorPinTest extends Specification { + + // --- the pinned bytes, generated by running FilesystemTools.descriptors(NAMES) ------------- + + private static final String READ_DESC = 'Read the contents of a single file. Small text-like files (.txt .md .json .yaml .yml .csv .tsv .tab .log) are returned inline under `content`; every other file — binary, bulk data, or over the inline size limit — is returned as an opaque absolute `path` handle you can pass to another tool but cannot see the bytes of. Use `ls` first if you are unsure the file exists. Confined to the agent sandbox: the task work dir plus any module-output files returned by an earlier tool call. A path outside it is refused.' + + private static final String READ_SCHEMA = '{"type":"object","properties":{"path":{"type":"string","description":"File to read, relative to the agent work dir (or an absolute path inside the sandbox)."}},"required":["path"],"additionalProperties":false}' + + private static final String WRITE_DESC = 'Create a file or replace its entire contents. Missing parent directories are created. To change part of an existing file use `edit` instead — this tool overwrites everything. Writes are confined to the agent work dir only: unlike reads, a module-output path outside the work dir is NOT writable.' + + private static final String WRITE_SCHEMA = '{"type":"object","properties":{"path":{"type":"string","description":"File to write, relative to the agent work dir. Missing parent directories are created."},"content":{"type":"string","description":"The full new contents of the file. The file is overwritten, not appended to."}},"required":["path","content"],"additionalProperties":false}' + + private static final String EDIT_DESC = 'Replace an exact literal string in an existing file, leaving the rest untouched. `old_string` is matched literally, never as a regular expression. If it occurs more than once the edit is REFUSED rather than applied to the first match: either extend `old_string` with surrounding lines until it is unique, or pass `replace_all: true` when you really mean every occurrence. The number of replacements made is reported back. Edits are confined to the agent work dir only, like writes.' + + private static final String EDIT_SCHEMA = '{"type":"object","properties":{"path":{"type":"string","description":"File to edit, relative to the agent work dir."},"old_string":{"type":"string","description":"The exact text to replace, including whitespace and indentation. Must occur exactly once in the file unless `replace_all` is true."},"new_string":{"type":"string","description":"The text to put in its place. May be empty to delete the matched text."},"replace_all":{"type":"boolean","description":"Replace every occurrence instead of requiring a unique one. Defaults to false."}},"required":["path","old_string","new_string"],"additionalProperties":false}' + + private static final String LS_DESC = 'List the immediate entries of a directory (not recursive). Each entry reports its `name`, its `type` (`file` or `dir`) and, for a file, its `size` in bytes — so you can tell whether reading it is worthwhile. Use `find` to search a directory tree instead. Confined to the agent sandbox: the task work dir plus any module-output files returned by an earlier tool call. A path outside it is refused.' + + private static final String LS_SCHEMA = '{"type":"object","properties":{"path":{"type":"string","description":"Directory to list, relative to the agent work dir. Defaults to the work dir itself."}},"required":[],"additionalProperties":false}' + + private static final String GREP_DESC = 'Search file contents line by line for a regular expression, recursively. Returns one entry per matching line with its absolute `file`, 1-based `line` number and the matched line `text` (truncated to 300 characters). Binary files and files larger than 2 MB are skipped. When more lines match than the limit allows the result sets `truncated: true` with `truncated_reason: "max_results"` and reports the `limit` used — narrow the pattern or the `include` glob rather than assuming you saw everything. The search itself is also bounded (at most 20000 entries visited and 2000 files read): when THAT stops it, `truncated_reason` is `"search_budget"` and the tree was NOT searched to the end — narrow the search root or the depth. Confined to the agent sandbox: the task work dir plus any module-output files returned by an earlier tool call. A path outside it is refused.' + + private static final String GREP_SCHEMA = '{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression (Java/PCRE syntax) matched against each line. A plain substring is a valid pattern."},"path":{"type":"string","description":"File or directory to search, relative to the agent work dir. Defaults to the work dir itself."},"include":{"type":"string","description":"Optional glob restricting which files are searched, e.g. `*.tsv`. Matched against the file name, or against the path relative to the search root when it contains a `/`."},"case_insensitive":{"type":"boolean","description":"Match case-insensitively. Defaults to false."},"max_results":{"type":"integer","description":"Maximum number of matching lines to return. Defaults to 200, capped at 1000."},"max_depth":{"type":"integer","description":"Maximum directory depth to descend. Defaults to 20, capped at 50."}},"required":["pattern"],"additionalProperties":false}' + + private static final String FIND_DESC = 'Find files and directories by name, recursively, returning their absolute paths. This searches NAMES only — use `grep` to search file contents. When more paths match than the limit allows the result sets `truncated: true` with `truncated_reason: "max_results"` and reports the `limit` used. The search itself is also bounded (at most 20000 entries visited): when THAT stops it, `truncated_reason` is `"search_budget"` and the tree was NOT searched to the end — narrow the search root or the depth. Confined to the agent sandbox: the task work dir plus any module-output files returned by an earlier tool call. A path outside it is refused.' + + private static final String FIND_SCHEMA = '{"type":"object","properties":{"pattern":{"type":"string","description":"Glob matched against the file NAME, e.g. `*.fastq.gz`. A pattern containing a `/` is matched against the path relative to the search root instead, e.g. `**/results/*.json`."},"path":{"type":"string","description":"Directory to search, relative to the agent work dir. Defaults to the work dir itself."},"type":{"type":"string","enum":["file","dir","any"],"description":"Restrict results to regular files or to directories. Defaults to `any`."},"max_results":{"type":"integer","description":"Maximum number of paths to return. Defaults to 200, capped at 1000."},"max_depth":{"type":"integer","description":"Maximum directory depth to descend. Defaults to 20, capped at 50."}},"required":["pattern"],"additionalProperties":false}' + + // --- the pins ---------------------------------------------------------------------------- + + def 'should advertise exactly the six fs tools, in this order'() { + expect: + FilesystemTools.NAMES == ['read', 'write', 'edit', 'ls', 'grep', 'find'] + } + + def 'should pin the descriptor bytes of every fs tool'() { + given: + final byName = FilesystemTools.descriptors(FilesystemTools.NAMES).collectEntries { [it.name, it] } + + expect: + byName[name].description == description + JsonOutput.toJson(byName[name].inputSchema) == schema + // no fs: tool declares an output schema -- the shape is described in prose, in the + // description above, because these results are handed straight to the model as JSON + byName[name].outputSchema == null + + where: + name | description | schema + 'read' | READ_DESC | READ_SCHEMA + 'write' | WRITE_DESC | WRITE_SCHEMA + 'edit' | EDIT_DESC | EDIT_SCHEMA + 'ls' | LS_DESC | LS_SCHEMA + 'grep' | GREP_DESC | GREP_SCHEMA + 'find' | FIND_DESC | FIND_SCHEMA + } + + def 'should return the selected leaves only, in the order given'() { + expect: + FilesystemTools.descriptors(['grep', 'read'])*.name == ['grep', 'read'] + FilesystemTools.descriptors([]).isEmpty() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/FilesystemToolsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/FilesystemToolsTest.groovy new file mode 100644 index 0000000000..c233e45c6c --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/FilesystemToolsTest.groovy @@ -0,0 +1,727 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import spock.lang.Specification +import spock.lang.TempDir + +/** + * Unit tests for the six {@code fs:} tools — {@code read}, {@code write}, {@code edit}, + * {@code ls}, {@code grep}, {@code find} — as served in the driver JVM by + * {@link ModuleToolBridge}, plus their {@link FilesystemTools} descriptors. + * + *

The bridge is built with NO modules, so every call exercises the filesystem dispatch + * path alone. Each tool is covered by a happy path and by both sandbox-escape shapes: a + * {@code ..} traversal out of the work dir, and a symlink INSIDE the work dir whose target + * is outside it — the case a lexical path check would miss. + * + * @author Paolo Di Tommaso + */ +class FilesystemToolsTest extends Specification { + + @TempDir Path workDir + + /** A directory outside the sandbox, created lazily by {@link #outsideFile}. */ + private Path outside + + def cleanup() { + ModuleToolBridge.clearContext() + } + + /** A bridge with no modules serving the given fs: tools (all six by default). */ + private ModuleToolBridge bridge(Collection fsTools = FilesystemTools.NAMES) { + new ModuleToolBridge( + Collections.emptyList(), + fsTools ) + } + + /** Invoke a tool and parse its JSON result. */ + private Map call(ModuleToolBridge bridge, String tool, Map args) { + return (Map) new JsonSlurper().parseText(bridge.call(tool, JsonOutput.toJson(args))) + } + + /** Create a file OUTSIDE the sandbox (a sibling of the work dir) and return it. */ + private Path outsideFile(String name, String content) { + if( outside == null ) + outside = Files.createDirectories(workDir.getParent().resolve('fs-outside-' + System.nanoTime())) + return Files.write(outside.resolve(name), content.getBytes('UTF-8')) + } + + /** A symlink inside the work dir pointing at a target outside it. */ + private Path escapingLink(String linkName, Path target) { + return Files.createSymbolicLink(workDir.resolve(linkName), target) + } + + private void sandbox() { + ModuleToolBridge.setContext(new DispatchContext(workDir)) + } + + // ========================================================================= + // descriptors (§4 wire names) + // ========================================================================= + + def 'should advertise the six fs tools under their bare wire names'() { + when: + def names = bridge().filesystemDescriptors()*.name + then: + names == ['read','write','edit','ls','grep','find'] + and: 'the legacy aggregate tool is gone for good' + !names.contains('filesystem') + and: 'no wire name is colon-bearing, and all are OpenAI-legal' + names.every { it ==~ /[a-zA-Z0-9_-]{1,64}/ } + and: '§5: a runner-native tool is never a brokered descriptor' + bridge().descriptors().isEmpty() + } + + def 'should advertise only the selected subset, in canonical order'() { + expect: 'the descriptor order follows the family inventory, never the declaration order' + bridge(['grep','read']).filesystemDescriptors()*.name == ['read','grep'] + bridge([]).filesystemDescriptors().isEmpty() + and: 'the sandbox flag follows the selection' + bridge(['read']).filesystemEnabled + !bridge([]).filesystemEnabled + } + + def 'should give every grammar leaf of the fs family a descriptor'() { + expect: 'the selectable set and the servable set cannot drift apart' + FilesystemTools.NAMES == ToolRefResolver.FS_TOOLS + ToolRefResolver.FS_TOOLS.every { FilesystemTools.descriptor(it) != null } + } + + def 'should reject a descriptor request for an unknown tool'() { + when: + FilesystemTools.descriptor('exists') + then: + def e = thrown(IllegalArgumentException) + e.message.contains('exists') + } + + def 'should state the sandbox boundary in every description'() { + expect: + FilesystemTools.descriptors(FilesystemTools.NAMES).every { + it.description.toLowerCase().contains('sandbox') || it.description.contains('work dir') + } + } + + def 'should not route a tool name that was not selected'() { + given: 'an agent that declared fs:read only' + def bridge = bridge(['read']) + sandbox() + when: 'the model calls a filesystem tool it was never given' + def result = call(bridge, 'grep', [pattern: 'x']) + then: 'it is an unknown tool, not a hijacked filesystem call' + result.error.contains('Unknown agent tool `grep`') + } + + // ========================================================================= + // no dispatch context + // ========================================================================= + + def 'should fail every fs tool without a sandbox context'() { + given: + def bridge = bridge() + expect: + call(bridge, tool, args).error.contains('no sandbox context') + where: + tool | args + 'read' | [path: 'a.txt'] + 'write' | [path: 'a.txt', content: 'x'] + 'edit' | [path: 'a.txt', old_string: 'a', new_string: 'b'] + 'ls' | [:] + 'grep' | [pattern: 'x'] + 'find' | [pattern: '*.txt'] + } + + // ========================================================================= + // read + // ========================================================================= + + def 'read should return the content of a text file'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('data.txt'), 'the content'.bytes) + sandbox() + when: + def result = call(bridge, 'read', [path: 'data.txt']) + then: + result.content == 'the content' + } + + def 'read should return a path handle for a non text-like file'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('reads.fastq'), 'ACGT'.bytes) + sandbox() + when: + def result = call(bridge, 'read', [path: 'reads.fastq']) + then: 'the opaque-path contract: chainable, but the bytes are not inlined - and never mislabelled as content' + result.content == null + result.error == null + result.path.endsWith('reads.fastq') + result.note.contains('not inlined') + } + + def 'read should not label a binary file as content'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('sneaky.txt'), ['a'.bytes, [0 as byte] as byte[], 'b'.bytes].flatten() as byte[]) + sandbox() + when: + def result = call(bridge, 'read', [path: 'sneaky.txt']) + then: + result.content == null + result.note.contains('binary') + } + + def 'read should honour the inline size cap'() { + given: + def bridge = bridge() + bridge.setMaxInlineBytes(8) + Files.write(workDir.resolve('big.txt'), ('x' * 100).bytes) + sandbox() + when: + def result = call(bridge, 'read', [path: 'big.txt']) + then: + result.note.contains('content not inlined') + } + + def 'read should reject a missing file and a directory'() { + given: + def bridge = bridge() + Files.createDirectories(workDir.resolve('sub')) + sandbox() + expect: + call(bridge, 'read', [path: 'nope.txt']).error.contains('file not found') + call(bridge, 'read', [path: 'sub']).error.contains('is a directory') + } + + def 'read should refuse a .. traversal out of the sandbox'() { + given: + def bridge = bridge() + outsideFile('secret.txt', 'topsecret') + sandbox() + when: + def result = call(bridge, 'read', [path: '../secret.txt']) + then: + result.error.contains('outside sandbox') + } + + def 'read should refuse a symlink whose target is outside the sandbox'() { + given: + def bridge = bridge() + escapingLink('link.txt', outsideFile('secret.txt', 'topsecret')) + sandbox() + when: + def result = call(bridge, 'read', [path: 'link.txt']) + then: + result.error.contains('outside sandbox') + result.content == null + } + + // ========================================================================= + // write + // ========================================================================= + + def 'write should create a file and its parent dirs'() { + given: + def bridge = bridge() + sandbox() + when: + def result = call(bridge, 'write', [path: 'nested/out.txt', content: 'hello world']) + then: + result.bytes == 11 + workDir.resolve('nested/out.txt').text == 'hello world' + } + + def 'write should refuse a .. traversal out of the sandbox'() { + given: + def bridge = bridge() + sandbox() + when: + def result = call(bridge, 'write', [path: '../evil.txt', content: 'bad']) + then: + result.error.contains('outside sandbox') + !Files.exists(workDir.getParent().resolve('evil.txt')) + } + + def 'write should refuse a symlink whose target is outside the sandbox'() { + given: + def bridge = bridge() + def target = outsideFile('victim.txt', 'original') + escapingLink('victim.txt', target) + sandbox() + when: + def result = call(bridge, 'write', [path: 'victim.txt', content: 'overwritten']) + then: + result.error.contains('outside sandbox') + target.text == 'original' + } + + def 'write should refuse a whitelisted read-only path'() { + given: 'a module output outside the work dir, readable but never writable' + def bridge = bridge() + def output = outsideFile('module-out.txt', 'produced') + def ctx = new DispatchContext(workDir) + ctx.addReadablePath(output) + ModuleToolBridge.setContext(ctx) + expect: + call(bridge, 'read', [path: output.toAbsolutePath().toString()]).content == 'produced' + call(bridge, 'write', [path: output.toAbsolutePath().toString(), content: 'x']).error.contains('outside sandbox') + output.text == 'produced' + } + + def 'write should refuse a missing content instead of truncating the file'() { + given: 'an existing file the model must not destroy by omitting an argument' + def bridge = bridge() + Files.write(workDir.resolve('keep.txt'), 'precious'.bytes) + sandbox() + + expect: 'a missing (or null) content is an error, exactly as edit treats new_string' + call(bridge, 'write', [path: 'keep.txt']).error.contains('missing required argument: content') + call(bridge, 'write', [path: 'keep.txt', content: null]).error.contains('missing required argument: content') + workDir.resolve('keep.txt').text == 'precious' + + and: 'an empty file is still writable -- it just has to be asked for' + call(bridge, 'write', [path: 'keep.txt', content: '']).bytes == 0 + workDir.resolve('keep.txt').text == '' + } + + /** + * The module-output whitelist is what makes {@code read} usable at all across a + * {@code nf:module_run} call: the produced FILE becomes readable, its siblings do not. + * Covered here because it is the {@code fs:} side of that contract. + */ + def 'read should honour the module-output whitelist, file by file'() { + given: + def bridge = bridge() + def output = outsideFile('result.txt', 'module output content') + def sibling = outsideFile('sibling-secret.txt', 'not an output') + def ctx = new DispatchContext(workDir) + ModuleToolBridge.setContext(ctx) + when: 'a successful module call reports the produced path' + ModuleToolBridge.whitelistOutputDirs([output: output.toAbsolutePath().toString()]) + then: 'the file is whitelisted, its parent dir is not' + ctx.readablePaths.contains(output) + !ctx.readablePaths.contains(output.getParent()) + and: + call(bridge, 'read', [path: output.toAbsolutePath().toString()]).content == 'module output content' + call(bridge, 'read', [path: sibling.toAbsolutePath().toString()]).error.contains('outside sandbox') + } + + // ========================================================================= + // edit + // ========================================================================= + + def 'edit should replace a unique occurrence'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('cfg.txt'), 'alpha\nbeta\ngamma\n'.bytes) + sandbox() + when: + def result = call(bridge, 'edit', [path: 'cfg.txt', old_string: 'beta', new_string: 'BETA']) + then: + result.replacements == 1 + workDir.resolve('cfg.txt').text == 'alpha\nBETA\ngamma\n' + } + + def 'edit should delete the matched text when new_string is empty'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('cfg.txt'), 'keep\ndrop\n'.bytes) + sandbox() + when: + def result = call(bridge, 'edit', [path: 'cfg.txt', old_string: 'drop\n', new_string: '']) + then: + result.replacements == 1 + workDir.resolve('cfg.txt').text == 'keep\n' + } + + def 'edit should refuse a non-unique old_string instead of editing the first match'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('dup.txt'), 'x = 1\ny = 2\nx = 1\n'.bytes) + sandbox() + when: + def result = call(bridge, 'edit', [path: 'dup.txt', old_string: 'x = 1', new_string: 'x = 9']) + then: 'the count is reported and BOTH ways out are named' + result.error.contains('occurs 2 times') + result.error.contains('replace_all') + and: 'the file is untouched - no silent first-match edit' + workDir.resolve('dup.txt').text == 'x = 1\ny = 2\nx = 1\n' + } + + def 'edit should replace every occurrence with replace_all'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('dup.txt'), 'x = 1\ny = 2\nx = 1\n'.bytes) + sandbox() + when: + def result = call(bridge, 'edit', [path: 'dup.txt', old_string: 'x = 1', new_string: 'x = 9', replace_all: true]) + then: + result.replacements == 2 + workDir.resolve('dup.txt').text == 'x = 9\ny = 2\nx = 9\n' + } + + def 'edit should report a missing match, an identical pair and a missing argument'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('cfg.txt'), 'alpha\n'.bytes) + sandbox() + expect: + call(bridge, 'edit', [path: 'cfg.txt', old_string: 'zeta', new_string: 'x']).error.contains('no match for old_string') + call(bridge, 'edit', [path: 'cfg.txt', old_string: 'alpha', new_string: 'alpha']).error.contains('identical') + call(bridge, 'edit', [path: 'cfg.txt', old_string: 'alpha']).error.contains('new_string') + call(bridge, 'edit', [path: 'cfg.txt', new_string: 'x']).error.contains('old_string') + and: 'a literal old_string is never read as a regular expression' + call(bridge, 'edit', [path: 'cfg.txt', old_string: 'a.p', new_string: 'x']).error.contains('no match for old_string') + } + + def 'edit should refuse a .. traversal and a symlink out of the sandbox'() { + given: + def bridge = bridge() + def target = outsideFile('victim.txt', 'original') + escapingLink('victim.txt', target) + sandbox() + expect: + call(bridge, 'edit', [path: '../victim.txt', old_string: 'original', new_string: 'hacked']).error.contains('outside sandbox') + call(bridge, 'edit', [path: 'victim.txt', old_string: 'original', new_string: 'hacked']).error.contains('outside sandbox') + target.text == 'original' + } + + // ========================================================================= + // ls + // ========================================================================= + + def 'ls should list the immediate entries with their type and size'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('a.txt'), 'abc'.bytes) + Files.createDirectories(workDir.resolve('sub')) + Files.write(workDir.resolve('sub/deep.txt'), 'nested'.bytes) + sandbox() + when: 'the path argument is omitted it defaults to the work dir' + def result = call(bridge, 'ls', [:]) + then: + result.entries.find { it.name=='a.txt' } == [name: 'a.txt', type: 'file', size: 3] + result.entries.find { it.name=='sub' } == [name: 'sub', type: 'dir'] + and: 'it is not recursive' + !result.entries.any { it.name=='deep.txt' } + } + + def 'ls should reject a missing dir and a file'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('a.txt'), 'abc'.bytes) + sandbox() + expect: + call(bridge, 'ls', [path: 'nope']).error.contains('directory not found') + call(bridge, 'ls', [path: 'a.txt']).error.contains('not a directory') + } + + def 'ls should refuse a .. traversal and a symlink out of the sandbox'() { + given: + def bridge = bridge() + outsideFile('secret.txt', 'topsecret') + Files.createSymbolicLink(workDir.resolve('linkdir'), outside) + sandbox() + expect: + call(bridge, 'ls', [path: '..']).error.contains('outside sandbox') + call(bridge, 'ls', [path: 'linkdir']).error.contains('outside sandbox') + } + + def 'ls should not report the type or size of an entry pointing outside the sandbox'() { + given: 'a symlink INSIDE the work dir whose target is a file outside it' + def bridge = bridge() + def secret = outsideFile('secret.txt', 'a very specific number of bytes') + escapingLink('innocent.txt', secret) + Files.write(workDir.resolve('own.txt'), 'abc'.bytes) + sandbox() + + when: 'the PARENT is listed -- `ls` on the link itself is already refused' + def result = call(bridge, 'ls', [:]) + def escaping = result.entries.find { it.name == 'innocent.txt' } + + then: 'the entry is reported, so the later read refusal is not inexplicable' + escaping != null + and: 'but neither its kind nor its exact size leaks, both of which stat THROUGH the link' + escaping == [name: 'innocent.txt', type: 'link'] + and: 'an entry genuinely inside the sandbox is unaffected' + result.entries.find { it.name == 'own.txt' } == [name: 'own.txt', type: 'file', size: 3] + } + + // ========================================================================= + // grep + // ========================================================================= + + def 'grep should find matching lines recursively with file and line number'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('a.txt'), 'nothing\nhello world\n'.bytes) + Files.createDirectories(workDir.resolve('sub')) + Files.write(workDir.resolve('sub/b.txt'), 'hello again\n'.bytes) + sandbox() + when: + def result = call(bridge, 'grep', [pattern: 'hello']) + then: + result.count == 2 + result.truncated == false + result.matches.find { it.text=='hello world' }.line == 2 + result.matches.find { it.text=='hello again' }.file.endsWith('sub/b.txt') + } + + def 'grep should accept a regular expression and a case_insensitive flag'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('a.txt'), 'Sample_01\nsample_02\nother\n'.bytes) + sandbox() + expect: + call(bridge, 'grep', [pattern: '^sample_\\d+$']).count == 1 + call(bridge, 'grep', [pattern: '^sample_\\d+$', case_insensitive: true]).count == 2 + call(bridge, 'grep', [pattern: '[unclosed']).error.contains('invalid regular expression') + } + + def 'grep should restrict the search with the include glob'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('a.txt'), 'needle\n'.bytes) + Files.write(workDir.resolve('b.log'), 'needle\n'.bytes) + sandbox() + expect: + call(bridge, 'grep', [pattern: 'needle']).count == 2 + call(bridge, 'grep', [pattern: 'needle', include: '*.log']).count == 1 + } + + def 'grep should cap the results and say so'() { + given: + def bridge = bridge() + Files.write(workDir.resolve('many.txt'), (1..20).collect { "match ${it}" }.join('\n').bytes) + sandbox() + when: + def result = call(bridge, 'grep', [pattern: 'match', max_results: 3]) + then: 'the cap travels back so the model knows it did not see everything' + result.count == 3 + result.truncated == true + result.limit == 3 + result.truncated_reason == FilesystemTools.TRUNCATED_RESULTS + when: 'the cap is left to the default' + def full = call(bridge, 'grep', [pattern: 'match']) + then: + full.count == 20 + full.truncated == false + full.limit == FilesystemTools.DEFAULT_MAX_RESULTS + and: 'a complete search never carries a reason to explain away' + full.truncated_reason == null + } + + /** + * {@code max_results} bounds the ANSWER; this bounds the WORK. The two are reported + * differently on purpose: "you saw the first N matches" and "the search stopped early" are + * opposite instructions — narrow the pattern versus narrow the root — and a model that cannot + * tell them apart reads an incomplete search as an exhaustive one that found nothing. + */ + def 'grep should bound the search itself and name that as the reason'() { + given: 'more candidate files than one call may read' + def bridge = bridge() + final over = FilesystemTools.MAX_GREP_FILES + 1 + for( int i=0; i enumValues = null) { + def it = new ModuleChannelItem().name(name).type(type).description(desc) + if( pattern ) it.pattern(pattern) + if( enumValues ) it._enum(enumValues) + return it + } + + private static ModuleChannel tuple(ModuleChannelItem... items) { + return new ModuleChannel().tuple(true).items(items.toList()) + } + + private static ModuleChannel scalar(ModuleChannelItem item) { + return new ModuleChannel().tuple(false).items([item]) + } + + def 'should flatten an nf-core tuple input with the meta.id convention'() { + given: + def metadata = new ModuleMetadata() + .description('Run FastQC on sequenced reads') + .input([ tuple( + item('meta', 'map', 'sample meta'), + item('reads', 'file', 'input reads', '*.{fastq,fq}.gz') ) ]) + + when: + def schema = ModuleMetadataToolSchema.inputSchema(metadata, true) + + then: + schema.type == 'object' + schema.additionalProperties == false + schema.required == ['meta', 'reads'] + and: + // meta.id convention -> nested object with an id string property + schema.properties.meta.type == 'object' + schema.properties.meta.properties.id.type == 'string' + schema.properties.meta.properties.id.description == 'sample identifier' + schema.properties.meta.additionalProperties == true + and: + // file -> string path, with the pattern appended to the description + schema.properties.reads.type == 'string' + schema.properties.reads.description == 'input reads (file path) (pattern: *.{fastq,fq}.gz)' + } + + def 'should NOT apply the meta.id convention for a non-nf-core module'() { + given: + def metadata = new ModuleMetadata() + .input([ scalar(item('meta', 'map', 'sample meta')) ]) + + when: + def schema = ModuleMetadataToolSchema.inputSchema(metadata, false) + + then: + // generic map -> open object, no nested id property + schema.properties.meta.type == 'object' + schema.properties.meta.additionalProperties == true + schema.properties.meta.description == 'sample meta' + !schema.properties.meta.containsKey('properties') + } + + def 'should map scalar / integer / number / boolean / enum input types'() { + given: + def metadata = new ModuleMetadata().input([ + scalar(item('label', 'string', 'a label')), + scalar(item('count', 'integer', 'a count')), + scalar(item('ratio', 'float', 'a ratio')), + scalar(item('flag', 'boolean', null)), + scalar(item('mode', 'string', 'a mode', null, ['fast', 'slow'])), + ]) + + when: + def schema = ModuleMetadataToolSchema.inputSchema(metadata, false) + + then: + schema.properties.label.type == 'string' + schema.properties.count.type == 'integer' + schema.properties.ratio.type == 'number' + schema.properties.flag.type == 'boolean' + and: + schema.properties.mode.type == 'string' + schema.properties.mode.enum == ['fast', 'slow'] + and: + schema.required == ['label', 'count', 'ratio', 'flag', 'mode'] + } + + def 'should throw on a duplicate flattened property name'() { + given: + def metadata = new ModuleMetadata().input([ + tuple(item('meta', 'map', null), item('reads', 'file', null)), + scalar(item('meta', 'map', null)), + ]) + + when: + ModuleMetadataToolSchema.inputSchema(metadata, true) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('duplicate input name `meta`') + } + + def 'should throw on an input item with no name'() { + given: + def metadata = new ModuleMetadata().input([ scalar(item(null, 'file', 'no name')) ]) + + when: + ModuleMetadataToolSchema.inputSchema(metadata, false) + + then: + thrown(IllegalArgumentException) + } + + def 'should build the description from module + tools + output shape'() { + given: + def metadata = new ModuleMetadata() + .description('Run FastQC on sequenced reads') + .tools([ new ModuleTool() + .name('fastqc') + .version('0.12.1') + .homepage(URI.create('https://www.bioinformatics.babraham.ac.uk/projects/fastqc/')) ]) + .output([ + report: tuple(item('meta', 'map', null), item('html', 'file', 'the report html')), + ]) + + when: + def text = ModuleMetadataToolSchema.description(metadata) + + then: + text.contains('Run FastQC on sequenced reads') + text.contains('fastqc') + text.contains('0.12.1') + text.contains('homepage: https://www.bioinformatics.babraham.ac.uk/projects/fastqc/') + and: + text.contains('`report`') + text.contains('`html`') + text.contains('the report html') + text.contains('a file path string') + text.contains('absolute path strings') + } + + def 'should report flattened input property names'() { + given: + def metadata = new ModuleMetadata().input([ + tuple(item('meta', 'map', null), item('reads', 'file', null)), + scalar(item('db', 'file', null)), + ]) + + expect: + ModuleMetadataToolSchema.inputPropertyNames(metadata) == ['meta', 'reads', 'db'] + } + + def 'should handle null metadata / empty inputs gracefully'() { + expect: + ModuleMetadataToolSchema.inputSchema(null, false).properties == [:] + ModuleMetadataToolSchema.inputPropertyNames(null) == [] + ModuleMetadataToolSchema.description(null).contains('module tool') + ModuleMetadataToolSchema.outputDescription(null).contains('no declared outputs') + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ModuleSpecToolSchemaTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleSpecToolSchemaTest.groovy new file mode 100644 index 0000000000..718674249d --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleSpecToolSchemaTest.groovy @@ -0,0 +1,196 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files + +import nextflow.module.ModuleSpec +import nextflow.module.ModuleSpec.ModuleParam +import nextflow.module.ModuleSpecFactory +import spock.lang.Specification + +/** + * Verifies that {@link ModuleSpecToolSchema} flattens a module {@code meta.yml} + * spec into a portable JSON-schema with per-component properties + descriptions, + * and renders a human-readable output description. + */ +class ModuleSpecToolSchemaTest extends Specification { + + private static ModuleParam param(String name, String type, String desc) { + return new ModuleParam(name: name, type: type, description: desc) + } + + private static ModuleParam tuple(ModuleParam... comps) { + return new ModuleParam(components: comps.toList()) + } + + def 'should flatten a tuple input channel into per-component properties with descriptions'() { + given: + def spec = new ModuleSpec( + name: 'fastqc', + description: 'Run FastQC', + inputs: [ tuple(param('meta', 'map', 'sample meta'), param('reads', 'file', 'input reads')) ], + outputs: [ + new ModuleParam(name: 'zip', components: [param('meta', 'map', null), param('zip', 'file', 'the zip')]), + ] + ) + + when: + def schema = ModuleSpecToolSchema.inputSchema(spec) + + then: + schema.type == 'object' + schema.additionalProperties == false + schema.required == ['meta', 'reads'] + and: + schema.properties.meta.type == 'object' + schema.properties.meta.additionalProperties == true + schema.properties.meta.description == 'sample meta' + // nf-core meta.id convention: meta input carries a nested `id` property + schema.properties.meta.properties.id.type == 'string' + schema.properties.meta.properties.id.description == 'sample identifier' + and: + schema.properties.reads.type == 'string' + schema.properties.reads.description == 'input reads (file path)' + } + + def 'should apply nf-core meta.id convention for map input named meta'() { + given: + def spec = new ModuleSpec( + name: 'fastqc', + inputs: [ tuple(param('meta', 'map', 'sample meta'), param('reads', 'file', 'input reads')) ] + ) + + when: + def schema = ModuleSpecToolSchema.inputSchema(spec) + + then: + schema.properties.meta.type == 'object' + schema.properties.meta.description == 'sample meta' + schema.properties.meta.properties.id.type == 'string' + schema.properties.meta.properties.id.description == 'sample identifier' + schema.properties.meta.additionalProperties == true + and: + schema.properties.reads.type == 'string' + } + + def 'should map scalar / integer / boolean input types leniently'() { + given: + def spec = new ModuleSpec( + name: 'mod', + inputs: [ + param('label', 'val', 'a label'), + param('count', 'integer', 'a count'), + param('flag', 'boolean', null), + param('weird', 'something-unknown', 'fallback'), + ] + ) + + when: + def schema = ModuleSpecToolSchema.inputSchema(spec) + + then: + schema.properties.label.type == 'string' + schema.properties.count.type == 'integer' + schema.properties.flag.type == 'boolean' + schema.properties.weird.type == 'string' + schema.required == ['label', 'count', 'flag', 'weird'] + } + + def 'should throw on duplicate flattened property names across channels'() { + given: + def spec = new ModuleSpec( + name: 'mod', + inputs: [ + tuple(param('meta', 'map', null), param('reads', 'file', null)), + param('meta', 'map', null), + ] + ) + + when: + ModuleSpecToolSchema.inputSchema(spec) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('duplicate input name `meta`') + } + + def 'should describe outputs as prose including file path contract'() { + given: + def spec = new ModuleSpec( + name: 'fastqc', + outputs: [ + new ModuleParam(name: 'report', components: [param('meta', 'map', null), param('outfile', 'file', 'the output')]), + ] + ) + + when: + def text = ModuleSpecToolSchema.outputDescription(spec) + + then: + text.contains('`report`') + text.contains('`outfile`') + text.contains('the output') + text.contains('a file path string') + text.contains('absolute path strings') + } + + def 'should derive the flattened schema from a fastqc-style meta.yml'() { + given: + def dir = Files.createTempDirectory('test') + def meta = dir.resolve('meta.yml') + meta.text = '''\ + name: fastqc + description: Run FastQC on sequenced reads + input: + - - meta: + type: map + description: sample meta + - reads: + type: file + description: input reads + output: + report: + - - meta: + type: map + description: sample meta + - outfile: + type: file + description: the output report + '''.stripIndent() + + when: + def spec = ModuleSpecFactory.fromYaml(meta) + def schema = ModuleSpecToolSchema.inputSchema(spec) + + then: + schema.properties.meta.type == 'object' + schema.properties.meta.description == 'sample meta' + schema.properties.reads.type == 'string' + schema.properties.reads.description == 'input reads (file path)' + schema.required == ['meta', 'reads'] + and: + // NOTE: ModuleSpecFactory.fromYaml drops the Map output channel key (`report`), + // so the channel-level emit name is not recovered from a Map-keyed meta.yml; the + // tuple component names (meta/outfile) are preserved. The bridge recovers the real + // emit name from the process ChannelOut at runtime. + def desc = ModuleSpecToolSchema.outputDescription(spec) + desc.contains('`meta`') + desc.contains('`outfile`') + desc.contains('the output report') + desc.contains('absolute path strings') + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeBindingTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeBindingTest.groovy new file mode 100644 index 0000000000..7a9efb7099 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeBindingTest.groovy @@ -0,0 +1,222 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import nextflow.Nextflow +import nextflow.Session +import nextflow.module.ModuleSpec +import nextflow.module.ModuleSpecFactory +import nextflow.script.ProcessDef +import nextflow.script.ProcessEntryHandler +import nextflow.script.ScriptMeta +import nextflow.script.parser.v2.ScriptLoaderV2 +import test.Dsl2Spec + +/** + * Asserts the agent tool bridge marshals the LLM's tool-call args into the module's input + * channel using the SAME logic as {@code nextflow module run} -- i.e. through + * {@link ProcessEntryHandler#getProcessArguments(ProcessDef, Map, ModuleSpec)} -- so the + * {@code meta} map, file coercion and tuple assembly are identical. + * + * For a classic-DSL2 {@code tuple val(meta), path(reads)} input (described by a sibling + * {@code meta.yml}), the flattened args {@code {meta:[id:'s1'], reads:'/abs/x'}} must build the + * channel value {@code [[id:'s1'], file('/abs/x')]} -- exactly what the bridge binds onto the + * pre-wired input queue. + */ +class ModuleToolBridgeBindingTest extends Dsl2Spec { + + /** Compile a classic-DSL2 (V1) module to its single ProcessDef, like AgentDef does. */ + private ProcessDef loadModuleProcess(Path modPath) { + final session = new Session() + final loader = new ScriptLoaderV2(session) + loader.setModule(true) + loader.parse(modPath) + loader.runScript() + final meta = ScriptMeta.get(loader.getScript()) + return meta.getProcess(meta.getProcessNames().first()) + } + + def 'should build the same channel value as module run for a tuple val(meta), path(reads) input'() { + given: + final dir = Files.createTempDirectory('test') + final modPath = dir.resolve('main.nf') + modPath.text = ''' + process echo_tool { + input: + tuple val(meta), path(reads) + + output: + tuple val(meta), path("out.txt"), emit: report + + script: + """ + cat ${reads} > out.txt + """ + } + '''.stripIndent() + + and: 'a sibling meta.yml describing the tuple I/O (map + file)' + final metaPath = dir.resolve('meta.yml') + metaPath.text = '''\ + name: echo_tool + description: Copy the input reads to an output file + input: + - - name: meta + type: map + description: sample meta + - name: reads + type: file + description: input reads + output: + - - name: meta + type: map + description: sample meta + - name: outfile + type: file + description: the output + '''.stripIndent() + + and: + final ProcessDef proc = loadModuleProcess(modPath) + final ModuleSpec spec = ModuleSpecFactory.fromYaml(metaPath) + + when: 'the bridge marshals the flattened LLM args via the module-run binding' + final args = ProcessEntryHandler.getProcessArguments(proc, [meta: [id: 's1'], reads: '/abs/x'], spec) + + then: 'one element per input channel: the tuple is assembled to [meta-map, file(reads)]' + args.size() == 1 + args[0] instanceof List + (args[0] as List).size() == 2 + (args[0] as List)[0] == [id: 's1'] + (args[0] as List)[1] == Nextflow.file('/abs/x') + (args[0] as List)[1] instanceof Path + } + + def 'should raise IllegalArgumentException for a missing required arg (LLM-recoverable contract)'() { + given: + final dir = Files.createTempDirectory('test') + final modPath = dir.resolve('main.nf') + modPath.text = ''' + process echo_tool { + input: + tuple val(meta), path(reads) + + output: + tuple val(meta), path("out.txt"), emit: report + + script: + """ + cat ${reads} > out.txt + """ + } + '''.stripIndent() + + and: + final metaPath = dir.resolve('meta.yml') + metaPath.text = '''\ + name: echo_tool + input: + - - name: meta + type: map + - name: reads + type: file + '''.stripIndent() + + and: + final ProcessDef proc = loadModuleProcess(modPath) + final ModuleSpec spec = ModuleSpecFactory.fromYaml(metaPath) + + when: 'a required `meta` arg is missing' + ProcessEntryHandler.getProcessArguments(proc, [reads: '/abs/x'], spec) + + then: 'the binding throws -- the bridge dispatcher turns this into a {"error":...} tool result' + thrown(IllegalArgumentException) + } + + def 'should omit an empty-valued path arg so the optional path input is bound as not provided'() { + given: + final dir = Files.createTempDirectory('test') + final modPath = dir.resolve('main.nf') + modPath.text = ''' + process echo_tool { + input: + tuple val(meta), path(reads) + path fasta + + output: + tuple val(meta), path("out.txt"), emit: report + + script: + """ + cat ${reads} > out.txt + """ + } + '''.stripIndent() + + and: 'a sibling meta.yml declaring a second, optional-by-convention path input' + final metaPath = dir.resolve('meta.yml') + metaPath.text = '''\ + name: echo_tool + input: + - - name: meta + type: map + - name: reads + type: file + - name: fasta + type: file + '''.stripIndent() + + and: + final ProcessDef proc = loadModuleProcess(modPath) + final ModuleSpec spec = ModuleSpecFactory.fromYaml(metaPath) + + when: 'the model sends "" for the path input it has nothing to supply for' + final args0 = ModuleToolBridge.dropEmptyPathArgs([meta: [id: 's1'], reads: '/abs/x', fasta: ''], spec) + + then: 'the empty path arg is OMITTED, never passed on as an empty value' + !args0.containsKey('fasta') + args0.reads == '/abs/x' + + when: + final args = ProcessEntryHandler.getProcessArguments(proc, args0, spec) + + then: 'the absent path input binds to an empty list (the "not provided" contract)' + args.size() == 2 + (args[0] as List)[1] == Nextflow.file('/abs/x') + args[1] == [] + } + + def 'should leave non-path args untouched when their value is empty'() { + given: + final dir = Files.createTempDirectory('test') + final metaPath = dir.resolve('meta.yml') + metaPath.text = '''\ + name: echo_tool + input: + - name: label + type: string + - name: fasta + type: file + '''.stripIndent() + final ModuleSpec spec = ModuleSpecFactory.fromYaml(metaPath) + + expect: 'only file/path args are dropped -- an empty string is a legit value for a val input' + ModuleToolBridge.dropEmptyPathArgs([label: '', fasta: ' '], spec) == [label: ''] + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeFilesystemTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeFilesystemTest.groovy new file mode 100644 index 0000000000..bb24ad3e62 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeFilesystemTest.groovy @@ -0,0 +1,375 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import spock.lang.Specification +import spock.lang.TempDir + +/** + * Unit tests for the {@code fs:} agent tools in {@link ModuleToolBridge}. + * + *

The tools are dispatched by their own wire names — {@code call('read', argsJson)}, + * {@code call('write', …)}, {@code call('ls', …)} — with no {@code operation} discriminator and + * no aggregate {@code filesystem} tool: the family is six separately-named tools the model + * selects between, so the tool name IS the operation. The bridge here is built with NO modules + * and the whole {@code fs:} family selected. + * + *

The old {@code exists} operation has no successor and its cases are gone: {@code ls} and + * {@code read} answer the same question, and a fourth way to ask it only cost the model a turn. + * + *

Scope note: the per-tool behaviour (argument handling, caps, the sandbox refusals for each + * of the six) lives in {@link FilesystemToolsTest}. What is unique here is the interaction with + * the MODULE side of the bridge — {@code whitelistOutputDirs} and its {@code isErrorResult} + * guard, which is what lets a module output be read back without widening the sandbox. + * + * @author Paolo Di Tommaso + */ +class ModuleToolBridgeFilesystemTest extends Specification { + + @TempDir Path workDir + + def cleanup() { + ModuleToolBridge.clearContext() + } + + private ModuleToolBridge fsOnlyBridge() { + // Build a bridge with NO modules and the whole `fs:` family selected + new ModuleToolBridge( + Collections.emptyList(), + FilesystemTools.NAMES // every fs: leaf + ) + } + + private static Map parseJson(String json) { + return (Map) new JsonSlurper().parseText(json) + } + + private static String argsJson(Map args) { + groovy.json.JsonOutput.toJson(args) + } + + // ------------------------------------------------------------------------- + // filesystemDescriptors() carries the six fs tools when the family is selected, + // and descriptors() -- the BROKERED half, i.e. `toolSpecs` -- never does (§5) + // ------------------------------------------------------------------------- + + def 'filesystem descriptors should carry the six fs tools under their own names when selected'() { + given: + def bridge = fsOnlyBridge() + expect: + (bridge.filesystemDescriptors()*.name as Set) == (['read','write','edit','ls','grep','find'] as Set) + and: 'never an aggregate tool the model would have to discriminate with an argument' + !bridge.filesystemDescriptors().any { it.name == 'filesystem' } + and: 'and they stay OUT of the brokered descriptors that become `toolSpecs`' + bridge.descriptors().isEmpty() + } + + def 'descriptors should carry no fs tool when none is selected'() { + given: + def bridge = new ModuleToolBridge( + Collections.emptyList(), + Collections.emptyList() + ) + expect: + bridge.descriptors().isEmpty() + bridge.filesystemDescriptors().isEmpty() + } + + // ------------------------------------------------------------------------- + // no context → error + // ------------------------------------------------------------------------- + + def 'an fs call without context returns error'() { + given: + def bridge = fsOnlyBridge() + // no context set + when: + def result = parseJson(bridge.call('read', argsJson([path: 'test.txt']))) + then: + result.error != null + result.error.contains('no sandbox context') + } + + // ------------------------------------------------------------------------- + // write + // ------------------------------------------------------------------------- + + def 'write creates a file in workDir'() { + given: + def bridge = fsOnlyBridge() + ModuleToolBridge.setContext(new DispatchContext(workDir)) + when: + def result = parseJson(bridge.call('write', argsJson([ + path: 'output.txt', + content: 'hello world' + ]))) + then: + result.error == null + result.bytes == 11 + workDir.resolve('output.txt').text == 'hello world' + } + + // ------------------------------------------------------------------------- + // read + // ------------------------------------------------------------------------- + + def 'read returns the content of a file in workDir'() { + given: + def bridge = fsOnlyBridge() + Files.write(workDir.resolve('data.txt'), 'the content'.bytes) + ModuleToolBridge.setContext(new DispatchContext(workDir)) + when: + def result = bridge.call('read', argsJson([path: 'data.txt'])) + then: + // readOrHandle returns inline string for .txt files; the JSON wraps it under 'content' + def parsed = parseJson(result) + parsed.content == 'the content' + } + + // ------------------------------------------------------------------------- + // ls + // ------------------------------------------------------------------------- + + def 'ls returns directory entries'() { + given: + def bridge = fsOnlyBridge() + Files.write(workDir.resolve('a.txt'), 'a'.bytes) + Files.write(workDir.resolve('b.txt'), 'b'.bytes) + ModuleToolBridge.setContext(new DispatchContext(workDir)) + when: + def result = parseJson(bridge.call('ls', argsJson([path: '.']))) + then: + result.entries instanceof List + (result.entries*.name as Set).containsAll(['a.txt', 'b.txt']) + } + + // ------------------------------------------------------------------------- + // sandbox enforcement + // ------------------------------------------------------------------------- + + def 'read of a path outside the sandbox returns error'() { + given: + def bridge = fsOnlyBridge() + ModuleToolBridge.setContext(new DispatchContext(workDir)) + when: + // Use a path that escapes the workDir via .. + def result = parseJson(bridge.call('read', argsJson([path: '../secret.txt']))) + then: + result.error != null + result.error.contains('outside sandbox') + } + + def 'write outside sandbox returns error'() { + given: + def bridge = fsOnlyBridge() + ModuleToolBridge.setContext(new DispatchContext(workDir)) + when: + def result = parseJson(bridge.call('write', argsJson([path: '../evil.txt', content: 'bad']))) + then: + result.error != null + result.error.contains('outside sandbox') + } + + // ------------------------------------------------------------------------- + // addReadablePath allows reads from module-output paths OUTSIDE workDir + // ------------------------------------------------------------------------- + + def 'read from a whitelisted readable dir outside workDir succeeds'() { + given: + def bridge = fsOnlyBridge() + // Use a SIBLING of workDir so module-out-sibling is NOT inside workDir; + // this exercises the whitelist branch (not the isInside(workDir) branch). + def tmp = workDir.getParent() + def sandboxDir = Files.createDirectories(tmp.resolve('sandbox-' + System.nanoTime())) + def outsideDir = Files.createDirectories(tmp.resolve('module-out-sibling-' + System.nanoTime())) + Files.write(outsideDir.resolve('result.txt'), 'module result'.bytes) + def ctx = new DispatchContext(sandboxDir) + ctx.addReadablePath(outsideDir) + ModuleToolBridge.setContext(ctx) + when: + // (a) read of a file inside the whitelisted outside dir SUCCEEDS + def result = parseJson(bridge.call('read', argsJson([ + path: outsideDir.resolve('result.txt').toAbsolutePath().toString() + ]))) + then: + result.content == 'module result' + } + + def 'read from a non-whitelisted outside dir returns error'() { + given: + def bridge = fsOnlyBridge() + // sandboxDir is the real workDir; outsideDir is a sibling NOT added to readablePaths + def tmp = workDir.getParent() + def sandboxDir = Files.createDirectories(tmp.resolve('sandbox2-' + System.nanoTime())) + def notWhitelisted = Files.createDirectories(tmp.resolve('not-whitelisted-' + System.nanoTime())) + Files.write(notWhitelisted.resolve('secret.txt'), 'secret'.bytes) + def ctx = new DispatchContext(sandboxDir) + // do NOT add notWhitelisted to ctx + ModuleToolBridge.setContext(ctx) + when: + // (b) read of a file in a DIFFERENT outside dir that is NOT whitelisted returns {"error":...} + def result = parseJson(bridge.call('read', argsJson([ + path: notWhitelisted.resolve('secret.txt').toAbsolutePath().toString() + ]))) + then: + result.error != null + result.error.contains('outside sandbox') + } + + // ------------------------------------------------------------------------- + // I2: whitelistOutputDirs auto-adds the produced file so an fs read succeeds + // ------------------------------------------------------------------------- + + /** + * Exercises the auto-whitelist path: calling {@link ModuleToolBridge#whitelistOutputDirs} + * with a parsed result Map that contains an absolute file path OUTSIDE the sandbox work dir + * must add THAT FILE — not its parent — to the context's readablePaths, so a subsequent + * {@code read} of that path succeeds while a SIBLING of the output stays rejected. + * Whitelisting the parent would grant every sibling too, and for an output landing outside + * the work tree that is a directory of content no cache key covers. + * + * Approach chosen: focused unit test calling the package-visible {@code whitelistOutputDirs} + * directly (rather than wiring a live process). This keeps the test lightweight and precisely + * exercises the auto-whitelisting logic without requiring a full Nextflow process harness. + * The {@code read} call through {@link ModuleToolBridge#call} then verifies that the sandbox + * guard correctly allows the whitelisted path and rejects the non-whitelisted one. + */ + def 'whitelistOutputDirs adds the produced file only, not its parent dir'() { + given: 'a sandbox work dir and a module output dir OUTSIDE the sandbox' + def bridge = fsOnlyBridge() + def tmp = workDir.getParent() + def sandboxDir = Files.createDirectories(tmp.resolve('wl-sandbox-' + System.nanoTime())) + def moduleOutDir = Files.createDirectories(tmp.resolve('wl-module-out-' + System.nanoTime())) + def nonWhitelistedDir = Files.createDirectories(tmp.resolve('wl-other-' + System.nanoTime())) + + // write the module output file outside the sandbox + def outputFile = moduleOutDir.resolve('result.txt') + Files.write(outputFile, 'module output content'.bytes) + // a SIBLING of the output, in the very same dir -- the file the old parent-dir + // whitelist would have exposed + def siblingFile = moduleOutDir.resolve('sibling-secret.txt') + Files.write(siblingFile, 'not an output'.bytes) + // write a file in the non-whitelisted sibling dir + Files.write(nonWhitelistedDir.resolve('secret.txt'), 'secret'.bytes) + + // set up a dispatch context with the sandbox dir + def ctx = new DispatchContext(sandboxDir) + ModuleToolBridge.setContext(ctx) + + when: 'whitelistOutputDirs is called with a parsed result map containing the output file path' + // simulate what call() does after a successful module-tool dispatch: the parsed result + // contains the absolute path of the output file + def resultMap = [output: outputFile.toAbsolutePath().toString()] + ModuleToolBridge.whitelistOutputDirs(resultMap) + + then: 'the produced FILE is whitelisted, and its parent dir is not' + ctx.readablePaths.contains(outputFile) + !ctx.readablePaths.contains(moduleOutDir) + + and: 'read of the output file SUCCEEDS (auto-whitelisted)' + def readResult = parseJson(bridge.call('read', argsJson([ + path: outputFile.toAbsolutePath().toString() + ]))) + readResult.content == 'module output content' + + and: 'a SIBLING of the output, in the same dir, is REJECTED' + def siblingResult = parseJson(bridge.call('read', argsJson([ + path: siblingFile.toAbsolutePath().toString() + ]))) + siblingResult.error != null + siblingResult.error.contains('outside sandbox') + + and: 'read of a file in a non-whitelisted sibling dir is REJECTED' + def rejectResult = parseJson(bridge.call('read', argsJson([ + path: nonWhitelistedDir.resolve('secret.txt').toAbsolutePath().toString() + ]))) + rejectResult.error != null + rejectResult.error.contains('outside sandbox') + } + + // ------------------------------------------------------------------------- + // I1: guard predicate isErrorResult detects error vs. non-error results + // ------------------------------------------------------------------------- + + /** + * Tests the extracted guard predicate {@link ModuleToolBridge#isErrorResult}. + * This predicate is used in {@link ModuleToolBridge#call} to skip + * {@code whitelistOutputDirs} when the result is an error, preventing absolute + * paths in error messages from widening the filesystem sandbox. + */ + def 'isErrorResult detects error-shaped results (Map with error key)'() { + given: 'parse an error result JSON containing an absolute path in the error message' + def errorJson = '{"error":"module failed at /etc/secret/data.txt"}' + def parsed = new JsonSlurper().parseText(errorJson) + + expect: 'isErrorResult returns true for error-shaped results' + ModuleToolBridge.isErrorResult(parsed) == true + + and: 'whitelistOutputDirs must be skipped for error results (guarded by isErrorResult)' + // this is what call() checks; the guard prevents whitelistOutputDirs + // from being called on error results, so paths in error messages stay out of the whitelist + } + + def 'isErrorResult returns false for non-error results'() { + given: 'parse a normal (non-error) result JSON with a file path' + def normalJson = '{"out":"/tmp/work/abc/result.fa"}' + def parsed = new JsonSlurper().parseText(normalJson) + + expect: 'isErrorResult returns false for non-error results' + ModuleToolBridge.isErrorResult(parsed) == false + + and: 'whitelistOutputDirs proceeds for non-error results (guarded by !isErrorResult)' + // this is what call() checks; when the result is NOT an error, + // whitelistOutputDirs is called to add output file paths to the whitelist + } + + def 'whitelistOutputDirs auto-adds the produced path of non-error results'() { + given: 'a sandbox work dir and a module output dir OUTSIDE the sandbox' + def bridge = fsOnlyBridge() + def tmp = workDir.getParent() + def sandboxDir = Files.createDirectories(tmp.resolve('wl-sandbox-' + System.nanoTime())) + def moduleOutDir = Files.createDirectories(tmp.resolve('wl-module-out-' + System.nanoTime())) + + // write the module output file outside the sandbox + def outputFile = moduleOutDir.resolve('result.txt') + Files.write(outputFile, 'module output content'.bytes) + + // set up a dispatch context with the sandbox dir + def ctx = new DispatchContext(sandboxDir) + ModuleToolBridge.setContext(ctx) + + when: 'a non-error result JSON containing the output file path is parsed and whitelisted' + def resultJson = JsonOutput.toJson([output: outputFile.toAbsolutePath().toString()]) + def resultParsed = new JsonSlurper().parseText(resultJson) + // guard check: the result is NOT an error, so whitelistOutputDirs IS called + if( !ModuleToolBridge.isErrorResult(resultParsed) ) + ModuleToolBridge.whitelistOutputDirs(resultParsed) + + then: 'the produced file is now in readablePaths' + ctx.readablePaths.contains(outputFile) + + and: 'read of the output file SUCCEEDS (auto-whitelisted)' + def readResult = parseJson(bridge.call('read', argsJson([ + path: outputFile.toAbsolutePath().toString() + ]))) + readResult.content == 'module output content' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeTaskFailureTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeTaskFailureTest.groovy new file mode 100644 index 0000000000..dcb742642f --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolBridgeTaskFailureTest.groovy @@ -0,0 +1,120 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import spock.lang.Timeout +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * Regression test for the task-failure cascade bug. + * + *

When a tool's underlying process task hard-fails (exit ≠ 0) the session aborts the dataflow + * network and interrupts the agent task thread blocked on its correlated reply variable. + * Before the fix, {@link ModuleToolBridge#call} swallowed that + * {@link InterruptedException} into a {@code {"error":...}} tool result; langchain4j fed it back to + * the model and the agent looped to {@code maxIterations}. The fix re-throws it as an + * {@link AgentToolFatalError} (an {@link Error}, NOT an {@link Exception}) so it escapes + * langchain4j's tool-execution {@code try/catch(Exception)} and aborts the run cleanly, while + * restoring the thread's interrupt flag. + * + *

The test runs a genuinely failing tool process inside a live session, exercising the + * request queue, dynamic process invocation, session abort and reply interruption end to end. + * + * @author Paolo Di Tommaso + */ +@Timeout(60) +class ModuleToolBridgeTaskFailureTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + Thread.interrupted() // clear any leaked interrupt flag on the test worker thread + } + + private static final String SCRIPT = ''' + nextflow.enable.types = true + + process greet { + input: + name: String + + output: + greeting: String + + exec: + throw new IllegalStateException('tool failed') + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run:greet' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''' + + def 'should abort when the request-scoped tool process fails'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + req.dispatch.call('greet', '{"name":"Ada"}') + } as AgentRunner + + when: + Throwable failure = null + try { + runScript(SCRIPT) + } + catch( Throwable e ) { + failure = e + } + + then: + failure != null + failure.message.contains('tool failed') || failure.message.contains('greet') + } + + def 'should still return a recoverable error result for a genuine dispatch-level failure (unknown tool)'() { + given: + String dispatchResult = null + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> + dispatchResult = req.dispatch.call('nope', '{}') + return 'done' + } as AgentRunner + + when: + runScript(SCRIPT) + + then: 'a dispatch-level error is recoverable: returned as a {"error":...} tool result, not thrown' + dispatchResult != null + new groovy.json.JsonSlurper().parseText(dispatchResult).error.contains('nope') + } + +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolDescriptorPinTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolDescriptorPinTest.groovy new file mode 100644 index 0000000000..0d2d7de638 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolDescriptorPinTest.groovy @@ -0,0 +1,326 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.json.JsonOutput +import io.seqera.npr.api.schema.v1.ModuleChannel +import io.seqera.npr.api.schema.v1.ModuleChannelItem +import io.seqera.npr.api.schema.v1.ModuleMetadata +import io.seqera.npr.api.schema.v1.ModuleTool +import nextflow.module.ModuleSpec +import nextflow.module.ModuleSpec.ModuleParam +import nextflow.script.AgentDef +import spock.lang.Specification + +/** + * This is a tripwire, not a unit test. + * + *

{@code AgentCacheKeyPinTest} pins {@code canonicalAgentSource}/{@code toolsFingerprint}, but + * both are PURE FUNCTIONS OF THEIR ARGUMENTS, so a pin built from hand-made {@link ToolDescriptor}s + * is green by construction no matter what happens to the code that PRODUCES those descriptors. + * This file closes that hole. The chain it guards is: + * + *

+ * ModuleSpecToolSchema.inputSchema / outputDescription  -> ModuleToolBridge.wireSpec
+ * ModuleMetadataToolSchema.inputSchema / description    -> ToolDescriptor(name, description, inputSchema)
+ *                                                      -> AgentDef.toolsFingerprint hashes
+ *                                                         d.description + canonicalJson(d.inputSchema)
+ *                                                      -> canonicalAgentSource `tools=` line
+ *                                                      -> BodyDef.source -> task hash -> -resume
+ * 
+ * + *

So the exact BYTES asserted below ARE the {@code -resume} cache key of every agent that wires a + * module tool. A change to any of them silently invalidates every affected user's stored runs: the + * pipeline still works, every other test still passes, and the only symptom is that resume re-runs + * everything. + * + *

One hop in that chain is re-spelled, not executed. {@code wireSpec} is an instance method + * that needs a {@link nextflow.script.ProcessDef} (it reads the declared input-channel count off it), + * which this file deliberately does not build -- the fixtures below are hand-made values, no session, + * no process, no registry client. So the two schema producers and {@code buildDescription} ARE the + * real code, reached directly or reflectively, but the {@code ToolDescriptor} they are assembled into + * is composed HERE, by {@link #specDescriptor} / {@link #metadataDescriptor}, mirroring + * {@code ModuleToolBridge.wireSpec}. A change to how {@code wireSpec} ITSELF composes the descriptor + * -- a different tool name, a non-null {@code outputSchema}, a swapped source branch -- moves the + * cache key and this file stays green. Keep the two in step by hand; if the descriptor ever becomes + * buildable without a process, execute it here instead. + * + *

The expected values were obtained by RUNNING the code, not by reasoning about what it ought to + * produce. They are asserted with {@code ==} against whole literals rather than {@code contains}, + * because the point is byte-identity — including the leading {@code 'Returns a JSON object with the + * following output(s):'}, the trailing {@code 'File/path outputs are returned as absolute path + * strings (never file contents).'}, the {@code '\n- `'} separators and the trailing space that an + * empty tuple leaves behind. + * + *

Do not "fix" a failure here by updating an expected value. A failure means a refactor + * moved the agent task hash. Either revert the change, or — if the change is intentional and + * accepted — treat it as a documented cache invalidation with a changelog entry, and say so. + * + *

Both input schemas are pinned twice: as {@code canonicalJson} (key-sorted — that is what + * {@code toolsFingerprint} actually hashes) AND as plain {@code JsonOutput.toJson} (insertion order + * — that is what the model is shown as the tool's {@code parameters}). The two envelopes and the + * two type ladders deliberately DISAGREE between the {@code meta.yml} source and the registry + * source (a {@code float} is {@code 'a string'} for one and {@code 'a number'} for the other; the + * nf-core {@code meta.id} convention is unconditional for one and gated for the other), so both are + * pinned in both variants. + * + * @author Paolo Di Tommaso + */ +class ModuleToolDescriptorPinTest extends Specification { + + // --- fixtures: hand-built values only, no session, no process, no registry client --------- + + static private ModuleParam p(String name, String type, String desc) { + return new ModuleParam(name: name, type: type, description: desc) + } + + static private ModuleParam tup(String name, ModuleParam... comps) { + return new ModuleParam(name: name, components: comps.toList()) + } + + /** + * A meta.yml-sourced module exercising every rung of the spec ladder: the unconditional + * {@code meta} map convention, a file, a path, a directory, a plain map, an integer, a boolean, + * a {@code val} and — the rung with no test of its own — a {@code float}, which this ladder + * renders as a string. The outputs add a named tuple, an output with a NULL name, a tuple with + * a NULL-named component and an EMPTY tuple (whose {@code 'an object with '} keeps a trailing + * space). + */ + static ModuleSpec richSpec() { + return new ModuleSpec( + name: 'RICH_MODULE', + description: 'Assemble and quality-check a sample', + inputs: [ + tup(null, p('meta', 'map', 'sample metadata'), p('reads', 'file', 'the reads')), + p('reference', 'path', 'the reference genome'), + p('threshold', 'float', 'the score threshold'), + p('rounds', 'integer', null), + p('strict', 'boolean', null), + p('outdir', 'directory', 'where to write'), + p('extras', 'map', 'free-form extras'), + p('label', 'val', null), + ], + outputs: [ + tup('assembly', p('meta', 'map', null), p('fasta', 'path', 'the assembly')), + p('score', 'float', 'the assembly score'), + p('report', 'path', null), + p(null, 'val', 'the unnamed one'), + tup('mixed', p(null, 'file', null), p('n', 'integer', 'a count')), + tup('empty'), + ]) + } + + /** A module with NO inputs and NO outputs: pins the empty properties/required envelope. */ + static ModuleSpec bareSpec() { + return new ModuleSpec(name: 'BARE_MODULE') + } + + static private ModuleChannelItem item(String name, String type, String desc, String pattern = null, List enumValues = null) { + final result = new ModuleChannelItem().name(name).type(type).description(desc) + if( pattern ) result.pattern(pattern) + if( enumValues ) result._enum(enumValues) + return result + } + + static private ModuleChannel chan(boolean tuple, ModuleChannelItem... items) { + return new ModuleChannel().tuple(tuple).items(items.toList()) + } + + /** + * A registry-sourced module exercising every rung of the metadata ladder: the gated nf-core + * {@code meta.id} convention, a file with a pattern, a path, an enum, an integer, a boolean, a + * plain map and a {@code float}, which THIS ladder renders as a number. A null channel and a + * null item pin the skips. The outputs add the {@code 'a value'} rung twice (a channel with no + * items list and one with an empty items list), a multi-item tuple, a {@code tuple:true} emit + * carrying a SINGLE item, a float item, a null-named item and a NULL emit name. + */ + static ModuleMetadata richMetadata() { + final out = new LinkedHashMap() + out.put('versions', new ModuleChannel()) + out.put('assembly', chan(true, item('meta', 'map', null), item('fasta', 'path', 'the assembly'))) + out.put('single', chan(true, item('bam', 'file', 'the alignment'))) + out.put('score', chan(false, item('score', 'float', 'the assembly score'))) + out.put('anon', chan(false, item(null, null, null))) + out.put('empty', new ModuleChannel().items([])) + out.put(null, chan(false, item('unnamed', 'string', 'the unnamed emit'))) + return new ModuleMetadata() + .description('Assemble and quality-check a sample') + .tools([ + new ModuleTool() + .name('spades') + .version('3.15.5') + .homepage(URI.create('https://example.org/spades')) + .documentation(URI.create('https://docs.example.org/spades')), + new ModuleTool().name('quast'), + new ModuleTool(), + ]) + .input([ + chan(true, item('meta', 'map', 'sample metadata'), item('reads', 'file', 'the reads', '*.{fastq,fq}.gz')), + chan(false, item('reference', 'path', 'the reference genome')), + chan(false, item('threshold', 'float', 'the score threshold')), + chan(false, item('rounds', 'integer', null)), + chan(false, item('mode', 'string', 'the mode', null, ['fast', 'slow'])), + chan(false, item('strict', 'boolean', null)), + chan(false, item('extras', 'map', 'free-form extras')), + null, + new ModuleChannel().items([null]), + ]) + .output(out) + } + + /** Registry metadata with NO inputs, NO outputs, NO tools and NO description. */ + static ModuleMetadata bareMetadata() { + return new ModuleMetadata() + } + + // --- the two producers, assembled the way ModuleToolBridge.wireSpec assembles them. The + // producers are the real code; the assembly is a mirror -- see the class javadoc. -------- + + /** + * {@code AgentDef.canonicalJson} is {@code protected static} and {@code ModuleToolBridge + * .buildDescription} is {@code private static}; both are reached reflectively so the pin runs + * the REAL code rather than a re-spelling of it. A {@code NoSuchMethodException} here is itself + * a finding: it means the descriptor-building path moved. + */ + static private String canonicalJson(Object obj) { + final m = AgentDef.getDeclaredMethod('canonicalJson', Object) + m.accessible = true + return (String) m.invoke(null, obj) + } + + static private String specDescription(ModuleSpec spec) { + final m = ModuleToolBridge.getDeclaredMethod('buildDescription', ModuleSpec) + m.accessible = true + return (String) m.invoke(null, spec) + } + + /** Mirrors the descriptor {@code wireSpec} builds when there is no registry metadata. */ + static ToolDescriptor specDescriptor(String name, ModuleSpec spec) { + return new ToolDescriptor(name, specDescription(spec), ModuleSpecToolSchema.inputSchema(spec), null) + } + + /** Mirrors the descriptor {@code wireSpec} builds when the registry metadata IS the source. */ + static ToolDescriptor metadataDescriptor(String name, ModuleMetadata metadata, boolean nfCore) { + return new ToolDescriptor( + name, + ModuleMetadataToolSchema.description(metadata), + ModuleMetadataToolSchema.inputSchema(metadata, nfCore), + null ) + } + + // --- the pins ---------------------------------------------------------------------------- + + /** + * An EMPTY tuple output renders as {@code 'an object with '} — {@code parts.join(', ')} over no + * components leaves the trailing space behind. It is spelled as its own constant so no editor + * or formatter can silently strip it out of the literal below. + */ + static private final String EMPTY_TUPLE_LINE = '- `empty`: an object with ' + + static private final String SPEC_OUTPUT_DESCRIPTION = '''\ +Returns a JSON object with the following output(s): +- `assembly`: an object with `meta` (an object), `fasta` (a file path string) (the assembly) +- `score`: `score` (a string) (the assembly score) +- `report`: `report` (a file path string) +- `result`: `value` (a string) (the unnamed one) +- `mixed`: an object with `value` (a file path string), `n` (an integer) (a count) +''' + EMPTY_TUPLE_LINE + ''' +File/path outputs are returned as absolute path strings (never file contents).''' + + static private final String META_OUTPUT_DESCRIPTION = '''\ +Returns a JSON object with the following output(s): +- `versions`: a value +- `assembly`: an object with `meta` (an object), `fasta` (a file path string) (the assembly) +- `single`: an object with `bam` (a file path string) (the alignment) +- `score`: `score` (a number) (the assembly score) +- `anon`: `value` (a string) +- `empty`: a value +- `result`: `unnamed` (a string) (the unnamed emit) +File/path outputs are returned as absolute path strings (never file contents).''' + + static private final String META_TOOL_PREAMBLE = '''\ +Assemble and quality-check a sample +Tool `spades` v3.15.5 (homepage: https://example.org/spades, documentation: https://docs.example.org/spades) +Tool `quast`''' + + def 'pins the meta.yml-sourced tool description, output prose included'() { + given: + def descriptor = specDescriptor('RICH_MODULE', richSpec()) + + expect: 'the output prose stands alone byte-for-byte' + ModuleSpecToolSchema.outputDescription(richSpec()) == SPEC_OUTPUT_DESCRIPTION + + and: 'and the descriptor is the module description, a blank line, then that prose' + descriptor.name == 'RICH_MODULE' + descriptor.description == 'Assemble and quality-check a sample\n\n' + SPEC_OUTPUT_DESCRIPTION + } + + def 'pins the meta.yml-sourced input schema'() { + given: + def descriptor = specDescriptor('RICH_MODULE', richSpec()) + + expect: 'key-sorted -- this is what toolsFingerprint hashes' + canonicalJson(descriptor.inputSchema) == '{"additionalProperties":false,"properties":{"extras":{"additionalProperties":true,"description":"free-form extras","type":"object"},"label":{"type":"string"},"meta":{"additionalProperties":true,"description":"sample metadata","properties":{"id":{"description":"sample identifier","type":"string"}},"type":"object"},"outdir":{"description":"where to write (file path)","type":"string"},"reads":{"description":"the reads (file path)","type":"string"},"reference":{"description":"the reference genome (file path)","type":"string"},"rounds":{"type":"integer"},"strict":{"type":"boolean"},"threshold":{"description":"the score threshold","type":"string"}},"required":["meta","reads","reference","threshold","rounds","strict","outdir","extras","label"],"type":"object"}' + + and: 'insertion order -- this is what the model is shown as the tool parameters' + JsonOutput.toJson(descriptor.inputSchema) == '{"type":"object","properties":{"meta":{"type":"object","description":"sample metadata","properties":{"id":{"type":"string","description":"sample identifier"}},"additionalProperties":true},"reads":{"type":"string","description":"the reads (file path)"},"reference":{"type":"string","description":"the reference genome (file path)"},"threshold":{"type":"string","description":"the score threshold"},"rounds":{"type":"integer"},"strict":{"type":"boolean"},"outdir":{"type":"string","description":"where to write (file path)"},"extras":{"type":"object","additionalProperties":true,"description":"free-form extras"},"label":{"type":"string"}},"required":["meta","reads","reference","threshold","rounds","strict","outdir","extras","label"],"additionalProperties":false}' + } + + def 'pins the registry-sourced tool description, tools and output prose included'() { + given: + def descriptor = metadataDescriptor('RICH_MODULE', richMetadata(), true) + + expect: 'the output prose stands alone byte-for-byte' + ModuleMetadataToolSchema.outputDescription(richMetadata()) == META_OUTPUT_DESCRIPTION + + and: 'and the descriptor is the module description, the tool lines, then that prose' + descriptor.name == 'RICH_MODULE' + descriptor.description == META_TOOL_PREAMBLE + '\n\n' + META_OUTPUT_DESCRIPTION + + and: 'the description does not depend on the nf-core flag' + metadataDescriptor('RICH_MODULE', richMetadata(), false).description == descriptor.description + } + + def 'pins the registry-sourced input schema, nf-core and not'() { + given: + def nfCore = metadataDescriptor('RICH_MODULE', richMetadata(), true) + def plain = metadataDescriptor('RICH_MODULE', richMetadata(), false) + + expect: 'nf-core: `meta` carries the id convention' + canonicalJson(nfCore.inputSchema) == '{"additionalProperties":false,"properties":{"extras":{"additionalProperties":true,"description":"free-form extras","type":"object"},"meta":{"additionalProperties":true,"description":"sample metadata","properties":{"id":{"description":"sample identifier","type":"string"}},"type":"object"},"mode":{"description":"the mode","enum":["fast","slow"],"type":"string"},"reads":{"description":"the reads (file path) (pattern: *.{fastq,fq}.gz)","type":"string"},"reference":{"description":"the reference genome (file path)","type":"string"},"rounds":{"type":"integer"},"strict":{"type":"boolean"},"threshold":{"description":"the score threshold","type":"number"}},"required":["meta","reads","reference","threshold","rounds","mode","strict","extras"],"type":"object"}' + JsonOutput.toJson(nfCore.inputSchema) == '{"type":"object","properties":{"meta":{"type":"object","description":"sample metadata","properties":{"id":{"type":"string","description":"sample identifier"}},"additionalProperties":true},"reads":{"type":"string","description":"the reads (file path) (pattern: *.{fastq,fq}.gz)"},"reference":{"type":"string","description":"the reference genome (file path)"},"threshold":{"type":"number","description":"the score threshold"},"rounds":{"type":"integer"},"mode":{"type":"string","description":"the mode","enum":["fast","slow"]},"strict":{"type":"boolean"},"extras":{"type":"object","description":"free-form extras","additionalProperties":true}},"required":["meta","reads","reference","threshold","rounds","mode","strict","extras"],"additionalProperties":false}' + + and: 'not nf-core: `meta` is a plain open object' + canonicalJson(plain.inputSchema) == '{"additionalProperties":false,"properties":{"extras":{"additionalProperties":true,"description":"free-form extras","type":"object"},"meta":{"additionalProperties":true,"description":"sample metadata","type":"object"},"mode":{"description":"the mode","enum":["fast","slow"],"type":"string"},"reads":{"description":"the reads (file path) (pattern: *.{fastq,fq}.gz)","type":"string"},"reference":{"description":"the reference genome (file path)","type":"string"},"rounds":{"type":"integer"},"strict":{"type":"boolean"},"threshold":{"description":"the score threshold","type":"number"}},"required":["meta","reads","reference","threshold","rounds","mode","strict","extras"],"type":"object"}' + JsonOutput.toJson(plain.inputSchema) == '{"type":"object","properties":{"meta":{"type":"object","description":"sample metadata","additionalProperties":true},"reads":{"type":"string","description":"the reads (file path) (pattern: *.{fastq,fq}.gz)"},"reference":{"type":"string","description":"the reference genome (file path)"},"threshold":{"type":"number","description":"the score threshold"},"rounds":{"type":"integer"},"mode":{"type":"string","description":"the mode","enum":["fast","slow"]},"strict":{"type":"boolean"},"extras":{"type":"object","description":"free-form extras","additionalProperties":true}},"required":["meta","reads","reference","threshold","rounds","mode","strict","extras"],"additionalProperties":false}' + } + + def 'pins the zero-input, zero-output envelope on both paths'() { + given: + def fromSpec = specDescriptor('BARE_MODULE', bareSpec()) + def fromMetadata = metadataDescriptor('BARE_MODULE', bareMetadata(), true) + + expect: 'an empty properties/required is EMITTED, never dropped' + canonicalJson(fromSpec.inputSchema) == '{"additionalProperties":false,"properties":{},"required":[],"type":"object"}' + JsonOutput.toJson(fromSpec.inputSchema) == '{"type":"object","properties":{},"required":[],"additionalProperties":false}' + canonicalJson(fromMetadata.inputSchema) == '{"additionalProperties":false,"properties":{},"required":[],"type":"object"}' + JsonOutput.toJson(fromMetadata.inputSchema) == '{"type":"object","properties":{},"required":[],"additionalProperties":false}' + + and: 'the meta.yml path falls back to the module name, the registry path to a literal' + fromSpec.description == 'BARE_MODULE\n\nReturns a JSON object (no declared outputs).' + fromMetadata.description == 'module tool\n\nReturns a JSON object (no declared outputs).' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolResolverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolResolverTest.groovy new file mode 100644 index 0000000000..e2fccabcfe --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ModuleToolResolverTest.groovy @@ -0,0 +1,130 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files + +import nextflow.module.ModuleInfo +import nextflow.module.ModuleReference +import spock.lang.Specification +import spock.lang.TempDir + +class ModuleToolResolverTest extends Specification { + + @TempDir + File tempDir + + // Helper: invoke the private static recoverModuleRef via Groovy metaprogramming + private static ModuleReference recoverModuleRef(java.nio.file.Path moduleDir) { + def m = ModuleToolResolver.getDeclaredMethod('recoverModuleRef', java.nio.file.Path) + m.accessible = true + // pass as explicit Object[] so null is not misinterpreted as (Object[]) null (zero-arg) + return (ModuleReference) m.invoke(null, new Object[]{moduleDir}) + } + + // ----------------------------------------------------------------------- + // recoverModuleRef unit tests (offline-safe — no network involved) + // ----------------------------------------------------------------------- + + def 'recoverModuleRef: registry install dir WITH marker returns correct ModuleReference'() { + given: 'a directory tree matching /modules// with .module-info marker' + def base = tempDir.toPath() + def moduleDir = base.resolve('modules').resolve('nf-core').resolve('skesa') + Files.createDirectories(moduleDir) + moduleDir.resolve(ModuleInfo.MODULE_INFO_FILE).text = 'checksum=abc123' + + when: + def ref = recoverModuleRef(moduleDir) + + then: + ref != null + ref.scope == 'nf-core' + ref.name == 'skesa' + ref.fullName == 'nf-core/skesa' + } + + def 'recoverModuleRef: dir WITHOUT marker returns null (local-file include)'() { + given: 'same layout but NO .module-info marker file' + def base = tempDir.toPath() + def moduleDir = base.resolve('modules').resolve('nf-core').resolve('fastqc') + Files.createDirectories(moduleDir) + // intentionally no .module-info + + when: + def ref = recoverModuleRef(moduleDir) + + then: + ref == null + } + + def 'recoverModuleRef: null input returns null'() { + expect: + recoverModuleRef(null) == null + } + + def 'recoverModuleRef: dir with marker but NOT under a "modules" grandparent returns null'() { + given: 'marker present but parent is named something other than "modules"' + def base = tempDir.toPath() + def moduleDir = base.resolve('notmodules').resolve('nf-core').resolve('skesa') + Files.createDirectories(moduleDir) + moduleDir.resolve(ModuleInfo.MODULE_INFO_FILE).text = 'checksum=abc' + + when: + def ref = recoverModuleRef(moduleDir) + + then: + ref == null + } + + def 'recoverModuleRef: dir with marker but only one parent level returns null'() { + given: 'marker present but dir has only one ancestor above it (no scope/name split possible)' + def base = tempDir.toPath() + // layout: /modules/skesa — no scope segment + def moduleDir = base.resolve('modules').resolve('skesa') + Files.createDirectories(moduleDir) + moduleDir.resolve(ModuleInfo.MODULE_INFO_FILE).text = 'checksum=abc' + + when: + // At this layout: moduleDir.parent.fileName = 'modules', moduleDir.parent.parent.fileName ≠ 'modules' + // So the check "modulesDir.fileName == 'modules'" fails because the grandparent of + // moduleDir is the base dir (temp dir), not 'modules'. Returns null. + def ref = recoverModuleRef(moduleDir) + + then: + // parent = modules dir (fileName='modules'), parent.parent = base (fileName != 'modules') + // scope = 'modules', modulesDir = base, base.fileName != 'modules' → null + ref == null + } + + def 'recoverModuleRef: multi-segment module name (e.g. nf-core/subworkflows/bam_sort_stats_samtools) returns reference'() { + given: 'a registry install with a multi-level name stored at scope/first_segment/rest' + // ModuleStorage stores /modules// where name can be 'bam_sort_stats_samtools' + // (nf-core uses flat single-segment names for modules; multi-segment only for subworkflows, + // stored as a single directory name). This test covers an arbitrary valid single-dir name. + def base = tempDir.toPath() + def moduleDir = base.resolve('modules').resolve('nf-core').resolve('bwa_mem') + Files.createDirectories(moduleDir) + moduleDir.resolve(ModuleInfo.MODULE_INFO_FILE).text = 'checksum=xyz' + + when: + def ref = recoverModuleRef(moduleDir) + + then: + ref != null + ref.scope == 'nf-core' + ref.name == 'bwa_mem' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ProcessToolSchemaTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ProcessToolSchemaTest.groovy new file mode 100644 index 0000000000..1f0f7e8a3a --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ProcessToolSchemaTest.groovy @@ -0,0 +1,180 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files + +import nextflow.Session +import nextflow.script.ProcessDef +import nextflow.script.ScriptMeta +import nextflow.script.parser.v2.ScriptLoaderV2 +import test.Dsl2Spec + +/** + * Verifies that {@link ProcessToolSchema} derives portable JSON-schema maps from + * a typed process's declared inputs/outputs, and fails loudly for kinds not yet + * supported as agent tools (tuple, path, ...). + * + * Schemas are obtained by loading a real Nextflow script so the actual compiled + * {@code ProcessConfigV2} / typed-I/O metadata is exercised. + */ +class ProcessToolSchemaTest extends Dsl2Spec { + + private ProcessDef loadProcess(String processDecl) { + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = """ + nextflow.enable.types = true + + ${processDecl} + + workflow { + } + """.stripIndent() + parser.parse(file) + parser.runScript() + return ScriptMeta.get(parser.script).getProcess('greet') + } + + def 'should derive the input schema from a scalar typed input'() { + given: + def proc = loadProcess(''' + process greet { + input: + name: String + + output: + result: String + + exec: + result = "Hello ${name}!" + } + ''') + + expect: + ProcessToolSchema.inputSchema(proc) == [ + type: 'object', + properties: [name: [type: 'string']], + required: ['name'], + additionalProperties: false, + ] + } + + def 'should mark an optional input as not required and map number/integer/boolean'() { + given: + def proc = loadProcess(''' + process greet { + input: + name: String + count: Integer? + score: Double + flag: Boolean + + output: + result: String + + exec: + result = "x" + } + ''') + + when: + def schema = ProcessToolSchema.inputSchema(proc) + + then: + schema.properties == [ + name : [type: 'string'], + count: [type: 'integer'], + score: [type: 'number'], + flag : [type: 'boolean'], + ] + schema.required == ['name', 'score', 'flag'] + schema.additionalProperties == false + } + + def 'should derive the output schema from a named scalar output'() { + given: + def proc = loadProcess(''' + process greet { + input: + name: String + + output: + answer: String + + exec: + answer = "Hello ${name}!" + } + ''') + + expect: + ProcessToolSchema.outputSchema(proc) == [ + type: 'object', + properties: [answer: [type: 'string']], + required: ['answer'], + additionalProperties: false, + ] + } + + def 'should throw a loud error for a tuple input'() { + given: + def proc = loadProcess(''' + process greet { + input: + tuple(id: String, num: Integer) + + output: + result: String + + exec: + result = "x" + } + ''') + + when: + ProcessToolSchema.inputSchema(proc) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('greet') + e.message.contains('not yet supported as an agent tool') + } + + def 'should throw a loud error for a path input'() { + given: + def proc = loadProcess(''' + process greet { + input: + sample: Path + + output: + result: String + + exec: + result = "x" + } + ''') + + when: + ProcessToolSchema.inputSchema(proc) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('sample') + e.message.contains('not yet supported as an agent tool') + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/RecordSchemaTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/RecordSchemaTest.groovy new file mode 100644 index 0000000000..91b700d0a0 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/RecordSchemaTest.groovy @@ -0,0 +1,122 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files + +import nextflow.Session +import nextflow.script.AgentDef +import nextflow.script.ScriptMeta +import nextflow.script.parser.v2.ScriptLoaderV2 +import test.Dsl2Spec + +/** + * Verifies that {@link RecordSchema#of} reflects a record output class into a + * portable JSON-schema map, rejecting unsupported field types. + * + * The record classes are obtained by loading a real Nextflow script (so the + * actual compiled record classes + {@code @Nullable} annotations are exercised), + * mirroring how an agent output type is resolved at runtime. + */ +class RecordSchemaTest extends Dsl2Spec { + + private Class loadOutputType(String recordDecls, String outputDecl) { + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = """ + nextflow.enable.types = true + + ${recordDecls} + + agent probe_agent { + input: + q: Question + + output: + ${outputDecl} + + prompt: + "hello" + } + + workflow { + } + """.stripIndent() + parser.parse(file) + parser.runScript() + def agent = ScriptMeta.get(parser.script).getDefinitions().find { it instanceof AgentDef } as AgentDef + return agent.outputs[0].type + } + + def 'should derive a JSON schema from a record output type'() { + given: + def cls = loadOutputType(''' + record Question { text: String } + record Answer { answer: String; confidence: Double } + ''', 'a: Answer') + + when: + def schema = RecordSchema.of(cls) + + then: + schema == [ + type: 'object', + properties: [ + answer : [type: 'string'], + confidence: [type: 'number'], + ], + required: ['answer', 'confidence'], + additionalProperties: false, + ] + } + + def 'should mark optional fields as not required'() { + given: + def cls = loadOutputType(''' + record Question { text: String } + record Answer { answer: String; note: String? } + ''', 'a: Answer') + + when: + def schema = RecordSchema.of(cls) + + then: + schema.properties.keySet() == ['answer', 'note'] as Set + schema.required == ['answer'] + + and: 'the portable layer keeps a plain scalar type and does NOT itself emit a nullable union' + // the union (type:['string','null']) is the downstream/plugin (langchain4j) responsibility; + // core only omits @Nullable fields from `required`. Guards against the portable layer + // starting to self-encode optionality (which would require the plan §6 contingency). + schema.properties.note == [type: 'string'] + } + + def 'should reject a Path output field'() { + given: + def cls = loadOutputType(''' + record Question { text: String } + record WithPath { p: Path } + ''', 'a: WithPath') + + when: + RecordSchema.of(cls) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('p') + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/SandboxGuardTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/SandboxGuardTest.groovy new file mode 100644 index 0000000000..77af6d4c0d --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/SandboxGuardTest.groovy @@ -0,0 +1,82 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path +import spock.lang.Specification +import spock.lang.TempDir + +class SandboxGuardTest extends Specification { + @TempDir Path tmp + + def 'should allow files inside the work dir for read and write'() { + given: + def work = Files.createDirectories(tmp.resolve('work')) + def f = work.resolve('out.txt') + expect: + SandboxGuard.isAllowed(f, work, [] as Set, true) + SandboxGuard.isAllowed(f, work, [] as Set, false) + } + + def 'should reject parent-traversal escape'() { + given: + def work = Files.createDirectories(tmp.resolve('work')) + def escape = work.resolve('../secret.txt') + expect: + !SandboxGuard.isAllowed(escape, work, [] as Set, false) + !SandboxGuard.isAllowed(escape, work, [] as Set, true) + } + + def 'should reject an absolute path outside all roots'() { + given: + def work = Files.createDirectories(tmp.resolve('work')) + def outside = Files.createDirectories(tmp.resolve('outside')).resolve('x.txt') + expect: + !SandboxGuard.isAllowed(outside, work, [] as Set, false) + } + + def 'should allow reads from a whitelisted module-output dir but not writes'() { + given: + def work = Files.createDirectories(tmp.resolve('work')) + def mod = Files.createDirectories(tmp.resolve('moduleout')) + def f = mod.resolve('result.fa') + expect: + SandboxGuard.isAllowed(f, work, [mod] as Set, false) + !SandboxGuard.isAllowed(f, work, [mod] as Set, true) + } + + def 'should reject a symlink whose target escapes the sandbox'() { + given: + def work = Files.createDirectories(tmp.resolve('work')) + def outside = Files.createDirectories(tmp.resolve('outside')) + def secret = Files.write(outside.resolve('secret.txt'), 'x'.bytes) + def link = Files.createSymbolicLink(work.resolve('link.txt'), secret) + expect: + !SandboxGuard.isAllowed(link, work, [] as Set, false) + } + + def 'should handle null elements in readablePaths without throwing'() { + given: + def work = Files.createDirectories(tmp.resolve('work')) + def mod = Files.createDirectories(tmp.resolve('moduleout')) + def f = mod.resolve('result.fa') + def readablePathsWithNull = Arrays.asList(null, mod) + expect: + SandboxGuard.isAllowed(f, work, readablePathsWithNull, false) + !SandboxGuard.isAllowed(f, work, readablePathsWithNull, true) + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/SkillResolverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/SkillResolverTest.groovy new file mode 100644 index 0000000000..5b87dcd387 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/SkillResolverTest.groovy @@ -0,0 +1,283 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.Path + +import nextflow.exception.ScriptRuntimeException +import spock.lang.Specification +import spock.lang.TempDir + +class SkillResolverTest extends Specification { + + @TempDir Path tmp + + // -- frontmatter -- + + def 'should parse valid SKILL.md frontmatter + body'() { + when: + def r = SkillResolver.parseFrontmatter("---\nname: greet\ndescription: a greeting skill\n---\nHello body\nline2\n") + then: + r.name == 'greet' + r.description == 'a greeting skill' + r.content == 'Hello body\nline2' + } + + def 'should tolerate BOM, CRLF and leading blank lines'() { + when: + def r = SkillResolver.parseFrontmatter("\r\n\r\n---\r\nname: x\r\ndescription: y\r\n---\r\nBODY\r\n") + then: + r.name == 'x' + r.description == 'y' + r.content == 'BODY' + } + + def 'should reject malformed frontmatter'() { + when: + SkillResolver.parseFrontmatter(text) + then: + thrown(ScriptRuntimeException) + where: + text << [ + 'no frontmatter at all', // missing opening --- + "---\nname: x\ndescription: y\nBODY", // unterminated (no closing ---) + "---\ndescription: y\n---\nbody", // missing name + "---\nname: x\n---\nbody", // missing description + "---\nname: x\ndescription: y\n---\n", // empty body + "---\nname: x\ndescription: y\n---\n \n", // whitespace-only body + ] + } + + // -- resource loading -- + + def 'should load bundled resources and skip SKILL.md'() { + given: + def dir = Files.createDirectories(tmp.resolve('skills/foo')) + Files.writeString(dir.resolve('SKILL.md'), "---\nname: foo\ndescription: d\n---\nbody") + Files.createDirectories(dir.resolve('references')) + Files.writeString(dir.resolve('references/a.txt'), 'AAA') + + when: + def d = SkillResolver.parseSkillDir(dir) + + then: + d.name == 'foo' + d.resources.size() == 1 + d.resources[0].relativePath == 'references/a.txt' + d.resources[0].content == 'AAA' + } + + def 'should skip .git files and symlinks when loading resources'() { + given: + def dir = Files.createDirectories(tmp.resolve('skills/foo')) + Files.writeString(dir.resolve('SKILL.md'), "---\nname: foo\ndescription: d\n---\nbody") + Files.createDirectories(dir.resolve('.git')) + Files.writeString(dir.resolve('.git/config'), 'secret') + def outside = Files.writeString(tmp.resolve('outside.txt'), 'OUT') + Files.createSymbolicLink(dir.resolve('link.txt'), outside) + + when: + def d = SkillResolver.parseSkillDir(dir) + + then: + d.resources.every { !it.relativePath.contains('.git') && it.relativePath != 'link.txt' } + } + + def 'should cap the number of resource files'() { + given: + def dir = Files.createDirectories(tmp.resolve('skills/foo')) + Files.writeString(dir.resolve('SKILL.md'), "---\nname: foo\ndescription: d\n---\nbody") + (1..70).each { Files.writeString(dir.resolve("r${it}.txt"), 'x') } + + when: + def d = SkillResolver.parseSkillDir(dir) + + then: + d.resources.size() <= 64 + } + + def 'should skip an oversized resource but keep the smaller ones (sorted so the big one is first)'() { + given: + def dir = Files.createDirectories(tmp.resolve('skills/foo')) + Files.writeString(dir.resolve('SKILL.md'), "---\nname: foo\ndescription: d\n---\nbody") + // names chosen so the oversized file sorts BEFORE the small one: proves skip (continue), not break + Files.write(dir.resolve('a-big.bin'), new byte[300 * 1024]) + Files.writeString(dir.resolve('b-small.txt'), 'keep me') + + when: + def d = SkillResolver.parseSkillDir(dir) + + then: + d.resources*.relativePath == ['b-small.txt'] + d.resources[0].content == 'keep me' + } + + // -- local resolution -- + + def 'should resolve a local skill by name'() { + given: + def base = Files.createDirectories(tmp.resolve('proj')) + def dir = Files.createDirectories(base.resolve('skills/greet')) + Files.writeString(dir.resolve('SKILL.md'), "---\nname: greet\ndescription: greets\n---\ninstructions") + + when: + def list = SkillResolver.loadLocal(base.resolve('skills'), 'greet') + + then: + list.size() == 1 + list[0].name == 'greet' + list[0].content == 'instructions' + } + + def 'should error when a local skill dir is missing'() { + given: + def base = Files.createDirectories(tmp.resolve('proj')) + when: + SkillResolver.loadLocal(base.resolve('skills'), 'nope') + then: + thrown(ScriptRuntimeException) + } + + def 'should error when a skill dir has no SKILL.md'() { + given: + def base = Files.createDirectories(tmp.resolve('proj')) + Files.createDirectories(base.resolve('skills/empty')) + when: + SkillResolver.loadLocal(base.resolve('skills'), 'empty') + then: + thrown(ScriptRuntimeException) + } + + // -- remote reference disambiguation -- + + def 'should recognize explicit github refs as remote and bare names/registry refs as local'() { + expect: + SkillResolver.isRemoteRef('https://github.com/org/repo') + SkillResolver.isRemoteRef('github.com/org/repo@v1') + SkillResolver.isRemoteRef('git@github.com:org/repo') + and: 'a bare name and a bare org/repo (registry-style) are NOT remote' + !SkillResolver.isRemoteRef('greet') + !SkillResolver.isRemoteRef('nf-core/fastqc') + !SkillResolver.isRemoteRef(null) + } + + def 'should parse a remote ref into clone url, repo and rev'() { + when: + def p = SkillResolver.parseRemoteRef('github.com/org/myrepo@abc123') + then: + p.url == 'https://github.com/org/myrepo.git' + p.repo == 'myrepo' + p.rev == 'abc123' + } + + // -- remote fetch (offline, via a file:// clone) -- + + def 'should fetch a remote skill via a file:// clone and reuse the cache'() { + given: 'a local git repo acting as the remote, containing a skill' + def remote = Files.createDirectories(tmp.resolve('remote')) + def git = org.eclipse.jgit.api.Git.init().setDirectory(remote.toFile()).call() + Files.writeString(remote.resolve('SKILL.md'), "---\nname: remoteskill\ndescription: from git\n---\nremote instructions") + git.add().addFilepattern('.').call() + git.commit().setMessage('init').setSign(false).setAuthor('t','t@t').setCommitter('t','t@t').call() + git.close() + def base = Files.createDirectories(tmp.resolve('proj')) + def url = "file://${remote.toAbsolutePath()}/.git".toString() + + when: + def list = SkillResolver.loadRemoteUrl(base.resolve('skills'), url, 'myrepo', null) + + then: + list.size() == 1 + list[0].name == 'remoteskill' + list[0].content == 'remote instructions' + and: 'the clone is cached under the reserved .remote segment, never beside the local skills' + Files.isDirectory(base.resolve('skills/.remote/myrepo')) + !Files.exists(base.resolve('skills/myrepo')) + + when: 'called again with a bogus url, the existing cache dir is reused (no re-clone)' + def list2 = SkillResolver.loadRemoteUrl(base.resolve('skills'), 'file:///does/not/exist.git', 'myrepo', null) + + then: + list2.size() == 1 + list2[0].name == 'remoteskill' + } + + // A module ships its hand-authored skills in the very same `/skills` directory that + // the remote clone cache lives under, so the clone cache MUST NOT be able to be satisfied by a + // same-named LOCAL skill dir -- otherwise the declared remote skill is silently never fetched + // and the model is instructed with the wrong content (with no warning, and folded into the + // resume fingerprint as if it were the remote skill). + def 'should not serve a remote skill from a same-named local skill directory'() { + given: 'a hand-authored local skill whose name equals the remote repo name' + def skillsRoot = Files.createDirectories(tmp.resolve('mod/skills')) + Files.createDirectories(skillsRoot.resolve('style')) + Files.writeString(skillsRoot.resolve('style/SKILL.md'), "---\nname: style\ndescription: local\n---\nLOCAL-CONTENT-NOT-FROM-GITHUB") + + when: 'the agent declares the skill as a remote ref (an unreachable LOCAL url, so the test is hermetic)' + SkillResolver.loadRemoteUrl(skillsRoot, 'file:///does/not/exist.git', 'style', null) + + then: 'the fetch is attempted and fails -- the local dir is NOT mistaken for the clone cache' + def e = thrown(ScriptRuntimeException) + e.message.contains('Unable to fetch remote skill') + and: 'the local skill is untouched and still resolvable by name' + SkillResolver.loadLocal(skillsRoot, 'style')[0].content == 'LOCAL-CONTENT-NOT-FROM-GITHUB' + } + + // The reserved `.remote` segment must not become a traversal foothold: `repoName` comes from the + // ref regex, which accepts `..`, so `/.remote/..` normalizes back to skillsRoot -- + // still CONTAINED in skillsRoot, so a containment-only guard passes it. The cache would then be + // seen as already populated and every hand-authored module skill would be served (and + // fingerprinted) as the remote skill's content, silently. + def 'should reject a traversing remote skill repo name instead of serving the module skills'() { + given: 'a module skills dir holding a hand-authored skill' + def skillsRoot = Files.createDirectories(tmp.resolve('mod/skills')) + Files.createDirectories(skillsRoot.resolve('style')) + Files.writeString(skillsRoot.resolve('style/SKILL.md'), "---\nname: style\ndescription: local\n---\nLOCAL-CONTENT") + + when: 'the repo name climbs out of the reserved cache dir' + SkillResolver.loadRemoteUrl(skillsRoot, 'file:///does/not/exist.git', '..', null) + + then: 'it is rejected outright -- NOT resolved to skillsRoot and served as a warm cache' + def e = thrown(ScriptRuntimeException) + e.message.contains('Invalid skills cache path') + and: 'the module skill was never scanned as remote content' + SkillResolver.loadLocal(skillsRoot, 'style')[0].content == 'LOCAL-CONTENT' + } + + def 'should sanitize a slash-bearing rev into a flat cache dir name (no path traversal/nesting)'() { + given: 'a remote repo with a branch whose name contains a slash' + def remote = Files.createDirectories(tmp.resolve('remote')) + def git = org.eclipse.jgit.api.Git.init().setDirectory(remote.toFile()).call() + Files.writeString(remote.resolve('SKILL.md'), "---\nname: branchskill\ndescription: from a branch\n---\nbranch instructions") + git.add().addFilepattern('.').call() + git.commit().setMessage('init').setSign(false).setAuthor('t','t@t').setCommitter('t','t@t').call() + git.branchCreate().setName('feature/x').call() + git.close() + def base = Files.createDirectories(tmp.resolve('proj')) + def url = "file://${remote.toAbsolutePath()}/.git".toString() + def skillsRoot = base.resolve('skills') + + when: + def list = SkillResolver.loadRemoteUrl(skillsRoot, url, 'repo', 'feature/x') + + then: 'the cache dir is a single flat segment with the slash sanitized' + list[0].name == 'branchskill' + Files.isDirectory(skillsRoot.resolve('.remote/repo@feature_x')) + and: 'no nested repo@feature/x path was created' + !Files.exists(skillsRoot.resolve('.remote/repo@feature')) + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ToolDescriptorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ToolDescriptorTest.groovy new file mode 100644 index 0000000000..adb1079ab5 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ToolDescriptorTest.groovy @@ -0,0 +1,59 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import spock.lang.Specification + +/** + * Verifies the langchain4j-free agent tool SPI: the {@link ToolDescriptor} DTO + * and the {@link ToolDispatcher} callback (coerced from a Groovy closure). + */ +class ToolDescriptorTest extends Specification { + + def 'should hold the descriptor fields'() { + given: + def inSchema = [type: 'object', properties: [name: [type: 'string']], required: ['name'], additionalProperties: false] + def outSchema = [type: 'object', properties: [result: [type: 'string']], required: ['result'], additionalProperties: false] + + when: + def desc = new ToolDescriptor('greet', 'Greet someone by name', inSchema, outSchema) + + then: + desc.name == 'greet' + desc.description == 'Greet someone by name' + desc.inputSchema == inSchema + desc.outputSchema == outSchema + } + + def 'should coerce a closure to a ToolDispatcher and invoke it'() { + given: + String seenName = null + String seenArgs = null + ToolDispatcher dispatch = { String toolName, String argsJson -> + seenName = toolName + seenArgs = argsJson + return '{"result":"ok"}' + } as ToolDispatcher + + when: + def out = dispatch.call('t', '{}') + + then: + out == '{"result":"ok"}' + seenName == 't' + seenArgs == '{}' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ToolNameValidationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ToolNameValidationTest.groovy new file mode 100644 index 0000000000..1c7071c775 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ToolNameValidationTest.groovy @@ -0,0 +1,472 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.exception.ScriptRuntimeException +import nextflow.script.AgentDef +import spock.lang.Timeout +import spock.lang.Unroll +import test.Dsl2Spec + +import static test.ScriptHelper.runScript + +/** + * The two checks that make the tool namespace safe rather than merely expressive (§4), plus the + * runner-native contribution to the resume key (§7). + * + *

The declaration grammar guarantees that a ref an author WRITES can always become a wire name + * — {@link ToolRef} admits only {@code [A-Za-z0-9_-]} in a segment. What it cannot guarantee is + * what a glob or a bare non-leaf DRAGS IN: {@code nf:module_run} enumerates every in-scope + * process, and a Nextflow process name is a {@code JavaLetter JavaLetterOrDigit*}, which admits + * {@code $}, non-ASCII letters and any length. Those are the names checked here. + * + *

The collision half covers the namespace the MODEL sees, which is wider than the declared one: + * the runner injects {@code activate_skill}/{@code read_skill_resource} for a skills agent and + * {@code final_answer} for a structured-output agent on the canonical runner, and the model cannot + * tell any of them apart from a tool. + * + * @author Paolo Di Tommaso + */ +@Timeout(60) +class ToolNameValidationTest extends Dsl2Spec { + + def cleanup() { + AgentRunnerProvider.testRunner = null + } + + /** The selection exactly as {@code AgentDef} builds it: the grammar over the in-scope processes. */ + private static ToolRefResolver.Selection select(List processNames, List declared) { + return ToolRefResolver.standard('Agent `assistant`', processNames).resolve(declared) + } + + /** + * The §4 pass over one selection. The named options are the state of the AGENT that decides + * which tools the runner injects beside the declared ones. + */ + private static void check(Map opts = [:], ToolRefResolver.Selection selection) { + AgentDef.checkWireNames('assistant', selection, + opts.skills as boolean, opts.structuredOutput as boolean, opts.containerized as boolean) + } + + // ----------------------------------------------------------------------- + // §4 — validate, never sanitize + // ----------------------------------------------------------------------- + + def 'should reject a selected process whose name the wire namespace cannot carry'() { + given: 'a process name that is legal Nextflow and illegal as an OpenAI function name' + def selection = select(['my$proc'], ['nf:module_run']) + + when: + check(selection) + + then: + def e = thrown(ScriptRuntimeException) + and: 'the process is named, and so is the character that made it illegal' + e.message.contains('my$proc') + e.message.contains('the illegal character(s) `$`') + and: 'the rule the name has to satisfy is quoted' + e.message.contains('[a-zA-Z0-9_-]') + and: 'the ref it was dragged in by is named, so the author knows which entry to narrow' + e.message.contains('nf:module_run:my$proc') + and: 'a rename is refused, not performed' + e.message.contains('never rewritten automatically') + } + + def 'should never silently rename an illegal process into an existing one'() { + given: 'the collision a sanitizing implementation would create: my$proc -> my_proc' + def selection = select(['my_proc', 'my$proc'], ['nf:module_run']) + + when: + check(selection) + + then: 'the illegal name is an error; it is never merged into its legal neighbour' + def e = thrown(ScriptRuntimeException) + e.message.contains('my$proc') + and: 'and the selection is NOT quietly reduced to the legal one' + selection.brokeredNames == ['my_proc', 'my$proc'] + } + + def 'should reject a selected process whose name exceeds the wire length limit'() { + when: + check(select(['P' * 65], ['nf:module_run:*'])) + + then: 'the limit is quoted together with the length that broke it' + def e = thrown(ScriptRuntimeException) + e.message.contains('65 characters (the limit is 64)') + + when: 'one character shorter' + check(select(['P' * 64], ['nf:module_run:*'])) + + then: 'the boundary is inclusive' + noExceptionThrown() + } + + @Unroll + def 'should reject the illegal wire name #NAME'() { + when: + check(select([NAME], ['nf:module_run'])) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains(NAME) + e.message.contains(OFFENDER) + + where: + NAME | OFFENDER + 'my$proc' | '`$`' + 'Σ_SORT' | '`Σ`' + 'a.b' | '`.`' + 'a b' | '` `' + 'naïve$sum' | '`ï`' // every offending character is listed, not just the first + } + + @Unroll + def 'should accept the legal wire name #NAME'() { + when: + check(select([NAME], ['nf:module_run'])) + + then: + noExceptionThrown() + + where: + NAME << ['GREET', 'greet', 'SAMTOOLS_SORT', 'a-b', 'x9', '_leading', 'P' * 64] + } + + def 'should validate the runner-native names too, not only the processes'() { + when: 'the fixed fs:/shell: leaves go through the same check' + check(select([], ['fs:*'])) + + then: 'they are legal by construction — this is the regression guard on that claim' + noExceptionThrown() + } + + // ----------------------------------------------------------------------- + // §4 — one wire name, one source + // ----------------------------------------------------------------------- + + def 'should reject a process colliding with a runner-native tool, naming both sources'() { + when: + check(select(['read', 'greet'], ['nf:module_run:read', 'fs:read'])) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('`read`') + and: 'BOTH sources are named — the message says which two things claim the name' + e.message.contains('`fs:read`') + e.message.contains('`nf:module_run:read`') + and: 'the reason the flat namespace forbids it' + e.message.contains('single flat namespace') + } + + def 'should report a collision identically whatever order the refs were declared in'() { + when: + check(select(['read'], ['nf:module_run:read', 'fs:read'])) + then: + def first = thrown(ScriptRuntimeException) + + when: 'the very same directive, written the other way round' + check(select(['read'], ['fs:read', 'nf:module_run:read'])) + then: + def second = thrown(ScriptRuntimeException) + + and: 'byte-identical — the sources are sorted, never listed in declaration order' + first.message == second.message + and: 'sorted, so `fs:read` precedes `nf:module_run:read`' + first.message.indexOf('`fs:read`') < first.message.indexOf('`nf:module_run:read`') + } + + def 'should not report a collision when a glob and an explicit ref select the same tool'() { + when: 'G9: an identical (wire name, source) pair collapses silently' + check(select(['GREET'], ['nf:module_run', 'nf:module_run:GREET'])) + then: + noExceptionThrown() + + when: + check(select([], ['fs:*', 'fs:read'])) + then: + noExceptionThrown() + } + + def 'should reject a process colliding with the skills tools the runner injects'() { + given: + def selection = select(['activate_skill', 'greet'], ['nf:module_run']) + + when: 'the agent declares no skills, so the runner injects nothing' + check(selection) + then: 'the name is unremarkable — it is a tool like any other' + noExceptionThrown() + + when: 'the same agent declares skills' + check(selection, skills: true) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('`activate_skill`') + e.message.contains('`nf:module_run:activate_skill`') + e.message.contains('the `skills` directive') + } + + def 'should reject a process colliding with read_skill_resource'() { + when: + check(select(['read_skill_resource'], ['nf:module_run']), skills: true) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('`read_skill_resource`') + e.message.contains('the `skills` directive') + } + + def 'should reject a process named final_answer on the canonical runner'() { + given: 'the tool the canonical runner injects to terminate a structured-output agent' + def selection = select(['final_answer'], ['nf:module_run']) + + when: + check(selection, structuredOutput: true, containerized: true) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('`final_answer`') + e.message.contains('`nf:module_run:final_answer`') + e.message.contains('the agent output declaration') + + when: 'the same agent with a free-text output declares no such tool' + check(selection, containerized: true) + then: + noExceptionThrown() + + when: 'and the in-JVM runner decodes the structured answer without injecting a tool' + check(selection, structuredOutput: true) + then: 'so the name is free there — this is a per-runner namespace, like shell:bash' + noExceptionThrown() + } + + def 'should tolerate the injected names when nothing else claims them'() { + when: 'the two skills tools share a source, so they never collide with each other' + check(select(['greet'], ['nf:module_run']), skills: true, structuredOutput: true, containerized: true) + + then: + noExceptionThrown() + } + + def 'should check the injected names even for an agent with no tools at all'() { + when: + check(null, skills: true, structuredOutput: true, containerized: true) + + then: + noExceptionThrown() + } + + // ----------------------------------------------------------------------- + // §4 — wired into the real agent-build path, through the DSL + // ----------------------------------------------------------------------- + + def 'a process colliding with an fs: tool fails the agent at build time'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'never reached' } as AgentRunner + + when: + runScript(''' + nextflow.enable.types = true + + process read { + input: + name: String + + output: + text: String + + exec: + text = "read ${name}" + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run:read', 'fs:read' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + ''') + + then: + def e = thrown(Exception) + def msg = messages(e) + msg.contains('the tool name `read` is claimed by') + msg.contains('`fs:read`') + msg.contains('`nf:module_run:read`') + } + + def 'a process whose name is illegal on the wire fails the agent at build time'() { + given: + AgentRunnerProvider.testRunner = { AgentRunnerRequest req -> 'never reached' } as AgentRunner + and: 'a process name that is legal Nextflow but 65 characters long' + def long65 = 'P' * 65 + + when: 'it is dragged in by a bare non-leaf ref rather than named explicitly' + runScript(''' + nextflow.enable.types = true + + process LONG_NAME { + input: + name: String + + output: + text: String + + exec: + text = name + } + + agent assistant { + model 'm' + instruction 'i' + tools 'nf:module_run' + + input: + request: String + + output: + answer: String + + prompt: + """ + ${request} + """ + } + + workflow { + assistant(channel.of('hi')).view { it } + } + '''.replace('LONG_NAME', long65)) + + then: + def e = thrown(Exception) + def msg = messages(e) + msg.contains('65 characters (the limit is 64)') + msg.contains('never rewritten automatically') + } + + // ----------------------------------------------------------------------- + // §7 — the runner-native contribution to the resume key + // ----------------------------------------------------------------------- + + def 'a runner-native tool must change the cache key, having no descriptor to hash'() { + when: 'an agent whose only tools are runner-native still fingerprints' + def withFs = AgentDef.toolsFingerprint(null, null, ['fs:read'], 'pi@1.0.0') + + then: 'the §7 hole: on the descriptor-only form this would have been null' + withFs != null + and: 'and it is NOT the key of the same agent with no tools' + withFs != AgentDef.toolsFingerprint(null, null, null, 'pi@1.0.0') + } + + def 'two agents differing only in their native tools get different keys'() { + given: + def runner = 'pi@1.0.0' + + expect: 'gaining a tool re-runs the agent rather than replaying a stale generation' + AgentDef.toolsFingerprint(null, null, ['fs:read'], runner) != + AgentDef.toolsFingerprint(null, null, ['fs:read', 'fs:write'], runner) + and: 'a different tool is a different key' + AgentDef.toolsFingerprint(null, null, ['fs:read'], runner) != + AgentDef.toolsFingerprint(null, null, ['fs:write'], runner) + and: 'declaring shell:bash is visible in the key' + AgentDef.toolsFingerprint(null, null, ['fs:read'], runner) != + AgentDef.toolsFingerprint(null, null, ['fs:read', 'shell:bash'], runner) + and: 'the same set is the same key, whatever order it was resolved in' + AgentDef.toolsFingerprint(null, null, ['fs:read', 'fs:write'], runner) == + AgentDef.toolsFingerprint(null, null, ['fs:write', 'fs:read'], runner) + } + + def 'the same agent gets a different key on a different runner'() { + expect: 'the same fs:read is a different implementation on each runner' + AgentDef.toolsFingerprint(null, null, ['fs:read'], 'pi@1.0.0') != + AgentDef.toolsFingerprint(null, null, ['fs:read'], 'langchain4j@1.0.0') + + and: 'and a runner UPGRADE invalidates it too — the version pins the image, which pins the SDK' + AgentDef.toolsFingerprint(null, null, ['fs:read'], 'pi@1.0.0') != + AgentDef.toolsFingerprint(null, null, ['fs:read'], 'pi@1.1.0') + + and: 'an unchanged runner keeps the key stable' + AgentDef.toolsFingerprint(null, null, ['fs:read'], 'pi@1.0.0') == + AgentDef.toolsFingerprint(null, null, ['fs:read'], 'pi@1.0.0') + } + + def 'the runner identity is folded in only when a native tool needs it'() { + given: 'a brokered-only agent — its tools are Nextflow tasks, identical on every runner' + def brokered = [new ToolDescriptor('greet', 'say hi', [type: 'object'], null)] + + expect: 'the runner argument does not move a brokered-only key' + AgentDef.toolsFingerprint(brokered, [greet: 'body'], null, 'pi@1.0.0') == + AgentDef.toolsFingerprint(brokered, [greet: 'body'], null, 'langchain4j@9.9.9') + and: 'which is exactly the 2-arg form, byte for byte — no existing agent`s key moves' + AgentDef.toolsFingerprint(brokered, [greet: 'body'], null, null) == + AgentDef.toolsFingerprint(brokered, [greet: 'body']) + and: 'a tool-free agent still fingerprints to null, keeping the canonical source unchanged' + AgentDef.toolsFingerprint(null, null, null, 'pi@1.0.0') == null + AgentDef.toolsFingerprint([] as List, [:], [] as List, 'pi@1.0.0') == null + } + + def 'a mixed agent keys on both halves'() { + given: + def brokered = [new ToolDescriptor('greet', 'say hi', [type: 'object'], null)] + def sources = [greet: 'body'] + def mixed = AgentDef.toolsFingerprint(brokered, sources, ['fs:read'], 'pi@1.0.0') + + expect: 'neither half alone is the same key' + mixed != AgentDef.toolsFingerprint(brokered, sources, null, 'pi@1.0.0') + mixed != AgentDef.toolsFingerprint(null, null, ['fs:read'], 'pi@1.0.0') + and: 'and editing the brokered process still invalidates it' + mixed != AgentDef.toolsFingerprint(brokered, [greet: 'edited'], ['fs:read'], 'pi@1.0.0') + } + + def 'the runner identity degrades to the bare name when no plugin owns the runner'() { + given: 'the AgentRunnerProvider test seam injects a runner with no plugin behind it' + def runner = { AgentRunnerRequest req -> 'ok' } as AgentRunner + + when: + def id = AgentDef.runnerIdentity(runner) + + then: 'a missing version is never made up, and never throws' + id == runner.getName() + and: 'it is deterministic within an installation, which is all the cache key needs' + id == AgentDef.runnerIdentity(runner) + and: + AgentDef.runnerIdentity(null) == null + } + + private static String messages(Throwable e) { + final sb = new StringBuilder() + Throwable t = e + while( t != null ) { + sb.append(t.message ?: '').append('\n') + t = t.cause === t ? null : t.cause + } + return sb.toString() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ToolOutputReaderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ToolOutputReaderTest.groovy new file mode 100644 index 0000000000..813f81d9e8 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ToolOutputReaderTest.groovy @@ -0,0 +1,145 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.nio.file.Files +import java.nio.file.FileSystem +import java.nio.file.Path +import java.nio.file.spi.FileSystemProvider + +import spock.lang.Specification + +/** + * + * @author Paolo Di Tommaso + */ +class ToolOutputReaderTest extends Specification { + + def 'should inline a small structured json file content'() { + given: + final dir = Files.createTempDirectory('test') + final file = dir.resolve('stats.json') + file.text = '{"n50":54321,"contigs":12}' + + when: + final result = ToolOutputReader.readOrHandle(file, 1024 * 1024) + + then: + result instanceof String + result == '{"n50":54321,"contigs":12}' + } + + def 'should return a path+note map when the file exceeds the cap'() { + given: + final dir = Files.createTempDirectory('test') + final file = dir.resolve('big.json') + file.text = '{"n50":54321,"contigs":12}' + + when: + final result = ToolOutputReader.readOrHandle(file, 8) + + then: + result instanceof Map + (result as Map).path == file.toAbsolutePath().toString() + ((result as Map).note as String).contains('not inlined') + } + + def 'should return the path for a non text-like extension'() { + given: + final dir = Files.createTempDirectory('test') + final file = dir.resolve('contigs.fa') + file.text = '>contig1\nACGT' + + when: + final result = ToolOutputReader.readOrHandle(file, 1024 * 1024) + + then: + result instanceof String + result == file.toAbsolutePath().toString() + } + + def 'should preserve the scheme for a remote path handle'() { + given: + final provider = Mock(FileSystemProvider) + final fs = Mock(FileSystem) + final file = Mock(Path) + file.getFileName() >> Path.of('contigs.fa') + file.getFileSystem() >> fs + fs.provider() >> provider + provider.getScheme() >> 'mock' + file.toAbsolutePath() >> file + file.toUri() >> URI.create('mock://bucket/work/contigs.fa') + + when: + final result = ToolOutputReader.readOrHandle(file, 1024 * 1024) + + then: + result == 'mock://bucket/work/contigs.fa' + } + + def 'should return the path when the file looks binary'() { + given: + final dir = Files.createTempDirectory('test') + final file = dir.resolve('data.txt') + Files.write(file, [0x68, 0x00, 0x69] as byte[]) + + when: + final result = ToolOutputReader.readOrHandle(file, 1024 * 1024) + + then: + result instanceof String + result == file.toAbsolutePath().toString() + } + + def 'should return the path for a file with no extension'() { + given: + final dir = Files.createTempDirectory('test') + final file = dir.resolve('README') + file.text = 'some readme content' + + when: + final result = ToolOutputReader.readOrHandle(file, 1024 * 1024) + + then: + result instanceof String + result == file.toAbsolutePath().toString() + } + + def 'should inline a small tsv file content'() { + given: + final dir = Files.createTempDirectory('test') + final file = dir.resolve('report.tsv') + file.text = 'name\tvalue\nn50\t54321' + + when: + final result = ToolOutputReader.readOrHandle(file, 1024 * 1024) + + then: + result instanceof String + result == 'name\tvalue\nn50\t54321' + } + + def 'should extract the lowercased extension'() { + expect: + ToolOutputReader.extensionOf(Path.of(NAME)) == EXT + where: + NAME | EXT + 'x.JSON' | 'json' + 'a.b.csv' | 'csv' + 'README' | '' + 'a.b/c' | '' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ToolRefResolverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ToolRefResolverTest.groovy new file mode 100644 index 0000000000..6cddf4462a --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ToolRefResolverTest.groovy @@ -0,0 +1,238 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.agent.ToolRefResolver.ToolKind +import nextflow.exception.ScriptRuntimeException +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Resolution tests: what a well-formed ref actually selects, given the members available. + * The resolver is a pure function of (declared refs, available members), so every case below + * runs without a session, a script or a runner. + * + * @author Paolo Di Tommaso + */ +class ToolRefResolverTest extends Specification { + + static final List PROCESSES = ['GREET', 'SAMTOOLS_SORT', 'SAMTOOLS_INDEX'] + + private static ToolRefResolver resolver(List processes = PROCESSES, String shellUnavailable = null) { + return ToolRefResolver.standard('Agent `qc`', processes, shellUnavailable) + } + + // ------------------------------------------------------------------ acceptance + + @Unroll + def 'should expand #DECLARED to #NAMES'() { + when: + def selection = resolver().resolve(DECLARED) + then: + selection.tools.collect { it.name } == NAMES + selection.tools.collect { it.ref } == REFS + + where: + DECLARED | NAMES | REFS + // -- G3: a non-leaf denotes its entire subtree, so `nf:module_run` is `nf:module_run:*` + ['nf:module_run'] | ['GREET', 'SAMTOOLS_SORT', 'SAMTOOLS_INDEX'] | ['nf:module_run:GREET', 'nf:module_run:SAMTOOLS_SORT', 'nf:module_run:SAMTOOLS_INDEX'] + ['nf:module_run:*'] | ['GREET', 'SAMTOOLS_SORT', 'SAMTOOLS_INDEX'] | ['nf:module_run:GREET', 'nf:module_run:SAMTOOLS_SORT', 'nf:module_run:SAMTOOLS_INDEX'] + // today `nf:*` and `nf:module_run` coincide because module_run is the family's only + // member -- an inventory coincidence, not an alias + ['nf:*'] | ['GREET', 'SAMTOOLS_SORT', 'SAMTOOLS_INDEX'] | ['nf:module_run:GREET', 'nf:module_run:SAMTOOLS_SORT', 'nf:module_run:SAMTOOLS_INDEX'] + ['nf:module_run:GREET'] | ['GREET'] | ['nf:module_run:GREET'] + // -- G4: a trailing glob, matched case-sensitively + ['nf:module_run:SAMTOOLS_*'] | ['SAMTOOLS_SORT', 'SAMTOOLS_INDEX'] | ['nf:module_run:SAMTOOLS_SORT', 'nf:module_run:SAMTOOLS_INDEX'] + // -- G5: the family root anchors a glob over release-fixed membership + ['fs:*'] | ['read', 'write', 'edit', 'ls', 'grep', 'find'] | ['fs:read', 'fs:write', 'fs:edit', 'fs:ls', 'fs:grep', 'fs:find'] + ['fs:read'] | ['read'] | ['fs:read'] + ['fs:read', 'fs:write'] | ['read', 'write'] | ['fs:read', 'fs:write'] + ['shell:bash'] | ['bash'] | ['shell:bash'] + ['shell:*'] | ['bash'] | ['shell:bash'] + // -- a mixed directive: brokered first because `nf` is the first family of the inventory + ['fs:*', 'nf:module_run:GREET'] | ['GREET', 'read', 'write', 'edit', 'ls', 'grep', 'find'] | ['nf:module_run:GREET', 'fs:read', 'fs:write', 'fs:edit', 'fs:ls', 'fs:grep', 'fs:find'] + } + + def 'should carry the runner split on each resolved tool'() { + when: + def selection = resolver().resolve(['nf:module_run:GREET', 'fs:read', 'shell:bash']) + then: + selection.tools.collect { it.kind } == [ToolKind.BROKERED, ToolKind.NATIVE, ToolKind.NATIVE] + and: 'the brokered half is the process names the driver wires as tasks' + selection.brokeredNames == ['GREET'] + and: 'the native half is bare wire names for the runner, plus the refs the cache key needs' + selection.nativeNames == ['read', 'bash'] + selection.nativeRefs == ['fs:read', 'shell:bash'] + } + + def 'should resolve an interpolated entry by its value'() { + given: + def proc = 'GREET' + when: + def selection = resolver().resolve(["nf:module_run:${proc}"]) + then: + selection.tools.collect { it.name } == ['GREET'] + } + + def 'should return an empty selection when nothing is declared'() { + expect: + resolver().resolve(DECLARED).isEmpty() + where: + DECLARED << [null, [], [] as Set] + } + + // ------------------------------------------------------------------ G9 union + + @Unroll + def 'should collapse the overlapping refs #DECLARED'() { + when: + def selection = resolver().resolve(DECLARED) + then: + selection.tools.collect { it.ref } == REFS + + where: + DECLARED | REFS + ['fs:*', 'fs:read'] | ['fs:read', 'fs:write', 'fs:edit', 'fs:ls', 'fs:grep', 'fs:find'] + ['fs:read', 'fs:read'] | ['fs:read'] + ['nf:module_run', 'nf:module_run:GREET'] | ['nf:module_run:GREET', 'nf:module_run:SAMTOOLS_SORT', 'nf:module_run:SAMTOOLS_INDEX'] + ['nf:*', 'nf:module_run:SAMTOOLS_*'] | ['nf:module_run:GREET', 'nf:module_run:SAMTOOLS_SORT', 'nf:module_run:SAMTOOLS_INDEX'] + } + + @Unroll + def 'should resolve the same set whatever the entry order'() { + expect: + resolver().resolve(A).tools == resolver().resolve(B).tools + + where: + A | B + ['fs:read', 'fs:write'] | ['fs:write', 'fs:read'] + ['fs:*', 'nf:module_run'] | ['nf:module_run', 'fs:*'] + ['nf:module_run:GREET', 'fs:*', 'shell:bash'] | ['shell:bash', 'fs:*', 'nf:module_run:GREET'] + ['fs:*', 'fs:read'] | ['fs:read', 'fs:*'] + } + + // ------------------------------------------------------------------ G8 zero-match + + def 'should reject a malformed ref naming the agent'() { + when: 'G8(a)' + resolver().resolve(['greet']) + then: + def e = thrown(ScriptRuntimeException) + e.message.startsWith('Agent `qc`: ') + e.message.contains('Invalid tool reference `greet`') + e.message.contains('must be namespaced') + } + + def 'should reject an unknown family'() { + when: 'G8(b)' + resolver().resolve(['mcp:github:get_issue']) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('unknown family `mcp`') + e.message.contains('known families are `nf`, `fs`, `shell`') + } + + def 'should reject an explicit leaf that does not exist'() { + when: 'G8(c)' + resolver().resolve(['nf:module_run:MISSING']) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('Tool `nf:module_run:MISSING` does not exist') + e.message.contains('`nf:module_run:GREET`') + } + + def 'should reject an explicit leaf whose case does not match'() { + when: 'G2 matching is case-sensitive, so this is a G8(c)' + resolver().resolve(['nf:module_run:greet']) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('Tool `nf:module_run:greet` does not exist') + } + + @Unroll + def 'should reject the glob #REF that matches nothing'() { + when: 'G8(d)' + resolver().resolve([REF]) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains("Tool pattern `${REF}` matches no tool") + e.message.contains('case-sensitive') + + where: + REF << [ + 'nf:module_run:ZZZ_*', + // shape-legal, but matches none of the six fs leaves + 'fs:RE*AD', + // the process segment keeps process-name case + 'nf:module_run:samtools_*' ] + } + + @Unroll + def 'should reject #REF over an empty subtree'() { + given: 'a script with no processes in scope' + def resolver = resolver([]) + when: 'G8(e)' + resolver.resolve([REF]) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains("Tool `${REF}` selects nothing") + e.message.contains('include') + + where: + REF << ['nf:module_run', 'nf:*', 'nf:module_run:*'] + } + + def 'should keep the five zero-match failures distinguishable'() { + given: + def r = resolver() + when: + r.resolve([BAD]) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains(EXPECTED) + + where: + BAD | EXPECTED + 'greet' | 'Invalid tool reference' + 'mcp:x' | 'unknown family' + 'nf:module_run:MISSING' | 'does not exist' + 'nf:module_run:ZZZ_*' | 'matches no tool' + 'fs:read:deeper' | 'does not exist' + } + + // ------------------------------------------------------------------ runner constraint + + def 'should reject the shell family when the runner cannot serve it'() { + given: + def r = resolver(PROCESSES, 'the `pi` runner is required') + when: + r.resolve([REF]) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains("Tool `${REF}` is not available") + e.message.contains('the `pi` runner is required') + + where: + REF << ['shell:bash', 'shell:*'] + } + + def 'should still serve the other families when shell is unavailable'() { + when: + def selection = resolver(PROCESSES, 'nope').resolve(['fs:read', 'nf:module_run:GREET']) + then: + selection.tools.collect { it.ref } == ['nf:module_run:GREET', 'fs:read'] + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/ToolRefTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/ToolRefTest.groovy new file mode 100644 index 0000000000..d65cc76747 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/ToolRefTest.groovy @@ -0,0 +1,135 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.exception.ScriptRuntimeException +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Grammar-level tests: the SHAPE of a single tool ref, independent of which families or tools + * happen to exist. Whether a well-formed ref actually selects anything is {@link ToolRefResolver}'s + * business and is covered by {@code ToolRefResolverTest}. + * + * @author Paolo Di Tommaso + */ +class ToolRefTest extends Specification { + + @Unroll + def 'should accept the well-formed ref #REF'() { + when: + def ref = ToolRef.parse(REF) + then: + ref.ref == REF + ref.segments == SEGMENTS + ref.family == SEGMENTS[0] + ref.globbed == GLOBBED + + where: + REF | SEGMENTS | GLOBBED + 'nf:module_run' | ['nf', 'module_run'] | false + 'nf:module_run:SAMTOOLS_SORT' | ['nf', 'module_run', 'SAMTOOLS_SORT'] | false + 'nf:module_run:SAMTOOLS_*' | ['nf', 'module_run', 'SAMTOOLS_*'] | true + 'nf:module_run:*' | ['nf', 'module_run', '*'] | true + 'nf:*' | ['nf', '*'] | true + 'fs:*' | ['fs', '*'] | true + 'fs:read' | ['fs', 'read'] | false + 'shell:bash' | ['shell', 'bash'] | false + // shape-legal even though it can only ever match nothing (that is a G8(d) failure, + // raised by the resolver, not a syntax error) + 'fs:RE*AD' | ['fs', 'RE*AD'] | true + // a family that does not exist is still a well-formed ref; G8(b) rejects it later + 'mcp:github:get_issue' | ['mcp', 'github', 'get_issue'] | false + } + + @Unroll + def 'should reject #REF because #WHY'() { + when: + ToolRef.parse(REF) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains("Invalid tool reference `${REF}`") + e.message.contains(REASON) + + where: + REF | WHY | REASON + // -- G1: no fallthrough, the four legacy non-capability forms are all errors + 'greet' | 'a bare process name is not namespaced' | 'must be namespaced' + './mod.nf' | 'a local module path is not namespaced' | 'must be namespaced' + '/abs/path/mod.nf' | 'an absolute path is not namespaced' | 'must be namespaced' + 'nf-core/skesa' | 'a registry ref is not namespaced' | 'must be namespaced' + 'module_run' | 'the old capability string is bare' | 'must be namespaced' + 'filesystem' | 'the old capability string is bare' | 'must be namespaced' + // -- G5: a glob with no family means "every tool that exists" + '*' | 'the glob is unanchored' | 'must be anchored to a tool family' + 'SAMTOOLS_*' | 'the glob is unanchored' | 'must be anchored to a tool family' + // -- G4: globs are trailing only + 'nf*:module_run' | 'the family carries a glob' | '`nf*` cannot contain a glob' + 'nf:*:SAMTOOLS_SORT' | 'an intermediate segment carries a glob' | '`*` cannot contain a glob' + // -- G6: there is no exclude operator + '!fs:bash' | 'negation is not supported' | 'exclusions are not supported' + '!nf:module_run' | 'negation is not supported' | 'exclusions are not supported' + // -- G2: at least two segments, none empty, never trimmed + 'nf::read' | 'the middle segment is empty' | 'segment 2 is empty' + ':read' | 'the family is empty' | 'segment 1 is empty' + 'fs:' | 'the last segment is empty' | 'segment 2 is empty' + 'nf' | 'a single segment is not a ref' | 'must be namespaced' + 'fs:read ' | 'the entry is never trimmed' | 'is not a legal segment' + ' fs:read' | 'the entry is never trimmed' | 'is not a legal segment' + 'fs:re ad' | 'a segment cannot contain a space' | 'is not a legal segment' + '~/Projects/nf:dev' | 'a path is not a legal family' | 'is not a legal segment' + 'nf:module.run' | 'a dot is not a legal segment character' | 'is not a legal segment' + } + + def 'should reject an empty entry'() { + when: + ToolRef.parse(VALUE) + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('cannot be empty') + + where: + VALUE << [null, ''] + } + + @Unroll + def 'should match #PATTERN against #NAME case-sensitively'() { + expect: + ToolRef.matches(PATTERN, NAME) == EXPECTED + + where: + PATTERN | NAME | EXPECTED + 'read' | 'read' | true + 'read' | 'Read' | false + '*' | 'read' | true + '*' | '' | true + 'SAMTOOLS_*' | 'SAMTOOLS_SORT' | true + 'SAMTOOLS_*' | 'SAMTOOLS' | false + 'SAMTOOLS_*' | 'BWA_MEM' | false + // G2: matching is case-sensitive in every segment, globs included + 'samtools_*' | 'SAMTOOLS_SORT' | false + 'RE*AD' | 'read' | false + 'RE*AD' | 'REAAD' | true + 'RE*AD' | 'REALLY_BAD' | true + // the glob is not a substring search: the pattern must span the WHOLE name + 'RE*AD' | 'REALLY_BADLY' | false + 'RE*AD' | 'THREAD' | false + 're*d' | 'read' | true + '*_SORT' | 'SAMTOOLS_SORT' | true + // `-` is a legal segment character and must not be read as a regex range + 'a-*' | 'a-b' | true + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/rpc/AgentRpcHostResolverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/rpc/AgentRpcHostResolverTest.groovy new file mode 100644 index 0000000000..0cc817d9ee --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/rpc/AgentRpcHostResolverTest.groovy @@ -0,0 +1,406 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.agent.rpc + +import nextflow.SysEnv +import nextflow.container.AppleContainerConfig +import nextflow.container.ApptainerConfig +import nextflow.container.CharliecloudConfig +import nextflow.container.ContainerConfig +import nextflow.container.DockerConfig +import nextflow.container.PodmanConfig +import nextflow.container.SarusConfig +import nextflow.container.ShifterConfig +import nextflow.container.SingularityConfig +import nextflow.container.SmolVmConfig +import nextflow.executor.AbstractGridExecutor +import nextflow.executor.Executor +import spock.lang.Specification + +/** + * Every row of the host-resolution ladder, and every error row, driven through the injected probes + * so no test opens a socket, reads {@code /proc}, or needs a container, a cluster or a cloud + * instance. The probes are the ONLY way the resolver observes its host, which is what makes this + * possible -- a row that could only be reached by really being on that host would not be testable. + * + * @author Paolo Di Tommaso + */ +class AgentRpcHostResolverTest extends Specification { + + /** + * A recording stand-in for the host facts. Every answer is settable and every call is counted, + * so a test can both place the driver in a situation and assert the resolver did not ask twice. + */ + static class TestProbes implements Probes { + String outbound = '10.0.3.17' + List interfaces = ['10.0.3.17'] + + final Map calls = [:].withDefault { 0 } + + private def count(String name) { calls[name] = calls[name] + 1 } + + @Override String outboundAddress() { count('outboundAddress'); outbound } + @Override List interfaceAddresses() { count('interfaceAddresses'); interfaces } + } + + def setup() { + // AgentRpcConfig reads the environment in its constructor, and NXF_AGENT_RPC_REMOTE_HOST + // exported in the developer's shell would silently answer R2 for every row below + SysEnv.push([:]) + } + + def cleanup() { + SysEnv.pop() + AgentRpcHostResolver.reset() + } + + private AgentRpcHostResolver resolver(TestProbes probes, Map sessionConfig = [:]) { + return new AgentRpcHostResolver(sessionConfig, probes) + } + + private static AgentRpcConfig rpc(Map opts = [:]) { + return new AgentRpcConfig(opts) + } + + /** The local executor with a docker engine, the shape every R3/R4/R5 row starts from. */ + private static ContainerConfig docker(Map opts = [:]) { new DockerConfig(opts) } + + // ======================================================================================= + // R1, R2 -- the explicit rungs + // ======================================================================================= + + def 'R1: an explicit `agent.rpc.remoteHost` wins over every inferred row, and probes nothing'() { + given: + def probes = new TestProbes(outbound: '172.17.0.5') + + when: 'a configuration that would otherwise resolve R3' + def host = resolver(probes).resolve(null, 'local', docker(), null, rpc([remoteHost: 'driver.internal'])) + + then: + host.resolved + host.host == 'driver.internal' + host.source == 'agent.rpc.remoteHost' + + and: 'no probe ran at all: an explicit value is not something to second-guess' + probes.calls.isEmpty() + } + + def 'R2: the environment answers when the config does not, and says so'() { + given: + def probes = new TestProbes() + SysEnv.push([NXF_AGENT_RPC_REMOTE_HOST: 'driver.from.env']) + + when: + def host = resolver(probes).resolve(null, 'k8s', null, null, rpc()) + + then: + host.host == 'driver.from.env' + host.source == 'NXF_AGENT_RPC_REMOTE_HOST' + probes.calls.isEmpty() + + cleanup: + SysEnv.pop() + } + + // ======================================================================================= + // R3 -- the containerized driver (a fix to shipped behaviour, not a new capability) + // ======================================================================================= + + // ======================================================================================= + // R4 -- the container shares the driver's network namespace + // ======================================================================================= + + def 'R4: the engines that create no network namespace resolve to loopback'() { + expect: + def host = resolver(new TestProbes()).resolve(null, 'local', config, null, rpc()) + host.host == '127.0.0.1' + host.source == 'host network namespace' + + where: + config << [ + new SingularityConfig([:]), + new ApptainerConfig([:]), + // an explicit `--network host` is not a namespace of its own + new ApptainerConfig([runOptions: '--network host']), + ] + } + + // ======================================================================================= + // R5, R6 -- the local engine rows + // ======================================================================================= + + def 'R5: a non-containerized driver on a local daemon gets the engine host alias'() { + expect: + def host = resolver(new TestProbes()).resolve(null, 'local', config, null, rpc()) + host.host == expected + host.source == source + + where: + config | expected | source + docker() | 'host.docker.internal' | 'docker host alias' + new PodmanConfig([:]) | 'host.containers.internal' | 'podman host alias' + } + + def 'R6: an isolated engine with no alias is given the default-route address'() { + given: + def probes = new TestProbes(outbound: '192.168.1.131', interfaces: ['192.168.1.131']) + + expect: + def host = resolver(probes).resolve(null, 'local', config, null, rpc()) + host.host == '192.168.1.131' + host.source == 'inferred from default route' + + where: + config << [new AppleContainerConfig([:]), new SmolVmConfig([:]), new SmolVmConfig([network: true])] + } + + // ======================================================================================= + // R7, R8, R9 -- the executor rows + // ======================================================================================= + + def 'R7: a grid executor is recognised by its TYPE, not by a name list'() { + given: 'a subclass whose @ServiceName no hand-written list would carry' + def executor = Mock(AbstractGridExecutor) + def probes = new TestProbes(outbound: '10.1.2.3', interfaces: ['10.1.2.3']) + + when: + def host = resolver(probes).resolve(executor, 'nqsii', docker(), null, rpc()) + + then: + host.host == '10.1.2.3' + host.source == 'inferred from default route' + } + + def 'R7: a multi-homed submit node still resolves, but says another address exists'() { + given: 'the campus NIC owns the default route; the compute fabric is the other one' + def probes = new TestProbes(outbound: '10.1.2.3', interfaces: ['10.1.2.3', '192.168.40.7']) + + when: + def host = resolver(probes).resolve(Mock(AbstractGridExecutor), 'slurm', docker(), null, rpc()) + + then: + host.host == '10.1.2.3' + host.warnings.size() == 1 + host.warnings[0].contains('192.168.40.7') + host.warnings[0].contains('agent.rpc.remoteHost') + } + + def 'R7: a k8s driver is given its own address, on the same rung as every other remote executor'() { + given: 'a driver in the cluster the agent pods run in' + def probes = new TestProbes(outbound: '10.42.0.9', interfaces: ['10.42.0.9']) + + when: + def host = resolver(probes).resolve(Mock(Executor), 'k8s', null, null, rpc()) + + then: + host.host == '10.42.0.9' + host.source == 'inferred from default route' + and: 'a hostNetwork pod yields the NODE address, which is correct but is not a pod IP' + !host.source.contains('pod IP') + } + + // ======================================================================================= + // error rows -- one message each, naming what was tried and what to set + // ======================================================================================= + + def 'E2: a smolvm microVM with no network at all is rejected'() { + when: + def host = resolver(new TestProbes()).resolve(null, 'local', new SmolVmConfig([network: false]), null, rpc()) + + then: + !host.resolved + host.code == 'E2' + host.error.contains('smolvm.network') + } + + def 'E7: an address that is not routable from another host is rejected, naming every step tried'() { + given: + def probes = new TestProbes(outbound: address, interfaces: []) + + when: + def host = resolver(probes).resolve(Mock(AbstractGridExecutor), 'slurm', docker(), null, rpc()) + + then: + !host.resolved + host.code == 'E7' + and: 'the message is a trace of the ladder, not a bare "cannot determine"' + host.error.contains('agent.rpc.remoteHost') + host.error.contains('NXF_AGENT_RPC_REMOTE_HOST') + host.error.contains('default route') + + where: + address << ['127.0.0.1', '0.0.0.0', '169.254.12.9', null] + } + + def 'a host with no default route falls back to its ONE routable interface address'() { + given: 'an air-gapped submit node: one NIC on the compute fabric, no default route' + def probes = new TestProbes(outbound: null, interfaces: ['10.20.0.5']) + + when: + def host = resolver(probes).resolve(Mock(AbstractGridExecutor), 'slurm', docker(), null, rpc()) + + then: 'the address the compute nodes reach it on was enumerated all along' + host.resolved + host.host == '10.20.0.5' + host.source.contains('no default route') + } + + def 'a host with no default route falls back to its ONE PUBLIC address, and says so'() { + given: 'no default route, and a private plus a public address on the interfaces' + def probes = new TestProbes(outbound: null, interfaces: ['10.0.3.17', '203.0.113.9']) + + when: 'a remote executor, i.e. the deployment where a public address is the fair fallback' + def host = resolver(probes).resolve(Mock(Executor), 'k8s', null, null, rpc()) + + then: 'the public one settles an otherwise ambiguous set' + host.resolved + host.host == '203.0.113.9' + + and: 'and the operator is told, because a public address is a consequential choice' + host.warnings.any { it.contains('public address') } + } + + def 'a public fallback is NOT taken when two public addresses are equally plausible'() { + given: + def probes = new TestProbes(outbound: null, interfaces: ['203.0.113.9', '198.51.100.4']) + + when: + def host = resolver(probes).resolve(Mock(Executor), 'k8s', null, null, rpc()) + + then: 'advertising a guess is the failure this design exists to avoid' + !host.resolved + host.code == 'E7' + } + + def 'a host with no default route and SEVERAL routable addresses is rejected, not guessed at'() { + given: + def probes = new TestProbes(outbound: null, interfaces: ['10.20.0.5', '192.168.9.4']) + + when: + def host = resolver(probes).resolve(Mock(AbstractGridExecutor), 'slurm', docker(), null, rpc()) + + then: 'nothing says which one the agent task can route to, and a guess is the failure mode' + !host.resolved + host.code == 'E7' + host.error.contains('10.20.0.5') + host.error.contains('192.168.9.4') + host.error.contains('agent.rpc.remoteHost') + } + + def 'the multi-homed warning ignores the SAME NIC in another address family'() { + given: 'the ordinary dual-stack Linux host: one NIC, an IPv4 and a global IPv6 address' + def probes = new TestProbes(outbound: '10.1.2.3', interfaces: ['10.1.2.3', '2a01:4f8:1:2::1']) + + when: + def host = resolver(probes).resolve(Mock(AbstractGridExecutor), 'slurm', docker(), null, rpc()) + + then: 'warning on every ordinary driver would train operators to ignore the one that matters' + host.host == '10.1.2.3' + host.warnings.isEmpty() + } + + def 'R7: an executor with no rung of its own takes the same remote row as k8s and grid'() { + given: 'an engine that names a driver-host address, but an executor that is not on the driver host' + def probes = new TestProbes(outbound: '10.1.2.3', interfaces: ['10.1.2.3']) + + when: + def host = resolver(probes).resolve(Mock(Executor), 'nonesuch', docker(), null, rpc()) + + then: 'the engine alias is NOT used - it names the wrong machine - and the routable address is' + host.resolved + host.host == '10.1.2.3' + host.source == 'inferred from default route' + } + + def 'R7: a non-namespace engine on the driver host with no rung of its own also falls through'() { + given: 'shifter/charliecloud/sarus get no special handling; the outbound address serves them' + def probes = new TestProbes(outbound: '10.1.2.3', interfaces: ['10.1.2.3']) + + expect: + def host = resolver(probes).resolve(null, 'local', config, null, rpc()) + host.resolved + host.host == '10.1.2.3' + + where: + config << [new ShifterConfig([:]), new CharliecloudConfig([:]), new SarusConfig([:])] + } + + // ======================================================================================= + // memoization, and the broker hand-off + // ======================================================================================= + + def 'every probe runs at most once, however many agent definitions resolve'() { + given: + def probes = new TestProbes(outbound: '10.1.2.3', interfaces: ['10.1.2.3']) + def resolver = resolver(probes) + + when: 'three agent definitions resolve through the same session resolver' + 3.times { resolver.resolve(Mock(AbstractGridExecutor), 'slurm', docker(), null, rpc()) } + + then: 'these answer questions about the HOST, so asking again could only cost' + probes.calls['outboundAddress'] == 1 + probes.calls['interfaceAddresses'] == 1 + } + + def 'each agent definition keeps its OWN address, so neither can be advertised to the other'() { + given: 'one agent runs locally on docker, another on a grid executor' + def probes = new TestProbes(outbound: '10.1.2.3', interfaces: ['10.1.2.3']) + def resolver = resolver(probes) + + when: + def local = resolver.resolve(null, 'local', docker(), null, rpc()) + def grid = resolver.resolve(Mock(AbstractGridExecutor), 'slurm', docker(), null, rpc()) + + then: 'the resolver is stateless across definitions -- the host rides on the request' + local.host == 'host.docker.internal' + grid.host == '10.1.2.3' + + and: 'and the order the definitions are built in cannot change either answer' + resolver.resolve(Mock(AbstractGridExecutor), 'slurm', docker(), null, rpc()).host == '10.1.2.3' + resolver.resolve(null, 'local', docker(), null, rpc()).host == 'host.docker.internal' + } + + // ======================================================================================= + // parity with the thin callers + // ======================================================================================= + + def 'an address is usable only when another host could route to it'() { + expect: + AgentRpcHostResolver.usableAddress(address) == expected + + where: + address | expected + '10.0.3.17' | true + '192.168.1.131' | true + '127.0.0.1' | false // the driver's own loopback is not the task's + '0.0.0.0' | false // the bind address, never an address to dial + '169.254.169.254' | false // link-local: reachable only on the same link + '' | false + null | false + } + + def 'AgentRpcConfig.resolveRemoteHost goes through the same ladder'() { + expect: 'the local rows, which are all an engine name alone can answer' + rpc().resolveRemoteHost('docker') == 'host.docker.internal' + rpc().resolveRemoteHost('podman') == 'host.containers.internal' + rpc().resolveRemoteHost('singularity') == '127.0.0.1' + rpc().resolveRemoteHost('apptainer') == '127.0.0.1' + + and: 'an explicit value still outranks every row' + rpc([remoteHost: 'driver.internal']).resolveRemoteHost('docker') == 'driver.internal' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/agent/rpc/AgentRpcRegistrationTest.groovy b/modules/nextflow/src/test/groovy/nextflow/agent/rpc/AgentRpcRegistrationTest.groovy new file mode 100644 index 0000000000..5d41445760 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/agent/rpc/AgentRpcRegistrationTest.groovy @@ -0,0 +1,83 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.rpc + +import spock.lang.Specification +import nextflow.agent.AgentRunner + +/** + * Pins the two properties of a registration that are security decisions rather than plumbing: + * a missing certificate fingerprint must never be read as "dial cleartext", and the capability + * token must never reach a log line, an exception or a test report through {@code toString()}. + */ +class AgentRpcRegistrationTest extends Specification { + + def 'should pin the driver certificate when a fingerprint is issued'() { + given: + def registration = new AgentRpcRegistration('inv-1', 'tok-1', '127.0.0.1:9999', 'abc123') + + expect: + registration.transportArgs() == ['--fingerprint', 'abc123'] + } + + def 'should opt out of transport security only when the broker says so explicitly'() { + given: 'the shape a broker built with `agent.rpc.tls = false` returns' + def registration = new AgentRpcRegistration('inv-1', 'tok-1', '127.0.0.1:9999', null, true) + + expect: + registration.transportArgs() == ['--insecure'] + } + + def 'should fail closed when a runner returns neither a pin nor an explicit opt-out'() { + given: 'an AgentRunner that forgot the fingerprint -- register() has a default impl and this type is @Canonical, so the short constructor still compiles' + def registration = new AgentRpcRegistration('inv-1', 'tok-1', '127.0.0.1:9999') + + when: + registration.transportArgs() + + then: 'the driver refuses to build the command, rather than telling the proxy to dial cleartext at a TLS listener' + // Downgrading silently would surface inside the task as an unrelated "connect to driver" + // failure -- the same class of misleading error transport security exists to remove. + def error = thrown(IllegalStateException) + error.message.contains('inv-1') + error.message.contains('fingerprint') + } + + def 'should keep the capability token out of the rendered form'() { + given: + def registration = new AgentRpcRegistration('inv-1', 'super-secret-token', '127.0.0.1:9999', 'abc123') + + when: + def rendered = registration.toString() + + then: 'the token is absent -- @Canonical implies a @ToString over EVERY property, so without the explicit one this renders the secret' + !rendered.contains('super-secret-token') + + and: 'the remaining values are NAMED, so a redacted render is not mistaken for a malformed one' + // Without `includeNames` the output is four bare positional values with one silently + // missing -- which reads as a bug in the object, not as a deliberate redaction. This clause + // is what pins that half: `@ToString(excludes='token')` alone still passes every other + // assertion here. + rendered.contains('invocationId:inv-1') + rendered.contains('endpoint:127.0.0.1:9999') + rendered.contains('fingerprint:abc123') + rendered.contains('insecure:false') + + and: 'while everything else @Canonical generates is untouched' + registration == new AgentRpcRegistration('inv-1', 'super-secret-token', '127.0.0.1:9999', 'abc123') + registration.hashCode() == new AgentRpcRegistration('inv-1', 'super-secret-token', '127.0.0.1:9999', 'abc123').hashCode() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/config/ConfigBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/config/ConfigBuilderTest.groovy index b308f8893f..b96ba6d342 100644 --- a/modules/nextflow/src/test/groovy/nextflow/config/ConfigBuilderTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/config/ConfigBuilderTest.groovy @@ -548,6 +548,44 @@ class ConfigBuilderTest extends Specification { } + def 'should resolve agent selectors inside a profile' () { + + given: + def folder = Files.createTempDirectory('test') + def file1 = folder.resolve('test.conf') + file1.text = ''' + profiles { + cloud { + agent { + cpus = 2 + ext.args = '--verbose' + + withName: bar { + cpus = 4 + ext.opts = '--fast' + } + + withLabel: foo { + cpus = 8 + } + } + } + } + ''' + + when: + def cfg = new ConfigBuilder().setProfile('cloud').build([file1]) + then: + cfg.agent.cpus == 2 + cfg.agent.ext == [args: '--verbose'] + cfg.agent.'withName:bar'.cpus == 4 + cfg.agent.'withName:bar'.ext == [opts: '--fast'] + cfg.agent.'withLabel:foo'.cpus == 8 + + cleanup: + folder?.deleteDir() + } + def 'should resolve ext config' () { given: diff --git a/modules/nextflow/src/test/groovy/nextflow/config/ConfigValidatorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/config/ConfigValidatorTest.groovy index c29858edfc..c36db6ee2d 100644 --- a/modules/nextflow/src/test/groovy/nextflow/config/ConfigValidatorTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/config/ConfigValidatorTest.groovy @@ -111,6 +111,38 @@ class ConfigValidatorTest extends Specification { !capture.toString().contains('Unrecognized config option') } + def 'should ignore agent selectors and accept process directives in the agent scope' () { + when: + new ConfigValidator().validate([ + agent: [ + disk: '10 GB', + publishDir: '/tmp/out', + tag: 'x', + 'withLabel:foobar': [ + cpus: 2 + ], + 'withName:foobar': [ + cpus: 2 + ], + "withName:'.*AGENT.*'": [ + cpus: 2 + ] + ] + ]) + then: + !capture.toString().contains('Unrecognized config option') + } + + def 'should warn for an unknown option in the agent scope' () { + when: + // `model`/`maxIterations` share the agent body directive names, as `process` does + new ConfigValidator().validate([agent: [maxIterations: 40, model: 'openai/gpt-5', fooBar: 1]]) + then: + !capture.toString().contains("Unrecognized config option 'agent.maxIterations'") + !capture.toString().contains("Unrecognized config option 'agent.model'") + capture.toString().contains("Unrecognized config option 'agent.fooBar'") + } + def 'should support map options' () { when: new ConfigValidator().validate([ diff --git a/modules/nextflow/src/test/groovy/nextflow/config/parser/v1/ConfigParserV1Test.groovy b/modules/nextflow/src/test/groovy/nextflow/config/parser/v1/ConfigParserV1Test.groovy index 34dafb024d..474edae81e 100644 --- a/modules/nextflow/src/test/groovy/nextflow/config/parser/v1/ConfigParserV1Test.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/config/parser/v1/ConfigParserV1Test.groovy @@ -550,16 +550,20 @@ class ConfigParserV1Test extends Specification { given: def folder = Files.createTempDirectory('test') folder.resolve('conf').mkdir() - // launch web server - HttpServer server = HttpServer.create(new InetSocketAddress(9900), 0); + // launch web server on an EPHEMERAL port: a fixed one collides with the other tests that + // serve HTTP (`SimpleHttpClientTest`, and `PluginsFacadeTest`/`HttpFilesTests` in modules + // whose test tasks Gradle runs concurrently with this one), and the loser of that race + // fails with a BindException that has nothing to do with what is under test + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + final port = server.address.port server.createContext("/", new ConfigFileHandler(folder)); server.start() // main `nextflow.config` file - folder.resolve('nextflow.config').text = ''' + folder.resolve('nextflow.config').text = """ includeConfig 'conf/base.config' - includeConfig 'http://localhost:9900/conf/remote.config' - ''' + includeConfig 'http://localhost:${port}/conf/remote.config' + """ folder.resolve('conf/base.config').text = ''' params.foo = 'Hello' @@ -574,7 +578,7 @@ class ConfigParserV1Test extends Specification { ''' when: - def url = 'http://localhost:9900/nextflow.config' as Path + def url = "http://localhost:${port}/nextflow.config".toString() as Path def cfg = new ConfigBuilder().build([url]) then: cfg.params.foo == 'Hello' diff --git a/modules/nextflow/src/test/groovy/nextflow/config/parser/v2/ConfigParserV2Test.groovy b/modules/nextflow/src/test/groovy/nextflow/config/parser/v2/ConfigParserV2Test.groovy index d71da13bea..5538725449 100644 --- a/modules/nextflow/src/test/groovy/nextflow/config/parser/v2/ConfigParserV2Test.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/config/parser/v2/ConfigParserV2Test.groovy @@ -568,14 +568,17 @@ class ConfigParserV2Test extends Specification { def folder = Files.createTempDirectory('test') folder.resolve('conf').mkdir() - HttpServer server = HttpServer.create(new InetSocketAddress(9900), 0); + // ephemeral port - see the note in ConfigParserV1Test: a fixed port races the other + // HTTP-serving tests, including ones in modules whose test tasks run concurrently + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + final port = server.address.port server.createContext("/", new ConfigFileHandler(folder)); server.start() - folder.resolve('nextflow.config').text = ''' + folder.resolve('nextflow.config').text = """ includeConfig 'conf/base.config' - includeConfig 'http://localhost:9900/conf/remote.config' - ''' + includeConfig 'http://localhost:${port}/conf/remote.config' + """ folder.resolve('conf/base.config').text = ''' params.foo = 'Hello' @@ -590,7 +593,7 @@ class ConfigParserV2Test extends Specification { ''' when: - def url = 'http://localhost:9900/nextflow.config' as Path + def url = "http://localhost:${port}/nextflow.config".toString() as Path def cfg = new ConfigBuilder().build([url]) then: cfg.params.foo == 'Hello' @@ -802,6 +805,60 @@ class ConfigParserV2Test extends Specification { config.params.config_profile_name == 'Test profile' } + def 'should parse selectors in the agent scope' () { + given: + def CONFIG = ''' + agent { + cpus = 1 + withName: 'x' { + cpus = 2 + } + withLabel: 'big' { + memory = '2 GB' + } + } + ''' + + when: + def config = new ConfigParserV2().parse(CONFIG) + then: + config.agent.cpus == 1 + config.agent['withName:x'] == [cpus: 2] + config.agent['withLabel:big'] == [memory: '2 GB'] + } + + def 'should parse a dynamic directive in the agent scope' () { + given: + def CONFIG = ''' + agent { + cpus = { task.attempt * 2 } + } + ''' + + when: + def config = new ConfigParserV2().parse(CONFIG) + then: + config.agent.cpus instanceof Closure + } + + def 'should reject selectors outside the process and agent scopes' () { + given: + def CONFIG = ''' + docker { + withName: 'x' { + enabled = true + } + } + ''' + + when: + new ConfigParserV2().parse(CONFIG) + then: + def e = thrown(ConfigParseException) + e.message.contains('Config selectors are only allowed in the `process` and `agent` scopes') + e.message.contains('offending scope: `docker`') + } + static class ConfigFileHandler implements HttpHandler { Path folder diff --git a/modules/nextflow/src/test/groovy/nextflow/executor/ExecutorFactoryTest.groovy b/modules/nextflow/src/test/groovy/nextflow/executor/ExecutorFactoryTest.groovy index 9360bd63ef..fcdb4275b8 100644 --- a/modules/nextflow/src/test/groovy/nextflow/executor/ExecutorFactoryTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/executor/ExecutorFactoryTest.groovy @@ -91,6 +91,40 @@ class ExecutorFactoryTest extends Specification { } + def 'should resolve an executor instance by name with no task body' () { + given: + def session = Mock(Session) + def factory = Spy(ExecutorFactory) + def executor = Mock(SgeExecutor) + + when: 'a name is resolved through the REAL name -> class map' + def result = factory.getExecutorByName('sge', session) + then: 'the named executor is instantiated, with no script-type check to downgrade it' + 1 * factory.createExecutor(SgeExecutor, 'sge', session) >> executor + 0 * factory.isTypeSupported(_, _) + result.is(executor) + + when: 'the same executor is asked for again' + result = factory.getExecutorByName('sge', session) + then: 'the cached instance is returned - an executor is created at most once per run' + 0 * factory.createExecutor(_, _, _) + result.is(executor) + + and: 'cached under the same key getExecutor reads, so the task runs on this very instance' + factory.executors.get(SgeExecutor).is(executor) + } + + def 'should reject an unknown executor name instead of falling back to local' () { + given: + def factory = Spy(ExecutorFactory) + + when: + factory.getExecutorByName('xyz', Mock(Session)) + then: 'the name error surfaces - it is never silently answered with the local executor' + thrown(IllegalArgumentException) + 0 * factory.createExecutor(_, _, _) + } + def 'should check type supported'() { setup: diff --git a/modules/nextflow/src/test/groovy/nextflow/executor/local/AgentTaskHandlerTest.groovy b/modules/nextflow/src/test/groovy/nextflow/executor/local/AgentTaskHandlerTest.groovy new file mode 100644 index 0000000000..6ca147dec5 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/executor/local/AgentTaskHandlerTest.groovy @@ -0,0 +1,178 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.executor.local + +import java.nio.file.FileSystem +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.spi.FileSystemProvider + +import nextflow.Session +import nextflow.file.FileHolder +import nextflow.processor.TaskConfig +import nextflow.processor.TaskRun +import nextflow.script.ScriptType +import nextflow.util.ArrayBag +import spock.lang.Specification +import spock.lang.TempDir + +/** + * The in-JVM half of agent path-input parity: a native agent task runs no wrapper script, so the + * handler is what puts the declared input files into the work dir. Driven against a hand-built + * {@link TaskRun} because the script-level harness substitutes every executor. + * + * @author Paolo Di Tommaso + */ +class AgentTaskHandlerTest extends Specification { + + @TempDir + Path tempDir + + def 'should materialize the declared input files under their stage name'() { + given: + final task = taskWith(['reads.fq': sourceFile('reads.fq')]) + + when: + AgentTaskHandler.materializeInputs(task) + + then: + final staged = task.workDir.resolve('reads.fq') + Files.isSymbolicLink(staged) + staged.text == 'DATA' + } + + def 'should honour stageInMode'() { + given: + final source = sourceFile('reads.fq') + final task = taskWith(['reads.fq': source], [stageInMode: mode]) + + when: + AgentTaskHandler.materializeInputs(task) + + then: + final staged = task.workDir.resolve('reads.fq') + Files.isSymbolicLink(staged) == symlink + staged.text == 'DATA' + and: 'a rellink resolves through a relative target, an absolute symlink through an absolute one' + !symlink || Files.readSymbolicLink(staged).isAbsolute() == absoluteTarget + + where: + mode || symlink | absoluteTarget + null || true | true + 'symlink' || true | true + 'rellink' || true | false + 'link' || false | true // hard link: `absoluteTarget` is unused + 'copy' || false | true // ditto + } + + def 'should reject an unknown stage-in mode, exactly as a scriptlet task does'() { + given: + final task = taskWith(['reads.fq': sourceFile('reads.fq')], [stageInMode: 'teleport']) + + when: + AgentTaskHandler.materializeInputs(task) + + then: 'the same message SimpleFileCopyStrategy raises -- a typo means one thing, not two' + def e = thrown(IllegalArgumentException) + e.message == 'Unknown stage-in strategy: teleport' + } + + def 'should skip materialization when the work dir is not on the local filesystem'() { + given: 'a work dir on a foreign provider, which cannot create links' + final provider = Mock(FileSystemProvider) { getScheme() >> 's3' } + final fs = Mock(FileSystem) { provider() >> provider } + final workDir = Mock(Path) { getFileSystem() >> fs } + and: + final task = taskWith(['reads.fq': sourceFile('reads.fq')]) + task.workDir = workDir + + when: + AgentTaskHandler.materializeInputs(task) + + then: 'no link attempt is made, so no UnsupportedOperationException escapes submit()' + noExceptionThrown() + 0 * provider.createSymbolicLink(*_) + 0 * provider.createLink(*_) + 0 * workDir.resolve(_) + } + + def 'should materialize a stage name carrying a sub-directory'() { + given: + final task = taskWith(['sub/dir/reads.fq': sourceFile('reads.fq')]) + + when: + AgentTaskHandler.materializeInputs(task) + + then: + task.workDir.resolve('sub/dir/reads.fq').text == 'DATA' + } + + def 'should replace a stale entry left by a previous attempt'() { + given: + final task = taskWith(['reads.fq': sourceFile('reads.fq')]) + and: 'a leftover regular file under the same name' + task.workDir.resolve('reads.fq').text = 'STALE' + + when: + AgentTaskHandler.materializeInputs(task) + + then: + task.workDir.resolve('reads.fq').text == 'DATA' + } + + def 'should do nothing when the task declares no input files'() { + given: + final task = taskWith([:]) + + when: + AgentTaskHandler.materializeInputs(task) + + then: + noExceptionThrown() + task.workDir.toFile().list().length == 0 + } + + def 'the agent executor dispatches a native task to the staging handler'() { + given: + final executor = new AgentExecutor(session: Mock(Session)) + final task = taskWith([:]) + task.type = ScriptType.GROOVY + + expect: + executor.createTaskHandler(task) instanceof AgentTaskHandler + } + + // ----------------------------------------------------------------------- + + private Path sourceFile(String name) { + final dir = Files.createTempDirectory(tempDir, 'src') + final file = dir.resolve(name) + file.text = 'DATA' + return file + } + + private TaskRun taskWith(Map inputs, Map directives = [:]) { + final task = new TaskRun(id: null) + task.workDir = Files.createTempDirectory(tempDir, 'work') + task.config = new TaskConfig(directives) + task.inputFiles = new ArrayBag<>(inputs.collect { name, path -> + new FileHolder(path).withName(name) + }) + return task + } + +} diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskErrorFormatterTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskErrorFormatterTest.groovy index 5ef8577e45..146b32d5a7 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskErrorFormatterTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskErrorFormatterTest.groovy @@ -18,6 +18,7 @@ package nextflow.processor import java.nio.file.Path +import nextflow.agent.AgentTaskInfo import nextflow.exception.FailedGuardException import nextflow.exception.ProcessEvalException import spock.lang.Specification @@ -318,4 +319,64 @@ class TaskErrorFormatterTest extends Specification { result.contains('-') } + def 'should redact the RPC capability token from a failing agent task report'() { + given: 'this report does not stay local -- TaskProcessor logs it to .nextflow.log and it' + // becomes session.fault.report -> workflow.errorReport, which nf-tower POSTs to Platform. + // An agent task script is the proxy launch command, so it carries the capability token, + // and a live token buys the provider credential off the start frame. + def formatter = new TaskErrorFormatter() + def token = 'cap-6f21ab9d7e0c4415' + def script = "exec '/opt/nf-agent/agent-rpc' '--endpoint' 'driver:41235' " + + "'--invocation' 'inv-1' '--fingerprint' 'aabb' '--token' '${token}' '--' '/usr/bin/node' 'runner.mjs'" + def agentInfo = new AgentTaskInfo('pi', 'openai/gpt-5-mini', 'be helpful', null, 'p', 20, null, null, null) + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + getScript() >> script + getConfig() >> new TaskConfig([(AgentTaskInfo.CONFIG_KEY): agentInfo]) + getTemplate() >> null + getExitStatus() >> 1 + dumpStdout(_) >> [] + dumpStderr(_) >> [] + isContainerEnabled() >> false + } + + when: + def result = formatter.formatTaskError([], new RuntimeException('Task failed'), task).join('\n') + + then: 'the token is gone' + !result.contains(token) + result.contains("'--token' '[REDACTED]'") + + and: 'and everything that is not a secret is kept, so the report stays diagnosable' + result.contains("'--endpoint' 'driver:41235'") + result.contains("'--invocation' 'inv-1'") + result.contains("'--fingerprint' 'aabb'") + } + + def 'should leave an ordinary task script untouched even when it mentions a token'() { + given: 'the redaction sits on the path EVERY failing task takes, so a process that happens' + // to name --token must be reported verbatim + def formatter = new TaskErrorFormatter() + def script = "curl -H 'x' https://api.example.com --token 'not-an-agent-token'" + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + getScript() >> script + getConfig() >> new TaskConfig([:]) + getTemplate() >> null + getExitStatus() >> 1 + dumpStdout(_) >> [] + dumpStderr(_) >> [] + isContainerEnabled() >> false + } + + when: + def result = formatter.formatTaskError([], new RuntimeException('Task failed'), task).join('\n') + + then: + result.contains("--token 'not-an-agent-token'") + !result.contains('[REDACTED]') + } + } diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskHandlerTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskHandlerTest.groovy index b5f913c97c..23e7dc360c 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskHandlerTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskHandlerTest.groovy @@ -20,6 +20,7 @@ import java.util.concurrent.atomic.LongAdder import nextflow.Session import nextflow.SysEnv +import nextflow.agent.AgentTaskInfo import nextflow.executor.Executor import nextflow.trace.TraceRecord import nextflow.util.Duration @@ -469,4 +470,65 @@ class TaskHandlerTest extends Specification { false | _ true | _ } + + /** + * The trace record is persisted in the resume cache DB and POSTed to Seqera Platform by + * nf-tower. An agent task's script is the RPC proxy launch command, whose `--token` is a bearer + * credential for the provider API key the driver sends on the start frame -- so it must not be + * in there. LinObserver already omits task.script from the lineage record for the same reason. + */ + def 'the trace record of an agent task carries no capability token'() { + given: + def token = 'Y2FwYWJpbGl0eS10b2tlbi0zMi1ieXRlcw' + def script = "exec '/opt/nf-agent/agent-rpc' '--endpoint' 'driver:41235' " + + "'--invocation' 'inv-1' '--fingerprint' 'aabb' '--token' '${token}' '--' '/usr/bin/node' 'runner.mjs'" + def agentInfo = new AgentTaskInfo('pi', 'openai/gpt-5-mini', 'be helpful', null, 'p', 20, null, null, null) + def task = new TaskRun(id: new TaskId(7), name: 'agentTask', script: script, + config: new TaskConfig([(AgentTaskInfo.CONFIG_KEY): agentInfo])) + task.processor = Mock(TaskProcessor) + task.processor.getSession() >> new Session() + task.processor.getName() >> 'summarize' + task.processor.getExecutor() >> Mock(Executor) + task.processor.getProcessEnvironment() >> [:] + task.context = new TaskContext(Mock(Script), [:], 'none') + + def handler = Spy(TaskHandler) + handler.task = task + handler.status = TaskStatus.COMPLETED + + when: + def record = handler.getTraceRecord() + + then: 'the token value is nowhere in the record' + !record.script.contains(token) + record.script.contains("'--token' '[REDACTED]'") + and: 'and the rest of the launch command still is -- a fingerprint is a public commitment' + record.script.contains("'--endpoint' 'driver:41235'") + record.script.contains("'--fingerprint' 'aabb'") + and: 'the EXECUTED script is untouched: .command.sh keeps the real token' + task.script == script + task.getScript() == script + } + + def 'the trace record of an ordinary task is unchanged by the agent redaction'() { + given: 'the agent seam sits on the path taken by EVERY task, so this is the contract' + def script = 'echo hello --token not-really-a-flag' + def task = new TaskRun(id: new TaskId(8), name: 'plainTask', script: script, config: new TaskConfig([:])) + task.processor = Mock(TaskProcessor) + task.processor.getSession() >> new Session() + task.processor.getName() >> 'plain' + task.processor.getExecutor() >> Mock(Executor) + task.processor.getProcessEnvironment() >> [:] + task.context = new TaskContext(Mock(Script), [:], 'none') + + def handler = Spy(TaskHandler) + handler.task = task + handler.status = TaskStatus.COMPLETED + + when: + def record = handler.getTraceRecord() + + then: 'byte-identical, even though it happens to contain the flag the redactor looks for' + record.script == script + } } diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskRunTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskRunTest.groovy index 849c677d19..72bbc83c47 100644 --- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskRunTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskRunTest.groovy @@ -29,7 +29,10 @@ import nextflow.container.resolver.ContainerMeta import nextflow.container.resolver.ContainerResolver import nextflow.executor.Executor import nextflow.file.FileHolder +import nextflow.script.BaseScript import nextflow.script.BodyDef +import nextflow.script.ProcessConfigV2 +import nextflow.script.ScriptType import nextflow.script.ScriptBinding import nextflow.script.ScriptType import nextflow.script.TaskClosure diff --git a/modules/nextflow/src/test/groovy/nextflow/script/AgentBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/AgentBuilderTest.groovy new file mode 100644 index 0000000000..e177ecfbdd --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/script/AgentBuilderTest.groovy @@ -0,0 +1,108 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script + +import spock.lang.Specification + +class AgentBuilderTest extends Specification { + + def 'should capture directives, inputs, outputs and prompt then build an AgentDef'() { + given: + def builder = new AgentBuilder(null, 'eval_agent') + + when: + builder.model('openai/gpt-5-mini') + builder.instruction('You are helpful.') + builder.tools() + builder.maxIterations(20) + builder._input_('question', String) + builder._output_('plan', String) + def prompt = new PromptDef({ "Question: x" }, 'Question: ${question}') + def agent = builder.withPrompt(prompt).build() + + then: + agent instanceof AgentDef + agent.name == 'eval_agent' + agent.model == 'openai/gpt-5-mini' + agent.instruction == 'You are helpful.' + agent.maxIterations == 20 + agent.tools == [] + agent.inputs*.name == ['question'] + agent.outputs*.name == ['plan'] + agent.prompt.source == 'Question: ${question}' + } + + def 'should capture the skills directive (single and list)'() { + given: + def builder = new AgentBuilder(null, 'a') + def b2 = new AgentBuilder(null, 'b') + + when: + builder.skills('greet') + builder._input_('q', String); builder._output_('a', String) + def a1 = builder.withPrompt(new PromptDef({ 'x' }, 'x')).build() + b2.skills('greet', 'github.com/org/repo') + b2._input_('q', String); b2._output_('a', String) + def a2 = b2.withPrompt(new PromptDef({ 'x' }, 'x')).build() + + then: + a1.skills == ['greet'] + a2.skills == ['greet', 'github.com/org/repo'] + } + + def 'should accumulate the repeatable label directive'() { + given: + def builder = new AgentBuilder(null, 'a') + def single = new AgentBuilder(null, 'b') + + when: + builder.label('a') + builder.label('b') + builder._input_('q', String); builder._output_('a', String) + def a1 = builder.withPrompt(new PromptDef({ 'x' }, 'x')).build() + and: + single.label('a') + single._input_('q', String); single._output_('a', String) + def a2 = single.withPrompt(new PromptDef({ 'x' }, 'x')).build() + + then: 'repeated invocations accumulate instead of overwriting' + a1.labels == ['a', 'b'] + and: 'a single label is still a list' + a2.labels == ['a'] + } + + def 'should reject an unknown directive'() { + given: + def builder = new AgentBuilder(null, 'a') + + when: + builder.bogusDirective('x') + + then: + thrown(Exception) + } + + def 'should fail to build without a prompt'() { + given: + def builder = new AgentBuilder(null, 'a') + + when: + builder.build() + + then: + thrown(IllegalStateException) + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/script/AgentCacheKeyPinTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/AgentCacheKeyPinTest.groovy new file mode 100644 index 0000000000..74d587cb98 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/script/AgentCacheKeyPinTest.groovy @@ -0,0 +1,218 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script + +import nextflow.agent.SkillDescriptor +import nextflow.agent.SkillResource +import nextflow.agent.ToolDescriptor +import spock.lang.Specification + +/** + * This is a tripwire, not a unit test. + * + *

{@link AgentDef#canonicalAgentSource} is written verbatim into {@code BodyDef.source}, which + * {@code TaskHasher} folds into an agent task's hash; {@link AgentDef#toolsFingerprint} is one of + * its lines. So the exact BYTES these two pure functions emit ARE the {@code -resume} cache key of + * every agent ever run. A change to either silently invalidates every user's stored runs: the + * pipeline still works, every other test still passes, and the only symptom is that resume re-runs + * everything. + * + *

The expected values below were obtained by RUNNING the code, not by reasoning about what it + * ought to produce. They are a record of what the parent commit does, deliberately asserted with + * {@code ==} against whole literals rather than {@code contains}, because the point is + * byte-identity — including the line order, the {@code \n} separators and the absence of a + * trailing newline. + * + *

Do not "fix" a failure here by updating an expected value. A failure means a refactor + * changed the agent task hash. Either revert the change, or — if the change is intentional and + * accepted — treat it as a documented cache invalidation with a changelog entry, and say so. + * + *

The fixture exercises the parts of the key that a refactor can plausibly disturb: a brokered + * module tool with a backing process source, a second brokered tool with an empty input envelope + * and no output schema and no source, a runner-native tool family with a runner identity, two + * skills (one with a bundled resource), a wrapped multi-output schema whose keys are NOT in + * alphabetical insertion order (so the canonical JSON sorting is pinned too), a resolved + * {@code baseUrl} and an explicit {@code apiProvider}. Tools, skills and native refs are all + * declared out of order, so their sorting is pinned as well. + * + * @author Paolo Di Tommaso + */ +class AgentCacheKeyPinTest extends Specification { + + // --- the fixture: hand-built values only, no session, no runner, no dataflow ------------- + + static private final String MODEL = 'anthropic/claude-sonnet-4-5-20250929' + static private final int MAX_ITER = 20 + static private final String RUNNER = 'pi' + static private final String RUNNER_ID = 'pi@0.5.0-alpha.1' + static private final String BASE_URL = 'https://api.example.com/v1' + static private final String API_PROVIDER = 'anthropic' + static private final String INSTRUCTION = 'Be precise and cite the tool output.' + static private final String GOAL = 'Produce a QC report for the sample.' + static private final String PROMPT_SOURCE = 'Analyse ${sample} and report' + + /** A wrapper (multi-output) schema, keys deliberately NOT in alphabetical insertion order. */ + static private Map wrappedSchema() { + return [ + type: 'object', + properties: [ + summary: [type: 'string', description: 'A short summary'], + report: [ + type: 'object', + properties: [ + score: [type: 'integer'], + label: [type: 'string', description: 'The label'] ], + required: ['score', 'label'], + additionalProperties: false ] ], + required: ['summary', 'report'], + additionalProperties: false ] + } + + /** Two brokered module tools, declared out of name order. */ + static private List brokeredTools() { + return [ + new ToolDescriptor( + name: 'fastqc', + description: 'Run FastQC on the reads.\nReturns a JSON object with the following output(s):\n- `html`: a file path string\nFile/path outputs are returned as absolute path strings (never file contents).', + inputSchema: [ + type: 'object', + properties: [ + reads: [type: 'string', description: 'The reads to analyse'], + meta: [ + type: 'object', + description: 'Sample metadata', + properties: [id: [type: 'string']], + additionalProperties: false ] ], + required: ['meta', 'reads'], + additionalProperties: false ], + outputSchema: [ + type: 'object', + properties: [html: [type: 'string']], + required: ['html'], + additionalProperties: false ]), + new ToolDescriptor( + name: 'align', + description: 'Align the reads to the reference genome.', + inputSchema: [ + type: 'object', + properties: [:], + required: [], + additionalProperties: false ], + outputSchema: null) ] + } + + /** The backing {@code BodyDef.source} of each brokered tool; `align` deliberately has none. */ + static private Map toolSources() { + return [ + fastqc: '\n fastqc --outdir . ${reads}\n ', + align: null ] as Map + } + + /** A runner-native tool family, declared out of order. */ + static private List nativeRefs() { + return ['fs:read', 'fs:grep'] + } + + /** Two skills, declared out of name order; one carries a bundled resource. */ + static private List skills() { + return [ + new SkillDescriptor( + name: 'variant-calling', + description: 'How to call variants on this reference', + content: '# Variant calling\n\nUse the joint caller.\n', + resources: [new SkillResource('references/guide.md', '# Guide\n\nRead this first.\n')]), + new SkillDescriptor( + name: 'assembly', + description: 'How to assemble a genome', + content: '# Assembly\n', + resources: null) ] + } + + private AgentDef agent() { + return new AgentDef( + Mock(BaseScript), + 'qc_agent', + [instruction: INSTRUCTION, goal: GOAL] as Map, + [], + [], + new PromptDef({ -> 'p' }, PROMPT_SOURCE)) + } + + /** The digests below are the ones the code emits; they are quoted in the pinned strings too. */ + static private final String TOOLS_DIGEST = '2d816ebf596efd499f96b0ec3ac6a69e' + static private final String SKILLS_DIGEST = '440b171d053115341f30ba92d4993717' + + // --- the pins ---------------------------------------------------------------------------- + + def 'toolsFingerprint pins the digest of the brokered module tools'() { + expect: 'the two descriptors are hashed name-sorted, with their backing process source' + AgentDef.toolsFingerprint(brokeredTools(), toolSources()) == '42b9658507fd5d9ae72825c3e5cb2672' + + and: 'an agent with no tools at all still contributes nothing' + AgentDef.toolsFingerprint(null, null) == null + } + + def 'toolsFingerprint pins the digest of the runner-native tools'() { + expect: 'the refs are hashed sorted, together with the runner identity, never a schema' + AgentDef.toolsFingerprint(null, null, nativeRefs(), RUNNER_ID) == '7b3ed3b3ebdea669b70c0fb81126104a' + } + + def 'toolsFingerprint pins the digest of the brokered and runner-native tools together'() { + expect: + AgentDef.toolsFingerprint(brokeredTools(), toolSources(), nativeRefs(), RUNNER_ID) == TOOLS_DIGEST + } + + def 'skillsFingerprint pins the digest of the declared skills'() { + expect: 'the descriptors are hashed name-sorted, over name/description/content/resources' + AgentDef.skillsFingerprint(skills()) == SKILLS_DIGEST + } + + def 'canonicalAgentSource pins the identity string of a tool-free, skill-free agent'() { + given: + def agent = agent() + + expect: 'byte-for-byte, no trailing newline, schema JSON key-sorted at every depth' + agent.canonicalAgentSource(MODEL, MAX_ITER, wrappedSchema()) == '''\ +agentModel=anthropic/claude-sonnet-4-5-20250929 +temperature=default +instruction=Be precise and cite the tool output. +goal=Produce a QC report for the sample. +maxIterations=20 +prompt=Analyse ${sample} and report +outputSchema={"additionalProperties":false,"properties":{"report":{"additionalProperties":false,"properties":{"label":{"description":"The label","type":"string"},"score":{"type":"integer"}},"required":["score","label"],"type":"object"},"summary":{"description":"A short summary","type":"string"}},"required":["summary","report"],"type":"object"}''' + } + + def 'canonicalAgentSource pins the identity string of an agent with a runner, tools, skills, an endpoint and a provider'() { + given: + def agent = agent() + def tools = AgentDef.toolsFingerprint(brokeredTools(), toolSources(), nativeRefs(), RUNNER_ID) + + expect: 'byte-for-byte: the leading agentRunner line, then the four trailing lines in this order' + agent.canonicalAgentSource(MODEL, MAX_ITER, wrappedSchema(), skills(), RUNNER, tools, BASE_URL, API_PROVIDER) == '''\ +agentRunner=pi +agentModel=anthropic/claude-sonnet-4-5-20250929 +temperature=default +instruction=Be precise and cite the tool output. +goal=Produce a QC report for the sample. +maxIterations=20 +prompt=Analyse ${sample} and report +outputSchema={"additionalProperties":false,"properties":{"report":{"additionalProperties":false,"properties":{"label":{"description":"The label","type":"string"},"score":{"type":"integer"}},"required":["score","label"],"type":"object"},"summary":{"description":"A short summary","type":"string"}},"required":["summary","report"],"type":"object"} +skills=440b171d053115341f30ba92d4993717 +tools=2d816ebf596efd499f96b0ec3ac6a69e +baseUrl=https://api.example.com/v1 +apiProvider=anthropic''' + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/script/AgentDefTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/AgentDefTest.groovy new file mode 100644 index 0000000000..84aa55d426 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/script/AgentDefTest.groovy @@ -0,0 +1,601 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script + +import java.nio.file.FileSystem +import java.nio.file.Path +import java.nio.file.spi.FileSystemProvider + +import groovy.json.JsonOutput +import nextflow.SysEnv +import nextflow.agent.AgentOutputMode +import nextflow.agent.AgentOutputPlan +import nextflow.agent.rpc.AgentRpcHostResolver +import nextflow.agent.SkillDescriptor +import nextflow.agent.SkillResource +import nextflow.agent.ToolDescriptor +import nextflow.exception.ScriptRuntimeException +import nextflow.file.FileHolder +import nextflow.processor.TaskPath +import nextflow.script.TokenValRef +import spock.lang.Specification +import nextflow.agent.rpc.AgentRpcConfig + +class AgentDefTest extends Specification { + + def setup() { + // AgentRpcConfig reads the environment in its constructor, and a NXF_AGENT_RPC_REMOTE_HOST + // exported in the developer's shell would silently answer the second rung of the ladder for + // every feature below, hiding the rung actually under test + SysEnv.push([:]) + } + + def cleanup() { + SysEnv.pop() + AgentRpcHostResolver.reset() + } + + private AgentDef makeAgent(BaseScript script, String name) { + def prompt = new PromptDef({ -> 'hello' }, 'hello') + return new AgentDef(script, name, [:], [], [], prompt) + } + + def 'should construct an AgentDef with name and content'() { + given: + def script = Mock(BaseScript) + + when: + def agent = makeAgent(script, 'eval_agent') + + then: + agent.name == 'eval_agent' + agent.simpleName == 'eval_agent' + agent.type == 'agent' + } + + def 'should preserve a remote path scheme in agent input json'() { + given: + final provider = Mock(FileSystemProvider) + final fs = Mock(FileSystem) + final path = Mock(Path) + path.getFileSystem() >> fs + fs.provider() >> provider + provider.getScheme() >> 'mock' + path.toAbsolutePath() >> path + path.toUri() >> URI.create('mock://bucket/reads.fastq') + + expect: + AgentDef.toJson([reads: path]) == '{"reads":"mock://bucket/reads.fastq"}' + and: 'nesting it in a Map-typed input -- a shape compile-time inference never stages -- keeps the scheme too' + AgentDef.toJson([refs: [primary: path]]) == '{"refs":{"primary":"mock://bucket/reads.fastq"}}' + } + + def 'should render a STAGED input by its work-dir stage name'() { + given: 'the value shape staging produces: a work-dir-relative view of an input file' + final source = Path.of('/data/run1/contigs.fa') + final staged = new TaskPath(new FileHolder(source)) + + expect: 'the JSON carries the name the model can open inside the task dir' + AgentDef.toJson(staged) == '"contigs.fa"' + and: 'which is exactly what the prompt interpolation of the same input produces --' + // this is the invariant: one input, one rendering, in the prompt and in the JSON + AgentDef.toJson(staged) == JsonOutput.toJson("${staged}".toString()) + and: 'a staged path nested inside a record input renders the same way' + AgentDef.toJson([id: 's1', seq: staged]) == '{"id":"s1","seq":"contigs.fa"}' + } + + def 'should fail on run() when a tool-free agent declares zero outputs (task path)'() { + given: + // no tools/skills -> task path, where the one-input guard is gone; a + // zero-output agent is still an error + def script = Mock(BaseScript) + def agent = makeAgent(script, 'foo') // no inputs, no outputs, no tools + + when: + agent.run(new Object[0]) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('must declare exactly one output') + } + + def 'should fail on run() with an input arity mismatch (task path)'() { + given: + // 1 declared input, 1 declared output, no tools -> task path; called with 0 args + def script = Mock(BaseScript) + def inp = new AgentBuilder.AgentInput('q', String) + def out = new AgentBuilder.AgentOutput('a', String) + def agent = new AgentDef(script, 'foo', [:] as Map, [inp], [out], new PromptDef({ -> 'h' }, 'h')) + + when: + agent.run(new Object[0]) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('expects 1 input channel') + } + + def 'a tools agent lowers to the task path (legacy one-input guard removed)'() { + given: + // M-Tools: declaring `tools` no longer forces the legacy operator path — every agent + // lowers to the task path, so the legacy exactly-one-input guard is gone (multiple + // inputs are now permitted). The generalized guards apply instead; here the zero-output + // guard fires (0 inputs / 0 args passes the arity check), proving task-path routing. + def script = Mock(BaseScript) + def agent = new AgentDef(script, 'foo', [tools: 'nf:module_run:someProc'] as Map, [], [], new PromptDef({ -> 'h' }, 'h')) + + when: + agent.run(new Object[0]) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('must declare exactly one output') + } + + def 'should clone with a new name'() { + given: + def script = Mock(BaseScript) + def agent = makeAgent(script, 'foo') + + when: + def renamed = agent.cloneWithName('bar') + + then: + renamed instanceof AgentDef + renamed.name == 'bar' + agent.name == 'foo' // original untouched + } + + def 'cloneWithName preserves the declared base name'() { + given: + def agent = makeAgent(Mock(BaseScript), 'critic') + + when: 'the agent is included under an alias, then invoked in a named workflow scope' + def aliased = (AgentDef) agent.cloneWithName('reviewer') + def qualified = (AgentDef) agent.cloneWithName('WF:critic') + + then: 'the declared name survives, so `withName:critic` keeps matching' + aliased.baseName == 'critic' + aliased.name == 'reviewer' + aliased.simpleName == 'reviewer' + and: + qualified.baseName == 'critic' + qualified.name == 'WF:critic' + qualified.simpleName == 'critic' + } + + def 'should expose the declared labels'() { + expect: + new AgentDef(Mock(BaseScript), 'a', [label: ['big', 'fast']] as Map, [], [], new PromptDef({ -> 'h' }, 'h')).labels == ['big', 'fast'] + and: 'an agent with no label declaration has none' + makeAgent(Mock(BaseScript), 'a').labels == [] + } + + def 'should expose the goal directive'() { + given: + def directives = [model: 'openai/gpt-5-mini', instruction: 'be careful', goal: 'assemble then QC'] as Map + def prompt = new PromptDef({ -> 'hi' }, 'hi') + def agent = new AgentDef(Mock(BaseScript), 'a', directives, [], [], prompt) + + expect: + agent.goal == 'assemble then QC' + agent.instruction == 'be careful' + } + + def 'should return null goal when not declared'() { + given: + def agent = new AgentDef(Mock(BaseScript), 'a', [model: 'openai/gpt-5-mini'] as Map, [], [], + new PromptDef({ -> 'hi' }, 'hi')) + expect: + agent.goal == null + } + + static class TestRec implements nextflow.script.types.Record {} + + static class WrapPlan implements nextflow.script.types.Record { + String title + Long count + } + + def 'buildWrapperSchema builds an object-root wrapper with per-output fragments'() { + given: + def outs = [ + new AgentBuilder.AgentOutput('rec', WrapPlan), + new AgentBuilder.AgentOutput('n', Long), + new AgentBuilder.AgentOutput('score', Double), + new AgentBuilder.AgentOutput('flag', Boolean), + new AgentBuilder.AgentOutput('label', String), + ] + + when: + def schema = AgentDef.buildWrapperSchema('agentX', outs) + + then: + schema.type == 'object' + schema.additionalProperties == false + schema.required == ['rec', 'n', 'score', 'flag', 'label'] + (schema.properties.keySet() as List) == ['rec', 'n', 'score', 'flag', 'label'] + + and: 'scalar fragments map to the right JSON-schema type' + schema.properties.n.type == 'integer' + schema.properties.score.type == 'number' + schema.properties.flag.type == 'boolean' + schema.properties.label.type == 'string' + + and: 'nested record fragment recursion is intact' + schema.properties.rec.type == 'object' + schema.properties.rec.properties.title.type == 'string' + schema.properties.rec.properties.count.type == 'integer' + } + + def 'buildWrapperSchema rejects an unsupported top-level output type'() { + when: + AgentDef.buildWrapperSchema('agentX', [new AgentBuilder.AgentOutput('p', java.nio.file.Path)]) + + then: + def e = thrown(ScriptRuntimeException) + e.message.contains('unsupported type') + e.message.contains('`p`') + and: 'the message enumerates the supported output set (plan §4.5/§9)' + e.message.contains('supported:') + e.message.contains('record type') + } + + def 'scalarOutputSchema represents a Path as an exact string field'() { + when: + def schema = AgentDef.scalarOutputSchema(new AgentBuilder.AgentOutput('assembly_path', java.nio.file.Path)) + + then: + schema.type == 'object' + schema.properties.assembly_path.type == 'string' + schema.required == ['assembly_path'] + schema.additionalProperties == false + } + + def 'decodeCanonicalOutput unwraps a scalar final answer contract'() { + given: + def stdout = '{"type":"complete","output":"{\\"answer\\":\\"HELLO\\"}"}' + + expect: + new AgentOutputPlan(AgentOutputMode.SCALAR_CONTRACT, null).decode(stdout, 'answer', String) == 'HELLO' + } + + // ----------------------------------------------------------------------- + // M2 resume: canonical BodyDef.source, key-sorted schema JSON, floating alias, + // PromptDef valRefs (design D2/D3/D5) + // ----------------------------------------------------------------------- + + private AgentDef agentWith(Map directives, PromptDef prompt) { + return new AgentDef(Mock(BaseScript), 'a', directives as Map, [], [], prompt) + } + + def 'canonicalAgentSource is deterministic for the same effective inputs'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']], required: ['x'], additionalProperties: false] + def a = agentWith([instruction: 'be careful', goal: 'do it'], new PromptDef({ -> 'p' }, 'the-prompt')) + def b = agentWith([instruction: 'be careful', goal: 'do it'], new PromptDef({ -> 'p' }, 'the-prompt')) + + expect: + a.canonicalAgentSource('openai/gpt-4o', 20, schema) == b.canonicalAgentSource('openai/gpt-4o', 20, schema) + } + + def 'canonicalAgentSource includes the effective model (differs when model differs)'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']]] + def a = agentWith([instruction: 'i'], new PromptDef({ -> 'p' }, 'src')) + + expect: 'the effective model id (resolved this.model ?: default) is folded in' + a.canonicalAgentSource('openai/gpt-4o', 20, schema) != a.canonicalAgentSource('openai/gpt-4o-mini', 20, schema) + + and: 'the temperature line is the stable literal (task path leaves it unset)' + a.canonicalAgentSource('openai/gpt-4o', 20, schema).contains('temperature=default') + !a.canonicalAgentSource('openai/gpt-4o', 20, schema).contains('temperature=0') + } + + def 'canonicalAgentSource includes an explicitly selected runner'() { + given: + def agent = agentWith([instruction: 'i'], new PromptDef({ -> 'p' }, 'src')) + + expect: + agent.canonicalAgentSource('openai/gpt-4o', 20, null, null, 'pi') != + agent.canonicalAgentSource('openai/gpt-4o', 20, null, null, 'langchain4j') + agent.canonicalAgentSource('openai/gpt-4o', 20, null, null, 'pi').startsWith('agentRunner=pi\n') + } + + // ----------------------------------------------------------------------- + // M-Skills: skill identity folded into the resume cache key (the 4-arg + // canonicalAgentSource overload). These are RED until the overload exists: + // the reflective lookup below throws NoSuchMethodException on current source. + // ----------------------------------------------------------------------- + + /** + * Invoke the (yet-to-exist) 4-arg {@code canonicalAgentSource(String, int, Map, List)} overload + * reflectively so the test compiles against current source (no such method) and fails at RUNTIME + * (RED) rather than breaking compilation of the whole test module — mirroring the reflective + * private-method helpers used elsewhere in this class. Once the overload lands this resolves and + * the assertions run (GREEN). + */ + private static String canonical4(AgentDef agent, String model, int maxIter, Map schema, List skills) { + def m = AgentDef.getDeclaredMethod('canonicalAgentSource', String, Integer.TYPE, Map, List) + m.accessible = true + return (String) m.invoke(agent, model, maxIter, schema, skills) + } + + private static SkillDescriptor skill(String name, String content, List resources = []) { + return new SkillDescriptor(name, "desc of ${name}".toString(), content, resources) + } + + def 'canonicalAgentSource 4-arg with null/empty skills equals the 3-arg form byte-for-byte'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']], required: ['x'], additionalProperties: false] + def a = agentWith([instruction: 'be careful', goal: 'do it'], new PromptDef({ -> 'p' }, 'the-prompt')) + def three = a.canonicalAgentSource('openai/gpt-4o', 20, schema) + + expect: 'a tool-free/skill-free agent keeps an IDENTICAL cache key (no spurious resume invalidation)' + canonical4(a, 'openai/gpt-4o', 20, schema, null) == three + canonical4(a, 'openai/gpt-4o', 20, schema, [] as List) == three + } + + def 'canonicalAgentSource 4-arg differs once skills are present and when a skill changes'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']]] + def a = agentWith([instruction: 'i'], new PromptDef({ -> 'p' }, 'src')) + def three = a.canonicalAgentSource('openai/gpt-4o', 20, schema) + def withGreet = canonical4(a, 'openai/gpt-4o', 20, schema, [ skill('greet', 'v1 instructions') ]) + + expect: 'declaring a skill changes the key (skills are a captured input now)' + withGreet != three + + and: 'a changed SKILL.md body invalidates the cache (different content -> different key)' + canonical4(a, 'openai/gpt-4o', 20, schema, [ skill('greet', 'v2 instructions') ]) != withGreet + + and: 'a changed bundled resource also invalidates the cache' + canonical4(a, 'openai/gpt-4o', 20, schema, [ skill('greet', 'v1 instructions', [ new SkillResource('references/a.txt', 'AAA') ]) ]) != withGreet + + and: 'the fingerprint is order-independent (sorted by name) - same set of skills, reordered, same key' + def s1 = skill('alpha', 'a-body') + def s2 = skill('beta', 'b-body') + canonical4(a, 'openai/gpt-4o', 20, schema, [ s1, s2 ]) == canonical4(a, 'openai/gpt-4o', 20, schema, [ s2, s1 ]) + } + + // ----------------------------------------------------------------------- + // M-Tools resume: tool identity folded into the resume cache key, which is what lets a + // module/process-tool agent be cacheable at all (instead of a blanket `cache false`). + // ----------------------------------------------------------------------- + + private static ToolDescriptor tool(String name, Map inSchema = [type: 'object'], Map outSchema = null) { + return new ToolDescriptor(name: name, description: "desc of ${name}", inputSchema: inSchema, outputSchema: outSchema) + } + + def 'toolsFingerprint is null for no tools so the cache key stays byte-identical'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']]] + def a = agentWith([instruction: 'i'], new PromptDef({ -> 'p' }, 'src')) + + expect: + AgentDef.toolsFingerprint(null, null) == null + AgentDef.toolsFingerprint([] as List, [:]) == null + + and: 'a tool-free agent keeps an IDENTICAL key (no spurious resume invalidation)' + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null) == + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi') + } + + def 'toolsFingerprint tracks the backing process script, not just the tool name'() { + given: + def upper = tool('uppercase') + def v1 = AgentDef.toolsFingerprint([upper], [uppercase: 'tr a-z A-Z']) + + expect: 'editing the tool process script invalidates the agent cache entry' + AgentDef.toolsFingerprint([upper], [uppercase: 'tr A-Z a-z']) != v1 + + and: 'a changed descriptor (what the LLM is told about the tool) also invalidates it' + AgentDef.toolsFingerprint([tool('uppercase', [type: 'object', properties: [text: [type: 'string']]])], + [uppercase: 'tr a-z A-Z']) != v1 + + and: 'an unchanged tool keeps the same fingerprint' + AgentDef.toolsFingerprint([tool('uppercase')], [uppercase: 'tr a-z A-Z']) == v1 + } + + def 'toolsFingerprint is order-independent'() { + given: + def a = tool('alpha') + def b = tool('beta') + def sources = [alpha: 'body-a', beta: 'body-b'] + + expect: 'the same set of tools, declared in either order, is the same key' + AgentDef.toolsFingerprint([a, b], sources) == AgentDef.toolsFingerprint([b, a], sources) + + and: 'but a different set is a different key' + AgentDef.toolsFingerprint([a], sources) != AgentDef.toolsFingerprint([a, b], sources) + } + + def 'a sourceless tool (e.g. filesystem) still fingerprints its descriptor'() { + expect: 'no backing process means a null source element, not a crash' + AgentDef.toolsFingerprint([tool('filesystem')], [:]) != null + AgentDef.toolsFingerprint([tool('filesystem')], [:]) == AgentDef.toolsFingerprint([tool('filesystem')], null) + } + + // ----------------------------------------------------------------------- + // M-Endpoint resume: the RESOLVED endpoint folded into the cache key (design D5). A different + // endpoint serves a different model under the same id, so a replay must not be shared across + // endpoints -- while the credential is not part of an agent's identity and never enters. + // ----------------------------------------------------------------------- + + def 'canonicalAgentSource 7-arg with no endpoint equals the 6-arg form byte-for-byte'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']], required: ['x'], additionalProperties: false] + def a = agentWith([instruction: 'be careful', goal: 'do it'], new PromptDef({ -> 'p' }, 'the-prompt')) + def six = a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null) + + expect: 'an agent with no endpoint set keeps an IDENTICAL key -- no spurious resume invalidation' + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null) == six + and: 'an empty endpoint is treated as unset (the core resolvers normalize "" to null anyway)' + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, '') == six + and: 'the byte-identical chain reaches the 3-arg form that predates all of this' + six == a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi') + !six.contains('baseUrl=') + } + + def 'canonicalAgentSource folds in the resolved endpoint'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']]] + def a = agentWith([instruction: 'i'], new PromptDef({ -> 'p' }, 'src')) + def none = a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null) + def local = a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, 'http://localhost:8000/v1') + + expect: 'declaring an endpoint changes the key, appended as a trailing line' + local != none + local == none + '\nbaseUrl=http://localhost:8000/v1' + + and: 'a DIFFERENT endpoint is a different key' + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, 'http://localhost:9000/v1') != local + + and: 'the endpoint composes with the tools fingerprint rather than replacing it' + def withTools = a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', 'tool-fp', 'http://localhost:8000/v1') + withTools.contains('\ntools=tool-fp') + withTools.endsWith('\nbaseUrl=http://localhost:8000/v1') + } + + def 'no canonicalAgentSource overload can fold in a credential'() { + given: 'drift guard for design D5 -- hashing a credential would invalidate every stored' + // entry on key rotation, and would write a secret into the task hash inputs + def overloads = AgentDef.declaredMethods.findAll { it.name == 'canonicalAgentSource' } + + expect: 'the widest overload is the 8-arg provider-aware one' + overloads.size() == 6 + overloads*.parameterCount.max() == 8 + + and: 'and its two trailing parameters are the endpoint and the credential NAMESPACE --' + // the only two values AgentDef passes from the resolved settings, neither of them a secret + overloads.find { it.parameterCount == 8 }.parameterTypes.toList() == + [String, Integer.TYPE, Map, List, String, String, String, String] + } + + // ----------------------------------------------------------------------- + // D6: an EXPLICIT `agent.apiProvider` selects which environment variables the endpoint and the + // credential come from, so it is part of how this agent was configured. An INFERRED one is a + // pure function of `baseUrl`, which is already in the key -- and leaving it out means a later + // addition to the D3 host table cannot silently invalidate anyone's stored runs. + // ----------------------------------------------------------------------- + + def 'canonicalAgentSource 8-arg with no apiProvider equals the 7-arg form byte-for-byte'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']], required: ['x'], additionalProperties: false] + def a = agentWith([instruction: 'be careful', goal: 'do it'], new PromptDef({ -> 'p' }, 'the-prompt')) + def seven = a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null) + + expect: 'an agent that does not set the option keeps an IDENTICAL key -- no spurious invalidation' + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null, null) == seven + and: 'an empty value is treated as unset (AgentConfig normalizes "" to null anyway)' + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null, '') == seven + and: 'the byte-identical chain still reaches the narrowest form that carries a runner' + seven == a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi') + !seven.contains('apiProvider=') + !seven.contains('baseUrl=') + } + + def 'canonicalAgentSource folds in an explicit apiProvider, after the endpoint'() { + given: + def schema = [type: 'object', properties: [x: [type: 'string']]] + def a = agentWith([instruction: 'i'], new PromptDef({ -> 'p' }, 'src')) + def none = a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null, null) + def routed = a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null, 'openrouter') + + expect: 'the option is appended as a trailing line, so it changes the key exactly once' + routed == none + '\napiProvider=openrouter' + + and: 'a DIFFERENT namespace is a different key -- it selects a different credential' + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null, 'azure') != routed + + and: 'setting it REDUNDANTLY to what the prefix already implied still changes the key' + // the documented one-time invalidation: the key records the CONFIG, not the resolution + a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', null, null, 'openai') != none + + and: 'it composes with the endpoint rather than replacing it, and comes after it' + def both = a.canonicalAgentSource('openai/gpt-4o', 20, schema, null, 'pi', 'tool-fp', 'https://gw.corp/v1', 'openai') + both.contains('\ntools=tool-fp') + both.contains('\nbaseUrl=https://gw.corp/v1') + both.endsWith('\napiProvider=openai') + } + + def 'canonicalJson is key-sorted and insertion-order-independent'() { + given: + def m1 = [b: 1, a: 2, nested: [z: 1, y: 2]] + def m2 = [a: 2, b: 1, nested: [y: 2, z: 1]] + + expect: 'reordered maps (including nested) produce the identical fingerprint' + AgentDef.canonicalJson(m1) == AgentDef.canonicalJson(m2) + + and: 'keys are sorted in the output (a before b, y before z)' + AgentDef.canonicalJson(m1) == '{"a":2,"b":1,"nested":{"y":2,"z":1}}' + } + + def 'PromptDef 3-arg ctor stores valRefs; 2-arg ctor delegates to empty'() { + given: + def refs = [new TokenValRef('params.threshold'), new TokenValRef('task.ext.args')] + + expect: 'the 3-arg ctor carries valRefs and exposes their names' + def p3 = new PromptDef({ -> 'x' }, 'src', refs) + p3.valRefs == refs + (p3.getValNames() as Set) == ['params.threshold', 'task.ext.args'] as Set + + and: 'the 2-arg ctor still works with empty valRefs' + def p2 = new PromptDef({ -> 'x' }, 'src') + p2.valRefs == [] + p2.getValNames() == [] + } + + def 'should expose the skills directive (single, list, none)'() { + expect: + new AgentDef(Mock(BaseScript), 'a', [skills: 'greet'] as Map, [], [], new PromptDef({ -> 'h' }, 'h')).skills == ['greet'] + new AgentDef(Mock(BaseScript), 'a', [skills: ['a', 'b']] as Map, [], [], new PromptDef({ -> 'h' }, 'h')).skills == ['a', 'b'] + new AgentDef(Mock(BaseScript), 'a', [:] as Map, [], [], new PromptDef({ -> 'h' }, 'h')).skills == [] + } + + def 'should no longer reject skills combined with a record (structured) output (M5 guard removed)'() { + given: + // M5 deletes the tools/skills-XOR-structured guard: skills + a record output is + // now allowed (the plugin runs a final structuring turn). Proof at this unit level + // is that run() proceeds PAST the (removed) guard to skills resolution instead of + // short-circuiting with the old guard message. + def inp = new AgentBuilder.AgentInput('q', String) + def out = new AgentBuilder.AgentOutput('a', TestRec) + def agent = new AgentDef(Mock(BaseScript), 'a', [skills: 'greet'] as Map, [inp], [out], new PromptDef({ -> 'h' }, 'h')) + // inject a stub runner so run() gets PAST the runner lookup and deterministically + // reaches skills resolution (otherwise it would abort earlier for a missing runner) + nextflow.agent.AgentRunnerProvider.testRunner = { req -> '{}' } as nextflow.agent.AgentRunner + + when: + agent.run(['x'] as Object[]) + + then: + // the old guard would have thrown ScriptRuntimeException with 'combining tools or + // skills' synchronously; it is gone, so run() proceeds PAST it into skills resolution + // and fails there because the `greet` skill is not on disk in this unit context. The + // positive skills+structured record BIND is covered end-to-end in + // AgentToolBridgeIntegrationTest 'should allow skills with a record (structured) + // output and bind the JSON'. + def e = thrown(ScriptRuntimeException) + !(e.message?.contains('combining tools or skills')) + and: + // proves we reached skills resolution (the removed guard sat before it) + e.message?.contains('skill') && e.message?.contains('greet') + + cleanup: + nextflow.agent.AgentRunnerProvider.testRunner = null + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/script/AgentPreviewWarnTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/AgentPreviewWarnTest.groovy new file mode 100644 index 0000000000..7c17e4faea --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/script/AgentPreviewWarnTest.groovy @@ -0,0 +1,127 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script + +import static test.ScriptHelper.* + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import nextflow.extension.Bolts +import org.slf4j.LoggerFactory +import test.Dsl2Spec + +/** + * A script declaring one or more agents must warn -- once -- that agents are + * a preview feature. + * + * @author Paolo Di Tommaso + */ +class AgentPreviewWarnTest extends Dsl2Spec { + + ListAppender appender + Logger logger + + def setup() { + // the `warn1` dedup cache is JVM-global and throttles for a minute, so it would + // swallow the warning if another test in the same JVM already emitted it + clearLoggerCache() + logger = (Logger) LoggerFactory.getLogger(BaseScript) + logger.setLevel(Level.WARN) + appender = new ListAppender() + appender.start() + logger.addAppender(appender) + } + + def cleanup() { + logger.detachAppender(appender) + clearLoggerCache() + } + + private static void clearLoggerCache() { + final field = Bolts.getDeclaredField('LOGGER_CACHE') + field.setAccessible(true) + (field.get(null) as Map).clear() + } + + private List warnings() { + return appender.list*.formattedMessage + } + + def 'should warn that the agent construct is experimental' () { + when: + loadScript(module: true, ''' + nextflow.enable.types = true + + agent alpha { + model 'openai/gpt-4o' + + input: + question: String + + output: + answer: String + + prompt: + """ + ${question} + """ + } + + agent beta { + model 'openai/gpt-4o' + + input: + question: String + + output: + answer: String + + prompt: + """ + ${question} + """ + } + ''') + + then: 'the warning is emitted once, not once per agent definition' + warnings().findAll { it.startsWith('Agents are') } == [ + 'Agents are a preview feature -- syntax and behavior may change in future releases' + ] + } + + def 'should not warn when the script declares no agent' () { + when: + loadScript(module: true, ''' + nextflow.enable.types = true + + process foo { + output: + out: String + + script: + """ + echo hello + """ + } + ''') + + then: + warnings().findAll { it.startsWith('Agents are') } == [] + } + +} diff --git a/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy index 1952f4a108..bdfb2ad529 100644 --- a/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy @@ -348,6 +348,24 @@ class ProcessEntryHandlerTest extends Specification { result == [] } + def 'should reject an empty-string path input instead of treating it as not provided (v1)' () { + given: + def session = Mock(Session) + def script = Mock(BaseScript) + def meta = Mock(ScriptMeta) { + getLocalProcessNames() >> [ 'hello' ] + } + def handler = new ProcessEntryHandler(script, session, meta) + def pathParam = Mock(FileInParam) { getName() >> 'proteins' } + + when: 'an empty value is supplied for a path input' + handler.getValueForInputV1(pathParam, [proteins: ''], [:]) + + then: 'it is NOT a stand-in for "not provided" -- an optional path is skipped by omitting the arg' + def e = thrown(IllegalArgumentException) + e.message.contains('cannot be') + } + def 'should throw error for missing val input (v1)' () { given: def session = Mock(Session) diff --git a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessConfigBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessConfigBuilderTest.groovy index 24ddd937f7..ebaa54f6f2 100644 --- a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessConfigBuilderTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessConfigBuilderTest.groovy @@ -30,6 +30,14 @@ class ProcessConfigBuilderTest extends Specification { new ProcessConfigBuilder(new ProcessConfig([:])) } + def 'should return distinct selector names in precedence order'() { + expect: + ConfigSelectorResolver.distinctNames('declared', 'alias', 'FLOW:alias') == + ['declared', 'alias', 'FLOW:alias'] + ConfigSelectorResolver.distinctNames('same', 'same', 'same') == ['same'] + ConfigSelectorResolver.distinctNames('declared', 'alias', 'alias') == ['declared', 'alias'] + } + @Unroll def 'should match selector: #SELECTOR with #TARGET' () { expect: diff --git a/modules/nextflow/src/test/groovy/nextflow/script/parser/v2/AgentScriptLoadingTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/parser/v2/AgentScriptLoadingTest.groovy new file mode 100644 index 0000000000..36b4e23213 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/script/parser/v2/AgentScriptLoadingTest.groovy @@ -0,0 +1,361 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script.parser.v2 + +import java.nio.file.Files + +import nextflow.Session +import nextflow.script.AgentDef +import nextflow.script.ScriptMeta +import test.Dsl2Spec + +/** + * End-to-end smoke test: parsing a script with an `agent` block must + * register an {@link AgentDef} on the script's {@link ScriptMeta}. + */ +class AgentScriptLoadingTest extends Dsl2Spec { + + def 'should load a script with a minimal record-typed agent definition'() { + given: + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = ''' + nextflow.enable.types = true + + record Question { text: String } + record Answer { answer: String } + + agent hello_agent { + input: + q: Question + + output: + a: Answer + + prompt: + "hello" + } + + workflow { + } + '''.stripIndent() + + when: + parser.parse(file) + parser.runScript() + + then: + def definitions = ScriptMeta.get(parser.script).getDefinitions() + definitions.any { it instanceof AgentDef && it.name == 'hello_agent' } + + cleanup: + file.parent.deleteDir() + } + + def 'should support helper statements before the prompt text'() { + given: + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = ''' + nextflow.enable.types = true + + agent qa { + input: + question: String + + output: + answer: String + + prompt: + def topic = question.toUpperCase() + def style = 'haiku' + """ + Write a ${style} about ${topic} + """ + } + + workflow { + } + '''.stripIndent() + + when: + parser.parse(file) + parser.runScript() + def agent = ScriptMeta.get(parser.script).getDefinitions() + .find { it instanceof AgentDef && it.name == 'qa' } as AgentDef + def prompt = (Closure) agent.prompt.closure.clone() + prompt.setDelegate([question: 'fastq']) + prompt.setResolveStrategy(Closure.DELEGATE_FIRST) + + then: 'the prompt is the block\'s last expression, with the helpers in scope' + prompt.call().toString().trim() == 'Write a haiku about FASTQ' + + and: 'the source text spans the whole block, so the helpers enter the cache key' + agent.prompt.source.contains('def topic') + + cleanup: + file.parent.deleteDir() + } + + def 'should load a script with a val-typed agent definition'() { + given: + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + + input: + question: String + + output: + answer: String + + prompt: + """ + Answer: ${question} + """ + } + + workflow { + } + '''.stripIndent() + + when: + parser.parse(file) + parser.runScript() + + then: + def definitions = ScriptMeta.get(parser.script).getDefinitions() + def agent = definitions.find { it instanceof AgentDef && it.name == 'qa' } as AgentDef + agent != null + agent.inputs*.name == ['question'] + agent.outputs*.name == ['answer'] + and: + // val I/O resolve to scalar (String) types, not record classes + (agent.inputs[0].type as Class) == String + (agent.outputs[0].type as Class) == String + + cleanup: + file.parent.deleteDir() + } + + def 'should load a script with a directive-rich record-typed agent definition'() { + given: + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = ''' + nextflow.enable.types = true + + record Question { text: String; context: String? } + record Answer { answer: String; confidence: Double } + + agent eval_agent { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + tools() + maxIterations 20 + + input: + q: Question + + output: + a: Answer + + prompt: + """ + Question: ${q.text} + """ + } + + workflow { + } + '''.stripIndent() + + when: + parser.parse(file) + parser.runScript() + + then: + def definitions = ScriptMeta.get(parser.script).getDefinitions() + def agent = definitions.find { it instanceof AgentDef && it.name == 'eval_agent' } as AgentDef + agent != null + agent.model == 'openai/gpt-5-mini' + agent.instruction == 'You are helpful.' + agent.maxIterations == 20 + agent.tools == [] + agent.inputs*.name == ['q'] + agent.outputs*.name == ['a'] + agent.prompt != null + agent.prompt.source.contains('Question:') + and: + // I/O are named record types: the resolved output type is the compiled + // record class (e.g. `Answer`) with the declared fields + def inputType = agent.inputs[0].type as Class + def outputType = agent.outputs[0].type as Class + inputType.name.endsWith('Question') + outputType.name.endsWith('Answer') + (outputType.declaredFields*.name as Set).containsAll(['answer', 'confidence']) + + cleanup: + file.parent.deleteDir() + } + + def 'should load a script with namespaced tool refs (nf:module_run + fs:*) and expose them via getTools()'() { + given: + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = ''' + nextflow.enable.types = true + + process word_stats { + input: text: String + output: stats: String + exec: stats = "{}" + } + + agent analyst { + model 'openai/gpt-5-mini' + instruction 'Analyse text.' + + tools 'nf:module_run', 'fs:*' + + input: + text: String + output: + summary: String + + prompt: + """ + Analyse: ${text} + """ + } + + workflow { + } + '''.stripIndent() + + when: + parser.parse(file) + parser.runScript() + + then: + def definitions = ScriptMeta.get(parser.script).getDefinitions() + def agent = definitions.find { it instanceof AgentDef && it.name == 'analyst' } as AgentDef + agent != null + agent.tools.contains('nf:module_run') + agent.tools.contains('fs:*') + agent.tools.size() == 2 + + cleanup: + file.parent.deleteDir() + } + + def 'should load a script with a skills directive and expose it via getSkills()'() { + given: + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = ''' + nextflow.enable.types = true + + agent helper { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + + skills 'greet', 'github.com/org/repo' + + input: + question: String + output: + answer: String + + prompt: + """ + ${question} + """ + } + + workflow { + } + '''.stripIndent() + + when: + parser.parse(file) + parser.runScript() + + then: + def definitions = ScriptMeta.get(parser.script).getDefinitions() + def agent = definitions.find { it instanceof AgentDef && it.name == 'helper' } as AgentDef + agent != null + agent.skills == ['greet', 'github.com/org/repo'] + + cleanup: + file.parent.deleteDir() + } + + def 'should load a script with a goal directive and expose it via getGoal()'() { + given: + def session = new Session() + def parser = new ScriptLoaderV2(session) + def file = Files.createTempDirectory('test').resolve('main.nf') + file.text = ''' + nextflow.enable.types = true + + agent qa { + model 'openai/gpt-5-mini' + instruction 'be concise' + goal 'answer the question accurately' + + input: + question: String + + output: + answer: String + + prompt: + """ + ${question} + """ + } + + workflow { + } + '''.stripIndent() + + when: + parser.parse(file) + parser.runScript() + + then: + def definitions = ScriptMeta.get(parser.script).getDefinitions() + def agent = definitions.find { it instanceof AgentDef && it.name == 'qa' } as AgentDef + agent != null + agent.goal == 'answer the question accurately' + agent.instruction == 'be concise' + + cleanup: + file.parent.deleteDir() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/script/parser/v2/ScriptLoaderV2Test.groovy b/modules/nextflow/src/test/groovy/nextflow/script/parser/v2/ScriptLoaderV2Test.groovy index 7fec9e7692..c03771bc99 100644 --- a/modules/nextflow/src/test/groovy/nextflow/script/parser/v2/ScriptLoaderV2Test.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/script/parser/v2/ScriptLoaderV2Test.groovy @@ -377,4 +377,57 @@ class ScriptLoaderV2Test extends Dsl2Spec { e.cause.message.contains 'Publish statements cannot be mixed with other statements in a dynamic publish path' } + def 'should statically resolve agent names apart from process names' () { + + given: + def session = new Session() + def parser = new ScriptLoaderV2(session) + + def TEXT = ''' + nextflow.enable.types = true + + process greet { + script: + """ + echo hi + """ + } + + agent critic { + model 'openai/gpt-5-mini' + + input: + q: String + + output: + a: String + + prompt: + """ + ${q} + """ + } + + workflow inner { + take: + ch + + main: + greet() + critic(ch) + } + + workflow { + inner(channel.of('x')) + } + ''' + + when: 'the script is compiled but not yet run, so only the static call-site walk has contributed' + parser.parse(TEXT) + + then: 'the agent is reported under its fully-qualified name, on the agent axis only' + ScriptMeta.allAgentNames() == ['inner:critic'] as Set + ScriptMeta.allProcessNames() == ['inner:greet'] as Set + } + } diff --git a/modules/nextflow/src/test/groovy/nextflow/trace/TraceRecordTest.groovy b/modules/nextflow/src/test/groovy/nextflow/trace/TraceRecordTest.groovy index 2d361d45fa..97a44b8557 100644 --- a/modules/nextflow/src/test/groovy/nextflow/trace/TraceRecordTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/trace/TraceRecordTest.groovy @@ -290,6 +290,23 @@ class TraceRecordTest extends Specification { } + def 'should remove an LLM provider api key from the recorded env' () { + given: 'the twin of SecretHelper.SECRET_REGEX. A trace record is persisted in the resume' + // cache and POSTed to Seqera Platform by nf-tower, and the out-of-band credential channel + // the agent docs recommend for a containerized runner puts the key in exactly this field + def rec = new TraceRecord() + + expect: + rec.secureEnvString('OPENAI_API_KEY=sk-1234') == 'OPENAI_API_KEY=[secure]' + rec.secureEnvString('ANTHROPIC_API_KEY=sk-ant') == 'ANTHROPIC_API_KEY=[secure]' + rec.secureEnvString('NXF_AGENT_API_KEY=sk-nxf') == 'NXF_AGENT_API_KEY=[secure]' + rec.secureEnvString('api_key=snake') == 'api_key=[secure]' + + and: 'through the field accessors, which is how a task environment actually gets there' + new TraceRecord().tap { it.env = 'FOO=bar\nOPENAI_API_KEY=sk-5678\n' }.store.env == + 'FOO=bar\nOPENAI_API_KEY=[secure]\n' + } + def 'should store safe env' () { given: def rec = new TraceRecord() diff --git a/modules/nextflow/src/test/groovy/nextflow/util/SecretHelperTest.groovy b/modules/nextflow/src/test/groovy/nextflow/util/SecretHelperTest.groovy index 694d9f14e7..ecba6fc90d 100644 --- a/modules/nextflow/src/test/groovy/nextflow/util/SecretHelperTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/util/SecretHelperTest.groovy @@ -71,4 +71,57 @@ class SecretHelperTest extends BaseSpec { ] } + def 'should remove an api key' () { + given: 'the `api_?key` alternation of SECRET_KEYS. `accessKey` does NOT match `apiKey`, so' + // without it a resolved LLM provider credential (`agent.apiKey`, possibly from a + // `secrets.*` reference already expanded to its real value) is persisted VERBATIM by the + // lineage observer and shipped to Seqera Platform as `workflow.configText`. + // NOTE cross-cutting by design: this also hides every other `*apiKey*`/`*API_KEY*` config + // key -- `ncbi.apiKey`, `registry.apiKey`, a user's own -- in `nextflow config` output and + // in every lineage record. Intended: they are all credentials. + def obj = [ + agent: [apiKey: 'sk-agent-1234', baseUrl: 'http://localhost:8000/v1'], + ncbi: [apiKey: 'ncbi-abcd'], + nested: [ [OPENAI_API_KEY: 'sk-env-5678'], [api_key: 'snake-9999'] ], + keyword: 'not a credential' ] + + expect: + SecretHelper.hideSecrets(obj) == [ + agent: [apiKey: '[secret]', baseUrl: 'http://localhost:8000/v1'], + ncbi: [apiKey: '[secret]'], + nested: [ [OPENAI_API_KEY: '[secret]'], [api_key: '[secret]'] ], + keyword: 'not a credential' ] + + and: 'the endpoint is not a secret and stays readable -- it is diagnostics, not a credential' + SecretHelper.hideSecrets([agent: [baseUrl: 'http://localhost:8000/v1']]) == + [agent: [baseUrl: 'http://localhost:8000/v1']] + } + + def 'should mask an api key in an environment string too, not only in a config map' () { + given: 'SECRET_KEYS gained `api_?key` and SECRET_REGEX had not, so the OUT-OF-BAND channel' + // the agent docs still recommend for a containerized runner -- `env { OPENAI_API_KEY = ... }` + // and `agent.containerOptions = '-e OPENAI_API_KEY'` -- was masked in the config dump and + // NOT in the `NAME=value` environment lines a trace record carries + expect: + SecretHelper.secureEnvString('OPENAI_API_KEY=sk-1234') == 'OPENAI_API_KEY=[secure]' + SecretHelper.secureEnvString('ANTHROPIC_API_KEY=sk-ant-1234') == 'ANTHROPIC_API_KEY=[secure]' + SecretHelper.secureEnvString('NXF_AGENT_API_KEY=sk-nxf') == 'NXF_AGENT_API_KEY=[secure]' + SecretHelper.secureEnvString('api_key=snake') == 'api_key=[secure]' + SecretHelper.secureEnvString('apiKey=camel') == 'apiKey=[secure]' + + and: 'mixed with the lines that already matched, one per line' + SecretHelper.secureEnvString('''\ + foo=hello + OPENAI_API_KEY=sk-abcd + git_token=909s-ds-''' + .stripIndent() ) == + '''\ + foo=hello + OPENAI_API_KEY=[secure] + git_token=[secure]'''.stripIndent() + + and: 'a variable that merely mentions an api is untouched -- this masks credentials, not URLs' + SecretHelper.secureEnvString('API_URL=https://api.openai.com/v1') == 'API_URL=https://api.openai.com/v1' + } + } diff --git a/modules/nextflow/src/test/groovy/nextflow/util/SimpleHttpClientTest.groovy b/modules/nextflow/src/test/groovy/nextflow/util/SimpleHttpClientTest.groovy index 4246825d44..29d62c90ce 100644 --- a/modules/nextflow/src/test/groovy/nextflow/util/SimpleHttpClientTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/util/SimpleHttpClientTest.groovy @@ -113,8 +113,9 @@ class SimpleHttpClientTest extends Specification{ def PAYLOAD = '{"hello":"world!"}' def RESULT = '{"status":"OK"}' def TOKEN = 'my:secret' - def PORT = 9900 - def ENDPOINT = "http://localhost:$PORT/foo" + // ephemeral port - see the note in ConfigParserV1Test: a fixed port races the other + // HTTP-serving tests, including ones in modules whose test tasks run concurrently + def PORT = 0 def AGENT = 'NEXTFLOW/1.0' def expectedPayload @@ -138,6 +139,8 @@ class SimpleHttpClientTest extends Specification{ } }); server.start() + // resolved AFTER start: with PORT=0 the real port is only known once bound + def ENDPOINT = "http://localhost:${server.address.port}/foo".toString() when: def client = new SimpleHttpClient() diff --git a/modules/nf-cli-v1/src/main/groovy/nextflow/cli/CmdRun.groovy b/modules/nf-cli-v1/src/main/groovy/nextflow/cli/CmdRun.groovy index 72044518d3..c4b642dd07 100644 --- a/modules/nf-cli-v1/src/main/groovy/nextflow/cli/CmdRun.groovy +++ b/modules/nf-cli-v1/src/main/groovy/nextflow/cli/CmdRun.groovy @@ -196,6 +196,9 @@ class CmdRun extends CmdBase implements HubAware { @Parameter(names = ['-with-timeline'], description = 'Create processes execution timeline file') String withTimeline + @Parameter(names = ['-with-agent-trace'], description = 'Log a readable trace of agent execution: turns, model reasoning and tool invocations at INFO (tool inputs/outputs at DEBUG)', arity = 0) + boolean withAgentTrace + @Parameter(names = '-with-charliecloud', description = 'Enable process execution in a Charliecloud container runtime') def withCharliecloud diff --git a/modules/nf-cli-v1/src/main/groovy/nextflow/config/ConfigCmdAdapter.groovy b/modules/nf-cli-v1/src/main/groovy/nextflow/config/ConfigCmdAdapter.groovy index fb1315e96a..e7d2b07b2e 100644 --- a/modules/nf-cli-v1/src/main/groovy/nextflow/config/ConfigCmdAdapter.groovy +++ b/modules/nf-cli-v1/src/main/groovy/nextflow/config/ConfigCmdAdapter.groovy @@ -429,6 +429,13 @@ class ConfigCmdAdapter { config.trace.file = cmdRun.withTrace } + // -- sets agent execution trace option + if( cmdRun.withAgentTrace ) { + if( !(config.agent instanceof Map) ) + config.agent = [:] + config.agent.trace = true + } + // -- sets report report options if( cmdRun.withReport ) { if( !(config.report instanceof Map) ) diff --git a/modules/nf-cli-v1/src/test/groovy/nextflow/config/ConfigCmdAdapterTest.groovy b/modules/nf-cli-v1/src/test/groovy/nextflow/config/ConfigCmdAdapterTest.groovy index 5352452683..3e19ab5501 100644 --- a/modules/nf-cli-v1/src/test/groovy/nextflow/config/ConfigCmdAdapterTest.groovy +++ b/modules/nf-cli-v1/src/test/groovy/nextflow/config/ConfigCmdAdapterTest.groovy @@ -691,6 +691,14 @@ class ConfigCmdAdapterTest extends Specification { config.trace.enabled } + def 'run with agent trace flag sets agent.trace'() { + when: + def config = new ConfigObject() + new ConfigCmdAdapter().configRunOptions(config, [:], new CmdRun(withAgentTrace: true)) + then: + config.agent.trace == true + } + def 'should set session report options' () { given: diff --git a/modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy b/modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy index fb7a958463..3b32281ea0 100644 --- a/modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy +++ b/modules/nf-commons/src/main/nextflow/plugin/PluginsFacade.groovy @@ -478,10 +478,25 @@ class PluginsFacade implements PluginStateListener { if( (Bolts.navigate(config,'wave.enabled') || Bolts.navigate(config,'fusion.enabled')) && !specs.find {it.id == 'nf-wave' } ) { specs << defaultPlugins.getPlugin('nf-wave') } - if( Bolts.navigate(config,'process.executor')=='seqera') { + if( 'seqera' in configuredExecutors(config) ) { specs << defaultPlugins.getPlugin('nf-seqera') } + // the `agent` scope names the RUNNER; the plugin providing it is loaded automatically, as an + // executor or a work-dir scheme is above. Skipped when the user declared an agent plugin + // themselves: adding a second one would make the runner ambiguous + // (see nextflow.agent.AgentRunnerProvider#get) instead of resolving it. + // Keyed on the scope being present, not merely on the runner name: `agentRunnerPlugin(null)` + // answers "the default runner", which must not pull nf-agent into every pipeline that + // never mentions an agent. + final agentScope = Bolts.navigate(config,'agent') + final agentPlugin = agentScope + ? agentRunnerPlugin(Bolts.navigate(config,'agent.runner') as String) + : null + if( agentPlugin && !specs.find { it.id in AGENT_RUNNER_PLUGINS.values() } ) { + specs << defaultPlugins.getPlugin(agentPlugin) + } + // add cloudcache plugin when cloudcache is enabled in the config if( Bolts.navigate(config, 'cloudcache.enabled')==true ) { specs << defaultPlugins.getPlugin('nf-cloudcache') @@ -491,6 +506,40 @@ class PluginsFacade implements PluginStateListener { return specs } + /** + * Maps an {@code agent.runner} name to the plugin that contributes it. The runner name is the + * user-facing selector, so the plugin id is an implementation detail they should not have to + * repeat in the {@code plugins} scope -- exactly as {@code process.executor = 'k8s'} does not + * require declaring {@code nf-k8s}. + */ + private static final Map AGENT_RUNNER_PLUGINS = Collections.unmodifiableMap( + [ 'pi': 'nf-agent-pi', 'langchain4j': 'nf-agent' ] as Map ) + + /** + * The plugin to load for the configured agent runner: the mapped one when the name is known, + * and the in-JVM {@code langchain4j} runner when {@code agent.runner} is unset -- it needs + * neither a container nor a reachable broker address, so it is the safe default for a bare + * {@code agent} scope. + * + *

{@code null} for an unrecognised name: it may come from a third-party plugin the user + * declared themselves, and guessing a plugin id from it would replace a clear + * "Unknown agent runner" error with a confusing download failure. + */ + protected static String agentRunnerPlugin(String runner) { + return runner ? AGENT_RUNNER_PLUGINS.get(runner) : 'nf-agent' + } + + /** + * Every executor name a run may need a plugin for. An AGENT resolves its executor from the + * {@code agent} scope independently of {@code process} -- it defaults to `local` and never + * inherits the global executor -- so an agent offloaded to Kubernetes pulls nf-k8s even when + * every process stays local, and the two placements may need different plugins. + */ + private static List configuredExecutors(Map config) { + return [ Bolts.navigate(config, 'process.executor')?.toString(), + Bolts.navigate(config, 'agent.executor')?.toString() ] + } + protected List defaultPluginsConf(Map config) { // retrieve the list from the env var final commaSepList = env.get('NXF_PLUGINS_DEFAULT') @@ -506,18 +555,18 @@ class PluginsFacade implements PluginStateListener { final plugins = new ArrayList() final workDir = config.workDir as String final bucketDir = config.bucketDir as String - final executor = Bolts.navigate(config, 'process.executor') + final executors = configuredExecutors(config) - if( executor == 'awsbatch' || workDir?.startsWith('s3://') || bucketDir?.startsWith('s3://') || env.containsKey('NXF_ENABLE_AWS_SES') ) + if( 'awsbatch' in executors || workDir?.startsWith('s3://') || bucketDir?.startsWith('s3://') || env.containsKey('NXF_ENABLE_AWS_SES') ) plugins << defaultPlugins.getPlugin('nf-amazon') - if( executor == 'google-lifesciences' || executor == 'google-batch' || workDir?.startsWith('gs://') || bucketDir?.startsWith('gs://') ) + if( 'google-lifesciences' in executors || 'google-batch' in executors || workDir?.startsWith('gs://') || bucketDir?.startsWith('gs://') ) plugins << defaultPlugins.getPlugin('nf-google') - if( executor == 'azurebatch' || workDir?.startsWith('az://') || bucketDir?.startsWith('az://') ) + if( 'azurebatch' in executors || workDir?.startsWith('az://') || bucketDir?.startsWith('az://') ) plugins << defaultPlugins.getPlugin('nf-azure') - if( executor == 'k8s' ) + if( 'k8s' in executors ) plugins << defaultPlugins.getPlugin('nf-k8s') if( Bolts.navigate(config, 'weblog.enabled')) diff --git a/modules/nf-commons/src/test/nextflow/plugin/PluginsFacadeTest.groovy b/modules/nf-commons/src/test/nextflow/plugin/PluginsFacadeTest.groovy index d57e9bfb04..e1886db4e3 100644 --- a/modules/nf-commons/src/test/nextflow/plugin/PluginsFacadeTest.groovy +++ b/modules/nf-commons/src/test/nextflow/plugin/PluginsFacadeTest.groovy @@ -201,6 +201,92 @@ class PluginsFacadeTest extends Specification { } + def 'should infer the executor plugin from the agent scope as well as the process scope' () { + given: + def defaults = new DefaultPlugins(plugins: [ + 'nf-amazon': new PluginRef('nf-amazon', '0.1.0'), + 'nf-azure': new PluginRef('nf-azure', '0.1.0'), + 'nf-google': new PluginRef('nf-google', '0.1.0'), + 'nf-k8s': new PluginRef('nf-k8s', '0.1.0'), + 'nf-seqera': new PluginRef('nf-seqera', '0.1.0') + ]) + // an explicit empty env: the real one leaks otherwise (NXF_ENABLE_AWS_SES alone would add + // nf-amazon to every row), which is why the neighbouring specs push an empty SysEnv + def handler = new PluginsFacade(defaultPlugins: defaults, env: [:]) + + expect: 'an agent resolves its executor independently, so it pulls its own plugin' + handler.defaultPluginsConf(config).collect { it.id }.toSorted() == expected + + where: + config || expected + // the gap: an agent offloaded to k8s while every process stays local + [agent:[executor:'k8s']] || ['nf-k8s'] + [process:[executor:'k8s']] || ['nf-k8s'] + [agent:[executor:'awsbatch']] || ['nf-amazon'] + [agent:[executor:'google-batch']] || ['nf-google'] + [agent:[executor:'azurebatch']] || ['nf-azure'] + // independent placement: each scope contributes its own + [process:[executor:'k8s'], agent:[executor:'awsbatch']] || ['nf-amazon', 'nf-k8s'] + // the same executor on both scopes is not requested twice + [process:[executor:'k8s'], agent:[executor:'k8s']] || ['nf-k8s'] + // the agent default needs no plugin + [agent:[executor:'local'], process:[executor:'local']] || [] + } + + def 'should infer the seqera executor plugin from the agent scope' () { + given: 'nf-agent is reachable because any `agent` scope also pulls the default runner' + def defaults = new DefaultPlugins(plugins: [ + 'nf-agent': new PluginRef('nf-agent', '0.1.0'), + 'nf-seqera': new PluginRef('nf-seqera', '0.1.0') + ]) + def handler = new PluginsFacade(defaultPlugins: defaults, env: [:]) + + when: 'the agent is placed on the seqera executor and every process stays local' + def result = handler.pluginsRequirement([agent:[executor:'seqera']]) + + then: + result.collect { it.id }.toSorted() == ['nf-agent', 'nf-seqera'] + } + + def 'should load the agent runner plugin named by the agent scope' () { + given: + def defaults = new DefaultPlugins(plugins: [ + 'nf-agent': new PluginRef('nf-agent', '0.1.0'), + 'nf-agent-pi': new PluginRef('nf-agent-pi', '0.1.0') + ]) + def handler = new PluginsFacade(defaultPlugins: defaults, env: [:]) + + expect: 'the runner name selects the plugin that contributes it' + handler.pluginsRequirement(config) == expected + + where: + config || expected + // no agent scope at all: nothing is added, or every pipeline would pull nf-agent + [:] || [] + [process:[executor:'local']] || [] + // an agent scope with no runner takes the in-JVM default + [agent:[container:'img']] || [new PluginRef('nf-agent', '0.1.0')] + [agent:[runner:'langchain4j']] || [new PluginRef('nf-agent', '0.1.0')] + [agent:[runner:'pi']] || [new PluginRef('nf-agent-pi', '0.1.0')] + // an unknown name may come from a third-party plugin the user declares themselves + [agent:[runner:'acme']] || [] + } + + def 'should not add an agent runner plugin when one is already declared' () { + given: + def defaults = new DefaultPlugins(plugins: [ + 'nf-agent': new PluginRef('nf-agent', '0.1.0'), + 'nf-agent-pi': new PluginRef('nf-agent-pi', '0.1.0') + ]) + def handler = new PluginsFacade(defaultPlugins: defaults, env: [:]) + + when: 'the user declared the pi plugin and set no runner' + def result = handler.pluginsRequirement([plugins:['nf-agent-pi@0.1.0'], agent:[container:'img']]) + + then: 'the default is NOT added beside it - two runners would be ambiguous' + result == [ new PluginRef('nf-agent-pi', '0.1.0') ] + } + def 'should return default plugins given config' () { given: SysEnv.push([:]) diff --git a/modules/nf-lang/src/main/antlr/ScriptLexer.g4 b/modules/nf-lang/src/main/antlr/ScriptLexer.g4 index fcf878705f..17d0dc9f1e 100644 --- a/modules/nf-lang/src/main/antlr/ScriptLexer.g4 +++ b/modules/nf-lang/src/main/antlr/ScriptLexer.g4 @@ -349,6 +349,10 @@ PARAMS : 'params'; INCLUDE : 'include'; FROM : 'from'; +// -- agent definition +AGENT : 'agent'; +PROMPT : 'prompt'; + // -- process definition PROCESS : 'process'; EXEC : 'exec'; diff --git a/modules/nf-lang/src/main/antlr/ScriptParser.g4 b/modules/nf-lang/src/main/antlr/ScriptParser.g4 index 18bf76c75c..50b1a79936 100644 --- a/modules/nf-lang/src/main/antlr/ScriptParser.g4 +++ b/modules/nf-lang/src/main/antlr/ScriptParser.g4 @@ -114,6 +114,7 @@ scriptDeclaration | paramDeclarationV1 #paramDeclV1Alt | recordDef #recordDefAlt | enumDef #enumDefAlt + | agentDef #agentDefAlt | processDef #processDefAlt | workflowDef #workflowDefAlt | outputDef #outputDefAlt @@ -282,6 +283,36 @@ processStub : STUB COLON nls blockStatements ; +// -- agent definition +agentDef + : AGENT name=identifier nls LBRACE + body=agentBody? + sep? RBRACE + ; + +agentBody + : (sep agentDirectives)? + (sep agentInputs)? + (sep agentOutputs)? + sep agentPrompt + ; + +agentDirectives + : statement (sep statement)* + ; + +agentInputs + : INPUT COLON nls processInput (sep processInput)* + ; + +agentOutputs + : OUTPUT COLON nls processOutput (sep processOutput)* + ; + +agentPrompt + : PROMPT COLON nls blockStatements + ; + // -- workflow definition workflowDef : WORKFLOW name=identifier? nls LBRACE @@ -598,6 +629,8 @@ identifier | PARAMS | FROM | RECORD + | AGENT + | PROMPT | PROCESS | EXEC | INPUT @@ -802,6 +835,8 @@ keywords | INCLUDE | FROM | RECORD + | AGENT + | PROMPT | PROCESS | EXEC | INPUT diff --git a/modules/nf-lang/src/main/java/nextflow/config/control/VariableScopeVisitor.java b/modules/nf-lang/src/main/java/nextflow/config/control/VariableScopeVisitor.java index 9d5ba5a1b2..bb6762bed6 100644 --- a/modules/nf-lang/src/main/java/nextflow/config/control/VariableScopeVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/config/control/VariableScopeVisitor.java @@ -145,7 +145,8 @@ public void visitConfigAssign(ConfigAssignNode node) { * Determine whether a config option can access the process * DSL for dynamic settings. * - * This includes options in the `process` config scope and `executor.jobName`. + * This includes options in the `process` and `agent` config scopes + * and `executor.jobName`. * * @param scopes * @param node @@ -153,7 +154,7 @@ public void visitConfigAssign(ConfigAssignNode node) { private static boolean isProcessScope(List scopes, ConfigAssignNode node) { if( scopes.isEmpty() ) return false; - if( "process".equals(scopes.get(0)) ) + if( "process".equals(scopes.get(0)) || "agent".equals(scopes.get(0)) ) return true; var option = node.names.get(node.names.size() - 1); return scopes.size() == 1 diff --git a/modules/nf-lang/src/main/java/nextflow/config/spec/SpecNode.java b/modules/nf-lang/src/main/java/nextflow/config/spec/SpecNode.java index 2e3b0717d4..9910c9edee 100644 --- a/modules/nf-lang/src/main/java/nextflow/config/spec/SpecNode.java +++ b/modules/nf-lang/src/main/java/nextflow/config/spec/SpecNode.java @@ -46,8 +46,20 @@ private static Scope rootScope() { var result = Scope.of(Config.class, ""); // derive `nextflow` config options from feature flags. result.children().put("nextflow", nextflowScope()); - // derive `process` config options from process directives. - result.children().put("process", processScope()); + // derive `process` and `agent` config options from process directives: an agent + // task accepts the same task directives, in its own independent scope. + // NOTE: the descriptions are inlined because an interface cannot declare private fields. + result.children().put("process", directiveScope(""" + The `process` scope allows you to specify default directives for processes in your pipeline. + + [Read more](https://docs.seqera.io/nextflow/config#process-configuration) + """)); + result.children().put("agent", directiveScope(""" + The `agent` scope allows you to specify default directives for agents in your pipeline. An + agent task accepts the same task directives as a process, in its own independent scope. + + [Read more](https://docs.seqera.io/nextflow/agent#configuration) + """)); return result; } @@ -76,20 +88,17 @@ else if( fqName.startsWith("nextflow.preview.") ) } /** - * Initialize the `process` config scope from the set of - * process directives. + * Initialize a task-directive config scope (`process`, `agent`) from the + * set of process directives. * * Directives with multiple method overloads are treated as * options with multiple supported types. Method overloads with * multiple parameters are ignored because they are not supported * in the configuration. + * + * @param description */ - private static SpecNode processScope() { - var description = """ - The `process` scope allows you to specify default directives for processes in your pipeline. - - [Read more](https://docs.seqera.io/nextflow/config#process-configuration) - """; + private static SpecNode directiveScope(String description) { var children = new HashMap(); for( var method : ProcessDsl.DirectiveDsl.class.getDeclaredMethods() ) { if( method.getParameters().length != 1 ) diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/AgentNode.java b/modules/nf-lang/src/main/java/nextflow/script/ast/AgentNode.java new file mode 100644 index 0000000000..dca7120977 --- /dev/null +++ b/modules/nf-lang/src/main/java/nextflow/script/ast/AgentNode.java @@ -0,0 +1,47 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script.ast; + +import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.stmt.EmptyStatement; +import org.codehaus.groovy.ast.stmt.Statement; + +/** + * AST node for an agent definition. + * + * Mirrors the shape of {@link ProcessNodeV2} but with a simpler body: + * directives, typed inputs, typed outputs, and a prompt block. + * No script/exec/stub/when/stage/topic — execution is delegated to the + * agent runner (see nf-agent plugin). + */ +public class AgentNode extends MethodNode { + + public final Statement directives; + public final Parameter[] inputs; + public final Statement outputs; + public final Statement prompt; + + public AgentNode(String name, Statement directives, Parameter[] inputs, Statement outputs, Statement prompt) { + super(name, 0, ClassHelper.OBJECT_TYPE, inputs, ClassNode.EMPTY_ARRAY, EmptyStatement.INSTANCE); + this.directives = directives; + this.inputs = inputs; + this.outputs = outputs; + this.prompt = prompt; + } +} diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptNode.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptNode.java index ccb43f3d2a..b46151b823 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptNode.java +++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptNode.java @@ -39,6 +39,7 @@ public class ScriptNode extends ModuleNode { private OutputBlockNode outputs; private List workflows = new ArrayList<>(); private List processes = new ArrayList<>(); + private List agents = new ArrayList<>(); private List functions = new ArrayList<>(); public ScriptNode(SourceUnit sourceUnit) { @@ -68,6 +69,7 @@ public List getDeclarations() { declarations.add(wn); } declarations.addAll(processes); + declarations.addAll(agents); declarations.addAll(functions); declarations.addAll(getTypes()); return declarations; @@ -105,6 +107,10 @@ public List getProcesses() { return processes; } + public List getAgents() { + return agents; + } + public List getFunctions() { return functions; } @@ -151,6 +157,10 @@ public void addProcess(ProcessNode processNode) { processes.add(processNode); } + public void addAgent(AgentNode agentNode) { + agents.add(agentNode); + } + public void addFunction(FunctionNode functionNode) { functions.add(functionNode); } diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitor.java index 3cdca35177..3ff6a05065 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitor.java @@ -35,6 +35,8 @@ public interface ScriptVisitor extends GroovyCodeVisitor { void visitWorkflow(WorkflowNode node); + void visitAgent(AgentNode node); + void visitProcess(ProcessNode node); void visitProcessV2(ProcessNodeV2 node); diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitorSupport.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitorSupport.java index 8f9e215717..33f44cf2a3 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitorSupport.java +++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitorSupport.java @@ -40,6 +40,8 @@ public void visit(ScriptNode script) { visitWorkflow(workflowNode); for( var processNode : script.getProcesses() ) visitProcess(processNode); + for( var agentNode : script.getAgents() ) + visitAgent(agentNode); for( var functionNode : script.getFunctions() ) visitFunction(functionNode); for( var classNode : script.getClasses() ) { @@ -85,6 +87,13 @@ public void visitWorkflow(WorkflowNode node) { visit(node.onError); } + @Override + public void visitAgent(AgentNode node) { + visit(node.directives); + visit(node.outputs); + visit(node.prompt); + } + @Override public void visitProcess(ProcessNode node) { if( node instanceof ProcessNodeV2 pn ) diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/AgentToGroovyVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/AgentToGroovyVisitor.java new file mode 100644 index 0000000000..1af67225ff --- /dev/null +++ b/modules/nf-lang/src/main/java/nextflow/script/control/AgentToGroovyVisitor.java @@ -0,0 +1,137 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script.control; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import nextflow.script.ast.ASTNodeMarker; +import nextflow.script.ast.AgentNode; +import nextflow.script.ast.AssignmentExpression; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.VariableScope; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.VariableExpression; +import org.codehaus.groovy.ast.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.ExpressionStatement; +import org.codehaus.groovy.ast.stmt.Statement; +import org.codehaus.groovy.control.SourceUnit; + +import static nextflow.script.ast.ASTUtils.*; +import static org.codehaus.groovy.ast.tools.GeneralUtils.*; + +/** + * Lowers an {@link AgentNode} to a runtime {@code agent('name', { ... })} call. + * The generated closure carries the directives, typed inputs/outputs and a + * {@code PromptDef}, mirroring how {@link ProcessToGroovyVisitorV2} lowers a + * process body. + */ +public class AgentToGroovyVisitor { + + private SourceUnit sourceUnit; + + private ScriptToGroovyHelper sgh; + + public AgentToGroovyVisitor(SourceUnit sourceUnit) { + this.sourceUnit = sourceUnit; + this.sgh = new ScriptToGroovyHelper(sourceUnit); + } + + public Statement transform(AgentNode node) { + // an agent's typed I/O IS a process's typed I/O, so the implicit stagers and the + // output unstagers are inferred by the very same compiler units the process uses + var stagers = new BlockStatement(); + // deliberately the RAW inputs, not asFlatParams: a tuple input is rejected upstream + // (ScriptResolveVisitor) precisely because it declares no context slot to stage from, + // and this keeps the stager loop provably in step with the `_input_` loop below + for( var input : node.inputs ) + ImplicitStagers.visitInputType(input, varX(input.getName()), stagers); + + var unstagers = new BlockStatement(); + // one visitor for the whole agent, so the `$path` keys are unique across outputs; + // filesOnly because an agent has no task script to read `env`/`eval` back from + var unstageVisitor = new ProcessToGroovyVisitorV2.ProcessUnstageVisitor(unstagers, true); + for( var stmt : asBlockStatements(node.outputs) ) + unstageVisitor.visit(stmt); + + var statements = new ArrayList(); + statements.add(node.directives); + // the stagers/unstagers MUST precede the prompt: BaseScript.agent takes the closure's + // RETURN value as the PromptDef, so the prompt statement has to stay last + statements.add(stagers); + statements.add(unstagers); + statements.add(agentInputs(node.inputs)); + statements.add(agentOutputs(node.outputs)); + statements.add(agentPrompt(node.prompt)); + var body = closureX(block(new VariableScope(), statements)); + return stmt(callThisX("agent", args(constX(node.getName()), body))); + } + + private Statement agentInputs(Parameter[] inputs) { + var statements = Arrays.stream(inputs) + .map((input) -> { + var type = input.getType(); + // a `Path?` declaration must mean optional here exactly as it does for a process, + // otherwise a null value is rejected by TaskProcessor telling the user to append + // the `?` they already appended + var optional = type.getNodeMetaData(ASTNodeMarker.NULLABLE) != null; + return (Statement) stmt(callThisX("_input_", args(constX(input.getName()), classX(type), constX(optional)))); + }) + .toList(); + return block(null, statements); + } + + private Statement agentOutputs(Statement outputs) { + var statements = asBlockStatements(outputs).stream() + .map(s -> ((ExpressionStatement) s).getExpression()) + .map(AgentToGroovyVisitor::outputDeclaration) + .filter(Objects::nonNull) + .toList(); + return block(null, statements); + } + + /** + * One `output:` entry lowered to its `_output_` call, or null for a form that is not an output + * declaration. A bare variable is answered by the model; an explicit right-hand side IS the + * output's value -- the process rule verbatim -- so the model is neither asked for it nor + * allowed to bind it. + */ + private static Statement outputDeclaration(Expression output) { + if( output instanceof VariableExpression ve ) + return stmt(callThisX("_output_", args(constX(ve.getName()), classX(ve.getType())))); + if( output instanceof AssignmentExpression ae && ae.getLeftExpression() instanceof VariableExpression ve ) + return stmt(callThisX("_output_", args(constX(ve.getName()), classX(ve.getType()), closureX(stmt(ae.getRightExpression()))))); + return null; + } + + private Statement agentPrompt(Statement prompt) { + // the prompt is a block, exactly like a process body: the closure's value is its + // last expression, so helper statements may precede the prompt text + return stmt(createX( + "nextflow.script.PromptDef", + args( + closureX(prompt), + constX(sgh.getSourceText(prompt)), + // capture the prompt closure's free-variable refs (params.*, task.ext.*) + // so prompt-globals fold into the resume cache key (design §7.2/D3); + // reuses the exact collector that populates process-body BodyDef.valRefs + sgh.getVariableRefs(prompt) + ) + )); + } +} diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/CallSiteCollector.java b/modules/nf-lang/src/main/java/nextflow/script/control/CallSiteCollector.java index a50dc048c2..5ad3cfcb3d 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/CallSiteCollector.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/CallSiteCollector.java @@ -20,6 +20,7 @@ import java.util.Map; import nextflow.script.ast.ASTNodeMarker; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.ProcessNode; import nextflow.script.ast.ScriptNode; import nextflow.script.ast.WorkflowNode; @@ -63,7 +64,7 @@ public void visitMethodCallExpression(MethodCallExpression node) { if( node.isImplicitThis() ) { var mn = (MethodNode) node.getNodeMetaData(ASTNodeMarker.METHOD_TARGET); - if( mn instanceof WorkflowNode || mn instanceof ProcessNode ) + if( mn instanceof WorkflowNode || mn instanceof ProcessNode || mn instanceof AgentNode ) calls.put(node.getMethodAsString(), mn); } } diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ImplicitStagers.java b/modules/nf-lang/src/main/java/nextflow/script/control/ImplicitStagers.java new file mode 100644 index 0000000000..d19ace8c1c --- /dev/null +++ b/modules/nf-lang/src/main/java/nextflow/script/control/ImplicitStagers.java @@ -0,0 +1,83 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script.control; + +import java.nio.file.Path; +import java.util.Collection; + +import nextflow.script.ast.RecordNode; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.Variable; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.stmt.BlockStatement; + +import static org.codehaus.groovy.ast.tools.GeneralUtils.*; + +/** + * Inference of the implicit staging directives that a typed input declaration + * carries: which declarations mean "stage this into the task directory". + * + * Shared by {@link ProcessToGroovyVisitorV2} and {@link AgentToGroovyVisitor} so + * that the same declaration means the same thing in a process and in an agent — + * the element type of a {@code Collection} and the fields of a record type + * are only visible at compile time, so this cannot be re-derived at runtime. + */ +class ImplicitStagers { + + /** + * Add the implicit staging directives inferred from the declared input type: + * + * - Inputs with type Path or a Path collection (e.g. Set<Path>) + * are staged as input files. + * + * - Inputs with a record type are recursively inspected for nested + * file inputs based on the record type definition. + * + * @param param + * @param target + * @param stagers + */ + static void visitInputType(Variable param, Expression target, BlockStatement stagers) { + var cn = param.getType(); + if( isPathType(cn) ) { + var stager = stmt(callThisX("stageAs", args(closureX(stmt(target))))); + stagers.addStatement(stager); + } + else if( isRecordType(cn) ) { + for( var fn : cn.getFields() ) + visitInputType(fn, propX(target, fn.getName()), stagers); + } + } + + private static boolean isPathType(ClassNode cn) { + if( !cn.isResolved() ) + return false; + var clazz = cn.getTypeClass(); + if( Path.class.isAssignableFrom(clazz) ) { + return true; + } + if( Collection.class.isAssignableFrom(clazz) && cn.isUsingGenerics() ) { + var elementType = cn.getGenericsTypes()[0].getType(); + return Path.class.isAssignableFrom(elementType.getTypeClass()); + } + return false; + } + + private static boolean isRecordType(ClassNode cn) { + return cn.redirect() instanceof RecordNode; + } + +} diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ProcessNameResolver.java b/modules/nf-lang/src/main/java/nextflow/script/control/ProcessNameResolver.java index b1f8bab14f..97ab3f0590 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/ProcessNameResolver.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/ProcessNameResolver.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.Set; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.ProcessNode; import nextflow.script.ast.ScriptNode; import nextflow.script.ast.WorkflowNode; @@ -28,9 +29,12 @@ import org.codehaus.groovy.control.SourceUnit; /** - * Resolve all fully-qualified process names invoked + * Resolve all fully-qualified process and agent names invoked * (directly or indirectly) by an entry workflow. * + *

The two kinds are kept apart because they are configured by separate scopes: + * a {@code process} selector never applies to an agent, and vice versa. + * * @author Ben Sherman */ public class ProcessNameResolver { @@ -41,8 +45,9 @@ public ProcessNameResolver(Map> callSites) this.callSites = callSites; } - public Set resolve(SourceUnit main) { - var result = new HashSet(); + public Names resolve(SourceUnit main) { + var processNames = new HashSet(); + var agentNames = new HashSet(); var queue = new LinkedList(); if( main.getAST() instanceof ScriptNode sn && sn.getEntry() != null ) @@ -58,14 +63,26 @@ public Set resolve(SourceUnit main) { } else if( mn instanceof ProcessNode pn ) { var processName = fullyQualifiedName(scope.name(), name); - result.add(processName); + processNames.add(processName); + } + else if( mn instanceof AgentNode an ) { + var agentName = fullyQualifiedName(scope.name(), name); + agentNames.add(agentName); } }); } - return result; + return new Names(processNames, agentNames); } + /** + * The fully-qualified names invoked by the entry workflow, split by kind. + */ + public record Names( + Set processes, + Set agents + ) {} + private String fullyQualifiedName(String scope, String name) { return scope.isEmpty() ? name diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV2.java b/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV2.java index 9c032dc65b..6ced855653 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV2.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV2.java @@ -15,22 +15,17 @@ */ package nextflow.script.control; -import java.nio.file.Path; import java.util.Arrays; -import java.util.Collection; import java.util.List; import nextflow.script.ast.ASTNodeMarker; import nextflow.script.ast.AssignmentExpression; import nextflow.script.ast.ProcessNodeV2; -import nextflow.script.ast.RecordNode; import nextflow.script.ast.ScriptNode; import nextflow.script.ast.TupleParameter; import org.codehaus.groovy.ast.ClassHelper; -import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.CodeVisitorSupport; import org.codehaus.groovy.ast.Parameter; -import org.codehaus.groovy.ast.Variable; import org.codehaus.groovy.ast.VariableScope; import org.codehaus.groovy.ast.expr.BinaryExpression; import org.codehaus.groovy.ast.expr.ClosureExpression; @@ -72,7 +67,8 @@ public Statement transform(ProcessNodeV2 node) { visitProcessStagers(node.stagers); var stagers = node.stagers instanceof BlockStatement block ? block : new BlockStatement(); - visitProcessInputs(node.inputs, stagers); + for( var param : asFlatParams(node.inputs) ) + ImplicitStagers.visitInputType(param, varX(param.getName()), stagers); var unstagers = new BlockStatement(); var unstageVisitor = new ProcessUnstageVisitor(unstagers); @@ -126,62 +122,17 @@ private void visitProcessStagers(Statement directives) { }); } - private void visitProcessInputs(Parameter[] inputs, BlockStatement stagers) { - for( var param : asFlatParams(inputs) ) { - visitProcessInputType(param, varX(param.getName()), stagers); - } - } - - /** - * Add implicit staging directives that are inferred from - * the process input type: - * - * - Inputs with type Path or a Path collection (e.g. Set) - * are staged as input files. - * - * - Inputs with a record type are recursively inspected for nested - * file inputs based on the record type definition. - * - * @param param - * @param target - * @param stagers - */ - private void visitProcessInputType(Variable param, Expression target, BlockStatement stagers) { - var cn = param.getType(); - if( isPathType(cn) ) { - var stager = stmt(callThisX("stageAs", args(closureX(stmt(target))))); - stagers.addStatement(stager); - } - else if( isRecordType(cn) ) { - for( var fn : cn.getFields() ) - visitProcessInputType(fn, propX(target, fn.getName()), stagers); - } - } - - private static boolean isPathType(ClassNode cn) { - if( !cn.isResolved() ) - return false; - var clazz = cn.getTypeClass(); - if( Path.class.isAssignableFrom(clazz) ) { - return true; - } - if( Collection.class.isAssignableFrom(clazz) && cn.isUsingGenerics() ) { - var elementType = cn.getGenericsTypes()[0].getType(); - return Path.class.isAssignableFrom(elementType.getTypeClass()); - } - return false; - } - - private static boolean isRecordType(ClassNode cn) { - return cn.redirect() instanceof RecordNode; - } - private void visitProcessUnstagers(Statement outputs, ProcessUnstageVisitor visitor) { for( var output : asBlockStatements(outputs) ) visitor.visit(output); } - private static class ProcessUnstageVisitor extends CodeVisitorSupport { + /** + * Lowers the unstage directives that appear in an output expression. Package-visible + * because an agent output means exactly what a process output means, so + * {@link AgentToGroovyVisitor} runs the very same visitor. + */ + static class ProcessUnstageVisitor extends CodeVisitorSupport { private int evalCount = 0; @@ -189,8 +140,20 @@ private static class ProcessUnstageVisitor extends CodeVisitorSupport { private BlockStatement unstagers; + /** + * When set, only the {@code file}/{@code files} directives are lowered. An agent has no + * task script and no {@code .command.env}, so {@code env}/{@code eval} cannot be served + * for it and must not be silently rewritten into unstagers it has no runtime for. + */ + private boolean filesOnly; + public ProcessUnstageVisitor(BlockStatement unstagers) { + this(unstagers, false); + } + + public ProcessUnstageVisitor(BlockStatement unstagers, boolean filesOnly) { this.unstagers = unstagers; + this.filesOnly = filesOnly; } @Override @@ -208,7 +171,7 @@ private void extractUnstageDirective(MethodCallExpression node) { // env() // emit: _unstage_env() - if( "env".equals(name) && arguments.size() == 1 ) { + if( !filesOnly && "env".equals(name) && arguments.size() == 1 ) { var key = arguments.get(0); var unstager = stmt(callThisX("_unstage_env", args(key))); unstagers.addStatement(unstager); @@ -219,7 +182,7 @@ private void extractUnstageDirective(MethodCallExpression node) { // eval() -> eval() // emit: _unstage_eval(, { }) - if( "eval".equals(name) && arguments.size() == 1 ) { + if( !filesOnly && "eval".equals(name) && arguments.size() == 1 ) { var key = constX("nxf_out_eval_" + (evalCount++)); var cmd = arguments.get(0); var unstager = stmt(callThisX("_unstage_eval", args(key, closureX(stmt(cmd))))); diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ResolveIncludeVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/ResolveIncludeVisitor.java index b298c5cfa1..5749f8a0aa 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/ResolveIncludeVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/ResolveIncludeVisitor.java @@ -173,6 +173,7 @@ private List getDefinitions(URI uri) { var result = new ArrayList(); result.addAll(scriptNode.getWorkflows()); result.addAll(scriptNode.getProcesses()); + result.addAll(scriptNode.getAgents()); result.addAll(scriptNode.getFunctions()); result.addAll(scriptNode.getTypes()); return result; diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptResolveVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptResolveVisitor.java index 38c1a90939..6780e025a1 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptResolveVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptResolveVisitor.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.List; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.AssignmentExpression; import nextflow.script.ast.FunctionNode; import nextflow.script.ast.IncludeNode; @@ -35,12 +36,14 @@ import nextflow.script.ast.WorkflowNode; import nextflow.script.types.Record; import nextflow.script.types.Tuple; +import org.codehaus.groovy.ast.ASTNode; import org.codehaus.groovy.ast.ClassHelper; import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.FieldNode; import org.codehaus.groovy.ast.DynamicVariable; import org.codehaus.groovy.ast.GenericsType; import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.expr.MethodCallExpression; import org.codehaus.groovy.ast.expr.VariableExpression; import org.codehaus.groovy.ast.stmt.ExpressionStatement; import org.codehaus.groovy.ast.stmt.Statement; @@ -100,6 +103,8 @@ public void visit() { visitParamV1(paramNode); for( var workflowNode : sn.getWorkflows() ) visitWorkflow(workflowNode); + for( var agentNode : sn.getAgents() ) + visitAgent(agentNode); for( var processNode : sn.getProcesses() ) visitProcess(processNode); for( var functionNode : sn.getFunctions() ) @@ -141,6 +146,71 @@ public void visitWorkflow(WorkflowNode node) { resolver.visit(node.onError); } + @Override + public void visitAgent(AgentNode node) { + for( var input : node.inputs ) { + // a destructured `record(...)` input parses to a TupleParameter + // whose type is the bare Record type -- reject it explicitly; any + // other resolvable type (scalar, path, named record) is allowed + if( input instanceof TupleParameter tp ) { + if( RECORD_TYPE.equals(input.getType()) ) + rejectDestructuredRecord(input, agentInputLabel(tp, "record")); + else + // a tuple input declares no context slot for its components, so an agent + // would silently half-ignore it (no input JSON entry, nothing to stage) + resolver.addError(agentInputLabel(tp, "tuple") + ": tuple inputs are not supported -- declare each component as a separate input", input); + continue; + } + resolver.resolveOrFail(input.getType(), input); + } + resolver.visit(node.directives); + resolveTypedOutputs(node.outputs); + checkAgentOutputs(node.outputs); + resolver.visit(node.outputs); + resolver.visit(node.prompt); + } + + /** + * Name a destructuring input in a diagnostic. A {@link TupleParameter} is constructed with an + * EMPTY name, so it has to be identified by its components — otherwise a script with several + * inputs gets a message that names none of them. + */ + private static String agentInputLabel(TupleParameter tp, String form) { + var names = new ArrayList(); + for( var component : tp.components ) + names.add(component.getName()); + return "Agent input `" + form + "(" + String.join(", ", names) + ")`"; + } + + /** + * An agent output must be a named declaration, because the name is the channel it binds and + * the key the model answers under. The shared `processOutput` grammar rule also admits a bare + * expression (a process lowers it to the implicit `$out`), which an agent has nothing to do + * with -- reject it here rather than let it be dropped and resurface as a runtime + * "must declare exactly one output". + */ + private void checkAgentOutputs(Statement block) { + for( var stmt : asBlockStatements(block) ) { + if( !(stmt instanceof ExpressionStatement stmtX) ) + continue; + var output = stmtX.getExpression(); + // a destructured `record(...)` output parses to a `record` method call + if( output instanceof MethodCallExpression mce && "record".equals(mce.getMethodAsString()) ) { + rejectDestructuredRecord(mce, "Agent output"); + continue; + } + if( output instanceof VariableExpression ) + continue; + if( output instanceof AssignmentExpression ae && ae.getLeftExpression() instanceof VariableExpression ) + continue; + resolver.addError("Agent output must be declared as `name: Type` -- a bare expression is not supported", output); + } + } + + private void rejectDestructuredRecord(ASTNode ctx, String label) { + resolver.addError(label + " must use a named record type; destructured `record(...)` is not yet supported for agents", ctx); + } + private void resolveTypedOutputs(Statement block) { for( var stmt : asBlockStatements(block) ) { var stmtX = (ExpressionStatement)stmt; diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java index 1cef61dd7e..17289bc8d3 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java @@ -23,6 +23,7 @@ import java.util.stream.Collectors; import nextflow.script.ast.ASTNodeMarker; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.AssignmentExpression; import nextflow.script.ast.FeatureFlagNode; import nextflow.script.ast.FunctionNode; @@ -103,7 +104,9 @@ public void visit() { declarations.sort(Comparator.comparing(node -> node.getLineNumber())); for( var decl : declarations ) { - if( decl instanceof ClassNode cn && cn.isEnum() ) + if( decl instanceof AgentNode an ) + visitAgent(an); + else if( decl instanceof ClassNode cn && cn.isEnum() ) visitEnum(cn); else if( decl instanceof FeatureFlagNode ffn ) visitFeatureFlag(ffn); @@ -279,6 +282,13 @@ private void visitWorkflowHandler(Statement code, String name, BlockStatement ma main.addStatement(stmt(callX(varX("workflow"), name, args(closureX(null, block))))); } + @Override + public void visitAgent(AgentNode node) { + checkReservedMethodName(node, "agent"); + var result = new AgentToGroovyVisitor(sourceUnit).transform(node); + moduleNode.addStatement(result); + } + @Override public void visitProcessV2(ProcessNodeV2 node) { checkReservedMethodName(node, "process"); diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/TypeCheckingVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/TypeCheckingVisitor.java index d867a5f9bd..7bdad8a4bb 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/TypeCheckingVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/TypeCheckingVisitor.java @@ -16,6 +16,7 @@ package nextflow.script.control; import nextflow.script.ast.ASTNodeMarker; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.ProcessNode; import nextflow.script.ast.ScriptNode; import nextflow.script.ast.ScriptVisitorSupport; @@ -58,7 +59,7 @@ public void visit() { @Override public void visitMethodCallExpression(MethodCallExpression node) { var defNode = (MethodNode) node.getNodeMetaData(ASTNodeMarker.METHOD_TARGET); - if( defNode instanceof ProcessNode || defNode instanceof WorkflowNode ) + if( defNode instanceof ProcessNode || defNode instanceof WorkflowNode || defNode instanceof AgentNode ) checkMethodCallArguments(node, defNode); super.visitMethodCallExpression(node); } diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeChecker.java b/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeChecker.java index 3a9a30ffad..4ae2ff5a75 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeChecker.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeChecker.java @@ -15,6 +15,9 @@ */ package nextflow.script.control; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; import java.util.Collections; import java.util.HashMap; import java.util.IdentityHashMap; @@ -22,6 +25,7 @@ import java.util.Map; import java.util.Set; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.ASTNodeMarker; import nextflow.script.ast.ProcessNode; import nextflow.script.ast.WorkflowNode; @@ -189,7 +193,14 @@ public Variable findVariableDeclaration(String name, ASTNode node) { */ private Variable findDslVariable(ClassNode cn, String name, ASTNode node) { var classScope = cn; - while( cn != null ) { + var queue = new ArrayDeque(); + var seen = new HashSet(); + // ArrayDeque rejects null elements, and this is reached with a null class scope + if( cn != null ) { + queue.add(cn); + seen.add(cn); + } + while( (cn = queue.poll()) != null ) { for( var mn : cn.getMethods() ) { // processes, workflows, and operators can be accessed as variables, e.g. with pipes if( isDataflowMethod(mn) && name.equals(mn.getName()) ) { @@ -206,9 +217,7 @@ private Variable findDslVariable(ClassNode cn, String name, ASTNode node) { return wrapMethodAsVariable(mn, name); } - cn = cn.getInterfaces().length > 0 - ? cn.getInterfaces()[0] - : null; + enqueueSupertypes(cn, queue, seen); } // an included definition can be accessed as a variable in a workflow @@ -224,7 +233,7 @@ private static boolean isWorkflowScope(ClassNode cn) { } public static boolean isDataflowMethod(MethodNode mn) { - return mn instanceof ProcessNode || mn instanceof WorkflowNode || isOperator(mn); + return mn instanceof ProcessNode || mn instanceof WorkflowNode || mn instanceof AgentNode || isOperator(mn); } public static boolean isOperator(MethodNode mn) { @@ -244,7 +253,7 @@ private static PropertyNode wrapMethodAsVariable(MethodNode mn, String name) { } private static ClassNode methodOutputType(MethodNode mn) { - if( mn instanceof ProcessNode || mn instanceof WorkflowNode ) + if( mn instanceof ProcessNode || mn instanceof WorkflowNode || mn instanceof AgentNode ) return ClassHelper.dynamicType(); return mn.getReturnType(); } @@ -260,7 +269,13 @@ public List findDslFunction(String name, ASTNode node, boolean direc VariableScope scope = currentScope; while( scope != null ) { ClassNode cn = scope.getClassScope(); - while( cn != null ) { + var queue = new ArrayDeque(); + var seen = new HashSet(); + if( cn != null ) { + queue.add(cn); + seen.add(cn); + } + while( (cn = queue.poll()) != null ) { // built-in functions are methods not annotated as @Constant var methods = cn.getDeclaredMethods(name).stream() .filter(mn -> !findAnnotation(mn, Constant.class).isPresent()) @@ -276,9 +291,7 @@ public List findDslFunction(String name, ASTNode node, boolean direc if( directive && scope == currentScope ) return Collections.emptyList(); - cn = cn.getInterfaces().length > 0 - ? cn.getInterfaces()[0] - : null; + enqueueSupertypes(cn, queue, seen); } scope = scope.getParent(); } @@ -364,4 +377,23 @@ public ASTNode getOtherNode() { } } + + /** + * The next DSL scope to search, walking the interface graph breadth-first. + * + *

A DSL scope is an interface, and an interface may extend several. Following only + * {@code getInterfaces()[0]} made resolution depend on DECLARATION ORDER: a scope written + * {@code extends A, B} resolved everything A inherits and nothing B does, silently, so adding a + * second parent to a shared scope such as {@code ProcessDsl.OutputDslV2} would have stopped + * `file()` resolving in every process output block in every pipeline with no error and no test + * to catch it. Breadth-first over all parents removes the ordering rule; the {@code seen} set + * keeps a diamond from being searched twice. + */ + private static void enqueueSupertypes(ClassNode cn, Deque queue, Set seen) { + for( var itf : cn.getInterfaces() ) { + if( seen.add(itf) ) + queue.add(itf); + } + } + } diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeVisitor.java index 046489c0c9..c156b1ed28 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeVisitor.java @@ -20,6 +20,7 @@ import java.util.List; import groovy.lang.groovydoc.GroovydocHolder; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.ASTNodeMarker; import nextflow.script.ast.AssignmentExpression; import nextflow.script.ast.FeatureFlagNode; @@ -36,6 +37,7 @@ import nextflow.script.ast.ScriptNode; import nextflow.script.ast.ScriptVisitorSupport; import nextflow.script.ast.WorkflowNode; +import nextflow.script.dsl.AgentDsl; import nextflow.script.dsl.Constant; import nextflow.script.dsl.EntryWorkflowDsl; import nextflow.script.dsl.FeatureFlag; @@ -117,6 +119,8 @@ public void declare() { } for( var processNode : sn.getProcesses() ) declareMethod(processNode); + for( var agentNode : sn.getAgents() ) + declareMethod(agentNode); for( var functionNode : sn.getFunctions() ) declareMethod(functionNode); declareTypes(sn); @@ -338,6 +342,36 @@ else if( output instanceof AssignmentExpression assign ) { } } + @Override + public void visitAgent(AgentNode node) { + vsc.pushScope(AgentDsl.class); + currentDefinition = node; + node.setVariableScope(currentScope()); + + for( var input : asFlatParams(node.inputs) ) { + vsc.declare(input, input); + + // suppress "unused variable" warnings since every input is sent to the model + vsc.findVariableDeclaration(input.getName(), input); + } + + vsc.pushScope(AgentDsl.DirectiveDsl.class); + visitDirectives(node.directives, "agent directive", false); + vsc.popScope(); + + // the prompt template may reference input parameters + visit(node.prompt); + + // mirrors visitProcessV2: `file(...)`/`files(...)` in an agent output collect from the + // task work dir, so they must NOT resolve to the driver-side global ScriptDsl.file + vsc.pushScope(AgentDsl.AgentOutputDsl.class); + visitTypedOutputs(node.outputs, "Agent output"); + vsc.popScope(); + + currentDefinition = null; + vsc.popScope(); + } + @Override public void visitProcessV2(ProcessNodeV2 node) { vsc.pushScope(ProcessDsl.class); @@ -763,6 +797,8 @@ private static String dataflowMethodType(MethodNode mn) { return "Processes"; if( mn instanceof WorkflowNode ) return "Workflows"; + if( mn instanceof AgentNode ) + return "Agents"; return "Operators"; } diff --git a/modules/nf-lang/src/main/java/nextflow/script/dsl/AgentDsl.java b/modules/nf-lang/src/main/java/nextflow/script/dsl/AgentDsl.java new file mode 100644 index 0000000000..062234df05 --- /dev/null +++ b/modules/nf-lang/src/main/java/nextflow/script/dsl/AgentDsl.java @@ -0,0 +1,84 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script.dsl; + +import groovy.transform.NamedParam; + +/** + * DSL scope for agent definitions. + * + * Mirrors {@link ProcessDsl}: the outer interface is the definition scope + * (where the agent's typed `input:` parameters are declared as locals), the + * nested {@link DirectiveDsl} declares the agent directive methods that + * may appear at the top of an agent body, and {@link AgentOutputDsl} the functions + * available in the `output:` section. + */ +public interface AgentDsl extends DslScope { + + interface DirectiveDsl extends DslScope { + + @Description(""" + The `goal` directive states a high-level objective that steers the agent's multi-turn loop. It is advisory: the model is encouraged to keep working until the goal is met, while `maxIterations` remains the hard cap. + """) + void goal(String value); + + @Description(""" + The `instruction` directive sets the agent system prompt (its role/persona). + """) + void instruction(String value); + + @Description(""" + The `label` directive annotates the agent with a mnemonic identifier, which can be used to apply configuration in the `agent` scope through a `withLabel:` selector. It can be specified more than once. + """) + void label(String value); + + @Description(""" + The `maxIterations` directive caps the LLM tool-calling loop. + """) + void maxIterations(Integer value); + + @Description(""" + The `model` directive selects the LLM in `provider/model` form (e.g. `openai/gpt-5-mini`). + """) + void model(String value); + + @Description(""" + The `skills` directive declares the agent skills (SKILL.md folders) the agent may use. Each entry is a local skill name (resolved under the `skills/` directory beside the script) or a remote GitHub reference (`github.com//[@rev]`) cloned and cached into that same `skills/` directory. + """) + void skills(Object... values); + + @Description(""" + The `tools` directive declares the tools the agent may invoke, as namespaced references of the form `family[:group]:name`. `nf:module_run` exposes every in-scope module or process, `nf:module_run:` a single one and `nf:module_run:*` those matching; `fs:*` selects the six sandboxed file tools (`read`, `write`, `edit`, `ls`, `grep`, `find`); `shell:bash` a shell inside the runner container (`pi` runner only). An agent declaring no tools gets none, and a reference matching nothing is an error. + """) + void tools(Object... values); + } + + /** + * Functions available in an agent `output:` section. + * + * Deliberately a SUBSET of {@link ProcessDsl.OutputDslV2}, and a subset BY CONSTRUCTION: both + * scopes inherit `file`/`files` from the shared {@link FileOutputDsl}, so an option added to + * one is added to the other. `eval`/`stdout` stay process-only because an agent has no task + * script to read them back from — leaving them undeclared makes them a resolution error + * instead of a runtime surprise. + * + *

Named {@code AgentOutputDsl} rather than {@code OutputDsl} to stay distinct from the + * unrelated top-level {@link OutputDsl} (the workflow output DSL) in this same package. + */ + /** Functions available in an agent `output:` section; `file`/`files` are shared with the process scope. */ + interface AgentOutputDsl extends FileOutputDsl { + } +} diff --git a/modules/nf-lang/src/main/java/nextflow/script/dsl/FileOutputDsl.java b/modules/nf-lang/src/main/java/nextflow/script/dsl/FileOutputDsl.java new file mode 100644 index 0000000000..35de0b5d5c --- /dev/null +++ b/modules/nf-lang/src/main/java/nextflow/script/dsl/FileOutputDsl.java @@ -0,0 +1,74 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script.dsl; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; + +import groovy.transform.NamedParam; +import groovy.transform.NamedParams; + +/** + * The output functions that collect files from the task directory, shared by + * {@link ProcessDsl.OutputDslV2} and {@link AgentDsl.AgentOutputDsl}. + * + *

Declared once because they mean the same thing in both: a process and an agent + * lower {@code file(...)}/{@code files(...)} through the very same unstage visitor, + * so an option added here must not be addable to only one of them. + * + *

Shared by the process and agent output scopes so the two cannot drift. Position in an + * `extends` list does not matter: {@code VariableScopeChecker} searches every supertype. + */ +public interface FileOutputDsl extends DslScope { + + @Description(""" + Get a file from the task directory that matches the given pattern. + """) + Path file( + @NamedParams({ + @NamedParam(value = "followLinks", type = Boolean.class), + @NamedParam(value = "glob", type = Boolean.class), + @NamedParam(value = "hidden", type = Boolean.class), + @NamedParam(value = "includeInputs", type = Boolean.class), + @NamedParam(value = "maxDepth", type = Integer.class), + @NamedParam(value = "optional", type = Boolean.class), + @NamedParam(value = "type", type = String.class), + }) + Map opts, + String name + ); + Path file(String name); + + @Description(""" + Get the files from the task directory that match the given pattern. + """) + Set files( + @NamedParams({ + @NamedParam(value = "followLinks", type = Boolean.class), + @NamedParam(value = "glob", type = Boolean.class), + @NamedParam(value = "hidden", type = Boolean.class), + @NamedParam(value = "includeInputs", type = Boolean.class), + @NamedParam(value = "maxDepth", type = Integer.class), + @NamedParam(value = "optional", type = Boolean.class), + @NamedParam(value = "type", type = String.class), + }) + Map opts, + String pattern + ); + Set files(String pattern); + +} diff --git a/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java b/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java index 13a0771c5d..7a5ef378e9 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java +++ b/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java @@ -446,7 +446,8 @@ Declare a variable input. The received value can be any type, and it will be mad } - interface OutputDslV2 extends DslScope { + /** {@code file}/{@code files} come from {@link FileOutputDsl}, shared with the agent scope. */ + interface OutputDslV2 extends FileOutputDsl { @Description(""" Get the value of an environment variable from the task environment. @@ -458,42 +459,6 @@ interface OutputDslV2 extends DslScope { """) String eval(String command); - @Description(""" - Get a file from the task environment that matches the given pattern. - """) - Path file( - @NamedParams({ - @NamedParam(value = "followLinks", type = Boolean.class), - @NamedParam(value = "glob", type = Boolean.class), - @NamedParam(value = "hidden", type = Boolean.class), - @NamedParam(value = "includeInputs", type = Boolean.class), - @NamedParam(value = "maxDepth", type = Integer.class), - @NamedParam(value = "optional", type = Boolean.class), - @NamedParam(value = "type", type = String.class), - }) - Map opts, - String name - ); - Path file(String name); - - @Description(""" - Get the files from the task environment that match the given pattern. - """) - Set files( - @NamedParams({ - @NamedParam(value = "followLinks", type = Boolean.class), - @NamedParam(value = "glob", type = Boolean.class), - @NamedParam(value = "hidden", type = Boolean.class), - @NamedParam(value = "includeInputs", type = Boolean.class), - @NamedParam(value = "maxDepth", type = Integer.class), - @NamedParam(value = "optional", type = Boolean.class), - @NamedParam(value = "type", type = String.class), - }) - Map opts, - String pattern - ); - Set files(String pattern); - @Description(""" Get the standard output of the task script. """) diff --git a/modules/nf-lang/src/main/java/nextflow/script/formatter/CommentAttacher.java b/modules/nf-lang/src/main/java/nextflow/script/formatter/CommentAttacher.java index 9b530517a3..380d44c685 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/formatter/CommentAttacher.java +++ b/modules/nf-lang/src/main/java/nextflow/script/formatter/CommentAttacher.java @@ -29,6 +29,7 @@ import nextflow.config.ast.ConfigNode; import nextflow.config.ast.ConfigStatement; import nextflow.script.ast.ASTNodeMarker; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.FunctionNode; import nextflow.script.ast.OutputBlockNode; import nextflow.script.ast.ParamBlockNode; @@ -312,6 +313,14 @@ private void scriptDeclaration(ASTNode decl, Container parent) { for( var field : cn.getFields() ) addChild(container, field); } + else if( decl instanceof AgentNode an ) { + var container = container(an, parent); + statements(an.directives, container); + for( var input : an.inputs ) + addChild(container, input); + statements(an.outputs, container); + statements(an.prompt, container); + } else if( decl instanceof FunctionNode fn ) { statements(fn.getCode(), container(fn, parent)); } diff --git a/modules/nf-lang/src/main/java/nextflow/script/formatter/ScriptFormattingVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/formatter/ScriptFormattingVisitor.java index a65d9acd9a..55a087f7f1 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/formatter/ScriptFormattingVisitor.java +++ b/modules/nf-lang/src/main/java/nextflow/script/formatter/ScriptFormattingVisitor.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.stream.Collectors; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.AssignmentExpression; import nextflow.script.ast.FeatureFlagNode; import nextflow.script.ast.FunctionNode; @@ -129,7 +130,9 @@ public void visit() { } for( var decl : declarations ) { - if( decl instanceof ClassNode cn && cn.isEnum() ) + if( decl instanceof AgentNode an ) + visitAgent(an); + else if( decl instanceof ClassNode cn && cn.isEnum() ) visitEnum(cn); else if( decl instanceof FeatureFlagNode ffn ) visitFeatureFlag(ffn); @@ -459,6 +462,38 @@ private void visitOutputAssignment(VariableExpression target, Expression source, } } + @Override + public void visitAgent(AgentNode node) { + fmt.appendLeadingComments(node); + fmt.append("agent "); + fmt.append(node.getName()); + fmt.append(" {\n"); + fmt.incIndent(); + if( !node.directives.isEmpty() ) { + visitDirectives(node.directives); + fmt.appendNewLine(); + } + var inputs = node.inputs; + if( inputs.length > 0 ) { + fmt.appendIndent(); + fmt.append("input:\n"); + visitTypedInputs(inputs); + fmt.appendNewLine(); + } + if( !node.outputs.isEmpty() ) { + visitProcessOutputs(node.outputs); + fmt.appendNewLine(); + } + fmt.appendIndent(); + fmt.append("prompt:\n"); + fmt.visit(node.prompt); + fmt.appendDanglingComments(node); + fmt.decIndent(); + fmt.append("}"); + fmt.appendTrailingComment(node); + fmt.appendNewLine(); + } + @Override public void visitProcessV2(ProcessNodeV2 node) { fmt.appendLeadingComments(node); diff --git a/modules/nf-lang/src/main/java/nextflow/script/parser/ScriptAstBuilder.java b/modules/nf-lang/src/main/java/nextflow/script/parser/ScriptAstBuilder.java index 0e02d0ec25..8db01db5d1 100644 --- a/modules/nf-lang/src/main/java/nextflow/script/parser/ScriptAstBuilder.java +++ b/modules/nf-lang/src/main/java/nextflow/script/parser/ScriptAstBuilder.java @@ -30,6 +30,7 @@ import nextflow.script.ast.ASTNodeMarker; import nextflow.script.formatter.Comment; import nextflow.script.formatter.Comments; +import nextflow.script.ast.AgentNode; import nextflow.script.ast.AssignmentExpression; import nextflow.script.ast.FeatureFlagNode; import nextflow.script.ast.FunctionNode; @@ -230,7 +231,7 @@ private void collectComments() { /// SCRIPT DECLARATIONS - private static final List SCRIPT_DEF_NAMES = List.of("process", "workflow", "output"); + private static final List SCRIPT_DEF_NAMES = List.of("agent", "process", "workflow", "output"); private ModuleNode compilationUnit(CompilationUnitContext ctx) { var statements = new ArrayList(); @@ -337,6 +338,11 @@ else if( ctx instanceof ParamDeclV1AltContext pac ) { moduleNode.addParamV1(node); } + else if( ctx instanceof AgentDefAltContext adac ) { + var node = agentDef(adac.agentDef()); + moduleNode.addAgent(node); + } + else if( ctx instanceof ProcessDefAltContext pdac ) { var node = processDef(pdac.processDef()); moduleNode.addProcess(node); @@ -808,6 +814,67 @@ private Statement processStub(ProcessStubContext ctx) { return ast( blockStatements(ctx.blockStatements()), ctx ); } + private AgentNode agentDef(AgentDefContext ctx) { + var name = ctx.name.getText(); + if( ctx.body == null ) + return invalidAgent("Missing agent body", ctx); + if( ctx.body.agentPrompt() == null ) + return invalidAgent("Missing `prompt:` section", ctx); + + var directives = agentDirectives(ctx.body.agentDirectives()); + var inputs = agentInputs(ctx.body.agentInputs()); + var outputs = agentOutputs(ctx.body.agentOutputs()); + var prompt = agentPrompt(ctx.body.agentPrompt()); + + var result = new AgentNode(name, directives, inputs, outputs, prompt); + ast(result, ctx); + return result; + } + + private AgentNode invalidAgent(String message, AgentDefContext ctx) { + var empty = EmptyStatement.INSTANCE; + var result = ast(new AgentNode("", empty, Parameter.EMPTY_ARRAY, empty, empty), ctx); + collectSyntaxError(new SyntaxException(message, result)); + return result; + } + + private Statement agentDirectives(AgentDirectivesContext ctx) { + if( ctx == null ) + return EmptyStatement.INSTANCE; + var statements = ctx.statement().stream() + .map(this::statement) + .map(stmt -> checkDirective(stmt, "Invalid agent directive")) + .toList(); + return ast( block(null, statements), ctx ); + } + + private Parameter[] agentInputs(AgentInputsContext ctx) { + if( ctx == null ) + return Parameter.EMPTY_ARRAY; + return ctx.processInput().stream() + .map(this::processInput) + .filter(input -> input != null) + .toArray(Parameter[]::new); + } + + private Statement agentOutputs(AgentOutputsContext ctx) { + if( ctx == null ) + return EmptyStatement.INSTANCE; + var statements = ctx.processOutput().stream() + .map(this::processOutput) + .filter(stmt -> stmt != null) + .toList(); + return ast( block(null, statements), ctx ); + } + + private Statement agentPrompt(AgentPromptContext ctx) { + // Anchored to the BLOCK, not to ctx. `getSourceText` reads the node's source extent + // and that text is part of the agent's resume cache key, so anchoring to ctx -- which + // starts at the `prompt` token -- would prepend the `prompt:` label and rekey every + // existing agent. Same anchoring as `processExec`. + return blockStatements(ctx.blockStatements()); + } + private WorkflowNode workflowDef(WorkflowDefContext ctx) { var name = ctx.name != null ? ctx.name.getText() : null; diff --git a/modules/nf-lang/src/test/groovy/nextflow/script/control/AgentToGroovyTest.groovy b/modules/nf-lang/src/test/groovy/nextflow/script/control/AgentToGroovyTest.groovy new file mode 100644 index 0000000000..0018fa8215 --- /dev/null +++ b/modules/nf-lang/src/test/groovy/nextflow/script/control/AgentToGroovyTest.groovy @@ -0,0 +1,354 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.script.control + +import nextflow.script.ast.ASTNodeMarker +import nextflow.script.ast.AssignmentExpression +import nextflow.script.ast.ProcessNodeV2 +import org.codehaus.groovy.ast.MethodNode +import org.codehaus.groovy.ast.stmt.Statement +import org.codehaus.groovy.ast.expr.ClosureExpression +import org.codehaus.groovy.ast.expr.ConstantExpression +import org.codehaus.groovy.ast.expr.MethodCallExpression +import org.codehaus.groovy.ast.expr.PropertyExpression +import org.codehaus.groovy.ast.expr.VariableExpression +import org.codehaus.groovy.ast.stmt.BlockStatement +import org.codehaus.groovy.ast.stmt.ExpressionStatement +import spock.lang.Shared +import spock.lang.Specification +import test.TestUtils + +/** + * An agent's typed I/O lowers through the SAME compiler units a process's does, so the implicit + * stagers and the output unstagers must come out identical for identical declarations. + * + * @see nextflow.script.control.AgentToGroovyVisitor + * @see nextflow.script.control.ImplicitStagers + * + * @author Paolo Di Tommaso + */ +class AgentToGroovyTest extends Specification { + + @Shared + ScriptParser scriptParser + + def setupSpec() { + scriptParser = new ScriptParser() + } + + def 'should generate an implicit stager for each Path-typed agent input'() { + when: + final blocks = lowerAgent(""" + agent qa { + input: + ${declaration} + output: + answer: String + prompt: "go" + } + """) + + then: + stagerTargets(blocks.stagers) == expected + + where: + declaration || expected + 'contigs: Path' || ['contigs'] + 'contigs: Path?' || ['contigs'] + 'reads: Set' || ['reads'] + 'reads: List' || ['reads'] + 'n: Integer' || [] + 's: String' || [] + 'contigs: Path\nn: Integer' || ['contigs'] + } + + def 'should generate the same stagers an equivalent process generates'() { + given: + final text = ''' + nextflow.enable.types = true + + record Sample { + id: String + seq: Path + index: Path + } + + process CHECK { + input: + sample: Sample + n: Integer + output: + answer: String = 'x' + script: + 'true' + } + + agent qa { + input: + sample: Sample + n: Integer + output: + answer: String + prompt: "go" + } + ''' + final source = analyze(text) + + when: 'the record recursion runs for both' + final agent = new AgentToGroovyVisitor(source).transform(source.getAST().getAgents().first()) + final process = new ProcessToGroovyVisitorV2(source) + .transform((ProcessNodeV2) source.getAST().getProcesses().first()) + + then: + stagerTargets(blockAt(agent, 1)) == ['sample.seq', 'sample.index'] + and: 'the process stagers block is byte-identical in shape' + stagerTargets(blockAt(process, 1)) == stagerTargets(blockAt(agent, 1)) + } + + def 'should carry the nullable marker into the input declaration'() { + when: + final blocks = lowerAgent(''' + agent qa { + input: + a: Path + b: Path? + output: + answer: String + prompt: "go" + } + ''') + + then: 'the third argument of _input_ is the optional flag' + callArgs(blocks.inputs) == [['a', 'java.nio.file.Path', false], ['b', 'java.nio.file.Path', true]] + } + + def 'should lower a file output into an unstager plus a value-carrying output'() { + when: + final blocks = lowerAgent(''' + agent qa { + input: + q: String + output: + answer: String + report: Path = file('report.md') + notes: Set = files('*.txt') + prompt: "go" + } + ''') + + then: 'one unstager per file()/files() call, keyed per agent' + callNames(blocks.unstagers) == ['_unstage_files', '_unstage_files'] + unstagerKeys(blocks.unstagers) == ['$path0', '$path1'] + + and: 'a bare output stays 2-arg (the model answers it); an RHS output carries its closure' + blocks.outputs.statements.collect { arity(it) } == [2, 3, 3] + } + + def 'should not lower env or eval in an agent output'() { + when: + final blocks = lowerAgent(''' + agent qa { + input: + q: String + output: + answer: String = env('HOME') + prompt: "go" + } + ''') + + then: 'no unstager is generated: an agent has no task environment to read back' + blocks.unstagers.statements.isEmpty() + } + + def 'should place the stagers and unstagers before the prompt statement'() { + when: + final blocks = lowerAgent(''' + agent qa { + input: + contigs: Path + output: + report: Path = file('report.md') + prompt: "go" + } + ''') + + then: 'the prompt must stay LAST: its value is the closure return value' + blocks.order == ['directives', 'stagers', 'unstagers', 'inputs', 'outputs', 'prompt'] + and: + stagerTargets(blocks.stagers) == ['contigs'] + callNames(blocks.unstagers) == ['_unstage_files'] + } + + def 'should lower every file()/files() call form a process output accepts'() { + given: """the one-arg and two-arg opts forms, singular and plural. DSL resolution matches by + NAME only, so this covers the LOWERING rather than the declared signature""" + final blocks = lowerAgent(''' + nextflow.enable.types = true + + agent qa { + input: + x: String + output: + one: Path = file('report.md') + typed: Path = file(type: 'file', 'report.md') + many: Set = files('*.tsv') + opts: Set = files(hidden: true, '*.log') + prompt: "go" + } + ''') + + expect: 'all four resolve, and each lowers to its own unstager' + callNames(blocks.unstagers) == ['_unstage_files'] * 4 + } + + def 'should resolve file() in an agent output to the same scope a process output resolves it to'() { + given: + final source = analyze(''' + nextflow.enable.types = true + + process CHECK { + input: + x: String + output: + report: Path = file('report.md') + script: + "true" + } + + agent qa { + input: + x: String + output: + report: Path = file('report.md') + prompt: "go" + } + ''') + final ast = source.getAST() + + when: + final agentTarget = fileCallTarget(ast.getAgents().first().outputs) + final processTarget = fileCallTarget(((ProcessNodeV2) ast.getProcesses().first()).outputs) + + then: 'the shared declaration -- NOT the driver-side global `ScriptDsl.file`, which also matches by name' + agentTarget == 'nextflow.script.dsl.FileOutputDsl' + and: 'and provably one declaration, not two that may drift' + processTarget == agentTarget + } + + // ----------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------- + + private analyze(String contents) { + scriptParser.compiler().getSources().clear() + final source = scriptParser.parse('main.nf', contents.stripIndent()) + scriptParser.analyze() + final errors = TestUtils.getErrors(source) + assert errors.isEmpty() : errors.collect { it.getOriginalMessage() + ' @' + it.getStartLine() }.join('; ') + return source + } + + /** Lower the (single) agent and name the six blocks of the generated closure body. */ + private Map lowerAgent(String contents) { + final source = analyze(contents) + final stmt = new AgentToGroovyVisitor(source).transform(source.getAST().getAgents().first()) + return [ + order : ['directives', 'stagers', 'unstagers', 'inputs', 'outputs', 'prompt'], + stagers : blockAt(stmt, 1), + unstagers: blockAt(stmt, 2), + inputs : blockAt(stmt, 3), + outputs : blockAt(stmt, 4), + ] + } + + /** The i-th statement of the generated `agent('name') { ... }` closure body. */ + private static BlockStatement blockAt(org.codehaus.groovy.ast.stmt.Statement stmt, int index) { + final call = (MethodCallExpression) ((ExpressionStatement) stmt).getExpression() + final closure = (ClosureExpression) call.getArguments().getExpression(1) + return (BlockStatement) ((BlockStatement) closure.getCode()).getStatements().get(index) + } + + /** The `stageAs({ })` targets, rendered as source-like text. */ + private static List stagerTargets(BlockStatement block) { + return block.getStatements().collect { st -> + final call = (MethodCallExpression) ((ExpressionStatement) st).getExpression() + assert call.getMethodAsString() == 'stageAs' + final closure = (ClosureExpression) call.getArguments().getExpression(0) + return render(closureBody(closure)) + } + } + + /** The single expression of a one-statement closure, wrapped in a block or not. */ + private static closureBody(ClosureExpression closure) { + final code = closure.getCode() + final st = code instanceof BlockStatement ? code.getStatements().get(0) : code + return ((ExpressionStatement) st).getExpression() + } + + private static String render(expr) { + if( expr instanceof VariableExpression ) + return expr.getName() + if( expr instanceof PropertyExpression ) + return render(expr.getObjectExpression()) + '.' + expr.getPropertyAsString() + return expr.getText() + } + + private static List callNames(BlockStatement block) { + return block.getStatements().collect { st -> + ((MethodCallExpression) ((ExpressionStatement) st).getExpression()).getMethodAsString() + } + } + + private static List unstagerKeys(BlockStatement block) { + return block.getStatements().collect { st -> + final call = (MethodCallExpression) ((ExpressionStatement) st).getExpression() + return ((ConstantExpression) call.getArguments().getExpression(0)).getValue() + } + } + + private static int arity(st) { + final call = (MethodCallExpression) ((ExpressionStatement) st).getExpression() + return call.getArguments().getExpressions().size() + } + + /** The (name, type, optional) triples of the generated `_input_` calls. */ + private static List callArgs(BlockStatement block) { + return block.getStatements().collect { st -> + final call = (MethodCallExpression) ((ExpressionStatement) st).getExpression() + final args = call.getArguments().getExpressions() + return [ + ((ConstantExpression) args[0]).getValue(), + args[1].getType().getName(), + ((ConstantExpression) args[2]).getValue() ] + } + } + + /** + * The declaring class the `file(...)` call in the FIRST output declaration resolved to. + * `file` is overloaded, so the scope visitor records METHOD_OVERLOADS rather than a single + * METHOD_TARGET; both spellings are read so the assertion does not depend on the arity used. + */ + private static String fileCallTarget(Statement outputs) { + final st = ((BlockStatement) outputs).getStatements().first() + final assign = (AssignmentExpression) ((ExpressionStatement) st).getExpression() + final call = (MethodCallExpression) assign.getRightExpression() + final overloads = (List) call.getNodeMetaData(ASTNodeMarker.METHOD_OVERLOADS) + final mn = overloads ? overloads.first() : (MethodNode) call.getNodeMetaData(ASTNodeMarker.METHOD_TARGET) + return mn?.getDeclaringClass()?.getName() + } + +} diff --git a/modules/nf-lang/src/test/groovy/nextflow/script/control/ResolveIncludeTest.groovy b/modules/nf-lang/src/test/groovy/nextflow/script/control/ResolveIncludeTest.groovy index e4f9939892..f071d6f2d8 100644 --- a/modules/nf-lang/src/test/groovy/nextflow/script/control/ResolveIncludeTest.groovy +++ b/modules/nf-lang/src/test/groovy/nextflow/script/control/ResolveIncludeTest.groovy @@ -136,4 +136,133 @@ class ResolveIncludeTest extends Specification { deleteDir(root) } + // -- agent modules. An `agent` must bind through the ordinary include statement, in both the + // plain-file and the directory (`main.nf`) form. The directory form matters twice over + // because getLocalIncludeUri is duplicated verbatim in ResolveIncludeVisitor and + // ModuleResolver (lint/LSP vs run); only a test driving the include catches a divergence. + + private static final String AGENT_MODULE = '''\ + nextflow.enable.types = true + + agent reporter { + model 'openai/gpt-4o' + instruction 'You write QA reports.' + + input: + sample: String + + output: + report: String + + prompt: + """ + Report on ${sample}. + """ + } + '''.stripIndent() + + def 'should resolve an agent include from a module file' () { + given: + def root = tempDir() + def main = tempFile(root, 'main.nf', + '''\ + include { reporter } from './reporter.nf' + + workflow { + reporter('s1') + } + '''.stripIndent()) + def module = tempFile(root, 'reporter.nf', AGENT_MODULE) + + when: + def errors = check(root, [main, module]) + then: + errors.size() == 0 + + cleanup: + deleteDir(root) + } + + def 'should resolve an agent include from a module directory' () { + given: + def root = tempDir() + def main = tempFile(root, 'main.nf', + '''\ + include { reporter } from './mods/reporter' + + workflow { + reporter('s1') + } + '''.stripIndent()) + def module = tempFile(root, 'mods/reporter/main.nf', AGENT_MODULE) + + when: + def errors = check(root, [main, module]) + then: + errors.size() == 0 + + cleanup: + deleteDir(root) + } + + def 'should resolve an aliased agent include at the call site' () { + given: + def root = tempDir() + def main = tempFile(root, 'main.nf', + '''\ + include { reporter as qc } from './mods/reporter' + + workflow { + qc('s1') + } + '''.stripIndent()) + def module = tempFile(root, 'mods/reporter/main.nf', AGENT_MODULE) + + when: + def errors = check(root, [main, module]) + then: + errors.size() == 0 + + cleanup: + deleteDir(root) + } + + def 'should resolve an agent include that is never called' () { + given: + def root = tempDir() + def main = tempFile(root, 'main.nf', + '''\ + include { reporter } from './mods/reporter' + '''.stripIndent()) + def module = tempFile(root, 'mods/reporter/main.nf', AGENT_MODULE) + + when: + def errors = check(root, [main, module]) + then: + errors.size() == 0 + + cleanup: + deleteDir(root) + } + + def 'should report an error for an undefined agent include' () { + given: + def root = tempDir() + def main = tempFile(root, 'main.nf', + '''\ + include { nope } from './mods/reporter' + '''.stripIndent()) + def module = tempFile(root, 'mods/reporter/main.nf', AGENT_MODULE) + + when: + def errors = check(root, [main, module]) + then: + errors.size() == 1 + errors[0].getSourceLocator().endsWith('main.nf') + errors[0].getOriginalMessage() == "Included name 'nope' is not defined in module '${module}'" + + cleanup: + deleteDir(root) + } + } diff --git a/modules/nf-lang/src/test/groovy/nextflow/script/control/ScriptResolveTest.groovy b/modules/nf-lang/src/test/groovy/nextflow/script/control/ScriptResolveTest.groovy index 2c1e4329bd..4bdfc6708a 100644 --- a/modules/nf-lang/src/test/groovy/nextflow/script/control/ScriptResolveTest.groovy +++ b/modules/nf-lang/src/test/groovy/nextflow/script/control/ScriptResolveTest.groovy @@ -100,6 +100,30 @@ class ScriptResolveTest extends Specification { errors[0].getOriginalMessage() == '`x` is already declared' } + def 'should resolve a DSL function inherited from a supertype that is not first in the extends list'() { + when: """`file()` reaches a process output through OutputDslV2 -> FileOutputDsl. Resolution + used to follow getInterfaces()[0] only, so this resolved purely because that parent + happened to sit first -- and a later `extends A, FileOutputDsl` would have broken + `file()` in every process output block in every pipeline, silently.""" + def errors = check( + '''\ + nextflow.enable.types = true + + process CHECK { + input: + x: String + output: + report: Path = file('report.md') + script: + "true" + } + ''' + ) + + then: 'it resolves, whatever position the declaring supertype occupies' + errors.size() == 0 + } + def 'should report an error for an unrecognized process directive' () { when: def errors = check( @@ -534,4 +558,34 @@ class ScriptResolveTest extends Specification { deleteDir(root) } + def 'should resolve every typed process output directive after the file/files extraction'() { + when: + // `file`/`files` now come from the shared FileOutputDsl rather than being declared on + // ProcessDsl.OutputDslV2. This pins that the process-only three (`env`/`eval`/`stdout`) + // survived the extraction; WHICH `file` resolves is pinned separately in AgentToGroovyTest, + // because a global `ScriptDsl.file` matches by name and would mask a broken scope here + def errors = check( + '''\ + nextflow.enable.types = true + + process FOO { + input: + x: String + + output: + report: Path = file('report.md') + notes: Set = files(hidden: true, '*.txt') + home: String = env('HOME') + lines: String = eval('wc -l report.md') + log: String = stdout() + + script: + "true" + } + ''') + + then: + errors.size() == 0 + } + } diff --git a/modules/nf-lang/src/test/groovy/nextflow/script/control/TypeCheckingTest.groovy b/modules/nf-lang/src/test/groovy/nextflow/script/control/TypeCheckingTest.groovy index c4369b6016..8019bad0bf 100644 --- a/modules/nf-lang/src/test/groovy/nextflow/script/control/TypeCheckingTest.groovy +++ b/modules/nf-lang/src/test/groovy/nextflow/script/control/TypeCheckingTest.groovy @@ -85,4 +85,69 @@ class TypeCheckingTest extends Specification { errors[0].getOriginalMessage() == 'Incorrect number of call arguments, expected 2 but received 1' } + // -- a wrong-arity call to an `agent` must be a COMPILE error, like a process call. It used to + // be deferred to AgentDef.buildAgentTask at run time, i.e. invisible to `nextflow lint` + // and the LSP -- unacceptable for a module agent, whose consumer cannot edit the module. + def 'should report an error for an agent call with the wrong number of arguments' () { + when: + def errors = check( + '''\ + nextflow.enable.types = true + + agent reporter { + model 'openai/gpt-4o' + instruction 'i' + + input: + sample: String + + output: + report: String + + prompt: + """ + Report on ${sample}. + """ + } + + workflow { + reporter('a', 'b') + } + ''' + ) + then: + errors.size() == 1 + errors[0].getStartColumn() == 5 + errors[0].getOriginalMessage() == 'Incorrect number of call arguments, expected 1 but received 2' + + when: 'the call arity matches the declared inputs' + errors = check( + '''\ + nextflow.enable.types = true + + agent reporter { + model 'openai/gpt-4o' + instruction 'i' + + input: + sample: String + + output: + report: String + + prompt: + """ + Report on ${sample}. + """ + } + + workflow { + reporter('a') + } + ''' + ) + then: + errors.size() == 0 + } + } diff --git a/modules/nf-lang/src/test/groovy/nextflow/script/formatter/ScriptFormatterTest.groovy b/modules/nf-lang/src/test/groovy/nextflow/script/formatter/ScriptFormatterTest.groovy index d7391e7269..44eb1bf59f 100644 --- a/modules/nf-lang/src/test/groovy/nextflow/script/formatter/ScriptFormatterTest.groovy +++ b/modules/nf-lang/src/test/groovy/nextflow/script/formatter/ScriptFormatterTest.groovy @@ -265,6 +265,36 @@ class ScriptFormatterTest extends Specification { ) } + // -- an `agent` declaration must survive `nextflow lint -format`. Before the AgentNode + // branch was added to the declaration dispatch, the node was walked and emitted NOTHING, + // so formatting an agent module's `main.nf` deleted its entire content. + def 'should format an agent definition' () { + expect: + checkFormat( + '''\ + nextflow.enable.types = true + + agent reporter { + model 'openai/gpt-4o' + instruction 'You write QA reports.' + skills 'qa-report', 'style' + + input: + sample: String + depth: Integer + + output: + report: String + + prompt: + """ + Write a report for ${sample} at depth ${depth}. + """ + } + ''' + ) + } + def 'should format a function definition' () { expect: checkFormat( @@ -915,6 +945,32 @@ class ScriptFormatterTest extends Specification { ) } + def 'should preserve comments in an agent' () { + expect: + checkFormat( + '''\ + nextflow.enable.types = true + + agent reporter { + // a directive + model 'openai/gpt-4o' + + input: + // about sample + sample: String + + output: + report: String // the report + + prompt: + """ + Write a report for ${sample}. + """ + } + ''' + ) + } + def 'should preserve comments in an if/else' () { expect: checkFormat( diff --git a/modules/nf-lang/src/test/groovy/nextflow/script/parser/AgentParserTest.groovy b/modules/nf-lang/src/test/groovy/nextflow/script/parser/AgentParserTest.groovy new file mode 100644 index 0000000000..0fe9d074f8 --- /dev/null +++ b/modules/nf-lang/src/test/groovy/nextflow/script/parser/AgentParserTest.groovy @@ -0,0 +1,480 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.script.parser + +import nextflow.script.ast.AgentNode +import nextflow.script.ast.ScriptNode +import nextflow.script.control.ScriptParser +import nextflow.script.control.ScriptToGroovyHelper +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.syntax.SyntaxException +import spock.lang.Shared +import spock.lang.Specification +import test.TestUtils + +/** + * @see nextflow.script.parser.ScriptAstBuilder + */ +class AgentParserTest extends Specification { + + @Shared + ScriptParser scriptParser + + def setupSpec() { + scriptParser = new ScriptParser() + } + + List check(String contents) { + return TestUtils.check(scriptParser, contents) + } + + ScriptNode parse(String contents) { + scriptParser.compiler().getSources().clear() + def source = scriptParser.parse('main.nf', contents.stripIndent()) + scriptParser.analyze() + assert !TestUtils.hasSyntaxErrors(source) + return source.getAST() as ScriptNode + } + + SourceUnit parseSource(String contents) { + scriptParser.compiler().getSources().clear() + def source = scriptParser.parse('main.nf', contents.stripIndent()) + scriptParser.analyze() + assert !TestUtils.hasSyntaxErrors(source) + return source + } + + // -- T3 (design §7.2/D3): the prompt closure's free-variable refs (params.*, task.ext.*) + // are captured via the same collector that populates process-body BodyDef.valRefs, so + // AgentToGroovyVisitor can fold them into the synthetic PromptDef/BodyDef cache key. + + def 'should capture params.* prompt globals as prompt valRefs (excluding declared inputs)'() { + given: + def source = parseSource('''\ + nextflow.enable.types = true + + agent eval_agent { + model 'openai/gpt-5-mini' + tools() + + input: + question: String + + output: + answer: String + + prompt: + """ + ${question} threshold=${params.threshold} args=${task.ext.args} + """ + } + ''') + + when: + def node = (source.getAST() as ScriptNode).agents[0] as AgentNode + def refs = new ScriptToGroovyHelper(source).getVariableRefs(node.prompt) + def names = refs.expressions + .collect { expr -> expr.arguments.expressions[0].value } + .sort() + + then: 'params.* and task.ext.* are captured; the declared input `question` is NOT' + names == ['params.threshold', 'task.ext.args'] + } + + def 'should capture no prompt valRefs when the prompt references only declared inputs'() { + given: + def source = parseSource('''\ + nextflow.enable.types = true + + agent eval_agent { + model 'openai/gpt-5-mini' + tools() + + input: + question: String + + output: + answer: String + + prompt: + """ + Question: ${question} + """ + } + ''') + + when: + def node = (source.getAST() as ScriptNode).agents[0] as AgentNode + def refs = new ScriptToGroovyHelper(source).getVariableRefs(node.prompt) + + then: + refs.expressions.isEmpty() + } + + def 'should parse a minimal agent definition'() { + when: + def script = parse('''\ + nextflow.enable.types = true + + record Question { + text: String + } + + record Answer { + plan: String + } + + agent eval_agent { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + tools() + maxIterations 20 + + input: + question: Question + + output: + plan: Answer + + prompt: + """ + Question: ${question.text} + """ + } + ''') + + then: + script.agents.size() == 1 + def node = script.agents[0] as AgentNode + node.name == 'eval_agent' + node.inputs.length == 1 + node.inputs[0].name == 'question' + } + + def 'should report an error for agent without prompt section'() { + when: + def errors = check('''\ + nextflow.enable.types = true + + record Question { + text: String + } + + agent broken { + model 'openai/gpt-5-mini' + instruction 'x' + tools() + + input: + q: Question + } + ''') + + then: + errors.size() == 1 + errors[0].getOriginalMessage() == 'Invalid agent definition -- check for missing or out-of-order section labels' + } + + def 'should resolve an agent reference from a workflow'() { + when: + def script = parse('''\ + nextflow.enable.types = true + + record Question { + text: String + } + + record Answer { + text: String + } + + agent eval_agent { + model 'openai/gpt-5-mini' + instruction 'x' + tools() + + input: + q: Question + + output: + r: Answer + + prompt: + """ + ${q.text} + """ + } + + workflow { + channel.of('hi') | eval_agent | view + } + ''') + + then: + script.agents.size() == 1 + script.workflows.size() == 1 + // The `parse` helper asserts no syntax errors. If `eval_agent` failed + // to resolve in the workflow body, that assertion would fail. + } + + def 'should resolve directives and prompt variables in an agent body'() { + when: + def errors = check('''\ + nextflow.enable.types = true + + record Question { + text: String + } + + record Answer { + plan: String + } + + agent eval_agent { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + tools() + maxIterations 20 + + input: + question: Question + + output: + plan: Answer + + prompt: + """ + Question: ${question.text} + """ + } + ''') + + then: + errors.isEmpty() + } + + def 'should resolve the repeatable label directive in an agent body'() { + when: + def errors = check('''\ + nextflow.enable.types = true + + agent eval_agent { + label 'reasoning' + label 'fast' + model 'openai/gpt-5-mini' + + input: + question: String + + output: + answer: String + + prompt: + """ + Question: ${question} + """ + } + ''') + + then: + errors.isEmpty() + } + + def 'should accept record-typed agent I/O'() { + when: + def errors = check('''\ + nextflow.enable.types = true + + record Question { + text: String + } + + record Answer { + answer: String + } + + agent eval_agent { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + tools() + + input: + q: Question + + output: + a: Answer + + prompt: + """ + ${q.text} + """ + } + ''') + + then: + errors.isEmpty() + } + + def 'should accept val agent I/O'() { + when: + def errors = check('''\ + nextflow.enable.types = true + + agent eval_agent { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + tools() + + input: + question: String + + output: + answer: String + + prompt: + """ + ${question} + """ + } + ''') + + then: + errors.isEmpty() + } + + def 'should reject destructured record agent I/O'() { + when: + def errors = check('''\ + nextflow.enable.types = true + + record Answer { + answer: String + } + + agent eval_agent { + model 'openai/gpt-5-mini' + instruction 'You are helpful.' + tools() + + input: + record(text: String) + + output: + a: Answer + + prompt: + """ + ${text} + """ + } + ''') + + then: + !errors.isEmpty() + errors.any { it.getOriginalMessage().contains('named record type') } + } + + def 'should reject a tuple agent input'() { + when: + // a tuple input declares no context slot for its components, so an agent would + // half-ignore it: nothing in the input JSON and nothing staged + def errors = check('''\ + nextflow.enable.types = true + + agent qa { + input: + tuple(a: Integer, b: Path) + + output: + answer: String + + prompt: + "go" + } + ''') + + then: + errors.any { it.getOriginalMessage().contains('tuple inputs are not supported') } + and: 'the message identifies WHICH input, by its components -- a tuple parameter has no name' + errors.any { it.getOriginalMessage().contains('Agent input `tuple(a, b)`') } + and: 'the message says what to do instead' + errors.any { it.getOriginalMessage().contains('separate input') } + } + + def 'should reject a bare expression as an agent output'() { + when: + // the shared `processOutput` rule admits a bare expression (a process lowers it to `$out`), + // and an agent has no such thing -- so it must be a diagnostic, not a dropped statement + def errors = check('''\ + nextflow.enable.types = true + + agent qa { + input: + q: String + + output: + file('report.md') + + prompt: + "go" + } + ''') + + then: + errors.any { it.getOriginalMessage().contains('Agent output must be declared as `name: Type`') } + } + + def 'should resolve file/files in an agent output but not the process-only directives'() { + when: 'the work-dir collectors are in scope' + def errors = check('''\ + nextflow.enable.types = true + + agent qa { + input: + q: String + + output: + report: Path = file('report.md') + notes: Set = files('*.txt') + + prompt: + "go" + } + ''') + + then: + errors.isEmpty() + + when: 'stdout() is process-only, so it must not resolve in an agent output' + errors = check('''\ + nextflow.enable.types = true + + agent qa { + input: + q: String + + output: + answer: String = stdout() + + prompt: + "go" + } + ''') + + then: + errors.any { it.getOriginalMessage().contains('stdout') } + } +} diff --git a/modules/nf-lineage/src/main/nextflow/lineage/LinObserver.groovy b/modules/nf-lineage/src/main/nextflow/lineage/LinObserver.groovy index b0c42c97dd..71b1884d6a 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/LinObserver.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/LinObserver.groovy @@ -34,6 +34,8 @@ import groovy.transform.CompileStatic import groovy.transform.Memoized import groovy.util.logging.Slf4j import nextflow.Session +import nextflow.agent.AgentTaskInfo +import nextflow.lineage.model.v1beta1.AgentRun import nextflow.lineage.model.v1beta1.Checksum import nextflow.lineage.model.v1beta1.FileOutput import nextflow.lineage.model.v1beta1.DataPath @@ -62,6 +64,8 @@ import nextflow.script.params.StdInParam import nextflow.script.params.StdOutParam import nextflow.script.params.ValueInParam import nextflow.script.params.ValueOutParam +import nextflow.script.params.v2.ProcessInput +import nextflow.script.params.v2.ProcessOutput import nextflow.trace.TraceObserverV2 import nextflow.trace.event.FilePublishEvent import nextflow.trace.event.TaskEvent @@ -258,8 +262,47 @@ class LinObserver implements TraceObserverV2 { } protected String storeTaskRun(TaskRun task, PathNormalizer normalizer) { + // an agent lowers to an ordinary task, so it lands here too - but its identity is the + // model/tools/skills it ran with, not a script, and it is recorded as an AgentRun + final agentInfo = task.config?.get(AgentTaskInfo.CONFIG_KEY) + final value = agentInfo instanceof AgentTaskInfo + ? newAgentRun(task, agentInfo, normalizer) + : newTaskRun(task, normalizer) + // store in the underlying persistence + final key = task.hash.toString() + store.save(key, value) + return key + } + + protected AgentRun newAgentRun(TaskRun task, AgentTaskInfo info, PathNormalizer normalizer) { + // the checksum covers the canonical agent identity source - the same text that feeds the + // resume cache key. NOTE: task.script is deliberately NOT recorded; on the RPC runner path + // it embeds the per-invocation capability token, which must never be persisted. + final codeChecksum = Checksum.ofNextflow(session.stubRun ? task.stubSource : task.source) + return new AgentRun( + session.uniqueId.toString(), + task.getName(), + codeChecksum, + info.runner, + info.model, + task.context?.get('$agentResolvedModel') as String, + info.instruction, + info.goal, + info.promptTemplate, + info.maxIterations, + info.outputSchema, + info.tools, + info.skills, + task.inputs ? manageTaskInputParameters(task.inputs, normalizer) : null, + task.isContainerEnabled() ? task.getContainerFingerprint() : null, + asUriString(executionHash), + getTaskModuleId(task) + ) + } + + protected nextflow.lineage.model.v1beta1.TaskRun newTaskRun(TaskRun task, PathNormalizer normalizer) { final codeChecksum = Checksum.ofNextflow(session.stubRun ? task.stubSource : task.source) - final value = new nextflow.lineage.model.v1beta1.TaskRun( + return new nextflow.lineage.model.v1beta1.TaskRun( session.uniqueId.toString(), task.getName(), codeChecksum, @@ -278,11 +321,6 @@ class LinObserver implements TraceObserverV2 { asUriString(executionHash), getTaskModuleId(task) ) - - // store in the underlying persistence - final key = task.hash.toString() - store.save(key, value) - return key } protected Map getTaskGlobalVars(TaskRun task) { @@ -452,6 +490,12 @@ class LinObserver implements TraceObserverV2 { protected static String getParameterType(Object param) { if( param instanceof BaseParam ) return taskParamToValue.get(param.class) + // typed (v2) process/agent params are not BaseParam, so without this they would be + // recorded as the literal type names 'ProcessInput'/'ProcessOutput' + if( param instanceof ProcessInput ) + return Path.isAssignableFrom(param.getType() ?: Object) ? 'path' : 'val' + if( param instanceof ProcessOutput ) + return Path.isAssignableFrom(param.getType() ?: Object) ? 'path' : 'val' // return generic types if( param instanceof Path ) return Path.simpleName diff --git a/modules/nf-lineage/src/main/nextflow/lineage/LinPropertyValidator.groovy b/modules/nf-lineage/src/main/nextflow/lineage/LinPropertyValidator.groovy index c3827c64ce..53504b792c 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/LinPropertyValidator.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/LinPropertyValidator.groovy @@ -17,6 +17,7 @@ package nextflow.lineage import groovy.transform.CompileStatic +import nextflow.lineage.model.v1beta1.AgentRun import nextflow.lineage.model.v1beta1.Checksum import nextflow.lineage.model.v1beta1.DataPath import nextflow.lineage.model.v1beta1.FileOutput @@ -36,6 +37,7 @@ import nextflow.lineage.model.v1beta1.WorkflowRun class LinPropertyValidator { private static final List LIN_MODEL_CLASSES = [ + AgentRun, Checksum, DataPath, FileOutput, diff --git a/modules/nf-lineage/src/main/nextflow/lineage/LinUtils.groovy b/modules/nf-lineage/src/main/nextflow/lineage/LinUtils.groovy index f522483436..a6dce5664f 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/LinUtils.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/LinUtils.groovy @@ -25,6 +25,7 @@ import java.time.ZoneId import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import nextflow.lineage.model.v1beta1.AgentRun import nextflow.lineage.model.v1beta1.TaskRun import nextflow.lineage.model.v1beta1.WorkflowRun import nextflow.lineage.serde.LinEncoder @@ -103,7 +104,7 @@ class LinUtils { * @return return 'true' if the parent is a Task/Workflow run and the first element in fragment is 'output'. Otherwise 'false' */ static boolean isSearchingOutputs(LinSerializable record, String fragment) { - return (record instanceof WorkflowRun || record instanceof TaskRun) && fragment && fragment.tokenize('.')[0] == 'output' + return (record instanceof WorkflowRun || record instanceof TaskRun || record instanceof AgentRun) && fragment && fragment.tokenize('.')[0] == 'output' } /** diff --git a/modules/nf-lineage/src/main/nextflow/lineage/cli/LinDagRenderer.groovy b/modules/nf-lineage/src/main/nextflow/lineage/cli/LinDagRenderer.groovy index d0e60825f8..61cf323269 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/cli/LinDagRenderer.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/cli/LinDagRenderer.groovy @@ -23,6 +23,7 @@ import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import nextflow.dag.MermaidHtmlRenderer import nextflow.lineage.LinStore +import nextflow.lineage.model.v1beta1.AgentRun import nextflow.lineage.model.v1beta1.FileOutput import nextflow.lineage.model.v1beta1.TaskRun import nextflow.lineage.model.v1beta1.WorkflowRun @@ -102,10 +103,12 @@ class LinDagRenderer { visitFileOutput(lid, record) else if( record instanceof TaskRun ) visitTaskRun(lid, record) + else if( record instanceof AgentRun ) + visitAgentRun(lid, record) else if( record instanceof WorkflowRun ) visitWorkflowRun(lid, record) else - throw new Exception("Cannot render lineage for type ${record.getClass().getSimpleName()} -- must be a FileOutput, TaskRun, or WorkflowRun") + throw new Exception("Cannot render lineage for type ${record.getClass().getSimpleName()} -- must be a FileOutput, TaskRun, AgentRun, or WorkflowRun") } private void visitFileOutput(String lid, FileOutput fileOutput) { @@ -132,6 +135,15 @@ class LinDagRenderer { } } + private void visitAgentRun(String lid, AgentRun agentRun) { + // ponytail: rendered with the same node shape as a task. Give agents their own shape + // if/when a diagram with both is common enough that telling them apart matters. + addNode(lid, "${agentRun.name} [${lid}]", NodeType.TASK) + for( final param : agentRun.input ) { + visitParameter(lid, param.value) + } + } + private void visitWorkflowRun(String lid, WorkflowRun workflowRun) { addNode(lid, "${workflowRun.name} [${lid}]", NodeType.TASK) for( final param : workflowRun.params ) { diff --git a/modules/nf-lineage/src/main/nextflow/lineage/fs/LinPath.groovy b/modules/nf-lineage/src/main/nextflow/lineage/fs/LinPath.groovy index c4d358e9e1..0673a36668 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/fs/LinPath.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/fs/LinPath.groovy @@ -37,6 +37,7 @@ import nextflow.file.FileHelper import nextflow.file.LogicalDataPath import nextflow.lineage.LinPropertyValidator import nextflow.lineage.LinStore +import nextflow.lineage.model.v1beta1.AgentRun import nextflow.lineage.model.v1beta1.Checksum import nextflow.lineage.model.v1beta1.FileOutput import nextflow.lineage.model.v1beta1.TaskRun @@ -239,7 +240,7 @@ class LinPath implements Path, LogicalDataPath { return getTargetPathFromOutput(object, subpath) } // Intermediate run case - if( asIntermediatePath && (object instanceof WorkflowRun || object instanceof TaskRun) ) { + if( asIntermediatePath && (object instanceof WorkflowRun || object instanceof TaskRun || object instanceof AgentRun) ) { return new LinIntermediatePath(fs, "$filePath/${subpath.join('/')}") } diff --git a/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1/AgentRun.groovy b/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1/AgentRun.groovy new file mode 100644 index 0000000000..3cfb5cbb31 --- /dev/null +++ b/modules/nf-lineage/src/main/nextflow/lineage/model/v1beta1/AgentRun.groovy @@ -0,0 +1,125 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.lineage.model.v1beta1 + +import groovy.transform.Canonical +import groovy.transform.CompileStatic +import nextflow.lineage.serde.LinSerializable + +/** + * Models an agent execution: the sibling of {@link TaskRun} for the {@code agent} primitive. + * + *

An agent lowers to an ordinary task, so this record is written in place of a + * {@link TaskRun} under the same task hash, and its results are recorded as a + * {@link TaskOutput} exactly like a process. What differs is the identity being captured: + * a process is identified by its script, an agent by the model it called and the tools, + * skills and prompt template it called it with. + * + *

Deliberately NOT recorded, and why: + *

    + *
  • the rendered prompt — a body-closure local that is never persisted; it is + * derivable from {@code promptTemplate} plus the recorded {@code input}, and recording it + * would put interpolated user data (potentially secrets) on disk uncapped.
  • + *
  • the resolved script — an agent has none worth keeping, and on the RPC path the + * rendered command embeds a per-invocation capability token that must not be persisted.
  • + *
  • token usage, turn count, tool-call count — not instrumented anywhere in the + * runtime today; there is nothing to read at task-complete time.
  • + *
+ * Add fields at the END: {@code @Canonical} makes the declaration order the positional + * constructor signature. + * + * @author Paolo Di Tommaso + */ +@Canonical +@CompileStatic +class AgentRun implements LinSerializable { + /** + * Execution session identifier + */ + String sessionId + /** + * Agent task name + */ + String name + /** + * Checksum of the canonical agent identity source, i.e. the same text that feeds the + * resume cache key: runner, model, instruction, goal, iteration ceiling, prompt template, + * output schema and skills fingerprint + */ + Checksum codeChecksum + /** + * Name of the agent runner implementation that executed the run + */ + String runner + /** + * Model requested for the run, after the `agent.defaultModel` config fallback + */ + String model + /** + * Concrete model reported by the provider, when available. May differ from {@link #model} + * when a floating alias resolves to a dated snapshot. Null for a tool agent (which is + * non-cacheable and so stores no context) and on the RPC runner path. + */ + String resolvedModel + /** + * Resolved `instruction:` directive + */ + String instruction + /** + * Resolved `goal:` directive + */ + String goal + /** + * Source text of the `prompt:` template. Combined with {@link #input} this reconstructs + * what the model was asked. + */ + String promptTemplate + /** + * Effective iteration ceiling for the agent loop + */ + int maxIterations + /** + * Key-sorted JSON of the synthesized output schema. Stored as text rather than a Map so a + * round-trip cannot coerce schema integers to floating point. + */ + String outputSchema + /** + * Names of the tools the agent was allowed to call + */ + List tools + /** + * Names of the skills made available to the agent + */ + List skills + /** + * Agent run input + */ + List input + /** + * Container used for the agent run, when the runner executes out of process + */ + String container + /** + * Workflow run associated to the agent run + */ + String workflowRun + /** + * Remote Nextflow module that defines the agent executed by this run, encoded as + * {@code name@version}. Null when the agent is not defined in a remote module. + */ + String moduleId +} diff --git a/modules/nf-lineage/src/main/nextflow/lineage/serde/LinTypeAdapterFactory.groovy b/modules/nf-lineage/src/main/nextflow/lineage/serde/LinTypeAdapterFactory.groovy index 046d2178e8..bfa50aefde 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/serde/LinTypeAdapterFactory.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/serde/LinTypeAdapterFactory.groovy @@ -24,6 +24,7 @@ import com.google.gson.reflect.TypeToken import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonWriter import groovy.transform.CompileStatic +import nextflow.lineage.model.v1beta1.AgentRun import nextflow.lineage.model.v1beta1.FileOutput import nextflow.lineage.model.v1beta1.LinModel import nextflow.lineage.model.v1beta1.TaskOutput @@ -52,6 +53,7 @@ class LinTypeAdapterFactory extends RuntimeTypeAdapterFactory { .registerSubtype(TaskRun, TaskRun.simpleName) .registerSubtype(TaskOutput, TaskOutput.simpleName) .registerSubtype(FileOutput, FileOutput.simpleName) + .registerSubtype(AgentRun, AgentRun.simpleName) } @Override diff --git a/modules/nf-lineage/src/test/nextflow/lineage/LinPropertyValidationTest.groovy b/modules/nf-lineage/src/test/nextflow/lineage/LinPropertyValidationTest.groovy index 2047965256..db8e7ac31d 100644 --- a/modules/nf-lineage/src/test/nextflow/lineage/LinPropertyValidationTest.groovy +++ b/modules/nf-lineage/src/test/nextflow/lineage/LinPropertyValidationTest.groovy @@ -36,4 +36,11 @@ class LinPropertyValidationTest extends Specification{ then: noExceptionThrown() } + + def 'should accept agent run properties'(){ + when: 'the fields that only exist on AgentRun are queryable, e.g. `lineage find -q model=...`' + new LinPropertyValidator().validate(['runner', 'model', 'resolvedModel', 'promptTemplate', 'maxIterations', 'skills']) + then: + noExceptionThrown() + } } diff --git a/plugins/nf-agent-pi/.dockerignore b/plugins/nf-agent-pi/.dockerignore new file mode 100644 index 0000000000..6161f52645 --- /dev/null +++ b/plugins/nf-agent-pi/.dockerignore @@ -0,0 +1,20 @@ +# ALLOWLIST: exclude everything, then re-admit exactly the files the Dockerfile COPYs. +# +# A denylist kept letting build output through -- node_modules/ (~170 MB, the tree this +# image exists to STOP shipping inside the plugin), build/, and the compiled agent-rpc +# binary a local `go build` leaves beside its source. None of it is copied, so uploading +# it to the builder is pure cost, and a stale copy must never be reachable by a COPY. +# +# Adding a COPY to the Dockerfile means adding its source here, or the build fails with +# "file not found" -- loudly, which is the point. +* + +# proxy sources: built by the agent-rpc stage +!agent-rpc/go.mod +!agent-rpc/go.sum +!agent-rpc/main.go + +# harness: installed into the runtime stage +!package.json +!package-lock.json +!harness/runner.mjs diff --git a/plugins/nf-agent-pi/.gitignore b/plugins/nf-agent-pi/.gitignore new file mode 100644 index 0000000000..3e2e84b087 --- /dev/null +++ b/plugins/nf-agent-pi/.gitignore @@ -0,0 +1,2 @@ +build/ +node_modules/ diff --git a/plugins/nf-agent-pi/Dockerfile b/plugins/nf-agent-pi/Dockerfile new file mode 100644 index 0000000000..1b4d47ad7a --- /dev/null +++ b/plugins/nf-agent-pi/Dockerfile @@ -0,0 +1,49 @@ +# Runner image for the `pi` agent runner. This image IS the distribution unit of the +# nf-agent-pi runtime: the plugin jar carries no proxy binary and no node_modules, so an +# agent selecting the `pi` runner must run in this image (`agent.container`). +# +# Build context is this directory. The Nextflow release publishes this image: release.sh step 1 +# runs `make release-agent-image`, i.e. the script beside this file, which sets up a +# multi-platform builder and verifies the pushed manifest. That script is also the escape hatch +# for publishing a tag by hand: +# +# plugins/nf-agent-pi/build-image.sh push +# +# The tag is the nf-agent-pi plugin version (see the VERSION file in this directory) so an +# agent's runtime is pinned as reproducibly as its model, and it is immutable: `push` is a +# no-op when the tag already exists, so bump VERSION to publish new image content. + +# The proxy is cross-compiled ON the build host rather than emulated: with CGO_ENABLED=0 Go +# targets any GOOS/GOARCH natively, so pinning this stage to $BUILDPLATFORM keeps the amd64 +# leg of a multi-arch build off QEMU. TARGETOS/TARGETARCH are supplied by BuildKit. +# +# Digest-pinned like the runtime base below, so the proxy the release publishes is built by a +# toolchain this repo names rather than by whatever `1.23-bookworm` points at that day. This is +# the OCI *index* digest (`docker buildx imagetools inspect golang:1.23-bookworm`), not a +# per-architecture manifest digest: a manifest digest would pin the stage to one build host. +FROM --platform=$BUILDPLATFORM golang:1.23-bookworm@sha256:167053a2bb901972bf2c1611f8f52c44d5fe7e762e5cab213708d82c421614db AS agent-rpc +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src +COPY agent-rpc/go.mod agent-rpc/go.sum ./ +RUN go mod download +COPY agent-rpc/main.go ./ +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags='-s -w' -o /agent-rpc . + +FROM node:24.4.1-bookworm-slim@sha256:36ae19f59c91f3303c7a648f07493fe14c4bd91320ac8d898416327bacf1bbfa + +WORKDIR /opt/nf-agent-pi +RUN apt-get update \ + && apt-get install -y --no-install-recommends procps \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts --omit=dev && npm cache clean --force +COPY harness/runner.mjs ./runner.mjs +COPY --from=agent-rpc /agent-rpc /usr/local/bin/agent-rpc + +# Canonical Nextflow executors own the container command/entrypoint so they can +# launch the task wrapper (and Fusion when enabled). The generated agent +# script explicitly execs /usr/local/bin/agent-rpc as its first process. +ENTRYPOINT [] diff --git a/plugins/nf-agent-pi/VERSION b/plugins/nf-agent-pi/VERSION new file mode 100644 index 0000000000..8f0916f768 --- /dev/null +++ b/plugins/nf-agent-pi/VERSION @@ -0,0 +1 @@ +0.5.0 diff --git a/plugins/nf-agent-pi/agent-rpc/go.mod b/plugins/nf-agent-pi/agent-rpc/go.mod new file mode 100644 index 0000000000..58d92c1011 --- /dev/null +++ b/plugins/nf-agent-pi/agent-rpc/go.mod @@ -0,0 +1,13 @@ +module github.com/nextflow-io/nf-agent-pi/agent-rpc + +go 1.23.0 + +require google.golang.org/grpc v1.75.0 + +require ( + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/protobuf v1.36.6 // indirect +) diff --git a/plugins/nf-agent-pi/agent-rpc/go.sum b/plugins/nf-agent-pi/agent-rpc/go.sum new file mode 100644 index 0000000000..0899aacaa3 --- /dev/null +++ b/plugins/nf-agent-pi/agent-rpc/go.sum @@ -0,0 +1,36 @@ +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= diff --git a/plugins/nf-agent-pi/agent-rpc/main.go b/plugins/nf-agent-pi/agent-rpc/main.go new file mode 100644 index 0000000000..d21c0efdef --- /dev/null +++ b/plugins/nf-agent-pi/agent-rpc/main.go @@ -0,0 +1,526 @@ +// Copyright 2013-2026, Seqera Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/encoding" + "google.golang.org/grpc/keepalive" +) + +const connectMethod = "/nextflow.agent.AgentBroker/Connect" + +type jsonCodec struct{} + +func (jsonCodec) Name() string { return "json" } +func (jsonCodec) Marshal(v any) ([]byte, error) { return json.Marshal(v) } +func (jsonCodec) Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) } + +type options struct { + endpoint string + invocationID string + token string + // The decoded SHA-256 certificate digest, not the text of --fingerprint: parseArgs owns the + // decode (see below), so nothing downstream can be handed a digest that has not been checked. + pinned []byte + insecureTLS bool + startup time.Duration + connect time.Duration + maxLineBytes int + harness []string +} + +type frame map[string]any + +func main() { + opts, err := parseArgs(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, "agent-rpc:", err) + os.Exit(2) + } + if err := run(opts); err != nil { + fmt.Fprintln(os.Stderr, "agent-rpc:", err) + os.Exit(1) + } +} + +func parseArgs(args []string) (options, error) { + var opts options + separator := -1 + for i, arg := range args { + if arg == "--" { + separator = i + break + } + } + if separator < 0 || separator == len(args)-1 { + return opts, errors.New("expected harness command after --") + } + var fingerprint string + flags := flag.NewFlagSet("agent-rpc", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.StringVar(&opts.endpoint, "endpoint", "", "Nextflow driver gRPC endpoint") + flags.StringVar(&opts.invocationID, "invocation", "", "agent invocation identity") + flags.StringVar(&opts.token, "token", "", "single-invocation capability token") + flags.StringVar(&fingerprint, "fingerprint", "", "SHA-256 digest of the driver TLS certificate to pin, as hex") + flags.BoolVar(&opts.insecureTLS, "insecure", false, "dial the driver in cleartext; only valid when the driver sets agent.rpc.tls = false") + flags.DurationVar(&opts.startup, "startup-timeout", 30*time.Second, "harness startup timeout") + flags.DurationVar(&opts.connect, "connect-timeout", defaultConnectTimeout, "driver connection timeout") + flags.IntVar(&opts.maxLineBytes, "max-line-bytes", 10*1024*1024, "maximum JSONL frame size") + if err := flags.Parse(args[:separator]); err != nil { + return opts, err + } + if opts.endpoint == "" || opts.invocationID == "" || opts.token == "" { + return opts, errors.New("--endpoint, --invocation, and --token are required") + } + // TLS is on by default on the driver, so the pin is mandatory unless cleartext is + // asked for explicitly. Absence of --fingerprint must never mean "dial unpinned". + if fingerprint == "" && !opts.insecureTLS { + return opts, errors.New("--fingerprint is required unless --insecure is given") + } + if fingerprint != "" && opts.insecureTLS { + return opts, errors.New("--fingerprint and --insecure are mutually exclusive") + } + // The digest is decoded HERE, while we are still only reading argv, and not lazily at dial + // time. dialCredentials is not reached until the Node harness has been forked, started, and + // waited on for up to --startup-timeout, so a one-character typo in the digest used to cost a + // full Pi-harness boot, report itself in .command.err underneath whatever the harness had + // already written to stderr, and exit 1 -- the code that means "the run failed" -- rather than + // the 2 that every other malformed argument gets. Rejecting it before anything is spawned + // costs nothing and keeps all argument errors indistinguishable to the caller. + // + // Placed after the two checks above so their messages, which name the flags rather than the + // value, stay the first thing an operator sees when the flags themselves are wrong. + if !opts.insecureTLS { + digest, err := parseFingerprint(fingerprint) + if err != nil { + return opts, err + } + opts.pinned = digest + } + if opts.maxLineBytes <= 0 { + return opts, errors.New("--max-line-bytes must be positive") + } + // Zero or negative would expire the budget before the first SYN, failing every task with a + // timeout no address could satisfy; there is no "wait forever" spelling to reserve here. + if opts.connect <= 0 { + return opts, errors.New("--connect-timeout must be positive") + } + opts.harness = append([]string(nil), args[separator+1:]...) + return opts, nil +} + +func run(opts options) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + cmd := exec.CommandContext(ctx, opts.harness[0], opts.harness[1:]...) + cmd.Stderr = os.Stderr + childOut, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("open harness stdout: %w", err) + } + childIn, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("open harness stdin: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start harness: %w", err) + } + // Always reap the child, whatever exit path (including a panic) run() takes. + defer terminate(cmd) + + childFrames := make(chan frame, 16) + childErrors := make(chan error, 1) + go scanHarness(childOut, opts.maxLineBytes, childFrames, childErrors) + + var ready frame + select { + case ready = <-childFrames: + if ready["type"] != "ready" { + return fmt.Errorf("expected harness ready frame, received %v", ready["type"]) + } + case err := <-childErrors: + return fmt.Errorf("harness failed before ready: %w", err) + case <-time.After(opts.startup): + return errors.New("timed out waiting for harness ready frame") + case <-ctx.Done(): + return ctx.Err() + } + + encoding.RegisterCodec(jsonCodec{}) + conn, err := grpc.NewClient(opts.endpoint, dialOptions(opts)...) + if err != nil { + return fmt.Errorf("connect to driver: %w", err) + } + defer conn.Close() + if err := awaitDriver(ctx, conn, opts.endpoint, opts.connect); err != nil { + return err + } + + desc := &grpc.StreamDesc{StreamName: "Connect", ClientStreams: true, ServerStreams: true} + stream, err := conn.NewStream(ctx, desc, connectMethod) + if err != nil { + return fmt.Errorf("open driver stream to %s: %w", opts.endpoint, err) + } + if err := stream.SendMsg(frame{"type": "connect", "invocationId": opts.invocationID, "token": opts.token}); err != nil { + // This is the send whose refusal the operator most needs to read -- an expired or + // already-consumed capability is answered here -- and nothing is receiving yet. + return fmt.Errorf("register invocation: %w", sendStatus(stream, err)) + } + + hostFrames := make(chan frame, 16) + hostErrors := make(chan error, 1) + go recvHost(stream, hostFrames, hostErrors) + // The ONLY writer to the harness' stdin, and it never leaves this goroutine: both Encode + // calls below are branches of the one select loop, and the channels are what cross goroutine + // boundaries. So no lock -- a mutex here would only claim a concurrency that does not exist. + // + // One encoder for the whole loop rather than one per write. That is not merely tidier: a + // json.Encoder LATCHES its first write error and short-circuits every later Encode, which a + // fresh per-write encoder would not. Unobservable here, because neither call outlives a + // failure -- the hostFrames branch returns the error, and the ctx.Done branch is the last + // write before the loop returns. Keep it that way: an Encode added below that carries on + // after an error would silently write nothing. + enc := json.NewEncoder(childIn) + + for { + select { + case msg := <-hostFrames: + if err := enc.Encode(msg); err != nil { + return fmt.Errorf("write harness frame: %w", err) + } + case msg := <-childFrames: + msg["invocationId"] = opts.invocationID + if err := stream.SendMsg(msg); err != nil { + if errors.Is(err, io.EOF) { + // Same io.EOF-hides-the-status shape as the connect send, but recvHost owns + // RecvMsg by now, so asking for it here would be the concurrent receive grpc-go + // forbids. It is already reading; give it a moment to hand the status over. + select { + case hostErr := <-hostErrors: + return fmt.Errorf("driver stream: %w", hostErr) + case <-time.After(2 * time.Second): + } + } + return fmt.Errorf("send driver frame: %w", err) + } + switch msg["type"] { + case "complete": + if err := json.NewEncoder(os.Stdout).Encode(msg); err != nil { + return fmt.Errorf("write final result: %w", err) + } + _ = stream.CloseSend() + _ = childIn.Close() + // Wait for the driver to END the stream before the deferred conn.Close runs. + // CloseSend half-closes only THIS direction: the driver still has trailers to + // flush, and dropping the TLS connection under that flush is what makes it log + // `SSLEngine closed already` / "Transport failed" on a run that in fact + // succeeded. recvHost owns RecvMsg, so the end of the stream reaches us as the + // io.EOF it reports. Bounded, so a driver that never ends the stream costs this + // much and no more. + select { + case <-hostErrors: + case <-time.After(gracefulCloseTimeout): + } + return nil + case "error": + _ = stream.CloseSend() + return fmt.Errorf("harness error [%v]: %v", msg["code"], msg["message"]) + } + case err := <-childErrors: + return fmt.Errorf("harness protocol: %w", err) + case err := <-hostErrors: + return fmt.Errorf("driver stream: %w", err) + case <-ctx.Done(): + _ = enc.Encode(frame{"type": "cancel", "invocationId": opts.invocationID, "reason": ctx.Err().Error()}) + return ctx.Err() + } + } +} + +// sendStatus resolves what SendMsg reported into an error that still carries the stream's status. +// +// grpc-go returns a bare io.EOF from SendMsg whenever the stream was ended by the server or the +// transport rather than by this client, and documents the status as retrievable only from RecvMsg +// ("otherwise, io.EOF is returned and the status of the stream may be discovered using RecvMsg"). +// Returning that io.EOF as-is costs precisely the diagnosis the broker went to the trouble of +// sending: an UNAUTHENTICATED description reaches the task's .command.err as "EOF". +// +// Only safe to call while nothing else is receiving on the stream -- grpc-go permits concurrent +// SendMsg and RecvMsg, but not two concurrent RecvMsg. Once recvHost is running it owns the +// receive side, so the frame loop recovers its status from hostErrors instead. +func sendStatus(stream grpc.ClientStream, err error) error { + if !errors.Is(err, io.EOF) { + // Anything else was generated by this client and is already the status it wants reported. + return err + } + var discard frame + if recvErr := stream.RecvMsg(&discard); recvErr != nil && !errors.Is(recvErr, io.EOF) { + return recvErr + } + // A clean close on both sides leaves nothing better to say, and callers still test for io.EOF. + return err +} + +// How long the driver connection has to come up before the task is failed. +// +// The driver no longer always hands this proxy an address a human wrote: where the endpoint used to +// be either a container engine's host alias or an explicit agent.rpc.remoteHost, it is now usually +// INFERRED from the driver's own default route, and the expensive way for inference to be wrong is +// to be plausible -- an address that routes nowhere rather than one that answers "refused". +// +// 30s, matching --startup-timeout, is above grpc-go's own 20s minimum per-attempt connect timeout, +// so a slow TCP+TLS handshake over a loaded link still completes inside the budget and only an +// endpoint that never answers spends it. +const defaultConnectTimeout = 30 * time.Second + +// awaitDriver bounds the wait for the driver connection to become usable, so a wrong endpoint fails +// the task in seconds and names the address it tried. +// +// A REFUSED endpoint is already loud without this: grpc.NewClient is lazy, the stream below is +// opened without WaitForReady, and a channel in TRANSIENT_FAILURE fails NewStream immediately. A +// BLACKHOLED one -- the shape a mis-inferred address takes, packets silently dropped -- is not: +// there is nothing to fail on, the channel sits in CONNECTING, and both NewStream and the OS would +// wait on it. Meanwhile the driver holds this invocation's capability until agent.rpc.capabilityTimeout +// expires, an hour by default, pinning the request behind a connection that will never arrive. +// +// TRANSIENT_FAILURE returns nil rather than an error of its own, deliberately: this state carries no +// reason, while the status NewStream fails with quotes the transport's ("connection refused", or the +// pinning refusal from pinnedTLSConfig). Reporting it here would cost exactly the diagnosis the +// operator needs. grpc's initial reconnect backoff is a second, so the caller is in NewStream long +// before the channel leaves that state. +// +// Nothing is offered to the endpoint here. The capability token crosses the wire in run()'s connect +// frame, after this returns and after the stream opened, which is after the TLS pin has verified -- +// so a driver that fails the pin is never told the token. +func awaitDriver(ctx context.Context, conn *grpc.ClientConn, endpoint string, budget time.Duration) error { + dialCtx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + // NewClient connects nothing until a stream asks for a subchannel, so without this the wait + // below would time out against a channel that never left IDLE. + conn.Connect() + for { + state := conn.GetState() + if state == connectivity.Ready || state == connectivity.TransientFailure { + return nil + } + if !conn.WaitForStateChange(dialCtx, state) { + if err := ctx.Err(); err != nil { + // The run was cancelled or the task killed; the endpoint is not at fault. + return err + } + return fmt.Errorf("connect to driver at %s: no connection after %s", endpoint, budget) + } + } +} + +// parseFingerprint decodes the pinned SHA-256 certificate digest. The driver emits lowercase +// unseparated hex over the certificate's DER; colons and case are tolerated here only so a +// digest copied out of keytool or `openssl x509 -fingerprint` also works. +func parseFingerprint(value string) ([]byte, error) { + cleaned := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(value)), ":", "") + digest, err := hex.DecodeString(cleaned) + if err != nil { + return nil, fmt.Errorf("invalid --fingerprint %q: expected a hex SHA-256 digest", value) + } + if len(digest) != sha256.Size { + return nil, fmt.Errorf("invalid --fingerprint %q: expected %d hex bytes, got %d", value, sha256.Size, len(digest)) + } + return digest, nil +} + +// How often the proxy pings an otherwise silent driver connection, and how long the driver then +// has to answer. The post-connect capability deadline is deliberately gone -- an agent may run +// for hours -- which leaves nothing on THIS side watching a driver that vanished: a killed JVM +// or a lost driver node sends no FIN, so the blocking RecvMsg in recvHost would simply never +// return and the task would burn its container until the operating system's own TCP keepalive +// noticed, 2h on Linux. +// +// Time is 10 minutes and is NOT derived from the 10s the current broker permits. This binary +// ships in the runner container image (/usr/local/bin/agent-rpc, see PiAgentRunner), not in the +// plugin jar, so it routinely meets a driver it was not built alongside -- including one whose +// broker never calls permitKeepAliveTime and therefore enforces grpc-java's default of 5 minutes. +// Two pings inside that window is a GOAWAY, and a GOAWAY is unrecoverable here: the capability +// token is single-use and was consumed on connect, so grpc-go's reconnect cannot register again +// and the invocation is lost. 10 minutes leaves 2x margin on the tightest policy any driver +// enforces; 5 would be arithmetically equal to it, so ordinary clock skew alone would turn a +// legitimate ping into a strike. The cost of erring long is only detection latency, and even that +// is mostly theoretical: the driver pings every 60s and grpc-go re-arms this timer from the last +// READ, so against a live driver this ping is essentially never sent. +// +// Timeout is 90 seconds and does double duty, which is the part that is easy to miss. Setting +// Time at all makes grpc-go call setsockopt(TCP_USER_TIMEOUT, Timeout) on the socket -- a real +// kernel timer on Linux, where these tasks run -- so this value is also the budget for +// unacknowledged OUTBOUND data, replacing the ~15 minutes the default tcp_retries2 allows. It is +// set well above grpc-go's own 20s default so a network blip or a stop-the-world GC on the driver +// is not fatal, and deliberately just above the ~80s (keepAliveTime 60s + keepAliveTimeout 20s, +// see AgentRpcBroker) at which the driver's server keepalive already gives up on this task: past +// that point the driver has failed the task anyway, so nothing is lost by the client's kernel +// giving up too, and below it the kernel could kill a link the driver still considers good. +// +// PermitWithoutStream stays false, matching the broker's permitKeepAliveWithoutCalls. grpc-go +// then parks the keepalive goroutine while no stream is open, which is exactly right: a ping sent +// before the Connect stream exists would be a strike against a server that is not obliged to +// tolerate it, and the window it would cover is the sub-millisecond gap between dial and stream. +var keepaliveParams = keepalive.ClientParameters{ + Time: 10 * time.Minute, + Timeout: 90 * time.Second, + PermitWithoutStream: false, +} + +// dialOptions is the whole transport configuration for the driver connection, kept in one place +// and shared with the tests so they exercise the transport the binary actually dials with rather +// than a hand-assembled approximation of it. +func dialOptions(opts options) []grpc.DialOption { + return []grpc.DialOption{ + grpc.WithTransportCredentials(dialCredentials(opts)), + grpc.WithDefaultCallOptions(grpc.ForceCodec(jsonCodec{})), + grpc.WithKeepaliveParams(keepaliveParams), + } +} + +// dialCredentials cannot fail: parseArgs has already decoded and length-checked the pin, so the +// only two outcomes are the deliberate cleartext escape hatch and a pinned TLS transport. +func dialCredentials(opts options) credentials.TransportCredentials { + if opts.insecureTLS { + return insecure.NewCredentials() + } + return credentials.NewTLS(pinnedTLSConfig(opts.pinned)) +} + +// pinnedTLSConfig pins the driver's per-run, self-signed certificate by SHA-256 digest. There is +// no CA to chain to and no name to match, so the digest is the whole trust decision: +// InsecureSkipVerify turns off chain and hostname verification only, and the hook below still +// runs on every handshake. +// +// The digest is taken over the leaf certificate's DER encoding, which is what the driver hashes +// too (X509Certificate.getEncoded(), see AgentRpcTlsCredentials). Hashing the PEM text or its +// base64 body instead yields an equally well-formed digest that simply never matches, and the +// failure is indistinguishable from a genuine pinning rejection. +// +// The comparison lives in VerifyConnection and NOT in VerifyPeerCertificate, and moving it back +// silently unpins any resumed connection. crypto/tls does not re-verify certificates on a +// resumption: handshake_client_tls13.go returns from readServerCertificate the moment hs.usingPSK +// is set, and the TLS 1.2 session-ticket path never reaches verifyServerCertificate at all -- so +// VerifyPeerCertificate is not consulted on either, while VerifyConnection is called on both. +// That cannot fire today (grpc-go leaves ClientSessionCache nil, so no PSK is ever offered, and +// each agent-rpc process opens exactly one connection), which is precisely the danger: a session +// cache or a shared channel introduced later would turn the pin off with nothing to notice. It is +// the same number of lines either way, so there is no reason to sit on the fragile one. +// +// cs.PeerCertificates[0].Raw carries the same DER that rawCerts[0] would have: on a full +// handshake it is the input crypto/tls handed to x509.ParseCertificate, and on a resumed one it +// is restored from the session that the full handshake already pinned. +// +// A nil pin fails closed rather than open -- bytes.Equal cannot match a 32-byte digest against +// nothing -- so a driver-side regression that stopped emitting a fingerprint would refuse every +// certificate instead of accepting every certificate. +func pinnedTLSConfig(pinned []byte) *tls.Config { + return &tls.Config{ + InsecureSkipVerify: true, + VerifyConnection: func(cs tls.ConnectionState) error { + if len(cs.PeerCertificates) == 0 { + return errors.New("driver presented no TLS certificate") + } + presented := sha256.Sum256(cs.PeerCertificates[0].Raw) + if !bytes.Equal(presented[:], pinned) { + // A fingerprint is a public commitment, not a secret, so both values are + // safe to report and make the failure unambiguously a pinning failure. + return fmt.Errorf("driver TLS certificate fingerprint mismatch: pinned %s, presented %s", + hex.EncodeToString(pinned), hex.EncodeToString(presented[:])) + } + return nil + }, + } +} + +func scanHarness(source io.Reader, max int, output chan<- frame, failures chan<- error) { + defer func() { + if r := recover(); r != nil { + failures <- fmt.Errorf("harness scan panic: %v", r) + } + }() + scanner := bufio.NewScanner(source) + scanner.Buffer(make([]byte, 64*1024), max) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var msg frame + if err := json.Unmarshal([]byte(line), &msg); err != nil { + failures <- fmt.Errorf("malformed JSON: %w", err) + return + } + // A literal `null` (or any JSON null) unmarshals into a nil map with no + // error; forwarding it would panic when the bridge writes into the map. + if msg == nil { + continue + } + output <- msg + } + if err := scanner.Err(); err != nil { + failures <- err + return + } + failures <- io.EOF +} + +// How long to wait, after the final frame, for the driver to end the stream. Only the tail of a +// successful invocation waits: the result has already been written to stdout by then, so this costs +// nothing but a clean connection teardown. +const gracefulCloseTimeout = 2 * time.Second + +func recvHost(stream grpc.ClientStream, output chan<- frame, failures chan<- error) { + defer func() { + if r := recover(); r != nil { + failures <- fmt.Errorf("host receive panic: %v", r) + } + }() + for { + var msg frame + if err := stream.RecvMsg(&msg); err != nil { + failures <- err + return + } + output <- msg + } +} + +func terminate(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + _ = cmd.Process.Signal(syscall.SIGTERM) + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + _ = cmd.Process.Kill() + <-done + } +} diff --git a/plugins/nf-agent-pi/agent-rpc/main_test.go b/plugins/nf-agent-pi/agent-rpc/main_test.go new file mode 100644 index 0000000000..2d77a42fe2 --- /dev/null +++ b/plugins/nf-agent-pi/agent-rpc/main_test.go @@ -0,0 +1,571 @@ +// Copyright 2013-2026, Seqera Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "errors" + "io" + "math/big" + "net" + "strings" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" +) + +func TestParseArgs(t *testing.T) { + opts, err := parseArgs([]string{ + "--endpoint", "driver:1234", + "--invocation", "inv-1", + "--token", "secret", + "--fingerprint", strings.Repeat("ab", sha256.Size), + "--startup-timeout", "5s", + "--", "node", "runner.mjs", + }) + if err != nil { + t.Fatal(err) + } + if opts.endpoint != "driver:1234" || opts.invocationID != "inv-1" || opts.token != "secret" { + t.Fatalf("unexpected options: %#v", opts) + } + if hex.EncodeToString(opts.pinned) != strings.Repeat("ab", sha256.Size) || opts.insecureTLS { + t.Fatalf("unexpected transport options: %#v", opts) + } + if opts.startup != 5*time.Second || strings.Join(opts.harness, " ") != "node runner.mjs" { + t.Fatalf("unexpected runtime options: %#v", opts) + } + // Not passed above: the driver does not emit --connect-timeout, so the default is what every + // task actually runs with. + if opts.connect != defaultConnectTimeout { + t.Fatalf("expected the shipped connect budget by default, got %v", opts.connect) + } +} + +func TestParseArgsRejectsANonPositiveConnectTimeout(t *testing.T) { + args := []string{ + "--endpoint", "driver:1234", "--invocation", "inv-1", "--token", "secret", + "--insecure", "--connect-timeout", "0", "--", "node", + } + if _, err := parseArgs(args); err == nil { + t.Fatal("expected a zero connect budget to be rejected: it would fail every task before the first SYN") + } +} + +func TestParseArgsRejectsMissingIdentity(t *testing.T) { + if _, err := parseArgs([]string{"--endpoint", "driver:1234", "--insecure", "--", "node"}); err == nil { + t.Fatal("expected missing invocation identity to fail") + } +} + +// The driver serves TLS unless explicitly told not to, so a missing pin must fail closed: if +// absence meant "dial unpinned", a driver-side regression would silently downgrade the transport. +func TestParseArgsRequiresFingerprintUnlessInsecure(t *testing.T) { + base := []string{"--endpoint", "driver:1234", "--invocation", "inv-1", "--token", "secret"} + if _, err := parseArgs(append(append([]string(nil), base...), "--", "node")); err == nil { + t.Fatal("expected a missing --fingerprint to fail") + } + if _, err := parseArgs(append(append([]string(nil), base...), "--insecure", "--", "node")); err != nil { + t.Fatalf("expected --insecure to stand in for the pin: %v", err) + } +} + +func TestParseArgsRejectsFingerprintWithInsecure(t *testing.T) { + args := []string{ + "--endpoint", "driver:1234", "--invocation", "inv-1", "--token", "secret", + "--fingerprint", strings.Repeat("ab", sha256.Size), "--insecure", "--", "node", + } + if _, err := parseArgs(args); err == nil { + t.Fatal("expected --fingerprint and --insecure to be mutually exclusive") + } +} + +func TestParseFingerprintForms(t *testing.T) { + canonical := strings.Repeat("ab", sha256.Size) + // Lowercase unseparated hex is what the driver emits; the rest are tolerated so a digest + // pasted from keytool or `openssl x509 -fingerprint` also works. + for _, in := range []string{canonical, strings.ToUpper(canonical), colonize(strings.ToUpper(canonical)), " " + canonical + " "} { + got, err := parseFingerprint(in) + if err != nil { + t.Fatalf("%q: %v", in, err) + } + if hex.EncodeToString(got) != canonical { + t.Fatalf("%q decoded to %s", in, hex.EncodeToString(got)) + } + } + // "sha256:" prefixes only ever precede base64 in the SSH form, never hex, so accepting one + // here would accept a string nothing produces. + for _, bad := range []string{"", "zz", "not-hex", canonical + "ab", canonical[:len(canonical)-2], "sha256:" + canonical} { + if _, err := parseFingerprint(bad); err == nil { + t.Fatalf("expected %q to be rejected", bad) + } + } +} + +// A malformed digest has to be caught while argv is still the only thing that has happened. By +// the time dialCredentials runs, the Node harness has been forked and waited on for up to +// --startup-timeout, so a typo used to cost a full harness boot and then report itself as a run +// failure (exit 1) rather than as the bad argument it is (exit 2). +func TestParseArgsRejectsMalformedFingerprint(t *testing.T) { + args := []string{ + "--endpoint", "driver:1234", "--invocation", "inv-1", "--token", "secret", + "--fingerprint", "not-a-digest", "--", "node", + } + _, err := parseArgs(args) + if err == nil { + t.Fatal("expected a malformed pin to be rejected while parsing arguments") + } + if !strings.Contains(err.Error(), "--fingerprint") { + t.Fatalf("the error should name the flag at fault: %v", err) + } +} + +func TestDialCredentialsInsecureOptsOutOfTLS(t *testing.T) { + if got := dialCredentials(options{insecureTLS: true}).Info().SecurityProtocol; got != "insecure" { + t.Fatalf("expected the escape hatch to select cleartext, got %q", got) + } +} + +func TestPinnedFingerprintAcceptsTheDriverCertificate(t *testing.T) { + cert, fingerprint := selfSignedCertificate(t) + if err := dialPinned(t, serveTLS(t, cert), fingerprint); err != nil { + t.Fatalf("expected the handshake to succeed with a matching pin: %v", err) + } +} + +// dialCredentials can no longer report an error, so the fail-closed property rests entirely on +// bytes.Equal refusing to match a 32-byte digest against nothing. If that were ever softened into +// "no pin means accept anything", a driver-side regression that stopped emitting --fingerprint +// would unpin every task in the run without a single failure to show for it. +func TestDialCredentialsWithoutAPinRefusesEveryCertificate(t *testing.T) { + cert, _ := selfSignedCertificate(t) + err := dialThrough(t, serveTLS(t, cert), options{}) + if err == nil { + t.Fatal("expected an absent pin to refuse the certificate, not to accept it") + } + if !strings.Contains(err.Error(), "fingerprint mismatch") { + t.Fatalf("expected a pinning refusal, got: %v", err) + } +} + +// TLS session resumption is where a certificate pin most often goes quietly missing: crypto/tls +// does not re-verify certificates on a resumed handshake, so a check installed in +// VerifyPeerCertificate is simply never consulted. grpc-go opens no session cache today, which is +// why this has to reach the TLS config directly rather than dial through grpc -- nothing in the +// shipped path can currently produce a resumption, and therefore nothing in the shipped path +// would notice the day a cache or a shared channel appears. What is asserted here is the +// placement of our own hook, which is the entire change. +func TestPinSurvivesASessionResumption(t *testing.T) { + cert, served := selfSignedCertificate(t) + endpoint := serveRawTLS(t, cert) + cache := tls.NewLRUClientSessionCache(4) + + resumed, err := pinnedHandshake(t, endpoint, served, cache) + if err != nil { + t.Fatalf("first handshake: %v", err) + } + if resumed { + t.Fatal("the first handshake cannot be a resumption") + } + // Proven, not assumed: if the second handshake were full, the rest of this test would pass + // against the very placement it exists to reject. + if resumed, err = pinnedHandshake(t, endpoint, served, cache); err != nil { + t.Fatalf("second handshake: %v", err) + } else if !resumed { + t.Fatal("expected the second handshake to resume the session") + } + + // Same warm cache, so the same PSK is offered and no certificate crosses the wire -- but the + // identity behind it is still the server's, and it still has to match. + _, other := selfSignedCertificate(t) + if _, err := pinnedHandshake(t, endpoint, other, cache); err == nil { + t.Fatal("a resumed handshake was accepted without checking the pin") + } else if !strings.Contains(err.Error(), "fingerprint mismatch") { + t.Fatalf("expected a pinning refusal on the resumed handshake, got: %v", err) + } +} + +// The values, and the fact that they reach the dial at all. An end-to-end ping exchange at the +// shipped interval is not testable in a unit suite (and testing it at an interval we do not ship +// would assert nothing about what we do ship), so this pins the decision instead. +func TestKeepaliveStaysWithinTheOldestDriversPingBudget(t *testing.T) { + // grpc-java's ServerBuilder default when a broker never calls permitKeepAliveTime. This + // binary lives in the runner container image, not in the plugin jar, so it meets such a + // driver whenever an image and a driver are not upgraded together. + const grpcJavaDefaultPermitKeepAliveTime = 5 * time.Minute + if keepaliveParams.Time < 2*grpcJavaDefaultPermitKeepAliveTime { + // Not merely "greater than": at exactly the enforcement floor, ordinary clock skew turns + // a legitimate ping into a strike, two strikes are a GOAWAY, and a GOAWAY cannot be + // recovered from because the capability token was consumed on connect. + t.Fatalf("ping interval %v leaves no margin over the oldest driver's %v enforcement floor", + keepaliveParams.Time, grpcJavaDefaultPermitKeepAliveTime) + } + // Timeout is also handed to setsockopt(TCP_USER_TIMEOUT) by grpc-go, so it is simultaneously + // the kernel's budget for unacknowledged outbound data. Below the ~80s at which the driver's + // own server keepalive gives up (AgentRpcBroker: 60s + 20s), it would start killing links the + // driver still considers healthy -- a far tighter policy than anything decided here. + if keepaliveParams.Timeout <= 80*time.Second { + t.Fatalf("ack budget %v undercuts the driver's own ~80s liveness window", keepaliveParams.Timeout) + } + // Must match the broker's permitKeepAliveWithoutCalls, which is deliberately left false. + if keepaliveParams.PermitWithoutStream { + t.Fatal("pinging with no stream open is a strike against a driver that does not permit it") + } + // Cheap tripwire for the one thing no behavioural test can observe: that the parameters are + // actually on the dial. Credentials, codec, keepalive. + if got := len(dialOptions(options{pinned: make([]byte, sha256.Size)})); got != 3 { + t.Fatalf("expected credentials, codec and keepalive on the dial, got %d options", got) + } +} + +// The digest, not the certificate's issuer or names, is the whole trust decision, so a server +// this proxy did not expect must be refused with an unmistakable pinning error rather than a +// generic handshake failure an operator cannot act on. +func TestPinnedFingerprintRejectsAnotherCertificate(t *testing.T) { + cert, served := selfSignedCertificate(t) + _, other := selfSignedCertificate(t) + err := dialPinned(t, serveTLS(t, cert), other) + if err == nil { + t.Fatal("expected a certificate that does not match the pin to be refused") + } + if !strings.Contains(err.Error(), "fingerprint mismatch") { + t.Fatalf("pinning error lost on the way out of grpc: %v", err) + } + if !strings.Contains(err.Error(), served) || !strings.Contains(err.Error(), other) { + t.Fatalf("both the pinned and the presented digest should be reported: %v", err) + } +} + +// The endpoint is no longer always an address a human wrote: it is usually inferred from the +// driver's default route, and the expensive way for inference to be wrong is to be plausible -- +// an address that routes nowhere rather than one that answers "refused". Left to itself the channel +// sits in CONNECTING, so nothing here fails, while the driver holds this invocation's capability +// for the full agent.rpc.capabilityTimeout (an hour by default) waiting for a connection that will +// never arrive. +// +// The dialer neither answers nor refuses, which is what a dropped SYN looks like from this side and +// is the one case no real endpoint can be made to reproduce on demand. +func TestAwaitDriverGivesUpOnABlackholedEndpoint(t *testing.T) { + const endpoint = "127.0.0.1:1" + released := make(chan struct{}) + t.Cleanup(func() { close(released) }) + blackhole := grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + select { + case <-ctx.Done(): + case <-released: + } + return nil, errors.New("blackholed") + }) + conn, err := grpc.NewClient(endpoint, append(dialOptions(options{insecureTLS: true}), blackhole)...) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + started := time.Now() + err = awaitDriver(context.Background(), conn, endpoint, 200*time.Millisecond) + + if err == nil { + t.Fatal("expected a blackholed endpoint to fail the task, not to be waited on") + } + // Bounded by OUR budget, not by grpc-go's 20s per-attempt connect timeout or the kernel's own + // retransmission schedule, either of which would dominate if the deadline were not enforced here. + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Fatalf("gave up only after %v, so the budget is not what bounded the wait", elapsed) + } + if !strings.Contains(err.Error(), endpoint) { + t.Fatalf("the failure must name the address it tried: %v", err) + } +} + +// A refused endpoint was already fast, and has to stay that way -- but through the whole of run(), +// because naming the address is half the point: an operator reading .command.err has to be able to +// tell WHICH driver address the task tried when the inference picked the wrong interface. +func TestRunFailsFastOnARefusedEndpointAndNamesIt(t *testing.T) { + endpoint := closedPort(t) + opts := options{ + endpoint: endpoint, + invocationID: "inv-1", + token: "secret", + insecureTLS: true, + startup: 10 * time.Second, + connect: 10 * time.Second, + maxLineBytes: 64 * 1024, + // The proxy dials only after the harness is up, so a stand-in has to announce itself and + // then stay alive; `cat` blocks on stdin exactly as the real harness does between frames. + harness: []string{"sh", "-c", `printf '{"type":"ready"}\n'; exec cat`}, + } + + started := time.Now() + err := run(opts) + + if err == nil { + t.Fatal("expected a refused endpoint to fail the task") + } + if elapsed := time.Since(started); elapsed > opts.connect { + t.Fatalf("a refusal took %v: it must not be waited out like a blackhole", elapsed) + } + if !strings.Contains(err.Error(), endpoint) { + t.Fatalf("the failure must name the address it tried: %v", err) + } + // The transport's own reason survives the connect wait; awaitDriver deliberately reports + // nothing of its own for TRANSIENT_FAILURE, which carries no reason to report. + if !strings.Contains(err.Error(), "refused") { + t.Fatalf("the transport's reason was lost on the way out: %v", err) + } +} + +// closedPort is an address nothing listens on: bound and then released, so the port is a real free +// one rather than a guess that something else on the machine might occupy. +func closedPort(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + endpoint := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + return endpoint +} + +// selfSignedCertificate stands in for the driver's per-run identity, and returns the fingerprint +// in the driver's own form: SHA-256 over the certificate DER, lowercase hex, no separators. +// +// It mirrors what AgentRpcTlsCredentials actually emits -- EC P-256, self-signed, the same two +// names, and deliberately no basicConstraints and no key-usage extensions -- so the pin path is +// exercised against the certificate shape the broker really serves rather than a tidier one. +func selfSignedCertificate(t *testing.T) (tls.Certificate, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: "nextflow-agent-rpc"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(der) + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, hex.EncodeToString(digest[:]) +} + +func serveTLS(t *testing.T, cert tls.Certificate) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}}))) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + return listener.Addr().String() +} + +// serveRawTLS is the resumption test's server: crypto/tls directly rather than grpc, because +// grpc-go offers no way to hand the server a session-ticket policy and the whole point is to +// obtain a resumable session. It writes a byte and then blocks, so a client read has something +// to return once the post-handshake NewSessionTicket has been processed. +func serveRawTLS(t *testing.T, cert tls.Certificate) string { + t.Helper() + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS13, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + if _, err := conn.Write([]byte{'x'}); err != nil { + return + } + _, _ = io.Copy(io.Discard, conn) + }() + } + }() + return listener.Addr().String() +} + +// pinnedHandshake runs one real handshake using the production pinning config with nothing added +// but the shared session cache, and reports whether it resumed. The read is not incidental: in +// TLS 1.3 the NewSessionTicket arrives after the handshake completes and the client only +// processes it while reading, so without it there would be nothing in the cache to resume from. +func pinnedHandshake(t *testing.T, endpoint, fingerprint string, cache tls.ClientSessionCache) (bool, error) { + t.Helper() + config := pinnedTLSConfig(mustPin(t, fingerprint)) + config.ClientSessionCache = cache + config.MinVersion = tls.VersionTLS13 + conn, err := tls.Dial("tcp", endpoint, config) + if err != nil { + return false, err + } + defer conn.Close() + if _, err := conn.Read(make([]byte, 1)); err != nil { + return false, err + } + return conn.ConnectionState().DidResume, nil +} + +// dialPinned exercises the real transport: grpc.NewClient is lazy, so the handshake -- and hence +// the pin check -- only happens once a stream is opened. +func dialPinned(t *testing.T, endpoint, fingerprint string) error { + t.Helper() + return dialThrough(t, endpoint, options{pinned: mustPin(t, fingerprint)}) +} + +// dialThrough builds the connection from dialOptions, i.e. from the exact option set run() uses, +// so these tests cannot drift away from what the binary dials with. That also means they inherit +// the JSON codec and the keepalive parameters; harmless here, since no message is ever sent and +// no test runs for anything close to the ping interval. +// +// It waits through awaitDriver for the same reason: that is the sequence run() performs, and a +// connect wait that swallowed the transport's own failure reason would show up here as a pinning +// test that stopped seeing pinning errors. +func dialThrough(t *testing.T, endpoint string, opts options) error { + t.Helper() + conn, err := grpc.NewClient(endpoint, dialOptions(opts)...) + if err != nil { + return err + } + defer conn.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := awaitDriver(ctx, conn, endpoint, 10*time.Second); err != nil { + return err + } + desc := &grpc.StreamDesc{StreamName: "Connect", ClientStreams: true, ServerStreams: true} + _, err = conn.NewStream(ctx, desc, connectMethod) + return err +} + +func mustPin(t *testing.T, fingerprint string) []byte { + t.Helper() + digest, err := parseFingerprint(fingerprint) + if err != nil { + t.Fatal(err) + } + return digest +} + +func colonize(s string) string { + var pairs []string + for i := 0; i+1 < len(s); i += 2 { + pairs = append(pairs, s[i:i+2]) + } + return strings.Join(pairs, ":") +} + +func TestScanHarness(t *testing.T) { + frames := make(chan frame, 2) + failures := make(chan error, 1) + go scanHarness(strings.NewReader("\n{\"type\":\"ready\"}\n"), 1024, frames, failures) + + if got := <-frames; got["type"] != "ready" { + t.Fatalf("unexpected frame: %#v", got) + } + if err := <-failures; err == nil || err.Error() != "EOF" { + t.Fatalf("expected EOF, got %v", err) + } +} + +func TestScanHarnessRejectsMalformedJSON(t *testing.T) { + frames := make(chan frame, 1) + failures := make(chan error, 1) + go scanHarness(strings.NewReader("not-json\n"), 1024, frames, failures) + if err := <-failures; err == nil || !strings.Contains(err.Error(), "malformed JSON") { + t.Fatalf("unexpected error: %v", err) + } +} + +// A stream whose RecvMsg answers with a status, standing in for a transport-terminated send. +// +// grpc-go returns a bare io.EOF from SendMsg whenever the stream was ended by the server or the +// transport rather than by this client, and documents the status as retrievable only from RecvMsg. +// Provoking that against a live server means killing the transport inside the window between +// NewStream and SendMsg, which is inherently racy. The contract under test -- "on io.EOF, ask +// RecvMsg" -- is exact, so it is asserted against grpc's own ClientStream interface rather than +// against a coin flip. The embedded nil interface supplies the methods this path never calls. +type statusStream struct { + grpc.ClientStream + recvErr error + recvCalls int +} + +func (s *statusStream) RecvMsg(any) error { + s.recvCalls++ + return s.recvErr +} + +func TestSendStatusRecoversTheStatusBehindAnEOF(t *testing.T) { + refusal := status.Error(codes.Unauthenticated, "Agent RPC invocation capability expired after 3600s while the task waited to start") + stream := &statusStream{recvErr: refusal} + + err := sendStatus(stream, io.EOF) + + if stream.recvCalls != 1 { + t.Fatalf("expected exactly one RecvMsg to recover the status, got %d", stream.recvCalls) + } + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("status code lost: got %v, want %v", status.Code(err), codes.Unauthenticated) + } + // The description is the whole point: without it the task's .command.err reads "EOF" and the + // operator cannot tell a lapsed capability from a forged one. + if !strings.Contains(err.Error(), "capability expired after 3600s") { + t.Fatalf("status description lost: %v", err) + } +} + +func TestSendStatusLeavesAClientGeneratedErrorAlone(t *testing.T) { + direct := status.Error(codes.ResourceExhausted, "grpc: trying to send message larger than max") + stream := &statusStream{recvErr: status.Error(codes.Unavailable, "should not be consulted")} + + err := sendStatus(stream, direct) + + if err != direct { + t.Fatalf("a client-generated status must pass through untouched: got %v", err) + } + if stream.recvCalls != 0 { + t.Fatalf("RecvMsg must not be consulted when SendMsg already carried the status, got %d calls", stream.recvCalls) + } +} + +func TestSendStatusKeepsEOFWhenTheStreamCarriesNoStatus(t *testing.T) { + stream := &statusStream{recvErr: io.EOF} + + err := sendStatus(stream, io.EOF) + + // A clean half-close on both sides leaves nothing better to report, and callers up the stack + // still test this with errors.Is(err, io.EOF). + if !errors.Is(err, io.EOF) { + t.Fatalf("expected the original io.EOF to survive, got %v", err) + } +} diff --git a/plugins/nf-agent-pi/build-image.sh b/plugins/nf-agent-pi/build-image.sh new file mode 100755 index 0000000000..0100b7b2c7 --- /dev/null +++ b/plugins/nf-agent-pi/build-image.sh @@ -0,0 +1,273 @@ +#!/bin/bash +# +# Copyright 2013-2026, Seqera Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Build and publish the `pi` agent runner image for linux/amd64 and linux/arm64. +# +# nf-agent-pi ships no runtime of its own, so this image IS the distribution unit: an +# agent selecting the `pi` runner runs in it. The Nextflow release publishes it, through this +# script: release.sh step 1 runs `make release-agent-image`, which runs the Gradle task +# :plugins:nf-agent-pi:releaseImageIfNotExists, which runs `push` below. That is a no-op when +# the tag is already published, so the release is safe to re-run. An ordinary build never +# touches docker. Running this script by hand remains the escape hatch, and is how a tag gets +# published outside a release. +# +# plugins/nf-agent-pi/build-image.sh build # both arches, publishes nothing +# plugins/nf-agent-pi/build-image.sh build -l # host arch only, into local docker +# plugins/nf-agent-pi/build-image.sh push -r # build, push, verify the manifest +# plugins/nf-agent-pi/build-image.sh ref # print the reference; no docker needed +# +# A single-arch image is the failure this script exists to prevent: pulling one on the other +# architecture fails with `no matching manifest for linux/amd64 in the manifest list entries`. +set -u + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PLATFORMS=linux/amd64,linux/arm64 +BUILDER=nf-agent-pi +OUT_DIR=$SCRIPT_DIR/build/image + +# THE image coordinate. Everything else derives from it: the reference this script builds and +# pushes, the coordinate the examples pin - see examples/agents/pi-runner.config - and the +# "/:" string the plugin jar embeds and asks for at run time +# (build.gradle reads these two lines by anchored match; keep them plain assignments). This is +# the same registry and namespace the Nextflow release already publishes `nextflow/nextflow` to +# (see docker/Makefile), so the release job's existing login covers it and no new credential +# exists for the runner image. Overridden for a private build by NF_AGENT_PI_REGISTRY or -r. +DEFAULT_REGISTRY=public.cr.seqera.io/nextflow +REGISTRY=${NF_AGENT_PI_REGISTRY:-$DEFAULT_REGISTRY} +IMAGE=nf-agent-pi +TAG="" +LOAD=0 # build -l: single-arch into the local image store +KEEP=0 # build -k: do not delete the verification archive +FORCE=0 # push -f: overwrite a tag that is already published + +usage() { + cat <<'TXT' +Build and publish the multi-arch nf-agent-pi runner image. + + usage: plugins/nf-agent-pi/build-image.sh [options] + +commands: + build build every platform locally; publishes nothing + push build every platform, push it, then verify the pushed manifest + ref print the fully resolved image reference and exit; needs no docker + +options (every command): + -r registry/namespace; default public.cr.seqera.io/nextflow, the coordinate the + release publishes. Overridable here or with NF_AGENT_PI_REGISTRY; pass -r '' to + build under a bare local name (which cannot be pushed) + -t image tag; default the plugin VERSION file + -i image name; default nf-agent-pi + -P platforms; default linux/amd64,linux/arm64 + -h this help + +build only: + -l build just this host's architecture and load it into the local docker image + store, for a local test run; a multi-platform result cannot be loaded + -k keep the OCI archive the build writes; it is deleted by default + +push only: + -f build and push even when the tag is already published, overwriting it + +`build` writes an OCI archive under plugins/nf-agent-pi/build/image/, which proves the +build works and is then deleted again - it weighs ~200 MB and nothing consumes it. Pass +-k to inspect it. Layer cache stays in the dedicated buildx builder either way, so +re-runs are fast. + +Tag releases with the plugin VERSION, which is the default, and keep the tag immutable, +so an agent's runtime is pinned as reproducibly as its model. `push` therefore publishes +only if the tag does not exist yet: an already published tag is a no-op that exits 0, so +a failed release can be re-run, and overwriting one takes an explicit -f. "Already +published" means its manifest carries every requested platform - a tag that exists but is +single-arch is a failure, not a skip. +TXT + exit "${1:-0}" +} + +die() { echo "ERROR: $*" >&2; exit 1; } + +# ---------------------------------------------------------------- command line + +CMD=${1:-} +case $CMD in + build|push|ref) shift ;; + help|-h|--help) usage 0 ;; + '') echo "Missing command" >&2; usage 2 ;; + *) echo "Unknown command: $CMD" >&2; usage 2 ;; +esac + +while getopts ':r:t:i:P:lkfh' opt; do + case $opt in + r) REGISTRY=$OPTARG ;; + t) TAG=$OPTARG ;; + i) IMAGE=$OPTARG ;; + P) PLATFORMS=$OPTARG ;; + l) LOAD=1 ;; + k) KEEP=1 ;; + f) FORCE=1 ;; + h) usage 0 ;; + :) echo "Missing argument for -$OPTARG" >&2; usage 2 ;; + ?) echo "Unknown option -$OPTARG" >&2; usage 2 ;; + esac +done +shift $((OPTIND - 1)) +(( $# )) && { echo "Unexpected argument: $1" >&2; usage 2; } + +if [[ $CMD == push ]]; then + (( LOAD )) && die "-l builds a single architecture and cannot be pushed; use build -l" + (( KEEP )) && die "-k applies to build, which writes an archive; push writes none" +elif (( FORCE )); then + die "-f overwrites an already published tag and applies to push, which publishes; $CMD does not" +fi + +# A stale DOCKER_DEFAULT_PLATFORM silently narrows what buildx produces, which is exactly +# the single-arch image this script exists to avoid. --platform below is authoritative. +# The note goes to stderr, not stdout: `ref` writes ONE machine-readable line that callers +# capture with $(...), and a diagnostic mixed into it would be read as part of the reference. +if [[ -n ${DOCKER_DEFAULT_PLATFORM:-} ]]; then + echo "note: ignoring DOCKER_DEFAULT_PLATFORM=$DOCKER_DEFAULT_PLATFORM for this build" >&2 + unset DOCKER_DEFAULT_PLATFORM +fi + +[[ -f $SCRIPT_DIR/VERSION ]] || die "no VERSION file beside $0" +[[ -n $TAG ]] || TAG=$(tr -d '[:space:]' < "$SCRIPT_DIR/VERSION") +[[ -n $TAG ]] || die "VERSION is empty and no -t tag was given" + +if [[ -n $REGISTRY ]]; then + REF="${REGISTRY%/}/$IMAGE:$TAG" +else + # only reachable via an explicit `-r ''`, which asks for a bare, unpushable name + [[ $CMD == push ]] && die "push needs a registry, but -r was given an empty value" + REF="$IMAGE:$TAG" +fi + +# The one place the coordinate is composed. `ref` is the read side of it: the plugin build embeds +# this string in the jar (see build.gradle), so the image the jar asks for is the image this +# script pushes. It exits BEFORE the docker checks below on purpose - resolving a name needs no +# docker, and the build that cross-checks the embedded coordinate must not require one. +if [[ $CMD == ref ]]; then + echo "$REF" + exit 0 +fi + +command -v docker >/dev/null || die "docker is not on PATH" +docker buildx version >/dev/null 2>&1 || die "docker buildx is not available" + +# ------------------------------------------------------------------- commands + +ensure_builder() { + # The default `docker` driver cannot emit a multi-platform manifest - it would silently + # build one architecture. A docker-container builder can, so use a dedicated one. + docker buildx inspect "$BUILDER" >/dev/null 2>&1 && return 0 + echo "==> creating buildx builder '$BUILDER' (docker-container driver)" + docker buildx create --name "$BUILDER" --driver docker-container --bootstrap >/dev/null \ + || die "could not create the '$BUILDER' builder" +} + +cmd_build() { + local platforms=$PLATFORMS archive="" out=() + + if (( LOAD )); then + # --load takes a single image; ask for the host's platform explicitly so the result is + # runnable here whatever DOCKER_DEFAULT_PLATFORM said. + local host_arch + host_arch=$(docker version --format '{{.Server.Arch}}' 2>/dev/null) \ + || die "docker daemon is not reachable" + platforms=linux/$host_arch + out=( --load ) + echo "==> building $platforms and loading $REF into the local image store" + else + # A previous run's archive is never reused - drop it before writing another one, so + # repeated verification runs do not accumulate. + rm -rf "$OUT_DIR" + mkdir -p "$OUT_DIR" || die "cannot create $OUT_DIR" + archive=$OUT_DIR/$IMAGE-$TAG.oci.tar + out=( --output "type=oci,dest=$archive" ) + echo "==> building $platforms to $archive (not published)" + fi + + docker buildx build --builder "$BUILDER" --pull -t "$REF" \ + --platform "$platforms" "${out[@]}" "$SCRIPT_DIR" || die "build failed" + + if (( LOAD )); then + echo "==> OK - $REF is in the local image store" + echo " point the examples at it: agent.container = '$REF'" + return 0 + fi + + echo "==> OK - $REF built for $platforms" + # The archive is a by-product of proving the build works, not a deliverable: the exit + # code above already carries that answer. Keep it only when the point was to inspect it. + if (( KEEP )); then + echo " archive kept: $archive ($(du -h "$archive" | cut -f1))" + else + rm -rf "$OUT_DIR" + echo " archive removed; -k keeps it, or use the push command to publish" + fi +} + +# Assert that the manifest published under $REF carries every requested platform, printing it. +# Returns non-zero, quietly, when the manifest cannot be read at all - "absent", "unreachable" and +# "unauthenticated" are indistinguishable here, and the caller decides what that means. A manifest +# that IS readable but incomplete is fatal wherever it is found: publishing one under the release +# coordinate is the failure this script exists to prevent, so it must not be reachable by pushing +# one, nor by SKIPPING one that a half-finished push - or a hand-run `push -P linux/amd64` - left +# behind. Hence the same check on both paths. +verify_manifest() { + local manifest missing=() p wanted + manifest=$(docker buildx imagetools inspect "$REF" 2>/dev/null) || return 1 + IFS=',' read -ra wanted <<< "$PLATFORMS" + for p in "${wanted[@]}"; do + # arm64 is reported as linux/arm64 or linux/arm64/v8 depending on the base image + grep -Eq "Platform:[[:space:]]+${p}(/v[0-9]+)?$" <<< "$manifest" || missing+=( "$p" ) + done + if (( ${#missing[@]} )); then + echo "$manifest" >&2 + die "pushed manifest is missing: ${missing[*]} - $REF is published but incomplete; republish it with 'push -f', or bump VERSION, before releasing against it" + fi + echo "$manifest" | grep -E '^(Name|Digest):|Platform:' +} + +cmd_push() { + echo "==> building $PLATFORMS and pushing $REF" + # naming the registry, because an authentication failure surfaces here as a build failure and + # this runs inside a release, where the operator sees only this line + docker buildx build --builder "$BUILDER" --pull -t "$REF" \ + --platform "$PLATFORMS" --push "$SCRIPT_DIR" \ + || die "build or push of $REF failed - if the registry refused it, log in to ${REGISTRY%%/*} first (the release job does that with SEQERA_PUBLIC_CR_USERNAME/SEQERA_PUBLIC_CR_PASSWORD)" + + echo "==> verifying the pushed manifest" + verify_manifest || die "cannot inspect $REF after push" + echo "==> OK - $REF carries every requested platform" + echo " point the examples at it: agent.container = '$REF'" +} + +if [[ $CMD == push ]] && (( ! FORCE )); then + # The tag is a runtime pin, so it is immutable: an existing tag is success, not a conflict. + # Mirrors releasePluginToRegistryIfNotExists, and makes a re-run of a failed release safe. + # It is success only if the published manifest is COMPLETE, though - verify_manifest dies on a + # readable but single-arch one rather than skipping it, so a re-run after a push that published + # and then failed verification re-checks instead of inheriting the earlier run's mistake. + # An unreadable manifest ("absent", "unreachable" and "unauthenticated" are all non-zero and + # indistinguishable) falls through to build-and-push and fails at the push, where the error is + # accurate. The push, not this probe, is the authority on what is published. + if verify_manifest >/dev/null; then + echo "==> $REF is already published with every requested platform - nothing to do (-f rebuilds and overwrites it)" + exit 0 + fi +fi + +ensure_builder +"cmd_$CMD" diff --git a/plugins/nf-agent-pi/build.gradle b/plugins/nf-agent-pi/build.gradle new file mode 100644 index 0000000000..9fe0ea845d --- /dev/null +++ b/plugins/nf-agent-pi/build.gradle @@ -0,0 +1,190 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id 'io.nextflow.nextflow-plugin' version "${nextflowPluginVersion}" +} + +nextflowPlugin { + // A minimum core requirement, written into the jar manifest as `Plugin-Requires` and enforced + // by BasePlugin.start(), so it must never exceed the root VERSION or the plugin cannot load + // from this checkout. Bumping it to the release this plugin ships in is a release-phase step, + // as CLAUDE.md's release process requires. + nextflowVersion = '25.08.0-edge' + + provider = "${nextflowPluginProvider}" + description = 'Provides a Pi-backed external agent runner for Nextflow agents' + className = 'nextflow.agent.pi.PiAgentPlugin' + useDefaultDependencies = false + generateSpec = false + extensionPoints = [ + 'nextflow.agent.pi.PiAgentRunner' + ] +} + +// The plugin ships no runtime: the `agent-rpc` proxy and the Node harness live in the +// container image built by the Dockerfile in this directory, so this build needs neither +// a Go toolchain nor npm. `agent-rpc/`, `harness/`, `package.json` and `package-lock.json` +// remain in the repo as the image build context - see the Dockerfile. +sourceSets { + main.java.srcDirs = [] + main.groovy.srcDirs = ['src/main'] + // no hand-written main resource; the one there is - the image coordinate - is generated, + // and is wired in below with srcDir() so it carries its own task dependency + main.resources.srcDirs = [] + test.groovy.srcDirs = ['src/test'] + test.java.srcDirs = [] + // carries only the Pi SDK stub PiHarnessProtocolTest runs the harness against + test.resources.srcDirs = ['src/testResources'] +} + +// ------------------------------------------------------- the image coordinate the jar declares +// +// The coordinate is generated, never typed: DEFAULT_REGISTRY and IMAGE come out of build-image.sh +// and the tag out of this project's VERSION, so what the jar asks for is by construction the +// reference `build-image.sh push` publishes and `build-image.sh ref` prints. PiAgentRunner reads +// it back as the runner's default container, which is why the plugin and its image cannot drift. +// +// This deliberately does NOT shell out to `build-image.sh ref`: Gradle 9 removed Project.exec, and +// making `:plugins:nf-agent-pi:jar` require bash buys nothing. Cross-checking the two compositions +// is PiAgentPackagingTest's job, where it is an independent recomputation rather than a tautology. +final imageScript = layout.projectDirectory.file('build-image.sh').asFile +final imageVersionFile = layout.projectDirectory.file('VERSION').asFile +final imageResourcesDir = layout.buildDirectory.dir('generated/image-resources').get().asFile + +final generateImageCoordinate = tasks.register('generateImageCoordinate') { + description = 'Generate META-INF/nf-agent-pi-image.properties from build-image.sh and VERSION' + inputs.file(imageScript) + inputs.file(imageVersionFile) + outputs.dir(imageResourcesDir) + doLast { + final lines = imageScript.readLines() + // anchored, and exactly one hit: `DEFAULT_REGISTRY=x` and `IMAGE=x` are top-level plain + // assignments in the script, while the getopts arms that reassign them are indented + final assignment = { String key -> + final found = lines.findResults { String line -> + final m = (line =~ ('^' + key + '=(\\S+)$')) + m.matches() ? m.group(1) : null + } + if( found.size() != 1 ) + throw new GradleException("Expected exactly one `${key}=` line in ${imageScript} but found ${found.size()} - the runner image coordinate is composed there; update generateImageCoordinate if it moved") + // this reads the line as TEXT, so a quoted or interpolated value would be embedded + // verbatim and differ from what bash composes. Refuse it here, where the message names + // the cause, rather than shipping a jar for PiAgentPackagingTest to catch by mismatch + if( !(found[0] ==~ /[A-Za-z0-9._:\/-]+/) ) + throw new GradleException("`${key}=${found[0]}` in ${imageScript} is not a plain literal - this line is read as text, so keep it unquoted and free of shell expansion") + return found[0] + } + // mirrors `${REGISTRY%/}/$IMAGE:$TAG` and `tr -d '[:space:]' < VERSION` in the script + final registry = assignment('DEFAULT_REGISTRY').replaceFirst('/$', '') + final tag = imageVersionFile.text.replaceAll(/\s/, '') + if( !tag ) + throw new GradleException("${imageVersionFile} is empty - it is the tag of the runner image") + final target = new File(imageResourcesDir, 'META-INF/nf-agent-pi-image.properties') + target.parentFile.mkdirs() + // written as text rather than with Properties.store(), which escapes the `:` before the + // tag. Properties.load() splits on the FIRST `=`, `:` or blank - here the `=` after + // `image` - so the registry's own colons and slashes survive the round trip either way. + target.text = [ + '# Generated by :plugins:nf-agent-pi:generateImageCoordinate -- do not edit.', + '# The reference `plugins/nf-agent-pi/build-image.sh ref` prints and `push` publishes.', + "image=${registry}/${assignment('IMAGE')}:${tag}", + '' ].join('\n') + } +} + +// a TaskProvider, not a bare directory: the source set then carries the generator as a task +// dependency, so processResources - and anything else consuming main resources - is wired +// automatically instead of relying on a dependsOn that a new consumer would silently miss +sourceSets.main.resources.srcDir(generateImageCoordinate) + +// -------------------------------------------------------- publishing the image with the release +// +// Publishing the runner image is part of the Nextflow release: release.sh step 1 runs +// `make release-agent-image`, which runs this task. This shells out and reimplements nothing - +// build-image.sh owns the buildx builder, the platform list, the if-not-exists gate and the +// post-push manifest verification, and remains the manual escape hatch. This task only puts it +// in the release's dependency graph. +// +// Deliberately NOT wired into assemble/check/build: an ordinary build must not touch docker. +// `Exec` fails the build on a non-zero exit, which is the whole point - a failed image build +// aborts release.sh at step 1, before anything at all has been published. +tasks.register('releaseImageIfNotExists', Exec) { + group = 'release' + description = 'Build and push the pi runner image, unless that tag is already published' + // The drift guard must run BEFORE the push, not merely somewhere in the release. + // + // It hangs off validatePluginVersions, which release.sh reaches only at steps 2 and 5 -- i.e. + // AFTER this step has already published. The uncovered case is not hypothetical: bump VERSION + // in commit A, change the build context in commit B, release. This step finds the tag absent, + // pushes B's content under the new tag and exits 0; step 2 then fails the guard and aborts the + // release. "A failure publishes nothing" still holds, but "nothing is published for a release + // that never happens" does not -- and the tag is the one artifact here that is immutable, so a + // remediation that reverts B leaves the registry holding the drifted build for good. + dependsOn rootProject.tasks.named('validateAgentImageVersion') + // generateImageCoordinate embeds DEFAULT_REGISTRY, which is what the jar asks for, while the + // script's REGISTRY honours NF_AGENT_PI_REGISTRY. Set during a release, the two would name + // different images and the jar would ask for one the release never published - the exact drift + // this whole arrangement exists to prevent, and nothing downstream would notice because the + // push and the jar are built by separate Gradle invocations. Refuse it rather than pick a side. + doFirst { + final override = System.getenv('NF_AGENT_PI_REGISTRY') + if( override ) + throw new GradleException("NF_AGENT_PI_REGISTRY=${override} is set, but the plugin jar embeds the registry from build-image.sh - a release would publish one image and ship a jar asking for another. Unset it, or push by hand with `build-image.sh push -r`.") + } + commandLine 'bash', imageScript.absolutePath, 'push' +} + +configurations { + runtimeClasspath.exclude group: 'org.slf4j', module: 'slf4j-api' +} + +dependencies { + compileOnly project(':nextflow') + compileOnly 'org.slf4j:slf4j-api:2.0.17' + compileOnly 'org.pf4j:pf4j:3.14.1' + + // Driver-side RPC broker for canonical agent tasks. Bundled with the runner + // plugin (not core) so a distribution without an agent runner ships no gRPC. + api 'io.grpc:grpc-api:1.75.0' + api 'io.grpc:grpc-stub:1.75.0' + api 'io.grpc:grpc-netty-shaded:1.75.0' + + // Self-signed certificate construction for the broker's per-run TLS identity: the JDK has no + // public API for it. Same pinned versions as nf-k8s -- PF4J isolates plugin classloaders, so + // that copy is not visible here; bump the two together. + api 'org.bouncycastle:bcprov-jdk18on:1.84' + api 'org.bouncycastle:bcpkix-jdk18on:1.84' + + testImplementation(testFixtures(project(':nextflow'))) + testImplementation 'org.apache.groovy:groovy:4.0.31' + testImplementation 'org.apache.groovy:groovy-json:4.0.31' +} + +// PiAgentPackagingTest asserts on the REAL artifacts - that the runtime is not vendored back into +// either of them - so build both before the tests run and tell them where they are. The +// distribution zip is what a user downloads, so it is ratcheted alongside the jar: a `from(...)` +// added to packagePlugin alone would otherwise slip past a jar-only assertion. +tasks.named('test') { + dependsOn tasks.named('jar'), tasks.named('packagePlugin') + systemProperty 'nf.agent.pi.jar', tasks.named('jar').get().archiveFile.get().asFile.absolutePath + systemProperty 'nf.agent.pi.zip', tasks.named('packagePlugin', Zip).get().archiveFile.get().asFile.absolutePath + // the harness spec runs `node` against a copy of harness/runner.mjs; it skips when node is absent + systemProperty 'nf.agent.pi.harness', layout.projectDirectory.file('harness/runner.mjs').asFile.absolutePath + systemProperty 'nf.agent.pi.dockerfile', layout.projectDirectory.file('Dockerfile').asFile.absolutePath + // PiAgentPackagingTest runs `build-image.sh ref` to recompute the coordinate independently of + // generateImageCoordinate above, so the two ways of composing it are compared, not assumed + systemProperty 'nf.agent.pi.buildscript', imageScript.absolutePath +} diff --git a/plugins/nf-agent-pi/changelog.txt b/plugins/nf-agent-pi/changelog.txt new file mode 100644 index 0000000000..a91a639e32 --- /dev/null +++ b/plugins/nf-agent-pi/changelog.txt @@ -0,0 +1,73 @@ +nf-agent-pi changelog +===================== +0.5.0 - 9 Aug 2026 +- Bound the wait for the driver connection with a new `--connect-timeout` proxy flag, 30s by + default, and fail the task naming the endpoint that was tried. The driver now INFERS its RPC + address from its own default route rather than requiring `agent.rpc.remoteHost`, and the + expensive way for inference to be wrong is to be plausible: a blackholed address left the + channel in CONNECTING with nothing to fail on, pinning the invocation until its capability + expired an hour later. A refused endpoint was already loud and still is. +- Report the endpoint in the "open driver stream" error, which previously named nothing. +- Digest-pin the Go builder stage of the image, as the Node runtime stage already was, so the + proxy is built by a toolchain this repo names rather than by whatever `golang:1.23-bookworm` + points at on the day of the build. +- Publishing the image is now part of the Nextflow release (`release.sh` step 1) instead of a + step someone remembers to run, the tag is guarded against silent drift from this VERSION, and + the plugin declares its own image so `agent.container` is optional for the `pi` runner. +- NOTE ON 0.4.1: it was published by hand BEFORE the connect-timeout work above landed, so the + published `0.4.1` tag does not carry it. That is the drift this release exists to end; treat + 0.5.0, not 0.4.1, as the first tag whose content the tree reproducibly describes. + +0.4.1 - 9 Aug 2026 +- Wait, up to 2s, for the driver to end the RPC stream before dropping the connection. CloseSend + half-closes only the proxy's direction, so tearing the TLS connection down under the driver's + trailer flush made a run that in fact succeeded log `SSLEngine closed already` and "Transport + failed". Only the tail of a successful invocation waits; the result is already on stdout. + +0.4.0 - 8 Aug 2026 +- BREAKING for the IMAGE, not for the plugin: the harness protocol goes to version 2. The driver + no longer sends a `filesystem` tool descriptor and instead names the runner's own tools in + `spec.nativeToolNames`, which the harness enables from the Pi SDK's builtins. Ignoring the new + field is not safe -- a 0.3.0 harness would find neither the native names nor the descriptor and + run an agent whose instruction names file and shell tools it was never given -- so the frame is + refused outright and the mismatch is one legible line instead of a confusing model error. + Rebuild or repin `agent.container` to a 0.4.0 image. +- Every descriptor the driver sends is now brokered back to it unconditionally: there is no + per-descriptor locality flag, which would have run a container-side tool in the driver JVM if + mis-set, and the harness no longer carries its own sandboxed filesystem tool. + +0.3.0 - 7 Aug 2026 +- Deliver the LLM provider credential to the agent task IN BAND, on the TLS-protected RPC start + frame, instead of requiring it to be forwarded into the container's environment. The driver + resolves it once (`agent.apiKey` -> `NXF_AGENT_API_KEY` -> `_API_KEY`, scoped to the + model's API provider) and the harness installs it as an in-memory runtime key for that process: + it enters neither the task environment nor pi's on-disk auth store. +- The credential is withheld, with one warning, when `agent.rpc.tls = false` -- the start frame is + then cleartext -- and when the driver resolved a provider key the endpoint gate refuses to send. + Both cases fall back to the runner's own resolution, so an `env`/`secret` deployment keeps working. +- Never send the `nxf-no-credential` placeholder over the link: a runtime key owns its provider in + pi, so a placeholder would shadow a credential the container was given out of band. +- BREAKING for the IMAGE, not for the plugin: in-band delivery needs a harness that reads + `start.apiKey`. A runner image built before 0.3.0 ignores it silently and the agent behaves as if + no credential had been sent. Rebuild or repin `agent.container` to a 0.3.0 image, or keep an + out-of-band channel (`agent.containerOptions = '-e OPENAI_API_KEY'`, the `env` scope, or the + `secret` directive). + +0.2.0 - 7 Aug 2026 +- Encrypt the agent RPC link with TLS and pin the driver certificate by SHA-256 fingerprint (#82) +- Fix an agent RPC capability expiring while its task was still queued, and report an expired or + already-consumed capability as such instead of as an invalid token (#82) +- Detect a vanished agent task with gRPC keepalive rather than waiting on the OS TCP timeout (#82) +- Refuse a connect frame carrying no invocation identity with a status, instead of aborting the call + with an unexplained `UNKNOWN` (#84) +- Remember enough terminal outcomes that a retry is still told why its capability was refused on runs + with more than a thousand agent tasks (#84) +- Report the driver's refusal reason when gRPC surfaces a terminated send as a bare `EOF` (#84) +- BREAKING: the driver now always passes `--fingerprint` or `--insecure` to `agent-rpc`, with no + negotiation. A runner image built before 0.2.0 rejects both flags and fails every agent task with + `flag provided but not defined: -fingerprint`; `agent.rpc.tls = false` is NOT a workaround, since + that path emits `--insecure`, which such an image also rejects. Rebuild or repin `agent.container` + to an image carrying a 0.2.0 proxy. + +0.1.0 - 24 Jul 2026 +- Initial release: the `pi` agent runner, shipped as a container image rather than a vendored runtime diff --git a/plugins/nf-agent-pi/harness/runner.mjs b/plugins/nf-agent-pi/harness/runner.mjs new file mode 100644 index 0000000000..3698209616 --- /dev/null +++ b/plugins/nf-agent-pi/harness/runner.mjs @@ -0,0 +1,449 @@ +#!/usr/bin/env node + +import process from "node:process"; +import { + createAgentSession, + DefaultResourceLoader, + defineTool, + ModelRuntime, + SessionManager, +} from "@earendil-works/pi-coding-agent"; + +// Bumped to 2 when `spec.nativeToolNames` was added. A new field is only backward-compatible +// when IGNORING it is safe, and this one is not: a v1 harness reads no native names and finds no +// `filesystem` descriptor either (the driver stopped sending one), so it would run an agent whose +// instruction names file and shell tools it was never given, and fail as a confusing model error +// rather than a version mismatch. Refusing the frame turns that into one legible line. +const PROTOCOL_VERSION = 2; +const pendingToolCalls = new Map(); +let activeInvocation; +let session; +let terminal = false; +let stdinBuffer = ""; + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +// stdout is the exclusive JSONL protocol channel: only send() may write to it. +// Redirect every diagnostic console channel to stderr so a stray log line from +// the Pi SDK or a transitive dependency cannot corrupt or inject a frame. +console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`); +console.info = console.log; +console.debug = console.log; +console.warn = console.log; +// console.error already targets stderr; leave it untouched. + +function fail(error, code = "runner_error") { + if (terminal) return; + terminal = true; + send({ + type: "error", + invocationId: activeInvocation, + code, + message: error instanceof Error ? error.message : String(error), + }); +} + +function composeSystemPrompt(spec) { + const parts = []; + if (spec.instruction) parts.push(spec.instruction); + if (spec.goal) parts.push(`Goal:\n${spec.goal}`); + if (spec.skills?.length) { + parts.push( + "Available skills:\n" + + spec.skills + .map((skill) => `- ${skill.name}: ${skill.description}`) + .join("\n") + + "\n\nUse activate_skill when a skill is relevant, then follow its instructions. " + + "Use read_skill_resource for any bundled resource named by the activated skill.", + ); + } + if (spec.outputSchema) { + parts.push( + "You MUST finish by calling final_answer exactly once with arguments matching its schema. " + + "Do not finish with ordinary assistant text.", + ); + } + return parts.join("\n\n"); +} + +function composeUserPrompt(spec) { + if (!spec.inputJson) return spec.prompt ?? ""; + return `${spec.prompt ?? ""}\n\nInput:\n${spec.inputJson}`; +} + +// The task work dir the driver assigned, or this process' own -- the resource loader, the session +// and its manager must all agree on it, so it is spelled once. +function workDirOf(spec) { + return spec.workDir || process.cwd(); +} + +function brokerTool(name, toolCallId, params, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error(`Tool ${name} was cancelled`)); + return; + } + const onAbort = () => { + pendingToolCalls.delete(toolCallId); + reject(new Error(`Tool ${name} was cancelled`)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + pendingToolCalls.set(toolCallId, { + resolve: (value) => { + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: (error) => { + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }); + send({ + type: "tool_call", + invocationId: activeInvocation, + callId: toolCallId, + name, + arguments: params, + }); + }); +} + +// The `maxIterations` budget, applied to every tool this harness defines EXCEPT `final_answer`: +// that one neither counts nor checks, so an agent that reaches the cap exactly can still return +// its answer rather than failing on the way out. +// +// Two details are load-bearing and invisible to the scripted stub: +// - the delegation forwards the FULL argument list, because the wrapped `execute`s do not share +// a signature -- a brokered tool takes `(toolCallId, params, signal)` and passes the abort +// signal on, while the skill tools take `(_toolCallId, params)`. Dropping `signal` would leave +// a cancelled invocation waiting out its RPC timeout instead of aborting; +// - the counter is incremented BEFORE the comparison, which is `>`. Checking first, or using +// `>=`, shortens every agent's tool budget by one call. +function cappedTool(spec, state, definition) { + return { + ...definition, + async execute(...args) { + state.toolTurns += 1; + if (state.toolTurns > spec.maxIterations) { + throw new Error( + `Agent exceeded the maximum number of tool-call iterations (${spec.maxIterations})`, + ); + } + return definition.execute(...args); + }, + }; +} + +// Every descriptor in `toolSpecs` is BROKERED, unconditionally: the driver owns the tools it +// describes, and the runner's own tools never arrive as descriptors -- they arrive as bare names +// in `spec.nativeToolNames` and are enabled from the SDK's builtins (see the session allowlist +// below). There is deliberately no per-descriptor locality flag: a mis-set one would execute a +// container-side tool in the driver JVM, and it would oblige this harness to reimplement what the +// SDK already ships. +function nextflowTools(spec, state) { + const result = []; + for (const descriptor of spec.toolSpecs ?? []) { + result.push( + defineTool( + cappedTool(spec, state, { + name: descriptor.name, + label: descriptor.name, + description: descriptor.description || `Run Nextflow tool ${descriptor.name}`, + promptSnippet: descriptor.description || `Run Nextflow tool ${descriptor.name}`, + parameters: descriptor.inputSchema, + executionMode: "sequential", + async execute(toolCallId, params, signal) { + const text = await brokerTool(descriptor.name, toolCallId, params, signal); + return { content: [{ type: "text", text }], details: {} }; + }, + }), + ), + ); + } + return result; +} + +function skillTools(spec, state) { + if (!spec.skills?.length) return []; + const byName = new Map(spec.skills.map((skill) => [skill.name, skill])); + return [ + defineTool( + cappedTool(spec, state, { + name: "activate_skill", + label: "Activate skill", + description: "Load the complete instructions for an available skill.", + promptSnippet: "Load the instructions for a relevant available skill", + parameters: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + }, + executionMode: "sequential", + async execute(_toolCallId, params) { + const skill = byName.get(params.name); + if (!skill) throw new Error(`Unknown skill: ${params.name}`); + state.activeSkills.add(skill.name); + return { content: [{ type: "text", text: skill.content }], details: {} }; + }, + }), + ), + defineTool( + cappedTool(spec, state, { + name: "read_skill_resource", + label: "Read skill resource", + description: "Read a bundled resource from an activated skill.", + promptSnippet: "Read a bundled resource from an activated skill", + parameters: { + type: "object", + properties: { + skill: { type: "string" }, + path: { type: "string" }, + }, + required: ["skill", "path"], + additionalProperties: false, + }, + executionMode: "sequential", + async execute(_toolCallId, params) { + if (!state.activeSkills.has(params.skill)) + throw new Error(`Skill is not active: ${params.skill}`); + const skill = byName.get(params.skill); + const resource = skill?.resources?.find((item) => item.relativePath === params.path); + if (!resource) throw new Error(`Unknown skill resource: ${params.skill}/${params.path}`); + return { content: [{ type: "text", text: resource.content }], details: {} }; + }, + }), + ), + ]; +} + +function structuredOutputTool(spec, state) { + if (!spec.outputSchema) return []; + return [ + defineTool({ + name: "final_answer", + label: "Final answer", + description: "Return the final structured answer and terminate the agent.", + promptSnippet: "Return the final structured answer and terminate", + promptGuidelines: ["Use final_answer exactly once as the last action for structured output."], + parameters: spec.outputSchema, + executionMode: "sequential", + async execute(_toolCallId, params) { + state.finalOutput = JSON.stringify(params); + return { + content: [{ type: "text", text: "Structured answer accepted" }], + details: {}, + terminate: true, + }; + }, + }), + ]; +} + +async function run(start) { + const spec = start.spec; + activeInvocation = start.invocationId; + if (start.protocolVersion !== PROTOCOL_VERSION) + throw new Error(`Unsupported protocol version: ${start.protocolVersion}`); + if (!spec?.model) throw new Error("Agent `model` directive is required"); + + const slash = spec.model.indexOf("/"); + if (slash <= 0 || slash === spec.model.length - 1) + throw new Error(`Invalid model identifier: ${spec.model}; expected provider/model`); + const provider = spec.model.slice(0, slash); + const modelId = spec.model.slice(slash + 1); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false }); + // A resolved endpoint RETARGETS the provider catalog: registerProvider with no `models` + // rewrites baseUrl on every built-in model of the provider, and is the only seam the SDK + // sanctions. The endpoint must NOT be spread onto the resolved model object instead: the + // catalog owns Model instances and re-resolves them by id (agent-session refreshes + // `state.model` from the registry, session restore does the same), so an ad-hoc field on a + // model is fragile. Registering an unknown provider id is harmless -- it composes an empty + // catalog, so the model lookup below still reports `Unknown Pi model`. + // The retargeted endpoint must speak the wire protocol of the provider being retargeted: the + // built-in `openai` provider is wired to the OpenAI RESPONSES api, so it posts /v1/responses. + // An OpenAI mirror or a Responses-capable gateway works; a chat/completions-only server + // (Ollama, llama.cpp, default vLLM) does not, and is out of scope here. + if (spec.baseUrl) modelRuntime.registerProvider(provider, { baseUrl: spec.baseUrl }); + const model = modelRuntime.getModel(provider, modelId); + // Only catalog model ids resolve: `baseUrl` retargets known ids at a compatible endpoint, + // it does not add models. Registering an arbitrary id needs a `models: [...]` entry that + // REPLACES the provider catalog and mandates per-model metadata, so it stays out of scope + // and this failure is the documented boundary. Checked BEFORE the credential is installed + // so an unknown id never surfaces as a confusing auth error. + if (!model) throw new Error(`Unknown Pi model: ${spec.model}`); + // The credential is delivered OUT OF BAND (beside `spec`, never inside it) and installed as + // a pi runtime API key: it is held in memory for this process only, so it neither enters + // this process' environment nor pi's on-disk auth store. + // A runtime key OWNS the provider -- runtime-credentials.js returns the override before the + // store, and auth/resolve.js consults the ambient environment only when nothing is stored -- + // so the driver sends a key here ONLY when it is a REAL credential it resolved in this + // provider's own namespace (`agent.apiKey`/`NXF_AGENT_API_KEY`, or `_API_KEY` for an + // endpoint that belongs to that provider). Never its no-credential placeholder: that one owns + // nothing, and installing it would shadow whatever the container was given out of band. When + // nothing arrives -- no credential resolved, `agent.rpc.tls = false`, or a key the driver's + // endpoint gate withheld (all three make the driver warn rather than fail, precisely because + // of what follows) -- pi's own resolution (its store, then provider variables such as + // ANTHROPIC_API_KEY delivered by the `env` scope or a Kubernetes Secret) is left untouched. + if (start.apiKey) await modelRuntime.setRuntimeApiKey(provider, start.apiKey); + + const state = { toolTurns: 0, activeSkills: new Set(), finalOutput: undefined }; + const customTools = [ + ...nextflowTools(spec, state), + ...skillTools(spec, state), + ...structuredOutputTool(spec, state), + ]; + const systemPrompt = composeSystemPrompt(spec); + const loader = new DefaultResourceLoader({ + cwd: workDirOf(spec), + agentDir: workDirOf(spec), + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPromptOverride: () => systemPrompt, + appendSystemPromptOverride: () => [], + }); + await loader.reload(); + + // The allowlist is the WHOLE tool gate, and it is exhaustive by construction: the tools this + // harness defines (brokered Nextflow tools, skills, final_answer) plus the SDK builtins the + // agent selected through the `fs:`/`shell:` families. Anything absent is disabled -- an + // allowlist is the only thing pi consults when one is given (`allowedToolNames = options.tools` + // in dist/core/sdk.js), which is also why `noTools` is gone: `noTools: "builtin"` was dead + // whenever `tools` was set, and it now reads as the opposite of what this line does, since + // enabling the resolved builtins is the entire point of the runner-native half of the split. + // The builtins are built for the session `cwd`, i.e. the task work dir passed below, and the + // container is their outer bound. + // NOTE a builtin is executed by the SDK, so it does NOT pass through `state.toolTurns`: the + // `maxIterations` budget bounds the tools defined here (brokered, skills, final_answer) and not + // a `read`/`grep` loop inside the container. Bounding those needs a counter driven off the + // session event stream, which is a separate change. + const created = await createAgentSession({ + cwd: workDirOf(spec), + modelRuntime, + model, + tools: [...customTools.map((tool) => tool.name), ...(spec.nativeToolNames ?? [])], + customTools, + resourceLoader: loader, + sessionManager: SessionManager.inMemory(workDirOf(spec)), + }); + session = created.session; + + let currentText = ""; + let lastAssistantText = ""; + // Last provider/transport failure seen on the event stream. The SDK reports a failed + // model call as an assistant message with `stopReason: "error"`, an `errorMessage`, and + // EMPTY content, then auto-retries. Without capturing it, an exhausted retry chain + // surfaces only as "no output", which misattributes a provider outage to the model + // declining to answer and hides the request id needed to report it. + let lastProviderError = ""; + session.subscribe((event) => { + if (event.message?.errorMessage) lastProviderError = event.message.errorMessage; + if (event.errorMessage) lastProviderError = event.errorMessage; + if (event.finalError) lastProviderError = String(event.finalError.message ?? event.finalError); + if (event.type === "message_start" && event.message?.role === "assistant") currentText = ""; + if (event.type === "message_update") { + const update = event.assistantMessageEvent; + if (update?.type === "text_delta") currentText += update.delta; + if (update?.type === "thinking_delta") + send({ type: "trace", invocationId: activeInvocation, event: "thinking", text: update.delta }); + } + if (event.type === "message_end" && event.message?.role === "assistant") + lastAssistantText = currentText; + if (event.type === "tool_execution_start") + send({ + type: "trace", + invocationId: activeInvocation, + event: "tool_start", + name: event.toolName, + callId: event.toolCallId, + }); + if (event.type === "tool_execution_end") + send({ + type: "trace", + invocationId: activeInvocation, + event: "tool_end", + name: event.toolName, + callId: event.toolCallId, + isError: event.isError, + }); + }); + + try { + await session.prompt(composeUserPrompt(spec)); + if (spec.outputSchema && state.finalOutput == null) { + await session.prompt( + "Your previous response did not satisfy the required machine-readable output contract. " + + "Call final_answer now with the complete answer. Do not respond with ordinary text.", + ); + } + const output = spec.outputSchema ? state.finalOutput : lastAssistantText; + if (output == null || output.trim() === "") { + const reason = spec.outputSchema ? "Pi did not call final_answer" : "Pi returned no final assistant text"; + throw new Error(lastProviderError ? `${reason}; last provider error: ${lastProviderError}` : reason); + } + terminal = true; + send({ + type: "complete", + invocationId: activeInvocation, + output, + resolvedModel: `${model.provider}/${model.id}`, + }); + } finally { + session.dispose(); + session = undefined; + } +} + +async function handle(message) { + if (message.type === "start") { + if (activeInvocation) throw new Error("Runner accepts exactly one invocation"); + await run(message); + return; + } + if (message.type === "tool_result") { + if (message.invocationId !== activeInvocation) throw new Error("Mismatched invocationId"); + const pending = pendingToolCalls.get(message.callId); + if (!pending) throw new Error(`Unknown tool call result: ${message.callId}`); + pendingToolCalls.delete(message.callId); + if (message.isError) pending.reject(new Error(message.result)); + else pending.resolve(message.result); + return; + } + if (message.type === "cancel") { + session?.abort(); + throw new Error(message.reason || "Agent invocation cancelled"); + } + throw new Error(`Unknown host message type: ${message.type}`); +} + +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + stdinBuffer += chunk; + for (;;) { + const newline = stdinBuffer.indexOf("\n"); + if (newline < 0) break; + const line = stdinBuffer.slice(0, newline).replace(/\r$/, ""); + stdinBuffer = stdinBuffer.slice(newline + 1); + if (!line) continue; + let message; + try { + message = JSON.parse(line); + } catch (error) { + fail(error, "invalid_json"); + continue; + } + handle(message).catch((error) => fail(error)); + } +}); +process.stdin.on("end", () => { + if (!terminal) fail(new Error("Host closed protocol input"), "unexpected_eof"); +}); +process.on("uncaughtException", (error) => fail(error)); +process.on("unhandledRejection", (error) => fail(error)); + +send({ type: "ready", protocolVersion: PROTOCOL_VERSION }); diff --git a/plugins/nf-agent-pi/package-lock.json b/plugins/nf-agent-pi/package-lock.json new file mode 100644 index 0000000000..2452be3c45 --- /dev/null +++ b/plugins/nf-agent-pi/package-lock.json @@ -0,0 +1,1851 @@ +{ + "name": "@nextflow/nf-agent-pi-runtime", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nextflow/nf-agent-pi-runtime", + "version": "0.1.0", + "dependencies": { + "@earendil-works/pi-coding-agent": "0.80.10" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10.tgz", + "integrity": "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.80.10", + "@earendil-works/pi-ai": "^0.80.10", + "@earendil-works/pi-tui": "^0.80.10", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.80.10", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "./dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.80.10", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/plugins/nf-agent-pi/package.json b/plugins/nf-agent-pi/package.json new file mode 100644 index 0000000000..bc33543185 --- /dev/null +++ b/plugins/nf-agent-pi/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nextflow/nf-agent-pi-runtime", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "dependencies": { + "@earendil-works/pi-coding-agent": "0.80.10" + } +} diff --git a/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentRpcBroker.groovy b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentRpcBroker.groovy new file mode 100644 index 0000000000..6bb4211464 --- /dev/null +++ b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentRpcBroker.groovy @@ -0,0 +1,859 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j +import io.grpc.BindableService +import io.grpc.MethodDescriptor +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.ServerServiceDefinition +import io.grpc.Status +import io.grpc.stub.ServerCalls +import io.grpc.stub.StreamObserver +import nextflow.Global +import nextflow.Session +import nextflow.agent.AgentProtocolSpec +import nextflow.agent.rpc.AgentRpcConfig +import nextflow.agent.rpc.AgentRpcHost +import nextflow.agent.rpc.AgentRpcRegistration +import nextflow.agent.AgentRunnerRequest + +/** + * Embedded, driver-side broker for canonical agent tasks. + * + * The POC deliberately uses a JSON gRPC marshaller so the semantic JSONL + * protocol and the Go proxy can evolve before committing to generated message + * classes. The service is still a real bidirectional gRPC stream. + * + * The broker and its gRPC dependencies live in this plugin rather than in core so + * that a distribution with no agent runner installed carries no RPC stack. Nothing + * here is Pi-specific: it only reads runner-neutral {@link AgentRunnerRequest} + * fields, and it is hosted here because {@code nf-agent-pi} is currently the only + * runner with a launch spec, and it already builds the matching Go proxy. Extract + * it to a shared {@code nf-agent-rpc} plugin when a second canonical runner needs + * it, rather than introducing plugin-to-plugin coupling now. + */ +@Slf4j +@CompileStatic +class AgentRpcBroker { + + static final String SERVICE_NAME = 'nextflow.agent.AgentBroker' + static final String METHOD_NAME = 'Connect' + + private static final MethodDescriptor.Marshaller STRING_MARSHALLER = new MethodDescriptor.Marshaller() { + @Override InputStream stream(String value) { + return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)) + } + + @Override String parse(InputStream stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8) + } + } + + private static final MethodDescriptor CONNECT_METHOD = MethodDescriptor + .newBuilder() + .setType(MethodDescriptor.MethodType.BIDI_STREAMING) + .setFullMethodName(MethodDescriptor.generateFullMethodName(SERVICE_NAME, METHOD_NAME)) + .setRequestMarshaller(STRING_MARSHALLER) + .setResponseMarshaller(STRING_MARSHALLER) + .build() + + /** + * The endpoint advertised to a non-remote agent task, i.e. a plain child process in the driver's + * own network namespace, for which loopback is always correct. Core no longer registers such a + * task -- a canonical agent task is always containerized -- but the runner SPI still allows it. + * This is only the advertised host, never the bind address: the server binds every interface. + */ + private static final String LOCAL_HOST = '127.0.0.1' + + /** + * How often the server pings an otherwise silent connection, and how long the peer then has to + * answer. Removing the post-connect deadline is right -- an agent may legitimately run for hours + * -- but on its own it leaves NOTHING watching a stream whose peer is simply gone: an OOM-killed + * pod or a reclaimed spot instance disappears without ever sending a FIN, so the {@link + * Invocation} and its {@link ResponseSink} would be held until the operating system's TCP + * keepalive noticed, which on Linux is {@code tcp_keepalive_time} = 2h. grpc-java's own server + * default is the same 2h, so bounding this needs an explicit setting. + * + *

60s + 20s detects a vanished node inside ~80s at the cost of a few bytes a minute. The ping + * is a transport frame answered by the peer's networking loop, not by the application, so it does + * NOT mistake "the model has been thinking for ten minutes" for a dead peer -- and the server + * pings even while the stream is idle, because {@code NettyServerHandler} builds its + * {@code KeepAliveManager} with {@code keepAliveDuringTransportIdle = true}. A silent-but-open + * agent stream is exactly the case this has to cover. + */ + private static final long KEEPALIVE_TIME_SECONDS = 60 + private static final long KEEPALIVE_TIMEOUT_SECONDS = 20 + + /** + * grpc-go clamps {@code keepalive.ClientParameters.Time} up to {@code KeepaliveMinPingTime} = + * 10s, so no build of the proxy can ping the driver more often than this however its interval is + * configured. The server's enforcement floor is derived from that CLIENT-LIBRARY floor rather + * than from whatever number {@code agent-rpc} currently passes: a server that permits pings more + * rarely than the client sends them answers with GOAWAY and kills a perfectly healthy stream, and + * pinning the floor to the library's own minimum makes the two sides impossible to get out of + * step when either is edited later. + */ + private static final long PERMIT_KEEPALIVE_TIME_SECONDS = 10 + + /** + * How many terminal outcomes are remembered so an arriving {@code connect} can be told what + * happened to a capability that is no longer pending, and how many expiries are reported at WARN + * before the rest fall to DEBUG. Both exist to stop a diagnosis gap without opening a second + * retention hole, so both are capped; the aggregate logged by {@link #reportRetention} carries + * the true totals regardless of either cap. + * + *

The record has to be sized against the RUN, not against a guess at how many rejections are + * interesting, because the row a retry needs is the one written when its FIRST attempt connected + * -- arbitrarily far back. A 1024-row budget got this exactly wrong: it is shared between + * {@code CONSUMED} and {@code EXPIRED}, so a wide fan-out or a cache-hit resume that lapses + * thousands of capabilities evicts the oldest rows first, which are precisely the connected + * tasks a node termination is most likely to retry. The retry then fell back to + * {@code Invalid agent RPC invocation identity or token} -- the security-shaped mislabel the + * record was added to remove, reappearing at the run sizes where retries are most likely. + * + *

One row is an id string and an enum reference, ~150 bytes, so this ceiling is ~15 MB and is + * reached only by a run with more than 100k agent tasks. That is a fraction of what the same run + * spends on the pending capabilities themselves, each of which pins its whole request. + */ + private static final int OUTCOME_HISTORY = 100_000 + private static final int LAPSE_WARN_LIMIT = 10 + + private static AgentRpcBroker singleton + + private final Map invocations = new ConcurrentHashMap<>() + private final Map outcomes = Collections.synchronizedMap(new OutcomeHistory()) + private final AtomicInteger lapsedCount = new AtomicInteger() + private final AtomicBoolean closed = new AtomicBoolean() + /** Whether the "credential withheld, TLS is off" warning has already been emitted for this run. */ + private final AtomicBoolean insecureCredentialWarned = new AtomicBoolean() + /** + * The advertised addresses already warned about. The warnings are a property of the ADDRESS, so + * emitting them per registration would repeat the same line once per agent task -- but a run + * with several agent definitions can legitimately advertise several addresses (see + * {@link AgentRunnerRequest#brokerHost}), so the gate is per address rather than per run. + */ + private final Set hostWarned = ConcurrentHashMap.newKeySet() + /** Model ids whose "credential withheld by the endpoint gate" warning has already been emitted. */ + private final Set withheldCredentialWarned = ConcurrentHashMap.newKeySet() + private final ExecutorService dispatchPool = Executors.newCachedThreadPool(daemonFactory('nf-agent-rpc-dispatch')) + private final ScheduledThreadPoolExecutor expiryPool = expiryPool() + private final SecureRandom random = new SecureRandom() + private final Server server + /** + * The address advertised to a remote agent task whose request carries NONE of its own -- a + * runner that registers without the pre-ignition guard, or a spec. It can be an error row, whose + * message {@link #register} raises verbatim. @see AgentRunnerRequest#brokerHost + */ + private final AgentRpcHost fallbackHost + private final long capabilitySeconds + /** SHA-256 of the served certificate, lowercase hex; {@code null} when TLS is disabled. */ + private final String fingerprint + /** Whether the broker deliberately serves cleartext, i.e. {@code agent.rpc.tls = false}. */ + private final boolean insecureTransport + /** The served certificate, for a test client that has to trust it; public material. */ + private final String certificatePem + + private static ThreadFactory daemonFactory(String name) { + return { Runnable task -> + final thread = new Thread(task, name) + thread.daemon = true + return thread + } as ThreadFactory + } + + /** + * Built directly rather than via {@code Executors.newSingleThreadScheduledExecutor}, whose + * wrapper hides {@code setRemoveOnCancelPolicy}. The default policy is {@code false}, so a + * cancelled expiry stays in the delay queue until its original deadline -- and the queued + * {@link Runnable} pins the whole {@link Invocation}: the prompt, the serialized inputs, the + * tool specs and the dispatcher closure. With the pre-connect budget now an hour, every + * successfully consumed invocation would hold that for an hour after it finished. Removing on + * cancel is what makes consumption actually release the capability. + */ + private static ScheduledThreadPoolExecutor expiryPool() { + final pool = new ScheduledThreadPoolExecutor(1, daemonFactory('nf-agent-rpc-expiry')) + pool.setRemoveOnCancelPolicy(true) + return pool + } + + /** + * How a capability left the pending map. A {@code connect} that arrives once the entry is gone + * finds only {@code null} and cannot otherwise tell an hour-old queueing delay from a forged + * identity, so both answers used to be the same security-shaped + * {@code Invalid agent RPC invocation identity or token} -- the exact message this PR set out to + * stop emitting for a non-security cause. + */ + private static enum Outcome { CONSUMED, EXPIRED } + + /** + * Terminal outcomes, newest last, capped at {@link #OUTCOME_HISTORY}. Evicting the oldest row is + * safe here in the way that evicting the oldest PENDING capability would not be: losing a row + * only downgrades a diagnostic message back to the generic rejection, where evicting a pending + * capability would invalidate a task that is about to start. + * + *

Keying it by invocation id makes it a status oracle for anyone who knows an id -- and the id + * is NOT secret: it is on the task's argv as {@code --invocation}, hence in {@code .command.run}, + * in the trace, and in {@code ps} on the node. Knowing "expired" versus "consumed" without the + * token buys such a reader nothing, while telling the operator which one it was is the whole + * point of keeping the record. + */ + private static class OutcomeHistory extends LinkedHashMap { + @Override protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > OUTCOME_HISTORY + } + } + + private static class Invocation { + String id + String token + AgentRunnerRequest request + Set callIds = ConcurrentHashMap.newKeySet() + AtomicInteger callCount = new AtomicInteger() + Set allowedTools = Collections.emptySet() + /** + * The pending pre-connect expiry task, cancelled once the capability is consumed so a + * connected stream leaves nothing scheduled. Written by {@code register} on the task-body + * thread and read by the gRPC thread that accepts the {@code connect} frame. + */ + volatile ScheduledFuture expiry + } + + /** + * Serializes writes to a single response stream and guards against writing + * after the stream has terminated. gRPC {@link StreamObserver}s are not + * thread-safe and reject {@code onNext} after a terminal event, so pool + * threads must funnel every send through here. + */ + private static class ResponseSink { + private final StreamObserver responses + private boolean closed + + ResponseSink(StreamObserver responses) { + this.responses = responses + } + + void send(Map message) { + synchronized(responses) { + if( closed ) + return + responses.onNext(JsonOutput.toJson(message)) + } + } + + void complete() { + synchronized(responses) { + if( closed ) + return + closed = true + responses.onCompleted() + } + } + + void markClosed() { + synchronized(responses) { + closed = true + } + } + + /** + * Fails the call with an explicit gRPC status, which is the ONLY way a reason reaches the + * task. Throwing out of {@code onNext} cannot carry one: grpc-java's + * {@code ServerImpl$JumpToApplicationThreadServerStreamListener.internalClose} closes with + * {@code Status.UNKNOWN.withDescription("Application error processing RPC").withCause(t)}, + * and the cause is never serialized -- so every message this replaces reached the proxy as a + * bare {@code UNKNOWN} and nothing else. + */ + void fail(Status status) { + synchronized(responses) { + if( closed ) + return + closed = true + responses.onError(status.asRuntimeException()) + } + } + } + + private AgentRpcBroker(AgentRpcConfig config, Session session) { + final requestedPort = config.port + // The address a registration actually advertises rides ON THE REQUEST: AgentDef's + // pre-ignition guard resolves it per agent definition with the full context -- the executor + // instance, the engine config, the task's container options -- none of which is recoverable + // here, and a run may hold several (a local-docker agent and a k8s agent resolve different + // addresses, and each task must be told its own). What is resolved here is only the FALLBACK + // for a registration that carries none, and it is deliberately weaker: session-level facts + // answer `docker` even for a run whose agent task is a Kubernetes pod. + this.fallbackHost = config.resolveBrokerHost(session) + // Explicitly null-checked, not Elvis: nextflow.util.Duration is falsy at zero, so `?:` would + // silently widen `agent.rpc.capabilityTimeout = '0s'` -- the tightest window an operator can + // ask for -- into the one-hour default, the wrong direction for a security-relevant knob. + this.capabilitySeconds = (config.capabilityTimeout != null ? config.capabilityTimeout : AgentRpcConfig.DEFAULT_CAPABILITY_TIMEOUT).seconds + this.insecureTransport = !config.tlsEnabled() + final ServerBuilder builder = ServerBuilder.forPort(requestedPort) + builder.addService(service()) + applyKeepAlive(builder) + if( config.tlsEnabled() ) { + // A per-run, self-signed identity the task pins by fingerprint: it closes the payload + // exposure (the start frame carries the prompt and inputJson, then every tool argument + // and result crosses the same link) and it authenticates the driver, so a process that + // occupies the advertised endpoint cannot serve a forged start frame. Both PEMs are fed + // in as in-memory streams -- no key material reaches disk. + final credentials = AgentRpcTlsCredentials.create() + builder.useTransportSecurity(credentials.certificateStream(), credentials.privateKeyStream()) + this.certificatePem = credentials.certificatePem + this.fingerprint = credentials.fingerprint + } + else { + this.certificatePem = null + this.fingerprint = null + log.warn "Agent RPC transport security is disabled (agent.rpc.tls=false) -- the agent prompt, inputs, tool arguments and results cross the network in cleartext, and the driver is not authenticated to the task" + } + this.server = builder.build().start() + session?.onShutdown { close() } + // The fingerprint is a public commitment, not a secret, and logging it is what lets an + // operator compare it against the two digests the proxy prints on a pinning failure. + log.debug "Agent RPC broker listening on port ${server.port}${fingerprint ? " tls-fingerprint=${fingerprint}" : ' (cleartext)'}" + } + + /** + * The only liveness bound on a CONNECTED stream. See {@link #KEEPALIVE_TIME_SECONDS} for why a + * connected stream cannot be put back on a deadline and what the keepalive replaces. + * + *

{@code permitKeepAliveWithoutCalls} is deliberately LEFT at its default of {@code false}. + * It would only matter in the window before the proxy's first stream opens, and that window is + * sub-millisecond -- grpc-go dials lazily on the first RPC, and {@code KeepAliveEnforcer + * .resetCounters()} runs on stream creation, so at most a strike or two can accrue against + * {@code MAX_PING_STRIKES = 2} and they are then reset. Turning it on, by contrast, is precisely + * what would make an unauthenticated peer unevictable: the server asks for no client certificate + * and binds every interface (see {@link #LOCAL_HOST}), so a process that completes the TLS + * handshake and never opens a stream would be free to hold the socket forever. + * + *

Extracted rather than inlined into the constructor so a spec can assert the four-way choice + * -- three calls made, one deliberately not made -- against a mock builder. There is no way to + * read these back off a built {@link Server}, so without the seam a dropped call in a later edit + * would be invisible to the suite. + */ + @PackageScope + static void applyKeepAlive(ServerBuilder builder) { + // written as separate statements, not a chain: the receiver is wildcard-typed, and Groovy's + // static checker handles a chain off `ServerBuilder` inconsistently + builder.keepAliveTime(KEEPALIVE_TIME_SECONDS, TimeUnit.SECONDS) + builder.keepAliveTimeout(KEEPALIVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + builder.permitKeepAliveTime(PERMIT_KEEPALIVE_TIME_SECONDS, TimeUnit.SECONDS) + } + + private static AgentRpcConfig configFor(Session session) { + final nav = session?.config?.navigate('agent.rpc') + final Map opts = nav instanceof Map ? (Map)nav : Collections.emptyMap() + return new AgentRpcConfig(opts) + } + + static synchronized AgentRpcBroker get() { + if( singleton == null ) { + final session = Global.session as Session + singleton = new AgentRpcBroker(configFor(session), session) + } + return singleton + } + + @PackageScope + void close() { + // Idempotent: `session.onShutdown` and an explicit close (the test harness rebuilds the + // singleton, and an aborted run can reach both paths) would otherwise report the retention + // aggregate twice for the same broker. + if( !closed.compareAndSet(false, true) ) + return + // Phased shutdown: stop accepting new work and let in-flight dispatch drain + // before the server is torn down, so a finishing dispatch does not throw in + // send(). Fall back to a forced shutdown if graceful draining does not complete. + dispatchPool.shutdown() + server.shutdown() + try { + if( !dispatchPool.awaitTermination(5, TimeUnit.SECONDS) ) + dispatchPool.shutdownNow() + if( !server.awaitTermination(5, TimeUnit.SECONDS) ) + server.shutdownNow() + } + catch( InterruptedException e ) { + dispatchPool.shutdownNow() + server.shutdownNow() + Thread.currentThread().interrupt() + } + // before the clear, so the report can count what was still pending + expiryPool.shutdownNow() + reportRetention() + invocations.clear() + outcomes.clear() + synchronized(AgentRpcBroker) { + if( singleton != null && singleton.is(this) ) + singleton = null + } + } + + /** + * Reports, once per run, the capabilities that were minted and never used. + * + *

This is the only place the shape it exists for is visible. {@code register()} runs while the + * task SCRIPT is generated -- {@code TaskProcessor} calls {@code task.resolve(taskBody)} BEFORE + * it consults {@code storeDir} and before the resume cache -- so on {@code nextflow run -resume} + * every agent task that turns out to be a cache HIT still mints a capability that nothing ever + * connects with and nothing ever cancels. Counting FIRED deadlines cannot surface that: such a + * run is over in seconds and the default budget is an hour, so zero deadlines fire and the + * operator gets silence. What is still PENDING at shutdown is the number that names it. + * + *

Note what a pending capability holds. The {@link Invocation} keeps the whole + * {@code AgentRunnerRequest}, including {@code dispatch} -- a {@code ConvertedClosure} over the + * task-body closure whose {@code delegate} is {@code TaskRun.context} -- so it transitively pins + * every resolved INPUT VALUE of its task. A wide agent fan-out resumed from cache holds all of + * it, and widening the pre-connect budget from 180s to an hour widened that window twentyfold. + */ + private void reportRetention() { + final pending = invocations.size() + final lapsed = lapsedCount.get() + if( !pending && !lapsed ) + return + final List parts = [] + if( pending ) + parts << "${capabilityCount(pending)} registered but never consumed".toString() + if( lapsed ) + parts << "${capabilityCount(lapsed)} that lapsed on the ${capabilitySeconds}s `agent.rpc.capabilityTimeout`".toString() + log.warn "Agent RPC broker shut down with ${parts.join(' and ')} - a capability is minted while the task script is generated, which happens before `storeDir` and the resume cache are consulted, so an agent task that turns out to be a cache hit leaves one behind holding its request, hence that task's resolved inputs, until the run ends or the budget elapses" + } + + private static String capabilityCount(int count) { + return count == 1 ? '1 agent capability' : "${count} agent capabilities".toString() + } + + AgentRpcRegistration register(AgentRunnerRequest request, boolean remote) { + // the request's own address first: it is the one the guard resolved FOR THIS AGENT, with + // context the fallback cannot see + final advertised = remote + ? (request.brokerHost ?: fallbackHost) + : AgentRpcHost.of(LOCAL_HOST, 'in-process runner') + final host = advertised?.host + if( !host ) + // the ladder's own message names the row that refused, what was tried and what to set; + // replacing it with a generic line here would discard exactly that + throw new IllegalStateException(advertised?.error + ?: "No address is configured for the driver's agent RPC broker - set `agent.rpc.remoteHost` to a host the agent task can reach the driver on") + final id = UUID.randomUUID().toString() + final bytes = new byte[32] + random.nextBytes(bytes) + final token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + // The BROKERED names only. A runner-native tool (`request.nativeToolNames`, the pi SDK + // builtins the harness adds to the session allowlist) is executed inside the runner + // container and has no dispatcher on this side, so admitting one here would let the model + // relocate, say, `bash` from the container into the driver JVM by simply calling it over + // the RPC stream. brokeredToolNames() is what refuses to build such an allowlist. + final Set allowedTools = request.brokeredToolNames() + final invocation = new Invocation(id: id, token: token, request: request, allowedTools: allowedTools) + invocations.put(id, invocation) + // A capability's lifetime is the PRE-CONNECT wait, and nothing else. register() runs while + // the task SCRIPT is generated -- TaskProcessor resolves the body long before it submits the + // job -- so this clock has to absorb the executor's queueing latency. Deriving it from the + // per-request LLM timeout (max(requestTimeout,30)+60, i.e. 180s by default) made any queueing + // executor fail with a security-shaped `Invalid agent RPC invocation identity or token`, so + // it is now its own generous, configurable budget. Once the connect frame is accepted the + // capability is consumed and this task is cancelled; the live stream is not on a timer. + // + // The deadline is also the ONLY thing that ever releases a capability nobody connects with, + // and register() runs on paths where no job is ever submitted -- a resume cache hit, a + // `storeDir` hit -- so those hold their request for the full budget. Three narrower bounds + // were considered and each is worse than the budget it would replace: + // + // - Revoking on "this task will never run". There is nothing to revoke BY: an + // AgentRunnerRequest carries no task identity of any kind (its workDir is a literal '.'), + // so a cached TaskRun cannot be mapped back to an invocation id. Building that mapping + // means having this plugin observe TaskRun lifecycle across a module boundary -- through + // a TraceObserverFactory this plugin does not have -- which is the coupling this design + // rejects, and it is only ever an approximation of the terminal state. + // - Capping the map. Evicting the OLDEST pending capability is actively harmful: under a + // FIFO scheduler the oldest queued tasks are the ones about to start, so the cap would + // kill exactly the tasks it must not. Blocking register() at the cap instead deadlocks on + // the very shape this is about -- on an all-cached resume nothing ever connects, so the + // cap never drains. There is no safe overflow policy. + // - Holding the request weakly until connect. The start frame is assembled FROM the request, + // and nothing can reconstitute it, so a cleared reference is an unrecoverable failure at + // exactly the moment the task finally starts. + // + // So the retention stands, bounded by min(capabilityTimeout, run lifetime) -- close() clears + // the map -- and reportRetention() makes it countable instead of silent. + invocation.expiry = expiryPool.schedule({ lapse(id, invocation) } as Runnable, capabilitySeconds, TimeUnit.SECONDS) + // The consumer can only cancel a deadline it can see, and it may have connected -- or the + // deadline may already have fired, at a capabilityTimeout of zero -- while this one was + // being armed. Absence from the map is exactly "no longer pending", so re-check and cancel + // here rather than leave an orphan holding the request for the full budget. + if( !invocations.containsKey(id) ) + invocation.expiry.cancel(false) + // the SOURCE is not decoration: an inferred address is indistinguishable from a configured + // one once it is on the wire, and the expensive failure of the whole ladder is a + // plausible-but-unroutable one, so name the row that answered -- and shout the rows that + // resolved something they are not certain about (a multi-homed driver, a containerized + // driver whose task may land on another docker network) + final endpoint = endpoint(host, server.port) + log.debug "Registering agent RPC invocation ${id} remote=${remote} advertising ${endpoint} (${advertised.source})" + if( advertised.warnings && hostWarned.add(host) ) + for( final warning : advertised.warnings ) + log.warn "Agent RPC broker is advertising ${endpoint} - ${warning}" + return new AgentRpcRegistration(id, token, endpoint, fingerprint, insecureTransport) + } + + /** + * {@code host:port}, with an IPv6 literal BRACKETED. The endpoint travels on argv as + * {@code --endpoint} and is parsed by Go's {@code grpc.NewClient}, and neither reads a bare + * {@code 2001:db8::1:8080} as an address and a port. A resolved host can be IPv6 whenever the + * driver's only routable interface address is (an IPv6-only fabric). + */ + protected static String endpoint(String host, int port) { + return host.contains(':') ? "[${host}]:${port}".toString() : "${host}:${port}".toString() + } + + /** + * Releases a capability nobody ever connected with, and says so. + * + *

The previous body was {@code { invocations.remove(id, invocation) }} with no logging at all, + * which is why widening the budget cut the FREQUENCY of the misleading rejection twentyfold and + * left its diagnosability at zero: an operator whose queue outran the budget still got + * {@code Invalid agent RPC invocation identity or token} and not one line of evidence that a + * deadline had fired. Logging only when the {@code remove} actually SUCCEEDS keeps this quiet for + * a capability that was consumed a microsecond earlier and lost the race. + */ + private void lapse(String id, Invocation invocation) { + if( !invocations.remove(id, invocation) ) + return + outcomes.put(id, Outcome.EXPIRED) + final message = "Agent RPC capability ${id} expired after ${capabilitySeconds}s without a connection - the agent task never dialled back within `agent.rpc.capabilityTimeout`" + // A wide fan-out lapses en masse (a tightened budget, or a run that outlives its queue), and + // one line per task would bury the run's real errors. The first few carry the diagnosis; the + // aggregate in reportRetention() carries the true total whatever this cap swallows. + if( lapsedCount.incrementAndGet() <= LAPSE_WARN_LIMIT ) + log.warn(message) + else + log.debug(message) + } + + /** + * The PEM of the certificate this broker serves, or {@code null} when TLS is disabled. Exposed + * for a test client that has to trust an identity no CA vouches for; a certificate is public + * material, and the private key is never exposed. + */ + @PackageScope + String getCertificatePem() { certificatePem } + + private BindableService service() { + return new BindableService() { + @Override ServerServiceDefinition bindService() { + return ServerServiceDefinition.builder(SERVICE_NAME) + .addMethod(CONNECT_METHOD, ServerCalls.asyncBidiStreamingCall({ StreamObserver responses -> + connect(responses) + } as ServerCalls.BidiStreamingMethod)) + .build() + } + } + } + + private StreamObserver connect(StreamObserver responses) { + final ResponseSink sink = new ResponseSink(responses) + return new StreamObserver() { + Invocation invocation + /** Set once the call has been failed with a status, so frames already on the wire when + * that happened cannot re-enter the handshake behind it. */ + boolean rejected + + @Override void onNext(String frame) { + if( rejected ) + return + final Object parsed = new JsonSlurper().parseText(frame) + if( !(parsed instanceof Map) ) + throw new IllegalArgumentException('Agent RPC frame must be a JSON object') + final Map msg = (Map)parsed + if( invocation == null ) { + if( msg.type != 'connect' ) + throw new IllegalArgumentException('First agent RPC frame must be connect') + final String id = msg.invocationId?.toString() + if( !id ) { + // A ConcurrentHashMap forbids a null key, so a connect frame with no identity + // used to throw out of onNext and close the call as `UNKNOWN: Application + // error processing RPC` -- the opaque answer this handshake exists to stop + // giving, reachable before authentication by anyone who can reach the port. + rejected = true + reject(sink, id, null) + return + } + final candidate = invocations.get(id) + if( candidate == null || candidate.token != msg.token?.toString() ) { + // A wrong token against a LIVE capability discloses nothing beyond the + // generic answer, so only the "no longer pending" case consults the record. + rejected = true + reject(sink, id, candidate == null ? outcomes.get(id) : null) + return + } + // Consume the capability atomically. A leaked command-line token cannot + // reconnect or race a second stream after the legitimate proxy connects. Losing + // this CAS means the expiry runnable -- or a second connect -- got there in the + // nanoseconds since the get() above, and whichever won recorded WHAT it did, so + // report that instead of guessing (this used to report a lapsed capability as a + // replay). + if( !invocations.remove(candidate.id, candidate) ) { + rejected = true + reject(sink, id, outcomes.get(id)) + return + } + outcomes.put(id, Outcome.CONSUMED) + invocation = candidate + candidate.expiry?.cancel(false) + sink.send(startFrame(candidate)) + return + } + if( msg.invocationId != invocation.id ) { + log.warn "Rejected agent RPC frame with mismatched invocation identity (expected=${invocation.id})" + rejected = true + // same reason as the connect rejections: a thrown exception reaches the task as a + // bare UNKNOWN, so the status has to be sent explicitly + sink.fail(Status.PERMISSION_DENIED.withDescription('Mismatched agent RPC invocation identity')) + return + } + switch( msg.type ) { + case 'trace': + trace(invocation.request, msg) + break + case 'tool_call': + dispatchTool(invocation, msg, sink) + break + case 'complete': + break + case 'error': + final detail = AgentSecretMasker.redact((msg.message ?: msg.code)?.toString(), invocation.request.apiKey) + log.warn "Agent `${invocation.request.agentName}` failed remotely: ${detail}" + break + default: + throw new IllegalArgumentException("Unknown agent RPC frame type: ${msg.type}") + } + } + + @Override void onError(Throwable error) { + sink.markClosed() + if( invocation != null ) + log.debug("Agent RPC stream failed for ${invocation.id}", error) + } + + @Override void onCompleted() { + sink.complete() + } + } + } + + /** + * The {@code start} frame: the portable {@link AgentProtocolSpec}, and BESIDE it the provider + * credential core resolved for this request. Beside and never inside -- the spec is the payload + * a transport may relay, persist or log, and it stays credential-free by construction. + * + *

The frame carries {@code request.apiKey} and NEVER {@code request.credential()}. The + * placeholder that method substitutes when an endpoint is declared but nothing resolved is a + * driver-side artifact of a client that refuses to issue a request without a key; a runtime key + * OWNS its provider in pi ({@code setRuntimeApiKey} is consulted before pi's own store and + * before the ambient environment), so sending {@code nxf-no-credential} here would shadow the + * very credential an {@code env}/{@code secret} channel had delivered to the container and turn + * a working run into a 401. + * + *

Sending the real one is what naming the provider makes safe. The broker used to withhold it + * because a runtime key owns its provider and the driver could not establish that the key + * belonged to THIS provider; {@link nextflow.agent.AgentConfig#apiKeyFor} now answers that -- it + * resolves in the model's own provider namespace and refuses to hand over an ambient provider + * variable when the endpoint belongs to somebody else -- so what arrives here is already scoped + * to the provider the task will call. + * + *

Gated on transport security. Under {@code agent.rpc.tls = false} the frame is cleartext and + * the driver is not authenticated to the task, so the credential is withheld and the run is told + * how to deliver it out of band: the escape hatch keeps working, it just carries no secret. + * + *

NOTE the exposure this accepts even WITH TLS. The capability token is on the task's argv, + * hence in {@code .command.sh} in the work directory, so from here on read access to a work + * directory is read access to the provider credential -- for as long as the capability is + * pending ({@code agent.rpc.capabilityTimeout}, an hour by default, and a resume cache hit mints + * capabilities nothing ever consumes). Single use and a pinned certificate bound a stolen + * token to ONE connection -- not to none. + */ + private Map startFrame(Invocation invocation) { + final Map frame = [ + type: 'start', + protocolVersion: 2, + invocationId: invocation.id, + spec: AgentProtocolSpec.fromRequest(invocation.request) ] as Map + final credential = invocation.request.apiKey + if( invocation.request.credentialWithheld ) + warnWithheldCredential(invocation.request) + if( !credential ) + return frame + if( insecureTransport ) { + warnInsecureCredential() + return frame + } + frame.put('apiKey', credential) + return frame + } + + /** + * A provider credential resolved on the driver and the endpoint gate refused to send it + * ({@code AgentConfig.credentialWithheldFor}). WARN, never throw. + * + *

This asymmetry with the langchain4j runner -- which raises the same condition as a fatal + * error in {@code ChatModelFactory.createModel} -- is deliberate and must stay. langchain4j is + * the whole credential chain: core resolving nothing means nothing exists. pi is not: it reads + * its own auth store and the provider variables present in the CONTAINER, which the driver + * cannot see and which are exactly how a Kubernetes Secret or an {@code env}/{@code secret} + * channel delivers a key. Aborting here would break a deployment that is working. + * + *

Said once per model id: the cause is that model's configuration, so a fan-out of tasks + * would otherwise restate one fact hundreds of times. + */ + private void warnWithheldCredential(AgentRunnerRequest request) { + if( !withheldCredentialWarned.add(request.model ?: '') ) + return + log.warn "The LLM provider credential resolved for agent model `${request.model}` was withheld from ${request.baseUrl ? "the endpoint ${request.baseUrl}" : 'the provider default endpoint'} because that endpoint is not one the `${request.apiProvider}` namespace owns - it is NOT sent to the agent task; the runner falls back to its own credential store and to the provider variables in the container, or set `agent.apiKey` to the credential this endpoint accepts, or `agent.apiProvider` to name the namespace it belongs to" + } + + /** + * Says why the credential was withheld, ONCE per broker: the cause is the session's + * {@code agent.rpc.tls}, not this invocation, so a line per agent task would restate one fact a + * fan-out's worth of times over the run's real errors. + */ + private void warnInsecureCredential() { + if( !insecureCredentialWarned.compareAndSet(false, true) ) + return + log.warn "Agent RPC transport security is disabled (agent.rpc.tls=false) - the LLM provider credential resolved by the driver is NOT sent to the agent task, because the start frame is cleartext and the driver is not authenticated to it; re-enable `agent.rpc.tls`, or deliver the credential to the container out of band with `agent.containerOptions = '-e OPENAI_API_KEY'` (Docker/Podman), the `env` config scope, or the `secret` directive" + } + + /** + * Refuses a {@code connect} frame, telling the task WHICH of the three ways it can fail happened. + * + *

{@code outcome} is what the pending map no longer holds: {@code null} means the id was never + * issued (or its token did not match a live capability), which is the only case that warrants the + * indiscriminate answer. The other two used to produce that same answer, and that is the whole + * defect -- a queue longer than the budget, and a task retried after it had already connected, + * are both operational events being reported as if they were forged credentials. + * + *

The record is written just AFTER the winning {@code remove}, never before, so that a lapse + * which loses the CAS cannot stamp {@code EXPIRED} over a capability that was in fact consumed. + * The cost is a nanosecond-wide window in which a connect sees the entry gone but no outcome yet + * and falls back to the generic answer -- a degraded message, never a wrong one. + */ + private void reject(ResponseSink sink, String id, Outcome outcome) { + String detail + if( outcome == Outcome.EXPIRED ) { + detail = "Agent RPC invocation capability expired after ${capabilitySeconds}s while the task waited to start - raise `agent.rpc.capabilityTimeout` above the executor's queueing delay".toString() + log.warn "Rejected agent RPC connection with a capability that expired after ${capabilitySeconds}s (invocationId=${id})" + } + else if( outcome == Outcome.CONSUMED ) { + // Named cause first, replay second, and on purpose. TaskProcessor re-submits a + // ProcessRetryableException / CloudSpotTerminationException copy via task.makeCopy() + // WITHOUT re-resolving the task body -- unlike the ordinary retry path, which does -- so a + // node termination or a spot reclaim re-runs the SAME `--invocation`/`--token`. If the + // first attempt had got as far as connecting, that legitimate retry lands here, and + // labelling it a replay attack is precisely the security-shaped mislabel this PR removes. + detail = 'Agent RPC invocation capability was already consumed - the task was retried after an earlier attempt had already connected, or the capability was replayed' + log.warn "Rejected agent RPC connection presenting an already-consumed capability (invocationId=${id}) - usually a task retried after an earlier attempt had connected; a replayed capability is the other reading" + } + else { + detail = 'Invalid agent RPC invocation identity or token' + // Never log the token value; the claimed invocation id is enough to audit. + log.warn "Rejected agent RPC connection with invalid identity or capability token (invocationId=${id})" + } + sink.fail(Status.UNAUTHENTICATED.withDescription(detail)) + } + + private void dispatchTool(Invocation invocation, Map msg, ResponseSink sink) { + dispatchPool.submit { + final String callId = msg.callId?.toString() + final String name = msg.name?.toString() + try { + if( !callId || !name ) + throw new IllegalArgumentException('Invalid tool_call without callId/name') + if( !invocation.allowedTools.contains(name) ) + throw new SecurityException("Tool `${name}` is not authorized for this invocation") + if( !invocation.callIds.add(callId) ) + throw new IllegalArgumentException("Duplicate tool call identity `${callId}`") + // Enforce the call ceiling atomically: two dispatch threads could otherwise + // both observe a within-limit size between add() and the size() check. + if( invocation.callCount.incrementAndGet() > Math.max(invocation.request.maxIterations, 1) ) { + invocation.callCount.decrementAndGet() + invocation.callIds.remove(callId) + throw new IllegalStateException('Agent exceeded the authorized tool-call limit') + } + final dispatcher = invocation.request.dispatch + if( dispatcher == null ) + throw new IllegalStateException("No dispatcher is available for tool `${name}`") + final args = JsonOutput.toJson(msg.arguments ?: Collections.emptyMap()) + final result = dispatcher.call(name, args) + trySend(sink, invocation, callId, [ + type: 'tool_result', + invocationId: invocation.id, + callId: callId, + result: result, + isError: false ]) + } + catch( Throwable error ) { + log.warn("Agent RPC tool dispatch failed for invocation ${invocation.id} tool=${name} callId=${callId}", error) + trySend(sink, invocation, callId, [ + type: 'tool_result', + invocationId: invocation.id, + callId: callId, + result: error.message ?: error.toString(), + isError: true ]) + } + } + } + + /** + * Sends a frame through the response sink, containing any failure so a dead + * stream cannot abort the dispatch task or escape into the never-read Future. + */ + private static void trySend(ResponseSink sink, Invocation invocation, String callId, Map message) { + try { + sink.send(message) + } + catch( Throwable error ) { + log.warn("Failed to send agent RPC frame for invocation ${invocation.id} callId=${callId}", error) + } + } + + private static void trace(AgentRunnerRequest request, Map msg) { + if( !request.trace ) + return + final event = msg.event ?: 'event' + // Trace frames can restate the prompt or model thinking; keep them at DEBUG + // and redact any embedded credential before logging. + final detail = AgentSecretMasker.redact((msg.name ?: msg.text ?: '').toString(), request.apiKey) + log.debug "[agent:${request.agentName ?: 'agent'}] ${event}${detail ? ' ' + detail : ''}" + } +} diff --git a/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentRpcTlsCredentials.groovy b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentRpcTlsCredentials.groovy new file mode 100644 index 0000000000..e21655bf41 --- /dev/null +++ b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentRpcTlsCredentials.groovy @@ -0,0 +1,144 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import java.nio.charset.StandardCharsets +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import java.util.concurrent.TimeUnit + +import groovy.transform.CompileStatic +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName +import org.bouncycastle.asn1.x509.GeneralNames +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder + +/** + * The driver-side TLS identity of the agent RPC broker: a key pair and a matching self-signed + * certificate, generated once per run and held only in memory. + * + *

There is no CA, no trust store and no certificate file. An agent task is given the + * certificate's SHA-256 {@link #getFingerprint fingerprint} on its command line and pins it — the + * SSH known-hosts pattern — so the certificate needs no issuer anyone else trusts, and its subject + * name is never what authenticates the driver. A fingerprint is a public commitment, not a secret, + * so it is harmless in {@code .command.sh}. + * + *

Two properties are load-bearing and must not be broken casually: + * + *

    + *
  • The fingerprint is the SHA-256 of the certificate's DER encoding, lowercase hex, 64 + * characters, no separators and no algorithm prefix. {@code agent-rpc}'s + * {@code VerifyPeerCertificate} hashes {@code rawCerts[0]}, which is exactly that DER. Hashing + * the PEM text instead — or its base64 body — yields an equally well-formed digest that simply + * never matches, and the resulting failure is indistinguishable from a genuine pinning + * rejection. Both ends must agree byte for byte.
  • + *
  • No global JCA provider is registered. Certificate construction needs only + * BouncyCastle's builder classes; the signature and the key pair come from the default + * provider. {@code nf-k8s} does call {@code Security.addProvider}, and a second plugin + * mutating JVM-wide state is exactly what this avoids.
  • + *
+ * + *

EC P-256 rather than RSA: every agent task holds an open stream for its whole life, so the + * per-connection handshake cost is what matters, not the one-off generation. + */ +@CompileStatic +class AgentRpcTlsCredentials { + + private static final String SUBJECT = 'CN=nextflow-agent-rpc' + + /** Signed with the key it certifies -- pinning makes the issuer irrelevant. */ + private static final String SIGNATURE_ALGORITHM = 'SHA256withECDSA' + + /** + * Certificate validity. The identity dies with the run either way, so this only has to be wide + * enough that a long pipeline and a modest clock skew between driver and execution node cannot + * expire it mid-flight. + */ + private static final long BACKDATE_MILLIS = TimeUnit.HOURS.toMillis(1) + private static final long VALIDITY_MILLIS = TimeUnit.DAYS.toMillis(365) + + private final String certificatePem + private final String privateKeyPem + private final String fingerprint + + private AgentRpcTlsCredentials(String certificatePem, String privateKeyPem, String fingerprint) { + this.certificatePem = certificatePem + this.privateKeyPem = privateKeyPem + this.fingerprint = fingerprint + } + + /** Generates a fresh key pair and self-signed certificate. */ + static AgentRpcTlsCredentials create() { + final random = new SecureRandom() + final generator = KeyPairGenerator.getInstance('EC') + generator.initialize(new ECGenParameterSpec('secp256r1'), random) + final KeyPair keys = generator.generateKeyPair() + final byte[] certificate = certificate(keys, random) + return new AgentRpcTlsCredentials( + pem('CERTIFICATE', certificate), + pem('PRIVATE KEY', keys.private.encoded), + fingerprint(certificate)) + } + + private static byte[] certificate(KeyPair keys, SecureRandom random) { + final subject = new X500Name(SUBJECT) + final now = System.currentTimeMillis() + final builder = new JcaX509v3CertificateBuilder( + subject, + new BigInteger(64, random), + new Date(now - BACKDATE_MILLIS), + new Date(now + VALIDITY_MILLIS), + subject, + keys.public) + // Deliberately no basicConstraints: a certificate that asserts CA=false cannot serve as its + // own trust anchor for Go's verifier, which would break a client that later chose to pin by + // trust root instead of by digest. Absent is permissive; CA=false is a trap. + // + // The names below are ones a pinning client never checks, but they keep the certificate + // usable with a conventional TLS client (openssl s_client, this broker's tests) on loopback. + builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames([ + new GeneralName(GeneralName.dNSName, 'localhost'), + new GeneralName(GeneralName.iPAddress, '127.0.0.1') ] as GeneralName[])) + final signer = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).build(keys.private) + return builder.build(signer).getEncoded() + } + + /** + * SHA-256 over the certificate DER, lowercase hex. This is the exact string the proxy compares + * against the digest of the certificate it is served -- see the class note. + */ + private static String fingerprint(byte[] der) { + return MessageDigest.getInstance('SHA-256').digest(der).encodeHex().toString() + } + + private static String pem(String type, byte[] der) { + final body = Base64.getMimeEncoder(64, '\n'.getBytes(StandardCharsets.US_ASCII)).encodeToString(der) + return "-----BEGIN ${type}-----\n${body}\n-----END ${type}-----\n".toString() + } + + String getCertificatePem() { certificatePem } + + String getFingerprint() { fingerprint } + + InputStream certificateStream() { new ByteArrayInputStream(certificatePem.getBytes(StandardCharsets.US_ASCII)) } + + InputStream privateKeyStream() { new ByteArrayInputStream(privateKeyPem.getBytes(StandardCharsets.US_ASCII)) } +} diff --git a/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentSecretMasker.groovy b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentSecretMasker.groovy new file mode 100644 index 0000000000..02d476a26c --- /dev/null +++ b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/AgentSecretMasker.groovy @@ -0,0 +1,84 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import java.util.regex.Pattern + +import groovy.transform.CompileStatic +import nextflow.SysEnv + +/** + * Masks LLM provider credentials embedded in text captured from an agent runner -- error + * messages, trace frames, stderr tails -- before any of it reaches the Nextflow log. + * + *

Masking is keyed off the RESOLVED credential first. A key supplied through + * {@code agent.apiKey} -- especially spelled {@code secrets.LLM_KEY} -- has no environment + * variable name, so a name-driven sweep cannot find it. The environment sweep is kept as a + * backstop for a key the user still exports, and reads through {@link SysEnv} rather than + * {@code System.getenv} so a test can swap the environment instead of mutating the process. + * The patterns are a last resort for a credential that is neither: text the provider itself + * echoed back, or an upstream gateway token. + * + *

Deliberately shared by both agent transports: an independent copy per transport is exactly + * how the canonical RPC path came to mask less than the legacy stdin harness. Nothing here is + * Pi-specific -- it belongs with {@link AgentRpcBroker} if that is ever extracted to a shared + * plugin. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentSecretMasker { + + /** + * Provider API-key environment variables swept from captured runner output. The list mirrors + * the provider tier of the core credential ladder -- {@code AgentConfig} reads exactly these, + * beside the neutral {@code NXF_AGENT_API_KEY} -- so the redaction backstop and the resolution + * contract cannot disagree about what counts as a credential. + * + *

This is the backstop for a key the driver holds but did NOT resolve for the invocation: + * the one it did resolve is redacted by VALUE, whether it reached the task in band on the RPC + * start frame or through the {@code env} config scope / {@code secret} directive. + */ + private static final List SECRET_ENV_KEYS = ['NXF_AGENT_API_KEY','OPENAI_API_KEY','ANTHROPIC_API_KEY','GOOGLE_API_KEY','GEMINI_API_KEY','MISTRAL_API_KEY','OPENROUTER_API_KEY','AZURE_OPENAI_API_KEY'] + + private static final Pattern BEARER_TOKEN = ~/(?i)(bearer\s+)[^\s"']+/ + private static final Pattern API_KEY = ~/(?i)\b(sk|rk|pk)-[a-z0-9_-]{8,}/ + + /** + * @param value The text to mask, or {@code null}. + * @param apiKey The credential resolved for this invocation, or {@code null} when none was. + * @return The text with every known credential replaced by {@code [REDACTED]}. + */ + static String redact(String value, String apiKey=null) { + if( value == null ) + return value + String result = value + // The resolved credential first: it is the one value this invocation could actually + // have leaked, and the only one no name-driven lookup can discover. + if( apiKey ) + result = result.replace(apiKey, '[REDACTED]') + for( final key : SECRET_ENV_KEYS ) { + final secret = SysEnv.get(key) + if( secret ) + result = result.replace(secret, '[REDACTED]') + } + // Mask credentials the text may embed even when the exact forwarded secret is not what + // leaked (e.g. an upstream Bearer header or a differently-prefixed API key). + result = result.replaceAll(BEARER_TOKEN, '$1[REDACTED]') + result = result.replaceAll(API_KEY, '[REDACTED]') + return result + } +} diff --git a/plugins/nf-agent-pi/src/main/nextflow/agent/pi/PiAgentPlugin.groovy b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/PiAgentPlugin.groovy new file mode 100644 index 0000000000..a48a4d5833 --- /dev/null +++ b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/PiAgentPlugin.groovy @@ -0,0 +1,28 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import groovy.transform.CompileStatic +import nextflow.plugin.BasePlugin +import org.pf4j.PluginWrapper + +@CompileStatic +class PiAgentPlugin extends BasePlugin { + + PiAgentPlugin(PluginWrapper wrapper) { + super(wrapper) + } +} diff --git a/plugins/nf-agent-pi/src/main/nextflow/agent/pi/PiAgentRunner.groovy b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/PiAgentRunner.groovy new file mode 100644 index 0000000000..3227e1342f --- /dev/null +++ b/plugins/nf-agent-pi/src/main/nextflow/agent/pi/PiAgentRunner.groovy @@ -0,0 +1,99 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import nextflow.agent.AgentLaunchSpec +import nextflow.agent.rpc.AgentRpcRegistration +import nextflow.agent.AgentRunner +import nextflow.agent.AgentRunnerRequest +import org.pf4j.Extension + +/** + * Pi SDK runner. Production execution is described by {@link #getLaunchSpec()} and runs as a + * canonical Nextflow task in the runner container image, through the {@code agent-rpc} proxy. + * + *

The plugin ships no runtime of its own: both the proxy and the Node harness are provided by + * the image, so an agent selecting this runner requires a container. + */ +@Extension +@CompileStatic +class PiAgentRunner implements AgentRunner { + + /** + * Generated at build time from {@code build-image.sh} and this plugin's VERSION - see + * {@code generateImageCoordinate} in build.gradle. Never hand-written, so the reference the + * jar asks for is the reference the release publishes. + */ + @PackageScope + static final String IMAGE_RESOURCE = '/META-INF/nf-agent-pi-image.properties' + + private static final String IMAGE = parseImageCoordinate(PiAgentRunner.getResourceAsStream(IMAGE_RESOURCE)) + + @Override + String getName() { 'pi' } + + /** + * The runner image, so an agent selecting {@code pi} needs no explicit {@code agent.container}. + * + *

Null when the generated resource is missing or unusable, which only a jar built outside + * the Gradle build can be (PiAgentPackagingTest pins its presence in both artifacts). Null + * rather than a throw on purpose: it degrades to core's existing "must declare a container" + * failure, which is legible and recoverable by setting {@code agent.container}, instead of an + * exception out of a static initialiser during PF4J's extension loading. + */ + @Override + String getDefaultContainer() { IMAGE } + + /** Read the {@code image} entry, or null if there is nothing usable to read. */ + @PackageScope + static String parseImageCoordinate(InputStream stream) { + if( stream == null ) + return null + try { + final props = new Properties() + stream.withCloseable { InputStream it -> props.load(it) } + return props.getProperty('image')?.trim() ?: null + } + catch( IOException e ) { + return null + } + } + + /** The in-image command pair. Paths must match those the runner image is built with. */ + @Override + AgentLaunchSpec getLaunchSpec() { + return new AgentLaunchSpec( + containerProxyCommand: ['/usr/local/bin/agent-rpc'], + containerHarnessCommand: ['node', '/opt/nf-agent-pi/runner.mjs'] ) + } + + @Override + AgentRpcRegistration register(AgentRunnerRequest request, boolean remote) { + return AgentRpcBroker.get().register(request, remote) + } + + /** + * Not supported: this runner has no driver-local execution path. A canonical agent task runs + * the container command described by {@link #getLaunchSpec()} and talks to the driver-side + * broker over RPC, so nothing invokes the model from the driver JVM. + */ + @Override + String run(AgentRunnerRequest request) { + throw new UnsupportedOperationException("Agent runner `${name}` runs as a container task - it cannot be invoked in the driver process") + } +} diff --git a/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcBrokerTest.groovy b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcBrokerTest.groovy new file mode 100644 index 0000000000..c23ee7b395 --- /dev/null +++ b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcBrokerTest.groovy @@ -0,0 +1,1124 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.TimeUnit + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import io.grpc.Grpc +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.MethodDescriptor +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.TlsChannelCredentials +import io.grpc.stub.ClientCalls +import io.grpc.stub.StreamObserver +import nextflow.Global +import nextflow.Session +import nextflow.agent.rpc.AgentRpcHost +import nextflow.agent.rpc.AgentRpcHostResolver +import nextflow.agent.AgentRunnerRequest +import nextflow.agent.ToolDescriptor +import nextflow.agent.ToolDispatcher +import org.slf4j.LoggerFactory +import spock.lang.Specification +import nextflow.agent.rpc.Probes + +/** + * Exercises the broker over a real gRPC connection. This is the guard for the + * transport itself: the broker moved out of core into this plugin together with + * its gRPC dependencies, so a resolution or packaging mistake shows up here as a + * failure to start the server or to complete the handshake. + */ +class AgentRpcBrokerTest extends Specification { + + private static final MethodDescriptor.Marshaller MARSHALLER = new MethodDescriptor.Marshaller() { + @Override InputStream stream(String value) { + return new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)) + } + @Override String parse(InputStream stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8) + } + } + + // The full method name is spelled out as a LITERAL, not derived from + // AgentRpcBroker.SERVICE_NAME/METHOD_NAME: agent-rpc/main.go hardcodes + // `/nextflow.agent.AgentBroker/Connect`, so deriving it here would let a rename of + // the constants keep this test green while breaking the Go proxy. The service name + // deliberately keeps the `nextflow.agent` prefix even though the class now lives in + // `nextflow.agent.pi` — it is a wire identifier, not a package reference. + private static final MethodDescriptor CONNECT = MethodDescriptor + .newBuilder() + .setType(MethodDescriptor.MethodType.BIDI_STREAMING) + .setFullMethodName('nextflow.agent.AgentBroker/Connect') + .setRequestMarshaller(MARSHALLER) + .setResponseMarshaller(MARSHALLER) + .build() + + /** + * The pre-connect budget the OLD rule produced: {@code max(requestTimeout,30) + 60} with the + * default {@code agent.requestTimeout} of 120s. Any queueing executor -- k8s under pressure, + * slurm, AWS Batch -- routinely waits longer than this before the job starts. + */ + private static final long OLD_EXPIRY_SECONDS = 180 + + AgentRpcBroker broker + List channels = [] + ListAppender logs + Logger brokerLog + + def setup() { + // attached before the first broker is built: the TLS opt-out warns from the constructor + brokerLog = (Logger) LoggerFactory.getLogger(AgentRpcBroker) + logs = new ListAppender() + logs.start() + brokerLog.addAppender(logs) + driverSession([:]) + broker = AgentRpcBroker.get() + } + + def cleanup() { + channels.each { it.shutdownNow() } + broker?.close() + // a feature that lowered the level to observe the registration line must not leave it there + brokerLog?.setLevel(null) + brokerLog?.detachAppender(logs) + Global.session = null + AgentRpcHostResolver.reset() + } + + /** + * The session the broker resolves its advertised address against, with the DRIVER HOST placed + * explicitly: an ordinary uncontainerized Linux host with one address. Without this the address + * ladder would read the machine running the suite, and a suite running inside a container would + * see {@code /.dockerenv}, take the containerized-driver row, and advertise that container's own + * address where these specs expect the engine host alias. + */ + private Session driverSession(Map config) { + final session = new Session(config) + AgentRpcHostResolver.install(session, new HostProbes()) + return Global.session = session + } + + /** @see #driverSession */ + static class HostProbes implements Probes { + @Override String outboundAddress() { '10.0.3.17' } + @Override List interfaceAddresses() { ['10.0.3.17'] } + } + + /** + * Replaces the singleton broker with one built from an explicit `agent.rpc` scope, so a test + * can exercise a capability lifetime it can actually wait out. + */ + private AgentRpcBroker rebuild(Map rpcOpts) { + broker.close() + driverSession([agent: [rpc: rpcOpts]]) + return broker = AgentRpcBroker.get() + } + + def 'should complete the handshake and dispatch a brokered tool call'() { + given: + def dispatched = [] + def request = new AgentRunnerRequest( + model: 'openai/test', + prompt: 'say hello', + maxIterations: 5, + requestTimeoutSeconds: 30, + agentName: 'demo', + toolSpecs: [new ToolDescriptor('echo', 'echo it back', [type: 'object'], null)], + dispatch: { String name, String args -> + dispatched << [name, args] + return '{"value":"hello"}' + } as ToolDispatcher) + def registration = broker.register(request, false) + + when: + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def start = frames.next() + + then: 'the broker admits the capability and replies with the portable spec' + start.type == 'start' + start.protocolVersion == 2 + start.invocationId == registration.invocationId + start.spec.model == 'openai/test' + start.spec.prompt == 'say hello' + + when: 'an authorized tool is called' + frames.send([ + type: 'tool_call', + invocationId: registration.invocationId, + callId: 'call-1', + name: 'echo', + arguments: [msg: 'hello'] ]) + def result = frames.next() + + then: 'the call reaches the dispatcher and the result comes back over the stream' + result.type == 'tool_result' + result.callId == 'call-1' + result.isError == false + result.result == '{"value":"hello"}' + dispatched.size() == 1 + dispatched[0][0] == 'echo' + dispatched[0][1].contains('hello') + } + + def 'should refuse an unauthorized tool without reaching the dispatcher'() { + given: + def dispatched = [] + def request = new AgentRunnerRequest( + model: 'openai/test', + maxIterations: 5, + requestTimeoutSeconds: 30, + toolSpecs: [new ToolDescriptor('echo', 'echo it back', [type: 'object'], null)], + dispatch: { String name, String args -> dispatched << name; return 'never' } as ToolDispatcher) + def registration = broker.register(request, false) + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + frames.next() + + when: + frames.send([ + type: 'tool_call', + invocationId: registration.invocationId, + callId: 'call-1', + name: 'not-declared', + arguments: [:] ]) + def result = frames.next() + + then: + result.type == 'tool_result' + result.isError == true + result.result.contains('not authorized') + dispatched.isEmpty() + } + + def 'should send the runner-native names in the start frame and authorize none of them'() { + given: 'the two halves of the tool split: one brokered process, three runner-native leaves' + def dispatched = [] + def request = new AgentRunnerRequest( + model: 'openai/test', + maxIterations: 5, + requestTimeoutSeconds: 30, + toolSpecs: [new ToolDescriptor('echo', 'echo it back', [type: 'object'], null)], + nativeToolNames: ['read', 'write', 'bash'], + dispatch: { String name, String args -> dispatched << name; return 'never' } as ToolDispatcher) + def registration = broker.register(request, false) + + when: + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def start = frames.next() + + then: 'the native names travel BESIDE the descriptors, so the harness can enable the' + // matching pi builtins -- they are not descriptors and carry no schema of ours + start.spec.nativeToolNames == ['read', 'write', 'bash'] + start.spec.toolSpecs*.name == ['echo'] + + when: 'the model calls one of them over the RPC stream anyway' + frames.send([ + type: 'tool_call', + invocationId: registration.invocationId, + callId: 'call-1', + name: 'bash', + arguments: [command: 'cat /etc/shadow'] ]) + def result = frames.next() + + then: 'the allowlist is built from the BROKERED half only, so a container-side tool cannot' + // be relocated into the driver JVM by calling it back + result.type == 'tool_result' + result.isError == true + result.result.contains('not authorized') + dispatched.isEmpty() + } + + def 'should refuse to register a request whose two tool halves overlap'() { + given: 'a process named `read` alongside the `fs:read` the runner serves itself' + def request = new AgentRunnerRequest( + model: 'openai/test', + requestTimeoutSeconds: 30, + toolSpecs: [new ToolDescriptor('read', 'a process', [type: 'object'], null)], + nativeToolNames: ['read']) + + when: + broker.register(request, false) + + then: 'no capability is minted at all - the partition is checked before a job is submitted' + def err = thrown(IllegalStateException) + err.message.contains('partition violated') + } + + def 'should reject a connection presenting a wrong token'() { + given: + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) + def frames = connect(registration.endpoint) + + when: + frames.send([type: 'connect', invocationId: registration.invocationId, token: 'not-the-token']) + + then: 'the stream is failed instead of admitting the caller' + frames.error() != null + } + + def 'should keep an unconsumed capability valid far beyond the old requestTimeout-derived expiry'() { + given: 'an invocation whose task sits queued -- register() runs when the SCRIPT is generated' + def request = new AgentRunnerRequest(model: 'openai/test', prompt: 'queued for ages', requestTimeoutSeconds: 120) + def registration = broker.register(request, false) + + expect: 'nothing can invalidate the capability for another hour, so a connect at t=180s+ finds it' + // The delay is read off the armed deadline rather than waited out: the ONLY thing that removes + // an unconsumed capability is that scheduled task, so "the job is still queued ten minutes + // from now" is exactly "no expiry is due for an hour". Under max(requestTimeout,30)+60 this + // same capability was dead at 180s and the task then failed with `Invalid agent RPC invocation + // identity or token` -- a security-shaped message for what was only scheduler latency. + pendingExpiry(registration.invocationId).getDelay(TimeUnit.SECONDS) > OLD_EXPIRY_SECONDS + pendingExpiry(registration.invocationId).getDelay(TimeUnit.MINUTES) >= 59 + + and: 'the per-request LLM timeout cannot shorten the capability either, at any value' + pendingExpiry(broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 1), false) + .invocationId).getDelay(TimeUnit.MINUTES) >= 59 + + when: 'the executor finally releases the job and the proxy dials back' + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def start = frames.next() + + then: 'the capability is still valid and the handshake completes' + start.type == 'start' + start.invocationId == registration.invocationId + start.spec.prompt == 'queued for ages' + } + + def 'should disarm the capability clock when the connect frame is accepted'() { + given: + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 120), false) + def expiry = pendingExpiry(registration.invocationId) + + expect: 'the deadline is armed at register(), because the PRE-connect wait is all it bounds' + !expiry.isCancelled() + !expiry.isDone() + + when: + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def start = frames.next() + + then: 'consuming the capability cancels it, and nothing is armed in its place' + // A live stream deliberately carries NO deadline. `requestTimeoutSeconds` bounds one LLM call, + // so a legitimate agent -- maxIterations 20 x 120s, plus driver-side tool dispatch that itself + // queues -- runs for tens of minutes; re-arming that constant here would kill nearly every + // multi-turn agent mid-stream, which is the same spurious security-shaped failure D1 removes. + // The next test proves a live stream outlives the capability budget. + start.type == 'start' + expiry.isCancelled() + } + + def 'should dequeue a consumed capability deadline, not merely cancel it'() { + given: 'two capabilities pending on the one-hour default budget' + def first = broker.register(new AgentRunnerRequest(model: 'openai/test', prompt: 'one', requestTimeoutSeconds: 30), false) + broker.register(new AgentRunnerRequest(model: 'openai/test', prompt: 'two', requestTimeoutSeconds: 30), false) + + expect: + expiryQueueSize() == 2 + + when: 'the first task starts and its proxy connects' + def frames = connect(first.endpoint) + frames.send([type: 'connect', invocationId: first.invocationId, token: first.token]) + frames.next() + + then: 'its deadline leaves the delay queue there and then' + // ScheduledThreadPoolExecutor defaults removeOnCancelPolicy to false, so a cancelled task + // sits in the queue until its ORIGINAL deadline -- and the queued Runnable captures the + // Invocation, hence the prompt, the serialized inputs, the tool specs and the dispatcher + // closure. Widening the pre-connect budget to an hour without this would mean an hour of + // retention per *finished* agent, which is worst exactly where fan-out is widest. + expiryQueueSize() == 1 + } + + def 'should honour a zero capability timeout rather than widening it to the default'() { + given: 'the tightest window an operator can ask for' + // nextflow.util.Duration is falsy at zero, so defaulting with Elvis here would silently + // replace the most restrictive setting with the most permissive one -- and silently + // relaxing a security knob is the one direction a default must never take. + broker = rebuild([capabilityTimeout: '0s']) + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 3600), false) + + when: + sleep(500) + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + + then: 'the capability was already gone, rather than valid for another hour' + frames.error() != null + } + + def 'should reject a connect frame once the capability timeout has elapsed'() { + given: 'a capability that expires in a second, and a much longer per-request LLM timeout' + broker = rebuild([capabilityTimeout: '1s']) + // requestTimeoutSeconds is deliberately large: the pre-connect budget must come from + // `agent.rpc.capabilityTimeout` ALONE. Under the previous max(requestTimeout,30)+60 rule + // this capability would still be valid for another hour. + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 3600), false) + + when: + sleep(1_500) + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + + then: 'the expired capability is gone from the broker and the stream is failed' + frames.error() != null + } + + def 'should not put a connected stream on the capability clock'() { + given: 'a capability that expires in a second, consumed immediately' + broker = rebuild([capabilityTimeout: '1s']) + def request = new AgentRunnerRequest( + model: 'openai/test', + maxIterations: 5, + requestTimeoutSeconds: 30, + toolSpecs: [new ToolDescriptor('echo', 'echo it back', [type: 'object'], null)], + dispatch: { String name, String args -> return '{"value":"hello"}' } as ToolDispatcher) + def registration = broker.register(request, false) + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + frames.next() + + when: 'the live stream outlives the capability timeout' + sleep(1_500) + frames.send([ + type: 'tool_call', + invocationId: registration.invocationId, + callId: 'call-1', + name: 'echo', + arguments: [:] ]) + def result = frames.next() + + then: 'the timeout bounds the PRE-connect wait only: an agent may legitimately run for hours' + result.type == 'tool_result' + result.isError == false + } + + def 'should reject a replayed connect reusing a consumed capability'() { + given: + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) + def first = connect(registration.endpoint) + first.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + first.next() + + when: 'a second stream presents the same identity and token' + def replay = connect(registration.endpoint) + replay.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + + then: 'single-use consumption still rejects it, independently of any expiry' + replay.error() != null + } + + def 'should report the capabilities a resumed run leaves behind, on the default budget'() { + given: 'fifty agent tasks whose scripts were generated and which then hit the resume cache' + // TaskProcessor calls task.resolve(taskBody) -- hence register() -- BEFORE checkStoredOutput + // and before checkCachedOrLaunchTask, so on `nextflow run -resume` a cache HIT still mints a + // capability. Nothing connects with it and nothing cancels it. + 50.times { + broker.register(new AgentRunnerRequest(model: 'openai/test', prompt: "cached ${it}", requestTimeoutSeconds: 30), false) + } + + expect: 'no deadline has fired, and none is anywhere near due' + // This is why counting EXPIRIES cannot surface the resume shape: on the default one-hour + // budget such a run is over in seconds, so the fired-deadline count is zero and the operator + // gets silence. The count that names it is what is still PENDING when the broker shuts down. + pendingCount() == 50 + warnings().isEmpty() + + when: 'the run ends' + broker.close() + + then: 'the retention is named and counted, rather than being released without a word' + warnings().any { + it.contains('50 agent capabilities registered but never consumed') && it.contains('resume cache') + } + + and: 'reported once, though close() is reachable twice for the same broker' + // cleanup() closes it again; session.onShutdown plus an explicit close is the same shape + broker.close() + warnings().count { it.contains('registered but never consumed') } == 1 + } + + def 'should tell an expired capability apart from an identity it never issued'() { + given: 'a capability whose task takes longer to start than the budget allows' + broker = rebuild([capabilityTimeout: '1s']) + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 3600), false) + + when: 'the proxy finally dials back' + sleep(1_500) + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def expired = frames.error() + + then: 'the task is told the budget lapsed, not that it presented a forged credential' + // The reason has to travel as a real gRPC status: grpc-java closes a call aborted by a thrown + // exception with `UNKNOWN: Application error processing RPC` and does not serialize the cause, + // so every message this replaces was invisible at the far end. + Status.fromThrowable(expired).code == Status.Code.UNAUTHENTICATED + Status.fromThrowable(expired).description.contains('expired') + Status.fromThrowable(expired).description.contains('agent.rpc.capabilityTimeout') + + and: 'the deadline that fired left evidence, naming the invocation and the budget' + // it used to fire silently -- `{ invocations.remove(id, invocation) }` and nothing else + warnings().any { it.contains(registration.invocationId) && it.contains('expired after 1s') } + + when: 'an identity the broker never issued is presented' + def unknown = connect(registration.endpoint) + unknown.send([type: 'connect', invocationId: UUID.randomUUID().toString(), token: registration.token]) + def rejected = unknown.error() + + then: 'there is nothing to disclose, so this one keeps the indiscriminate answer' + Status.fromThrowable(rejected).code == Status.Code.UNAUTHENTICATED + Status.fromThrowable(rejected).description == 'Invalid agent RPC invocation identity or token' + } + + def 'should name the retry cause when an already-consumed capability comes back'() { + given: + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) + def first = connect(registration.endpoint) + first.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + first.next() + + when: 'the same identity and token are presented a second time' + def again = connect(registration.endpoint) + again.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def error = again.error() + + then: 'the answer comes from the outcome record, because the entry itself is already gone' + // The first connect REMOVED the entry, so this lookup finds null -- exactly what an id the + // broker never issued finds. Without a record of how the capability left, the two are + // indistinguishable and this path reports `Invalid ... identity or token`. + Status.fromThrowable(error).code == Status.Code.UNAUTHENTICATED + Status.fromThrowable(error).description.contains('already consumed') + + and: 'and it names the operational cause, not only the replay reading' + // TaskProcessor re-submits a ProcessRetryableException / CloudSpotTerminationException copy + // through task.makeCopy() WITHOUT re-resolving the task body, so a node termination or a spot + // reclaim re-runs the SAME --invocation/--token. A first attempt that got as far as + // connecting therefore lands here legitimately, and calling that a replay attack is the + // security-shaped mislabel this branch exists to remove. + Status.fromThrowable(error).description.contains('retried') + warnings().any { it.contains(registration.invocationId) && it.contains('retried') } + } + + def 'should refuse a connect frame that carries no invocation identity'() { + given: 'a live capability, so the broker is serving and the pending map is not empty' + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) + def frames = connect(registration.endpoint) + + when: 'a connect frame omits invocationId altogether' + frames.send([type: 'connect', token: registration.token]) + def error = frames.error() + + then: 'the caller is refused with a status rather than with an opaque close' + // `invocations` is a ConcurrentHashMap, so the null key threw NullPointerException out of + // onNext and grpc-java closed the call with `UNKNOWN: Application error processing RPC`, + // discarding the cause -- the exact failure this branch exists to stop emitting, and + // reachable before authentication by any peer that can reach the port. + Status.fromThrowable(error).code == Status.Code.UNAUTHENTICATED + Status.fromThrowable(error).description == 'Invalid agent RPC invocation identity or token' + + and: 'the live capability is untouched, so the real proxy can still spend it' + pendingCount() == 1 + } + + def 'should still name the consumed cause after a mass lapse of other capabilities'() { + given: 'a budget long enough to connect within, and short enough that later capabilities lapse' + broker = rebuild([capabilityTimeout: '2s']) + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) + def first = connect(registration.endpoint) + first.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + assert first.next().type == 'start' + + when: 'the run goes on to lapse more capabilities than one 1024-row budget could hold' + 1100.times { broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) } + awaitDrained() + + and: 'the connected task is retried, presenting the same capability a second time' + def again = connect(registration.endpoint) + again.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def error = again.error() + + then: 'it still learns an earlier attempt had connected, instead of being told it forged the token' + // This CONSUMED row is the OLDEST, so a shared 1024-row budget let the 1100 EXPIRED rows evict + // precisely the one a retry needs. The message then fell back to `Invalid agent RPC invocation + // identity or token`: the security-shaped mislabel the record exists to remove, reappearing at + // exactly the run sizes where retries are most likely. + Status.fromThrowable(error).code == Status.Code.UNAUTHENTICATED + Status.fromThrowable(error).description.contains('already consumed') + } + + def 'should keep a wide fan-out diagnosable without burying the run in lapse warnings'() { + given: 'a fan-out far wider than the old record held, every capability lapsing at once' + broker = rebuild([capabilityTimeout: '0s']) + + when: + 1124.times { broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) } + awaitDrained() + + then: 'every outcome is still on the record, so any of these tasks can still be told why' + // 1124 is deliberately just past the 1024 rows this record used to hold, because that bound + // silently traded away the diagnosis it exists to provide: the evicted rows are the OLDEST, + // i.e. the tasks whose retries arrive latest. The cap still exists at OUTCOME_HISTORY and is + // two orders of magnitude up; a suite cannot reach it without a 100k-task run, so what is + // asserted here is the property that was actually broken. + outcomeCount() == 1124 + + and: 'and a wide fan-out cannot bury the run in lapse warnings' + warnings().count { it.contains('expired after') } == 10 + + when: 'the broker shuts down' + broker.close() + + then: 'the aggregate still carries the true total, whatever the per-lapse cap swallowed' + warnings().any { it.contains('1124 agent capabilities that lapsed') } + } + + def 'should bound a half-open agent stream with server keepalive'() { + given: + def builder = Mock(ServerBuilder) + + when: + AgentRpcBroker.applyKeepAlive(builder) + + then: 'the server pings a silent connection, so a vanished node is noticed in ~80s not ~2h' + // Without this there is NOTHING watching a connected stream: the post-connect deadline was + // deliberately removed, an OOM-killed pod or a reclaimed spot instance never sends a FIN, and + // both grpc-java's server default and Linux `tcp_keepalive_time` are 2h. + 1 * builder.keepAliveTime(60, TimeUnit.SECONDS) + 1 * builder.keepAliveTimeout(20, TimeUnit.SECONDS) + + and: 'the enforcement floor is grpc-go\'s own clamp, not a number agreed with the proxy' + // grpc-go raises keepalive.ClientParameters.Time to KeepaliveMinPingTime = 10s whatever the + // proxy configures, so a floor of 10s cannot be violated by any build of agent-rpc. A floor + // stricter than the client's interval makes the server answer healthy streams with GOAWAY -- + // pinning it to the client library's minimum is what removes that coordination failure. + 1 * builder.permitKeepAliveTime(10, TimeUnit.SECONDS) + + and: 'permitKeepAliveWithoutCalls is left OFF, deliberately' + // The server asks for no client certificate and binds every interface, so ping enforcement is + // the only thing that evicts a peer which completes the TLS handshake and never opens a + // stream. Turning it on to cover the pre-stream window would be a fail-open for a window that + // is sub-millisecond: grpc-go dials lazily on the first RPC, and KeepAliveEnforcer resets its + // strike count on stream creation. + 0 * builder.permitKeepAliveWithoutCalls(_) + + and: 'and nothing else is imposed on the connection' + // maxConnectionAge / maxConnectionIdle would tear down a live agent mid-stream, which is the + // spurious kill the pre/post-connect split removed in the first place. + 0 * builder._ + } + + def 'should serve TLS with a per-run certificate and pin it by its DER digest'() { + given: 'the default broker, i.e. transport security on' + def request = new AgentRunnerRequest(model: 'openai/test', prompt: 'confidential', requestTimeoutSeconds: 30) + def registration = broker.register(request, false) + + expect: 'the pin is the SHA-256 of the served certificate DER, lowercase hex, no separators' + // This is the whole cross-language contract: agent-rpc hashes rawCerts[0], which IS this DER. + // Hashing the PEM text (or its base64 body) would produce an equally well-formed digest that + // never matches, and the failure would be indistinguishable from a real pinning rejection. + registration.fingerprint ==~ /[0-9a-f]{64}/ + registration.fingerprint == MessageDigest.getInstance('SHA-256') + .digest(servedCertificateDer(broker.certificatePem)).encodeHex().toString() + + and: 'the proxy is told to pin it, rather than being left to infer trust from a missing flag' + registration.transportArgs() == ['--fingerprint', registration.fingerprint] + + and: 'one identity per run, not per invocation' + broker.register(request, false).fingerprint == registration.fingerprint + + when: 'a client that trusts only that certificate connects' + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def start = frames.next() + + then: 'the handshake completes, so the server really serves the pinned certificate' + start.type == 'start' + start.spec.prompt == 'confidential' + + when: 'a cleartext client dials the same port' + def plaintext = ManagedChannelBuilder.forTarget(registration.endpoint).usePlaintext().build() + channels << plaintext + // no frame is sent: the RPC alone drives the connection, and a cleartext h2 preface against + // a TLS listener fails the handshake, so the failure arrives without touching the stream + def rejected = framesOn(plaintext) + + then: 'there is no h2c fallback, so no frame -- and no prompt -- can cross in cleartext' + rejected.error() != null + } + + def 'should refuse a client that trusts a certificate the broker does not serve'() { + given: 'the driver identity a task pins, and an impostor identity of exactly the same shape' + def registration = broker.register( + new AgentRunnerRequest(model: 'openai/test', prompt: 'confidential', requestTimeoutSeconds: 30), false) + def impostor = AgentRpcTlsCredentials.create() + + expect: 'a different certificate is a different pin -- subject and issuer are identical' + // the digest assertion is what stops this test from passing vacuously if the broker ever + // stopped serving TLS: a cleartext listener also refuses a TLS client + registration.fingerprint ==~ /[0-9a-f]{64}/ + impostor.fingerprint != registration.fingerprint + + when: 'the client trusts only the impostor, as a proxy handed the wrong digest would' + final parts = registration.endpoint.split(':') + final credentials = TlsChannelCredentials.newBuilder() + .trustManager(new ByteArrayInputStream(impostor.certificatePem.getBytes(StandardCharsets.US_ASCII))) + .build() + final channel = Grpc.newChannelBuilderForAddress(parts[0], parts[1] as int, credentials).build() + channels << channel + def frames = framesOn(channel) + + then: 'the handshake fails, so no token and no start frame -- hence no prompt -- crosses' + // This is the JVM half of pinning: a grpc-java client trusts "this exact certificate", so a + // mismatch surfaces as a handshake failure. The requirement that the PROXY report a clear + // pinning error rather than a generic TLS message is asserted where the digest comparison + // actually lives -- agent-rpc's TestPinnedFingerprintRejectsAnotherCertificate. + frames.error() != null + } + + def 'should serve cleartext and say so explicitly when tls is disabled'() { + given: 'the secure default broker built in setup() warned about nothing' + assert warnings().isEmpty() + + when: + broker = rebuild([tls: false]) + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) + + then: 'the opt-out is loud: a cleartext run must not be an unnoticed one' + warnings().any { it.contains('agent.rpc.tls=false') && it.contains('cleartext') } + + and: 'no certificate, no pin' + broker.certificatePem == null + registration.fingerprint == null + + and: 'the opt-out is an explicit flag: an absent --fingerprint must never mean "unpinned"' + registration.transportArgs() == ['--insecure'] + + when: 'a cleartext client connects' + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + + then: 'the escape hatch still works' + frames.next().type == 'start' + } + + // ----------------------------------------------------------------------- + // In-band credential delivery (design D4). The credential core resolved travels BESIDE the + // portable spec, as a top-level start-frame field, and only when the link is TLS-protected. + // ----------------------------------------------------------------------- + + def 'should carry the resolved credential beside the spec, not inside it'() { + given: 'the default broker, i.e. transport security on' + def registration = broker.register(new AgentRunnerRequest( + model: 'openai/gpt-5-mini', + prompt: 'p', + requestTimeoutSeconds: 30, + apiKey: 'sk-in-band-7d41', + baseUrl: 'https://gw.corp/v1'), false) + + when: + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def start = frames.next() + + then: 'the credential is a TOP-LEVEL frame field, which runner.mjs installs with setRuntimeApiKey' + start.apiKey == 'sk-in-band-7d41' + + and: 'and NOT inside the spec -- that half is what a transport may relay verbatim, log or persist' + !((Map) start.spec).containsKey('apiKey') + !start.spec.toString().contains('sk-in-band-7d41') + + and: 'the endpoint still travels on the spec, where it always did: it is not a secret' + start.spec.baseUrl == 'https://gw.corp/v1' + + and: 'and nothing was warned -- TLS is on, so there is nothing to withhold' + warnings().isEmpty() + } + + def 'should send the resolved key and NEVER the no-credential placeholder'() { + given: 'core resolved nothing while an endpoint IS declared -- exactly the shape that makes' + // AgentRunnerRequest.credential() substitute `nxf-no-credential`. A runtime key OWNS its + // provider in pi (setRuntimeApiKey is consulted before pi's own store and before the ambient + // environment), so sending the placeholder here would SHADOW a credential an `env`/`secret` + // channel had already delivered to the container and turn a working run into a 401. + def request = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', + prompt: 'p', + requestTimeoutSeconds: 30, + apiKey: null, + baseUrl: 'https://gateway.corp/v1') + + expect: 'the placeholder is what credential() would have produced for this very request' + request.credential() == AgentRunnerRequest.PLACEHOLDER_API_KEY + + when: + def registration = broker.register(request, false) + def frames = connect(registration.endpoint) + frames.send([type: 'connect', invocationId: registration.invocationId, token: registration.token]) + def start = frames.next() + + then: 'and the frame carries no credential at all, under any key' + start.type == 'start' + !((Map) start).containsKey('apiKey') + !start.toString().contains(AgentRunnerRequest.PLACEHOLDER_API_KEY) + } + + def 'should withhold the credential when tls is disabled, and say so once'() { + given: 'the cleartext escape hatch, where the frame is readable and the driver is not' + // authenticated to the task -- shipping a credential there is strictly worse than today + broker = rebuild([tls: false]) + final secret = 'sk-must-not-cross-2e88' + def newRequest = { String prompt -> new AgentRunnerRequest( + model: 'openai/gpt-5-mini', prompt: prompt, requestTimeoutSeconds: 30, apiKey: secret) } + + when: 'two agent tasks connect over it' + def first = broker.register(newRequest('one'), false) + def f1 = connect(first.endpoint) + f1.send([type: 'connect', invocationId: first.invocationId, token: first.token]) + def s1 = f1.next() + def second = broker.register(newRequest('two'), false) + def f2 = connect(second.endpoint) + f2.send([type: 'connect', invocationId: second.invocationId, token: second.token]) + def s2 = f2.next() + + then: 'the escape hatch keeps working, it just carries no secret' + s1.type == 'start' + s1.spec.prompt == 'one' + !((Map) s1).containsKey('apiKey') + !((Map) s2).containsKey('apiKey') + !s1.toString().contains(secret) + + and: 'the run is told why, and how to deliver the credential out of band instead' + def withheld = warnings().findAll { it.contains('credential resolved by the driver is NOT sent') } + withheld.size() == 1 + withheld[0].contains('agent.rpc.tls') + withheld[0].contains('agent.containerOptions') + withheld[0].contains('secret') + + and: 'ONCE per broker, not once per task: the cause is the session setting, not the invocation' + // a wide fan-out would otherwise restate one fact hundreds of times over the run's real + // errors -- the same reasoning as the per-lapse warning cap + warnings().every { !it.contains(secret) } + } + + def 'should warn -- never fail -- when the driver withheld a resolved credential'() { + given: 'AgentConfig resolved a provider key and the endpoint gate refused to send it. The' + // langchain4j runner treats this as fatal: core is its ONLY credential source. pi is not -- + // it reads its own auth store and the provider variables present in the CONTAINER, which + // the driver cannot see and which is exactly how a Kubernetes Secret delivers a key. A + // driver-side error here would break a deployment that is working, so it is a WARN. + def newRequest = { String prompt -> new AgentRunnerRequest( + model: 'openai/gpt-4o', + prompt: prompt, + requestTimeoutSeconds: 30, + apiKey: null, + apiProvider: 'openai', + baseUrl: 'https://gw.corp/v1', + credentialWithheld: true) } + + when: 'two tasks of the same fan-out connect' + def first = broker.register(newRequest('one'), false) + def f1 = connect(first.endpoint) + f1.send([type: 'connect', invocationId: first.invocationId, token: first.token]) + def s1 = f1.next() + def second = broker.register(newRequest('two'), false) + def f2 = connect(second.endpoint) + f2.send([type: 'connect', invocationId: second.invocationId, token: second.token]) + def s2 = f2.next() + + then: 'the run is NOT aborted -- both agents start, and pi resolves for itself' + s1.type == 'start' + s2.type == 'start' + and: 'with no credential on the frame, and above all no placeholder' + !((Map) s1).containsKey('apiKey') + !((Map) s2).containsKey('apiKey') + !s1.toString().contains(AgentRunnerRequest.PLACEHOLDER_API_KEY) + + and: 'one warning names the model, the endpoint, the namespace and the two remedies' + def withheld = warnings().findAll { it.contains('was withheld from') } + withheld.size() == 1 + withheld[0].contains('openai/gpt-4o') + withheld[0].contains('https://gw.corp/v1') + withheld[0].contains('agent.apiKey') + withheld[0].contains('agent.apiProvider') + + and: 'said ONCE per model id, not once per task in the fan-out' + warnings().size() == 1 + } + + /** + * The address is resolved PER AGENT DEFINITION and rides on the request, so a run that mixes a + * local-docker agent with, say, a Kubernetes one must advertise each task ITS OWN address. Before + * this the broker held a single field and handed the first definition's address to every task, + * which is the plausible-but-unroutable failure the whole ladder exists to prevent -- and the + * cost of it is silent, because the mis-addressed task holds its capability for the full + * `agent.rpc.capabilityTimeout`. + */ + def 'each registration advertises the address resolved for ITS OWN agent'() { + given: + def alias = AgentRpcHost.of('host.docker.internal', 'docker host alias') + def inCluster = AgentRpcHost.of('10.42.0.9', 'inferred from default route (in-cluster driver)') + + when: 'the local-docker agent registers first' + def local = broker.register(remoteRequest(alias), true) + def pod = broker.register(remoteRequest(inCluster), true) + + then: 'neither address leaks into the other registration' + local.endpoint.startsWith('host.docker.internal:') + pod.endpoint.startsWith('10.42.0.9:') + } + + def 'the registration line carries the address and the ladder row that produced it'() { + given: 'the line is DEBUG -- the source label is a diagnostic, not an operator warning' + brokerLog.setLevel(Level.DEBUG) + + when: + broker.register(remoteRequest(AgentRpcHost.of('10.0.3.17', 'docker host alias')), true) + broker.register(remoteRequest(AgentRpcHost.of('driver.internal', 'agent.rpc.remoteHost')), true) + + then: 'an inferred address is indistinguishable from a configured one without this label' + debugMessages().any { it ==~ /.*advertising 10\.0\.3\.17:\d+ \(docker host alias\).*/ } + debugMessages().any { it ==~ /.*advertising driver\.internal:\d+ \(agent\.rpc\.remoteHost\).*/ } + } + + def 'an address the ladder is not certain about is warned about once per ADDRESS'() { + given: + def multiHomed = AgentRpcHost.of('10.1.2.3', 'inferred from default route', + ['the driver host is multi-homed: the default route selected `10.1.2.3`, but `192.168.40.7` also exist - set `agent.rpc.remoteHost`']) + + when: 'two tasks of the SAME agent register' + broker.register(remoteRequest(multiHomed), true) + broker.register(remoteRequest(multiHomed), true) + + then: 'the warning is a property of the address, so a fan-out must not repeat it per task' + def warned = warnings().findAll { it.contains('multi-homed') } + warned.size() == 1 + warned[0] ==~ /.*advertising 10\.1\.2\.3:\d+ - .*/ + warned[0].contains('192.168.40.7') + } + + def 'a registration that carries no address of its own falls back to the session-level one'() { + when: 'a runner that registers without going through the pre-ignition guard' + def registration = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), true) + + then: 'shipped behaviour: the local rows, keyed off the session\'s enabled engine' + registration.endpoint.startsWith('host.docker.internal:') + } + + private static AgentRunnerRequest remoteRequest(AgentRpcHost host) { + return new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30, brokerHost: host) + } + + /** The broker warnings emitted so far in this feature method. */ + private List warnings() { + return new ArrayList(logs.list).findAll { it.level == Level.WARN }*.formattedMessage + } + + /** @see #warnings */ + private List debugMessages() { + return new ArrayList(logs.list).findAll { it.level == Level.DEBUG }*.formattedMessage + } + + /** + * The pending pre-connect expiry of a capability that has not been consumed yet. + * + * Reaching into the broker is deliberate. The D1 regression is "a capability survives a queueing + * delay of many minutes", and the honest way to observe that without a suite that takes minutes is + * to read the deadline it is holding: nothing else removes an unconsumed capability, so an expiry + * that is not due for an hour IS the guarantee. + */ + private ScheduledFuture pendingExpiry(String invocationId) { + final field = AgentRpcBroker.getDeclaredField('invocations') + field.setAccessible(true) + final invocation = ((Map)field.get(broker)).get(invocationId) + assert invocation != null : "No pending capability for invocation ${invocationId}" + return (ScheduledFuture) invocation.expiry + } + + /** How many capability deadlines the broker is still holding, cancelled ones included. */ + private int expiryQueueSize() { + final field = AgentRpcBroker.getDeclaredField('expiryPool') + field.setAccessible(true) + return ((ScheduledThreadPoolExecutor) field.get(broker)).getQueue().size() + } + + /** How many capabilities are registered and still waiting for a connection. */ + private int pendingCount() { + final field = AgentRpcBroker.getDeclaredField('invocations') + field.setAccessible(true) + return ((Map) field.get(broker)).size() + } + + /** How many terminal outcomes the broker remembers, i.e. the size of the bounded record. */ + private int outcomeCount() { + final field = AgentRpcBroker.getDeclaredField('outcomes') + field.setAccessible(true) + return ((Map) field.get(broker)).size() + } + + /** + * Waits for every pending capability to leave the map, by polling rather than by sleeping for a + * guessed interval: the expiries run on the broker's own single-threaded pool, so how long a + * thousand of them take is a property of the machine, not of the test. + */ + private void awaitDrained() { + final deadline = System.currentTimeMillis() + 20_000 + while( pendingCount() > 0 && System.currentTimeMillis() < deadline ) + sleep(10) + assert pendingCount() == 0 : "Capabilities were still pending after 20s: ${pendingCount()}" + } + + def 'should advertise the driver host a containerized task can reach the broker on'() { + given: 'a session whose only container engine is the one under test' + def registration = brokerWith(config).register( + new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), true) + + expect: 'the ADVERTISED host, with the bound port - the server itself binds every interface' + registration.endpoint ==~ /\Q${host}\E:\d+/ + + where: 'the two engines that name their container host, and an explicit override' + config || host + [docker: [enabled: true]] || 'host.docker.internal' + [podman: [enabled: true]] || 'host.containers.internal' + [docker: [enabled: true], agent: [rpc: [remoteHost: 'driver.svc']]] || 'driver.svc' + [singularity: [enabled: true], agent: [rpc: [remoteHost: '127.0.0.1']]] || '127.0.0.1' + } + + def 'should refuse to advertise an unresolvable driver host'() { + given: 'a microVM created with no network at all, which no address can reach' + // singularity used to stand here, and no longer does: it creates no network namespace, so + // the ladder now answers it with the driver's own loopback (error row E2 is what is left) + brokerWith([smolvm: [enabled: true, network: false]]) + + // AgentDef rejects this configuration before the run starts, but the runner SPI is public, + // so the broker must not hand out an endpoint that reads literally `null:` + when: 'a remote registration is asked for anyway' + broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), true) + + then: 'the ladder row itself is raised -- it names the fact that decided and the remedy, both of which a generic "no address is configured" line would discard' + def e = thrown(IllegalStateException) + e.message.contains('smolvm.network') + + when: 'the same broker registers a driver-local task' + def local = broker.register(new AgentRunnerRequest(model: 'openai/test', requestTimeoutSeconds: 30), false) + + then: 'loopback needs no configuration' + local.endpoint ==~ /127\.0\.0\.1:\d+/ + } + + /** + * Replace the broker created by {@code setup()} with one built from the given session config, so + * a spec can pin how the advertised endpoint is resolved. The broker reads its config once, in + * its constructor, hence the rebuild. + */ + private AgentRpcBroker brokerWith(Map config) { + broker.close() + driverSession(config) + broker = AgentRpcBroker.get() + return broker + } + + /** Minimal blocking client over the broker's bidi JSON stream. */ + private Frames connect(String endpoint) { + final parts = endpoint.split(':') + final channel = channelFor(parts[0], parts[1] as int) + channels << channel + return framesOn(channel) + } + + private Frames framesOn(ManagedChannel channel) { + final inbound = new LinkedBlockingQueue() + final observer = ClientCalls.asyncBidiStreamingCall( + channel.newCall(CONNECT, io.grpc.CallOptions.DEFAULT), + new StreamObserver() { + @Override void onNext(String value) { inbound.put(new JsonSlurper().parseText(value)) } + @Override void onError(Throwable error) { inbound.put(error) } + @Override void onCompleted() { inbound.put('completed') } + }) + return new Frames(observer, inbound) + } + + /** + * Dials the broker the way the Go proxy does: no CA vouches for the per-run certificate, so the + * only trust root is that certificate itself. Falls back to cleartext when the broker was built + * with `agent.rpc.tls = false`, which is what the proxy's `--insecure` branch does. + */ + private ManagedChannel channelFor(String host, int port) { + final pem = broker.certificatePem + if( !pem ) + return ManagedChannelBuilder.forAddress(host, port).usePlaintext().build() + final credentials = TlsChannelCredentials.newBuilder() + .trustManager(new ByteArrayInputStream(pem.getBytes(StandardCharsets.US_ASCII))) + .build() + return Grpc.newChannelBuilderForAddress(host, port, credentials).build() + } + + /** The leaf certificate DER, i.e. the bytes both ends must hash to agree on a fingerprint. */ + private static byte[] servedCertificateDer(String pem) { + final body = pem + .replace('-----BEGIN CERTIFICATE-----', '') + .replace('-----END CERTIFICATE-----', '') + .replaceAll('\\s', '') + return Base64.decoder.decode(body) + } + + private static class Frames { + private final StreamObserver outbound + private final LinkedBlockingQueue inbound + + Frames(StreamObserver outbound, LinkedBlockingQueue inbound) { + this.outbound = outbound + this.inbound = inbound + } + + void send(Map message) { outbound.onNext(JsonOutput.toJson(message)) } + + Map next() { + final frame = take() + if( !(frame instanceof Map) ) + throw new AssertionError("Expected a broker frame but received: ${frame}" as Object) + return (Map)frame + } + + Throwable error() { + final frame = take() + return frame instanceof Throwable ? (Throwable)frame : null + } + + private Object take() { + final frame = inbound.poll(20, TimeUnit.SECONDS) + if( frame == null ) + throw new AssertionError('Timed out waiting for a broker frame' as Object) + return frame + } + } +} diff --git a/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcServedCertificateTest.groovy b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcServedCertificateTest.groovy new file mode 100644 index 0000000000..b58df12ca6 --- /dev/null +++ b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcServedCertificateTest.groovy @@ -0,0 +1,163 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import java.security.MessageDigest +import java.security.cert.Certificate +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +import nextflow.Global +import nextflow.Session +import nextflow.agent.AgentRunnerRequest +import spock.lang.Specification + +/** + * Closes the one loop that neither transport-security suite closes: that the certificate DER the + * server actually puts ON THE WIRE is byte-identical to the DER the advertised fingerprint was + * computed over. + * + *

Both existing suites compare against a same-language counterpart, which is why the gap + * survives them. {@code agent-rpc}'s {@code main_test.go} builds its OWN Go certificate and stands + * up a Go gRPC server, so it is a hand-maintained mirror of what {@link AgentRpcTlsCredentials} + * emits, and mirrors drift. {@code AgentRpcBrokerTest} does dial the real broker, but with a + * grpc-java client that installs the broker's PEM as a TRUST ANCHOR -- which validates by path + * building, so a certificate that was re-encoded on the way out yet stayed semantically identical + * would still be accepted there while hashing to a different digest. + * + *

That distinction is load-bearing because the driver never hands the task a certificate, only a + * digest of one. grpc-netty-shaded bundles netty-tcnative, so on the platforms it ships natives for + * -- CI included -- the PEM is re-parsed and re-serialized by BoringSSL before it reaches the + * socket, rather than the PEM body being echoed through. Reproducing identical bytes for DER input + * is what any conforming encoder does, so the risk is low; but if it ever were not so, BOTH existing + * suites stay green while every real agent task dies with + * {@code driver TLS certificate fingerprint mismatch} -- a message that reads like an attack and + * would be diagnosed like one. + * + *

The probe is a bare JSSE {@link SSLSocket} rather than a shell-out to {@code openssl s_client}: + * it always runs, so there is no skip to go stale; it parses no command output; and it introduces no + * second TLS implementation as a variable. The assertion is over {@code chain[0].encoded} from the + * PEER-RECEIVED certificate, i.e. bytes that came back from the server. Hashing + * {@code broker.certificatePem} instead would restate what the broker already believes and prove + * nothing new, which is why that accessor is deliberately untouched here. + * + *

What this does NOT close: the socket negotiates ALPN and then hangs up without ever sending an + * HTTP/2 preface, so no h2 framing and no gRPC call is exercised. The cross-LANGUAGE half of the + * handshake stays covered only by agent-rpc's Go tests against a Go server, and the h2-over-TLS half + * by {@code AgentRpcBrokerTest}'s grpc-java client. This file is the byte-identity half alone; + * retiring either of the others on the strength of it would lose real coverage. + */ +class AgentRpcServedCertificateTest extends Specification { + + /** + * How long the handshake may take before the spec fails instead of hanging the suite. Generous + * because it covers a loopback connect and one EC handshake, nothing more. + */ + private static final int HANDSHAKE_TIMEOUT_MILLIS = 20_000 + + AgentRpcBroker broker + + def setup() { + Global.session = new Session([:]) + broker = AgentRpcBroker.get() + } + + def cleanup() { + broker?.close() + Global.session = null + } + + def 'should serve on the wire the exact certificate bytes the advertised fingerprint pins'() { + given: 'a capability registered against the default broker, i.e. transport security on' + def registration = broker.register( + new AgentRunnerRequest(model: 'openai/test', prompt: 'confidential', requestTimeoutSeconds: 30), false) + + and: 'the broker really did mint a pin' + // Diagnostic, not the property under test, and an EXPLICIT assert because a bare condition in + // a setup block is only an expression -- Spock asserts implicitly in expect:/then: alone. + // AgentRpcBroker.get() returns a process-wide singleton and a sibling spec builds one with + // `tls: false`; if its cleanup ever regressed, this spec would inherit a cleartext broker and + // fail with an opaque `Unsupported or unrecognized SSL message` out of startHandshake() + // instead of naming the cause. + assert registration.fingerprint ==~ /[0-9a-f]{64}/ + + when: 'the certificate is taken off the socket, not out of the broker' + def chain = servedChain(registration.endpoint) + // computed here, not folded into the comparison, so a failure prints the two digests as the + // bare 64-hex strings agent-rpc itself reports on a pinning rejection + def presented = MessageDigest.getInstance('SHA-256').digest(chain[0].encoded).encodeHex().toString() + + then: 'the DER Netty wrote is byte for byte the DER the fingerprint was taken over' + // The whole pinning design rests on this equality and nothing else asserts it: the task is + // given a digest, never the certificate, so any re-encoding between + // `builder.build(signer).getEncoded()` and the socket makes the two ends disagree forever. + presented == registration.fingerprint + + and: 'the broker serves a single self-signed leaf' + // Not because a chain would break pinning -- it would not, the leaf stays rawCerts[0] and the + // digest still matches -- but because the broker deliberately serves one self-signed + // certificate with no issuer above it. A chain appearing here means the credential shape + // changed, and what the pin then commits to has to be re-examined rather than assumed. + chain.length == 1 + ((X509Certificate) chain[0]).subjectX500Principal == ((X509Certificate) chain[0]).issuerX500Principal + } + + /** + * The certificate chain as RECEIVED from the broker over a real TLS socket. + * + *

The trust manager accepts anything, which is the proxy's own posture -- {@code agent-rpc} + * sets {@code InsecureSkipVerify} and then decides by digest. Building a trust manager out of + * {@code broker.certificatePem} instead would turn this into the same path-building check + * {@code AgentRpcBrokerTest} already performs, i.e. exactly the comparison that cannot see a + * re-encoding. + * + *

Deliberately NOT wrapped in a try/catch: a handshake that fails here IS the failure this + * spec exists to report, and the only effect of catching it would be to keep the spec green + * after the transport changed underneath it. + */ + private static Certificate[] servedChain(String endpoint) { + final parts = endpoint.split(':') + final context = SSLContext.getInstance('TLS') + context.init(null, [ new X509TrustManager() { + @Override void checkClientTrusted(X509Certificate[] chain, String authType) {} + @Override void checkServerTrusted(X509Certificate[] chain, String authType) {} + @Override X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0] } + } ] as TrustManager[], null) + final socket = (SSLSocket) context.socketFactory.createSocket(parts[0], parts[1] as int) + try { + socket.setSoTimeout(HANDSHAKE_TIMEOUT_MILLIS) + // Offer h2 so the server runs the same ALPN selection it runs for the proxy. grpc's + // server config is SelectedListenerFailureBehavior.ACCEPT, so an ALPN-less client would + // also complete the handshake -- but then the negotiation production depends on would be + // the one path this probe skipped, and the connection would be torn down by grpc's + // "Failed protocol negotiation" branch instead of by the close below. + final params = socket.getSSLParameters() + params.setApplicationProtocols([ 'h2' ] as String[]) + socket.setSSLParameters(params) + socket.startHandshake() + return socket.session.peerCertificates + } + finally { + // No h2 preface is ever sent, so the server sees a connection that completed TLS and then + // went away. That is intentional: this spec is about the certificate bytes, and driving a + // gRPC call here would only re-test what AgentRpcBrokerTest already covers. + socket.close() + } + } +} diff --git a/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcTlsCredentialsTest.groovy b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcTlsCredentialsTest.groovy new file mode 100644 index 0000000000..161457d9b2 --- /dev/null +++ b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentRpcTlsCredentialsTest.groovy @@ -0,0 +1,86 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import java.security.MessageDigest +import java.security.Security +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +import spock.lang.Specification + +/** + * Guards the two properties of the broker's TLS identity that fail silently or globally if broken: + * the fingerprint is the digest of the certificate DER (the value the Go proxy compares), and + * generating it registers no JVM-wide JCA provider. + */ +class AgentRpcTlsCredentialsTest extends Specification { + + private static X509Certificate parse(String pem) { + return (X509Certificate) CertificateFactory.getInstance('X.509') + .generateCertificate(new ByteArrayInputStream(pem.bytes)) + } + + def 'should fingerprint the certificate DER as lowercase hex'() { + when: + def credentials = AgentRpcTlsCredentials.create() + def certificate = parse(credentials.certificatePem) + + then: 'the digest is over getEncoded() -- the DER -- not over the PEM text or its base64 body' + credentials.fingerprint ==~ /[0-9a-f]{64}/ + credentials.fingerprint == MessageDigest.getInstance('SHA-256') + .digest(certificate.encoded).encodeHex().toString() + + and: 'the PEM the digest was taken from is what a TLS stack will parse back' + credentials.certificateStream().text == credentials.certificatePem + credentials.privateKeyStream().text.startsWith('-----BEGIN PRIVATE KEY-----') + } + + def 'should generate a self-signed EC certificate usable as a server identity'() { + when: + def credentials = AgentRpcTlsCredentials.create() + def certificate = parse(credentials.certificatePem) + + then: 'EC P-256 and self-signed: the pin makes the issuer irrelevant' + certificate.publicKey.algorithm == 'EC' + certificate.sigAlgName == 'SHA256withECDSA' + certificate.subjectX500Principal == certificate.issuerX500Principal + certificate.subjectX500Principal.name.contains('nextflow-agent-rpc') + + and: 'valid now, with room for clock skew and a long pipeline' + certificate.checkValidity() + + and: 'loopback names, so a conventional TLS client can also verify it' + certificate.subjectAlternativeNames*.last() as Set == ['localhost', '127.0.0.1'] as Set + } + + def 'should mint a distinct identity per broker and touch no global JCA state'() { + given: + def before = Security.providers*.name + + when: + def first = AgentRpcTlsCredentials.create() + def second = AgentRpcTlsCredentials.create() + + then: 'a fresh key pair each time -- the identity is per run, never reused across runs' + first.fingerprint != second.fingerprint + first.certificatePem != second.certificatePem + + and: 'no Security.addProvider: a second plugin must not mutate the JVM provider list' + Security.providers*.name == before + Security.getProvider('BC') == null + } +} diff --git a/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentSecretMaskerTest.groovy b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentSecretMaskerTest.groovy new file mode 100644 index 0000000000..0c80051e4a --- /dev/null +++ b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/AgentSecretMaskerTest.groovy @@ -0,0 +1,95 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import nextflow.SysEnv +import spock.lang.Specification + +/** + * Pins design D9: redaction keys off the RESOLVED credential, not off an environment variable + * name. A key from `agent.apiKey` (typically `secrets.LLM_KEY`) has no variable name, so the + * name-driven sweep that predates this cannot find it. + */ +class AgentSecretMaskerTest extends Specification { + + def 'redacts a credential that has NO environment variable name'() { + given: 'the D9 case: the key came from `agent.apiKey`, so an env sweep cannot discover it' + SysEnv.push([:]) + and: 'and it is NOT OpenAI-shaped, so the `sk-` backstop pattern cannot catch it either' + def key = 'gateway-credential-4a71b2' + + expect: + AgentSecretMasker.redact("401 unauthorized for key ${key}".toString(), key) == + '401 unauthorized for key [REDACTED]' + + and: 'every occurrence goes, not just the first' + AgentSecretMasker.redact("${key} and again ${key}".toString(), key) == '[REDACTED] and again [REDACTED]' + + and: 'without the resolved key the same text leaks -- this is the regression being fixed' + AgentSecretMasker.redact("401 unauthorized for key ${key}".toString()) == + "401 unauthorized for key ${key}" + + cleanup: + SysEnv.pop() + } + + def 'sweeps the exported provider variables through SysEnv, not System.getenv'() { + given: 'the sweep is kept as a backstop for a key the user still exports' + SysEnv.push([OPENAI_API_KEY: 'sk-exported-01234567', ANTHROPIC_API_KEY: 'ant-exported-9999', NXF_AGENT_API_KEY: 'nxf-exported-8888']) + + expect: 'reading through SysEnv is what lets a test swap the environment at all' + AgentSecretMasker.redact('failed with ant-exported-9999') == 'failed with [REDACTED]' + AgentSecretMasker.redact('failed with nxf-exported-8888') == 'failed with [REDACTED]' + + and: 'an unrelated variable is left alone' + AgentSecretMasker.redact('failed at http://localhost:8000/v1') == 'failed at http://localhost:8000/v1' + + cleanup: + SysEnv.pop() + } + + def 'masks bearer headers and credential-shaped tokens the provider echoes back'() { + given: 'the last resort: a credential that is neither resolved nor exported here' + SysEnv.push([:]) + + expect: + AgentSecretMasker.redact('Authorization: Bearer abcd1234efgh') == 'Authorization: Bearer [REDACTED]' + AgentSecretMasker.redact('rejected sk-abcdef0123456789') == 'rejected [REDACTED]' + AgentSecretMasker.redact('rejected rk-abcdef0123456789') == 'rejected [REDACTED]' + AgentSecretMasker.redact('rejected pk-abcdef0123456789') == 'rejected [REDACTED]' + + and: 'a short `sk-` fragment is not a key and is left readable' + AgentSecretMasker.redact('sk-short') == 'sk-short' + + cleanup: + SysEnv.pop() + } + + def 'is null-safe and leaves clean text untouched'() { + given: + SysEnv.push([:]) + + expect: 'null in, null out -- callers pass optional message fields straight through' + AgentSecretMasker.redact(null) == null + AgentSecretMasker.redact(null, 'sk-whatever') == null + and: 'an empty resolved key must not turn every character boundary into [REDACTED]' + AgentSecretMasker.redact('provider refused request', '') == 'provider refused request' + AgentSecretMasker.redact('provider refused request', null) == 'provider refused request' + + cleanup: + SysEnv.pop() + } +} diff --git a/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentImageCoordinateTest.groovy b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentImageCoordinateTest.groovy new file mode 100644 index 0000000000..37c012ee7c --- /dev/null +++ b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentImageCoordinateTest.groovy @@ -0,0 +1,70 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import spock.lang.Specification + +/** + * Pins how the runner declares the image it needs: {@code getDefaultContainer()} is read out of + * {@code META-INF/nf-agent-pi-image.properties}, which the plugin build generates from + * {@code build-image.sh} and VERSION. + * + *

That the generated resource actually reaches the shipped artifacts is + * {@link PiAgentPackagingTest}'s job. This spec pins the other half: what the runner does when it + * is there, and what it does when it is NOT - it must degrade to "no declaration", so a jar built + * outside the Gradle build fails with core's "must declare a container" message rather than + * with an exception out of a static initialiser during PF4J's extension loading. + */ +class PiAgentImageCoordinateTest extends Specification { + + private static InputStream stream(String content) { + return new ByteArrayInputStream(content.getBytes('ISO-8859-1')) + } + + def 'the runner declares the generated coordinate as its default container'() { + expect: 'the resource the plugin build generates is on the runtime classpath' + PiAgentRunner.getResourceAsStream(PiAgentRunner.IMAGE_RESOURCE) != null + + and: 'and the runner hands it out, so a pi agent needs no explicit `agent.container`' + new PiAgentRunner().getDefaultContainer() ==~ $/\S+/nf-agent-pi:\S+/$ + } + + def 'a missing resource declares no container instead of throwing'() { + given: 'the real lookup for a resource that is genuinely absent, not a stubbed stream' + final absent = PiAgentRunner.getResourceAsStream('/META-INF/nf-agent-pi-image-absent.properties') + + expect: 'the classpath really has nothing there, so the null below is the case under test' + absent == null + + and: 'which reads as "this runner declares no image", the shape a stale local jar has' + PiAgentRunner.parseImageCoordinate(absent) == null + } + + def 'an unusable resource also reads as no declaration'() { + expect: + PiAgentRunner.parseImageCoordinate(stream(content)) == expected + + where: + content || expected + 'image=reg.example.io/ns/nf-agent-pi:1.2.3' || 'reg.example.io/ns/nf-agent-pi:1.2.3' + // the value keeps its colons and slashes: load() splits on the FIRST separator, the `=` + 'image= reg.example.io/ns/x:1.2.3 ' || 'reg.example.io/ns/x:1.2.3' + 'image=' || null + 'image= ' || null + '# generated header only, no image key' || null + '' || null + } +} diff --git a/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentLaunchSpecTest.groovy b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentLaunchSpecTest.groovy new file mode 100644 index 0000000000..0d53116320 --- /dev/null +++ b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentLaunchSpecTest.groovy @@ -0,0 +1,59 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import nextflow.agent.AgentLaunchSpec +import nextflow.agent.AgentRunnerRequest +import spock.lang.Specification + +/** + * Pins the LIVE production path: {@code getLaunchSpec()} is what an agent task actually execs. + */ +class PiAgentLaunchSpecTest extends Specification { + + def 'the launch spec describes the in-image command only'() { + given: + def spec = new PiAgentRunner().getLaunchSpec() + + expect: 'the proxy, then the packaged harness after the separator, at their IN-IMAGE paths' + // these must match the paths the runner image is built with (see the plugin Dockerfile); + // a canonical agent task always runs in that image, so there is no driver-local variant + spec.command() == ['/usr/local/bin/agent-rpc', '--', 'node', '/opt/nf-agent-pi/runner.mjs'] + } + + def 'the runner has no driver-local execution path'() { + when: 'the deleted stdio route is invoked' + new PiAgentRunner().run(new AgentRunnerRequest(prompt: 'Q')) + + then: 'it fails loudly instead of half-working, since the runtime lives in the image' + def e = thrown(UnsupportedOperationException) + e.message.contains('container task') + } + + def 'proxy arguments are composed without parsing the command separator'() { + given: + def spec = new AgentLaunchSpec( + ['launcher', '--', 'agent-rpc'], + ['node', '/runner.mjs']) + + expect: 'an existing proxy argument named `--` is not mistaken for the harness separator' + spec.command(['--endpoint', 'driver:1234']) == [ + 'launcher', '--', 'agent-rpc', + '--endpoint', 'driver:1234', + '--', + 'node', '/runner.mjs' ] + } +} diff --git a/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentPackagingTest.groovy b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentPackagingTest.groovy new file mode 100644 index 0000000000..834007e3e3 --- /dev/null +++ b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiAgentPackagingTest.groovy @@ -0,0 +1,199 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import java.util.zip.ZipFile + +import spock.lang.Specification + +/** + * Pins the plugin's DISTRIBUTION shape: the agent proxy and the Node harness are shipped in the + * runner container image, so neither the jar nor the distribution zip carries them. Re-vendoring + * them -- the 186 MB, host-arch-only artifact this replaced -- fails here rather than at release + * time. + * + *

Also pins the seam between the two halves of that split: the in-image paths + * {@link PiAgentRunner#getLaunchSpec} emits are only correct because the Dockerfile puts the proxy + * and the harness exactly there. Nothing else couples the two files, and a drift is a `No such + * file` inside the container on every agent task. + */ +class PiAgentPackagingTest extends Specification { + + /** + * The generated image coordinate as an ARCHIVE ENTRY name - {@link PiAgentRunner#IMAGE_RESOURCE} + * names it the way a classpath lookup does, with a leading slash. Derived from that constant so + * renaming the resource cannot leave this spec asserting on a name nothing reads. + */ + private static final String IMAGE_RESOURCE = PiAgentRunner.IMAGE_RESOURCE.substring(1) + + /** An artifact Gradle just built; its path is handed to the test task (see build.gradle). */ + private static File artifact(String property) { + final path = System.getProperty(property) + assert path, "${property} is unset - run this spec through Gradle (:plugins:nf-agent-pi:test)" + final file = new File(path) + assert file.exists(), "the artifact was not built: ${file}" + return file + } + + private static List entriesOf(File archive) { + return new ZipFile(archive).withCloseable { ZipFile zip -> zip.entries().toList().collect { it.name } } + } + + def 'the plugin jar ships no vendored runtime'() { + given: + final jar = artifact('nf.agent.pi.jar') + + when: + final entries = entriesOf(jar) + + then: 'it IS the plugin jar - asserted first, because an empty jar would satisfy everything below' + entries.contains('nextflow/agent/pi/PiAgentRunner.class') + entries.contains('nextflow/agent/pi/AgentRpcBroker.class') + + and: 'nothing under the prefix the deleted PiRuntime used to extract to a temp dir' + entries.every { !it.startsWith('pi-runtime/') } + + and: 'each of these was a defect of its own: the 173 MB Node tree, the arch-specific Go proxy, the harness' + entries.every { !it.contains('node_modules') } + entries.every { !(it == 'agent-rpc' || it.endsWith('/agent-rpc')) } + entries.every { !it.endsWith('runner.mjs') } + + and: 'the generated image coordinate DOES ship - without it the runner declares no container' + entries.contains(IMAGE_RESOURCE) + + and: 'so the jar is Groovy classes plus the plugin metadata - measured ~37 KB' + jar.length() < 128 * 1024 + } + + def 'the distribution zip ships no vendored runtime either'() { + given: 'the artifact a user actually downloads, not just the jar inside it' + final zip = artifact('nf.agent.pi.zip') + + when: + final entries = entriesOf(zip) + + then: 'it IS the plugin distribution' + entries.any { it.endsWith('nextflow/agent/pi/PiAgentRunner.class') } + + and: 'the generated image coordinate reached the artifact a user installs, not just the jar' + // asserted here as well as on the jar for the reason this file already gives in reverse: + // the two artifacts are assembled by different tasks, so a resource can reach one alone + entries.any { it.endsWith(IMAGE_RESOURCE) } + + and: 'no runtime re-vendored through packagePlugin, which a jar-only assertion would miss' + entries.every { !it.contains('node_modules') } + entries.every { !it.contains('pi-runtime/') } + entries.every { !it.endsWith('/agent-rpc') } + entries.every { !it.endsWith('runner.mjs') } + + and: 'BouncyCastle actually SHIPS -- the broker builds its per-run TLS identity with it, and weakening `api` to `compileOnly` in build.gradle would pass every other assertion here and then fail as NoClassDefFoundError on the first agent task of every run' + libJars(entries).containsAll(['bcprov-jdk18on', 'bcpkix-jdk18on']) + + and: 'and the runtime is pinned by IDENTITY, not by weight' + libJars(entries) == EXPECTED_LIB_JARS + } + + /** + * The dependency jars the distribution is expected to carry, versions stripped. + * + *

This replaces a byte ceiling, which had quietly stopped guarding anything: it was written + * when the only weight was the gRPC transport, then BouncyCastle added ~10.8 MB and left ~1.5 MB + * of headroom under a 24 MB bound. A ceiling degrades silently as things slip under it, and it + * answers the wrong question -- a re-vendored runtime is a defect at ANY size, while a bcprov + * patch bump is harmless and would have failed the build with a message reading like a + * re-vendoring regression. + * + *

Identity does not degrade. Versions are stripped so an ordinary bump is invisible here; + * only a jar APPEARING or DISAPPEARING fails, which is exactly the change that deserves a human + * decision. If you added a dependency on purpose, add it here in the same commit. + */ + private static final Set EXPECTED_LIB_JARS = [ + // the broker's TLS identity -- see AgentRpcTlsCredentials + 'bcpkix-jdk18on', 'bcprov-jdk18on', 'bcutil-jdk18on', + // the agent RPC transport + 'grpc-api', 'grpc-context', 'grpc-core', 'grpc-netty-shaded', 'grpc-stub', 'grpc-util', + 'perfmark-api', 'gson', 'guava', 'failureaccess', 'listenablefuture', + // annotation-only artifacts the above drag in + 'animal-sniffer-annotations', 'annotations', 'checker-qual', 'error_prone_annotations', + 'j2objc-annotations', 'jsr305', + ] as Set + + /** + * Artifact names of the jars under {@code lib/}, with the trailing {@code -} removed. + * The pattern anchors on the first hyphen followed by a DIGIT, so `grpc-netty-shaded-1.75.0.jar` + * and `listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar` both reduce to their + * artifact name rather than being truncated at the first hyphen. + */ + private static Set libJars(List entries) { + return entries + .findAll { it.startsWith('lib/') && it.endsWith('.jar') } + .collect { it.substring('lib/'.length()).replaceFirst(/-\d[^\/]*\.jar$/, '') } as Set + } + + def 'the launch spec paths are the paths the runner image is built with'() { + given: + final spec = new PiAgentRunner().getLaunchSpec() + final dockerfile = artifact('nf.agent.pi.dockerfile').readLines() + + when: 'the destinations the image build puts the proxy and the harness at' + final workDir = dockerfile.findAll { it.trim().startsWith('WORKDIR ') }.last().trim() - 'WORKDIR ' + final copied = dockerfile + .findAll { it.trim().startsWith('COPY ') } + .collectEntries { line -> + final args = line.trim().split(/\s+/).findAll { !it.startsWith('--') && it != 'COPY' } + [ (args[-2]): resolveIn(workDir, args[-1]) ] + } + + then: 'the proxy binary the launch command execs' + spec.containerProxyCommand == [ copied['/agent-rpc'] ] + + and: 'the harness the proxy runs after the `--` separator' + spec.containerHarnessCommand == [ 'node', copied['harness/runner.mjs'] ] + } + + def 'the declared image is the image build-image.sh publishes'() { + given: + final declared = new PiAgentRunner().getDefaultContainer() + final script = artifact('nf.agent.pi.buildscript') + + when: 'ask the publishing script itself, which needs no docker for `ref`' + // NF_AGENT_PI_REGISTRY is a supported override (build-image.sh), and Groovy's no-arg + // .execute() inherits the JVM environment, so a developer or a CI runner with it exported + // would get a red test claiming the jar's coordinate is wrong. Hand the child an + // environment with that one variable removed: what the jar embeds is DEFAULT_REGISTRY. + final env = System.getenv() + .findAll { k, v -> k != 'NF_AGENT_PI_REGISTRY' } + .collect { k, v -> "$k=$v" as String } + final proc = [ 'bash', script.absolutePath, 'ref' ].execute(env, null) + final out = new StringBuffer(), err = new StringBuffer() + // drained rather than read through proc.text: the script writes diagnostics to stderr, + // and an unread stderr pipe is a deadlock waiting for a longer message + proc.waitForProcessOutput(out, err) + + then: 'the two compositions - Gradle reading the script, and the script itself - agree' + proc.exitValue() == 0 + declared + declared == out.toString().trim() + } + + /** Resolve a Dockerfile COPY destination, which may be relative to the current WORKDIR. */ + private static String resolveIn(String workDir, String destination) { + if( destination.startsWith('/') ) + return destination + final relative = destination.startsWith('./') ? destination.substring(2) : destination + return "${workDir}/${relative}".toString() + } +} diff --git a/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiHarnessProtocolTest.groovy b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiHarnessProtocolTest.groovy new file mode 100644 index 0000000000..dd68b317d0 --- /dev/null +++ b/plugins/nf-agent-pi/src/test/nextflow/agent/pi/PiHarnessProtocolTest.groovy @@ -0,0 +1,505 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent.pi + +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import nextflow.agent.AgentProtocolSpec +import nextflow.agent.AgentRunnerRequest +import spock.lang.Requires +import spock.lang.Specification +import spock.lang.TempDir +import spock.lang.Timeout + +/** + * Drives the REAL {@code harness/runner.mjs} over its JSONL protocol, in the role the + * {@code agent-rpc} proxy plays inside the runner image. + * + *

The harness is the largest piece of the runtime that moved out of the plugin jar and into + * the container image, and the image is not built by this project's Gradle build -- so without + * this spec nothing in the build exercises it at all. The Pi SDK it imports is replaced by a + * scripted stub installed into a throwaway {@code node_modules} (see + * {@code src/testResources/harness/pi-coding-agent-stub.mjs}), which keeps the build free of + * {@code npm}, of the network and of provider credentials while still running the harness's own + * code: its framing, its tool brokering, its structured-output contract and the session it builds. + * + *

The stub ships no builtin tools, and that is faithful: a runner-native tool is the SDK's, not + * the harness's, so what the harness controls -- and all these specs can therefore assert -- is the + * {@code tools:} ALLOWLIST it hands to {@code createAgentSession}. + * + *

What a stub cannot catch is a breaking change in the real + * {@code @earendil-works/pi-coding-agent}: it pins the API SURFACE the harness depends on (the + * factory names, the session event shapes, the tool result contract), so a rename on OUR side + * fails here, while a rename on THEIRS surfaces when the image is built against a new pinned + * version. Running the SDK for real needs {@code npm} and provider credentials, which is exactly + * what moving the runtime into the image took out of this build. + * + *

Skipped when {@code node} is not on PATH; the image build is what actually needs it. + */ +@Timeout(value = 60, unit = TimeUnit.SECONDS) +@Requires({ PiHarnessProtocolTest.nodeAvailable() }) +class PiHarnessProtocolTest extends Specification { + + /** The invocation identity the proxy stamps on every frame in both directions. */ + private static final String INVOCATION = 'inv-1' + + @TempDir Path folder + + private Harness harness + private Path workDir + + static boolean nodeAvailable() { + try { + return new ProcessBuilder('node', '--version') + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + .waitFor() == 0 + } + catch( Exception e ) { + return false + } + } + + def cleanup() { + harness?.close() + } + + def 'should announce the protocol version before anything else'() { + when: 'the harness is started, as the proxy starts it' + harness = start() + + then: 'the ready frame the proxy waits for (agent-rpc/main.go) comes first, unprompted' + harness.next() == [type: 'ready', protocolVersion: 2] + } + + def 'should reject a start frame it cannot act on'() { + given: + harness = start() + harness.next() + + when: + harness.send([type: 'start', protocolVersion: version, invocationId: INVOCATION, spec: specOf(spec)]) + final frame = harness.nextFrame() + + then: 'the error names the invocation, so the driver can attribute it' + frame.type == 'error' + frame.invocationId == INVOCATION + frame.message.contains(reason) + + where: 'the current version is 2, so 1 is an OLD driver and 3 a newer one - both refused' + version | spec || reason + 1 | [:] || 'Unsupported protocol version' + 3 | [:] || 'Unsupported protocol version' + 2 | [model: 'gpt-4o'] || 'Invalid model identifier' + 2 | [model: 'openai/no-such-model'] || 'Unknown Pi model' + } + + def 'should report a malformed protocol line instead of dying on it'() { + given: + harness = start() + harness.next() + + when: 'a line the host never should have written' + harness.sendRaw('{ this is not json') + final frame = harness.nextFrame() + + then: + frame.type == 'error' + frame.code == 'invalid_json' + } + + def 'should complete with the assistant text, over a stdout that carries protocol frames only'() { + given: 'a model that answers in one turn' + harness = start([[[text: 'the capital is Paris']]]) + harness.next() + + when: + harness.send(startFrame(specOf())) + final frame = harness.nextFrame() + + // the stub logs through console.log while the run is in flight; the harness routes every + // diagnostic channel to stderr, which is why each line read here parses as a frame + then: + frame == [ + type: 'complete', + invocationId: INVOCATION, + output: 'the capital is Paris', + resolvedModel: 'openai/gpt-4o' ] + } + + def 'should broker a declared tool to the host and feed the result back to the model'() { + given: 'the model calls the tool, then answers with what the tool returned' + harness = start([[[tool: 'word_stats', args: [path: 'reads.txt'], echo: true]]]) + harness.next() + + when: + harness.send(startFrame(specOf(toolSpecs: [toolSpec('word_stats')]))) + final call = harness.nextFrame() + + then: 'a Nextflow tool is a process on the DRIVER - the harness asks the host to run it' + call.type == 'tool_call' + call.invocationId == INVOCATION + call.name == 'word_stats' + call.arguments == [path: 'reads.txt'] + and: 'and traces the dispatch for `-with-agent-trace`' + harness.traces*.subMap(['event', 'name']) == [[event: 'tool_start', name: 'word_stats']] + + when: 'the host replies with the process output' + harness.send([type: 'tool_result', invocationId: INVOCATION, callId: call.callId, result: '{"words":42}']) + final done = harness.nextFrame() + + then: 'the model saw the result - it is echoed back as the answer' + done.type == 'complete' + done.output == '{"words":42}' + and: + harness.traces*.event == ['tool_start', 'tool_end'] + } + + def 'should refuse a tool result stamped with another invocation'() { + given: + harness = start([[[tool: 'word_stats', args: [:]]]]) + harness.next() + harness.send(startFrame(specOf(toolSpecs: [toolSpec('word_stats')]))) + final call = harness.nextFrame() + + when: 'a result arrives for a different invocation than the one in flight' + harness.send([type: 'tool_result', invocationId: 'someone-else', callId: call.callId, result: 'x']) + final frame = harness.nextFrame() + + then: + frame.type == 'error' + frame.message.contains('Mismatched invocationId') + } + + def 'should terminate on final_answer and return its arguments as the output'() { + given: 'structured output is requested, and the model calls the schema-bound tool' + harness = start([[[tool: 'final_answer', args: [capital: 'Paris', country: 'France']]]]) + harness.next() + + when: + harness.send(startFrame(specOf(outputSchema: CAPITAL_SCHEMA))) + final frame = harness.nextFrame() + + then: + frame.type == 'complete' + new JsonSlurper().parseText(frame.output as String) == [capital: 'Paris', country: 'France'] + } + + def 'should take one corrective turn when the model answers structured output with prose'() { + given: 'turn one is ordinary text; turn two is the re-prompt the harness issues' + harness = start([ + [[text: 'The capital of France is Paris.']], + [[tool: 'final_answer', args: [capital: 'Paris', country: 'France']]] ]) + harness.next() + + when: + harness.send(startFrame(specOf(outputSchema: CAPITAL_SCHEMA))) + final frame = harness.nextFrame() + + then: 'the prose is discarded - only the schema-bound arguments are the output' + frame.type == 'complete' + new JsonSlurper().parseText(frame.output as String) == [capital: 'Paris', country: 'France'] + } + + def 'should attribute an empty answer to the provider failure that caused it'() { + given: 'the SDK reports a failed model call as an assistant message with no content' + harness = start([[[providerError: 'HTTP 500 (request id req_abc123)']]]) + harness.next() + + when: + harness.send(startFrame(specOf())) + final frame = harness.nextFrame() + + then: 'an exhausted retry chain is not misreported as the model declining to answer' + frame.type == 'error' + frame.message.contains('Pi returned no final assistant text') + frame.message.contains('req_abc123') + } + + // --- the runner split: brokered descriptors vs runner-native names + + def 'should broker EVERY declared descriptor, whatever it is named'() { + given: 'a tool named exactly like the capability the harness used to serve in-process' + harness = start([[[tool: 'filesystem', args: [path: 'reads.txt'], echo: true]]]) + harness.next() + + when: + harness.send(startFrame(specOf(toolSpecs: [toolSpec('filesystem')]))) + final call = harness.nextFrame() + + then: 'there is no local branch left to fall into - a descriptor means the DRIVER owns it' + call.type == 'tool_call' + call.name == 'filesystem' + + when: + harness.send([type: 'tool_result', invocationId: INVOCATION, callId: call.callId, result: 'from the driver']) + final done = harness.nextFrame() + + then: + done.type == 'complete' + done.output == 'from the driver' + } + + def 'should enable the runner-native tools through the session allowlist, beside the brokered ones'() { + given: 'the answer is the session the harness built' + harness = start([[[runtimeState: true]]]) + harness.next() + + when: 'the agent selected one `nf:module_run` tool and four `fs:`/`shell:` leaves' + harness.send(startFrame(specOf( + toolSpecs: [toolSpec('word_stats')], + nativeToolNames: ['read', 'write', 'grep', 'bash'] ))) + final session = new JsonSlurper().parseText(harness.nextFrame().output as String).session + + then: 'a native name enters the ALLOWLIST, which is how an SDK builtin is turned on;' + // it is never a customTool, because the runner - not this harness - implements it + session.tools == ['word_stats', 'read', 'write', 'grep', 'bash'] + session.customTools == ['word_stats'] + + and: 'the allowlist is the whole gate, so `noTools` is gone rather than contradicting it' + session.noTools == null + + and: 'the builtins are rooted at the task work dir - that is the pi-side sandbox' + session.cwd == workDir.toString() + } + + def 'should enable nothing at all when the agent declared no tools'() { + given: + harness = start([[[runtimeState: true]]]) + harness.next() + + when: 'G7: no `tools` directive, hence no descriptors and no native names' + harness.send(startFrame(specOf())) + final session = new JsonSlurper().parseText(harness.nextFrame().output as String).session + + then: 'an EMPTY allowlist, not an absent one - pi would otherwise enable its four defaults' + session.tools == [] + } + + def 'should stop a runaway agent at maxIterations'() { + given: 'two tool turns against a budget of one' + harness = start([[ + [tool: 'word_stats', args: [:]], + [tool: 'word_stats', args: [:]] ]]) + harness.next() + + when: + harness.send(startFrame(specOf(maxIterations: 1, toolSpecs: [toolSpec('word_stats')]))) + final call = harness.nextFrame() + harness.send([type: 'tool_result', invocationId: INVOCATION, callId: call.callId, result: 'ok']) + final frame = harness.nextFrame() + + then: 'the budget is counted on this side, so a second turn never reaches the host' + frame.type == 'error' + frame.message.contains('exceeded the maximum number of tool-call iterations (1)') + } + + def 'should retarget the provider catalog at the endpoint resolved by the driver'() { + given: 'the answer is whatever the harness asked of the ModelRuntime' + harness = start([[[runtimeState: true]]]) + harness.next() + + when: '`agent.baseUrl` resolved to a compatible endpoint and travelled inside the spec' + harness.send(startFrame(specOf(baseUrl: 'http://localhost:8000/v1'))) + final frame = harness.nextFrame() + + then: 'registerProvider rewrites the endpoint of the catalog, which is the only seam the' + // SDK sanctions -- the endpoint must NOT be spread onto the resolved model, because the + // catalog re-resolves Model instances by id and would drop it + new JsonSlurper().parseText(frame.output as String).providers == [openai: [baseUrl: 'http://localhost:8000/v1']] + } + + def 'should install no credential from the frame AgentRpcBroker actually builds'() { + given: + harness = start([[[runtimeState: true]]]) + harness.next() + + when: 'an endpoint, and deliberately no credential: a runtime key OWNS its provider, so' + // one pushed from the driver would shadow exactly the credential the `env` scope or + // `secret` directive delivered to this container. Pi is left to resolve its own. + harness.send(startFrame(specOf(baseUrl: 'http://localhost:8000/v1'))) + final frame = harness.nextFrame() + + then: + new JsonSlurper().parseText(frame.output as String).apiKeys == [:] + } + + def 'should install a credential the start frame carries beside the spec'() { + given: 'the receiving half, kept live for a sender that can establish the credential is' + // for THIS provider -- see the send-site comment in AgentRpcBroker + harness = start([[[runtimeState: true]]]) + harness.next() + + when: 'the credential travels BESIDE the portable spec, never inside it' + harness.send(startFrame(specOf()) + [apiKey: 'gateway-credential-4a71b2']) + final frame = harness.nextFrame() + + then: 'it is installed in memory for this process, never in its environment' + new JsonSlurper().parseText(frame.output as String).apiKeys == [openai: 'gateway-credential-4a71b2'] + + and: 'and the payload that crosses the wire never carried it' + !specOf().containsKey('apiKey') + } + + def 'should report an unexpected EOF when the host closes the protocol input'() { + given: + harness = start() + harness.next() + + when: 'the proxy dies, or the driver goes away, before the run finishes' + harness.closeInput() + final frame = harness.nextFrame() + + then: 'the container does not exit silently with no answer and no reason' + frame.type == 'error' + frame.code == 'unexpected_eof' + } + + // --- fixtures + + private static final Map CAPITAL_SCHEMA = [ + type: 'object', + properties: [capital: [type: 'string'], country: [type: 'string']], + required: ['capital', 'country'], + additionalProperties: false ] + + private static Map toolSpec(String name) { + return [ + name: name, + description: "Nextflow tool ${name}".toString(), + inputSchema: [type: 'object', properties: [:], additionalProperties: true] ] + } + + /** + * The request payload the broker sends, built by the SAME core class the broker uses + * ({@link AgentProtocolSpec}), so a spec field renamed there fails here rather than in the + * container. + */ + private Map specOf(Map overrides = [:]) { + final defaults = [ + model: 'openai/gpt-4o', + prompt: 'What is the capital of France?', + maxIterations: 10, + workDir: workDir.toString() ] + return AgentProtocolSpec.fromRequest(new AgentRunnerRequest(defaults + overrides)) + } + + private static Map startFrame(Map spec) { + return [type: 'start', protocolVersion: 2, invocationId: INVOCATION, spec: spec] + } + + /** + * Start the real harness against the stub SDK. Both are COPIED into a throwaway directory: + * Node resolves a bare import by walking up from the importing file, so running the harness + * where it lives would pick up a developer's `npm ci` tree instead of the stub -- and the + * point of this build is that no such tree has to exist. + */ + private Harness start(List plan = []) { + final sandbox = Files.createDirectories(folder.resolve('sandbox')) + workDir = Files.createDirectories(folder.resolve('work')) + Files.copy(harnessSource(), sandbox.resolve('runner.mjs')) + final module = Files.createDirectories(sandbox.resolve('node_modules/@earendil-works/pi-coding-agent')) + module.resolve('package.json').text = JsonOutput.toJson([ + name: '@earendil-works/pi-coding-agent', + version: '0.0.0-stub', + type: 'module', + exports: './index.mjs' ]) + module.resolve('index.mjs').text = stubSource() + return new Harness(sandbox, JsonOutput.toJson(plan)) + } + + /** The harness under test, handed over by the Gradle test task -- see build.gradle. */ + private static Path harnessSource() { + final path = System.getProperty('nf.agent.pi.harness') + assert path, 'nf.agent.pi.harness is unset - run this spec through Gradle (:plugins:nf-agent-pi:test)' + final file = Path.of(path) + assert Files.exists(file), "the harness was not found: ${file}" + return file + } + + private static String stubSource() { + final url = PiHarnessProtocolTest.getResource('/harness/pi-coding-agent-stub.mjs') + assert url, 'the Pi SDK stub is missing from the test resources' + return url.text + } + + /** + * The proxy's half of the protocol: JSON frames in on stdin, JSON frames out on stdout, one + * per line. Mirrors what agent-rpc/main.go does between the driver's gRPC stream and the + * harness process. + */ + private static class Harness implements Closeable { + + private final Process process + private final BufferedReader output + private final Writer input + + /** Trace frames seen so far; they interleave with the frames a spec asserts on. */ + final List traces = [] + + Harness(Path dir, String plan) { + final builder = new ProcessBuilder('node', 'runner.mjs') + .directory(dir.toFile()) + .redirectError(ProcessBuilder.Redirect.INHERIT) + builder.environment().put('PI_STUB_PLAN', plan) + process = builder.start() + output = new BufferedReader(new InputStreamReader(process.getInputStream(), 'UTF-8')) + input = new OutputStreamWriter(process.getOutputStream(), 'UTF-8') + } + + void send(Map frame) { + sendRaw(JsonOutput.toJson(frame)) + } + + void sendRaw(String line) { + input.write(line) + input.write('\n') + input.flush() + } + + void closeInput() { + input.close() + } + + /** The next frame, or {@code null} at end of stream. */ + Map next() { + final line = output.readLine() + return line != null ? new JsonSlurper().parseText(line) as Map : null + } + + /** The next non-trace frame; traces are collected into {@link #traces} on the way. */ + Map nextFrame() { + Map frame = next() + while( frame?.type == 'trace' ) { + traces.add(frame) + frame = next() + } + return frame + } + + @Override + void close() { + try { input.close() } catch( IOException e ) { /* already closed */ } + process.destroy() + process.waitFor(10, TimeUnit.SECONDS) + } + } +} diff --git a/plugins/nf-agent-pi/src/testResources/harness/pi-coding-agent-stub.mjs b/plugins/nf-agent-pi/src/testResources/harness/pi-coding-agent-stub.mjs new file mode 100644 index 0000000000..b66b29966c --- /dev/null +++ b/plugins/nf-agent-pi/src/testResources/harness/pi-coding-agent-stub.mjs @@ -0,0 +1,146 @@ +// Copyright 2013-2026, Seqera Labs +// SPDX-License-Identifier: Apache-2.0 +// +// Test double for `@earendil-works/pi-coding-agent`, the only package harness/runner.mjs +// imports. PiHarnessProtocolTest installs it into a throwaway `node_modules` beside a copy of +// the harness, so the REAL harness can be driven end to end with no `npm ci`, no network and +// no provider credentials -- the runtime now lives in the container image, and the Gradle +// build must not need npm to test it. +// +// The scripted model behaviour comes from PI_STUB_PLAN: a JSON array of TURNS, one consumed +// per `session.prompt()` (a second turn is the harness's corrective re-prompt). Each turn is +// an array of steps: +// +// {"text": "..."} emit assistant text +// {"tool": "", "args": {...}} call a tool the harness registered +// {"tool": ..., "echo": true} ... and make its result the assistant's answer +// {"providerError": "..."} a failed model call: an assistant message carrying an +// errorMessage and NO content, as the SDK reports it +// {"runtimeState": true} answer with the ModelRuntime calls the harness made and +// the session options it built, so a spec can assert on +// provider retargeting, credentials and the tool allowlist + +import process from "node:process"; + +const turns = JSON.parse(process.env.PI_STUB_PLAN ?? "[]"); + +/** + * What the harness asked of the ModelRuntime before the session started, plus the tool selection + * it handed to createAgentSession. Recorded rather than acted on: these are how an endpoint, a + * credential and the enabled tool set reach the model, and none of them is observable from the + * protocol frames alone. + * + * `session.tools` is the SDK's ALLOWLIST -- the runner-native half of the agent's tools is enabled + * by naming builtins in it, so it is the only place a spec can see that half at all (a builtin has + * no descriptor and never appears as a customTool). + */ +const runtimeState = { providers: {}, apiKeys: {}, session: {} }; + +/** The SDK returns the definition enriched; the harness only ever reads `.name` off it. */ +export function defineTool(definition) { + return definition; +} + +export class ModelRuntime { + static async create() { + return new ModelRuntime(); + } + /** Retargets the catalog of a provider; with no `models` it only rewrites the endpoint. */ + registerProvider(provider, options) { + runtimeState.providers[provider] = options; + } + /** An in-memory credential that OWNS the provider, taking precedence over the auth store. */ + async setRuntimeApiKey(provider, apiKey) { + runtimeState.apiKeys[provider] = apiKey; + } + getModel(provider, id) { + return id === "no-such-model" ? null : { provider, id }; + } +} + +export class DefaultResourceLoader { + constructor(options) { + this.options = options; + } + async reload() { + // exercise the overrides the harness passes, so a rename is not silently ignored + this.systemPrompt = this.options.systemPromptOverride(); + this.appended = this.options.appendSystemPromptOverride(); + } +} + +export const SessionManager = { + inMemory: (cwd) => ({ cwd }), +}; + +export async function createAgentSession(options) { + const { customTools } = options; + // The harness redirects console.log to stderr before it gets here, so a chatty SDK cannot + // corrupt the JSONL protocol channel. If that redirect ever goes away this line lands on + // stdout and every spec fails to parse a frame -- which is the point. + console.log("pi stub: this console.log must not reach stdout"); + + runtimeState.session = { + cwd: options.cwd, + tools: options.tools, + // recorded so a spec can assert on its ABSENCE: the allowlist wins over it in the real SDK + noTools: options.noTools ?? null, + customTools: customTools.map((tool) => tool.name), + }; + const tools = new Map(customTools.map((tool) => [tool.name, tool])); + const listeners = []; + const emit = (event) => listeners.forEach((listener) => listener(event)); + let callSeq = 0; + let aborted = false; + + const say = (text) => { + emit({ type: "message_start", message: { role: "assistant" } }); + emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: text } }); + emit({ type: "message_end", message: { role: "assistant" } }); + }; + + const session = { + subscribe(listener) { + listeners.push(listener); + }, + abort() { + aborted = true; + }, + dispose() {}, + async prompt() { + for (const step of turns.shift() ?? []) { + if (aborted) return; + if (step.text !== undefined) { + say(step.text); + continue; + } + if (step.runtimeState !== undefined) { + say(JSON.stringify(runtimeState)); + continue; + } + if (step.providerError !== undefined) { + emit({ + type: "message_end", + message: { role: "assistant", errorMessage: step.providerError }, + }); + continue; + } + const tool = tools.get(step.tool); + if (!tool) throw new Error(`pi stub: the harness registered no tool named ${step.tool}`); + const callId = `call-${++callSeq}`; + emit({ type: "tool_execution_start", toolName: tool.name, toolCallId: callId }); + const result = await tool.execute(callId, step.args ?? {}, undefined); + emit({ + type: "tool_execution_end", + toolName: tool.name, + toolCallId: callId, + isError: false, + }); + if (step.echo) say(result.content.map((part) => part.text).join("")); + if (result.terminate) return; + } + }, + }; + + return { session }; +} diff --git a/plugins/nf-agent/VERSION b/plugins/nf-agent/VERSION new file mode 100644 index 0000000000..6e8bf73aa5 --- /dev/null +++ b/plugins/nf-agent/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/plugins/nf-agent/build.gradle b/plugins/nf-agent/build.gradle new file mode 100644 index 0000000000..05712f4b1d --- /dev/null +++ b/plugins/nf-agent/build.gradle @@ -0,0 +1,67 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id 'io.nextflow.nextflow-plugin' version "${nextflowPluginVersion}" + id 'java-test-fixtures' +} + +nextflowPlugin { + nextflowVersion = '25.08.0-edge' + + provider = "${nextflowPluginProvider}" + description = 'Provides a langchain4j-backed agent runner to execute Nextflow agents against an LLM' + className = 'nextflow.agent.AgentPlugin' + useDefaultDependencies = false + generateSpec = false + extensionPoints = [ + 'nextflow.agent.LangChainAgentRunner' + ] +} + +sourceSets { + main.java.srcDirs = [] + main.groovy.srcDirs = ['src/main'] + main.resources.srcDirs = ['src/resources'] + test.groovy.srcDirs = ['src/test'] + test.java.srcDirs = [] + test.resources.srcDirs = [] +} + +configurations { + // see https://docs.gradle.org/4.1/userguide/dependency_management.html#sub:exclude_transitive_dependencies + runtimeClasspath.exclude group: 'org.slf4j', module: 'slf4j-api' +} + +dependencies { + compileOnly project(':nextflow') + compileOnly 'org.slf4j:slf4j-api:2.0.17' + compileOnly 'org.pf4j:pf4j:3.14.1' + + // NOTE: optional (@Nullable/`?`) structured-output fields rely on langchain4j's strict-mode + // JsonSchemaElementUtils.toMap emitting the OpenAI nullable-union type (`type:['string','null']`) + // for fields omitted from `required` (via its internal `type(type, strict, required)` helper). + // This is library-internal behavior, not a contractual API. Keep langchain4j at a version that + // preserves it: NullableStrictSchemaTest guards it and will turn red on a version bump/downgrade + // that removes/changes the helper — at which point apply the contingency source fix in + // RecordSchema.groovy/JsonSchemaMapper.groovy (M4 plan §6). + api 'dev.langchain4j:langchain4j-open-ai:1.17.0' + api 'dev.langchain4j:langchain4j:1.17.0' + api 'dev.langchain4j:langchain4j-skills:1.17.0-beta27' + + testImplementation(testFixtures(project(":nextflow"))) + testImplementation "org.apache.groovy:groovy:4.0.31" + testImplementation "org.apache.groovy:groovy-nio:4.0.31" +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/AgentPlugin.groovy b/plugins/nf-agent/src/main/nextflow/agent/AgentPlugin.groovy new file mode 100644 index 0000000000..1c4de7d14a --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/AgentPlugin.groovy @@ -0,0 +1,33 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic +import nextflow.plugin.BasePlugin +import org.pf4j.PluginWrapper + +/** + * nf-agent plugin entry point: provides a langchain4j-backed {@link AgentRunner}. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentPlugin extends BasePlugin { + + AgentPlugin(PluginWrapper wrapper) { + super(wrapper) + } +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/AgentService.groovy b/plugins/nf-agent/src/main/nextflow/agent/AgentService.groovy new file mode 100644 index 0000000000..ff6014ce83 --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/AgentService.groovy @@ -0,0 +1,38 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic + +/** + * Minimal proxy interface implemented at runtime by langchain4j + * {@code AiServices}. A single {@code chat} method drives one agent turn-set: + * the proxy advertises the registered tools, dispatches tool-execution + * requests, feeds results back to the model and returns the model's final + * text answer. + * + * Named {@code AgentService} (not {@code Agent}) to avoid clashing with the + * langchain4j-agentic {@code @Agent} annotation. The method carries no + * annotations so the full user text is passed verbatim as the user message. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +interface AgentService { + + String chat(String userMessage) + +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/AgentStructuredOutputException.groovy b/plugins/nf-agent/src/main/nextflow/agent/AgentStructuredOutputException.groovy new file mode 100644 index 0000000000..7c813cf16d --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/AgentStructuredOutputException.groovy @@ -0,0 +1,36 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import groovy.transform.CompileStatic + +/** + * Raised by the plugin's final structuring turn when the model refuses or returns + * no usable content for the required structured-output schema (detected via a + * {@code CONTENT_FILTER} finish reason or blank/null content). + * + *

A refusal is deterministic, so this fails fast with a clear, refusal-flavored + * cause instead of a retry-storm. The exception is plugin-scoped; the core sees it + * only as a {@link Throwable} through the {@code AgentRunner} SPI (boundary preserved). + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class AgentStructuredOutputException extends RuntimeException { + AgentStructuredOutputException(String message) { + super(message) + } +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/AgentTrace.groovy b/plugins/nf-agent/src/main/nextflow/agent/AgentTrace.groovy new file mode 100644 index 0000000000..c33bd3cd29 --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/AgentTrace.groovy @@ -0,0 +1,176 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.data.message.AiMessage +import dev.langchain4j.model.chat.listener.ChatModelListener +import dev.langchain4j.model.chat.listener.ChatModelResponseContext +import groovy.json.JsonSlurper +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +/** + * Renders a readable trace of an agent run that simulates the agent "thinking": each + * model round-trip is a turn, the model's free text is shown as its reasoning, and each + * tool invocation is reported as it runs. + * + *

Two levels: the human-readable narrative — turns, model reasoning, which tool was + * invoked, the final answer — is logged at INFO; the low-level tool inputs and outputs + * are logged at DEBUG. + * + *

Turn boundaries and reasoning are captured as a langchain4j + * {@link ChatModelListener} ({@link #onResponse} fires once per model round-trip); tool + * invocations are reported via {@link #tool} from the runner's tool executor. Everything + * runs on the single agent operator thread (AiServices dispatches sequentially), so the + * turn counter needs no synchronization. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class AgentTrace implements ChatModelListener { + + private final String label + + private int turn + + AgentTrace(String agentName) { + this.label = agentName ? "[agent ${agentName}]" : '[agent]' + } + + /** Emit the run header: model and the tools the agent may call. */ + void begin(String model, Collection toolNames) { + emit("model ${model}" + (toolNames ? " · tools: ${toolNames.join(', ')}" : ' · no tools')) + } + + /** Emit the run footer with the final answer. */ + void end(String finalAnswer) { + paragraph('final answer: ', finalAnswer) + } + + @Override + void onResponse(ChatModelResponseContext ctx) { + final AiMessage msg = ctx.chatResponse().aiMessage() + turn += 1 + final boolean isFinal = !msg.hasToolExecutionRequests() + emit(isFinal ? "── turn ${turn} ── (final)" : "── turn ${turn} ──") + // the model's reasoning: its dedicated thinking channel when present, else its free-text + // content on a tool-decision turn (on the final turn the content is the answer, shown by + // end()). NOTE: most OpenAI tool-call turns carry no content/thinking, so a reasoning line + // appears only when the model actually narrates (or returnThinking is supported by the model). + final String reasoning = msg.thinking()?.trim() ?: (isFinal ? null : msg.text()?.trim()) + if( reasoning ) + paragraph('reasoning: ', reasoning) + // tool inputs/outputs are emitted by #tool as each call runs + } + + /** + * Report a single tool invocation: the decision — the tool name plus a short, readable digest + * of its arguments (paths shown as file names, nested maps inlined) — is the human-readable + * narrative logged at INFO; the full raw JSON arguments and result are logged at DEBUG. + */ + void tool(String name, String argsJson, String resultJson) { + emit(" → ${name}(${summarizeArgs(argsJson)})") + emitDebug(" ${name} input: ${argsJson}") + emitDebug(" ${name} output: ${resultJson}") + } + + private void emit(String text) { + log.info("${label} ${text}") + } + + private void emitDebug(String text) { + log.debug("${label} ${text}") + } + + /** + * Emit possibly multi-line text under a marker: the first line carries the + * marker, continuation lines are indented so the block reads as one thought. + */ + private void paragraph(String marker, String text) { + final lines = (text ?: '').readLines() + if( !lines ) { + emit(marker.trim()) + return + } + emit(" ${marker}${lines[0]}") + final pad = ' ' * (marker.length() + 2) + for( int i = 1; i < lines.size(); i++ ) + emit("${pad}${lines[i]}") + } + + /** Max length of the rendered argument digest on the INFO line. */ + private static final int MAX_ARGS_CHARS = 100 + + /** + * A short, human-readable digest of a tool's JSON arguments for the INFO line: top-level + * {@code key=value} pairs, with file-path values shown as their file name, nested maps inlined + * as {@code {k:v}}, and the whole thing clipped to {@link #MAX_ARGS_CHARS}. Falls back to the + * flattened raw string when the arguments are not a JSON object. + */ + private static String summarizeArgs(String argsJson) { + final parsed = parseJson(argsJson) + if( !(parsed instanceof Map) ) + return clip(flatten(argsJson), MAX_ARGS_CHARS) + final entries = ((Map) parsed).collect { k, v -> "${k}=${renderValue(v)}".toString() } + return clip(entries.join(', '), MAX_ARGS_CHARS) + } + + private static String renderValue(Object v) { + if( v == null ) + return 'null' + if( v instanceof Map ) { + final inner = ((Map) v).collect { k, x -> "${k}:${scalar(x)}".toString() }.join(', ') + return "{${inner}}" + } + if( v instanceof List ) { + final list = (List) v + return list.isEmpty() ? '[]' : "[${scalar(list[0])}${list.size() > 1 ? ', …' : ''}]" + } + return scalar(v) + } + + /** Render a scalar: a path-like value becomes its file name; anything long is clipped. */ + private static String scalar(Object v) { + if( v == null ) + return 'null' + final s = v.toString() + final slash = s.lastIndexOf('/') + return slash >= 0 ? s.substring(slash + 1) : clip(s, 40) + } + + private static String clip(String s, int max) { + if( s == null ) + return '' + return s.length() > max ? s.substring(0, max) + '…' : s + } + + private static String flatten(String s) { + return s != null ? s.replaceAll(/\s+/, ' ').trim() : '' + } + + private static Object parseJson(String json) { + if( !json?.trim() ) + return null + try { + return new JsonSlurper().parseText(json) + } + catch( Exception e ) { + log.trace("Agent trace: tool arguments are not valid JSON, showing raw: ${e.message}") + return null + } + } +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/ChatModelFactory.groovy b/plugins/nf-agent/src/main/nextflow/agent/ChatModelFactory.groovy new file mode 100644 index 0000000000..d91ac24deb --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/ChatModelFactory.groovy @@ -0,0 +1,158 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.time.Duration + +import dev.langchain4j.model.chat.ChatModel +import dev.langchain4j.model.chat.listener.ChatModelListener +import dev.langchain4j.model.chat.request.ResponseFormat +import dev.langchain4j.model.chat.request.ResponseFormatType +import dev.langchain4j.model.chat.request.json.JsonSchema +import dev.langchain4j.model.openai.OpenAiChatModel +import groovy.transform.CompileStatic + +/** + * Builds a langchain4j {@link ChatModel} from a {@code provider/model} identifier. + * + * The {@code openai} prefix names the OpenAI WIRE PROTOCOL, not the vendor: with an + * {@code agent.baseUrl} it also reaches any OpenAI-compatible endpoint (vLLM, Ollama, + * llama.cpp, OpenRouter, LiteLLM, a corporate gateway). + * + * This factory NEVER reads the environment. The endpoint and the credential are + * resolved once in core ({@code AgentConfig.apiKeyFor}/{@code baseUrlFor} implement the + * config -> {@code NXF_AGENT_*} -> {@code _*} ladder, where the provider namespace + * is {@code agent.apiProvider}, else the provider of a well-known endpoint host, else the + * model-id prefix), travel on the {@link nextflow.agent.AgentRunnerRequest} and are passed in + * as {@code createModel} arguments — so one ladder is authoritative for every runner. The + * no-credential rule ({@code AgentRunnerRequest.PLACEHOLDER_API_KEY}) is shared with the pi + * runner for the same reason, and with it the two rules that bound it: a {@code baseUrl} whose + * host IS a known provider's endpoint fails here rather than presenting a placeholder that + * buys an opaque 401, and a credential that RESOLVED and was withheld by the endpoint gate + * ({@code AgentConfig.credentialWithheldFor}) fails too — the placeholder is for a genuine + * no-credential local endpoint, never for a misroute. This runner has no credential source of + * its own, so both are fatal here; the pi runner only warns. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ChatModelFactory { + + private static int slashIndex(String modelId) { + final i = modelId?.indexOf('/') ?: -1 + if( i < 0 ) + throw new IllegalArgumentException("Invalid model id `${modelId}` - expected `provider/model`") + return i + } + + /** + * The provider prefix, LOWER-CASED so this and {@link AgentConfig#providerPrefixOf} give the + * same answer for the same model id. They must: core resolves and TRANSMITS a credential for + * the provider it computes, so a mixed-case {@code OpenAI/gpt-4o} that core reads as + * {@code openai} and this factory read as {@code OpenAI} disagreed about whose key was in + * flight -- core sent one, and the runner rejected the model as an unsupported provider. + */ + static String providerOf(String modelId) { + return modelId.substring(0, slashIndex(modelId)).toLowerCase() + } + + static String modelOf(String modelId) { + return modelId.substring(slashIndex(modelId) + 1) + } + + /** + * Build a chat model for the given {@code provider/model} id. When a + * structured-output {@code schema} is provided, the OpenAI model is + * configured with a strict JSON-schema response format so that the model + * is constrained to return JSON matching the schema. + * + * @param modelId the {@code provider/model} identifier + * @param timeoutSeconds the request timeout in seconds + * @param schema the structured-output JSON schema, or {@code null} + * for free-form text output + * @param temperature the sampling temperature to apply, or {@code null} to + * leave the provider default (legacy tool/skill path) + * @param apiKey the credential resolved by core, or {@code null} when none + * resolved (allowed only together with a {@code baseUrl}) + * @param baseUrl the OpenAI-compatible endpoint resolved by core, or + * {@code null} to use the provider default + * @param apiProvider the provider NAMESPACE core resolved the pair from + * ({@code AgentRunnerRequest.apiProvider}), used to name the right + * variables in a missing-credential message; {@code null} when unknown + * @param credentialWithheld {@code true} when a provider credential resolved and the endpoint + * gate refused to send it ({@code AgentConfig.credentialWithheldFor}) + * @param listeners optional chat-model listeners (e.g. the execution + * tracer), or {@code null} for none + * + * NOTE: the credential parameters are declared AFTER {@code temperature} and BEFORE the + * defaulted {@code listeners} parameter, so the generated default-argument overload cannot + * bind one of them into the listener slot. + */ + ChatModel createModel(String modelId, int timeoutSeconds, JsonSchema schema, Double temperature, String apiKey, String baseUrl, String apiProvider, boolean credentialWithheld, List listeners = null) { + final provider = providerOf(modelId) + if( provider != AgentConfig.OPENAI_PROVIDER ) + throw new IllegalArgumentException("Unsupported agent model provider `${provider}` - only the `openai` wire protocol is supported; to reach an OpenAI-compatible endpoint keep the `openai/` prefix and set `agent.baseUrl`") + // The credential namespace the ladder consulted, as core resolved it. Falling back to the + // prefix keeps a request built before this field existed (or by a test) from claiming a + // namespace nobody chose. + final namespace = apiProvider ?: provider + if( credentialWithheld ) + // A key RESOLVED and the endpoint gate refused to send it. That is a misconfiguration + // with a name, so say it here rather than let the placeholder below turn it into a 401. + // langchain4j has no other credential source, so this is fatal -- deliberately UNLIKE + // the pi path, which only warns (see AgentRpcBroker.warnWithheldCredential). + throw new IllegalArgumentException("The `${namespace}` credential resolved for agent model `${modelId}` was withheld from ${baseUrl ? "the endpoint ${baseUrl}" : "the default `${provider}` endpoint"} - that endpoint is not one `${namespace}` owns; set `agent.apiKey` in the Nextflow configuration, or the `NXF_AGENT_API_KEY` environment variable, to the credential it does accept, or `agent.apiProvider` to name the namespace it belongs to") + // the shared D8 rule: the resolved credential, else a placeholder when an endpoint is + // declared (the endpoint is assumed to need none: a local vLLM/Ollama), else nothing -- + // and, since the endpoint may instead be a known provider's own API, an abort when that + // assumption is plainly false. Owned by AgentRunnerRequest so this runner and the pi + // runner cannot drift apart. + final credential = AgentRunnerRequest.credentialFor(apiKey, baseUrl) + if( !credential ) + // Name the variables the ladder ACTUALLY consulted for the resolved namespace. Asserting + // OPENAI_API_KEY here was wrong whenever `agent.apiProvider` redirected the namespace -- + // the very thing the old comment claimed to avoid. The namespace is still named as a + // remedy, because it is also the rung a user may need to CHANGE. + throw new IllegalArgumentException("Missing LLM provider credential for agent model `${modelId}` - ${AgentConfig.missingCredentialHint(namespace)}; the credential namespace is `${namespace}`, set `agent.apiProvider` to name a different one") + final builder = OpenAiChatModel.builder() + .apiKey(credential) + .modelName(modelOf(modelId)) + .timeout(Duration.ofSeconds(timeoutSeconds)) + if( baseUrl ) + builder.baseUrl(baseUrl) + if( temperature != null ) + builder.temperature(temperature) + if( listeners ) { + builder.listeners(listeners) + // also request the model's reasoning content: it is parsed into AiMessage.thinking() + // for models/providers that return it (reasoning models, or providers exposing + // `reasoning_content`), and is absent otherwise. Only returnThinking is set — + // sendThinking stays false, so the reasoning is never echoed back to the model on + // later turns (no risk of chat-completions rejecting an echoed reasoning field). + builder.returnThinking(true) + } + if( schema != null ) { + final responseFormat = ResponseFormat.builder() + .type(ResponseFormatType.JSON) + .jsonSchema(schema) + .build() + builder + .responseFormat(responseFormat) + .strictJsonSchema(true) + } + return builder.build() + } +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/JsonSchemaMapper.groovy b/plugins/nf-agent/src/main/nextflow/agent/JsonSchemaMapper.groovy new file mode 100644 index 0000000000..d32dcab147 --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/JsonSchemaMapper.groovy @@ -0,0 +1,139 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.model.chat.request.json.JsonArraySchema +import dev.langchain4j.model.chat.request.json.JsonBooleanSchema +import dev.langchain4j.model.chat.request.json.JsonEnumSchema +import dev.langchain4j.model.chat.request.json.JsonIntegerSchema +import dev.langchain4j.model.chat.request.json.JsonNumberSchema +import dev.langchain4j.model.chat.request.json.JsonObjectSchema +import dev.langchain4j.model.chat.request.json.JsonSchema +import dev.langchain4j.model.chat.request.json.JsonSchemaElement +import dev.langchain4j.model.chat.request.json.JsonStringSchema +import groovy.transform.CompileStatic + +/** + * Converts a portable JSON-schema {@link Map} (the shape produced by core's + * {@code nextflow.agent.RecordSchema.of}) into a langchain4j + * {@link JsonSchema} suitable for use as a structured-output contract. + * + * The portable shape is: + *

+ * [ type:'object',
+ *   properties:[ field:[ type:'string'|'integer'|'number'|'boolean'|'array'|'object',
+ *                        items:..., properties:..., required:... ] ],
+ *   required:[...],
+ *   additionalProperties:false ]
+ * 
+ * + * @author Paolo Di Tommaso + */ +@CompileStatic +class JsonSchemaMapper { + + /** + * Build a langchain4j {@link JsonSchema} with the given name from a portable + * object schema map. + * + * @param name the schema name (used as the structured-output schema name) + * @param schema the portable schema map (must describe an {@code object}) + * @return a non-null {@link JsonSchema} whose root is a {@link JsonObjectSchema} + */ + static JsonSchema toJsonSchema(String name, Map schema) { + final root = toObjectSchema(schema) + return JsonSchema.builder() + .name(name) + .rootElement(root) + .build() + } + + /** + * Build a langchain4j {@link JsonObjectSchema} from a portable object schema + * map. Used both as the root of a structured-output {@link JsonSchema} and as + * the {@code parameters} schema of a tool specification. + * + * @param schema the portable schema map (must describe an {@code object}) + * @return a non-null {@link JsonObjectSchema} + */ + static JsonObjectSchema toObjectSchema(Map schema) { + final builder = JsonObjectSchema.builder() + final Map properties = (schema?.properties ?: [:]) as Map + for( Map.Entry entry : properties.entrySet() ) { + final key = entry.key as String + final spec = entry.value as Map + builder.addProperty(key, toElement(key, spec)) + } + final required = schema?.required as List + if( required != null ) + builder.required(required.collect { it as String }) + final additional = schema?.additionalProperties + if( additional != null ) + builder.additionalProperties(additional as Boolean) + final description = schema?.description as String + if( description != null ) + builder.description(description) + return builder.build() + } + + private static JsonSchemaElement toElement(String name, Map spec) { + final type = spec?.type as String + final description = spec?.description as String + switch( type ) { + case 'string': + final enumValues = spec?.enum as List + if( enumValues != null ) { + final eb = JsonEnumSchema.builder() + .enumValues(enumValues.collect { it as String }) + if( description != null ) + eb.description(description) + return eb.build() + } + final sb = JsonStringSchema.builder() + if( description != null ) + sb.description(description) + return sb.build() + case 'integer': + final ib = JsonIntegerSchema.builder() + if( description != null ) + ib.description(description) + return ib.build() + case 'number': + final nb = JsonNumberSchema.builder() + if( description != null ) + nb.description(description) + return nb.build() + case 'boolean': + final bb = JsonBooleanSchema.builder() + if( description != null ) + bb.description(description) + return bb.build() + case 'array': + final items = spec.items as Map + if( items == null ) + throw new IllegalArgumentException("Array property `${name}` is missing an `items` schema") + final ab = JsonArraySchema.builder() + .items(toElement(name + '[]', items)) + if( description != null ) + ab.description(description) + return ab.build() + case 'object': + return toObjectSchema(spec) + default: + throw new IllegalArgumentException("Unsupported schema type `${type}` for property `${name}`") + } + } +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/LangChainAgentRunner.groovy b/plugins/nf-agent/src/main/nextflow/agent/LangChainAgentRunner.groovy new file mode 100644 index 0000000000..501cc62cf8 --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/LangChainAgentRunner.groovy @@ -0,0 +1,444 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.agent.tool.ToolExecutionRequest +import dev.langchain4j.agent.tool.ToolSpecification +import dev.langchain4j.data.message.ChatMessage +import dev.langchain4j.data.message.SystemMessage +import dev.langchain4j.data.message.UserMessage +import dev.langchain4j.invocation.InvocationContext +import dev.langchain4j.memory.ChatMemory +import dev.langchain4j.memory.chat.MessageWindowChatMemory +import dev.langchain4j.model.chat.ChatModel +import dev.langchain4j.model.chat.listener.ChatModelListener +import dev.langchain4j.model.chat.request.json.JsonSchema +import dev.langchain4j.model.chat.response.ChatResponse +import dev.langchain4j.model.output.FinishReason +import dev.langchain4j.service.AiServices +import dev.langchain4j.skills.Skills +import dev.langchain4j.service.tool.ToolExecutionResult +import dev.langchain4j.service.tool.ToolExecutor +import dev.langchain4j.service.tool.ToolProvider +import dev.langchain4j.service.tool.ToolProviderRequest +import dev.langchain4j.service.tool.ToolProviderResult +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j +import org.pf4j.Extension + +/** + * langchain4j-backed {@link AgentRunner}. + * + * When the request declares no tools, the runner does a single-shot chat + * (optionally constraining the model to a structured-output JSON schema). When + * the request declares one or more tools, the runner uses a langchain4j {@code AiServices} + * proxy which registers the tool specifications with their executors. For each + * tool-execution request the model emits, the executor delegates to the dispatch + * callback (which runs the real module), and the proxy feeds the result back to + * the model, looping until the model returns a final text answer or the iteration + * cap is reached. + * + * The tool loop itself always runs schema-free (no {@code responseFormat} forced on + * the loop model). When the request also declares a structured output schema, a single + * stateless "final structuring turn" (M5) re-encodes the loop's free-text answer into + * schema-valid JSON; the loop behavior is otherwise unchanged. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@Extension +@CompileStatic +class LangChainAgentRunner implements AgentRunner { + + private static final int DEFAULT_TIMEOUT_SECONDS = 120 + + private static final int DEFAULT_MAX_ITERATIONS = 10 + + ChatModelFactory modelFactory = new ChatModelFactory() + + @Override + String getName() { 'langchain4j' } + + @Override + String run(AgentRunnerRequest request) { + if( !request.model ) + throw new IllegalArgumentException("Agent `model` directive is required") + + // the runner-native names count as tools here exactly as the brokered descriptors do: this + // runner serves them ITSELF, in the driver JVM, so an `fs:`-only agent still needs the + // tool loop even though its `toolSpecs` is empty (§5 keeps the two halves disjoint) + return (request.toolSpecs || request.nativeToolNames || request.skills) + ? runWithTools(request) + : runSingleShot(request) + } + + /** + * The per-request LLM chat timeout (seconds): the configured value carried by + * the request, or the built-in default when none was configured. + */ + private static int timeoutSeconds(AgentRunnerRequest request) { + return request.requestTimeoutSeconds > 0 ? request.requestTimeoutSeconds : DEFAULT_TIMEOUT_SECONDS + } + + /** + * Single-shot chat (no tool calls). When an output record type is declared + * the model is constrained to a structured-output JSON schema. + */ + private String runSingleShot(AgentRunnerRequest request) { + // derive the structured-output schema (when an output record type is declared) + final JsonSchema schema = request.outputSchema + ? JsonSchemaMapper.toJsonSchema('Output', request.outputSchema) + : null + + final model = modelFactory.createModel(request.model, timeoutSeconds(request), schema, (Double) request.temperature, request.apiKey, request.baseUrl, request.apiProvider, request.credentialWithheld) + + final List messages = composeMessages(request) + + log.debug "Running agent model=${request.model}; messages=${messages.size()}; structured=${schema != null}" + final ChatResponse response = model.chat(messages) + // stash the concrete model snapshot back to core for resume drift observability + // (design §9.5/D6); boundary-safe: the plugin writes only a String into the + // core-owned ThreadLocal and still returns only a String from run(). + AgentCallInfo.setResolvedModel(response.metadata()?.modelName()) + return response.aiMessage().text() + } + + /** + * Tool-call loop driven by a langchain4j {@code AiServices} proxy. The tool + * specifications are registered with their executors; for each tool-execution + * request the model emits, the executor delegates to the dispatch callback + * which runs the real module, and the proxy feeds the result back to the + * model — looping until the model returns a final text answer or the + * iteration cap is reached. + * + * Tool calls are dispatched sequentially on the calling thread (the + * AiServices default); {@code executeToolsConcurrently} is never enabled. + * + * Prompt composition: only the optional {@link SystemMessage} is seeded into + * the chat memory; the full composed user text (prompt plus input JSON) is + * passed to {@code chat(...)} so the conversation holds exactly one + * {@link UserMessage}. + */ + private String runWithTools(AgentRunnerRequest request) { + final AgentTrace trace = request.trace ? new AgentTrace(request.agentName) : null + final int maxIterations = effectiveMaxIterations(request) + + final ChatModel model = buildChatModel(request, trace) + final Map moduleTools = moduleTools(request, trace) + final Skills skills = skillsOf(request) + + final ChatMemory memory = seededMemory(request, skills) + final ToolProvider skillProvider = skillToolProvider(skills, trace) + final String userText = composeUserText(request) + + traceBegin(trace, request, moduleTools, skills, userText) + + final AgentService agent = buildAgent(model, memory, moduleTools, skillProvider, maxIterations) + final String answer = runChat(agent, userText, trace, maxIterations) + // free-text tools/skills (byte-for-byte the historical behavior) when no output + // schema is declared; otherwise run one stateless "final structuring turn" that + // converts the loop's free-text answer into schema-valid JSON. The tool loop + // itself stays schema-free (buildChatModel passes a null schema) so the tested + // M1-M4 loop behavior is preserved. + if( request.outputSchema == null ) + return answer + return structureFinalAnswer(request, answer) + } + + /** + * The final structuring turn: a single stateless {@code chat} that re-encodes the + * tool loop's free-text {@code answer} into JSON matching the request's structured + * output schema. Uses the existing structured-output plumbing + * ({@link JsonSchemaMapper} + {@link ChatModelFactory#createModel}); temperature is + * left unset (the legacy tool/skill path never pins it). A refusal (a + * {@code CONTENT_FILTER} finish reason or blank/null content) throws a distinct + * {@link AgentStructuredOutputException} so the failure is deterministic and does not + * retry-storm; a non-empty-but-malformed answer is returned verbatim and surfaces as + * a clear parse error in the shared core bind. + */ + private String structureFinalAnswer(AgentRunnerRequest request, String answer) { + final JsonSchema schema = JsonSchemaMapper.toJsonSchema('Output', request.outputSchema) + final ChatModel model = modelFactory.createModel(request.model, timeoutSeconds(request), schema, (Double) null, request.apiKey, request.baseUrl, request.apiProvider, request.credentialWithheld) + final List messages = [ + SystemMessage.from('Convert the following answer into the required structured JSON. Do not omit or invent information.'), + UserMessage.from(answer) ] as List + final ChatResponse response = model.chat(messages) + // stash the concrete model snapshot from the structuring turn for resume-drift + // observability (design §9.5/D6), mirroring runSingleShot; boundary-safe (String only) + AgentCallInfo.setResolvedModel(response.metadata()?.modelName()) + final String text = response.aiMessage()?.text() + if( response.finishReason() == FinishReason.CONTENT_FILTER || !text?.trim() ) + throw new AgentStructuredOutputException("Agent `${request.agentName}`: the model refused or returned no structured content for the required output schema") + return text + } + + /** The effective tool-call iteration cap: the request value, or the built-in default. */ + private static int effectiveMaxIterations(AgentRunnerRequest request) { + return request.maxIterations > 0 ? request.maxIterations : DEFAULT_MAX_ITERATIONS + } + + /** + * Build the chat model for a tool/skill run. The tool loop always stays schema-free, so no + * {@code responseFormat} is forced (null schema) even when a structured output is declared + * (the structuring is a separate final turn). The execution tracer is attached as a + * {@link ChatModelListener} only when tracing. + */ + private ChatModel buildChatModel(AgentRunnerRequest request, AgentTrace trace) { + final List listeners = trace != null + ? Collections.singletonList(trace) + : null + return modelFactory.createModel(request.model, timeoutSeconds(request), null, (Double) null, request.apiKey, request.baseUrl, request.apiProvider, request.credentialWithheld, listeners) + } + + /** + * The static tools of the run: one {@link ToolSpecification} per declared tool, all sharing a + * single executor that dispatches by tool name back to the core callback. Empty for a + * skills-only agent. + * + *

Two sources, one flat list, because the model must not be able to tell them apart: + * the BROKERED descriptors carried on {@code toolSpecs}, and the RUNNER-NATIVE {@code fs:} + * names carried on {@code nativeToolNames}. This runner IS the driver JVM, so "native" here + * means served by {@link nextflow.agent.ModuleToolBridge} behind the same {@code dispatch} + * callback — the descriptors are rebuilt from the names rather than shipped, which is what + * keeps a native name out of {@code toolSpecs} and therefore out of any broker allowlist (§5). + * {@code shell:bash} never appears: core rejects it for this runner at agent-build time. + */ + private static Map moduleTools(AgentRunnerRequest request, AgentTrace trace) { + final Map tools = new LinkedHashMap<>() + final List descriptors = new ArrayList() + if( request.toolSpecs ) + descriptors.addAll(request.toolSpecs) + if( request.nativeToolNames ) + descriptors.addAll(FilesystemTools.descriptors( + request.nativeToolNames.findAll { String it -> FilesystemTools.NAMES.contains(it) })) + if( !descriptors ) + return tools + final ToolExecutor executor = moduleToolExecutor(request, trace) + for( final descriptor : descriptors ) + tools.put(ModuleToolAdapter.toToolSpecification(descriptor), executor) + return tools + } + + /** The executor shared by every module tool: dispatch by name to the core callback, trace the call. */ + private static ToolExecutor moduleToolExecutor(AgentRunnerRequest request, AgentTrace trace) { + return { ToolExecutionRequest ter, Object memoryId -> + log.debug "Agent tool call name=${ter.name()}; args=${ter.arguments()}" + final String result = request.dispatch.call(ter.name(), ter.arguments()) + if( trace != null ) + trace.tool(ter.name(), ter.arguments(), result) + return result + } as ToolExecutor + } + + /** Map the request's declared skills onto a langchain4j {@link Skills} container, or null when none. */ + private static Skills skillsOf(AgentRunnerRequest request) { + return request.skills ? SkillAdapter.toSkills(request.skills as List) : null + } + + /** + * Chat memory seeded with the single system message — the instruction/goal plus, for a skills run, + * the available-skills catalog (the user text is passed to {@code chat()}, not seeded here). + */ + private static ChatMemory seededMemory(AgentRunnerRequest request, Skills skills) { + final ChatMemory memory = MessageWindowChatMemory.withMaxMessages(Integer.MAX_VALUE) + final String systemMessage = systemMessageWithSkillCatalog(request, skills) + if( systemMessage ) + memory.add(SystemMessage.from(systemMessage)) + return memory + } + + /** + * The system message with the available-skills catalog appended when skills are present, so the + * model knows which skills it can activate (the {@code activate_skill} arg is free text, not an enum). + */ + private static String systemMessageWithSkillCatalog(AgentRunnerRequest request, Skills skills) { + final String base = composeSystemMessage(request) + if( skills == null ) + return base + final String catalog = skills.formatAvailableSkills() + return base ? (base + '\n\n' + catalog) : catalog + } + + /** + * The skills tool provider ({@code activate_skill}/{@code read_skill_resource}), or null when no + * skills. When tracing, wrapped so each skill-tool call is itemized like a module tool. + */ + private static ToolProvider skillToolProvider(Skills skills, AgentTrace trace) { + if( skills == null ) + return null + final ToolProvider provider = skills.toolProvider() + return trace != null ? tracingSkillProvider(provider, trace) : provider + } + + /** Emit the trace run header listing the module and skill tool names the agent may call. */ + private static void traceBegin(AgentTrace trace, AgentRunnerRequest request, Map moduleTools, Skills skills, String userText) { + if( trace == null ) + return + final List toolNames = new ArrayList(moduleTools.keySet()*.name() as List) + if( skills != null ) + toolNames.addAll(enumerateSkillTools(skills.toolProvider(), userText)) + trace.begin(request.model, toolNames) + } + + /** + * Assemble the {@code AiServices} agent. A cap of N permits only N-1 round-trips, so pass + * maxIterations+1. In langchain4j 1.17 static {@code .tools(map)} and {@code .toolProvider(...)} + * coexist and merge, so the skills provider is additive; {@code .tools(...)} is skipped when there + * are no module tools. + */ + private static AgentService buildAgent(ChatModel model, ChatMemory memory, Map moduleTools, ToolProvider skillProvider, int maxIterations) { + final AiServices builder = AiServices.builder(AgentService) + .chatModel(model) + .chatMemory(memory) + .maxSequentialToolsInvocations(maxIterations + 1) + if( !moduleTools.isEmpty() ) + builder.tools(moduleTools) + if( skillProvider != null ) + builder.toolProvider(skillProvider) + return builder.build() + } + + /** + * Drive the chat to a final answer. langchain4j throws a plain {@code RuntimeException} on cap + * exceed; re-throw it with the historical {@link IllegalStateException} shape, while letting any + * genuine {@code IllegalStateException} propagate unchanged. + */ + private static String runChat(AgentService agent, String userText, AgentTrace trace, int maxIterations) { + try { + final String answer = agent.chat(userText) + if( trace != null ) + trace.end(answer) + return answer + } + catch( IllegalStateException e ) { + throw e + } + catch( RuntimeException e ) { + throw new IllegalStateException("Agent exceeded the maximum number of tool-call iterations (${maxIterations})", e) + } + } + + /** + * Wrap a skills {@link ToolProvider} so each skill-tool invocation is reported to the + * {@link AgentTrace} (the {@code → activate_skill(...)} line), the same way module-tool executors + * report theirs. langchain4j-skills' executors carry their real logic in {@code executeWithContext} + * (it reads activation state from the chat-message history via the {@link InvocationContext}), and + * AiServices invokes {@code executeWithContext} — so the wrapper MUST override and delegate that + * method (not just {@code execute}), or skill activation would break. {@code isDynamic()} is also + * delegated so the provider's invocation cadence is preserved. + */ + @PackageScope + static ToolProvider tracingSkillProvider(ToolProvider delegate, AgentTrace trace) { + return new ToolProvider() { + @Override + boolean isDynamic() { return delegate.isDynamic() } + + @Override + ToolProviderResult provideTools(ToolProviderRequest request) { + final ToolProviderResult res = delegate.provideTools(request) + final Map wrapped = new LinkedHashMap<>() + for( final Map.Entry entry : res.tools().entrySet() ) { + final ToolExecutor inner = entry.value + wrapped.put(entry.key, new ToolExecutor() { + @Override + String execute(ToolExecutionRequest req, Object memoryId) { + return inner.execute(req, memoryId) + } + @Override + ToolExecutionResult executeWithContext(ToolExecutionRequest req, InvocationContext ctx) { + final ToolExecutionResult result = inner.executeWithContext(req, ctx) + trace.tool(req.name(), req.arguments(), result?.resultText()) + return result + } + }) + } + return new ToolProviderResult(wrapped) + } + } + } + + /** + * Best-effort enumeration of a skills provider's tool names (e.g. {@code activate_skill}, + * {@code read_skill_resource}) for the trace header. Failures degrade to an empty list — the + * per-call itemization above does not depend on this. + */ + private static List enumerateSkillTools(ToolProvider provider, String userText) { + try { + final ToolProviderRequest req = new ToolProviderRequest(null, UserMessage.from(userText)) + return new ArrayList(provider.provideTools(req).tools().keySet()*.name()) + } + catch( Exception e ) { + log.debug("Agent trace: could not enumerate skill tool names: ${e.message}") + return Collections.emptyList() + } + } + + /** + * Compose the system message: the {@code instruction} (role/persona), + * followed by an optional {@code goal} section that steers the multi-turn + * loop toward an objective. Returns {@code null} when neither is set, so the + * caller seeds no {@link SystemMessage}. + */ + private static String composeSystemMessage(AgentRunnerRequest request) { + final StringBuilder sb = new StringBuilder() + if( request.instruction ) + sb.append(request.instruction) + if( request.goal ) { + if( sb.length() > 0 ) + sb.append('\n\n') + sb.append('Goal:\n').append(request.goal) + .append('\nYou are done when the goal is met; produce your final answer as plain text.') + } + // when tracing a TOOL run, ask the model to narrate its reasoning so the trace can surface + // it: OpenAI emits no reasoning on the wire for tool-call turns, so a one-line rationale in + // the message content is the only way to make the "thinking" visible. Gated on toolSpecs so + // it never touches the single-shot / structured-output path; trace-only, so normal runs are + // unaffected. It nudges the model to think out loud, which generally aids tool selection. + if( request.trace && (request.toolSpecs || request.skills) ) { + if( sb.length() > 0 ) + sb.append('\n\n') + sb.append('Before each tool call, briefly state your reasoning for the call in one short sentence.') + } + return sb.length() > 0 ? sb.toString() : null + } + + /** + * Compose the user text: the rendered prompt, plus the input record + * serialized as JSON when present. This is the single user message passed + * to the AiServices proxy. + */ + private static String composeUserText(AgentRunnerRequest request) { + String userText = request.prompt + if( request.inputJson ) + userText += "\n\nInput (JSON):\n" + request.inputJson + return userText + } + + /** + * Compose the chat messages: an optional system instruction followed by the + * user message (the rendered prompt, plus the input record serialized as JSON + * when present). + */ + private static List composeMessages(AgentRunnerRequest request) { + final List messages = new ArrayList() + final String systemMessage = composeSystemMessage(request) + if( systemMessage ) + messages.add(SystemMessage.from(systemMessage)) + messages.add(UserMessage.from(composeUserText(request))) + return messages + } +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/ModuleToolAdapter.groovy b/plugins/nf-agent/src/main/nextflow/agent/ModuleToolAdapter.groovy new file mode 100644 index 0000000000..3ec614eeab --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/ModuleToolAdapter.groovy @@ -0,0 +1,54 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.agent.tool.ToolSpecification +import dev.langchain4j.model.chat.request.json.JsonObjectSchema +import groovy.transform.CompileStatic + +/** + * Maps a core, langchain4j-free {@link ToolDescriptor} onto a langchain4j + * {@link ToolSpecification} so the LLM can be told which tools it may call. + * + * The descriptor's portable {@code inputSchema} {@link Map} (the same shape + * {@link JsonSchemaMapper} consumes) becomes the tool's {@code parameters} + * {@link JsonObjectSchema}. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class ModuleToolAdapter { + + /** + * Build a langchain4j {@link ToolSpecification} from the given descriptor. + * + * @param d the portable tool descriptor (name, description, input schema) + * @return a {@link ToolSpecification} whose {@code parameters} schema is + * derived from {@code d.inputSchema} + */ + static ToolSpecification toToolSpecification(ToolDescriptor d) { + if( !d ) + throw new IllegalArgumentException("Tool descriptor cannot be null") + if( !d.name ) + throw new IllegalArgumentException("Tool descriptor `name` is required") + final JsonObjectSchema params = JsonSchemaMapper.toObjectSchema(d.inputSchema ?: [type: 'object']) + return ToolSpecification.builder() + .name(d.name) + .description(d.description) + .parameters(params) + .build() + } +} diff --git a/plugins/nf-agent/src/main/nextflow/agent/SkillAdapter.groovy b/plugins/nf-agent/src/main/nextflow/agent/SkillAdapter.groovy new file mode 100644 index 0000000000..de29f86136 --- /dev/null +++ b/plugins/nf-agent/src/main/nextflow/agent/SkillAdapter.groovy @@ -0,0 +1,58 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.skills.Skill +import dev.langchain4j.skills.Skills +import dev.langchain4j.skills.SkillResource as LcSkillResource +import groovy.transform.CompileStatic + +/** + * Maps core's portable, langchain4j-free {@link SkillDescriptor}s onto langchain4j + * {@code Skill}s and bundles them into a {@code Skills} container (Tool Mode). The + * resulting {@code Skills} provides both the tool provider (the {@code activate_skill} + * / {@code read_skill_resource} tools) and the available-skills catalog the runner + * injects into the system message. + * + * Mirrors {@link ModuleToolAdapter}: core does all filesystem work and passes plain + * DTOs; the plugin builds the langchain4j objects, touching no filesystem path. + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class SkillAdapter { + + /** + * Build a langchain4j {@code Skills} container from the given portable descriptors. + */ + static Skills toSkills(List descriptors) { + final List skills = new ArrayList<>() + for( final SkillDescriptor d : descriptors ) { + final List resources = new ArrayList<>() + if( d.resources ) { + for( final SkillResource r : d.resources ) + resources.add(LcSkillResource.builder().relativePath(r.relativePath).content(r.content).build()) + } + skills.add(Skill.builder() + .name(d.name) + .description(d.description) + .content(d.content) + .resources(resources) + .build()) + } + return Skills.from(skills) + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/AgentEndToEndTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/AgentEndToEndTest.groovy new file mode 100644 index 0000000000..02e270f523 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/AgentEndToEndTest.groovy @@ -0,0 +1,68 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.SysEnv +import groovy.json.JsonSlurper +import spock.lang.Requires +import spock.lang.Specification + +/** + * End-to-end test exercising the real OpenAI integration through the + * langchain4j runner, using structured (JSON-schema) output. Skipped + * automatically when OPENAI_API_KEY is not set (CI and keyless dev + * environments). + */ +@Requires({ System.getenv('OPENAI_API_KEY') }) +class AgentEndToEndTest extends Specification { + + def 'should get a real structured answer from the model'() { + given: + def runner = new LangChainAgentRunner() + def req = new AgentRunnerRequest( + 'openai/gpt-5-mini', + 'You are a terse geography assistant.', + 'What is the capital of France?', + 5, + [], + [ + type: 'object', + properties: [ + capital: [type: 'string'], + country: [type: 'string'], + ], + required: ['capital', 'country'], + additionalProperties: false, + ], + null) + // the runner never reads the environment: core resolves the credential and + // carries it on the request, so a direct-to-runner test must supply it -- through + // SysEnv, the same seam the production ladder uses, so a SysEnv.push here would + // actually take effect + req.apiKey = SysEnv.get('OPENAI_API_KEY') + + when: + def answer = runner.run(req) + + then: + answer != null + !answer.trim().isEmpty() + and: + // the model returns structured JSON matching the requested schema + def parsed = new JsonSlurper().parseText(answer) as Map + parsed.capital.toString().toLowerCase().contains('paris') + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/AgentServiceTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/AgentServiceTest.groovy new file mode 100644 index 0000000000..b93076dbc1 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/AgentServiceTest.groovy @@ -0,0 +1,41 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import java.lang.reflect.Method + +import spock.lang.Specification + +class AgentServiceTest extends Specification { + + def 'should declare a single chat(String):String proxy method'() { + when: + Method[] methods = AgentService.getDeclaredMethods() + + then: 'exactly one declared method' + methods.length == 1 + + and: 'named chat, returning String, taking a single String argument' + final m = methods[0] + m.name == 'chat' + m.returnType == String + m.parameterTypes.toList() == [String] + + and: 'it is an interface with no annotations on the method (avoids @Agent clash)' + AgentService.isInterface() + m.annotations.length == 0 + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/AgentSkillEndToEndTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/AgentSkillEndToEndTest.groovy new file mode 100644 index 0000000000..8d24224dd8 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/AgentSkillEndToEndTest.groovy @@ -0,0 +1,67 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.SysEnv +import spock.lang.Requires +import spock.lang.Specification + +/** + * End-to-end test of the agent SKILLS path through the real OpenAI integration. + * The distinctive `[SEQ-REPORT v1]` output format is defined ONLY inside the skill + * content (never in the instruction or prompt), so its presence in the final answer + * proves the model saw the available-skills catalog, called {@code activate_skill} + * to read the instructions, and followed them. Skipped when OPENAI_API_KEY is unset. + * + * @author Paolo Di Tommaso + */ +@Requires({ System.getenv('OPENAI_API_KEY') }) +class AgentSkillEndToEndTest extends Specification { + + def 'should activate a skill and follow its instructions end-to-end against OpenAI'() { + given: 'the sequence-report skill (same content as the POC example)' + def skill = new SkillDescriptor( + 'sequence-report', + 'Format a sequencing or genome-assembly summary as a standardized QC report. Use this skill whenever the user asks for a sequence, assembly, or read-QC summary or verdict.', + '''When producing a sequencing or assembly summary, format the answer EXACTLY as follows and nothing else: +[SEQ-REPORT v1] +STATUS: +METRICS: +NOTE: ''', + []) + + and: 'a skills-only request whose instruction does NOT mention the report format' + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', + instruction: 'You summarize sequencing and genome-assembly results for bioinformaticians.', + prompt: 'Summarize this assembly: N50 = 45 kb, total length = 5.1 Mb, GC = 50.8%. Is it good enough to proceed?', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: null, dispatch: null, requestTimeoutSeconds: 0, goal: null, + skills: [skill], + // the runner never reads the environment: core resolves the credential and + // carries it on the request, so a direct-to-runner test must supply it -- through + // SysEnv, the same seam the production ladder uses, so a SysEnv.push here would + // actually take effect + apiKey: SysEnv.get('OPENAI_API_KEY')) + + when: + def answer = new LangChainAgentRunner().run(req) + + then: 'the answer follows the skill-dictated format — proving activate_skill fired and was followed' + answer.contains('[SEQ-REPORT v1]') + answer.contains('STATUS:') + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/AgentToolEndToEndTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/AgentToolEndToEndTest.groovy new file mode 100644 index 0000000000..a40f8e4f80 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/AgentToolEndToEndTest.groovy @@ -0,0 +1,85 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.SysEnv +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import spock.lang.Requires +import spock.lang.Specification + +/** + * End-to-end test exercising the FULL tool-call loop through the real OpenAI + * integration: the model is asked to uppercase a word, which it can only do by + * calling the {@code uppercase} tool we advertise; the runner dispatches the + * call back to our {@link ToolDispatcher} and feeds the result back to the model + * until it returns a final answer. Skipped automatically when OPENAI_API_KEY is + * not set (CI and keyless dev environments). + * + * @author Paolo Di Tommaso + */ +@Requires({ System.getenv('OPENAI_API_KEY') }) +class AgentToolEndToEndTest extends Specification { + + private static final Map UPPERCASE_INPUT_SCHEMA = [ + type: 'object', + properties: [text: [type: 'string']], + required: ['text'], + additionalProperties: false, + ] + + def 'should drive the real tool-call loop end-to-end against OpenAI'() { + given: 'a dispatcher that uppercases the text arg and records that it ran' + boolean dispatched = false + ToolDispatcher dispatch = { String name, String argsJson -> + dispatched = true + def args = new JsonSlurper().parseText(argsJson) as Map + def text = args.text as String + return JsonOutput.toJson([result: text.toUpperCase()]) + } as ToolDispatcher + + and: 'a request advertising the single uppercase tool' + def descriptor = new ToolDescriptor( + 'uppercase', + 'Uppercase the given text', + UPPERCASE_INPUT_SCHEMA, + [:]) + def req = new AgentRunnerRequest( + 'openai/gpt-5-mini', + 'To uppercase text you MUST call the uppercase tool, then reply with only the tool\'s result.', + 'uppercase the word hello', + 5, + [], + null, + null, + [descriptor], + dispatch) + // the runner never reads the environment: core resolves the credential and + // carries it on the request, so a direct-to-runner test must supply it -- through + // SysEnv, the same seam the production ladder uses, so a SysEnv.push here would + // actually take effect + req.apiKey = SysEnv.get('OPENAI_API_KEY') + + when: + def answer = new LangChainAgentRunner().run(req) + + then: 'the tool was actually called' + dispatched + + and: 'the final answer reflects the uppercased result' + answer.toUpperCase().contains('HELLO') + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/AgentToolStructuredEndToEndTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/AgentToolStructuredEndToEndTest.groovy new file mode 100644 index 0000000000..23056c18f0 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/AgentToolStructuredEndToEndTest.groovy @@ -0,0 +1,85 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import nextflow.SysEnv +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import spock.lang.Requires +import spock.lang.Specification + +/** + * End-to-end test exercising M5 (tools + structured output together) against the real + * OpenAI integration: the model is asked to uppercase a word, which it can only do by + * calling the advertised {@code uppercase} tool; the tool loop runs schema-free, then a + * final structuring turn re-encodes the free-text answer into JSON matching the declared + * {@code {result:string}} schema. Skipped automatically when OPENAI_API_KEY is not set. + * + * @author Paolo Di Tommaso + */ +@Requires({ System.getenv('OPENAI_API_KEY') }) +class AgentToolStructuredEndToEndTest extends Specification { + + private static final Map UPPERCASE_INPUT_SCHEMA = [ + type: 'object', + properties: [text: [type: 'string']], + required: ['text'], + additionalProperties: false, + ] + + private static final Map RESULT_OUTPUT_SCHEMA = [ + type: 'object', + properties: [result: [type: 'string']], + required: ['result'], + additionalProperties: false, + ] + + def 'should drive the tool loop then structure the final answer end-to-end against OpenAI'() { + given: 'a dispatcher that uppercases the text arg and records that it ran' + boolean dispatched = false + ToolDispatcher dispatch = { String name, String argsJson -> + dispatched = true + def args = new JsonSlurper().parseText(argsJson) as Map + return JsonOutput.toJson([result: (args.text as String).toUpperCase()]) + } as ToolDispatcher + + and: 'a request advertising the uppercase tool AND a structured {result:string} output' + def descriptor = new ToolDescriptor( + 'uppercase', 'Uppercase the given text', UPPERCASE_INPUT_SCHEMA, [:]) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', + instruction: 'To uppercase text you MUST call the uppercase tool, then reply with the tool\'s result.', + prompt: 'uppercase the word hello', + maxIterations: 5, tools: [], outputSchema: RESULT_OUTPUT_SCHEMA, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, agentName: 'uppercaser', + // the runner never reads the environment: core resolves the credential and + // carries it on the request, so a direct-to-runner test must supply it -- through + // SysEnv, the same seam the production ladder uses, so a SysEnv.push here would + // actually take effect + apiKey: SysEnv.get('OPENAI_API_KEY')) + + when: + def answer = new LangChainAgentRunner().run(req) + + then: 'the tool was actually called' + dispatched + + and: 'the final answer is schema-valid JSON whose result is the uppercased word' + def parsed = new JsonSlurper().parseText(answer) as Map + parsed.containsKey('result') + parsed.result.toString().toUpperCase().contains('HELLO') + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/AgentTraceTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/AgentTraceTest.groovy new file mode 100644 index 0000000000..53e97d5165 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/AgentTraceTest.groovy @@ -0,0 +1,120 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import dev.langchain4j.agent.tool.ToolExecutionRequest +import dev.langchain4j.data.message.AiMessage +import dev.langchain4j.data.message.UserMessage +import dev.langchain4j.model.ModelProvider +import dev.langchain4j.model.chat.listener.ChatModelResponseContext +import dev.langchain4j.model.chat.request.ChatRequest +import dev.langchain4j.model.chat.response.ChatResponse +import org.slf4j.LoggerFactory +import spock.lang.Specification + +/** + * @author Paolo Di Tommaso + */ +class AgentTraceTest extends Specification { + + ListAppender appender + Logger logger + + def setup() { + logger = (Logger) LoggerFactory.getLogger(AgentTrace) + logger.setLevel(Level.TRACE) + appender = new ListAppender() + appender.start() + logger.addAppender(appender) + } + + def cleanup() { + logger.detachAppender(appender) + } + + private List lines() { + return appender.list*.formattedMessage + } + + private static ChatModelResponseContext respCtx(AiMessage msg) { + final resp = ChatResponse.builder().aiMessage(msg).build() + final req = ChatRequest.builder().messages(UserMessage.from('x')).build() + return new ChatModelResponseContext(resp, req, ModelProvider.OTHER, [:]) + } + + def 'labels every line with the agent name'() { + when: + new AgentTrace('triage').begin('openai/gpt-5', ['FASTQC']) + then: + lines().every { it.startsWith('[agent triage] ') } + lines().join('\n').contains('model openai/gpt-5') + lines().join('\n').contains('tools: FASTQC') + } + + def 'numbers turns and shows the model reasoning'() { + given: + def trace = new AgentTrace('triage') + def decide = AiMessage.from('I will run FastQC first', [ToolExecutionRequest.builder().name('FASTQC').arguments('{}').build()]) + def finalMsg = AiMessage.from('All samples passed QC') + + when: + trace.onResponse(respCtx(decide)) // turn 1: decides a tool + trace.onResponse(respCtx(finalMsg)) // turn 2: final answer + def out = lines().join('\n') + + then: + out.contains('── turn 1 ──') + out.contains('reasoning: I will run FastQC first') + out.contains('── turn 2 ── (final)') + } + + def 'logs the tool decision digest at INFO and full payloads at DEBUG'() { + when: + new AgentTrace('triage').tool('FASTQC', '{"reads":"/data/s1.fq.gz"}', '{"html":"/work/ab/s1.html"}') + def infos = appender.list.findAll { it.level == Level.INFO }*.formattedMessage + def debugs = appender.list.findAll { it.level == Level.DEBUG }*.formattedMessage + + then: 'INFO shows the decision digest (path as file name), not the raw payloads' + infos.any { it.contains('→ FASTQC(reads=s1.fq.gz)') } + !infos.join('\n').contains('/data/s1.fq.gz') + !infos.join('\n').contains('/work/ab/s1.html') + + and: 'the full raw inputs and outputs are low-level DEBUG' + debugs.any { it.contains('FASTQC input:') && it.contains('/data/s1.fq.gz') } + debugs.any { it.contains('FASTQC output:') && it.contains('/work/ab/s1.html') } + } + + def 'renders a readable argument digest with nested maps'() { + when: + new AgentTrace('a').tool('SKESA', '{"reads":"/work/ab/isolate_001.fastq.gz","meta":{"id":"isolate_001"}}', '{}') + def infos = appender.list.findAll { it.level == Level.INFO }*.formattedMessage + then: + infos.any { it.contains('→ SKESA(reads=isolate_001.fastq.gz, meta={id:isolate_001})') } + } + + def 'renders a multi-line final answer with the answer text'() { + when: + new AgentTrace('triage').end('line one\nline two') + def out = lines().join('\n') + then: + out.contains('final answer: line one') + out.contains('line two') + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/ChatModelFactoryTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/ChatModelFactoryTest.groovy new file mode 100644 index 0000000000..1538d02718 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/ChatModelFactoryTest.groovy @@ -0,0 +1,307 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler +import com.sun.net.httpserver.HttpServer +import dev.langchain4j.model.chat.ChatModel +import dev.langchain4j.model.chat.request.json.JsonSchema +import nextflow.exception.AbortOperationException +import spock.lang.Specification +import spock.lang.Timeout + +class ChatModelFactoryTest extends Specification { + + def 'should split provider and model id'() { + expect: + ChatModelFactory.providerOf('openai/gpt-5-mini') == 'openai' + ChatModelFactory.modelOf('openai/gpt-5-mini') == 'gpt-5-mini' + } + + def 'the prefix this factory reads is the same one core resolves credentials from'() { + given: 'core lower-cases the prefix and this factory did not, so a mixed-case id made them' + // disagree about whose key was in flight -- core resolved and TRANSMITTED an OPENAI_API_KEY + // for a model this factory then rejected as an unsupported provider + expect: + ChatModelFactory.providerOf(MODEL) == AgentConfig.providerPrefixOf(MODEL) + + where: + MODEL << ['openai/gpt-5-mini', 'OpenAI/gpt-4o', 'OPENAI/gpt-4o', 'Anthropic/claude-sonnet-4'] + } + + def 'a mixed-case openai prefix builds a model instead of being refused'() { + when: 'the direct consequence of the agreement above' + ChatModel model = new ChatModelFactory().createModel('OpenAI/gpt-4o', 30, null, (Double) null, 'sk-test', null, null, false) + then: + model != null + } + + def 'should fail for a model id without a provider'() { + when: + ChatModelFactory.providerOf('gpt-5-mini') + then: + thrown(IllegalArgumentException) + } + + def 'should fail for an unknown provider'() { + when: + new ChatModelFactory().createModel('acme/whatever', 30, null, (Double) null, 'sk-test', null, null, false) + then: + def e = thrown(IllegalArgumentException) + e.message.toLowerCase().contains('provider') + and: 'the message says it is the WIRE PROTOCOL that is unsupported, and points at baseUrl' + e.message.contains('wire protocol') + e.message.contains('agent.baseUrl') + } + + def 'should fail when neither a credential nor an endpoint resolves'() { + when: + new ChatModelFactory().createModel('openai/gpt-5-mini', 30, null, (Double) null, null, null, null, false) + then: + def e = thrown(IllegalArgumentException) + and: 'the message names the whole resolution ladder, not one variable' + e.message.contains('agent.apiKey') + e.message.contains('NXF_AGENT_API_KEY') + e.message.contains('OPENAI_API_KEY') + and: 'plus the one rung this factory cannot see -- the namespace an explicit option redirected to' + // credentialFor has already aborted for an endpoint whose HOST names a provider, so the + // only way the ladder read something other than the OPENAI_ variables and still arrived + // here empty is an `agent.apiProvider` the request does not carry + e.message.contains('agent.apiProvider') + } + + def 'should refuse to present a placeholder to a well-known provider endpoint'() { + given: 'design D5. The placeholder assumes the endpoint needs no credential -- true of a' + // local vLLM/Ollama, plainly false of a provider's own API. Substituting it there buys an + // opaque 401 one request later instead of a diagnosis now. + when: + new ChatModelFactory().createModel('openai/gpt-4o', 30, null, (Double) null, null, ENDPOINT, null, false) + + then: + def e = thrown(AbortOperationException) + e.message.contains(PROVIDER) + e.message.contains(ENDPOINT) + e.message.contains(VAR) + + where: + ENDPOINT | PROVIDER | VAR + 'https://api.openai.com/v1' | 'openai' | 'OPENAI_API_KEY' + 'https://openrouter.ai/api/v1' | 'openrouter' | 'OPENROUTER_API_KEY' + } + + @Timeout(30) + def 'the widened credential ladder reaches the builder for a non-openai namespace'() { + given: 'the whole point of D1/D2: `openai` is the WIRE PROTOCOL, and the credential comes' + // from whichever namespace `agent.apiProvider` names -- OpenRouter here, which the openai + // carve-out had no spelling for at all. Core resolves it; this factory only presents it. + def authorizations = Collections.synchronizedList([]) + def server = HttpServer.create(new InetSocketAddress(0), 0) + server.createContext('/', new HttpHandler() { + @Override + void handle(HttpExchange exchange) { + authorizations.add(exchange.requestHeaders.getFirst('Authorization')) + final body = '{"id":"1","object":"chat.completion","created":0,"model":"local","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}' + exchange.responseHeaders.add('Content-Type', 'application/json') + exchange.sendResponseHeaders(200, body.bytes.length) + exchange.responseBody.withCloseable { it.write(body.bytes) } + } + }) + server.start() + + and: 'the endpoint stands in for the OpenRouter gateway, vouched for by an explicit namespace' + def endpoint = "http://localhost:${server.address.port}/v1".toString() + def config = new AgentConfig([apiProvider: 'openrouter', baseUrl: endpoint], + [OPENROUTER_API_KEY: 'sk-or-resolved', OPENAI_API_KEY: 'sk-openai-must-not-travel']) + + when: 'the pair core resolved is handed to the factory, as LangChainAgentRunner does' + def model = new ChatModelFactory().createModel('openai/gpt-4o', 30, null, (Double) null, + config.apiKeyFor('openai/gpt-4o'), config.baseUrlFor('openai/gpt-4o'), + config.apiProviderFor('openai/gpt-4o'), config.credentialWithheldFor('openai/gpt-4o')) + def answer = model.chat('ping') + + then: 'the OpenRouter credential is what was presented, and the OpenAI one never left the driver' + answer == 'pong' + authorizations == ['Bearer sk-or-resolved'] + + cleanup: + server?.stop(0) + } + + def 'should build a model with an endpoint and no credential (no network call)'() { + when: 'a local endpoint needing no credential - a placeholder is sent instead of failing' + ChatModel model = new ChatModelFactory().createModel('openai/llama-3.3-70b', 30, null, (Double) null, null, 'http://localhost:8000/v1', null, false) + then: + noExceptionThrown() + model != null + } + + def 'should build an openai model when api key is present (no network call)'() { + given: + def factory = new ChatModelFactory() + when: + ChatModel model = factory.createModel('openai/gpt-5-mini', 30, null, (Double) null, 'sk-test', null, null, false) + then: + model != null + } + + @Timeout(30) + def 'should send the chat request to the configured endpoint'() { + given: 'a loopback stub standing in for an OpenAI-compatible endpoint (vLLM, Ollama, a gateway)' + String requestedPath = null + String authorization = null + def server = HttpServer.create(new InetSocketAddress(0), 0) + server.createContext('/', new HttpHandler() { + @Override + void handle(HttpExchange exchange) { + requestedPath = exchange.requestURI.path + authorization = exchange.requestHeaders.getFirst('Authorization') + final body = '{"id":"1","object":"chat.completion","created":0,"model":"local","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}' + exchange.responseHeaders.add('Content-Type', 'application/json') + exchange.sendResponseHeaders(200, body.bytes.length) + exchange.responseBody.withCloseable { it.write(body.bytes) } + } + }) + server.start() + + and: 'no credential resolved, so the placeholder is sent (D8)' + def model = new ChatModelFactory().createModel('openai/llama-3.3-70b', 30, null, (Double) null, null, "http://localhost:${server.address.port}/v1".toString(), null, false) + + when: + def answer = model.chat('ping') + + then: 'the request went to the configured endpoint, not to api.openai.com' + answer == 'pong' + requestedPath == '/v1/chat/completions' + and: 'carrying the placeholder SHARED with the pi runner, so the two cannot drift' + authorization == "Bearer ${AgentRunnerRequest.PLACEHOLDER_API_KEY}" + + cleanup: + server?.stop(0) + } + + @Timeout(30) + def 'one shared factory serves two different credentials (per-call, not per-instance)'() { + given: 'LangChainAgentRunner holds ONE long-lived factory shared by concurrently executing' + // agent tasks, so a per-request credential kept as a FIELD would be a data race across + // parallel agents -- and a field initialized from the environment would put a second, + // divergent resolution ladder next to the core one (design D4) + def authorizations = Collections.synchronizedList([]) + def server = HttpServer.create(new InetSocketAddress(0), 0) + server.createContext('/', new HttpHandler() { + @Override + void handle(HttpExchange exchange) { + authorizations.add(exchange.requestHeaders.getFirst('Authorization')) + final body = '{"id":"1","object":"chat.completion","created":0,"model":"local","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}' + exchange.responseHeaders.add('Content-Type', 'application/json') + exchange.sendResponseHeaders(200, body.bytes.length) + exchange.responseBody.withCloseable { it.write(body.bytes) } + } + }) + server.start() + and: + def endpoint = "http://localhost:${server.address.port}/v1".toString() + def factory = new ChatModelFactory() + + when: 'the SAME factory builds two models with different credentials' + factory.createModel('openai/gpt-5-mini', 30, null, (Double) null, 'sk-alpha', endpoint, null, false).chat('ping') + factory.createModel('openai/gpt-5-mini', 30, null, (Double) null, 'sk-beta', endpoint, null, false).chat('ping') + + then: 'each request carried its own key -- no cross-talk, and no placeholder substituted' + authorizations == ['Bearer sk-alpha', 'Bearer sk-beta'] + + cleanup: + server?.stop(0) + } + + def 'should build an openai model with an explicit temperature (no network call)'() { + given: + def factory = new ChatModelFactory() + when: 'a pinned temperature is applied on the builder (0.0 must not throw)' + ChatModel model = factory.createModel('openai/gpt-4o', 30, null, 0.0d, 'sk-test', null, null, false) + then: + model != null + } + + def 'a WITHHELD credential is a distinct, fatal, named failure -- never a placeholder'() { + given: 'the driver resolved OPENAI_API_KEY and the endpoint gate refused to send it to a' + // gateway OpenAI does not own. Substituting the D8 placeholder there guarantees an opaque + // 401 where a diagnosis is available; langchain4j has no other credential source, so it is + // fatal here -- deliberately UNLIKE the pi runner, which only warns. + def config = new AgentConfig([baseUrl: 'https://gw.corp/v1'], [OPENAI_API_KEY: 'sk-oai']) + + when: + new ChatModelFactory().createModel('openai/gpt-4o', 30, null, (Double) null, + config.apiKeyFor('openai/gpt-4o'), config.baseUrlFor('openai/gpt-4o'), + config.apiProviderFor('openai/gpt-4o'), config.credentialWithheldFor('openai/gpt-4o')) + + then: + def e = thrown(IllegalArgumentException) + and: 'it names the three ways out, and the endpoint the credential was refused for' + e.message.contains('`agent.apiKey`') + e.message.contains('NXF_AGENT_API_KEY') + e.message.contains('agent.apiProvider') + e.message.contains('https://gw.corp/v1') + and: 'and it is NOT the generic "missing credential" message: one was found' + !e.message.contains('Missing LLM provider credential') + } + + def 'the withheld error names the default endpoint when no baseUrl resolved'() { + given: 'the F1 case: an apiProvider that is not the model prefix, so the request would go' + // to the prefix provider's own default endpoint with somebody else's key + def config = new AgentConfig([apiProvider: 'openrouter'], [OPENROUTER_API_KEY: 'sk-or']) + + when: + new ChatModelFactory().createModel('openai/gpt-4o', 30, null, (Double) null, + config.apiKeyFor('openai/gpt-4o'), config.baseUrlFor('openai/gpt-4o'), + config.apiProviderFor('openai/gpt-4o'), config.credentialWithheldFor('openai/gpt-4o')) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('`openrouter`') + e.message.contains('default `openai` endpoint') + } + + def 'the missing-credential message names the RESOLVED namespace, not always OpenAI'() { + when: 'agent.apiProvider redirected the namespace and nothing was exported for it' + // the old message asserted OPENAI_API_KEY unconditionally -- the very thing its own comment + // claimed to avoid + new ChatModelFactory().createModel('openai/gpt-4o', 30, null, (Double) null, null, null, 'mistral', false) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains('MISTRAL_API_KEY') + e.message.contains('NXF_AGENT_API_KEY') + e.message.contains('agent.apiProvider') + and: 'the variable that was NOT read is not suggested' + !e.message.contains('OPENAI_API_KEY') + } + + def 'should build an openai model with a structured-output schema (no network call)'() { + given: + def factory = new ChatModelFactory() + def schema = JsonSchemaMapper.toJsonSchema('Answer', [ + type: 'object', + properties: [answer: [type: 'string']], + required: ['answer'], + additionalProperties: false, + ]) + when: + ChatModel model = factory.createModel('openai/gpt-5-mini', 30, schema, (Double) null, 'sk-test', null, null, false) + then: + model != null + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/JsonSchemaMapperTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/JsonSchemaMapperTest.groovy new file mode 100644 index 0000000000..868a8ea990 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/JsonSchemaMapperTest.groovy @@ -0,0 +1,210 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.model.chat.request.json.JsonArraySchema +import dev.langchain4j.model.chat.request.json.JsonBooleanSchema +import dev.langchain4j.model.chat.request.json.JsonEnumSchema +import dev.langchain4j.model.chat.request.json.JsonIntegerSchema +import dev.langchain4j.model.chat.request.json.JsonNumberSchema +import dev.langchain4j.model.chat.request.json.JsonObjectSchema +import dev.langchain4j.model.chat.request.json.JsonSchema +import dev.langchain4j.model.chat.request.json.JsonStringSchema +import spock.lang.Specification + +class JsonSchemaMapperTest extends Specification { + + def 'should map a flat object schema to a JsonSchema'() { + given: + def schema = [ + type: 'object', + properties: [ + answer : [type: 'string'], + confidence: [type: 'number'], + ], + required: ['answer'], + additionalProperties: false, + ] + + when: + JsonSchema result = JsonSchemaMapper.toJsonSchema('Answer', schema) + + then: + result != null + result.name() == 'Answer' + and: + result.rootElement() instanceof JsonObjectSchema + def root = result.rootElement() as JsonObjectSchema + root.properties().keySet() == ['answer', 'confidence'] as Set + root.properties().get('answer') instanceof JsonStringSchema + root.properties().get('confidence') instanceof JsonNumberSchema + root.required() == ['answer'] + root.additionalProperties() == Boolean.FALSE + } + + def 'should map all scalar property types'() { + given: + def schema = [ + type: 'object', + properties: [ + s: [type: 'string'], + i: [type: 'integer'], + n: [type: 'number'], + b: [type: 'boolean'], + ], + required: ['s', 'i', 'n', 'b'], + ] + + when: + def root = JsonSchemaMapper.toJsonSchema('All', schema).rootElement() as JsonObjectSchema + + then: + root.properties().get('s') instanceof JsonStringSchema + root.properties().get('i') instanceof JsonIntegerSchema + root.properties().get('n') instanceof JsonNumberSchema + root.properties().get('b') instanceof JsonBooleanSchema + } + + def 'should map an array property recursing on items'() { + given: + def schema = [ + type: 'object', + properties: [ + tags: [type: 'array', items: [type: 'string']], + ], + ] + + when: + def root = JsonSchemaMapper.toJsonSchema('Tagged', schema).rootElement() as JsonObjectSchema + + then: + root.properties().get('tags') instanceof JsonArraySchema + (root.properties().get('tags') as JsonArraySchema).items() instanceof JsonStringSchema + } + + def 'should map a nested object property recursively'() { + given: + def schema = [ + type: 'object', + properties: [ + inner: [ + type: 'object', + properties: [name: [type: 'string']], + required: ['name'], + ], + ], + ] + + when: + def root = JsonSchemaMapper.toJsonSchema('Outer', schema).rootElement() as JsonObjectSchema + + then: + root.properties().get('inner') instanceof JsonObjectSchema + def inner = root.properties().get('inner') as JsonObjectSchema + inner.properties().get('name') instanceof JsonStringSchema + inner.required() == ['name'] + } + + def 'should fail on an unsupported property type'() { + when: + JsonSchemaMapper.toJsonSchema('Bad', [type: 'object', properties: [x: [type: 'whatever']]]) + + then: + thrown(IllegalArgumentException) + } + + def 'should propagate per-field descriptions, nested object properties and enum'() { + given: + def schema = [ + type: 'object', + properties: [ + meta : [ + type : 'object', + description : 'Groovy Map ... e.g. [id:..]', + properties : [id: [type: 'string', description: 'sample identifier']], + additionalProperties: true, + ], + reads: [type: 'string', description: 'input reads (file path)'], + ], + required: ['meta', 'reads'], + additionalProperties: false, + ] + + when: + def root = JsonSchemaMapper.toObjectSchema(schema) + + then: 'scalar property carries its description' + def reads = root.properties().get('reads') as JsonStringSchema + reads.description() == 'input reads (file path)' + + and: 'object property recurses and carries its description' + def meta = root.properties().get('meta') as JsonObjectSchema + meta instanceof JsonObjectSchema + meta.description() == 'Groovy Map ... e.g. [id:..]' + meta.additionalProperties() == Boolean.TRUE + def id = meta.properties().get('id') as JsonStringSchema + id instanceof JsonStringSchema + id.description() == 'sample identifier' + + and: 'root structure preserved' + root.required() == ['meta', 'reads'] + root.additionalProperties() == Boolean.FALSE + } + + def 'should map a string with enum to an enum schema'() { + given: + def schema = [ + type: 'object', + properties: [ + mode: [type: 'string', enum: ['fast', 'slow'], description: 'run mode'], + ], + ] + + when: + def root = JsonSchemaMapper.toObjectSchema(schema) + + then: + def mode = root.properties().get('mode') + mode instanceof JsonEnumSchema + (mode as JsonEnumSchema).enumValues() == ['fast', 'slow'] + (mode as JsonEnumSchema).description() == 'run mode' + } + + def 'should propagate descriptions on all scalar types and arrays'() { + given: + def schema = [ + type: 'object', + properties: [ + s : [type: 'string', description: 'a string'], + i : [type: 'integer', description: 'an integer'], + n : [type: 'number', description: 'a number'], + b : [type: 'boolean', description: 'a boolean'], + tags: [type: 'array', description: 'a list', items: [type: 'string']], + ], + ] + + when: + def root = JsonSchemaMapper.toObjectSchema(schema) + + then: + (root.properties().get('s') as JsonStringSchema).description() == 'a string' + (root.properties().get('i') as JsonIntegerSchema).description() == 'an integer' + (root.properties().get('n') as JsonNumberSchema).description() == 'a number' + (root.properties().get('b') as JsonBooleanSchema).description() == 'a boolean' + (root.properties().get('tags') as JsonArraySchema).description() == 'a list' + (root.properties().get('tags') as JsonArraySchema).items() instanceof JsonStringSchema + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentRunnerTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentRunnerTest.groovy new file mode 100644 index 0000000000..b8fc1886d4 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentRunnerTest.groovy @@ -0,0 +1,364 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.data.message.AiMessage +import dev.langchain4j.data.message.ChatMessage +import dev.langchain4j.data.message.SystemMessage +import dev.langchain4j.data.message.UserMessage +import dev.langchain4j.model.chat.ChatModel +import dev.langchain4j.model.chat.request.json.JsonObjectSchema +import dev.langchain4j.model.chat.request.json.JsonSchema +import dev.langchain4j.model.chat.response.ChatResponse +import dev.langchain4j.model.chat.response.ChatResponseMetadata +import spock.lang.Specification + +class LangChainAgentRunnerTest extends Specification { + + def cleanup() { + AgentCallInfo.clear() + } + + private static final Map ANSWER_SCHEMA = [ + type: 'object', + properties: [answer: [type: 'string'], confidence: [type: 'number']], + required: ['answer', 'confidence'], + additionalProperties: false, + ] + + def 'should compose prompt + input JSON, pass the schema, and return the assistant JSON'() { + given: + List captured = null + // langchain4j ChatModel has no single abstract method (all default), so + // mock it by overriding the chat(List) entry point used by the runner. + ChatModel model = [ + chat: { List messages -> + captured = messages + ChatResponse.builder().aiMessage(AiMessage.from('{"answer":"ok","confidence":0.9}')).build() + } + ] as ChatModel + + and: + JsonSchema capturedSchema = null + def factory = Stub(ChatModelFactory) { + // createModel(modelId, timeout, schema, temperature, apiKey, baseUrl[, listeners]) + createModel(*_) >> { args -> + capturedSchema = args[2] as JsonSchema + model + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + def req = new AgentRunnerRequest( + 'openai/gpt-5-mini', + 'inst', + 'the prompt', + 5, + [], + ANSWER_SCHEMA, + '{"text":"hi"}') + + when: + def answer = runner.run(req) + + then: + answer == '{"answer":"ok","confidence":0.9}' + + and: 'the schema was passed through to the model factory' + capturedSchema != null + capturedSchema.rootElement() instanceof JsonObjectSchema + (capturedSchema.rootElement() as JsonObjectSchema).properties().containsKey('answer') + + and: 'the user message carries both the prompt and the input JSON' + captured.size() == 2 + captured[0] instanceof SystemMessage + (captured[0] as SystemMessage).text() == 'inst' + captured[1] instanceof UserMessage + def userText = (captured[1] as UserMessage).singleText() + userText.contains('the prompt') + userText.contains('{"text":"hi"}') + } + + def 'should omit the system message and the input JSON when not provided'() { + given: + List captured = null + ChatModel model = [ + chat: { List messages -> + captured = messages + ChatResponse.builder().aiMessage(AiMessage.from('{"answer":"ok"}')).build() + } + ] as ChatModel + + and: + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { + createModel(*_) >> model + }) + def req = new AgentRunnerRequest('openai/gpt-5-mini', null, 'just a prompt', 5, [], null, null) + + when: + def answer = runner.run(req) + + then: + answer == '{"answer":"ok"}' + captured.size() == 1 + captured[0] instanceof UserMessage + (captured[0] as UserMessage).singleText() == 'just a prompt' + } + + def 'should fold goal into the system message on the single-shot path'() { + given: + List captured = null + ChatModel model = [ + chat: { List messages -> + captured = messages + ChatResponse.builder().aiMessage(AiMessage.from('result')).build() + } + ] as ChatModel + + and: + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { + createModel(*_) >> model + }) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'Be helpful.', prompt: 'do it', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: null, dispatch: null, requestTimeoutSeconds: 0, + goal: 'reach the summit') + + when: + def answer = runner.run(req) + + then: + answer == 'result' + + and: 'exactly one system message carrying both instruction and goal' + captured.count { it instanceof SystemMessage } == 1 + def sys = captured.find { it instanceof SystemMessage } as SystemMessage + sys.text().contains('Be helpful.') + sys.text().contains('reach the summit') + + and: 'the user message follows' + captured.count { it instanceof UserMessage } == 1 + (captured.find { it instanceof UserMessage } as UserMessage).singleText() == 'do it' + } + + def 'should fail when the model is missing'() { + given: + def runner = new LangChainAgentRunner() + def req = new AgentRunnerRequest(null, 'inst', 'prompt', 5, [], null, null) + + when: + runner.run(req) + + then: + thrown(IllegalArgumentException) + } + + def 'should pass the configured request timeout through to the model factory'() { + given: + ChatModel model = [ + chat: { List messages -> + ChatResponse.builder().aiMessage(AiMessage.from('ok')).build() + } + ] as ChatModel + + and: + int capturedTimeout = -1 + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + capturedTimeout = args[1] as int + model + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + // 10th positional arg = requestTimeoutSeconds + def req = new AgentRunnerRequest('openai/gpt-5-mini', null, 'prompt', 5, [], null, null, null, null, 90) + + when: + runner.run(req) + + then: + capturedTimeout == 90 + } + + def 'should fall back to the default request timeout when none is configured'() { + given: + ChatModel model = [ + chat: { List messages -> + ChatResponse.builder().aiMessage(AiMessage.from('ok')).build() + } + ] as ChatModel + + and: + int capturedTimeout = -1 + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + capturedTimeout = args[1] as int + model + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + // requestTimeoutSeconds left at the default 0 -> built-in default (120) + def req = new AgentRunnerRequest('openai/gpt-5-mini', null, 'prompt', 5, [], null, null) + + when: + runner.run(req) + + then: + capturedTimeout == 120 + } + + def 'should thread the request temperature through on the single-shot path'() { + given: + ChatModel model = [ + chat: { List messages -> + ChatResponse.builder().aiMessage(AiMessage.from('ok')).build() + } + ] as ChatModel + + and: + Double capturedTemp = -999d + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + capturedTemp = args[3] as Double + model + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + // a tool-free single-shot request carrying a pinned temperature (task path pins 0.0) + def req = new AgentRunnerRequest(model: 'openai/gpt-5-mini', prompt: 'prompt', temperature: 0.0d) + + when: + runner.run(req) + + then: 'the request temperature is threaded through (boxed, non-null) to the model factory' + capturedTemp == 0.0d + capturedTemp instanceof Double + } + + def 'should capture the resolved model snapshot into AgentCallInfo after model.chat (design §9.5/D6)'() { + given: 'a model whose response metadata carries the concrete snapshot' + ChatModel model = [ + chat: { List messages -> + ChatResponse.builder() + .aiMessage(AiMessage.from('ok')) + .metadata(ChatResponseMetadata.builder().modelName('gpt-4o-2024-08-06').build()) + .build() + } + ] as ChatModel + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { + createModel(*_) >> model + }) + def req = new AgentRunnerRequest(model: 'openai/gpt-4o', prompt: 'p') + + when: + runner.run(req) + + then: 'the concrete snapshot crossed the SPI via the core-owned ThreadLocal' + AgentCallInfo.consumeResolvedModel() == 'gpt-4o-2024-08-06' + } + + def 'should leave temperature unset (null) on the legacy tools path'() { + given: 'a request advertising a tool so the runner takes the runWithTools path' + Double capturedTemp = -999d + ChatModel model = [ + chat: { List messages -> + ChatResponse.builder().aiMessage(AiMessage.from('ok')).build() + } + ] as ChatModel + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + // buildChatModel passes (modelId, timeout, null, (Double)null[, listeners]) + capturedTemp = args[3] as Double + model + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + def descriptor = new ToolDescriptor('noop', 'a tool', [type: 'object', properties: [:], required: [], additionalProperties: false], [:]) + def dispatch = { String name, String argsJson -> '{}' } as ToolDispatcher + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', prompt: 'prompt', maxIterations: 3, + toolSpecs: [descriptor], dispatch: dispatch) + + when: 'the tool loop is driven (the AiServices call may fail on the bare model stub - irrelevant here)' + try { runner.run(req) } catch( Throwable ignored ) {} + + then: 'buildChatModel invoked createModel with a null temperature (legacy behavior unchanged)' + capturedTemp == null + } + + def 'should thread the resolved credential and endpoint through on the single-shot path'() { + given: + ChatModel model = [ + chat: { List messages -> + ChatResponse.builder().aiMessage(AiMessage.from('ok')).build() + } + ] as ChatModel + + and: 'the runner must not read the environment: both values come from the request' + String capturedKey = null + String capturedUrl = null + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + capturedKey = args[4] as String + capturedUrl = args[5] as String + model + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', prompt: 'prompt', + apiKey: 'sk-from-config', baseUrl: 'http://localhost:8000/v1') + + when: + runner.run(req) + + then: + capturedKey == 'sk-from-config' + capturedUrl == 'http://localhost:8000/v1' + } + + def 'should thread the resolved credential and endpoint through on the tools path'() { + given: 'a request advertising a tool so the runner takes the runWithTools path' + String capturedKey = null + String capturedUrl = null + ChatModel model = [ + chat: { List messages -> + ChatResponse.builder().aiMessage(AiMessage.from('ok')).build() + } + ] as ChatModel + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + capturedKey = args[4] as String + capturedUrl = args[5] as String + model + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + def descriptor = new ToolDescriptor('noop', 'a tool', [type: 'object', properties: [:], required: [], additionalProperties: false], [:]) + def dispatch = { String name, String argsJson -> '{}' } as ToolDispatcher + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', prompt: 'prompt', maxIterations: 3, + toolSpecs: [descriptor], dispatch: dispatch, + apiKey: 'sk-from-config', baseUrl: 'http://localhost:8000/v1') + + when: 'the tool loop is driven (the AiServices call may fail on the bare model stub - irrelevant here)' + try { runner.run(req) } catch( Throwable ignored ) {} + + then: 'buildChatModel forwarded both, before the trailing listeners argument' + capturedKey == 'sk-from-config' + capturedUrl == 'http://localhost:8000/v1' + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentToolLoopTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentToolLoopTest.groovy new file mode 100644 index 0000000000..ee0bbf6f0d --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentToolLoopTest.groovy @@ -0,0 +1,492 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.agent.tool.ToolExecutionRequest +import dev.langchain4j.data.message.AiMessage +import dev.langchain4j.data.message.ChatMessage +import dev.langchain4j.data.message.SystemMessage +import dev.langchain4j.data.message.ToolExecutionResultMessage +import dev.langchain4j.data.message.UserMessage +import dev.langchain4j.model.chat.ChatModel +import dev.langchain4j.model.chat.request.ChatRequest +import dev.langchain4j.model.chat.request.json.JsonSchema +import dev.langchain4j.model.chat.response.ChatResponse +import spock.lang.Specification + +/** + * Exercises {@link LangChainAgentRunner#runWithTools} which is now driven by a + * langchain4j {@code AiServices} proxy. AiServices routes the LLM call through + * {@code ChatModel.chat(ChatRequest)}, so a Groovy-closure-coerced ChatModel + * mock overriding that overload remains the injection seam. AiServices builds + * each ChatRequest from the shared (seeded) chat memory, so each request's + * {@code messages()} snapshot reflects the accumulating memory contents. + */ +class LangChainAgentToolLoopTest extends Specification { + + private static final Map GREET_INPUT_SCHEMA = [ + type: 'object', + properties: [name: [type: 'string']], + required: ['name'], + additionalProperties: false, + ] + + private static final Map GREET_OUTPUT_SCHEMA = [ + type: 'object', + properties: [greeting: [type: 'string']], + required: ['greeting'], + additionalProperties: false, + ] + + def 'should drive the AiServices tool loop: call dispatch then return the final text'() { + given: 'a mock model that requests the greet tool first, then answers' + List capturedRequests = [] + int calls = 0 + // AiServices invokes chat(ChatRequest); override only that overload. + ChatModel model = [ + chat: { ChatRequest req -> + // snapshot the memory-derived messages as the proxy sees them + capturedRequests << req + calls++ + if( calls == 1 ) { + // first turn: ask to run the greet tool + final ter = ToolExecutionRequest.builder() + .id('call-1') + .name('greet') + .arguments('{"name":"Ada"}') + .build() + return ChatResponse.builder().aiMessage(AiMessage.from([ter])).build() + } + // second turn: final plain-text answer (no tool requests) + return ChatResponse.builder().aiMessage(AiMessage.from('Final: greeted Ada')).build() + } + ] as ChatModel + + and: 'a stub dispatcher recording the call and returning a canned JSON result' + List> dispatched = [] + ToolDispatcher dispatch = { String name, String args -> + dispatched << [name, args] + return '{"greeting":"Hello Ada!"}' + } as ToolDispatcher + + and: 'the runner wired to a factory that never forces a responseFormat' + JsonSchema capturedSchema = null + boolean factoryCalled = false + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + factoryCalled = true + capturedSchema = args[2] as JsonSchema + model + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + + and: 'a request carrying the greet tool spec and the dispatch callback' + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, GREET_OUTPUT_SCHEMA) + def req = new AgentRunnerRequest( + 'openai/gpt-5-mini', + 'Use the greet tool.', + 'greet Ada', + 5, + [], + null, + null, + [descriptor], + dispatch) + + when: + def answer = runner.run(req) + + then: 'the final text is returned' + answer == 'Final: greeted Ada' + + and: 'the dispatcher was called exactly once with the right name and args' + dispatched == [['greet', '{"name":"Ada"}']] + + and: 'the model was chatted twice (one round-trip per tool turn plus the final)' + calls == 2 + + and: 'tools were advertised on every request, including the greet spec' + capturedRequests.size() == 2 + capturedRequests.every { it.toolSpecifications() && it.toolSpecifications()*.name().contains('greet') } + + and: 'no structured-output schema was forced (tools XOR responseFormat)' + factoryCalled + capturedSchema == null + + and: 'the FIRST request memory held exactly: system message + one user message (full prompt)' + List first = capturedRequests[0].messages() + first.count { it instanceof SystemMessage } == 1 + first.count { it instanceof UserMessage } == 1 + (first.find { it instanceof SystemMessage } as SystemMessage).text() == 'Use the greet tool.' + (first.find { it instanceof UserMessage } as UserMessage).singleText() == 'greet Ada' + + and: 'the SECOND request memory grew with the assistant tool-request turn and the tool result' + List second = capturedRequests[1].messages() + // still exactly one user message — no prompt duplication + second.count { it instanceof UserMessage } == 1 + // the assistant turn carrying the tool request is present + second.find { it instanceof AiMessage && (it as AiMessage).hasToolExecutionRequests() } != null + // the tool result message was fed back with the right name and JSON payload + def resultMsg = second.find { it instanceof ToolExecutionResultMessage } as ToolExecutionResultMessage + resultMsg != null + resultMsg.toolName() == 'greet' + resultMsg.text() == '{"greeting":"Hello Ada!"}' + + and: 'the original user prompt persisted unchanged across the loop' + (second.find { it instanceof UserMessage } as UserMessage).singleText() == 'greet Ada' + } + + def 'should enter the tool loop for a skills-only request and inject the available-skills catalog'() { + given: 'a mock model that answers immediately (no tool calls) and captures the request' + List capturedRequests = [] + ChatModel model = [ + chat: { ChatRequest req -> + capturedRequests << req + ChatResponse.builder().aiMessage(AiMessage.from('answered')).build() + } + ] as ChatModel + + and: 'a runner with a skills-only request (no toolSpecs, no dispatch)' + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { createModel(*_) >> model }) + def skill = new SkillDescriptor('greet', 'a greeting skill', 'say hi politely', []) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'You are helpful.', prompt: 'greet Ada', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: null, dispatch: null, requestTimeoutSeconds: 0, goal: null, + skills: [skill]) + + when: + def answer = runner.run(req) + + then: 'the runWithTools path ran (model chatted) and returned the final text' + answer == 'answered' + capturedRequests.size() >= 1 + + and: 'the system message carries the instruction + the available-skills catalog' + def sys = capturedRequests[0].messages().find { it instanceof SystemMessage } as SystemMessage + sys != null + sys.text().contains('You are helpful.') + sys.text().contains('greet') + sys.text().contains('a greeting skill') + } + + def 'should compose user text with the input JSON as a single user message'() { + given: 'a model that answers immediately on the first turn' + List capturedRequests = [] + ChatModel model = [ + chat: { ChatRequest req -> + capturedRequests << req + return ChatResponse.builder().aiMessage(AiMessage.from('done')).build() + } + ] as ChatModel + + and: + ToolDispatcher dispatch = { String name, String args -> '{}' } as ToolDispatcher + def factory = Stub(ChatModelFactory) { createModel(*_) >> model } + def runner = new LangChainAgentRunner(modelFactory: factory) + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, null) + def req = new AgentRunnerRequest( + 'openai/gpt-5-mini', + 'sys', + 'do it', + 5, + [], + null, + '{"k":"v"}', + [descriptor], + dispatch) + + when: + def answer = runner.run(req) + + then: 'the answer is returned' + answer == 'done' + + and: 'exactly one user message carrying prompt + input JSON' + List msgs = capturedRequests[0].messages() + msgs.count { it instanceof UserMessage } == 1 + (msgs.find { it instanceof UserMessage } as UserMessage).singleText() == 'do it\n\nInput (JSON):\n{"k":"v"}' + } + + def 'should succeed with exactly one tool round-trip when maxIterations=1 (verifies the +1 cap offset)'() { + // Rationale: maxIterations=1 → maxSequentialToolsInvocations is passed as 2. + // A cap of 2 permits one tool round-trip (tool call + final answer). + // If the code wrongly passed maxIterations (=1) the cap would be 1, which + // allows zero round-trips, and the run would throw before the tool executes. + // This test therefore fails if the "+1" in maxIterations+1 is dropped. + given: 'a model that requests the greet tool on the first call, then answers' + int calls = 0 + ChatModel model = [ + chat: { ChatRequest req -> + calls++ + if( calls == 1 ) { + final ter = ToolExecutionRequest.builder() + .id('call-boundary') + .name('greet') + .arguments('{"name":"Boundary"}') + .build() + return ChatResponse.builder().aiMessage(AiMessage.from([ter])).build() + } + // second turn: final plain-text answer + return ChatResponse.builder().aiMessage(AiMessage.from('Boundary answer')).build() + } + ] as ChatModel + + and: + int dispatchCalls = 0 + ToolDispatcher dispatch = { String name, String args -> dispatchCalls++; '{"greeting":"Hello Boundary!"}' } as ToolDispatcher + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { + createModel(*_) >> model + }) + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, null) + // maxIterations=1: only one tool round-trip is permitted + def req = new AgentRunnerRequest('openai/gpt-5-mini', null, 'greet Boundary', 1, [], null, null, [descriptor], dispatch) + + when: + def answer = runner.run(req) + + then: 'the run succeeds and returns the final text' + answer == 'Boundary answer' + + and: 'the dispatcher was called exactly once' + dispatchCalls == 1 + } + + def 'should throw IllegalStateException when the iteration cap is exceeded without a final answer'() { + given: 'a model that always requests the tool, never answering' + ChatModel model = [ + chat: { ChatRequest req -> + final ter = ToolExecutionRequest.builder() + .id('loop') + .name('greet') + .arguments('{"name":"Ada"}') + .build() + ChatResponse.builder().aiMessage(AiMessage.from([ter])).build() + } + ] as ChatModel + + and: + int dispatchCalls = 0 + ToolDispatcher dispatch = { String name, String args -> dispatchCalls++; '{"greeting":"Hello"}' } as ToolDispatcher + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { + createModel(*_) >> model + }) + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, null) + def req = new AgentRunnerRequest('openai/gpt-5-mini', null, 'greet Ada', 2, [], null, null, [descriptor], dispatch) + + when: + runner.run(req) + + then: 'the cap RuntimeException is surfaced as the historical IllegalStateException' + def e = thrown(IllegalStateException) + e.message == 'Agent exceeded the maximum number of tool-call iterations (2)' + } + + def 'should fold goal into the single system message (tool path)'() { + given: + List capturedRequests = [] + ChatModel model = [ + chat: { ChatRequest req -> + capturedRequests << req + ChatResponse.builder() + .aiMessage(AiMessage.from('done')).build() + } + ] as ChatModel + ToolDispatcher dispatch = { String n, String a -> '{}' } as ToolDispatcher + def factory = Stub(ChatModelFactory) { createModel(*_) >> model } + def runner = new LangChainAgentRunner(modelFactory: factory) + def descriptor = new ToolDescriptor('greet', 'greet', [type:'object', properties:[name:[type:'string']], required:['name'], additionalProperties:false], null) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'You are careful.', prompt: 'go', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, + goal: 'assemble then QC') + + when: + def answer = runner.run(req) + + then: + answer == 'done' + def sys = capturedRequests[0].messages().find { it instanceof SystemMessage } as SystemMessage + capturedRequests[0].messages().count { it instanceof SystemMessage } == 1 + sys.text().contains('You are careful.') + sys.text().contains('assemble then QC') + } + + def 'should produce a system message from goal alone (no instruction)'() { + given: + List capturedRequests = [] + ChatModel model = [ + chat: { ChatRequest req -> + capturedRequests << req + ChatResponse.builder() + .aiMessage(AiMessage.from('ok')).build() + } + ] as ChatModel + ToolDispatcher dispatch = { String n, String a -> '{}' } as ToolDispatcher + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { createModel(*_) >> model }) + def descriptor = new ToolDescriptor('greet', 'greet', [type:'object', properties:[name:[type:'string']], required:['name'], additionalProperties:false], null) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: null, prompt: 'go', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, + goal: 'reach the objective') + + when: + runner.run(req) + + then: + def msgs = capturedRequests[0].messages() + msgs.count { it instanceof SystemMessage } == 1 + (msgs.find { it instanceof SystemMessage } as SystemMessage).text().contains('reach the objective') + } + + def 'should seed no system message when neither instruction nor goal is set'() { + given: + List capturedRequests = [] + ChatModel model = [ + chat: { ChatRequest req -> + capturedRequests << req + ChatResponse.builder() + .aiMessage(AiMessage.from('x')).build() + } + ] as ChatModel + ToolDispatcher dispatch = { String n, String a -> '{}' } as ToolDispatcher + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { createModel(*_) >> model }) + def descriptor = new ToolDescriptor('greet', 'greet', [type:'object', properties:[name:[type:'string']], required:['name'], additionalProperties:false], null) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: null, prompt: 'go', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, goal: null) + + when: + runner.run(req) + + then: + capturedRequests[0].messages().count { it instanceof SystemMessage } == 0 + } + + def 'should add the reasoning-narration directive to the system message when tracing'() { + given: + List capturedRequests = [] + ChatModel model = [ + chat: { ChatRequest req -> + capturedRequests << req + ChatResponse.builder().aiMessage(AiMessage.from('done')).build() + } + ] as ChatModel + ToolDispatcher dispatch = { String n, String a -> '{}' } as ToolDispatcher + // tracing builds the model via the 4-arg createModel (with listeners) + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { createModel(*_) >> model }) + def descriptor = new ToolDescriptor('greet', 'greet', [type:'object', properties:[name:[type:'string']], required:['name'], additionalProperties:false], null) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'You are careful.', prompt: 'go', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, + goal: null, agentName: 't', trace: true) + + when: + runner.run(req) + + then: + def sys = capturedRequests[0].messages().find { it instanceof SystemMessage } as SystemMessage + sys.text().contains('You are careful.') + sys.text().contains('briefly state your reasoning') + } + + def 'should not add the narration directive when not tracing'() { + given: + List capturedRequests = [] + ChatModel model = [ + chat: { ChatRequest req -> + capturedRequests << req + ChatResponse.builder().aiMessage(AiMessage.from('done')).build() + } + ] as ChatModel + ToolDispatcher dispatch = { String n, String a -> '{}' } as ToolDispatcher + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { createModel(*_) >> model }) + def descriptor = new ToolDescriptor('greet', 'greet', [type:'object', properties:[name:[type:'string']], required:['name'], additionalProperties:false], null) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'You are careful.', prompt: 'go', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, + goal: null) + + when: + runner.run(req) + + then: + def sys = capturedRequests[0].messages().find { it instanceof SystemMessage } as SystemMessage + sys.text().contains('You are careful.') + !sys.text().contains('briefly state your reasoning') + } + + /** + * The RUNNER-NATIVE half of the §5 partition, on the runner that IS the driver JVM. + * + *

A {@code fs:} tool never travels as a descriptor — it travels as a bare name on + * {@code nativeToolNames}, so this runner has to rebuild the descriptor itself and dispatch it + * back through the same callback. Two things break silently if it does not: an {@code fs:}-only + * agent takes the single-shot path and never gets a tool loop at all, and the model is offered + * nothing under a name it was told exists. + */ + def 'should serve the runner-native fs tools from nativeToolNames alone'() { + given: 'a model that calls `read` on the first turn, then answers' + List capturedRequests = [] + int calls = 0 + ChatModel model = [ + chat: { ChatRequest req -> + capturedRequests << req + calls++ + if( calls == 1 ) { + final ter = ToolExecutionRequest.builder() + .id('call-1').name('read').arguments('{"path":"a.txt"}').build() + return ChatResponse.builder().aiMessage(AiMessage.from([ter])).build() + } + return ChatResponse.builder().aiMessage(AiMessage.from('done')).build() + } + ] as ChatModel + + and: 'a dispatcher standing in for the bridge that serves the fs: tools in-JVM' + List> dispatched = [] + ToolDispatcher dispatch = { String name, String args -> + dispatched << [name, args] + return '{"content":"hello"}' + } as ToolDispatcher + + and: 'a request whose ONLY tools are runner-native: no descriptor, no brokered half' + def runner = new LangChainAgentRunner(modelFactory: Stub(ChatModelFactory) { createModel(*_) >> model }) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'i', prompt: 'go', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: null, dispatch: dispatch, requestTimeoutSeconds: 0, goal: null, + nativeToolNames: ['read', 'grep']) + + when: + def answer = runner.run(req) + + then: 'the tool loop ran, rather than the single-shot path an empty toolSpecs used to force' + answer == 'done' + calls == 2 + + and: 'the selected native names were advertised to the model, under their bare wire names' + capturedRequests[0].toolSpecifications()*.name() as Set == (['read','grep'] as Set) + + and: 'and dispatched back into the driver JVM through the same callback' + dispatched == [['read', '{"path":"a.txt"}']] + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentToolStructuredTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentToolStructuredTest.groovy new file mode 100644 index 0000000000..b925e6bbb4 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/LangChainAgentToolStructuredTest.groovy @@ -0,0 +1,284 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.agent.tool.ToolExecutionRequest +import dev.langchain4j.data.message.AiMessage +import dev.langchain4j.data.message.ChatMessage +import dev.langchain4j.data.message.SystemMessage +import dev.langchain4j.data.message.UserMessage +import dev.langchain4j.model.chat.ChatModel +import dev.langchain4j.model.chat.request.ChatRequest +import dev.langchain4j.model.chat.request.json.JsonSchema +import dev.langchain4j.model.chat.response.ChatResponse +import dev.langchain4j.model.output.FinishReason +import spock.lang.Specification + +/** + * Exercises the M5 "final structuring turn": a tool-using (or skill-using) agent that + * also declares a structured output. The tool loop runs schema-free (byte-for-byte the + * M1-M4 behavior) and, only when {@code outputSchema != null}, a single stateless + * structuring call converts the loop's free-text answer into schema-valid JSON. + * + * The {@link ChatModelFactory} is stubbed to hand back two different mock models + * differentiated by the schema arg: a null schema returns the AiServices loop model + * (overriding {@code chat(ChatRequest)}), a non-null schema returns the structuring + * model (overriding {@code chat(List)}). + */ +class LangChainAgentToolStructuredTest extends Specification { + + private static final Map GREET_INPUT_SCHEMA = [ + type: 'object', + properties: [name: [type: 'string']], + required: ['name'], + additionalProperties: false, + ] + + private static final Map GREET_OUTPUT_SCHEMA = [ + type: 'object', + properties: [greeting: [type: 'string']], + required: ['greeting'], + additionalProperties: false, + ] + + /** A loop model that asks for the greet tool on the first turn, then answers free text. */ + private static ChatModel loopModel(Closure onCall = null) { + int calls = 0 + return [ + chat: { ChatRequest req -> + if( onCall != null ) onCall.call(req) + calls++ + if( calls == 1 ) { + final ter = ToolExecutionRequest.builder() + .id('call-1').name('greet').arguments('{"name":"Ada"}').build() + return ChatResponse.builder().aiMessage(AiMessage.from([ter])).build() + } + return ChatResponse.builder().aiMessage(AiMessage.from('Hello Ada!')).build() + } + ] as ChatModel + } + + def 'should run the tool loop schema-free then structure the final answer'() { + given: 'a loop model (tool then free text) and a structuring model returning schema JSON' + JsonSchema loopSchema = null + JsonSchema structuringSchema = null + List structuringMessages = null + boolean structuringCalled = false + + def loop = loopModel() + ChatModel structuring = [ + chat: { List messages -> + structuringCalled = true + structuringMessages = messages + ChatResponse.builder().aiMessage(AiMessage.from('{"greeting":"Hello Ada!"}')).build() + } + ] as ChatModel + + and: 'a factory that routes on the schema arg (null -> loop, non-null -> structuring)' + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + final JsonSchema schema = args[2] as JsonSchema + if( schema == null ) { loopSchema = schema; return loop } + structuringSchema = schema + return structuring + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + + and: 'a stub dispatcher recording that the tool loop ran' + List> dispatched = [] + ToolDispatcher dispatch = { String name, String args -> + dispatched << [name, args]; '{"greeting":"Hello Ada!"}' + } as ToolDispatcher + + and: 'a tool request that ALSO carries a structured output schema' + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, GREET_OUTPUT_SCHEMA) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'Use the greet tool.', prompt: 'greet Ada', + maxIterations: 5, tools: [], outputSchema: GREET_OUTPUT_SCHEMA, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, agentName: 'assistant') + + when: + def answer = runner.run(req) + + then: 'the tool loop actually ran' + dispatched == [['greet', '{"name":"Ada"}']] + + and: 'the loop model received a NULL schema (schema-free loop preserved)' + loopSchema == null + + and: 'the structuring model received the non-null schema' + structuringCalled + structuringSchema != null + + and: 'the returned value is the structuring JSON, not the free text' + answer == '{"greeting":"Hello Ada!"}' + + and: 'the structuring conversation is exactly a system instruction + the loop answer as user text' + structuringMessages.size() == 2 + structuringMessages[0] instanceof SystemMessage + (structuringMessages[0] as SystemMessage).text().contains('structured JSON') + structuringMessages[1] instanceof UserMessage + (structuringMessages[1] as UserMessage).singleText() == 'Hello Ada!' + } + + def 'should not make a structuring turn when no outputSchema is set'() { + given: 'a loop model and a structuring model that must never be called' + boolean structuringCalled = false + def loop = loopModel() + ChatModel structuring = [ + chat: { List messages -> + structuringCalled = true + ChatResponse.builder().aiMessage(AiMessage.from('{}')).build() + } + ] as ChatModel + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> (args[2] as JsonSchema) == null ? loop : structuring } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + ToolDispatcher dispatch = { String n, String a -> '{"greeting":"Hello Ada!"}' } as ToolDispatcher + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, null) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'Use the greet tool.', prompt: 'greet Ada', + maxIterations: 5, tools: [], outputSchema: null, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, agentName: 'assistant') + + when: + def answer = runner.run(req) + + then: 'the free-text answer is returned verbatim and no structuring turn happened' + answer == 'Hello Ada!' + !structuringCalled + } + + def 'should structure a skills-only structured request'() { + given: 'a skills-only request (no toolSpecs) that answers immediately, plus a structuring model' + def loop = [ + chat: { ChatRequest req -> + ChatResponse.builder().aiMessage(AiMessage.from('Hi Ada')).build() + } + ] as ChatModel + boolean structuringCalled = false + ChatModel structuring = [ + chat: { List messages -> + structuringCalled = true + ChatResponse.builder().aiMessage(AiMessage.from('{"greeting":"Hi Ada"}')).build() + } + ] as ChatModel + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> (args[2] as JsonSchema) == null ? loop : structuring } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + def skill = new SkillDescriptor('greet', 'a greeting skill', 'say hi politely', []) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'You are helpful.', prompt: 'greet Ada', + maxIterations: 5, tools: [], outputSchema: GREET_OUTPUT_SCHEMA, inputJson: null, + toolSpecs: null, dispatch: null, requestTimeoutSeconds: 0, goal: null, + agentName: 'assistant', skills: [skill]) + + when: + def answer = runner.run(req) + + then: 'the skills path (runWithTools) also runs the structuring turn' + structuringCalled + answer == '{"greeting":"Hi Ada"}' + } + + def 'should throw a refusal-flavored error when the structuring turn is content-filtered'() { + given: + def loop = loopModel() + ChatModel structuring = [ + chat: { List messages -> + ChatResponse.builder().aiMessage(AiMessage.from('')).finishReason(FinishReason.CONTENT_FILTER).build() + } + ] as ChatModel + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> (args[2] as JsonSchema) == null ? loop : structuring } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + ToolDispatcher dispatch = { String n, String a -> '{"greeting":"Hello Ada!"}' } as ToolDispatcher + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, GREET_OUTPUT_SCHEMA) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'Use the greet tool.', prompt: 'greet Ada', + maxIterations: 5, tools: [], outputSchema: GREET_OUTPUT_SCHEMA, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, agentName: 'assistant') + + when: + runner.run(req) + + then: + def e = thrown(AgentStructuredOutputException) + e.message.contains('assistant') + } + + def 'should throw a refusal-flavored error when the structuring turn returns blank content'() { + given: + def loop = loopModel() + ChatModel structuring = [ + chat: { List messages -> + // blank text, normal STOP finish reason -> must NOT fall through to a blank result + ChatResponse.builder().aiMessage(AiMessage.from(' ')).finishReason(FinishReason.STOP).build() + } + ] as ChatModel + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> (args[2] as JsonSchema) == null ? loop : structuring } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + ToolDispatcher dispatch = { String n, String a -> '{"greeting":"Hello Ada!"}' } as ToolDispatcher + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, GREET_OUTPUT_SCHEMA) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'Use the greet tool.', prompt: 'greet Ada', + maxIterations: 5, tools: [], outputSchema: GREET_OUTPUT_SCHEMA, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 0, agentName: 'assistant') + + when: + runner.run(req) + + then: + thrown(AgentStructuredOutputException) + } + + def 'should thread the request timeout into the structuring model'() { + given: 'capture the timeout passed for the structuring (non-null schema) call' + int structuringTimeout = -1 + def loop = loopModel() + ChatModel structuring = [ + chat: { List messages -> + ChatResponse.builder().aiMessage(AiMessage.from('{"greeting":"Hello Ada!"}')).build() + } + ] as ChatModel + def factory = Stub(ChatModelFactory) { + createModel(*_) >> { args -> + final JsonSchema schema = args[2] as JsonSchema + if( schema != null ) structuringTimeout = args[1] as int + schema == null ? loop : structuring + } + } + def runner = new LangChainAgentRunner(modelFactory: factory) + ToolDispatcher dispatch = { String n, String a -> '{"greeting":"Hello Ada!"}' } as ToolDispatcher + def descriptor = new ToolDescriptor('greet', 'greet someone', GREET_INPUT_SCHEMA, GREET_OUTPUT_SCHEMA) + def req = new AgentRunnerRequest( + model: 'openai/gpt-5-mini', instruction: 'Use the greet tool.', prompt: 'greet Ada', + maxIterations: 5, tools: [], outputSchema: GREET_OUTPUT_SCHEMA, inputJson: null, + toolSpecs: [descriptor], dispatch: dispatch, requestTimeoutSeconds: 90, agentName: 'assistant') + + when: + runner.run(req) + + then: 'the per-request timeout was applied to the structuring call' + structuringTimeout == 90 + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/ModuleToolAdapterTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/ModuleToolAdapterTest.groovy new file mode 100644 index 0000000000..a54fdf38f7 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/ModuleToolAdapterTest.groovy @@ -0,0 +1,78 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.model.chat.request.json.JsonObjectSchema +import dev.langchain4j.model.chat.request.json.JsonStringSchema +import spock.lang.Specification + +class ModuleToolAdapterTest extends Specification { + + def 'should build a ToolSpecification with a JsonObjectSchema param schema'() { + given: + def descriptor = new ToolDescriptor( + 'greet', + 'greet someone by name', + [ + type: 'object', + properties: [name: [type: 'string']], + required: ['name'], + additionalProperties: false, + ], + [type: 'object', properties: [answer: [type: 'string']]]) + + when: + def spec = ModuleToolAdapter.toToolSpecification(descriptor) + + then: + spec.name() == 'greet' + spec.description() == 'greet someone by name' + + and: 'the params are a JsonObjectSchema exposing the declared property' + spec.parameters() instanceof JsonObjectSchema + def params = spec.parameters() as JsonObjectSchema + params.properties().containsKey('name') + params.properties().get('name') instanceof JsonStringSchema + params.required() == ['name'] + params.additionalProperties() == false + } + + def 'should tolerate a descriptor with no input properties'() { + given: + def descriptor = new ToolDescriptor('noop', 'does nothing', [type: 'object'], null) + + when: + def spec = ModuleToolAdapter.toToolSpecification(descriptor) + + then: + spec.name() == 'noop' + spec.parameters() instanceof JsonObjectSchema + } + + def 'should fail when the descriptor is null'() { + when: + ModuleToolAdapter.toToolSpecification(null) + then: + thrown(IllegalArgumentException) + } + + def 'should fail when the descriptor name is missing'() { + when: + ModuleToolAdapter.toToolSpecification(new ToolDescriptor(null, 'd', [type: 'object'], null)) + then: + thrown(IllegalArgumentException) + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/NullableStrictSchemaTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/NullableStrictSchemaTest.groovy new file mode 100644 index 0000000000..f7451977a8 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/NullableStrictSchemaTest.groovy @@ -0,0 +1,164 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.internal.JsonSchemaElementUtils +import dev.langchain4j.model.chat.request.json.JsonSchema +import spock.lang.Specification + +/** + * Characterization / regression guard for OpenAI-strict optional ({@code @Nullable}/{@code ?}) + * structured-output fields (milestone M4). + * + * The portable schema layer (core {@code RecordSchema.of}) omits {@code @Nullable} fields from + * the {@code required} list. This test asserts what the OpenAI plugin actually sends on the wire: + * langchain4j's strict serialization ({@code JsonSchemaElementUtils.toMap(root, true)} with + * {@code strict=true}, exactly what {@code OpenAiUtils.toOpenAiResponseFormat} invokes) rewrites the object-level + * {@code required} to include every property, BUT emits an OpenAI nullable-union type + * ({@code ["","null"]}) for fields omitted from {@code required}. That is the canonical + * OpenAI-strict optional-field idiom: the field is listed as required but its type is nullable, + * so the model may legitimately return it null. See {@code adr/specs/agent-design.md} §3.3. + * + * This lives in the plugin because the nullable-union is produced by langchain4j serialization, + * which is on the plugin classpath only. It is a faithful, network-free proxy for the schema the + * model receives. If a langchain4j version bump removes the {@code type()} nullable helper this + * test turns red — the signal to apply the contingency source fix. + * + * @author Paolo Di Tommaso + */ +class NullableStrictSchemaTest extends Specification { + + /** Normalise a schema {@code type} which may be a plain {@code String} or a {@code String[]} union. */ + private static List asList(Object t) { t instanceof String[] ? (t as List) : (t instanceof List ? t : [t]) } + + private static Map toWire(String name, Map portable, boolean strict) { + final JsonSchema schema = JsonSchemaMapper.toJsonSchema(name, portable) + return JsonSchemaElementUtils.toMap(schema.rootElement(), strict) + } + + def 'scalar optional field becomes a nullable union but stays in the strict required set'() { + given: 'the portable map RecordSchema.of produces for `record Answer { answer: String; note: String? }`' + def portable = [type : 'object', + properties : [answer: [type: 'string'], note: [type: 'string']], + required : ['answer'], + additionalProperties: false] + + when: 'serialized with strict=true (as OpenAiUtils.toOpenAiResponseFormat does)' + def wire = toWire('Answer', portable, true) + + then: 'the required field keeps a plain type' + asList(wire.properties.answer.type) == ['string'] + + and: 'the optional field carries the OpenAI nullable union' + asList(wire.properties.note.type) == ['string', 'null'] + + and: 'strict rewrote required to all keys - the field is "required" but nullable' + (wire.required as Set) == ['answer', 'note'] as Set + + and: + wire.additionalProperties == false + } + + def 'optional field inside a nested record recurses to a nullable union'() { + given: '`record Outer { title: String; inner: Inner }`, Inner { name: String; note: String? }' + def portable = [type : 'object', + properties : [ + title: [type: 'string'], + inner: [type : 'object', + properties : [name: [type: 'string'], note: [type: 'string']], + required : ['name'], + additionalProperties: false], + ], + required : ['title', 'inner'], + additionalProperties: false] + + when: + def wire = toWire('Outer', portable, true) + + then: 'the required nested object stays a plain object' + asList(wire.properties.inner.type) == ['object'] + + and: 'its optional field carries the nullable union at depth' + asList(wire.properties.inner.properties.note.type) == ['string', 'null'] + + and: 'the nested required set was rewritten to include the nullable field' + (wire.properties.inner.required as Set) == ['name', 'note'] as Set + } + + def 'a nullable nested object becomes an object/null union'() { + given: '`record Outer { name: String; addr: Address }` with addr omitted from required (i.e. @Nullable)' + def portable = [type : 'object', + properties : [ + name: [type: 'string'], + addr: [type : 'object', + properties : [city: [type: 'string']], + required : ['city'], + additionalProperties: false], + ], + required : ['name'], + additionalProperties: false] + + when: + def wire = toWire('Outer', portable, true) + + then: 'the nullable object becomes an object/null union' + asList(wire.properties.addr.type) == ['object', 'null'] + + and: 'it is still listed in the strict required set' + (wire.required as Set) == ['name', 'addr'] as Set + } + + def 'the exit criterion holds at the M1 multi-output wrapper nesting level'() { + given: 'a synthetic wrapper (independent of M1 code) whose record property carries a nullable field' + def portable = [type : 'object', + properties : [ + plan : [type : 'object', + properties : [step: [type: 'string'], hint: [type: 'string']], + required : ['step'], + additionalProperties: false], + count: [type: 'integer'], + ], + required : ['plan', 'count'], + additionalProperties: false] + + when: + def wire = toWire('Wrapper', portable, true) + + then: 'the deeply-nested nullable field still receives the nullable union - recursion at every wrapper level' + asList(wire.properties.plan.properties.hint.type) == ['string', 'null'] + + and: + (wire.properties.plan.required as Set) == ['step', 'hint'] as Set + } + + def 'non-strict serialization keeps the classic optional idiom (control)'() { + given: 'the same single-record portable map' + def portable = [type : 'object', + properties : [answer: [type: 'string'], note: [type: 'string']], + required : ['answer'], + additionalProperties: false] + + when: 'serialized with strict=false' + def wire = toWire('Answer', portable, false) + + then: 'the optional field keeps a plain scalar type' + asList(wire.properties.note.type) == ['string'] + + and: 'and is simply omitted from required (classic optional idiom)' + !(wire.required as List).contains('note') + (wire.required as List) == ['answer'] + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/SkillAdapterTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/SkillAdapterTest.groovy new file mode 100644 index 0000000000..a409bd20ac --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/SkillAdapterTest.groovy @@ -0,0 +1,47 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import spock.lang.Specification + +class SkillAdapterTest extends Specification { + + def 'should build a langchain4j Skills container with catalog and tool provider'() { + given: + def d = new SkillDescriptor('greet', 'a greeting skill', 'say hi politely', + [new SkillResource('references/a.txt', 'AAA')]) + + when: + def skills = SkillAdapter.toSkills([d]) + + then: 'a usable tool provider is produced' + skills != null + skills.toolProvider() != null + + and: 'the available-skills catalog advertises the name + description (what the model sees)' + def catalog = skills.formatAvailableSkills() + catalog.contains('greet') + catalog.contains('a greeting skill') + } + + def 'should handle a skill with no resources'() { + when: + def skills = SkillAdapter.toSkills([new SkillDescriptor('plain', 'no resources', 'body', null)]) + + then: + skills.formatAvailableSkills().contains('plain') + } +} diff --git a/plugins/nf-agent/src/test/nextflow/agent/SkillTraceProviderTest.groovy b/plugins/nf-agent/src/test/nextflow/agent/SkillTraceProviderTest.groovy new file mode 100644 index 0000000000..4fd08955e0 --- /dev/null +++ b/plugins/nf-agent/src/test/nextflow/agent/SkillTraceProviderTest.groovy @@ -0,0 +1,68 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.agent + +import dev.langchain4j.agent.tool.ToolExecutionRequest +import dev.langchain4j.agent.tool.ToolSpecification +import dev.langchain4j.data.message.UserMessage +import dev.langchain4j.invocation.InvocationContext +import dev.langchain4j.service.tool.ToolExecutionResult +import dev.langchain4j.service.tool.ToolExecutor +import dev.langchain4j.service.tool.ToolProvider +import dev.langchain4j.service.tool.ToolProviderRequest +import dev.langchain4j.service.tool.ToolProviderResult +import spock.lang.Specification + +/** + * The tracing wrapper around a skills tool provider must (a) report each skill-tool + * invocation to the {@link AgentTrace} like a module tool, and (b) delegate + * {@code executeWithContext} (langchain4j-skills' real execution path) so activation + * is not broken. + */ +class SkillTraceProviderTest extends Specification { + + def 'should report skill-tool calls to the trace while delegating executeWithContext'() { + given: 'a delegate provider exposing one skill tool whose executeWithContext returns a result' + def spec = ToolSpecification.builder().name('activate_skill').build() + boolean delegated = false + def innerExec = new ToolExecutor() { + String execute(ToolExecutionRequest req, Object memoryId) { 'via-execute' } + ToolExecutionResult executeWithContext(ToolExecutionRequest req, InvocationContext ctx) { + delegated = true + return ToolExecutionResult.builder().resultText('SKILL INSTRUCTIONS').build() + } + } + def delegate = new ToolProvider() { + ToolProviderResult provideTools(ToolProviderRequest req) { + new ToolProviderResult([(spec): innerExec] as Map) + } + } + def trace = Mock(AgentTrace) + + when: 'the wrapped provider is asked for tools and the wrapped executor runs' + def wrapped = LangChainAgentRunner.tracingSkillProvider(delegate, trace) + def exec = wrapped.provideTools(new ToolProviderRequest(null, UserMessage.from('hi'))).tools()[spec] + def ter = ToolExecutionRequest.builder().id('1').name('activate_skill').arguments('{"skill_name":"greet"}').build() + def result = exec.executeWithContext(ter, null) + + then: 'the real executor was delegated to and its result returned unchanged' + delegated + result.resultText() == 'SKILL INSTRUCTIONS' + + and: 'the invocation was itemized on the trace, like a module tool' + 1 * trace.tool('activate_skill', '{"skill_name":"greet"}', 'SKILL INSTRUCTIONS') + } +} diff --git a/release.sh b/release.sh index eb3b0451a7..099d991083 100755 --- a/release.sh +++ b/release.sh @@ -3,6 +3,7 @@ # Nextflow Release Script # # This script performs the complete Nextflow release process including: +# - Publishing the nf-agent-pi runner image # - Building and assembling artifacts # - Uploading to S3 and Maven repositories # - Releasing plugins to registry @@ -29,6 +30,12 @@ # DOCKERHUB_USERNAME - Docker Hub username for container publishing # DOCKERHUB_TOKEN - Docker Hub token/password for container publishing # SEQERA_PUBLIC_CR_PASSWORD - Seqera public container registry password +# The same credential authorizes step 1, which publishes the +# nf-agent-pi runner image to public.cr.seqera.io/nextflow - the +# registry and namespace this release already pushes +# `nextflow/nextflow` to. The push is authorized by the workflow's +# `Docker Login to Seqera public CR` step, so nothing new is +# required here and step 1 adds no entry to REQUIRED_VARS below. # # Usage: Only run when commit message contains '[release]' # @@ -71,24 +78,37 @@ fi echo "✅ All required environment variables are set" -echo "🔧 === Step 1: Assemble, upload, and deploy ===" -make assemble upload deploy +echo "🐳 === Step 1: Publish the pi agent runner image ===" +# FIRST, before anything is published: `deploy` (step 2) uploads to s3://www2.nextflow.io, +# `release-plugins` (step 4) creates permanent registry entries, and `make release` (step 5) +# creates the git tag and the GitHub release. None of those is undoable, and the image build is +# the newest and least proven thing in this script - it is the only step that reaches a +# third-party registry, bootstraps buildkit and runs an emulated `npm ci`. Placed first, a +# failure there is the harmless one: nothing has been published yet. Its build context is only +# plugins/nf-agent-pi/ (see the .dockerignore allowlist), so it depends on nothing the Gradle +# build produces and can run first. Skips when the tag is already published. +make release-agent-image echo "✅ Step 1 completed successfully" echo "" -echo "📦 === Step 2: Publish artifacts ===" -make publish-artifacts +echo "🔧 === Step 2: Assemble, upload, and deploy ===" +make assemble upload deploy echo "✅ Step 2 completed successfully" echo "" -echo "🔌 === Step 3: Release plugins ===" -make release-plugins +echo "📦 === Step 3: Publish artifacts ===" +make publish-artifacts echo "✅ Step 3 completed successfully" echo "" -echo "🚀 === Step 4: Final release ===" -make release +echo "🔌 === Step 4: Release plugins ===" +make release-plugins echo "✅ Step 4 completed successfully" echo "" +echo "🚀 === Step 5: Final release ===" +make release +echo "✅ Step 5 completed successfully" +echo "" + echo "🎉 === Release process completed successfully ===" diff --git a/settings.gradle b/settings.gradle index d1e34069e4..496cf9b693 100644 --- a/settings.gradle +++ b/settings.gradle @@ -49,6 +49,8 @@ include 'plugins:nf-wave' include 'plugins:nf-cloudcache' include 'plugins:nf-k8s' include 'plugins:nf-seqera' +include 'plugins:nf-agent' +include 'plugins:nf-agent-pi' //includeBuild('../plugin-registry') //includeBuild '../sched' diff --git a/tests/checks/.IGNORE-PARSER-V2 b/tests/checks/.IGNORE-PARSER-V2 index a0526a6c10..3ab74c4481 100644 --- a/tests/checks/.IGNORE-PARSER-V2 +++ b/tests/checks/.IGNORE-PARSER-V2 @@ -9,7 +9,8 @@ params-dsl.nf process-typed-taskpath.nf record-types.nf records.nf +resume-typed-value-output.nf task-ext-block.nf topic-channel-typed.nf type-annotations.nf -workflow-oncomplete-v2.nf \ No newline at end of file +workflow-oncomplete-v2.nf diff --git a/tests/checks/resume-typed-value-output.nf/.checks b/tests/checks/resume-typed-value-output.nf/.checks new file mode 100644 index 0000000000..3e7e201961 --- /dev/null +++ b/tests/checks/resume-typed-value-output.nf/.checks @@ -0,0 +1,36 @@ +set -e + +# +# normal run: both tasks execute and every output is emitted +# +echo '' +$NXF_RUN | tee stdout + +[[ `< .nextflow.log grep -c 'Submitted process > foo'` == 1 ]] || false +[[ `< .nextflow.log grep -c 'Submitted process > bar'` == 1 ]] || false +[[ `grep -c 'upper=HELLO' stdout` == 1 ]] || false +[[ `grep -c 'size=5' stdout` == 1 ]] || false +[[ `grep -c 'env=hello-env' stdout` == 1 ]] || false +[[ `grep -c 'file=hello' stdout` == 1 ]] || false +[[ `grep -c 'std=out:hello' stdout` == 1 ]] || false +[[ `grep -c 'rev=dlrow' stdout` == 1 ]] || false + +# +# RESUME mode: both tasks are cached and every output is emitted identically. +# - `foo` is a script task: its value outputs (upper/size) are NOT persisted in the +# cache DB, they are rebuilt from the re-evaluated task context. +# - `bar` is an exec task: its body is not re-run, so `rev` can only come from the +# persisted task context -- it emits `null` if that context is not cached. +# +echo '' +$NXF_RUN -resume | tee stdout2 + +[[ `< .nextflow.log grep -c 'Cached process > foo'` == 1 ]] || false +[[ `< .nextflow.log grep -c 'Cached process > bar'` == 1 ]] || false +[[ `< .nextflow.log grep -c 'Missing cache context'` == 0 ]] || false +[[ `grep -c 'upper=HELLO' stdout2` == 1 ]] || false +[[ `grep -c 'size=5' stdout2` == 1 ]] || false +[[ `grep -c 'env=hello-env' stdout2` == 1 ]] || false +[[ `grep -c 'file=hello' stdout2` == 1 ]] || false +[[ `grep -c 'std=out:hello' stdout2` == 1 ]] || false +[[ `grep -c 'rev=dlrow' stdout2` == 1 ]] || false diff --git a/tests/resume-typed-value-output.nf b/tests/resume-typed-value-output.nf new file mode 100644 index 0000000000..c74bf629f8 --- /dev/null +++ b/tests/resume-typed-value-output.nf @@ -0,0 +1,71 @@ +#!/usr/bin/env nextflow +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +nextflow.enable.types = true + +/* + * A typed (v2) *script* process mixing genuine in-memory VALUE outputs with + * work-dir derived ones (env/file/stdout). A script task always re-evaluates + * its body before the cache is consulted, so the task context is rebuilt on + * resume and the value outputs must be emitted identically without the + * context being persisted in the cache DB. + */ +process foo { + input: + message: String + + output: + upper: String = message.toUpperCase() + size: Integer = message.length() + out_env: String = env('MESSAGE') + out_file: Path = file('message.txt') + out_std: String = stdout() + + script: + """ + export MESSAGE='${message}-env' + echo '${message}' > message.txt + printf 'out:${message}' + """ +} + +/* + * A typed (v2) *exec* process: the body IS the task execution, so on a cache hit it is + * never re-run and its value output can only come from the persisted task context. + */ +process bar { + input: + message: String + + output: + reversed: String = rev + + exec: + rev = message.reverse() +} + +workflow { + bar(channel.of('world')) + bar.out.reversed.view { v -> "rev=${v}" } + + foo(channel.of('hello')) + foo.out.upper.view { v -> "upper=${v}" } + foo.out.size.view { v -> "size=${v}" } + foo.out.out_env.view { v -> "env=${v}" } + foo.out.out_file.view { v -> "file=${v.text.trim()}" } + foo.out.out_std.view { v -> "std=${v}" } +}