{"slug": "apache-airflow-3-4-0-task-loops-dags-in-any-language-and-ai-agents", "title": "Apache Airflow 3.4.0: Task Loops, Dags in Any Language, and AI Agents", "summary": "Apache Airflow 3.4.0 shipped with Task Loops (AIP-111), a native bounded-iteration primitive that repeats a decorated task group up to a required max_iterations cap with an optional until predicate, plus the ability to author an entire Dag in TypeScript, Go, Java, or YAML instead of only Python. The release also matures the common.ai provider, which turns LLM calls and agents into ordinary Airflow tasks with human approval steps. Loops are handled at runtime only, so the Dag itself remains acyclic, and Airflow materializes each iteration lazily rather than creating a tail of skipped tasks.", "body_md": "# Apache Airflow 3.4.0: Task Loops, Dags in Any Language, and AI Agents\n\nWe’re proud to announce the release of **Apache Airflow 3.4.0**! Where 3.3 gave your *tasks* memory and task bodies in multiple languages, 3.4 is about **shape and reach**. It brings **Task Loops**, a native loop primitive for workflows that repeat until they are done; the ability to author an entire Dag in TypeScript, Go, Java, or YAML rather than only Python; and a steadily maturing `common.ai` provider that turns LLM calls and agents into ordinary Airflow tasks, complete with human approval.\n\n**🎯 Release Highlights**\n\n📦 PyPI: [https://pypi.org/project/apache-airflow/3.4.0/](https://pypi.org/project/apache-airflow/3.4.0/) \n\n📚 Docs: [https://airflow.apache.org/docs/apache-airflow/3.4.0/](https://airflow.apache.org/docs/apache-airflow/3.4.0/) \n\n🛠️ Release Notes: [https://airflow.apache.org/docs/apache-airflow/3.4.0/release_notes.html](https://airflow.apache.org/docs/apache-airflow/3.4.0/release_notes.html) \n\n🐳 Docker Image: `docker pull apache/airflow:3.4.0` \n\n🚏 Constraints: [https://github.com/apache/airflow/tree/constraints-3.4.0](https://github.com/apache/airflow/tree/constraints-3.4.0)\n\n# 🔁 Task Loops (AIP-111): Workflows That Repeat Until They Are Done\n\nSome workflows do not have a fixed shape. You walk a tree of unknown depth, you refine an estimate until the error is small enough, or you run an agentic *generate → score → refine* loop until the output is good. Until now you reached for hard-coding a fixed depth or for Dags that trigger themselves. Airflow 3.4 makes **bounded, sequential iteration** a native concept.\n\nBefore we go any further, it is worth addressing one thing: “How can you have loops in a Dag, given DAG stands for Directed Acyclic Graph”. The answer to that is by being very careful. Essentially, there is no dependency loop introduced, the tasks in the Dag still do form a cycle-free graph. The looping nature is handled at runtime only.\n\nCall `.loop()` on a decorated task group to repeat it a number of times decided at runtime, where each iteration can build on the result of the last. It behaves like a `do … while` loop with a hard cap:\n\n``` python\nfrom airflow.sdk import dag, task, task_group\n\n@dag(tags=[\"example\"])\ndef refine_estimate():\n    @task_group\n    def refine():\n        @task\n        def improve(*, loop):\n            # First pass starts from 1.0; later passes read the previous result.\n            estimate = 1.0 if loop.index == 1 else loop.previous[\"estimate\"]\n            return (estimate + 2.0 / estimate) / 2.0\n\n        @task\n        def evaluate(estimate):\n            return {\"estimate\": estimate, \"error\": abs(estimate * estimate - 2.0)}\n\n        evaluate(improve())\n\n    def accurate_enough(*, loop):\n        return loop.result[\"error\"] < 0.000001\n\n    @task\n    def finished():\n        print(\"Refinement finished.\")\n\n    refinement = refine.loop(max_iterations=10, until=accurate_enough)\n    refinement >> finished()\n\nrefine_estimate()\n```\n\n## Key Capabilities\n\n- **`max_iterations`** : a required upper bound, so a loop can never run away.\n- **`until`** : an optional predicate function that decides when to stop, or omit it entirely for a loop that runs a fixed number of times. Because the body runs before the check, it always runs at least once.\n- **A `loop` context object** inside the body and the predicate:`loop.index` (the 1-based current pass),`loop.previous` (the last iteration’s result), and`loop.result` (for the predicate).\n- **Lazy materialization** : Airflow only creates the next iteration when the loop decides to continue, so you do not see a tail of skipped iterations.\n- **Composable** : loops work with`.partial()` for body arguments,`.override()` for group config, and even`.expand()` to run mapped tasks*inside* a loop.\n\nA loop with a fixed count (no `until`) always runs exactly `max_iterations` times and succeeds. If a loop reaches `max_iterations` while its `until` predicate is still `False`, meaning it never converged, the final iteration is marked failed, so a failure to converge surfaces as a real failure. The Airflow UI gains dedicated loop views so you can see each iteration’s history at a glance.\n\nA few limits to know: loops cannot be nested, a loop cannot sit inside a mapped task group (though mapped tasks inside loops are okay), and a whole loop cannot itself be mapped.\n\nFor detailed usage, see the [loops authoring guide](https://airflow.apache.org/docs/apache-airflow/3.4.0/authoring-and-scheduling/loops.html).\n\n# 🌐 Write Airflow Dags in TypeScript, Java and Go\n\nAirflow 3.3 let you write individual *tasks* in Java or Go while Python still owned the Dag. Airflow 3.4 takes the next step: you can define the entire Dag, including its schedule, tasks and dependencies, in TypeScript, Java or Go, with no Python file involved. All three SDKs are generally available.\n\nThis is powered by **AIP-85, the Dag Importer**, a pluggable framework that teaches the Dag processor to read Dag sources beyond `.py`. Each format registers an importer. For a language SDK, the Dag processor runs the language’s runtime to read the Dags an artifact declares, and Airflow then treats them exactly like Python Dags. The same framework also powers an opt-in YAML importer.\n\n## What’s supported\n\nEach SDK works in two modes, and one artifact can serve both: a **Dag definition** declares the whole Dag in the SDK’s language, which is what 3.4 adds, and a **Python Dag with stub TaskHandler** implements the `@task.stub` tasks of a Python Dag, including their TaskFlow arguments. A Dag definition supports Dag and task options under Airflow’s own names, one queue for all of a Dag’s tasks, TaskFlow inputs from task results and literals, order-only dependencies (`before` / `after`), nested task groups, edge labels, if / else and switch / case branching, and triggering another Dag’s run. Task code can use Variables, Connections, XCom and the task state store. An artifact can hold several Dags, including Dags generated in a loop, and the Code view shows each Dag’s own source file. All of this works the same in TypeScript, Java and Go, and the TypeScript SDK adds human-in-the-loop tasks (approval, `hitl`).\n\n## The same Dag declared in three languages\n\nEach example below declares the same Dag, `orders_daily`:\n\n- the `extract` task group counts orders for two regions,\n- `summarize` adds them up,\n- `has_orders` runs`load` when there are orders, and`alert` when there are none,\n- `pick_report` picks a full or a brief report,\n- `trigger_finance` starts a run of the`finance_close` Dag after`load` , on a labeled edge.\n\nThe UI screenshot in each tab is of a separate real Dag authored with that SDK, not the `orders_daily` snippet itself.\n\n## TypeScript\n\nA handler takes one object of named arguments, and calling a task names its inputs. The calls you write are the dependencies, and the compiler checks each one.\n\n``` js\nimport { Bundle, Dag, label, triggerDagRun } from \"apache-airflow-ts-sdk\";\n\nconst dag = new Dag(\"orders_daily\", { schedule: \"@daily\", tags: [\"orders\"], queue: \"typescript\" });\n\n// A task group: its task ids are \"extract.eu\" and \"extract.us\".\nconst extract = dag.taskGroup(\"extract\");\nconst eu = extract.task(\"eu\", async (): Promise<number> => 120)();\nconst us = extract.task(\"us\", async (): Promise<number> => 80)();\n\n// TaskFlow: passing a task's result draws the dependency and delivers the value.\nconst summarize = dag.task(\"summarize\", async ({ eu, us }: { eu: number; us: number }) => eu + us);\nconst total = summarize({ eu, us });\n\nconst load = dag.task(\"load\", async ({ total, table }: { total: number; table: string }) => {\n  console.log(`Loading ${total} orders into ${table}`);\n});\nconst loaded = load({ total, table: \"warehouse.orders\" });\nconst alerted = dag.task(\"alert\", async () => console.log(\"No orders today\"))();\n\n// if / else: the side not taken is skipped.\nconst hasOrders = async ({ total }: { total: number }) => total > 0;\ndag.if(hasOrders, { total }, { taskId: \"has_orders\" }).then(loaded).else(alerted);\n\n// switch / case: the decider returns the task to run.\nconst full = dag.task(\"report_full\", async () => console.log(\"Full report\"))();\nconst brief = dag.task(\"report_brief\", async () => console.log(\"Brief report\"))();\nconst pickReport = async ({ total }: { total: number }) => (total > 1000 ? full : brief);\ndag.switch(pickReport, { total }, { taskId: \"pick_report\" }).case(full).case(brief);\n\n// Trigger another Dag after the load, on a labeled edge.\nconst triggered = dag.task(\"trigger_finance\", triggerDagRun({ dagId: \"finance_close\" }))();\nloaded.before(label(triggered, \"hand off\"));\n\nawait new Bundle(dag).serve();\n```\n\n## Java\n\nAnnotations carry the configuration. A `@Airflow.Deps` class wires the graph through a view that the annotation processor generates, so `javac` type-checks every edge. An interface-based API (`DagDef`, `dag.task(...)`) is also available.\n\n``` python\npackage com.example;\n\nimport static java.lang.System.Logger.Level.INFO;\n\nimport org.apache.airflow.sdk.*;\n\n@Airflow.Dag(id = \"orders_daily\", schedule = \"@daily\", tags = {\"orders\"}, queue = \"java\")\npublic class OrdersDaily {\n  private static final System.Logger log = System.getLogger(OrdersDaily.class.getName());\n\n  // A task group: its task ids are \"extract.eu\" and \"extract.us\".\n  @Airflow.TaskGroup(id = \"extract\")\n  static class Extract {\n    @Airflow.Task\n    public long eu() { return 120L; }\n\n    @Airflow.Task\n    public long us() { return 80L; }\n  }\n\n  @Airflow.Task\n  public long summarize(long eu, long us) { return eu + us; }\n\n  @Airflow.Task\n  public void load(long total, String table) {\n    log.log(INFO, \"Loading {0} orders into {1}\", total, table);\n  }\n\n  @Airflow.Task\n  public void alert() { log.log(INFO, \"No orders today\"); }\n\n  // if / else: the side not taken is skipped.\n  @Airflow.If(id = \"has_orders\")\n  public boolean hasOrders(long total) { return total > 0; }\n\n  @Airflow.Task(id = \"report_full\")\n  public void reportFull() { log.log(INFO, \"Full report\"); }\n\n  @Airflow.Task(id = \"report_brief\")\n  public void reportBrief() { log.log(INFO, \"Brief report\"); }\n\n  // switch / case: the decider returns the class generated for the task to run.\n  @Airflow.Switch(id = \"pick_report\")\n  public Class<? extends Task> pickReport(long total) {\n    return total > 1000 ? OrdersDailyBuilder.ReportFull.class : OrdersDailyBuilder.ReportBrief.class;\n  }\n\n  // Trigger another Dag. This method runs when the Dag is built, not when the task runs.\n  @Airflow.Task(id = \"trigger_finance\")\n  public TriggerDagRun triggerFinance() { return new TriggerDagRun(\"finance_close\"); }\n\n  // Each call is a task, and passing its result to another call draws the dependency.\n  @Airflow.Deps\n  static class Wiring implements OrdersDailyDeps {\n    void depends() {\n      var total = summarize(extract().eu(), extract().us());\n      var loaded = load(total, lit(\"warehouse.orders\"));\n      hasOrders(total).Then(loaded).Else(alert());\n      pickReport(total).Case(reportFull()).Case(reportBrief());\n      loaded.before(Flow.label(triggerFinance(), \"hand off\"));\n    }\n  }\n\n  public static void main(String[] args) {\n    Server.create(args).serve(new Bundle().register(OrdersDaily.class));\n  }\n}\n```\n\n## Go\n\n`Inputs` passes task results and literals to a task function, in parameter order. A task’s id is its function’s name unless a `TaskSpec` sets one.\n\n```\npackage main\n\nimport (\n    \"log\"\n\n    \"github.com/apache/airflow/go-sdk/airflow\"\n)\n\nfunc main() {\n    bundle := airflow.Bundle()\n    bundle.Register(ordersDaily())\n    if err := bundle.Serve(); err != nil {\n        log.Fatal(err)\n    }\n}\n\nfunc ordersDaily() *airflow.DagRef {\n    dag := airflow.Dag(\"orders_daily\", airflow.DagSpec{\n        Schedule: \"@daily\",\n        Tags:     []string{\"orders\"},\n        Queue:    \"golang\",\n    })\n\n    // A task group: its task ids are \"extract.eu\" and \"extract.us\".\n    extract := dag.TaskGroup(\"extract\")\n    eu := extract.Task(countOrders, airflow.TaskSpec{TaskID: \"eu\"}, airflow.Inputs(airflow.Literal(\"eu\")))\n    us := extract.Task(countOrders, airflow.TaskSpec{TaskID: \"us\"}, airflow.Inputs(airflow.Literal(\"us\")))\n\n    // TaskFlow: passing a task's result draws the dependency and delivers the value.\n    total := dag.Task(summarize, airflow.Inputs(eu, us))\n    loaded := dag.Task(load, airflow.Inputs(total, airflow.Literal(\"warehouse.orders\")))\n\n    // if / else: the side not taken is skipped.\n    dag.If(hasOrders, airflow.TaskSpec{TaskID: \"has_orders\"}, airflow.Inputs(total)).\n        Then(loaded).\n        Else(dag.Task(alert))\n\n    // switch / case: the decider returns the task to run.\n    r := &reports{\n        full:  dag.Task(reportFull, airflow.TaskSpec{TaskID: \"report_full\"}),\n        brief: dag.Task(reportBrief, airflow.TaskSpec{TaskID: \"report_brief\"}),\n    }\n    dag.Switch(r.pick, airflow.TaskSpec{TaskID: \"pick_report\"}, airflow.Inputs(total)).\n        Case(r.full).\n        Case(r.brief)\n\n    // Trigger another Dag after the load, on a labeled edge.\n    triggered := dag.Task(\n        airflow.TriggerDagRun(airflow.TriggerDagRunSpec{DagID: \"finance_close\"}),\n        airflow.TaskSpec{TaskID: \"trigger_finance\"},\n    )\n    loaded.Before(airflow.Label(triggered, \"hand off\"))\n\n    return dag\n}\n\nvar ordersByRegion = map[string]int{\"eu\": 120, \"us\": 80}\n\nfunc countOrders(_ airflow.Context, region string) (int, error) { return ordersByRegion[region], nil }\n\nfunc summarize(_ airflow.Context, eu, us int) (int, error) { return eu + us, nil }\n\nfunc load(actx airflow.Context, total int, table string) error {\n    actx.Logger().Info(\"loading orders\", \"total\", total, \"table\", table)\n    return nil\n}\n\nfunc alert(actx airflow.Context) error {\n    actx.Logger().Info(\"no orders today\")\n    return nil\n}\n\nfunc hasOrders(_ airflow.Context, total int) (bool, error) { return total > 0, nil }\n\nfunc reportFull(actx airflow.Context) error {\n    actx.Logger().Info(\"full report\")\n    return nil\n}\n\nfunc reportBrief(actx airflow.Context) error {\n    actx.Logger().Info(\"brief report\")\n    return nil\n}\n\n// reports holds the switch's cases, because a decider returns a *TaskRef.\ntype reports struct{ full, brief *airflow.TaskRef }\n\nfunc (r *reports) pick(_ airflow.Context, total int) (*airflow.TaskRef, error) {\n    if total > 1000 {\n        return r.full, nil\n    }\n    return r.brief, nil\n}\n```\n\n## Building and deploying\n\nEach SDK builds your code into one self-contained artifact, which goes into a Dag bundle like any `.py` file.\n\n### Building\n\n## TypeScript\n\n`airflow-ts-pack` bundles the entry module and everything it imports into one file.\n\n```\nnpm install apache-airflow-ts-sdk\nnpm install --save-dev esbuild\nnpx airflow-ts-pack src/orders_daily.ts --outdir dist   # writes dist/bundle.min.mjs\n```\n\n## Java\n\nThe Gradle plugin builds a fat JAR by default and packs the source of each Dag class into it. Maven is supported too.\n\n```\nplugins {\n    id(\"org.apache.airflow.sdk\") version \"<version>\"\n}\n\ndependencies {\n    annotationProcessor(\"org.apache.airflow:airflow-sdk-processor:<version>\")\n    implementation(\"org.apache.airflow:airflow-sdk:<version>\")\n    implementation(\"org.apache.airflow:airflow-sdk-jpl:<version>\")  // sends System.Logger output to the task log\n}\n\nairflowBundle {\n    mainClass = \"com.example.OrdersDaily\"\n}\n./gradlew bundle   # writes the JAR to build/bundle/\n```\n\n## Go\n\n`airflow-go-pack` compiles the program and embeds each Dag’s source file and a manifest in the executable. It cross-compiles for the platform your Airflow components run on.\n\n```\n# go.mod\ntool github.com/apache/airflow/go-sdk/cmd/airflow-go-pack\n\ngo tool airflow-go-pack --goos linux --goarch amd64 --output dist/orders_daily .\n```\n\n### Deploying\n\nPut the artifact in a Dag bundle, such as the default dags-folder. The Dag processor and the workers need the language’s runtime:\n\n| **SDK** | **Artifact** | **Runtime on the Dag processor and workers** | \n|---|---|---|\n| TypeScript | `*.min.mjs` file | Node.js 22+ | \n| Java | `.jar` whose manifest sets`Main-Class` | Java runtime | \n| Go | executable with no file extension | None | \n\nThen register a coordinator for each language and route each Dag’s queue to it, on the Dag processor and on the workers:\n\n```\n[sdk]\ncoordinators = {\n    \"ts\": {\"classpath\": \"airflow.sdk.coordinators.node.NodeCoordinator\"},\n    \"java\": {\"classpath\": \"airflow.sdk.coordinators.java.JavaCoordinator\"},\n    \"go\": {\"classpath\": \"airflow.sdk.coordinators.executable.ExecutableCoordinator\"}\n  }\nqueue_to_coordinator = {\"typescript\": \"ts\", \"java\": \"java\", \"golang\": \"go\"}\n```\n\nThe Dag processor runs each artifact to read its Dags, and each task runs from the same Dag bundle. From there, Airflow treats the Dags like Python ones: the scheduler plans their runs, they show up in the UI, and the Code view shows each Dag’s own source file. With more than one coordinator for the same language, map each Dag bundle to one of them with `[sdk] dag_bundle_to_coordinator`.\n\nFor a Python Dag with stub TaskHandler, put the artifact next to the Python Dag, or in the Dag bundle named by the coordinator’s `task_handler_bundle_name`.\n\n## Learn more\n\n## YAML Dags\n\nFor teams that want Dags as declarative configuration, with no code and no build step, Airflow 3.4 adds a native YAML importer on top of AIP-85. A `.yaml` file carrying a `$schema` marker is discovered, validated against a schema, and built into a real Dag with real operators.\n\nThe format is intentionally small. A task either **`uses:`** a built-in operator or **` run:`** a code task, binds arguments with **` with:`**, and declares dependencies with **` needs:`**:\n\n```\n# Illustrative of the YAML grammar. Confirm exact keys against the released schema.\n$schema: airflow/dag\ndag_id: daily_sales\nschedule: \"@daily\"\ntasks:\n  - id: extract\n    uses: airflow.providers.standard.operators.bash.BashOperator\n    with:\n      bash_command: \"python extract.py\"\n  - id: load\n    uses: airflow.providers.standard.operators.bash.BashOperator\n    with:\n      bash_command: \"python load.py\"\n    needs: [extract]\n```\n\nTemplating is explicit: a field only renders when the author marks it. The value grammar uses small markers, `$t` to render a template, `$x` to read an XCom, `$f` to read a file, and `$const` for a literal constant, and a marker on a field the operator does not template is a build error rather than something silently ignored. That makes YAML Dags predictable: what you see is what runs.\n\n# 🤖 Common AI Goes GA: LLM and Agent Tasks, With a Human in the Loop\n\nAI steps are becoming a normal part of data pipelines, and the `common.ai` provider reaches **GA** alongside this release — shipping **1.1.0** with Airflow 3.4.0 — stabilizing the API for running model calls and agents as ordinary Airflow tasks. Built on [pydantic-ai](https://ai.pydantic.dev/), it lets the API key come from a connection, a failed call retry like any task, the result land in XCom, and the whole thing run on a schedule.\n\nThe simplest form is a decorator:\n\n```\n@task.llm(\n    llm_conn_id=\"pydanticai_default\",\n    system_prompt=\"You write release announcements. Be concise.\",\n)\ndef summarize(notes: str):\n    return f\"Summarize these release notes in two sentences:\\n{notes}\"\n```\n\nFor anything that needs tools, the `AgentOperator` runs a full agent that can use tools. Toolsets wire the model to real Airflow resources such as SQL, hooks, MCP servers, object storage, and sandboxes, so the agent can actually *do* work, not just talk:\n\n```\nAgentOperator(\n    task_id=\"analyst\",\n    prompt=\"What are the top 5 customers by order count?\",\n    llm_conn_id=\"pydanticai_default\",\n    system_prompt=\"You are a SQL analyst. Use the tools to explore the schema and answer with data.\",\n    toolsets=[SQLToolset(db_conn_id=\"postgres_default\", allowed_tables=[\"customers\", \"orders\"], max_rows=20)],\n)\n```\n\n## Key Capabilities\n\n- **A family of operators and decorators** :`@task.llm` and`LLMOperator` ,`@task.agent` and`AgentOperator` , plus`LLMBranchOperator` (let the model pick the next task),`LLMSQLQueryOperator` (natural language to SQL), and operators for batch and file analysis.\n- **Agents that use tools** via toolsets for SQL, hooks, MCP, object storage, and sandboxes, with LangChain and LlamaIndex integrations for pipelines in the RAG style.\n- **Cost and usage limits** : cap tokens, requests, or spend per run, with usage (including prompt cache hits) pushed to XCom. Prompt caching is on by default.\n- **Human in the loop, natively** : this is where the rest of the release pays off. An agent can pause partway through a run for a person to**approve a tool call** before it executes, or to**approve the final answer** , using the first-class`awaiting_input` state from Airflow 3.3. The task parks**without a triggerer and without holding a worker slot** , shows up on the**Required Actions** page, and resumes when a reviewer responds. Approve and it proceeds; reject and the agent is told and carries on.\n\nSeveral of the more advanced pieces, such as durable replay at the step level on retry, sandbox toolsets, and code mode, are shipping as **experimental previews** and may change before they stabilize.\n\n# 🖥️ UI, Observability, and Operations\n\n- \n**Loop iteration views** : new UI for Task Loops shows each iteration’s run and a per-task loop history chart, so an iterative workflow reads clearly instead of as an opaque group.\n- \n**Dag Folders** : the Dags list gains a**Folders** panel that groups your Dags into a navigable tree mirroring each bundle’s directory layout, so you can drill into`multilang/go` or`task_loops/experiments` and filter to a folder instead of scrolling one flat list. The tree nests to any depth.\n- \n**Language-aware Dags in the UI** : Dags not written in Python surface with their source language, so a Go or TypeScript Dag is treated like any other Dag in the grid and graph.\n- \nPlus the usual round of stability, performance, and quality-of-life fixes across the scheduler, API server, and UI.\n\n# ⬆️ Upgrading to 3.4.0\n\nBefore upgrading, review the [Significant Changes](https://airflow.apache.org/docs/apache-airflow/3.4.0/release_notes.html) in the release notes, and install against the [constraints file](https://github.com/apache/airflow/tree/constraints-3.4.0) for a reproducible upgrade. The language SDKs, the Dag Importer framework, and several `common.ai` previews are explicitly marked experimental, so pin to the exact versions you test with.\n\n# 🙏 Community Appreciation\n\nApache Airflow 3.4.0 is the work of hundreds of contributors who authored AIPs, wrote and reviewed code, improved docs, triaged issues, and tested release candidates. Task Loops, the Dag Importer and its language SDKs, and the `common.ai` provider each represent months of design discussion and collaboration across the community. Thank you to everyone who helped shape and ship this release.\n\n# 🔗 Get Involved\n\n- 📥 **Upgrade** :`pip install apache-airflow==3.4.0` (use the[constraints files](https://github.com/apache/airflow/tree/constraints-3.4.0) for a reproducible install).\n- 📚 **Read the docs** :[https://airflow.apache.org/docs/apache-airflow/3.4.0/](https://airflow.apache.org/docs/apache-airflow/3.4.0/)\n- 💬 **Join the conversation** on the[Airflow Slack](https://s.apache.org/airflow-slack) and the[dev mailing list](https://airflow.apache.org/community/) .\n- 🐛 **Report issues** and open PRs on[GitHub](https://github.com/apache/airflow) .\n\nHappy orchestrating, whether you are iterating, writing in another language, or putting an agent to work with a human in the loop!\n\nShare", "url": "https://wpnews.pro/news/apache-airflow-3-4-0-task-loops-dags-in-any-language-and-ai-agents", "canonical_source": "https://airflow.apache.org/blog/airflow-3.4.0/", "published_at": "2026-11-06 00:00:00+00:00", "updated_at": "2026-11-06 15:49:40.778371+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "mlops", "large-language-models", "ai-tools"], "entities": ["Apache Airflow", "Apache Airflow 3.4.0", "Task Loops", "AIP-111", "common.ai"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/apache-airflow-3-4-0-task-loops-dags-in-any-language-and-ai-agents", "markdown": "https://wpnews.pro/news/apache-airflow-3-4-0-task-loops-dags-in-any-language-and-ai-agents.md", "text": "https://wpnews.pro/news/apache-airflow-3-4-0-task-loops-dags-in-any-language-and-ai-agents.txt", "jsonld": "https://wpnews.pro/news/apache-airflow-3-4-0-task-loops-dags-in-any-language-and-ai-agents.jsonld"}}