[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 4\n\n[*.md]\ntrim_trailing_whitespace = false"
  },
  {
    "path": ".github/workflows/docs-build.yml",
    "content": "name: docs-build\n\non:\n  pull_request:\n    paths:\n      - 'docs-site/**'\n      - '.github/workflows/docs-build.yml'\n  push:\n    branches: [main, dev]\n    paths:\n      - 'docs-site/**'\n      - '.github/workflows/docs-build.yml'\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: docs-site\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          cache: 'npm'\n          cache-dependency-path: docs-site/package-lock.json\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build docs\n        run: npm run build\n"
  },
  {
    "path": ".github/workflows/docs-pages.yml",
    "content": "name: docs-pages\n\non:\n  push:\n    branches: [main]\n    paths:\n      - 'docs-site/**'\n      - '.github/workflows/docs-pages.yml'\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: docs-pages\n  cancel-in-progress: true\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: docs-site\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          cache: 'npm'\n          cache-dependency-path: docs-site/package-lock.json\n\n      - name: Configure Pages\n        uses: actions/configure-pages@v5\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build docs\n        env:\n          GITHUB_PAGES: 'true'\n        run: npm run build\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v3\n        with:\n          path: docs-site/build\n\n  deploy:\n    runs-on: ubuntu-latest\n    needs: build\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".gitignore",
    "content": "target/\n!.mvn/wrapper/maven-wrapper.jar\n!**/src/main/**/target/\n!**/src/test/**/target/\n\n### IntelliJ IDEA ###\n.idea/modules.xml\n.idea/jarRepositories.xml\n.idea/compiler.xml\n.idea/libraries/\n*.iws\n*.iml\n*.ipr\n\n### Eclipse ###\n.apt_generated\n.classpath\n.factorypath\n.project\n.settings\n.springBeans\n.sts4-cache\n\n### NetBeans ###\n/nbproject/private/\n/nbbuild/\n/dist/\n/nbdist/\n/.nb-gradle/\nbuild/\n!**/src/main/**/build/\n!**/src/test/**/build/\n\n### VS Code ###\n.vscode/\n\n### Local tooling ###\n.claude/\nAGENTS.md\nnode_modules/\ndist/\n.rsbuild/\n.turbo/\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\n\n### Scratch / temp ###\n*.class\n.tmp*/\n.tmp*\ntmp/\ntmp-*/\ntmp-*.png\ntmp-*.txt\ntmp-*.log\n\n**/.flattened-pom.xml\n\n### Mac OS ###\n.DS_Store\n/.idea/\n/ai4j/.gitignore\n/ai4j/src/main/resources/新建文本文档 (2).txt\n/ai4j-spring-boot-stater/.gitignore\n.ai4j/\n.docs/\ndocs/\nAPIResponse.md\nAGENT.md\nai4j-release-package.log\njavadoc-ai4j.log\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "README-EN.md",
    "content": "<p align=\"center\">\n  <img src=\"https://capsule-render.vercel.app/api?type=waving&color=0:6A5ACD,100:2E86C1&height=180&section=header&text=ai4j&fontSize=46&fontColor=ffffff&animation=fadeIn&desc=Java%20AI%20Agentic%20SDK%20for%20JDK%208%2B&descAlignY=68\" alt=\"ai4j banner\" />\n</p>\n\n<p align=\"center\">\n  <a href=\"https://search.maven.org/artifact/io.github.lnyo-cly/ai4j\">\n    <img src=\"https://img.shields.io/maven-central/v/io.github.lnyo-cly/ai4j?color=2E86C1&label=Maven%20Central\" alt=\"Maven Central\" />\n  </a>\n  <a href=\"https://lnyo-cly.github.io/ai4j/\">\n    <img src=\"https://img.shields.io/badge/Docs-GitHub%20Pages-0A7EA4\" alt=\"Docs\" />\n  </a>\n  <a href=\"https://www.apache.org/licenses/LICENSE-2.0.txt\">\n    <img src=\"https://img.shields.io/badge/License-Apache%202.0-1F6FEB\" alt=\"License\" />\n  </a>\n  <img src=\"https://img.shields.io/badge/JDK-8%2B-2EA043\" alt=\"JDK 8+\" />\n  <img src=\"https://img.shields.io/badge/Agentic-Enabled-6F42C1\" alt=\"Agentic Enabled\" />\n  <img src=\"https://img.shields.io/badge/MCP-Supported-0F766E\" alt=\"MCP Supported\" />\n  <img src=\"https://img.shields.io/badge/RAG-Built--in-B45309\" alt=\"RAG Built-in\" />\n  <img src=\"https://img.shields.io/badge/CLI%20%2F%20TUI%20%2F%20ACP-Built--in-475569\" alt=\"CLI TUI ACP Built-in\" />\n</p>\n\n# ai4j\nA Java AI Agentic development toolkit for JDK 8+, combining foundational AI capabilities with higher-level agent development capabilities.  \nIt covers multi-provider model access, unified I/O, Tool Calling, MCP, RAG, unified `VectorStore`, ChatMemory, agent runtime, coding agent, CLI / TUI / ACP, FlowGram integration, and integration with published AgentFlow endpoints such as Dify, Coze, and n8n, helping Java applications grow from basic model integration to more complete agentic application development.\n\nThis repository has evolved into a multi-module SDK. In addition to the core `ai4j` module, it now provides `ai4j-agent`, `ai4j-coding`, `ai4j-cli`, `ai4j-spring-boot-starter`, `ai4j-flowgram-spring-boot-starter`, and `ai4j-bom`. If you only need the basic LLM integration layer, start with `ai4j`. If you need agent runtime, coding agent, CLI / ACP, Spring Boot, or FlowGram integration, add the corresponding modules.\n\n## Positioning Compared with Common Java AI Options\n\n| Option | Java baseline | Application style | Primary focus |\n| --- | --- | --- | --- |\n| `ai4j` | `JDK 8+` | Plain Java / Spring | Unified model access, Tool / MCP / RAG, agent runtime, coding agent, CLI / TUI / ACP |\n| `Spring AI` | `Java 17+` | `Spring Boot 3.x` | Spring-native AI integration, model access, Tool Calling, MCP, and RAG |\n| `Spring AI Alibaba` | `Java 17+` | `Spring Boot 3.x` | Spring and Alibaba Cloud AI ecosystem integration |\n| `LangChain4j` | `Java 17+` | Plain Java / Spring / Quarkus and more | General Java abstractions for LLM, agent, and RAG integration, plus AI Services |\n\n## Supported platforms\n+ OpenAi\n+ Jina (Rerank / Jina-compatible Rerank)\n+ Zhipu\n+ DeepSeek\n+ Moonshot\n+ Tencent Hunyuan\n+ Lingyi AI\n+ Ollama\n+ MiniMax\n+ Baichuan\n\n## Supported services\n+ Chat Completions（streaming and non-streaming）\n+ Responses\n+ Embedding\n+ Rerank\n+ Audio\n+ Image\n+ Realtime\n\n## Supported AgentFlow / hosted workflow platforms\n+ Dify (chat / workflow)\n+ Coze (chat / workflow)\n+ n8n (webhook workflow)\n\n## Features\n+ Supports Spring and ordinary Java applications. Supports applications above Java 8.\n+ Multi-platform and multi-service.\n+ Provides `AgentFlow` support for integrating published Agent / Workflow endpoints from Dify, Coze, and n8n.\n+ Provides `ai4j-agent` as the general agent runtime, with ReAct, subagents, agent teams, memory, tracing, and tool loop support.\n+ Built-in Coding Agent CLI / TUI with interactive repository sessions, provider profiles, workspace model override, and session/process management.\n+ Provides `ai4j-coding` as the coding agent runtime, with workspace-aware tools, outer loop, checkpoint compaction, subagent, and team collaboration support.\n+ Provides `ai4j-flowgram-spring-boot-starter` for integrating FlowGram workflows and trace in Spring Boot applications.\n+ Provides `ai4j-bom` for version alignment across multiple ai4j modules.\n+ Unified input and output.\n+ Unified error handling.\n+ Supports streaming output. Supports streaming output of function call parameters.\n+ Easily use Tool Calls.\n+ Supports simultaneous calls of multiple functions (Zhipu does not support this).\n+ Supports stream_options, and directly obtains statistical token usage through streaming output.\n+ Supports RAG. Built-in vector database support: Pinecone.\n+ Uses Tika to read files.\n+ Token statistics`TikTokensUtil.java`\n\n\n## Tutorial documents\n+ [Quick access to Spring Boot, access to streaming and non-streaming and function calls.](http://t.csdnimg.cn/iuIAW)\n+ [Quick access to open source large models such as qwen2.5 and llama3.1 on the Ollama platform in Java.](https://blog.csdn.net/qq_35650513/article/details/142408092?spm=1001.2014.3001.5501)\n+ [Build a legal AI assistant in Java and quickly implement RAG applications.](https://blog.csdn.net/qq_35650513/article/details/142568177?fromshare=blogdetail&sharetype=blogdetail&sharerId=142568177&sharerefer=PC&sharesource=qq_35650513&sharefrom=from_link)\n\n## Coding Agent CLI / TUI\n\nAI4J now includes `ai4j-cli`, which can be used directly as a local coding agent. Current capabilities include:\n\n+ one-shot and persistent sessions\n+ CLI and TUI interaction modes\n+ provider profile persistence\n+ workspace-level model override\n+ subagent and agent team collaboration\n+ session persistence, resume, fork, history, tree, events, replay\n+ team board, team messages, and team resume for collaboration visibility\n+ process management and buffered logs\n\n### Install\n\n```bash\ncurl -fsSL https://lnyo-cly.github.io/ai4j/install.sh | sh\n```\n\n```powershell\nirm https://lnyo-cly.github.io/ai4j/install.ps1 | iex\n```\n\nThe installer downloads `ai4j-cli` from Maven Central and creates the `ai4j` command. Java 8+ must already be installed on the machine.\n\n### one-shot example\n\n```powershell\nai4j code `\n  --provider openai `\n  --protocol responses `\n  --model gpt-5-mini `\n  --prompt \"Read README and summarize the project structure\"\n```\n\n### interactive CLI example\n\n```powershell\nai4j code `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n### TUI example\n\n```powershell\nai4j tui `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n### ACP example\n\n```powershell\nai4j acp `\n  --provider openai `\n  --protocol responses `\n  --model gpt-5-mini `\n  --workspace .\n```\n\n### Build from source (optional)\n\n```powershell\nmvn -pl ai4j-cli -am -DskipTests package\n```\n\nArtifact:\n\n```text\nai4j-cli/target/ai4j-cli-<version>-jar-with-dependencies.jar\n```\n\nIf you want to run the locally built artifact directly:\n\n```powershell\njava -jar .\\ai4j-cli\\target\\ai4j-cli-<version>-jar-with-dependencies.jar code --help\n```\n\n### Current protocol rules\n\nThe CLI currently exposes only two protocol families:\n\n+ `chat`\n+ `responses`\n\nIf `--protocol` is omitted, the CLI resolves a default locally from provider/baseUrl:\n\n+ `openai` + official OpenAI host -> `responses`\n+ `openai` + custom compatible `baseUrl` -> `chat`\n+ `doubao` / `dashscope` -> `responses`\n+ other providers -> `chat`\n\nNotes:\n\n+ `auto` is no longer exposed to users\n+ legacy `auto` values in existing config files are normalized to explicit protocols on load\n\n### provider profile locations\n\n+ global config: `~/.ai4j/providers.json`\n+ workspace config: `<workspace>/.ai4j/workspace.json`\n\nRecommended workflow:\n\n+ keep reusable long-term runtime profiles in the global config\n+ let each workspace reference one `activeProfile`\n+ use workspace `modelOverride` for temporary model switching\n\n### Common commands\n\n+ `/providers`\n+ `/provider`\n+ `/provider use <name>`\n+ `/provider save <name>`\n+ `/provider add <name> --provider <name> [--protocol <chat|responses>] [--model <name>] [--base-url <url>] [--api-key <key>]`\n+ `/provider edit <name> [--provider <name>] [--protocol <chat|responses>] [--model <name>|--clear-model] [--base-url <url>|--clear-base-url] [--api-key <key>|--clear-api-key]`\n+ `/provider default <name|clear>`\n+ `/provider remove <name>`\n+ `/model`\n+ `/model <name>`\n+ `/model reset`\n+ `/stream [on|off]`\n+ `/processes`\n+ `/process status|follow|logs|write|stop ...`\n+ `/resume <id>` / `/load <id>` / `/fork ...`\n\n### Documentation entry points\n\n+ [Coding Agent CLI Quickstart](docs-site/docs/getting-started/coding-agent-cli-quickstart.md)\n+ [Coding Agent CLI and TUI](docs-site/docs/agent/coding-agent-cli.md)\n+ [Multi-Provider Profiles](docs-site/docs/agent/multi-provider-profiles.md)\n+ [Coding Agent Command Reference](docs-site/docs/agent/coding-agent-command-reference.md)\n+ [Provider Configuration Examples](docs-site/docs/agent/provider-config-examples.md)\n\n## Other support\n+ [[Low-cost transit platform] Low-cost ApiKey - Limited-time special offer 0.7:1 - Supports the latest o1 model.](https://api.trovebox.online/)\n\n# Quick start\n## Import\n### Module selection\n+ Use `ai4j` for the core LLM / Tool Call / MCP / RAG capabilities\n+ Use `ai4j-agent` for the general agent runtime\n+ Use `ai4j-coding` for coding agent, workspace tools, and outer loop\n+ Use `ai4j-cli` for the local CLI / TUI / ACP host\n+ Use `ai4j-spring-boot-starter` for Spring Boot auto-configuration\n+ Use `ai4j-flowgram-spring-boot-starter` for FlowGram workflow integration\n+ Use `ai4j-bom` when you want version alignment across multiple modules\n\n### Gradle\n```groovy\nimplementation platform(\"io.github.lnyo-cly:ai4j-bom:${project.version}\")\nimplementation \"io.github.lnyo-cly:ai4j\"\nimplementation \"io.github.lnyo-cly:ai4j-agent\"\n```\n\n```groovy\nimplementation group: 'io.github.lnyo-cly', name: 'ai4j', version: '${project.version}'\n```\n\n```groovy\nimplementation group: 'io.github.lnyo-cly', name: 'ai4j-spring-boot-starter', version: '${project.version}'\n```\n\n\n### Maven\n```xml\n<dependencyManagement>\n    <dependencies>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j-bom</artifactId>\n            <version>${project.version}</version>\n            <type>pom</type>\n            <scope>import</scope>\n        </dependency>\n    </dependencies>\n</dependencyManagement>\n```\n\n```xml\n<!-- Recommended for multi-module usage -->\n<dependency>\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-agent</artifactId>\n</dependency>\n\n<dependency>\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-coding</artifactId>\n</dependency>\n```\n\n```xml\n<!-- Non-Spring application -->\n<dependency>\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j</artifactId>\n    <version>${project.version}</version>\n</dependency>\n\n```\n```xml\n<!-- Spring application -->\n<dependency>\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-spring-boot-starter</artifactId>\n    <version>${project.version}</version>\n</dependency>\n```\n\n## Obtain AI service instance\n\n### Obtaining without Spring\n```java\n    public void test_init(){\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n\n        Configuration configuration = new Configuration();\n        configuration.setOpenAiConfig(openAiConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\",10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n        chatService = aiService.getChatService(PlatformType.getPlatform(\"OPENAI\"));\n\n    }\n```\n### Obtaining with Spring\n```yml\n# Domestic access usually requires a proxy by default.\nai:\n  openai:\n    api-key: \"api-key\"\n  okhttp:\n    proxy-port: 10809\n    proxy-url: \"127.0.0.1\"\n  zhipu:\n    api-key: \"xxx\"\n  #other...\n```\n\n```java\n// Inject Ai service\n@Autowired\nprivate AiService aiService;\n\n// Obtain the required service instance\nIChatService chatService = aiService.getChatService(PlatformType.OPENAI);\nIEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n// ......\n```\n\n## Chat service\n\n### Synchronous request call\n```java\n\npublic void test_chat() throws Exception {\n    // Obtain chat service instance\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    // Build request parameters\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n            .build();\n\n    // Send dialogue request\n    ChatCompletionResponse response = chatService.chatCompletion(chatCompletion);\n\n    System.out.println(response);\n}\n\n```\n\n### Streaming call\n```java\npublic void test_chat_stream() throws Exception {\n    // Obtain chat service instance\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    // Construct request parameters\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(\"查询北京明天的天气\"))\n            .functions(\"queryWeather\")\n            .build();\n\n\n    // Construct listener\n    SseListener sseListener = new SseListener() {\n        @Override\n        protected void send() {\n            System.out.println(this.getCurrStr());\n        }\n    };\n    // Display function parameters. Default is not to display.\n    sseListener.setShowToolArgs(true);\n\n    // Send SSE request\n    chatService.chatCompletionStream(chatCompletion, sseListener);\n\n    System.out.println(sseListener.getOutput());\n\n}\n```\n\n### Image recognition\n\n```java\npublic void test_chat_image() throws Exception {\n    // Obtain chat service instance\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    // Build request parameters\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(\"图片中有什么东西\", \"https://cn.bing.com/images/search?view=detailV2&ccid=r0OnuYkv&id=9A07DE578F6ED50DB59DFEA5C675AC71845A6FC9&thid=OIP.r0OnuYkvsbqBrYk3kUT53AHaKX&mediaurl=https%3a%2f%2fimg.zcool.cn%2fcommunity%2f0104c15cd45b49a80121416816f1ec.jpg%401280w_1l_2o_100sh.jpg&exph=1792&expw=1280&q=%e5%b0%8f%e7%8c%ab%e5%9b%be%e7%89%87&simid=607987191780608963&FORM=IRPRST&ck=12127C1696CF374CB9D0F09AE99AFE69&selectedIndex=2&itb=0&qpvt=%e5%b0%8f%e7%8c%ab%e5%9b%be%e7%89%87\"))\n            .build();\n\n    // Send dialogue request\n    ChatCompletionResponse response = chatService.chatCompletion(chatCompletion);\n\n    System.out.println(response);\n}\n```\n\n### Function call\n\n```java\npublic void test_chat_tool_call() throws Exception {\n    // Obtain chat service instance\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    // Build request parameters\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(\"今天北京天气怎么样\"))\n            .functions(\"queryWeather\")\n            .build();\n\n    // Send dialogue request\n    ChatCompletionResponse response = chatService.chatCompletion(chatCompletion);\n\n    System.out.println(response);\n}\n```\n#### Define function\n```java\n@FunctionCall(name = \"queryWeather\", description = \"查询目标地点的天气预报\")\npublic class QueryWeatherFunction implements Function<QueryWeatherFunction.Request, String> {\n\n    @Data\n    @FunctionRequest\n    public static class Request{\n        @FunctionParameter(description = \"需要查询天气的目标位置, 可以是城市中文名、城市拼音/英文名、省市名称组合、IP 地址、经纬度\")\n        private String location;\n        @FunctionParameter(description = \"需要查询未来天气的天数, 最多15日\")\n        private int days = 15;\n        @FunctionParameter(description = \"预报的天气类型，daily表示预报多天天气、hourly表示预测当天24天气、now为当前天气实况\")\n        private Type type;\n    }\n\n    public enum Type{\n        daily,\n        hourly,\n        now\n    }\n\n    @Override\n    public String apply(Request request) {\n        final String key = \"\";\n\n        String url = String.format(\"https://api.seniverse.com/v3/weather/%s.json?key=%s&location=%s&days=%d\",\n                request.type.name(),\n                key,\n                request.location,\n                request.days);\n\n\n        OkHttpClient client = new OkHttpClient();\n\n        okhttp3.Request http = new okhttp3.Request.Builder()\n                .url(url)\n                .get()\n                .build();\n\n        try (Response response = client.newCall(http).execute()) {\n            if (response.isSuccessful()) {\n                // 解析响应体\n                return response.body() != null ? response.body().string() : \"\";\n            } else {\n                return \"获取天气失败 当前天气未知\";\n            }\n        } catch (Exception e) {\n            // 处理异常\n            e.printStackTrace();\n            return \"获取天气失败 当前天气未知\";\n        }\n    }\n\n}\n```\n\n## Embedding service\n\n```java\npublic void test_embed() throws Exception {\n    // Obtain embedding service instance\n    IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n\n    // Build request parameters\n    Embedding embeddingReq = Embedding.builder().input(\"1+1\").build();\n\n    // Send embedding request\n    EmbeddingResponse embeddingResp = embeddingService.embedding(embeddingReq);\n\n    System.out.println(embeddingResp);\n}\n```\n\n## RAG\n### Configure vector database\n```yml\nai:\n  vector:\n    pinecone:\n      url: \"\"\n      key: \"\"\n```\n### Obtain instance\n```java\n@Autowired\nprivate PineconeService pineconeService;\n```\n### Insert into vector database\n```java\npublic void test_insert_vector_store() throws Exception {\n    // Obtain embedding service instance\n    IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n\n    // Read file content using Tika\n    String fileContent = TikaUtil.parseFile(new File(\"D:\\\\data\\\\test\\\\test.txt\"));\n\n    // Split text content\n    RecursiveCharacterTextSplitter recursiveCharacterTextSplitter = new RecursiveCharacterTextSplitter(1000, 200);\n    List<String> contentList = recursiveCharacterTextSplitter.splitText(fileContent);\n\n    // Convert to vector\n    Embedding build = Embedding.builder()\n            .input(contentList)\n            .model(\"text-embedding-3-small\")\n            .build();\n    EmbeddingResponse embedding = embeddingService.embedding(build);\n    List<List<Float>> vectors = embedding.getData().stream().map(EmbeddingObject::getEmbedding).collect(Collectors.toList());\n    VertorDataEntity vertorDataEntity = new VertorDataEntity();\n    vertorDataEntity.setVector(vectors);\n    vertorDataEntity.setContent(contentList);\n\n    // Vector storage\n    Integer count = pineconeService.insert(vertorDataEntity, \"userId\");\n\n}\n```\n### Query from vector database\n```java\npublic void test_query_vector_store() throws Exception {\n    // // Obtain embedding service instance\n    IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n\n    // Build the question to be queried and convert it to a vector\n    Embedding build = Embedding.builder()\n            .input(\"question\")\n            .model(\"text-embedding-3-small\")\n            .build();\n    EmbeddingResponse embedding = embeddingService.embedding(build);\n    List<Float> question = embedding.getData().get(0).getEmbedding();\n\n    // Build the query object for the vector database\n    PineconeQuery pineconeQueryReq = PineconeQuery.builder()\n            .namespace(\"userId\")\n            .vector(question)\n            .build();\n\n    String result = pineconeService.query(pineconeQueryReq, \" \");\n    \n    // Carry the result and have a conversation with the chat service.\n    // ......\n}\n```\n\n### Delete data from vector database\n```java\npublic void test_delete_vector_store() throws Exception {\n    // Build parameters\n    PineconeDelete pineconeDelete = PineconeDelete.builder()\n                                    .deleteAll(true)\n                                    .namespace(\"userId\")\n                                    .build();\n    // Delete\n    Boolean res = pineconeService.delete(pineconeDelete);\n}\n```\n\n\n\n# Contribute to ai4j\nYou are welcome to provide suggestions, report issues, or contribute code to ai4j. You can contribute to ai4j in the following ways:\n\n## Issue feedback\nPlease use the GitHub Issue page to report issues. Describe as specifically as possible how to reproduce your issue, including detailed information such as the operating system, Java version, and any relevant log traces.\n## PR\n1. Fork this repository and create your branch.\n2. Write your code and test it.\n3. Ensure that your code conforms to the existing style.\n4. Write clear log information when submitting. For small changes, a single line of information is sufficient, but for larger changes, there should be a detailed description.\n5. Complete the pull request form and ensure that changes are made on the `dev` branch and link to the issue that your PR addresses.\n\n# Support\nIf you find this project helpful to you, please give it a star⭐。\n\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <img src=\"https://capsule-render.vercel.app/api?type=waving&color=0:6A5ACD,100:2E86C1&height=180&section=header&text=ai4j&fontSize=46&fontColor=ffffff&animation=fadeIn&desc=Java%20AI%20Agentic%20SDK%20for%20JDK%208%2B&descAlignY=68\" alt=\"ai4j banner\" />\n</p>\n\n<p align=\"center\">\n  <a href=\"https://search.maven.org/artifact/io.github.lnyo-cly/ai4j\">\n    <img src=\"https://img.shields.io/maven-central/v/io.github.lnyo-cly/ai4j?color=2E86C1&label=Maven%20Central\" alt=\"Maven Central\" />\n  </a>\n  <a href=\"https://lnyo-cly.github.io/ai4j/\">\n    <img src=\"https://img.shields.io/badge/Docs-GitHub%20Pages-0A7EA4\" alt=\"Docs\" />\n  </a>\n  <a href=\"https://www.apache.org/licenses/LICENSE-2.0.txt\">\n    <img src=\"https://img.shields.io/badge/License-Apache%202.0-1F6FEB\" alt=\"License\" />\n  </a>\n  <img src=\"https://img.shields.io/badge/JDK-8%2B-2EA043\" alt=\"JDK 8+\" />\n  <img src=\"https://img.shields.io/badge/Agentic-Enabled-6F42C1\" alt=\"Agentic Enabled\" />\n  <img src=\"https://img.shields.io/badge/MCP-Supported-0F766E\" alt=\"MCP Supported\" />\n  <img src=\"https://img.shields.io/badge/RAG-Built--in-B45309\" alt=\"RAG Built-in\" />\n  <img src=\"https://img.shields.io/badge/CLI%20%2F%20TUI%20%2F%20ACP-Built--in-475569\" alt=\"CLI TUI ACP Built-in\" />\n</p>\n\n# ai4j\n一款面向 JDK8+ 的 Java AI Agentic 开发套件，既提供统一的大模型调用与常用 AI 基座能力，也提供更完善的智能体式 Agent 开发能力。  \n覆盖多平台模型接入、统一输入输出、Tool Call、MCP、RAG、统一 `VectorStore`、ChatMemory、Agent Runtime、Coding Agent、CLI / TUI / ACP、FlowGram 集成，以及 Dify / Coze / n8n 等已发布 AgentFlow 端点接入能力，帮助 Java 应用从基础模型接入扩展到更完整的 agentic 应用开发。\n\n当前仓库已经演进为多模块 SDK，除核心 `ai4j` 外，还提供 `ai4j-agent`、`ai4j-coding`、`ai4j-cli`、`ai4j-spring-boot-starter`、`ai4j-flowgram-spring-boot-starter`、`ai4j-bom`。如果只需要基础大模型调用，优先引入 `ai4j`；如果需要 Agent、Coding Agent、CLI / ACP、Spring Boot 或 FlowGram 集成，再按模块引入对应能力。\n\n## 适用场景与常见方案对比\n\n| 方案 | Java 基线 | 应用形态 | 能力侧重点 |\n| --- | --- | --- | --- |\n| `ai4j` | `JDK8+` | 普通 Java / Spring | 统一大模型接入、Tool / MCP / RAG、Agent Runtime、Coding Agent、CLI / TUI / ACP |\n| `Spring AI` | `Java 17+` | `Spring Boot 3.x` | Spring 原生 AI 集成、模型访问、Tool Calling、MCP、RAG |\n| `Spring AI Alibaba` | `Java 17+` | `Spring Boot 3.x` | Spring 与阿里云 AI 生态整合 |\n| `LangChain4j` | `Java 17+` | 普通 Java / Spring / Quarkus 等 | 通用 Java LLM / Agent / RAG 抽象、AI Services、多框架集成 |\n\n## 支持的平台\n+ OpenAi(包含与OpenAi请求格式相同/兼容的平台)\n+ Jina（Rerank / Jina-compatible Rerank）\n+ Zhipu(智谱)\n+ DeepSeek(深度求索)\n+ Moonshot(月之暗面)\n+ Hunyuan(腾讯混元)\n+ Lingyi(零一万物)\n+ Ollama\n+ MiniMax\n+ Baichuan\n\n## 支持的服务\n+ Chat Completions（流式与非流式）\n+ Responses\n+ Embedding\n+ Rerank\n+ Audio\n+ Image\n+ Realtime\n\n## 已适配的 AgentFlow / 工作流平台\n+ Dify（Chat / Workflow）\n+ Coze（Chat / Workflow）\n+ n8n（Webhook Workflow）\n\n## 特性\n+ 支持MCP服务，内置MCP网关，支持建立动态MCP数据源。\n+ 支持Spring以及普通Java应用、支持Java 8以上的应用\n+ 多平台、多服务\n+ 提供 `AgentFlow` 能力，可直接接入 Dify、Coze、n8n 等已发布 Agent / Workflow 端点\n+ 提供 `ai4j-agent` 通用 Agent 运行时，支持 ReAct、subagent、agent teams、memory、trace 与 tool loop\n+ 内置 Coding Agent CLI / TUI，支持本地代码仓交互式会话、provider profile、workspace model override、session/process 管理\n+ 提供 `ai4j-coding` Coding Agent 运行时，支持 workspace tools、outer loop、checkpoint compaction、subagent 与 team 协作\n+ 提供 `ai4j-flowgram-spring-boot-starter`，便于在 Spring Boot 中接入 FlowGram 工作流与 trace\n+ 提供 `ai4j-bom`，便于多模块项目统一版本管理\n+ 统一的输入输出\n+ 统一的错误处理\n+ 支持SPI机制，可自定义Dispatcher和ConnectPool\n+ 支持服务增强，例如增加websearch服务\n+ 支持流式输出。支持函数调用参数流式输出.\n+ 简洁的多模态调用方式，例如vision识图\n+ 轻松使用Tool Calls\n+ 支持多个函数同时调用（智谱不支持）\n+ 支持stream_options，流式输出直接获取统计token usage\n+ 内置 `ChatMemory`，支持基础多轮会话上下文维护，可同时适配 Chat / Responses\n+ 支持RAG，内置统一 `VectorStore` 抽象，当前支持: Pinecone、Qdrant、pgvector、Milvus\n+ 内置 `IngestionPipeline`，统一串联 `DocumentLoader -> Chunker -> MetadataEnricher -> Embedding -> VectorStore.upsert`\n+ 内置 `DenseRetriever`、`Bm25Retriever`、`HybridRetriever`，可按语义检索、关键词检索、混合检索方式组合知识库召回\n+ `HybridRetriever` 支持 `RrfFusionStrategy`、`RsfFusionStrategy`、`DbsfFusionStrategy`，默认使用 RRF；融合排序与 `Reranker` 语义精排解耦\n+ 支持统一 `IRerankService`，当前可接 Jina / Jina-compatible、Ollama、Doubao(方舟知识库重排)；可通过 `ModelReranker` 无缝接入 RAG 精排\n+ RAG 运行时可直接拿到 `rank/retrieverSource/retrievalScore/fusionScore/rerankScore/scoreDetails/trace`，并可通过 `RagEvaluator` 计算 `Precision@K/Recall@K/F1@K/MRR/NDCG`\n+ 使用Tika读取文件\n+ Token统计`TikTokensUtil.java`\n\n## 官方文档站\n+ 在线文档站：`https://lnyo-cly.github.io/ai4j/`\n+ 文档站源码位于 `docs-site/`\n+ 适合直接使用者的入口：`docs-site/docs/coding-agent/`\n+ 适合 SDK 接入的入口：`docs-site/docs/getting-started/` 与 `docs-site/docs/ai-basics/`\n+ 适合协议与扩展集成的入口：`docs-site/docs/mcp/`、`docs-site/docs/agent/`\n\n推荐阅读顺序：\n\n+ `docs-site/docs/intro.md`\n+ `docs-site/docs/getting-started/installation.md`\n+ `docs-site/docs/coding-agent/overview.md`\n+ `docs-site/docs/ai-basics/overview.md`\n+ `docs-site/docs/mcp/overview.md`\n\n基础会话上下文新增入口：\n\n+ `docs-site/docs/ai-basics/chat/chat-memory.md`\n+ `docs-site/docs/ai-basics/services/rerank.md`\n+ `docs-site/docs/ai-basics/rag/ingestion-pipeline.md`\n\n本地运行文档站：\n\n```powershell\ncd .\\docs-site\nnpm install\nnpm run start\n```\n\n```powershell\ncd .\\docs-site\nnpm run build\n```\n\n## 更新日志\n+ [2026-03-28] 修复 Coding Agent ACP 流式场景下纯空白 chunk 被 runtime 过滤的问题；ACP 保持透传原始 delta，不做 chunk 聚合；补充 CLI/文档中的流式语义说明\n+ [2026-03-26] 新增 Coding Agent CLI / TUI 文档与能力说明，覆盖交互式会话、provider profile、workspace model override、命令参考与配置样例\n+ [2025-08-19] 修复传递有验证参数的sse-url时，key丢失问题\n+ [2025-08-08] OpenAi: max_tokens字段现已废弃，推荐使用max_completion_tokens(GPT-5已经不支持max_tokens字段)\n+ [2025-08-08] 支持MCP协议，支持STDIO,SSE,Streamable HTTP; 支持MCP Server与MCP Client; 支持MCP网关; 支持自定义MCP数据源; 支持MCP自动重连\n+ [2025-06-23] 修复ollama的流式错误；修复ollama函数调用的错误；修复moonshot请求时错误；修复ollama embedding错误；修复思考无内容；修复日志冲突；新增自定义异常方法。\n+ [2025-02-28] 新增对Ollama平台的embedding接口的支持。\n+ [2025-02-17] 新增对DeepSeek平台推理模型的适配。\n+ [2025-02-12] 为Ollama平台添加Authorization\n+ [2025-02-11] 实现自定义的Jackson序列化，解决OpenAi已经无法通过Json String来直接实现多模态接口的问题。\n+ [2024-12-12] 使用装饰器模式增强Chat服务，支持SearXNG网络搜索增强，无需模型支持内置搜索以及function_call。\n+ [2024-10-17] 支持SPI机制，可自定义Dispatcher和ConnectPool。新增百川Baichuan平台Chat接口支持。\n+ [2024-10-16] 增加MiniMax平台Chat接口对接\n+ [2024-10-15] 增加realtime服务\n+ [2024-10-12] 修复早期遗忘的小bug; 修复错误拦截器导致的音频字节流异常错误问题; 增加OpenAi Audio服务。\n+ [2024-10-10] 增强对SSE输出的获取，新加入`currData`属性，记录当前消息的整个对象。而原先的`currStr`为当前消息的content内容，保留不变。\n+ [2024-09-26] 修复有关Pinecone向量数据库的一些问题。发布0.6.3版本\n+ [2024-09-20] 增加对Ollama平台的支持，并修复一些bug。发布0.6.2版本\n+ [2024-09-19] 增加错误处理链，统一处理为openai错误类型; 修复部分情况下URL拼接问题，修复拦截器中response重复调用而导致的关闭问题。发布0.5.3版本\n+ [2024-09-12] 修复上个问题OpenAi参数导致错误的遗漏，发布0.5.2版本\n+ [2024-09-12] 修复SpringBoot 2.6以下导致OkHttp变为3.14版本的报错问题；修复OpenAi参数`parallel_tool_calls`在tools为null时的异常问题。发布0.5.1版本。\n+ [2024-09-09] 新增零一万物大模型支持、发布0.5.0版本。\n+ [2024-09-02] 新增腾讯混元Hunyuan平台支持（注意：所需apiKey 属于SecretId与SecretKey的拼接，格式为 {SecretId}.{SecretKey}），发布0.4.0版本。\n+ [2024-08-30] 新增对Moonshot(Kimi)平台的支持，增加`OkHttpUtil.java`实现忽略SSL证书的校验。\n+ [2024-08-29] 新增对DeepSeek平台的支持、新增stream_options可以直接统计usage、新增错误拦截器`ErrorInterceptor.java`、发布0.3.0版本。\n+ [2024-08-29] 修改SseListener以兼容智谱函数调用。\n+ [2024-08-28] 添加token统计、添加智谱AI的Chat服务、优化函数调用可以支持多轮多函数。\n+ [2024-08-17] 增强SseListener监听器功能。发布0.2.0版本。\n\n## 教程文档\n+ [快速接入SpringBoot、接入流式与非流式以及函数调用](http://t.csdnimg.cn/iuIAW)\n+ [Java快速接入qwen2.5、llama3.1等Ollama平台开源大模型](https://blog.csdn.net/qq_35650513/article/details/142408092?spm=1001.2014.3001.5501)\n+ [Java搭建法律AI助手，快速实现RAG应用](https://blog.csdn.net/qq_35650513/article/details/142568177?fromshare=blogdetail&sharetype=blogdetail&sharerId=142568177&sharerefer=PC&sharesource=qq_35650513&sharefrom=from_link)\n+ [大模型不支持联网搜索？为Deepseek、Qwen、llama等本地模型添加网络搜索](https://blog.csdn.net/qq_35650513/article/details/144572824)\n+ [java快速接入mcp以及结合mysql动态管理](https://blog.csdn.net/qq_35650513/article/details/150532784?fromshare=blogdetail&sharetype=blogdetail&sharerId=150532784&sharerefer=PC&sharesource=qq_35650513&sharefrom=from_link)\n\n## Coding Agent CLI / TUI\n\nAI4J 目前已经内置 `ai4j-cli`，可以直接作为本地 coding agent 使用，支持：\n\n+ one-shot 与持续会话\n+ CLI / TUI 两种交互模式\n+ provider profile 持久化\n+ workspace 级 model override\n+ subagent 与 agent teams 协作\n+ session 持久化、resume、fork、history、tree、events、replay\n+ team board、team messages、team resume 等协作观测能力\n+ process 管理与日志查看\n\n### 安装\n\n```bash\ncurl -fsSL https://lnyo-cly.github.io/ai4j/install.sh | sh\n```\n\n```powershell\nirm https://lnyo-cly.github.io/ai4j/install.ps1 | iex\n```\n\n安装脚本会从 Maven Central 下载 `ai4j-cli` 并生成 `ai4j` 命令，前提是本机已经安装 Java 8+。\n\n### one-shot 示例\n\n```powershell\nai4j code `\n  --provider openai `\n  --protocol responses `\n  --model gpt-5-mini `\n  --prompt \"Read README and summarize the project structure\"\n```\n\n### 交互式 CLI 示例\n\n```powershell\nai4j code `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n### TUI 示例\n\n```powershell\nai4j tui `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n### ACP 示例\n\n```powershell\nai4j acp `\n  --provider openai `\n  --protocol responses `\n  --model gpt-5-mini `\n  --workspace .\n```\n\n### 源码构建（可选）\n\n```powershell\nmvn -pl ai4j-cli -am -DskipTests package\n```\n\n产物示例：\n\n```text\nai4j-cli/target/ai4j-cli-<version>-jar-with-dependencies.jar\n```\n\n如果你需要直接运行本地构建产物：\n\n```powershell\njava -jar .\\ai4j-cli\\target\\ai4j-cli-<version>-jar-with-dependencies.jar code --help\n```\n\n### 当前协议规则\n\n当前 CLI 对用户只暴露两种协议：\n\n+ `chat`\n+ `responses`\n\n如果省略 `--protocol`，会按 provider/baseUrl 在本地推导默认值：\n\n+ `openai` + 官方 OpenAI host -> `responses`\n+ `openai` + 自定义兼容 `baseUrl` -> `chat`\n+ `doubao` / `dashscope` -> `responses`\n+ 其他 provider -> `chat`\n\n注意：\n\n+ 不再对用户暴露 `auto`\n+ 旧配置中的 `auto` 会在读取时自动归一化为显式协议\n\n### provider profile 配置位置\n\n+ 全局配置：`~/.ai4j/providers.json`\n+ 工作区配置：`<workspace>/.ai4j/workspace.json`\n\n推荐工作流：\n\n+ 全局保存长期可复用 profile\n+ workspace 只引用当前 activeProfile\n+ 临时切模型时使用 workspace 的 `modelOverride`\n\n`workspace.json` 也可以显式挂载额外 skill 目录：\n\n```json\n{\n  \"activeProfile\": \"openai-main\",\n  \"modelOverride\": \"gpt-5-mini\",\n  \"enabledMcpServers\": [\"fetch\"],\n  \"skillDirectories\": [\n    \".ai4j/skills\",\n    \"C:/skills/team\",\n    \"../shared-skills\"\n  ]\n}\n```\n\nskill 发现规则：\n\n+ 默认扫描 `<workspace>/.ai4j/skills`\n+ 默认扫描 `~/.ai4j/skills`\n+ `skillDirectories` 中的相对路径按 workspace 根目录解析\n+ 进入 CLI 后可用 `/skills` 查看当前发现到的 skill\n+ 可用 `/skills <name>` 查看某个 skill 的路径、来源、描述和扫描 roots，不打印 `SKILL.md` 正文\n\n### `/stream`、`Esc` 与状态提示\n\n当前 `/stream` 的语义是“当前 CLI 会话里的模型请求是否启用 `stream`”，不是单纯的 transcript 渲染开关：\n\n+ 作用域是当前 CLI 会话\n+ `/stream on|off` 会切换请求级 `stream=true|false`，并立即重建当前 session runtime\n+ `on` 时 provider 响应按增量到达，assistant 文本也按增量呈现\n+ `off` 时等待完整响应后再输出整理后的完成块\n+ 流式 event 粒度由上游 provider/SSE 决定，不保证“一个 event = 一个 token”\n+ 如果通过 ACP/IDE 接入，宿主应按收到的 chunk 顺序渲染，并保留换行与空白\n\n当前交互壳层里：\n\n+ `Esc` 在活跃 turn 中断当前任务；空闲时关闭 palette 或清空输入\n+ 状态栏会显示 `Thinking`、`Connecting`、`Responding`、`Working`、`Retrying`\n+ 一段时间没有新进展会升级为 `Waiting`\n+ 更久没有新进展会显示 `Stalled`，并提示 `press Esc to interrupt`\n\n### 常用命令\n\n+ `/providers`\n+ `/provider`\n+ `/provider use <name>`\n+ `/provider save <name>`\n+ `/provider add <name> --provider <name> [--protocol <chat|responses>] [--model <name>] [--base-url <url>] [--api-key <key>]`\n+ `/provider edit <name> [--provider <name>] [--protocol <chat|responses>] [--model <name>|--clear-model] [--base-url <url>|--clear-base-url] [--api-key <key>|--clear-api-key]`\n+ `/provider default <name|clear>`\n+ `/provider remove <name>`\n+ `/model`\n+ `/model <name>`\n+ `/model reset`\n+ `/skills`\n+ `/skills <name>`\n+ `/stream [on|off]`\n+ `/processes`\n+ `/process status|follow|logs|write|stop ...`\n+ `/resume <id>` / `/load <id>` / `/fork ...`\n\n### 文档入口\n\n+ [Coding Agent 总览](docs-site/docs/coding-agent/overview.md)\n+ [Coding Agent 快速开始](docs-site/docs/coding-agent/quickstart.md)\n+ [CLI / TUI 使用指南](docs-site/docs/coding-agent/cli-and-tui.md)\n+ [会话、流式与进程](docs-site/docs/coding-agent/session-runtime.md)\n+ [配置体系](docs-site/docs/coding-agent/configuration.md)\n+ [Tools 与审批机制](docs-site/docs/coding-agent/tools-and-approvals.md)\n+ [Skills 使用与组织](docs-site/docs/coding-agent/skills.md)\n+ [MCP 对接](docs-site/docs/coding-agent/mcp-integration.md)\n+ [ACP 集成](docs-site/docs/coding-agent/acp-integration.md)\n+ [TUI 定制与主题](docs-site/docs/coding-agent/tui-customization.md)\n+ [命令参考](docs-site/docs/coding-agent/command-reference.md)\n\n## 其它支持\n+ [[低价中转平台] 低价ApiKey—限时特惠 ](https://api.trovebox.online/)\n+ [[在线平台] 每日白嫖额度-所有模型均可使用 ](https://chat.trovebox.online/)\n\n# 快速开始\n## 导入\n### 模块选型\n+ 只需要基础 LLM / Tool Call / MCP / RAG 能力：引入 `ai4j`\n+ 需要通用 Agent 运行时：引入 `ai4j-agent`\n+ 需要 Coding Agent、workspace tools、outer loop：引入 `ai4j-coding`\n+ 需要本地 CLI / TUI / ACP 宿主：引入 `ai4j-cli`\n+ 需要 Spring Boot 自动配置：引入 `ai4j-spring-boot-starter`\n+ 需要 FlowGram 工作流集成：引入 `ai4j-flowgram-spring-boot-starter`\n+ 同时引入多个模块：建议额外引入 `ai4j-bom`\n\n### Gradle\n```groovy\nimplementation platform(\"io.github.lnyo-cly:ai4j-bom:${project.version}\")\nimplementation \"io.github.lnyo-cly:ai4j\"\nimplementation \"io.github.lnyo-cly:ai4j-agent\"\n```\n\n```groovy\nimplementation group: 'io.github.lnyo-cly', name: 'ai4j', version: '${project.version}'\n```\n\n```groovy\nimplementation group: 'io.github.lnyo-cly', name: 'ai4j-spring-boot-starter', version: '${project.version}'\n```\n\n\n### Maven\n```xml\n<dependencyManagement>\n    <dependencies>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j-bom</artifactId>\n            <version>${project.version}</version>\n            <type>pom</type>\n            <scope>import</scope>\n        </dependency>\n    </dependencies>\n</dependencyManagement>\n```\n\n```xml\n<!-- 多模块项目推荐 -->\n<dependency>\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-agent</artifactId>\n</dependency>\n\n<dependency>\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-coding</artifactId>\n</dependency>\n```\n\n```xml\n<!-- 非Spring应用 -->\n<dependency>\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j</artifactId>\n    <version>${project.version}</version>\n</dependency>\n\n```\n```xml\n<!-- Spring应用 -->\n<dependency>\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-spring-boot-starter</artifactId>\n    <version>${project.version}</version>\n</dependency>\n```\n\n## 获取AI服务实例\n\n### 非Spring获取\n```java\n    public void test_init(){\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n\n        Configuration configuration = new Configuration();\n        configuration.setOpenAiConfig(openAiConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\",10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n        chatService = aiService.getChatService(PlatformType.getPlatform(\"OPENAI\"));\n\n    }\n```\n### Spring获取\n```yml\n# 国内访问默认需要代理\nai:\n  openai:\n    api-key: \"api-key\"\n  okhttp:\n    proxy-port: 10809\n    proxy-url: \"127.0.0.1\"\n  zhipu:\n    api-key: \"xxx\"\n  #other...\n```\n\n```java\n// 注入Ai服务\n@Autowired\nprivate AiService aiService;\n\n// 获取需要的服务实例\nIChatService chatService = aiService.getChatService(PlatformType.OPENAI);\nIEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n// ......\n```\n\n## Chat服务\n\n### 同步请求调用\n```java\n\npublic void test_chat() throws Exception {\n    // 获取chat服务实例\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    // 构建请求参数\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n            .build();\n\n    // 发送对话请求\n    ChatCompletionResponse response = chatService.chatCompletion(chatCompletion);\n\n    System.out.println(response);\n}\n\n```\n\n### 流式调用\n```java\npublic void test_chat_stream() throws Exception {\n    // 获取chat服务实例\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    // 构造请求参数\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(\"查询北京明天的天气\"))\n            .functions(\"queryWeather\")\n            .build();\n\n\n    // 构造监听器\n    SseListener sseListener = new SseListener() {\n        @Override\n        protected void send() {\n            System.out.println(this.getCurrStr());\n        }\n    };\n    // 显示函数参数，默认不显示\n    sseListener.setShowToolArgs(true);\n\n    // 发送SSE请求\n    chatService.chatCompletionStream(chatCompletion, sseListener);\n\n    System.out.println(sseListener.getOutput());\n\n}\n```\n\n### 图片识别\n\n```java\npublic void test_chat_image() throws Exception {\n    // 获取chat服务实例\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    // 构建请求参数\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(\"图片中有什么东西\", \"https://cn.bing.com/images/search?view=detailV2&ccid=r0OnuYkv&id=9A07DE578F6ED50DB59DFEA5C675AC71845A6FC9&thid=OIP.r0OnuYkvsbqBrYk3kUT53AHaKX&mediaurl=https%3a%2f%2fimg.zcool.cn%2fcommunity%2f0104c15cd45b49a80121416816f1ec.jpg%401280w_1l_2o_100sh.jpg&exph=1792&expw=1280&q=%e5%b0%8f%e7%8c%ab%e5%9b%be%e7%89%87&simid=607987191780608963&FORM=IRPRST&ck=12127C1696CF374CB9D0F09AE99AFE69&selectedIndex=2&itb=0&qpvt=%e5%b0%8f%e7%8c%ab%e5%9b%be%e7%89%87\"))\n            .build();\n\n    // 发送对话请求\n    ChatCompletionResponse response = chatService.chatCompletion(chatCompletion);\n\n    System.out.println(response);\n}\n```\n\n### 函数调用\n\n```java\npublic void test_chat_tool_call() throws Exception {\n    // 获取chat服务实例\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    // 构建请求参数\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(\"今天北京天气怎么样\"))\n            .functions(\"queryWeather\")\n            .build();\n\n    // 发送对话请求\n    ChatCompletionResponse response = chatService.chatCompletion(chatCompletion);\n\n    System.out.println(response);\n}\n```\n\n### 内置 ChatMemory\n\n如果你只是做基础多轮对话，不想自己每轮维护完整上下文，可以直接使用 `ChatMemory`：\n\n```java\nIChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\nChatMemory memory = new InMemoryChatMemory(new MessageWindowChatMemoryPolicy(12));\nmemory.addSystem(\"你是一个简洁的 Java 助手\");\nmemory.addUser(\"请用三点介绍 AI4J\");\n\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .messages(memory.toChatMessages())\n        .build();\n\nChatCompletionResponse response = chatService.chatCompletion(request);\nString answer = response.getChoices().get(0).getMessage().getContent().getText();\n\nmemory.addAssistant(answer);\n```\n\n同一份 `memory` 也可以直接给 `Responses`：\n\n```java\nIResponsesService responsesService = aiService.getResponsesService(PlatformType.DOUBAO);\n\nResponseRequest request = ResponseRequest.builder()\n        .model(\"doubao-seed-1-8-251228\")\n        .input(memory.toResponsesInput())\n        .build();\n```\n#### 定义函数\n```java\n@FunctionCall(name = \"queryWeather\", description = \"查询目标地点的天气预报\")\npublic class QueryWeatherFunction implements Function<QueryWeatherFunction.Request, String> {\n\n    @Data\n    @FunctionRequest\n    public static class Request{\n        @FunctionParameter(description = \"需要查询天气的目标位置, 可以是城市中文名、城市拼音/英文名、省市名称组合、IP 地址、经纬度\")\n        private String location;\n        @FunctionParameter(description = \"需要查询未来天气的天数, 最多15日\")\n        private int days = 15;\n        @FunctionParameter(description = \"预报的天气类型，daily表示预报多天天气、hourly表示预测当天24天气、now为当前天气实况\")\n        private Type type;\n    }\n\n    public enum Type{\n        daily,\n        hourly,\n        now\n    }\n\n    @Override\n    public String apply(Request request) {\n        final String key = \"\";\n\n        String url = String.format(\"https://api.seniverse.com/v3/weather/%s.json?key=%s&location=%s&days=%d\",\n                request.type.name(),\n                key,\n                request.location,\n                request.days);\n\n\n        OkHttpClient client = new OkHttpClient();\n\n        okhttp3.Request http = new okhttp3.Request.Builder()\n                .url(url)\n                .get()\n                .build();\n\n        try (Response response = client.newCall(http).execute()) {\n            if (response.isSuccessful()) {\n                // 解析响应体\n                return response.body() != null ? response.body().string() : \"\";\n            } else {\n                return \"获取天气失败 当前天气未知\";\n            }\n        } catch (Exception e) {\n            // 处理异常\n            e.printStackTrace();\n            return \"获取天气失败 当前天气未知\";\n        }\n    }\n\n}\n```\n\n## Embedding服务\n\n```java\npublic void test_embed() throws Exception {\n    // 获取embedding服务实例\n    IEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n\n    // 构建请求参数\n    Embedding embeddingReq = Embedding.builder().input(\"1+1\").build();\n\n    // 发送embedding请求\n    EmbeddingResponse embeddingResp = embeddingService.embedding(embeddingReq);\n\n    System.out.println(embeddingResp);\n}\n```\n\n## Rerank服务\n\n### 直接调用统一重排服务\n\n```java\nIRerankService rerankService = aiService.getRerankService(PlatformType.JINA);\n\nRerankRequest request = RerankRequest.builder()\n        .model(\"jina-reranker-v2-base-multilingual\")\n        .query(\"哪段最适合回答 Java 8 为什么仍然常见\")\n        .documents(Arrays.asList(\n                RerankDocument.builder().id(\"doc-1\").text(\"Java 8 仍是很多传统系统的默认运行时\").build(),\n                RerankDocument.builder().id(\"doc-2\").text(\"AI4J 提供统一 Chat、Responses 和 RAG 接口\").build(),\n                RerankDocument.builder().id(\"doc-3\").text(\"历史中间件和升级成本让很多企业延后 JDK 升级\").build()\n        ))\n        .topN(2)\n        .build();\n\nRerankResponse response = rerankService.rerank(request);\nSystem.out.println(response.getResults());\n```\n\n### 作为 RAG 精排器接入\n\n```java\nReranker reranker = aiService.getModelReranker(\n        PlatformType.JINA,\n        \"jina-reranker-v2-base-multilingual\",\n        5,\n        \"优先保留制度原文、版本说明和编号明确的片段\"\n);\n```\n\n## RAG\n### 推荐：使用统一 IngestionPipeline 入库\n\n```java\nVectorStore vectorStore = aiService.getQdrantVectorStore();\n\nIngestionPipeline ingestionPipeline = aiService.getIngestionPipeline(\n        PlatformType.OPENAI,\n        vectorStore\n);\n\nIngestionResult ingestResult = ingestionPipeline.ingest(IngestionRequest.builder()\n        .dataset(\"kb_docs\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .document(RagDocument.builder()\n                .sourceName(\"员工手册\")\n                .sourcePath(\"/docs/employee-handbook.md\")\n                .tenant(\"acme\")\n                .biz(\"hr\")\n                .version(\"2026.03\")\n                .build())\n        .source(IngestionSource.text(\"第一章 假期政策。第二章 报销政策。\"))\n        .build());\n\nSystem.out.println(ingestResult.getUpsertedCount());\n```\n\n如果你已经走 Pinecone，也可以直接：\n\n```java\nIngestionPipeline ingestionPipeline = aiService.getPineconeIngestionPipeline(PlatformType.OPENAI);\n```\n\n推荐主线是：\n\n1. `IngestionPipeline` 负责文档入库\n2. `VectorStore` 负责底层向量存储\n3. `DenseRetriever / HybridRetriever / ModelReranker / RagService` 负责查询阶段\n\n完整说明见：\n\n+ `docs-site/docs/ai-basics/rag/ingestion-pipeline.md`\n+ `docs-site/docs/ai-basics/rag/overview.md`\n\n### 配置向量数据库\n```yml\nai:\n  vector:\n    pinecone:\n      host: \"\"\n      key: \"\"\n```\n### 推荐：Pinecone 也走统一 `VectorStore + IngestionPipeline`\n\n```java\nVectorStore vectorStore = aiService.getPineconeVectorStore();\n\nIngestionPipeline ingestionPipeline = aiService.getPineconeIngestionPipeline(PlatformType.OPENAI);\n\nIngestionResult ingestResult = ingestionPipeline.ingest(IngestionRequest.builder()\n        .dataset(\"tenant_a_hr_v202603\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .document(RagDocument.builder()\n                .sourceName(\"员工手册\")\n                .sourcePath(\"/docs/employee-handbook.pdf\")\n                .tenant(\"tenant_a\")\n                .biz(\"hr\")\n                .version(\"2026.03\")\n                .build())\n        .source(IngestionSource.file(new File(\"D:/data/employee-handbook.pdf\")))\n        .build());\n\nSystem.out.println(\"upserted=\" + ingestResult.getUpsertedCount());\n```\n\n### 查询阶段：直接走统一 `RagService`\n\n```java\nRagService ragService = aiService.getRagService(\n        PlatformType.OPENAI,\n        vectorStore\n);\n\nRagQuery ragQuery = RagQuery.builder()\n        .query(\"年假如何计算\")\n        .dataset(\"tenant_a_hr_v202603\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .topK(5)\n        .build();\n\nRagResult ragResult = ragService.search(ragQuery);\n\nSystem.out.println(ragResult.getContext());\nSystem.out.println(ragResult.getCitations());\n```\n\n### 如果需要更高精度，再接 Rerank\n\n```java\nReranker reranker = aiService.getModelReranker(\n        PlatformType.JINA,\n        \"jina-reranker-v2-base-multilingual\",\n        5,\n        \"优先制度原文、章节标题和编号明确的片段\"\n);\n\nRagService ragService = new DefaultRagService(\n        new DenseRetriever(\n                aiService.getEmbeddingService(PlatformType.OPENAI),\n                vectorStore\n        ),\n        reranker,\n        new DefaultRagContextAssembler()\n);\n```\n\n### 什么时候还需要直接用已废弃的 `PineconeService`（Deprecated）\n\n`PineconeService` 目前在文档层已视为 Deprecated。只有在你明确需要 Pinecone 特有的底层控制时，才建议继续直接用：\n\n+ namespace 级底层操作\n+ 兼容旧项目里已经写死的 `PineconeQuery / PineconeDelete`\n+ 你就是在做 Pinecone 专用封装，而不是面向统一 RAG 抽象开发\n\n## 内置联网\n\n### SearXNG\n\n#### 配置\n```java\n// 非spring应用\nSearXNGConfig searXNGConfig = new SearXNGConfig();\nsearXNGConfig.setUrl(\"http://127.0.0.1:8080/search\");\n\nConfiguration configuration = new Configuration();\nconfiguration.setSearXNGConfig(searXNGConfig);\n```\n\n```YML\n# spring应用\nai:\n  websearch:\n    searxng:\n      url: http://127.0.0.1:8080/search\n\n```\n\n#### 使用\n\n```java\n\n// ...\n\nwebEnhance = aiService.webSearchEnhance(chatService);\n\n// ...\n\n\n@Test\npublic void test_chatCompletions_common_websearch_enhance() throws Exception {\n    ChatCompletion chatCompletion = ChatCompletion.builder()\n            .model(\"qwen2.5:7b\")\n            .message(ChatMessage.withUser(\"鸡你太美是什么梗\"))\n            .build();\n\n    System.out.println(\"请求参数\");\n    System.out.println(chatCompletion);\n\n    ChatCompletionResponse chatCompletionResponse = webEnhance.chatCompletion(chatCompletion);\n\n    System.out.println(\"请求成功\");\n    System.out.println(chatCompletionResponse);\n\n}\n```\n\n\n# 为AI4J提供贡献\n欢迎您对AI4J提出建议、报告问题或贡献代码。您可以按照以下的方式为AI4J提供贡献: \n\n## 问题反馈\n请使用GitHub Issue页面报告问题。尽可能具体地说明如何重现您的问题，包括操作系统、Java版本和任何相关日志跟踪等详细信息。\n\n## PR\n1. Fork 本仓库并创建您的分支（建议命名：feature/功能名、fix/问题名 或 docs/文档优化）。\n2. 编写代码或修改内容（如更新文档），并完成测试（确保功能正常或文档无误）。\n3. 确保您的代码符合现有的样式。\n4. 提交时编写清晰的日志信息。对于小的改动，单行信息就可以了，但较大的改动应该有详细的描述。\n5. 完成拉取请求表单，确保在`dev`分支进行改动，链接到您的 PR 解决的问题。\n\n# 支持\n如果您觉得这个项目对您有帮助，请点一个star⭐。\n\n# Buy Me a Coffee\n您的支持是我更新的最大的动力。\n\n![新图片](https://cdn.jsdelivr.net/gh/lnyo-cly/blogImg/pics/新图片.jpg)\n\n# 贡献者\n\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n\n<a href=\"https://github.com/LnYo-Cly/ai4j/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=LnYo-Cly/ai4j\" />\n</a>\n\n\n# ⭐️ Star History\n<a href=\"https://star-history.com/#LnYo-Cly/ai4j&Date\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=LnYo-Cly/ai4j&type=Date&theme=dark\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=LnYo-Cly/ai4j&type=Date\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=LnYo-Cly/ai4j&type=Date\" />\n </picture>\n</a>\n\n"
  },
  {
    "path": "ai4j/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j</artifactId>\n    <packaging>jar</packaging>\n    <version>2.3.0</version>\n\n    <name>ai4j</name>\n    <description>ai4j 核心 Java SDK，提供统一大模型接入、Tool Call、RAG 与 MCP 能力。 Core Java SDK for unified LLM access, tool calling, RAG, and MCP integration.</description>\n\n\n    <properties>\n        <maven.compiler.source>8</maven.compiler.source>\n        <maven.compiler.target>8</maven.compiler.target>\n        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>\n        <skipTests>true</skipTests>\n        <graalvm.version>20.3.4</graalvm.version>\n        <graalsdk.version>${graalvm.version}</graalsdk.version>\n        <nashorn.version>15.6</nashorn.version>\n    </properties>\n    <licenses>\n        <license>\n            <name>The Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n        </license>\n    </licenses>\n\n    <issueManagement>\n        <system>GitHub</system>\n        <url>https://github.com/LnYo-Cly/ai4j/issues</url>\n    </issueManagement>\n\n    <url>https://github.com/LnYo-Cly/ai4j</url>\n    <developers>\n        <developer>\n            <id>LnYo-Cly</id>\n            <name>LnYo-Cly</name>\n            <email>lnyocly@gmail.com</email>\n            <url>https://github.com/LnYo-Cly/ai4j</url>\n            <timezone>+8</timezone>\n        </developer>\n    </developers>\n\n    <scm>\n        <!--项目访问url -->\n        <url>https://github.com/LnYo-Cly/ai4j</url>\n        <!--项目访问url.git结尾 -->\n        <connection>scm:git:https://github.com/LnYo-Cly/ai4j.git</connection>\n        <!--项目访问url.git结尾 -->\n        <developerConnection>scm:git:https://github.com/LnYo-Cly/ai4j.git</developerConnection>\n    </scm>\n\n    <dependencies>\n        <!-- 日志实现，例如 slf4j-simple -->\n        <dependency>\n            <groupId>org.slf4j</groupId>\n            <artifactId>slf4j-simple</artifactId>\n            <version>1.7.30</version>\n        </dependency>\n        <!-- Apache Tika core -->\n        <dependency>\n            <groupId>org.apache.tika</groupId>\n            <artifactId>tika-core</artifactId>\n            <version>2.9.2</version>\n        </dependency>\n        <dependency>\n            <groupId>org.apache.tika</groupId>\n            <artifactId>tika-parsers-standard-package</artifactId>\n            <version>2.8.0</version>\n        </dependency>\n        <dependency>\n            <groupId>com.knuddels</groupId>\n            <artifactId>jtokkit</artifactId>\n            <version>1.1.0</version>\n        </dependency>\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <version>1.18.30</version>\n        </dependency>\n        <!-- https://mvnrepository.com/artifact/org.slf4j/slf4j-api -->\n        <dependency>\n            <groupId>org.slf4j</groupId>\n            <artifactId>slf4j-api</artifactId>\n            <version>1.7.30</version>\n        </dependency>\n\n        <dependency>\n            <groupId>org.reflections</groupId>\n            <artifactId>reflections</artifactId>\n            <version>0.10.2</version>\n        </dependency>\n        <dependency>\n            <groupId>junit</groupId>\n            <artifactId>junit</artifactId>\n            <version>4.13.2</version>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>com.h2database</groupId>\n            <artifactId>h2</artifactId>\n            <version>2.2.224</version>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>com.squareup.okhttp3</groupId>\n            <artifactId>mockwebserver</artifactId>\n            <version>4.12.0</version>\n            <scope>test</scope>\n        </dependency>\n        <!-- https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2 -->\n        <dependency>\n            <groupId>com.alibaba.fastjson2</groupId>\n            <artifactId>fastjson2</artifactId>\n            <version>2.0.43</version>\n        </dependency>\n        <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-annotations -->\n        <dependency>\n            <groupId>com.fasterxml.jackson.core</groupId>\n            <artifactId>jackson-annotations</artifactId>\n            <version>2.16.0</version>\n        </dependency>\n        <dependency>\n            <groupId>com.fasterxml.jackson.core</groupId>\n            <artifactId>jackson-databind</artifactId>\n            <version>2.14.2</version>\n        </dependency>\n\n        <!-- okhttp -->\n        <dependency>\n            <groupId>com.squareup.okhttp3</groupId>\n            <artifactId>okhttp</artifactId>\n            <version>4.12.0</version>\n        </dependency>\n        <!-- https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp-sse -->\n        <dependency>\n            <groupId>com.squareup.okhttp3</groupId>\n            <artifactId>okhttp-sse</artifactId>\n            <version>4.12.0</version>\n        </dependency>\n        <!-- https://mvnrepository.com/artifact/com.squareup.okhttp3/logging-interceptor -->\n        <dependency>\n            <groupId>com.squareup.okhttp3</groupId>\n            <artifactId>logging-interceptor</artifactId>\n            <version>4.12.0</version>\n        </dependency>\n        <!-- https://mvnrepository.com/artifact/com.google.guava/guava -->\n        <dependency>\n            <groupId>com.google.guava</groupId>\n            <artifactId>guava</artifactId>\n            <version>33.0.0-jre</version>\n        </dependency>\n        <dependency>\n            <groupId>org.apache.commons</groupId>\n            <artifactId>commons-lang3</artifactId>\n            <version>3.12.0</version>\n        </dependency>\n        <!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->\n        <dependency>\n            <groupId>com.auth0</groupId>\n            <artifactId>java-jwt</artifactId>\n            <version>4.4.0</version>\n        </dependency>\n        <dependency>\n            <groupId>cn.hutool</groupId>\n            <artifactId>hutool-core</artifactId>\n            <version>5.8.38</version>\n        </dependency>\n        <dependency>\n            <groupId>com.alibaba</groupId>\n            <artifactId>dashscope-sdk-java</artifactId>\n            <scope>compile</scope>\n            <version>2.19.0</version>\n        </dependency>\n        <dependency>\n            <groupId>org.jetbrains</groupId>\n            <artifactId>annotations</artifactId>\n            <version>13.0</version>\n        </dependency>\n\n        <!-- GraalVM Polyglot SDK for CodeAct Python runtime -->\n        <dependency>\n            <groupId>org.graalvm.sdk</groupId>\n            <artifactId>graal-sdk</artifactId>\n            <version>${graalsdk.version}</version>\n        </dependency>\n    </dependencies>\n\n    <build>\n        <finalName>${project.name}-${project.version}</finalName>\n        <plugins>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-surefire-plugin</artifactId>\n                <version>2.12.4</version>\n                <configuration>\n                    <skipTests>${skipTests}</skipTests>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-compiler-plugin</artifactId>\n                <version>3.8.1</version>\n                <configuration>\n                    <source>1.8</source>\n                    <target>1.8</target>\n                    <encoding>UTF8</encoding>\n                    <compilerArgs>\n                        <arg>-parameters</arg>\n                    </compilerArgs>\n                    <parameters>true</parameters>\n                    <annotationProcessorPaths>\n                        <path>\n                            <groupId>org.projectlombok</groupId>\n                            <artifactId>lombok</artifactId>\n                            <version>1.18.30</version>\n                        </path>\n                    </annotationProcessorPaths>\n                </configuration>\n            </plugin>\n\n        </plugins>\n    </build>\n\n\n\n    <profiles>\n        <profile>\n            <id>nashorn-runtime</id>\n            <activation>\n                <jdk>[15,)</jdk>\n            </activation>\n            <dependencies>\n                <dependency>\n                    <groupId>org.openjdk.nashorn</groupId>\n                    <artifactId>nashorn-core</artifactId>\n                    <version>${nashorn.version}</version>\n                    <scope>runtime</scope>\n                </dependency>\n            </dependencies>\n        </profile>\n        <profile>\n            <id>graalpy-runtime</id>\n            <activation>\n                <jdk>[17,)</jdk>\n            </activation>\n            <properties>\n                <graalsdk.version>24.1.2</graalsdk.version>\n            </properties>\n            <dependencies>\n                <dependency>\n                    <groupId>org.graalvm.python</groupId>\n                    <artifactId>python-community</artifactId>\n                    <version>${graalsdk.version}</version>\n                    <type>pom</type>\n                    <scope>runtime</scope>\n                </dependency>\n            </dependencies>\n        </profile>\n\n        <profile>\n            <id>release</id>\n            <build>\n                <plugins>\n                    <!-- source源码插件 -->\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-source-plugin</artifactId>\n                        <version>3.3.1</version>\n                        <executions>\n                            <execution>\n                                <id>attach-sources</id>\n                                <goals>\n                                    <goal>jar-no-fork</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n\n                    <!-- Javadoc插件 -->\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-javadoc-plugin</artifactId>\n                        <version>3.6.3</version>\n                        <executions>\n                            <execution>\n                                <id>attach-javadocs</id>\n                                <goals>\n                                    <goal>jar</goal>\n                                </goals>\n                                <configuration>\n                                    <doclint>none</doclint>\n                                    <failOnError>false</failOnError>\n                                    <tags>\n                                        <tag>\n                                            <name>Author</name>\n                                            <placement>a</placement>\n                                            <head>Author:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Description</name>\n                                            <placement>a</placement>\n                                            <head>Description:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Date</name>\n                                            <placement>a</placement>\n                                            <head>Date:</head>\n                                        </tag>\n                                    </tags>\n                                </configuration>\n                            </execution>\n\n                        </executions>\n                    </plugin>\n\n                    <!-- GPG插件 -->\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-gpg-plugin</artifactId>\n                        <version>1.6</version>\n                        <configuration>\n                            <executable>D:\\Develop\\DevelopEnv\\GnuPG\\bin\\gpg.exe</executable>\n                            <keyname>cly</keyname>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>sign-artifacts</id>\n                                <phase>verify</phase>\n                                <goals>\n                                    <goal>sign</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n\n                    <!--   central发布插件    -->\n                    <plugin>\n                        <groupId>org.sonatype.central</groupId>\n                        <artifactId>central-publishing-maven-plugin</artifactId>\n                        <version>0.4.0</version>\n                        <extensions>true</extensions>\n                        <configuration>\n                            <publishingServerId>LnYo-Cly</publishingServerId>\n                            <tokenAuth>true</tokenAuth>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n\n</project>\n\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlow.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatService;\nimport io.github.lnyocly.ai4j.agentflow.chat.CozeAgentFlowChatService;\nimport io.github.lnyocly.ai4j.agentflow.chat.DifyAgentFlowChatService;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowService;\nimport io.github.lnyocly.ai4j.agentflow.workflow.CozeAgentFlowWorkflowService;\nimport io.github.lnyocly.ai4j.agentflow.workflow.DifyAgentFlowWorkflowService;\nimport io.github.lnyocly.ai4j.agentflow.workflow.N8nAgentFlowWorkflowService;\nimport io.github.lnyocly.ai4j.service.Configuration;\n\npublic class AgentFlow {\n\n    private final Configuration configuration;\n    private final AgentFlowConfig config;\n\n    public AgentFlow(Configuration configuration, AgentFlowConfig config) {\n        if (configuration == null) {\n            throw new IllegalArgumentException(\"configuration is required\");\n        }\n        if (config == null) {\n            throw new IllegalArgumentException(\"agentFlowConfig is required\");\n        }\n        this.configuration = configuration;\n        this.config = config;\n    }\n\n    public Configuration getConfiguration() {\n        return configuration;\n    }\n\n    public AgentFlowConfig getConfig() {\n        return config;\n    }\n\n    public AgentFlowChatService chat() {\n        if (config.getType() == AgentFlowType.DIFY) {\n            return new DifyAgentFlowChatService(configuration, config);\n        }\n        if (config.getType() == AgentFlowType.COZE) {\n            return new CozeAgentFlowChatService(configuration, config);\n        }\n        throw new IllegalArgumentException(\"Chat is not supported for agent flow type: \" + config.getType());\n    }\n\n    public AgentFlowWorkflowService workflow() {\n        if (config.getType() == AgentFlowType.DIFY) {\n            return new DifyAgentFlowWorkflowService(configuration, config);\n        }\n        if (config.getType() == AgentFlowType.COZE) {\n            return new CozeAgentFlowWorkflowService(configuration, config);\n        }\n        if (config.getType() == AgentFlowType.N8N) {\n            return new N8nAgentFlowWorkflowService(configuration, config);\n        }\n        throw new IllegalArgumentException(\"Workflow is not supported for agent flow type: \" + config.getType());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlowConfig.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceListener;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport lombok.NonNull;\n\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowConfig {\n\n    @NonNull\n    private AgentFlowType type;\n\n    private String baseUrl;\n\n    private String webhookUrl;\n\n    private String apiKey;\n\n    private String botId;\n\n    private String workflowId;\n\n    private String appId;\n\n    private String userId;\n\n    private String conversationId;\n\n    @Builder.Default\n    private Long pollIntervalMillis = 1_000L;\n\n    @Builder.Default\n    private Long pollTimeoutMillis = 60_000L;\n\n    @Builder.Default\n    private Map<String, String> headers = Collections.emptyMap();\n\n    @Builder.Default\n    private List<AgentFlowTraceListener> traceListeners = Collections.emptyList();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlowException.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\npublic class AgentFlowException extends RuntimeException {\n\n    public AgentFlowException(String message) {\n        super(message);\n    }\n\n    public AgentFlowException(String message, Throwable cause) {\n        super(message, cause);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlowType.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\npublic enum AgentFlowType {\n    DIFY,\n    COZE,\n    N8N\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/AgentFlowUsage.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowUsage {\n\n    private Integer inputTokens;\n\n    private Integer outputTokens;\n\n    private Integer totalTokens;\n\n    private Object raw;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatEvent.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.chat;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowChatEvent {\n\n    private String type;\n\n    private String contentDelta;\n\n    private String conversationId;\n\n    private String messageId;\n\n    private String taskId;\n\n    private boolean done;\n\n    private AgentFlowUsage usage;\n\n    private Object raw;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatListener.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.chat;\n\npublic interface AgentFlowChatListener {\n\n    void onEvent(AgentFlowChatEvent event);\n\n    default void onOpen() {\n    }\n\n    default void onError(Throwable throwable) {\n    }\n\n    default void onComplete(AgentFlowChatResponse response) {\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatRequest.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.chat;\n\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport lombok.NonNull;\n\nimport java.util.Collections;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowChatRequest {\n\n    @NonNull\n    private String prompt;\n\n    @Builder.Default\n    private Map<String, Object> inputs = Collections.emptyMap();\n\n    private String userId;\n\n    private String conversationId;\n\n    @Builder.Default\n    private Map<String, Object> metadata = Collections.emptyMap();\n\n    @Builder.Default\n    private Map<String, Object> extraBody = Collections.emptyMap();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatResponse.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.chat;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowChatResponse {\n\n    private String content;\n\n    private String conversationId;\n\n    private String messageId;\n\n    private String taskId;\n\n    private AgentFlowUsage usage;\n\n    private Object raw;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/AgentFlowChatService.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.chat;\n\npublic interface AgentFlowChatService {\n\n    AgentFlowChatResponse chat(AgentFlowChatRequest request) throws Exception;\n\n    void chatStream(AgentFlowChatRequest request, AgentFlowChatListener listener) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/CozeAgentFlowChatService.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.chat;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowConfig;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowException;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class CozeAgentFlowChatService extends AgentFlowSupport implements AgentFlowChatService {\n\n    public CozeAgentFlowChatService(Configuration configuration, AgentFlowConfig agentFlowConfig) {\n        super(configuration, agentFlowConfig);\n    }\n\n    @Override\n    public AgentFlowChatResponse chat(AgentFlowChatRequest request) throws Exception {\n        AgentFlowTraceContext traceContext = startTrace(\"chat\", false, request);\n        try {\n            JSONObject createResponse = executeObject(buildCreateRequest(request, false));\n            assertCozeSuccess(createResponse);\n\n            JSONObject createData = createResponse.getJSONObject(\"data\");\n            String chatId = createData == null ? null : createData.getString(\"id\");\n            String conversationId = firstNonBlank(\n                    createData == null ? null : createData.getString(\"conversation_id\"),\n                    defaultConversationId(request.getConversationId())\n            );\n            if (isBlank(chatId)) {\n                throw new AgentFlowException(\"Coze chat id is missing\");\n            }\n\n            JSONObject chatData = pollChat(conversationId, chatId);\n            String status = chatData.getString(\"status\");\n            if (!\"completed\".equals(status)) {\n                throw new AgentFlowException(\"Coze chat finished with status: \" + status);\n            }\n\n            JSONObject messageResponse = executeObject(buildMessageListRequest(conversationId, chatId));\n            assertCozeSuccess(messageResponse);\n\n            JSONArray messages = messageResponse.getJSONArray(\"data\");\n            StringBuilder content = new StringBuilder();\n            String messageId = null;\n            if (messages != null) {\n                for (int i = messages.size() - 1; i >= 0; i--) {\n                    JSONObject message = messages.getJSONObject(i);\n                    if (message == null) {\n                        continue;\n                    }\n                    if (!\"assistant\".equals(message.getString(\"role\"))) {\n                        continue;\n                    }\n                    String messageContent = message.getString(\"content\");\n                    if (!isBlank(messageContent)) {\n                        if (content.length() > 0) {\n                            content.insert(0, \"\\n\");\n                        }\n                        content.insert(0, messageContent);\n                        if (messageId == null) {\n                            messageId = message.getString(\"id\");\n                        }\n                    }\n                }\n            }\n\n            Map<String, Object> raw = new LinkedHashMap<String, Object>();\n            raw.put(\"chat\", chatData);\n            raw.put(\"messages\", messages);\n\n            AgentFlowChatResponse chatResponse = AgentFlowChatResponse.builder()\n                    .content(content.toString())\n                    .conversationId(conversationId)\n                    .messageId(messageId)\n                    .taskId(chatId)\n                    .usage(usageFromCoze(chatData.getJSONObject(\"usage\")))\n                    .raw(raw)\n                    .build();\n            traceComplete(traceContext, chatResponse);\n            return chatResponse;\n        } catch (Exception ex) {\n            traceError(traceContext, ex);\n            throw ex;\n        }\n    }\n\n    @Override\n    public void chatStream(AgentFlowChatRequest request, final AgentFlowChatListener listener) throws Exception {\n        if (listener == null) {\n            throw new IllegalArgumentException(\"listener is required\");\n        }\n        final AgentFlowTraceContext traceContext = startTrace(\"chat\", true, request);\n        Request httpRequest = buildCreateRequest(request, true);\n\n        final CountDownLatch latch = new CountDownLatch(1);\n        final AtomicReference<Throwable> failure = new AtomicReference<Throwable>();\n        final AtomicReference<String> chatIdRef = new AtomicReference<String>();\n        final AtomicReference<String> conversationIdRef = new AtomicReference<String>();\n        final AtomicReference<String> messageIdRef = new AtomicReference<String>();\n        final AtomicReference<AgentFlowUsage> usageRef = new AtomicReference<AgentFlowUsage>();\n        final AtomicReference<AgentFlowChatResponse> completionRef = new AtomicReference<AgentFlowChatResponse>();\n        final StringBuilder content = new StringBuilder();\n        final AtomicBoolean closed = new AtomicBoolean(false);\n        final AtomicBoolean sawDelta = new AtomicBoolean(false);\n\n        eventSourceFactory.newEventSource(httpRequest, new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                listener.onOpen();\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource,\n                                @Nullable String id,\n                                @Nullable String type,\n                                @NotNull String data) {\n                try {\n                    String eventType = type;\n                    JSONObject payload = parseObjectOrNull(data);\n\n                    if (\"done\".equals(eventType)) {\n                        AgentFlowChatEvent event = AgentFlowChatEvent.builder()\n                                .type(eventType)\n                                .conversationId(conversationIdRef.get())\n                                .messageId(messageIdRef.get())\n                                .taskId(chatIdRef.get())\n                                .done(true)\n                                .usage(usageRef.get())\n                                .raw(data)\n                                .build();\n                        listener.onEvent(event);\n                        traceEvent(traceContext, event);\n\n                        AgentFlowChatResponse responsePayload = AgentFlowChatResponse.builder()\n                                .content(content.toString())\n                                .conversationId(conversationIdRef.get())\n                                .messageId(messageIdRef.get())\n                                .taskId(chatIdRef.get())\n                                .usage(usageRef.get())\n                                .raw(data)\n                                .build();\n                        completionRef.set(responsePayload);\n                        listener.onComplete(responsePayload);\n                        traceComplete(traceContext, responsePayload);\n                        closed.set(true);\n                        eventSource.cancel();\n                        latch.countDown();\n                        return;\n                    }\n\n                    if (\"error\".equals(eventType)) {\n                        throw new AgentFlowException(\"Coze stream error: \" + data);\n                    }\n\n                    String delta = null;\n                    if (eventType != null && eventType.startsWith(\"conversation.chat.\")) {\n                        conversationIdRef.set(firstNonBlank(\n                                payload == null ? null : payload.getString(\"conversation_id\"),\n                                conversationIdRef.get()\n                        ));\n                        chatIdRef.set(firstNonBlank(\n                                payload == null ? null : payload.getString(\"id\"),\n                                chatIdRef.get()\n                        ));\n                        AgentFlowUsage usage = payload == null ? null : usageFromCoze(payload.getJSONObject(\"usage\"));\n                        if (usage != null) {\n                            usageRef.set(usage);\n                        }\n                        if (\"conversation.chat.failed\".equals(eventType) || \"conversation.chat.requires_action\".equals(eventType)) {\n                            throw new AgentFlowException(\"Coze chat status event: \" + eventType);\n                        }\n                    } else if (eventType != null && eventType.startsWith(\"conversation.message.\")) {\n                        conversationIdRef.set(firstNonBlank(\n                                payload == null ? null : payload.getString(\"conversation_id\"),\n                                conversationIdRef.get()\n                        ));\n                        chatIdRef.set(firstNonBlank(\n                                payload == null ? null : payload.getString(\"chat_id\"),\n                                chatIdRef.get()\n                        ));\n                        messageIdRef.set(firstNonBlank(\n                                payload == null ? null : payload.getString(\"id\"),\n                                messageIdRef.get()\n                        ));\n                        String contentValue = payload == null ? null : payload.getString(\"content\");\n                        if (\"conversation.message.delta\".equals(eventType)) {\n                            delta = contentValue;\n                            if (!isBlank(delta)) {\n                                content.append(delta);\n                                sawDelta.set(true);\n                            }\n                        } else if (\"conversation.message.completed\".equals(eventType)) {\n                            if (!sawDelta.get() && !isBlank(contentValue)) {\n                                content.append(contentValue);\n                            }\n                        }\n                    }\n\n                    AgentFlowChatEvent event = AgentFlowChatEvent.builder()\n                            .type(eventType)\n                            .contentDelta(delta)\n                            .conversationId(conversationIdRef.get())\n                            .messageId(messageIdRef.get())\n                            .taskId(chatIdRef.get())\n                            .usage(usageRef.get())\n                            .raw(payload == null ? data : payload)\n                            .build();\n                    listener.onEvent(event);\n                    traceEvent(traceContext, event);\n                } catch (Throwable ex) {\n                    failure.set(ex);\n                    traceError(traceContext, ex);\n                    listener.onError(ex);\n                    closed.set(true);\n                    eventSource.cancel();\n                    latch.countDown();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                if (closed.compareAndSet(false, true)) {\n                    AgentFlowChatResponse responsePayload = completionRef.get();\n                    if (responsePayload == null) {\n                        responsePayload = AgentFlowChatResponse.builder()\n                                .content(content.toString())\n                                .conversationId(conversationIdRef.get())\n                                .messageId(messageIdRef.get())\n                                .taskId(chatIdRef.get())\n                                .usage(usageRef.get())\n                                .build();\n                        completionRef.set(responsePayload);\n                        listener.onComplete(responsePayload);\n                        traceComplete(traceContext, responsePayload);\n                    }\n                    latch.countDown();\n                }\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                Throwable error = t;\n                if (error == null && response != null) {\n                    error = new AgentFlowException(\"Coze stream failed: HTTP \" + response.code());\n                }\n                if (error == null) {\n                    error = new AgentFlowException(\"Coze stream failed\");\n                }\n                failure.set(error);\n                traceError(traceContext, error);\n                listener.onError(error);\n                closed.set(true);\n                latch.countDown();\n            }\n        });\n\n        if (!latch.await(pollTimeoutMillis(), TimeUnit.MILLISECONDS)) {\n            throw new AgentFlowException(\"Coze stream timed out\");\n        }\n        if (failure.get() != null) {\n            if (failure.get() instanceof Exception) {\n                throw (Exception) failure.get();\n            }\n            throw new AgentFlowException(\"Coze stream failed\", failure.get());\n        }\n    }\n\n    private JSONObject pollChat(String conversationId, String chatId) throws Exception {\n        long deadline = System.currentTimeMillis() + pollTimeoutMillis();\n        while (true) {\n            String url = appendQuery(\n                    joinedUrl(requireBaseUrl(), \"v3/chat/retrieve\"),\n                    query(conversationId, chatId)\n            );\n            JSONObject retrieveResponse = executeObject(jsonRequestBuilder(url).get().build());\n            assertCozeSuccess(retrieveResponse);\n            JSONObject chatData = retrieveResponse.getJSONObject(\"data\");\n            String status = chatData == null ? null : chatData.getString(\"status\");\n            if (\"completed\".equals(status) || \"failed\".equals(status) || \"canceled\".equals(status) || \"requires_action\".equals(status)) {\n                return chatData == null ? new JSONObject() : chatData;\n            }\n            if (System.currentTimeMillis() >= deadline) {\n                throw new AgentFlowException(\"Coze chat poll timed out\");\n            }\n            sleep(pollIntervalMillis());\n        }\n    }\n\n    private Request buildCreateRequest(AgentFlowChatRequest request, boolean stream) {\n        String conversationId = defaultConversationId(request.getConversationId());\n        String url = appendQuery(\n                joinedUrl(requireBaseUrl(), \"v3/chat\"),\n                queryConversation(conversationId)\n        );\n        return jsonRequestBuilder(url).post(jsonBody(buildCreateBody(request, stream))).build();\n    }\n\n    private JSONObject buildCreateBody(AgentFlowChatRequest request, boolean stream) {\n        JSONObject body = new JSONObject();\n        body.put(\"bot_id\", requireBotId());\n        body.put(\"user_id\", defaultUserId(request.getUserId()));\n        body.put(\"stream\", stream);\n        body.put(\"additional_messages\", Collections.singletonList(userMessage(request.getPrompt())));\n        if (request.getInputs() != null && !request.getInputs().isEmpty()) {\n            body.put(\"parameters\", request.getInputs());\n        }\n        if (request.getMetadata() != null && !request.getMetadata().isEmpty()) {\n            body.put(\"meta_data\", toStringMap(request.getMetadata()));\n        }\n        if (!stream && !body.containsKey(\"auto_save_history\")) {\n            body.put(\"auto_save_history\", true);\n        }\n        if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) {\n            body.putAll(request.getExtraBody());\n        }\n        return body;\n    }\n\n    private Request buildMessageListRequest(String conversationId, String chatId) {\n        String url = appendQuery(\n                joinedUrl(requireBaseUrl(), \"v1/conversation/message/list\"),\n                queryConversation(conversationId)\n        );\n        JSONObject body = new JSONObject();\n        body.put(\"conversation_id\", conversationId);\n        body.put(\"chat_id\", chatId);\n        body.put(\"order\", \"asc\");\n        body.put(\"limit\", 50);\n        if (!isBlank(agentFlowConfig.getBotId())) {\n            body.put(\"bot_id\", agentFlowConfig.getBotId());\n        }\n        return jsonRequestBuilder(url).post(jsonBody(body)).build();\n    }\n\n    private JSONObject userMessage(String prompt) {\n        JSONObject message = new JSONObject();\n        message.put(\"role\", \"user\");\n        message.put(\"type\", \"question\");\n        message.put(\"content\", prompt);\n        message.put(\"content_type\", \"text\");\n        return message;\n    }\n\n    private JSONObject parseObjectOrNull(String data) {\n        if (isBlank(data)) {\n            return null;\n        }\n        try {\n            Object parsed = JSON.parse(data);\n            return parsed instanceof JSONObject ? (JSONObject) parsed : null;\n        } catch (Exception ex) {\n            return null;\n        }\n    }\n\n    private Map<String, String> queryConversation(String conversationId) {\n        Map<String, String> values = new LinkedHashMap<String, String>();\n        if (!isBlank(conversationId)) {\n            values.put(\"conversation_id\", conversationId);\n        }\n        return values;\n    }\n\n    private Map<String, String> query(String conversationId, String chatId) {\n        Map<String, String> values = queryConversation(conversationId);\n        if (!isBlank(chatId)) {\n            values.put(\"chat_id\", chatId);\n        }\n        return values;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/chat/DifyAgentFlowChatService.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.chat;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowConfig;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowException;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.Collections;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class DifyAgentFlowChatService extends AgentFlowSupport implements AgentFlowChatService {\n\n    public DifyAgentFlowChatService(Configuration configuration, AgentFlowConfig agentFlowConfig) {\n        super(configuration, agentFlowConfig);\n    }\n\n    @Override\n    public AgentFlowChatResponse chat(AgentFlowChatRequest request) throws Exception {\n        AgentFlowTraceContext traceContext = startTrace(\"chat\", false, request);\n        try {\n            JSONObject body = buildRequestBody(request, \"blocking\");\n            String url = joinedUrl(requireBaseUrl(), \"v1/chat-messages\");\n            JSONObject response = executeObject(jsonRequestBuilder(url).post(jsonBody(body)).build());\n            AgentFlowChatResponse chatResponse = mapBlockingResponse(response);\n            traceComplete(traceContext, chatResponse);\n            return chatResponse;\n        } catch (Exception ex) {\n            traceError(traceContext, ex);\n            throw ex;\n        }\n    }\n\n    @Override\n    public void chatStream(AgentFlowChatRequest request, final AgentFlowChatListener listener) throws Exception {\n        if (listener == null) {\n            throw new IllegalArgumentException(\"listener is required\");\n        }\n        final AgentFlowTraceContext traceContext = startTrace(\"chat\", true, request);\n        JSONObject body = buildRequestBody(request, \"streaming\");\n        String url = joinedUrl(requireBaseUrl(), \"v1/chat-messages\");\n        Request httpRequest = jsonRequestBuilder(url).post(jsonBody(body)).build();\n\n        final CountDownLatch latch = new CountDownLatch(1);\n        final AtomicReference<Throwable> failure = new AtomicReference<Throwable>();\n        final AtomicReference<AgentFlowChatResponse> completion = new AtomicReference<AgentFlowChatResponse>();\n        final AtomicReference<String> conversationIdRef = new AtomicReference<String>();\n        final AtomicReference<String> messageIdRef = new AtomicReference<String>();\n        final AtomicReference<String> taskIdRef = new AtomicReference<String>();\n        final AtomicReference<AgentFlowUsage> usageRef = new AtomicReference<AgentFlowUsage>();\n        final StringBuilder content = new StringBuilder();\n        final AtomicBoolean closed = new AtomicBoolean(false);\n\n        eventSourceFactory.newEventSource(httpRequest, new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                listener.onOpen();\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource,\n                                @Nullable String id,\n                                @Nullable String type,\n                                @NotNull String data) {\n                try {\n                    JSONObject payload = parseObjectOrNull(data);\n                    String eventType = firstNonBlank(type, payload == null ? null : payload.getString(\"event\"));\n                    if (isBlank(eventType) || \"ping\".equals(eventType)) {\n                        return;\n                    }\n\n                    String conversationId = payload == null ? null : payload.getString(\"conversation_id\");\n                    String messageId = payload == null ? null : firstNonBlank(payload.getString(\"message_id\"), payload.getString(\"id\"));\n                    String taskId = payload == null ? null : payload.getString(\"task_id\");\n                    if (!isBlank(conversationId)) {\n                        conversationIdRef.set(conversationId);\n                    }\n                    if (!isBlank(messageId)) {\n                        messageIdRef.set(messageId);\n                    }\n                    if (!isBlank(taskId)) {\n                        taskIdRef.set(taskId);\n                    }\n\n                    AgentFlowUsage usage = payload == null ? null : usageFromDify(metadataUsage(payload));\n                    if (usage != null) {\n                        usageRef.set(usage);\n                    }\n\n                    String delta = null;\n                    if (\"message\".equals(eventType) || \"agent_message\".equals(eventType)) {\n                        delta = payload == null ? null : payload.getString(\"answer\");\n                        if (!isBlank(delta)) {\n                            content.append(delta);\n                        }\n                    }\n\n                    boolean done = \"message_end\".equals(eventType);\n                    AgentFlowChatEvent event = AgentFlowChatEvent.builder()\n                            .type(eventType)\n                            .contentDelta(delta)\n                            .conversationId(conversationIdRef.get())\n                            .messageId(messageIdRef.get())\n                            .taskId(taskIdRef.get())\n                            .done(done)\n                            .usage(usageRef.get())\n                            .raw(payload == null ? data : payload)\n                            .build();\n                    listener.onEvent(event);\n                    traceEvent(traceContext, event);\n\n                    if (\"error\".equals(eventType)) {\n                        throw new AgentFlowException(\"Dify stream error: \" + (payload == null ? data : payload.toJSONString()));\n                    }\n                    if (done) {\n                        AgentFlowChatResponse responsePayload = AgentFlowChatResponse.builder()\n                                .content(content.toString())\n                                .conversationId(conversationIdRef.get())\n                                .messageId(messageIdRef.get())\n                                .taskId(taskIdRef.get())\n                                .usage(usageRef.get())\n                                .raw(payload)\n                                .build();\n                        completion.set(responsePayload);\n                        listener.onComplete(responsePayload);\n                        traceComplete(traceContext, responsePayload);\n                        closed.set(true);\n                        eventSource.cancel();\n                        latch.countDown();\n                    }\n                } catch (Throwable ex) {\n                    failure.set(ex);\n                    traceError(traceContext, ex);\n                    listener.onError(ex);\n                    closed.set(true);\n                    eventSource.cancel();\n                    latch.countDown();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                if (closed.compareAndSet(false, true)) {\n                    AgentFlowChatResponse responsePayload = completion.get();\n                    if (responsePayload == null) {\n                        responsePayload = AgentFlowChatResponse.builder()\n                                .content(content.toString())\n                                .conversationId(conversationIdRef.get())\n                                .messageId(messageIdRef.get())\n                                .taskId(taskIdRef.get())\n                                .usage(usageRef.get())\n                                .build();\n                        completion.set(responsePayload);\n                        listener.onComplete(responsePayload);\n                        traceComplete(traceContext, responsePayload);\n                    }\n                    latch.countDown();\n                }\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                Throwable error = t;\n                if (error == null && response != null) {\n                    error = new AgentFlowException(\"Dify stream failed: HTTP \" + response.code());\n                }\n                if (error == null) {\n                    error = new AgentFlowException(\"Dify stream failed\");\n                }\n                failure.set(error);\n                traceError(traceContext, error);\n                listener.onError(error);\n                closed.set(true);\n                latch.countDown();\n            }\n        });\n\n        if (!latch.await(pollTimeoutMillis(), TimeUnit.MILLISECONDS)) {\n            throw new AgentFlowException(\"Dify stream timed out\");\n        }\n        if (failure.get() != null) {\n            if (failure.get() instanceof Exception) {\n                throw (Exception) failure.get();\n            }\n            throw new AgentFlowException(\"Dify stream failed\", failure.get());\n        }\n    }\n\n    private JSONObject buildRequestBody(AgentFlowChatRequest request, String responseMode) {\n        JSONObject body = new JSONObject();\n        body.put(\"query\", request.getPrompt());\n        body.put(\"inputs\", request.getInputs() == null ? Collections.emptyMap() : request.getInputs());\n        body.put(\"user\", defaultUserId(request.getUserId()));\n        body.put(\"response_mode\", responseMode);\n        String conversationId = defaultConversationId(request.getConversationId());\n        if (!isBlank(conversationId)) {\n            body.put(\"conversation_id\", conversationId);\n        }\n        if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) {\n            body.putAll(request.getExtraBody());\n        }\n        return body;\n    }\n\n    private AgentFlowChatResponse mapBlockingResponse(JSONObject response) {\n        return AgentFlowChatResponse.builder()\n                .content(firstNonBlank(response.getString(\"answer\"), response.getString(\"message\")))\n                .conversationId(response.getString(\"conversation_id\"))\n                .messageId(firstNonBlank(response.getString(\"message_id\"), response.getString(\"id\")))\n                .taskId(response.getString(\"task_id\"))\n                .usage(usageFromDify(metadataUsage(response)))\n                .raw(response)\n                .build();\n    }\n\n    private JSONObject metadataUsage(JSONObject payload) {\n        JSONObject metadata = payload == null ? null : payload.getJSONObject(\"metadata\");\n        return metadata == null ? null : metadata.getJSONObject(\"usage\");\n    }\n\n    private JSONObject parseObjectOrNull(String data) {\n        if (isBlank(data)) {\n            return null;\n        }\n        try {\n            Object parsed = JSON.parse(data);\n            return parsed instanceof JSONObject ? (JSONObject) parsed : null;\n        } catch (Exception ex) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/support/AgentFlowSupport.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.support;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowConfig;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowException;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceListener;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.HttpUrl;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport okhttp3.sse.EventSource;\n\nimport java.io.IOException;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic abstract class AgentFlowSupport {\n\n    protected static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    protected final Configuration configuration;\n    protected final AgentFlowConfig agentFlowConfig;\n    protected final OkHttpClient okHttpClient;\n    protected final EventSource.Factory eventSourceFactory;\n\n    protected AgentFlowSupport(Configuration configuration, AgentFlowConfig agentFlowConfig) {\n        if (configuration == null) {\n            throw new IllegalArgumentException(\"configuration is required\");\n        }\n        if (configuration.getOkHttpClient() == null) {\n            throw new IllegalArgumentException(\"OkHttpClient configuration is required\");\n        }\n        if (agentFlowConfig == null) {\n            throw new IllegalArgumentException(\"agentFlowConfig is required\");\n        }\n        this.configuration = configuration;\n        this.agentFlowConfig = agentFlowConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.eventSourceFactory = configuration.createRequestFactory();\n    }\n\n    protected String defaultUserId(String requestUserId) {\n        if (!isBlank(requestUserId)) {\n            return requestUserId;\n        }\n        if (!isBlank(agentFlowConfig.getUserId())) {\n            return agentFlowConfig.getUserId();\n        }\n        return \"default-user\";\n    }\n\n    protected String defaultConversationId(String requestConversationId) {\n        if (!isBlank(requestConversationId)) {\n            return requestConversationId;\n        }\n        return agentFlowConfig.getConversationId();\n    }\n\n    protected String requireBaseUrl() {\n        if (isBlank(agentFlowConfig.getBaseUrl())) {\n            throw new IllegalArgumentException(\"baseUrl is required\");\n        }\n        return agentFlowConfig.getBaseUrl();\n    }\n\n    protected String requireWebhookUrl() {\n        if (isBlank(agentFlowConfig.getWebhookUrl())) {\n            throw new IllegalArgumentException(\"webhookUrl is required\");\n        }\n        return agentFlowConfig.getWebhookUrl();\n    }\n\n    protected String requireApiKey() {\n        if (isBlank(agentFlowConfig.getApiKey())) {\n            throw new IllegalArgumentException(\"apiKey is required\");\n        }\n        return agentFlowConfig.getApiKey();\n    }\n\n    protected String requireBotId() {\n        if (isBlank(agentFlowConfig.getBotId())) {\n            throw new IllegalArgumentException(\"botId is required\");\n        }\n        return agentFlowConfig.getBotId();\n    }\n\n    protected String requireWorkflowId(String requestWorkflowId) {\n        if (!isBlank(requestWorkflowId)) {\n            return requestWorkflowId;\n        }\n        if (!isBlank(agentFlowConfig.getWorkflowId())) {\n            return agentFlowConfig.getWorkflowId();\n        }\n        throw new IllegalArgumentException(\"workflowId is required\");\n    }\n\n    protected String joinedUrl(String baseUrl, String path) {\n        return UrlUtils.concatUrl(baseUrl, path);\n    }\n\n    protected String appendQuery(String url, Map<String, String> queryParameters) {\n        HttpUrl parsed = HttpUrl.parse(url);\n        if (parsed == null) {\n            throw new IllegalArgumentException(\"Invalid URL: \" + url);\n        }\n        HttpUrl.Builder builder = parsed.newBuilder();\n        if (queryParameters != null) {\n            for (Map.Entry<String, String> entry : queryParameters.entrySet()) {\n                if (!isBlank(entry.getValue())) {\n                    builder.addQueryParameter(entry.getKey(), entry.getValue());\n                }\n            }\n        }\n        return builder.build().toString();\n    }\n\n    protected RequestBody jsonBody(Object body) {\n        return RequestBody.create(JSON.toJSONString(body), JSON_MEDIA_TYPE);\n    }\n\n    protected Request.Builder jsonRequestBuilder(String url) {\n        Request.Builder builder = new Request.Builder().url(url);\n        builder.header(\"Content-Type\", Constants.APPLICATION_JSON);\n        if (!isBlank(agentFlowConfig.getApiKey())) {\n            builder.header(\"Authorization\", \"Bearer \" + agentFlowConfig.getApiKey());\n        }\n        if (agentFlowConfig.getHeaders() != null) {\n            for (Map.Entry<String, String> entry : agentFlowConfig.getHeaders().entrySet()) {\n                if (!isBlank(entry.getKey()) && entry.getValue() != null) {\n                    builder.header(entry.getKey(), entry.getValue());\n                }\n            }\n        }\n        return builder;\n    }\n\n    protected String execute(Request request) throws IOException {\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            return readResponse(request, response);\n        }\n    }\n\n    protected JSONObject executeObject(Request request) throws IOException {\n        String body = execute(request);\n        if (isBlank(body)) {\n            return new JSONObject();\n        }\n        Object parsed = JSON.parse(body);\n        if (parsed instanceof JSONObject) {\n            return (JSONObject) parsed;\n        }\n        throw new AgentFlowException(\"Expected JSON object response but got: \" + body);\n    }\n\n    protected Object parseJsonOrText(String body) {\n        if (isBlank(body)) {\n            return null;\n        }\n        try {\n            return JSON.parse(body);\n        } catch (Exception ex) {\n            return body;\n        }\n    }\n\n    protected String readResponse(Request request, Response response) throws IOException {\n        ResponseBody body = response.body();\n        String content = body == null ? \"\" : body.string();\n        if (!response.isSuccessful()) {\n            throw new AgentFlowException(\"HTTP \" + response.code() + \" calling \" + request.url() + \": \" + abbreviate(content));\n        }\n        return content;\n    }\n\n    protected void assertCozeSuccess(JSONObject response) {\n        Integer code = response == null ? null : response.getInteger(\"code\");\n        if (code != null && code.intValue() != 0) {\n            throw new AgentFlowException(\"Coze request failed: code=\" + code + \", msg=\" + response.getString(\"msg\"));\n        }\n    }\n\n    protected Map<String, Object> mutableMap(Map<String, Object> source) {\n        if (source == null || source.isEmpty()) {\n            return new LinkedHashMap<String, Object>();\n        }\n        return new LinkedHashMap<String, Object>(source);\n    }\n\n    protected Map<String, String> toStringMap(Map<String, Object> source) {\n        if (source == null || source.isEmpty()) {\n            return Collections.emptyMap();\n        }\n        Map<String, String> result = new LinkedHashMap<String, String>();\n        for (Map.Entry<String, Object> entry : source.entrySet()) {\n            if (entry.getKey() != null && entry.getValue() != null) {\n                result.put(entry.getKey(), String.valueOf(entry.getValue()));\n            }\n        }\n        return result;\n    }\n\n    protected String extractText(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof String) {\n            return (String) value;\n        }\n        if (value instanceof JSONObject) {\n            JSONObject jsonObject = (JSONObject) value;\n            String direct = firstNonBlank(\n                    jsonObject.getString(\"answer\"),\n                    jsonObject.getString(\"output\"),\n                    jsonObject.getString(\"text\"),\n                    jsonObject.getString(\"content\"),\n                    jsonObject.getString(\"result\"),\n                    jsonObject.getString(\"message\")\n            );\n            if (!isBlank(direct)) {\n                return direct;\n            }\n            if (jsonObject.size() == 1) {\n                Map.Entry<String, Object> entry = jsonObject.entrySet().iterator().next();\n                return extractText(entry.getValue());\n            }\n            return JSON.toJSONString(jsonObject);\n        }\n        return String.valueOf(value);\n    }\n\n    protected AgentFlowUsage usageFromDify(JSONObject usage) {\n        if (usage == null || usage.isEmpty()) {\n            return null;\n        }\n        return AgentFlowUsage.builder()\n                .inputTokens(usage.getInteger(\"prompt_tokens\"))\n                .outputTokens(usage.getInteger(\"completion_tokens\"))\n                .totalTokens(usage.getInteger(\"total_tokens\"))\n                .raw(usage)\n                .build();\n    }\n\n    protected AgentFlowUsage usageFromCoze(JSONObject usage) {\n        if (usage == null || usage.isEmpty()) {\n            return null;\n        }\n        return AgentFlowUsage.builder()\n                .inputTokens(firstNonNullInteger(usage.getInteger(\"input_tokens\"), usage.getInteger(\"input_count\")))\n                .outputTokens(firstNonNullInteger(usage.getInteger(\"output_tokens\"), usage.getInteger(\"output_count\")))\n                .totalTokens(usage.getInteger(\"token_count\"))\n                .raw(usage)\n                .build();\n    }\n\n    protected Integer firstNonNullInteger(Integer first, Integer second) {\n        return first != null ? first : second;\n    }\n\n    protected String firstNonBlank(String first, String second) {\n        return firstNonBlank(first, second, null, null, null, null);\n    }\n\n    protected String firstNonBlank(String first,\n                                   String second,\n                                   String third,\n                                   String fourth,\n                                   String fifth,\n                                   String sixth) {\n        String[] values = new String[]{first, second, third, fourth, fifth, sixth};\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    protected long pollIntervalMillis() {\n        Long value = agentFlowConfig.getPollIntervalMillis();\n        return value == null || value.longValue() <= 0L ? 1_000L : value.longValue();\n    }\n\n    protected long pollTimeoutMillis() {\n        Long value = agentFlowConfig.getPollTimeoutMillis();\n        return value == null || value.longValue() <= 0L ? 60_000L : value.longValue();\n    }\n\n    protected void sleep(long millis) throws InterruptedException {\n        if (millis > 0L) {\n            Thread.sleep(millis);\n        }\n    }\n\n    protected String abbreviate(String value) {\n        if (value == null) {\n            return null;\n        }\n        if (value.length() <= 500) {\n            return value;\n        }\n        return value.substring(0, 500) + \"...\";\n    }\n\n    protected boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    protected AgentFlowTraceContext startTrace(String operation, boolean streaming, Object request) {\n        AgentFlowTraceContext context = AgentFlowTraceContext.builder()\n                .executionId(UUID.randomUUID().toString())\n                .type(agentFlowConfig.getType())\n                .operation(operation)\n                .streaming(streaming)\n                .startedAt(System.currentTimeMillis())\n                .baseUrl(agentFlowConfig.getBaseUrl())\n                .webhookUrl(agentFlowConfig.getWebhookUrl())\n                .botId(agentFlowConfig.getBotId())\n                .workflowId(agentFlowConfig.getWorkflowId())\n                .appId(agentFlowConfig.getAppId())\n                .configuredUserId(agentFlowConfig.getUserId())\n                .configuredConversationId(agentFlowConfig.getConversationId())\n                .request(request)\n                .build();\n        notifyTraceStart(context);\n        return context;\n    }\n\n    protected void traceEvent(AgentFlowTraceContext context, Object event) {\n        List<AgentFlowTraceListener> listeners = traceListeners();\n        if (context == null || event == null || listeners.isEmpty()) {\n            return;\n        }\n        for (AgentFlowTraceListener listener : listeners) {\n            if (listener == null) {\n                continue;\n            }\n            try {\n                listener.onEvent(context, event);\n            } catch (Throwable ignored) {\n                // Trace listeners must never break the primary AgentFlow call path.\n            }\n        }\n    }\n\n    protected void traceComplete(AgentFlowTraceContext context, Object response) {\n        List<AgentFlowTraceListener> listeners = traceListeners();\n        if (context == null || listeners.isEmpty()) {\n            return;\n        }\n        for (AgentFlowTraceListener listener : listeners) {\n            if (listener == null) {\n                continue;\n            }\n            try {\n                listener.onComplete(context, response);\n            } catch (Throwable ignored) {\n                // Trace listeners must never break the primary AgentFlow call path.\n            }\n        }\n    }\n\n    protected void traceError(AgentFlowTraceContext context, Throwable throwable) {\n        List<AgentFlowTraceListener> listeners = traceListeners();\n        if (context == null || throwable == null || listeners.isEmpty()) {\n            return;\n        }\n        for (AgentFlowTraceListener listener : listeners) {\n            if (listener == null) {\n                continue;\n            }\n            try {\n                listener.onError(context, throwable);\n            } catch (Throwable ignored) {\n                // Trace listeners must never break the primary AgentFlow call path.\n            }\n        }\n    }\n\n    private void notifyTraceStart(AgentFlowTraceContext context) {\n        List<AgentFlowTraceListener> listeners = traceListeners();\n        if (context == null || listeners.isEmpty()) {\n            return;\n        }\n        for (AgentFlowTraceListener listener : listeners) {\n            if (listener == null) {\n                continue;\n            }\n            try {\n                listener.onStart(context);\n            } catch (Throwable ignored) {\n                // Trace listeners must never break the primary AgentFlow call path.\n            }\n        }\n    }\n\n    private List<AgentFlowTraceListener> traceListeners() {\n        List<AgentFlowTraceListener> listeners = agentFlowConfig.getTraceListeners();\n        return listeners == null ? Collections.<AgentFlowTraceListener>emptyList() : listeners;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/trace/AgentFlowTraceContext.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.trace;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowType;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowTraceContext {\n\n    private String executionId;\n\n    private AgentFlowType type;\n\n    private String operation;\n\n    private boolean streaming;\n\n    private long startedAt;\n\n    private String baseUrl;\n\n    private String webhookUrl;\n\n    private String botId;\n\n    private String workflowId;\n\n    private String appId;\n\n    private String configuredUserId;\n\n    private String configuredConversationId;\n\n    private Object request;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/trace/AgentFlowTraceListener.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.trace;\n\npublic interface AgentFlowTraceListener {\n\n    default void onStart(AgentFlowTraceContext context) {\n    }\n\n    default void onEvent(AgentFlowTraceContext context, Object event) {\n    }\n\n    default void onComplete(AgentFlowTraceContext context, Object response) {\n    }\n\n    default void onError(AgentFlowTraceContext context, Throwable throwable) {\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowEvent.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.workflow;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowWorkflowEvent {\n\n    private String type;\n\n    private String status;\n\n    private String outputText;\n\n    @Builder.Default\n    private Map<String, Object> outputs = Collections.emptyMap();\n\n    private String taskId;\n\n    private String workflowRunId;\n\n    private boolean done;\n\n    private AgentFlowUsage usage;\n\n    private Object raw;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowListener.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.workflow;\n\npublic interface AgentFlowWorkflowListener {\n\n    void onEvent(AgentFlowWorkflowEvent event);\n\n    default void onOpen() {\n    }\n\n    default void onError(Throwable throwable) {\n    }\n\n    default void onComplete(AgentFlowWorkflowResponse response) {\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowRequest.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.workflow;\n\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowWorkflowRequest {\n\n    @Builder.Default\n    private Map<String, Object> inputs = Collections.emptyMap();\n\n    private String userId;\n\n    private String workflowId;\n\n    @Builder.Default\n    private Map<String, Object> metadata = Collections.emptyMap();\n\n    @Builder.Default\n    private Map<String, Object> extraBody = Collections.emptyMap();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowResponse.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.workflow;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\npublic class AgentFlowWorkflowResponse {\n\n    private String status;\n\n    private String outputText;\n\n    @Builder.Default\n    private Map<String, Object> outputs = Collections.emptyMap();\n\n    private String taskId;\n\n    private String workflowRunId;\n\n    private AgentFlowUsage usage;\n\n    private Object raw;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/AgentFlowWorkflowService.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.workflow;\n\npublic interface AgentFlowWorkflowService {\n\n    AgentFlowWorkflowResponse run(AgentFlowWorkflowRequest request) throws Exception;\n\n    void runStream(AgentFlowWorkflowRequest request, AgentFlowWorkflowListener listener) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/CozeAgentFlowWorkflowService.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.workflow;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowConfig;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowException;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class CozeAgentFlowWorkflowService extends AgentFlowSupport implements AgentFlowWorkflowService {\n\n    public CozeAgentFlowWorkflowService(Configuration configuration, AgentFlowConfig agentFlowConfig) {\n        super(configuration, agentFlowConfig);\n    }\n\n    @Override\n    public AgentFlowWorkflowResponse run(AgentFlowWorkflowRequest request) throws Exception {\n        AgentFlowTraceContext traceContext = startTrace(\"workflow\", false, request);\n        try {\n            JSONObject response = executeObject(buildRunRequest(request, false));\n            assertCozeSuccess(response);\n\n            Object dataValue = response.get(\"data\");\n            Object parsedData = parseWorkflowData(dataValue);\n            Map<String, Object> outputs = parsedData instanceof JSONObject\n                    ? new LinkedHashMap<String, Object>((JSONObject) parsedData)\n                    : Collections.<String, Object>emptyMap();\n\n            AgentFlowWorkflowResponse workflowResponse = AgentFlowWorkflowResponse.builder()\n                    .status(\"completed\")\n                    .outputText(extractText(parsedData))\n                    .outputs(outputs)\n                    .workflowRunId(response.getString(\"execute_id\"))\n                    .usage(usageFromCoze(response.getJSONObject(\"usage\")))\n                    .raw(response)\n                    .build();\n            traceComplete(traceContext, workflowResponse);\n            return workflowResponse;\n        } catch (Exception ex) {\n            traceError(traceContext, ex);\n            throw ex;\n        }\n    }\n\n    @Override\n    public void runStream(AgentFlowWorkflowRequest request, final AgentFlowWorkflowListener listener) throws Exception {\n        if (listener == null) {\n            throw new IllegalArgumentException(\"listener is required\");\n        }\n        final AgentFlowTraceContext traceContext = startTrace(\"workflow\", true, request);\n\n        Request httpRequest = buildRunRequest(request, true);\n        final CountDownLatch latch = new CountDownLatch(1);\n        final AtomicReference<Throwable> failure = new AtomicReference<Throwable>();\n        final AtomicReference<AgentFlowWorkflowResponse> completion = new AtomicReference<AgentFlowWorkflowResponse>();\n        final AtomicReference<AgentFlowUsage> usageRef = new AtomicReference<AgentFlowUsage>();\n        final StringBuilder content = new StringBuilder();\n        final AtomicBoolean closed = new AtomicBoolean(false);\n\n        eventSourceFactory.newEventSource(httpRequest, new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                listener.onOpen();\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource,\n                                @Nullable String id,\n                                @Nullable String type,\n                                @NotNull String data) {\n                try {\n                    String eventType = type;\n                    JSONObject payload = parseObjectOrNull(data);\n                    boolean done = false;\n                    String outputText = null;\n\n                    if (\"Message\".equals(eventType)) {\n                        outputText = payload == null ? null : payload.getString(\"content\");\n                        if (!isBlank(outputText)) {\n                            content.append(outputText);\n                        }\n                        AgentFlowUsage usage = payload == null ? null : usageFromCoze(payload.getJSONObject(\"usage\"));\n                        if (usage != null) {\n                            usageRef.set(usage);\n                        }\n                    } else if (\"Interrupt\".equals(eventType)) {\n                        throw new AgentFlowException(\"Coze workflow interrupted: \" + data);\n                    } else if (\"Error\".equals(eventType)) {\n                        throw new AgentFlowException(\"Coze workflow stream error: \" + data);\n                    } else if (\"Done\".equals(eventType)) {\n                        done = true;\n                    }\n\n                    AgentFlowWorkflowEvent event = AgentFlowWorkflowEvent.builder()\n                            .type(eventType)\n                            .status(done ? \"completed\" : null)\n                            .outputText(outputText)\n                            .done(done)\n                            .usage(usageRef.get())\n                            .raw(payload == null ? data : payload)\n                            .build();\n                    listener.onEvent(event);\n                    traceEvent(traceContext, event);\n\n                    if (done) {\n                        AgentFlowWorkflowResponse responsePayload = AgentFlowWorkflowResponse.builder()\n                                .status(\"completed\")\n                                .outputText(content.toString())\n                                .usage(usageRef.get())\n                                .raw(payload == null ? data : payload)\n                                .build();\n                        completion.set(responsePayload);\n                        listener.onComplete(responsePayload);\n                        traceComplete(traceContext, responsePayload);\n                        closed.set(true);\n                        eventSource.cancel();\n                        latch.countDown();\n                    }\n                } catch (Throwable ex) {\n                    failure.set(ex);\n                    traceError(traceContext, ex);\n                    listener.onError(ex);\n                    closed.set(true);\n                    eventSource.cancel();\n                    latch.countDown();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                if (closed.compareAndSet(false, true)) {\n                    AgentFlowWorkflowResponse responsePayload = completion.get();\n                    if (responsePayload == null) {\n                        responsePayload = AgentFlowWorkflowResponse.builder()\n                                .status(\"completed\")\n                                .outputText(content.toString())\n                                .usage(usageRef.get())\n                                .build();\n                        completion.set(responsePayload);\n                        listener.onComplete(responsePayload);\n                        traceComplete(traceContext, responsePayload);\n                    }\n                    latch.countDown();\n                }\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                Throwable error = t;\n                if (error == null && response != null) {\n                    error = new AgentFlowException(\"Coze workflow stream failed: HTTP \" + response.code());\n                }\n                if (error == null) {\n                    error = new AgentFlowException(\"Coze workflow stream failed\");\n                }\n                failure.set(error);\n                traceError(traceContext, error);\n                listener.onError(error);\n                closed.set(true);\n                latch.countDown();\n            }\n        });\n\n        if (!latch.await(pollTimeoutMillis(), TimeUnit.MILLISECONDS)) {\n            throw new AgentFlowException(\"Coze workflow stream timed out\");\n        }\n        if (failure.get() != null) {\n            if (failure.get() instanceof Exception) {\n                throw (Exception) failure.get();\n            }\n            throw new AgentFlowException(\"Coze workflow stream failed\", failure.get());\n        }\n    }\n\n    private Request buildRunRequest(AgentFlowWorkflowRequest request, boolean stream) {\n        String path = stream ? \"v1/workflow/stream_run\" : \"v1/workflow/run\";\n        String url = joinedUrl(requireBaseUrl(), path);\n        return jsonRequestBuilder(url).post(jsonBody(buildRequestBody(request))).build();\n    }\n\n    private JSONObject buildRequestBody(AgentFlowWorkflowRequest request) {\n        JSONObject body = new JSONObject();\n        body.put(\"workflow_id\", requireWorkflowId(request.getWorkflowId()));\n        body.put(\"parameters\", request.getInputs() == null ? Collections.emptyMap() : request.getInputs());\n        if (!isBlank(agentFlowConfig.getBotId())) {\n            body.put(\"bot_id\", agentFlowConfig.getBotId());\n        }\n        if (!isBlank(agentFlowConfig.getAppId())) {\n            body.put(\"app_id\", agentFlowConfig.getAppId());\n        }\n        if (request.getMetadata() != null && !request.getMetadata().isEmpty()) {\n            body.put(\"ext\", toStringMap(request.getMetadata()));\n        }\n        if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) {\n            body.putAll(request.getExtraBody());\n        }\n        return body;\n    }\n\n    private Object parseWorkflowData(Object dataValue) {\n        if (dataValue == null) {\n            return null;\n        }\n        if (!(dataValue instanceof String)) {\n            return dataValue;\n        }\n        String text = (String) dataValue;\n        if (isBlank(text)) {\n            return null;\n        }\n        try {\n            return JSON.parse(text);\n        } catch (Exception ex) {\n            return text;\n        }\n    }\n\n    private JSONObject parseObjectOrNull(String data) {\n        if (isBlank(data)) {\n            return null;\n        }\n        try {\n            Object parsed = JSON.parse(data);\n            return parsed instanceof JSONObject ? (JSONObject) parsed : null;\n        } catch (Exception ex) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/DifyAgentFlowWorkflowService.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.workflow;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowConfig;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowException;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class DifyAgentFlowWorkflowService extends AgentFlowSupport implements AgentFlowWorkflowService {\n\n    public DifyAgentFlowWorkflowService(Configuration configuration, AgentFlowConfig agentFlowConfig) {\n        super(configuration, agentFlowConfig);\n    }\n\n    @Override\n    public AgentFlowWorkflowResponse run(AgentFlowWorkflowRequest request) throws Exception {\n        AgentFlowTraceContext traceContext = startTrace(\"workflow\", false, request);\n        try {\n            JSONObject body = buildRequestBody(request, \"blocking\");\n            String url = joinedUrl(requireBaseUrl(), \"v1/workflows/run\");\n            JSONObject response = executeObject(jsonRequestBuilder(url).post(jsonBody(body)).build());\n            AgentFlowWorkflowResponse workflowResponse = mapWorkflowResponse(response);\n            traceComplete(traceContext, workflowResponse);\n            return workflowResponse;\n        } catch (Exception ex) {\n            traceError(traceContext, ex);\n            throw ex;\n        }\n    }\n\n    @Override\n    public void runStream(AgentFlowWorkflowRequest request, final AgentFlowWorkflowListener listener) throws Exception {\n        if (listener == null) {\n            throw new IllegalArgumentException(\"listener is required\");\n        }\n        final AgentFlowTraceContext traceContext = startTrace(\"workflow\", true, request);\n\n        JSONObject body = buildRequestBody(request, \"streaming\");\n        String url = joinedUrl(requireBaseUrl(), \"v1/workflows/run\");\n        Request httpRequest = jsonRequestBuilder(url).post(jsonBody(body)).build();\n\n        final CountDownLatch latch = new CountDownLatch(1);\n        final AtomicReference<Throwable> failure = new AtomicReference<Throwable>();\n        final AtomicReference<AgentFlowWorkflowResponse> completion = new AtomicReference<AgentFlowWorkflowResponse>();\n        final AtomicReference<String> taskIdRef = new AtomicReference<String>();\n        final AtomicReference<String> workflowRunIdRef = new AtomicReference<String>();\n        final AtomicReference<AgentFlowUsage> usageRef = new AtomicReference<AgentFlowUsage>();\n        final AtomicReference<Map<String, Object>> outputsRef = new AtomicReference<Map<String, Object>>(Collections.<String, Object>emptyMap());\n        final StringBuilder content = new StringBuilder();\n        final AtomicBoolean closed = new AtomicBoolean(false);\n\n        eventSourceFactory.newEventSource(httpRequest, new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                listener.onOpen();\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource,\n                                @Nullable String id,\n                                @Nullable String type,\n                                @NotNull String data) {\n                try {\n                    JSONObject payload = parseObjectOrNull(data);\n                    String eventType = firstNonBlank(type, payload == null ? null : payload.getString(\"event\"));\n                    if (isBlank(eventType) || \"ping\".equals(eventType)) {\n                        return;\n                    }\n\n                    if (payload != null) {\n                        taskIdRef.set(firstNonBlank(payload.getString(\"task_id\"), taskIdRef.get()));\n                        workflowRunIdRef.set(firstNonBlank(payload.getString(\"workflow_run_id\"), workflowRunIdRef.get()));\n                    }\n\n                    String outputText = null;\n                    String status = null;\n                    Map<String, Object> outputs = outputsRef.get();\n                    boolean done = false;\n\n                    if (\"workflow_finished\".equals(eventType)) {\n                        done = true;\n                        JSONObject dataObject = payload == null ? null : payload.getJSONObject(\"data\");\n                        status = dataObject == null ? null : dataObject.getString(\"status\");\n                        JSONObject outputObject = dataObject == null ? null : dataObject.getJSONObject(\"outputs\");\n                        outputs = outputObject == null\n                                ? Collections.<String, Object>emptyMap()\n                                : new LinkedHashMap<String, Object>(outputObject);\n                        outputsRef.set(outputs);\n                        outputText = extractText(outputObject);\n                        if (!isBlank(outputText)) {\n                            content.setLength(0);\n                            content.append(outputText);\n                        }\n                        AgentFlowUsage usage = usageFromDify(dataObject == null ? null : dataObject.getJSONObject(\"usage\"));\n                        if (usage != null) {\n                            usageRef.set(usage);\n                        }\n                    } else if (\"message\".equals(eventType) || \"text_chunk\".equals(eventType)) {\n                        outputText = payload == null ? null : firstNonBlank(payload.getString(\"answer\"), payload.getString(\"text\"));\n                        if (!isBlank(outputText)) {\n                            content.append(outputText);\n                        }\n                    } else if (\"error\".equals(eventType)) {\n                        throw new AgentFlowException(\"Dify workflow stream error: \" + (payload == null ? data : payload.toJSONString()));\n                    }\n\n                    AgentFlowWorkflowEvent event = AgentFlowWorkflowEvent.builder()\n                            .type(eventType)\n                            .status(status)\n                            .outputText(outputText)\n                            .outputs(outputs)\n                            .taskId(taskIdRef.get())\n                            .workflowRunId(workflowRunIdRef.get())\n                            .done(done)\n                            .usage(usageRef.get())\n                            .raw(payload == null ? data : payload)\n                            .build();\n                    listener.onEvent(event);\n                    traceEvent(traceContext, event);\n\n                    if (done) {\n                        AgentFlowWorkflowResponse responsePayload = AgentFlowWorkflowResponse.builder()\n                                .status(status)\n                                .outputText(content.toString())\n                                .outputs(outputsRef.get())\n                                .taskId(taskIdRef.get())\n                                .workflowRunId(workflowRunIdRef.get())\n                                .usage(usageRef.get())\n                                .raw(payload)\n                                .build();\n                        completion.set(responsePayload);\n                        listener.onComplete(responsePayload);\n                        traceComplete(traceContext, responsePayload);\n                        closed.set(true);\n                        eventSource.cancel();\n                        latch.countDown();\n                    }\n                } catch (Throwable ex) {\n                    failure.set(ex);\n                    traceError(traceContext, ex);\n                    listener.onError(ex);\n                    closed.set(true);\n                    eventSource.cancel();\n                    latch.countDown();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                if (closed.compareAndSet(false, true)) {\n                    AgentFlowWorkflowResponse responsePayload = completion.get();\n                    if (responsePayload == null) {\n                        responsePayload = AgentFlowWorkflowResponse.builder()\n                                .outputText(content.toString())\n                                .outputs(outputsRef.get())\n                                .taskId(taskIdRef.get())\n                                .workflowRunId(workflowRunIdRef.get())\n                                .usage(usageRef.get())\n                                .build();\n                        completion.set(responsePayload);\n                        listener.onComplete(responsePayload);\n                        traceComplete(traceContext, responsePayload);\n                    }\n                    latch.countDown();\n                }\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                Throwable error = t;\n                if (error == null && response != null) {\n                    error = new AgentFlowException(\"Dify workflow stream failed: HTTP \" + response.code());\n                }\n                if (error == null) {\n                    error = new AgentFlowException(\"Dify workflow stream failed\");\n                }\n                failure.set(error);\n                traceError(traceContext, error);\n                listener.onError(error);\n                closed.set(true);\n                latch.countDown();\n            }\n        });\n\n        if (!latch.await(pollTimeoutMillis(), TimeUnit.MILLISECONDS)) {\n            throw new AgentFlowException(\"Dify workflow stream timed out\");\n        }\n        if (failure.get() != null) {\n            if (failure.get() instanceof Exception) {\n                throw (Exception) failure.get();\n            }\n            throw new AgentFlowException(\"Dify workflow stream failed\", failure.get());\n        }\n    }\n\n    private JSONObject buildRequestBody(AgentFlowWorkflowRequest request, String responseMode) {\n        JSONObject body = new JSONObject();\n        body.put(\"inputs\", request.getInputs() == null ? Collections.emptyMap() : request.getInputs());\n        body.put(\"user\", defaultUserId(request.getUserId()));\n        body.put(\"response_mode\", responseMode);\n        if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) {\n            body.putAll(request.getExtraBody());\n        }\n        return body;\n    }\n\n    private AgentFlowWorkflowResponse mapWorkflowResponse(JSONObject response) {\n        JSONObject data = response.getJSONObject(\"data\");\n        JSONObject outputs = data == null ? null : data.getJSONObject(\"outputs\");\n        Map<String, Object> outputMap = outputs == null\n                ? Collections.<String, Object>emptyMap()\n                : new LinkedHashMap<String, Object>(outputs);\n        return AgentFlowWorkflowResponse.builder()\n                .status(data == null ? null : data.getString(\"status\"))\n                .outputText(extractText(outputs))\n                .outputs(outputMap)\n                .taskId(response.getString(\"task_id\"))\n                .workflowRunId(firstNonBlank(response.getString(\"workflow_run_id\"), data == null ? null : data.getString(\"id\")))\n                .usage(usageFromDify(data == null ? null : data.getJSONObject(\"usage\")))\n                .raw(response)\n                .build();\n    }\n\n    private JSONObject parseObjectOrNull(String data) {\n        if (isBlank(data)) {\n            return null;\n        }\n        try {\n            Object parsed = JSON.parse(data);\n            return parsed instanceof JSONObject ? (JSONObject) parsed : null;\n        } catch (Exception ex) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow/workflow/N8nAgentFlowWorkflowService.java",
    "content": "package io.github.lnyocly.ai4j.agentflow.workflow;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowConfig;\nimport io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.Request;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class N8nAgentFlowWorkflowService extends AgentFlowSupport implements AgentFlowWorkflowService {\n\n    public N8nAgentFlowWorkflowService(Configuration configuration, AgentFlowConfig agentFlowConfig) {\n        super(configuration, agentFlowConfig);\n    }\n\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public AgentFlowWorkflowResponse run(AgentFlowWorkflowRequest request) throws Exception {\n        AgentFlowTraceContext traceContext = startTrace(\"workflow\", false, request);\n        try {\n            Map<String, Object> payload = new LinkedHashMap<String, Object>();\n            if (request.getInputs() != null) {\n                payload.putAll(request.getInputs());\n            }\n            if (request.getMetadata() != null && !request.getMetadata().isEmpty()) {\n                payload.put(\"_metadata\", request.getMetadata());\n            }\n            if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) {\n                payload.putAll(request.getExtraBody());\n            }\n\n            Request httpRequest = jsonRequestBuilder(requireWebhookUrl()).post(jsonBody(payload)).build();\n            String responseBody = execute(httpRequest);\n            Object raw = parseJsonOrText(responseBody);\n            Map<String, Object> outputs = raw instanceof Map\n                    ? new LinkedHashMap<String, Object>((Map<String, Object>) raw)\n                    : Collections.<String, Object>emptyMap();\n\n            AgentFlowWorkflowResponse workflowResponse = AgentFlowWorkflowResponse.builder()\n                    .status(\"completed\")\n                    .outputText(extractText(raw))\n                    .outputs(outputs)\n                    .raw(raw)\n                    .build();\n            traceComplete(traceContext, workflowResponse);\n            return workflowResponse;\n        } catch (Exception ex) {\n            traceError(traceContext, ex);\n            throw ex;\n        }\n    }\n\n    @Override\n    public void runStream(AgentFlowWorkflowRequest request, AgentFlowWorkflowListener listener) {\n        throw new UnsupportedOperationException(\"n8n workflow streaming is not supported yet\");\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/annotation/FunctionCall.java",
    "content": "package io.github.lnyocly.ai4j.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/12 15:50\n */\n@Retention(RetentionPolicy.RUNTIME)\n@Target(ElementType.TYPE)\npublic @interface FunctionCall {\n    String name();\n    String description();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/annotation/FunctionParameter.java",
    "content": "package io.github.lnyocly.ai4j.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/12 15:55\n */\n@Retention(RetentionPolicy.RUNTIME)\n@Target(ElementType.FIELD)\npublic @interface FunctionParameter {\n    String description();\n    boolean required() default true;\n}"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/annotation/FunctionRequest.java",
    "content": "package io.github.lnyocly.ai4j.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/12 15:55\n */\n@Retention(RetentionPolicy.RUNTIME)\n@Target(ElementType.TYPE)\npublic @interface FunctionRequest {\n    String description() default \"\";\n}"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/auth/BearerTokenUtils.java",
    "content": "package io.github.lnyocly.ai4j.auth;\n\nimport com.auth0.jwt.JWT;\nimport com.auth0.jwt.algorithms.Algorithm;\nimport com.google.common.cache.Cache;\nimport com.google.common.cache.CacheBuilder;\n\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport javax.xml.bind.DatatypeConverter;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.text.SimpleDateFormat;\nimport java.util.Date;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.TimeZone;\nimport java.util.concurrent.TimeUnit;\n\n\npublic class BearerTokenUtils {\n    // 过期时间；默认24小时\n    private static final long EXPIRE_MILLIS = 24 * 60 * 60 * 1000L;\n\n    // 缓存服务\n    public static Cache<String, String> cache = CacheBuilder.newBuilder()\n            .initialCapacity(100)\n            .expireAfterWrite(EXPIRE_MILLIS - (60 * 1000L), TimeUnit.MILLISECONDS)\n            .build();\n\n    /**\n     * 对 API Key 进行签名\n     * 新版机制中平台颁发的 API Key 同时包含 “用户标识 id” 和 “签名密钥 secret”，即格式为 {id}.{secret}\n     *\n     * @param apiKey 智谱APIkey\n     * @return Token\n     */\n    public static String getToken(String apiKey) {\n        // 分割APIKEY\n        String[] args = apiKey.split(\"\\\\.\");\n        if (args.length != 2) {\n            throw new IllegalArgumentException(\"API Key 格式错误\");\n        }\n        String id = args[0];\n        String secret = args[1];\n        // 缓存Token\n        String token = cache.getIfPresent(apiKey);\n        if (null != token) return token;\n        // 创建Token\n        Algorithm algorithm = Algorithm.HMAC256(secret.getBytes(StandardCharsets.UTF_8));\n        Map<String, Object> payload = new HashMap<>();\n        payload.put(\"api_key\", id);\n        payload.put(\"exp\", System.currentTimeMillis() + EXPIRE_MILLIS);\n        payload.put(\"timestamp\", System.currentTimeMillis());\n        Map<String, Object> headerClaims = new HashMap<>();\n        headerClaims.put(\"alg\", \"HS256\");\n        headerClaims.put(\"sign_type\", \"SIGN\");\n        token = JWT.create().withPayload(payload).withHeader(headerClaims).sign(algorithm);\n        cache.put(id, token);\n        return token;\n    }\n\n    /**\n     * apiKey 属于SecretId与SecretKey的拼接，格式为 {SecretId}.{SecretKey}\n     *\n     * @param apiKey 腾讯混元APIkey\n     * @return\n     */\n    public static String getAuthorization(String apiKey, String action, String payloadJson) throws Exception {\n        String[] args = apiKey.split(\"\\\\.\");\n        if (args.length != 2) {\n            throw new IllegalArgumentException(\"API Key 格式错误\");\n        }\n        String id = args[0];\n        String key = args[1];\n\n        String algorithm = \"TC3-HMAC-SHA256\";\n        String service = \"hunyuan\";\n        String host = \"hunyuan.tencentcloudapi.com\";\n        String timestamp = String.valueOf(System.currentTimeMillis() / 1000);\n        SimpleDateFormat sdf = new SimpleDateFormat(\"yyyy-MM-dd\");\n        // 注意时区，否则容易出错\n        sdf.setTimeZone(TimeZone.getTimeZone(\"UTC\"));\n        String date = sdf.format(new Date(Long.valueOf(timestamp + \"000\")));\n\n        // ************* 步骤 1：拼接规范请求串 *************\n        String httpRequestMethod = \"POST\";\n        String canonicalUri = \"/\";\n        String canonicalQueryString = \"\";\n        String canonicalHeaders = \"content-type:application/json; charset=utf-8\\n\" + \"host:\" + host + \"\\n\" + \"x-tc-action:\" + action.toLowerCase() + \"\\n\";\n        String signedHeaders = \"content-type;host;x-tc-action\";\n        String hashedRequestPayload = sha256Hex(payloadJson);\n\n        String canonicalRequest = httpRequestMethod + \"\\n\" + canonicalUri + \"\\n\" + canonicalQueryString + \"\\n\"\n                + canonicalHeaders + \"\\n\" + signedHeaders + \"\\n\" + hashedRequestPayload;\n\n        // ************* 步骤 2：拼接待签名字符串 *************\n        String credentialScope = date + \"/\" + service + \"/\" + \"tc3_request\";\n        String hashedCanonicalRequest = sha256Hex(canonicalRequest);\n\n        String stringToSign = algorithm + \"\\n\" + timestamp + \"\\n\" + credentialScope + \"\\n\" + hashedCanonicalRequest;\n\n        // ************* 步骤 3：计算签名 *************\n        byte[] secretDate = hmac256((\"TC3\" + key).getBytes(StandardCharsets.UTF_8), date);\n        byte[] secretService = hmac256(secretDate, service);\n        byte[] secretSigning = hmac256(secretService, \"tc3_request\");\n\n        String signature = DatatypeConverter.printHexBinary(hmac256(secretSigning, stringToSign)).toLowerCase();\n\n        // ************* 步骤 4：拼接 Authorization *************\n        String authorization = algorithm + \" \" + \"Credential=\" + id + \"/\" + credentialScope + \", \"\n                + \"SignedHeaders=\" + signedHeaders + \", \" + \"Signature=\" + signature;\n        return authorization;\n    }\n\n    private static byte[] hmac256(byte[] key, String msg) throws Exception {\n        Mac mac = Mac.getInstance(\"HmacSHA256\");\n        SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm());\n        mac.init(secretKeySpec);\n        return mac.doFinal(msg.getBytes(StandardCharsets.UTF_8));\n    }\n\n    private static String sha256Hex(String s) throws Exception {\n        MessageDigest md = MessageDigest.getInstance(\"SHA-256\");\n        byte[] d = md.digest(s.getBytes(StandardCharsets.UTF_8));\n        return DatatypeConverter.printHexBinary(d).toLowerCase();\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/AiPlatform.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.Data;\n\n@Data\npublic class AiPlatform {\n    private String id;\n    private String platform;\n    private String apiHost;\n    private String apiKey;\n    private String chatCompletionUrl;\n    private String embeddingUrl;\n    private String speechUrl;\n    private String transcriptionUrl;\n    private String translationUrl;\n    private String realtimeUrl;\n    private String rerankApiHost;\n    private String rerankUrl;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/BaichuanConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class BaichuanConfig {\n\n    private String apiHost = \"https://api.baichuan-ai.com/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/chat/completions\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/DashScopeConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description OpenAi骞冲彴閰嶇疆鏂囦欢淇℃伅\n * @Date 2024/8/8 0:18\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class DashScopeConfig {\n    private String apiHost = \"https://dashscope.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1/\";\n    private String responsesUrl = \"responses\";\n    private String apiKey = \"\";\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/DeepSeekConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description DeepSeek 配置文件\n * @Date 2024/8/29 10:31\n */\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class DeepSeekConfig {\n\n    private String apiHost = \"https://api.deepseek.com/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"chat/completions\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/DoubaoConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 璞嗗寘(鐏北寮曟搸鏂硅垷) 閰嶇疆鏂囦欢\n */\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class DoubaoConfig {\n\n    private String apiHost = \"https://ark.cn-beijing.volces.com/api/v3/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"chat/completions\";\n    private String imageGenerationUrl = \"images/generations\";\n    private String responsesUrl = \"responses\";\n    private String rerankApiHost = \"https://api-knowledgebase.mlp.cn-beijing.volces.com/\";\n    private String rerankUrl = \"api/knowledge/service/rerank\";\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/HunyuanConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 腾讯混元配置\n * @Date 2024/8/30 19:50\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class HunyuanConfig {\n    private String apiHost = \"https://hunyuan.tencentcloudapi.com/\";\n    /**\n     * apiKey 属于SecretId与SecretKey的拼接，格式为 {SecretId}.{SecretKey}\n     */\n    private String apiKey = \"\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/JinaConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class JinaConfig {\n\n    private String apiHost = \"https://api.jina.ai/\";\n\n    private String apiKey = \"\";\n\n    private String rerankUrl = \"v1/rerank\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/LingyiConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 零一万物大模型\n * @Date 2024/9/9 22:53\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class LingyiConfig {\n    private String apiHost = \"https://api.lingyiwanwu.com/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/chat/completions\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/McpConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.Data;\n\n/**\n * @Author cly\n * @Description MCP (Model Context Protocol) 配置类\n */\n@Data\npublic class McpConfig {\n    \n    /**\n     * MCP服务器地址\n     */\n    private String serverUrl;\n    \n    /**\n     * MCP服务器端口\n     */\n    private Integer serverPort = 3000;\n    \n    /**\n     * 连接超时时间（毫秒）\n     */\n    private Long connectTimeout = 30000L;\n    \n    /**\n     * 读取超时时间（毫秒）\n     */\n    private Long readTimeout = 60000L;\n    \n    /**\n     * 写入超时时间（毫秒）\n     */\n    private Long writeTimeout = 60000L;\n    \n    /**\n     * 是否启用SSL\n     */\n    private Boolean enableSsl = false;\n    \n    /**\n     * 认证令牌\n     */\n    private String authToken;\n    \n    /**\n     * 客户端名称\n     */\n    private String clientName = \"ai4j-mcp-client\";\n    \n    /**\n     * 客户端版本\n     */\n    private String clientVersion = \"1.0.0\";\n    \n    /**\n     * 最大重连次数\n     */\n    private Integer maxRetries = 3;\n    \n    /**\n     * 重连间隔（毫秒）\n     */\n    private Long retryInterval = 5000L;\n    \n    /**\n     * 是否启用心跳检测\n     */\n    private Boolean enableHeartbeat = true;\n    \n    /**\n     * 心跳间隔（毫秒）\n     */\n    private Long heartbeatInterval = 30000L;\n    \n    /**\n     * 消息队列大小\n     */\n    private Integer messageQueueSize = 1000;\n    \n    /**\n     * 是否启用消息压缩\n     */\n    private Boolean enableCompression = false;\n    \n    /**\n     * 传输类型：stdio, http, websocket\n     */\n    private String transportType = \"http\";\n    \n    /**\n     * 获取完整的服务器URL\n     */\n    public String getFullServerUrl() {\n        if (serverUrl == null) {\n            return null;\n        }\n        \n        String protocol = enableSsl ? \"https\" : \"http\";\n        if (serverUrl.startsWith(\"http://\") || serverUrl.startsWith(\"https://\")) {\n            return serverUrl;\n        }\n        \n        return String.format(\"%s://%s:%d\", protocol, serverUrl, serverPort);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/MilvusConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Arrays;\nimport java.util.List;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class MilvusConfig {\n\n    private boolean enabled = false;\n\n    private String host = \"http://localhost:19530\";\n\n    private String token = \"\";\n\n    private String dbName = \"\";\n\n    private String partitionName = \"\";\n\n    private String idField = \"id\";\n\n    private String vectorField = \"vector\";\n\n    private String contentField = \"content\";\n\n    private List<String> outputFields = Arrays.asList(\n            \"id\",\n            \"content\",\n            \"documentId\",\n            \"sourceName\",\n            \"sourcePath\",\n            \"sourceUri\",\n            \"pageNumber\",\n            \"sectionTitle\",\n            \"chunkIndex\"\n    );\n\n    private String upsert = \"/v2/vectordb/entities/upsert\";\n\n    private String search = \"/v2/vectordb/entities/search\";\n\n    private String delete = \"/v2/vectordb/entities/delete\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/MinimaxConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author : isxuwl\n * @Date: 2024/10/15 16:08\n * @Model Description:\n * @Description:\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class MinimaxConfig {\n    private String apiHost = \"https://api.minimax.chat/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/text/chatcompletion_v2\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/MoonshotConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 月之暗面配置\n * @Date 2024/8/29 23:00\n */\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class MoonshotConfig {\n    private String apiHost = \"https://api.moonshot.cn/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/chat/completions\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/OkHttpConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport okhttp3.logging.HttpLoggingInterceptor;\n\n/**\n * @Author cly\n * @Description OkHttp配置信息\n * @Date 2024/8/11 0:11\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class OkHttpConfig {\n\n    private HttpLoggingInterceptor.Level log = HttpLoggingInterceptor.Level.HEADERS;\n    private int connectTimeout = 300;\n    private int writeTimeout = 300;\n    private int readTimeout = 300;\n    private int proxyPort = 10809;\n    private String proxyHost = \"\";\n\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/OllamaConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description Ollama配置文件\n * @Date 2024/9/20 11:07\n */\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class OllamaConfig {\n    private String apiHost = \"http://localhost:11434/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"api/chat\";\n    private String embeddingUrl = \"api/embed\";\n    private String rerankUrl = \"api/rerank\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/OpenAiConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description OpenAi骞冲彴閰嶇疆鏂囦欢淇℃伅\n * @Date 2024/8/8 0:18\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class OpenAiConfig {\n    private String apiHost = \"https://api.openai.com/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/chat/completions\";\n    private String embeddingUrl = \"v1/embeddings\";\n    private String speechUrl = \"v1/audio/speech\";\n    private String transcriptionUrl = \"v1/audio/transcriptions\";\n    private String translationUrl = \"v1/audio/translations\";\n    private String realtimeUrl = \"v1/realtime\";\n    private String imageGenerationUrl = \"v1/images/generations\";\n    private String responsesUrl = \"v1/responses\";\n\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/PgVectorConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class PgVectorConfig {\n\n    private boolean enabled = false;\n\n    private String jdbcUrl = \"jdbc:postgresql://localhost:5432/postgres\";\n\n    private String username = \"\";\n\n    private String password = \"\";\n\n    private String tableName = \"ai4j_vectors\";\n\n    private String idColumn = \"id\";\n\n    private String datasetColumn = \"dataset\";\n\n    private String vectorColumn = \"embedding\";\n\n    private String contentColumn = \"content\";\n\n    private String metadataColumn = \"metadata\";\n\n    private String distanceOperator = \"<=>\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/PineconeConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description Pinecone向量数据配置文件\n * @Date 2024/8/16 16:37\n */\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class PineconeConfig {\n    private String host = \"https://xxx.svc.xxx.pinecone.io\";\n    private String key = \"\";\n\n    private String upsert = \"/vectors/upsert\";\n    private String query = \"/query\";\n    private String delete = \"/vectors/delete\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/QdrantConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class QdrantConfig {\n\n    private boolean enabled = false;\n\n    private String host = \"http://localhost:6333\";\n\n    private String apiKey = \"\";\n\n    private String vectorName = \"\";\n\n    private String upsert = \"/collections/%s/points\";\n\n    private String query = \"/collections/%s/points/query\";\n\n    private String delete = \"/collections/%s/points/delete\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/config/ZhipuConfig.java",
    "content": "package io.github.lnyocly.ai4j.config;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 智谱AI平台配置信息\n * @Date 2024/8/27 22:12\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ZhipuConfig {\n\n    private String apiHost = \"https://open.bigmodel.cn/api/paas/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v4/chat/completions\";\n    private String embeddingUrl= \"v4/embeddings\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/constant/Constants.java",
    "content": "package io.github.lnyocly.ai4j.constant;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/11 0:19\n */\npublic class Constants {\n    public static final String SSE_CONTENT_TYPE = \"text/event-stream\";\n    public static final String DEFAULT_USER_AGENT = \"Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)\";\n    public static final String APPLICATION_JSON = \"application/json\";\n    public static final String JSON_CONTENT_TYPE = APPLICATION_JSON + \"; charset=utf-8\";\n    public static final String METADATA_KEY = \"content\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/convert/audio/AudioParameterConvert.java",
    "content": "package io.github.lnyocly.ai4j.convert.audio;\n\n/**\n * @Author cly\n * @Description 处理请求参数。 由统一的OpenAi音频格式--->其它模型格式\n * @Date 2024/10/12 13:36\n */\npublic interface AudioParameterConvert<T> {\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/convert/audio/AudioResultConvert.java",
    "content": "package io.github.lnyocly.ai4j.convert.audio;\n\n/**\n * @Author cly\n * @Description 返回结果统一处理。 其它模型音频返回格式--->统一的OpenAi返回格式\n * @Date 2024/10/12 13:35\n */\npublic interface AudioResultConvert<T> {\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/convert/chat/ParameterConvert.java",
    "content": "package io.github.lnyocly.ai4j.convert.chat;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\n\n/**\n * @Author cly\n * @Description 处理请求参数 统一的OpenAi格式--->其它模型格式\n * @Date 2024/8/12 1:04\n */\npublic interface ParameterConvert<T> {\n    T convertChatCompletionObject(ChatCompletion chatCompletion);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/convert/chat/ResultConvert.java",
    "content": "package io.github.lnyocly.ai4j.convert.chat;\n\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport okhttp3.sse.EventSourceListener;\n\n/**\n * @Author cly\n * @Description 处理结果输出 其它模型格式--->统一的OpenAi格式\n * @Date 2024/8/12 1:05\n */\npublic interface ResultConvert<T> {\n    EventSourceListener convertEventSource(SseListener eventSourceListener);\n    ChatCompletionResponse convertChatCompletionResponse(T t);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/convert/embedding/EmbeddingParameterConvert.java",
    "content": "package io.github.lnyocly.ai4j.convert.embedding;\n\n\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\n\n/**\n * EmbeddingParameterConvert\n * @param <T>\n */\npublic interface EmbeddingParameterConvert<T> {\n    T convertEmbeddingRequest(Embedding embeddingRequest);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/convert/embedding/EmbeddingResultConvert.java",
    "content": "package io.github.lnyocly.ai4j.convert.embedding;\n\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\n\n\n/**\n * @Author cly\n * @param <T>\n */\npublic interface EmbeddingResultConvert<T> {\n    EmbeddingResponse convertEmbeddingResponse(T t);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/document/RecursiveCharacterTextSplitter.java",
    "content": "package io.github.lnyocly.ai4j.document;\n\nimport lombok.extern.slf4j.Slf4j;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 分词器\n * @Date 2024/8/2 22:59\n */\n@Slf4j\npublic class RecursiveCharacterTextSplitter {\n    private List<String> separators;\n    private int chunkSize = 500;\n    private int chunkOverlap = 50;\n\n    // 构造函数，接受分隔符列表、块大小和块重叠作为参数\n    public RecursiveCharacterTextSplitter(List<String> separators, int chunkSize, int chunkOverlap) {\n        // 如果分隔符列表为null，则使用默认值\n        if (separators == null) {\n            this.separators = Arrays.asList(\"\\n\\n\", \"\\n\", \" \", \"\");\n        } else {\n            this.separators = separators;\n        }\n        this.chunkSize = chunkSize;\n        this.chunkOverlap = chunkOverlap;\n    }\n\n    public RecursiveCharacterTextSplitter(int chunkSize, int chunkOverlap) {\n        this.separators = Arrays.asList(\"\\n\\n\", \"\\n\", \" \", \"\");\n        this.chunkSize = chunkSize;\n        this.chunkOverlap = chunkOverlap;\n    }\n\n    // 将文本分割成块的方法\n    public List<String> splitText(String text) {\n        // 声明一个空的字符串列表，用于存储最终的文本块\n        List<String> finalChunks = new ArrayList<>();\n        String separator = separators.get(separators.size() - 1);\n\n        // 循环遍历分隔符列表，找到可以在文本中找到的最合适的分隔符\n        for (String s : separators) {\n            if (text.contains(s) || s.isEmpty()) {\n                separator = s;\n                break;\n            }\n        }\n\n        List<String> splits = Arrays.asList(text.split(separator));\n\n        // 声明一个空的字符串列表，用于存储长度小于块大小的子字符串\n        List<String> goodSplits = new ArrayList<>();\n        // 循环遍历子字符串列表，将较短的子字符串添加到goodSplits列表中，将较长的子字符串递归地传递给splitText方法\n        for (String s : splits) {\n            if (s.length() < chunkSize) {\n                goodSplits.add(s);\n            } else {\n                if (!goodSplits.isEmpty()) {\n                    // 将goodSplits列表中的子字符串合并为一个文本块，并将其添加到最终的文本块列表中\n                    List<String> mergedText = mergeSplits(goodSplits, separator);\n                    finalChunks.addAll(mergedText);\n                    goodSplits.clear();\n                }\n                // 递归地将较长的子字符串传递给splitText方法\n                List<String> otherInfo = splitText(s);\n                finalChunks.addAll(otherInfo);\n            }\n        }\n\n        if (!goodSplits.isEmpty()) {\n            List<String> mergedText = mergeSplits(goodSplits, separator);\n            finalChunks.addAll(mergedText);\n        }\n\n        return finalChunks;\n    }\n\n    private List<String> mergeSplits(List<String> splits, String separator) {\n        int separatorLen = separator.length();\n\n        List<String> docs = new ArrayList<>();\n        List<String> currentDoc = new ArrayList<>();\n        int total = 0;\n\n        for (String d : splits) {\n            int len = d.length();\n            if (total + len + (separatorLen > 0 && !currentDoc.isEmpty() ? separatorLen : 0) > chunkSize) {\n                if (total > chunkSize) {\n                    log.warn(\"Warning: Created a chunk of size {}, which is longer than the specified {}\", total, chunkSize);\n                }\n                if (!currentDoc.isEmpty()) {\n                    String doc = joinDocs(currentDoc, separator);\n                    if (doc != null) {\n                        docs.add(doc);\n                    }\n                    // 通过移除currentDoc中的文档，将currentDoc的长度减小到指定的文档重叠长度chunkOverlap或更小, 结果存到下一个chunk的开始位置\n                    while (total > chunkOverlap || (total + len + (separatorLen > 0 && !currentDoc.isEmpty() ? separatorLen : 0) > chunkSize && total > 0)) {\n                        total -= currentDoc.get(0).length() + (separatorLen > 0 && currentDoc.size() > 1 ? separatorLen : 0);\n                        currentDoc.remove(0);\n                    }\n                }\n            }\n            currentDoc.add(d);\n            total += len + (separatorLen > 0 && currentDoc.size() > 1 ? separatorLen : 0);\n        }\n\n        String doc = joinDocs(currentDoc, separator);\n        if (doc != null) {\n            docs.add(doc);\n        }\n\n        return docs;\n    }\n\n    private String joinDocs(List<String> docs, String separator) {\n        if (docs.isEmpty()) {\n            return null;\n        }\n        StringBuilder sb = new StringBuilder();\n        for (int i = 0; i < docs.size(); i++) {\n            sb.append(docs.get(i));\n            if (i < docs.size() - 1) {\n                sb.append(separator);\n            }\n        }\n        return sb.toString();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/document/TikaUtil.java",
    "content": "package io.github.lnyocly.ai4j.document;\n\nimport org.apache.tika.Tika;\nimport org.apache.tika.exception.TikaException;\nimport org.apache.tika.metadata.Metadata;\nimport org.apache.tika.parser.AutoDetectParser;\nimport org.apache.tika.parser.ParseContext;\nimport org.apache.tika.sax.BodyContentHandler;\nimport org.xml.sax.SAXException;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.io.InputStream;\n\npublic class TikaUtil {\n\n    private static final Tika tika = new Tika();\n\n    /**\n     * 解析File文件，返回文档内容\n     * @param file 要解析的文件\n     * @return 解析后的文档内容\n     * @throws IOException\n     * @throws TikaException\n     * @throws SAXException\n     */\n    public static String parseFile(File file) throws IOException, TikaException, SAXException {\n        try (InputStream stream = file.toURI().toURL().openStream()) {\n            return parseInputStream(stream);\n        }\n    }\n\n    /**\n     * 解析InputStream输入流，返回文档内容\n     * @param stream 要解析的输入流\n     * @return 解析后的文档内容\n     * @throws IOException\n     * @throws TikaException\n     * @throws SAXException\n     */\n    public static String parseInputStream(InputStream stream) throws IOException, TikaException, SAXException {\n        BodyContentHandler handler = new BodyContentHandler();\n        Metadata metadata = new Metadata();\n        AutoDetectParser parser = new AutoDetectParser();\n        ParseContext context = new ParseContext();\n\n        parser.parse(stream, handler, metadata, context);\n        return handler.toString();\n    }\n\n    /**\n     * 使用Tika简单接口解析文件，返回文档内容\n     * @param file 要解析的文件\n     * @return 解析后的文档内容\n     * @throws IOException\n     * @throws TikaException\n     */\n    public static String parseFileWithTika(File file) throws IOException, TikaException {\n        return tika.parseToString(file);\n    }\n\n    /**\n     * 解析InputStream输入流，使用Tika简单接口，返回文档内容\n     * @param stream 要解析的输入流\n     * @return 解析后的文档内容\n     * @throws IOException\n     * @throws TikaException\n     */\n    public static String parseInputStreamWithTika(InputStream stream) throws IOException, TikaException {\n        return tika.parseToString(stream);\n    }\n\n    /**\n     * 检测File文件的MIME类型\n     * @param file 要检测的文件\n     * @return MIME类型\n     * @throws IOException\n     */\n    public static String detectMimeType(File file) throws IOException {\n        return tika.detect(file);\n    }\n\n    /**\n     * 检测InputStream输入流的MIME类型\n     * @param stream 要检测的输入流\n     * @return MIME类型\n     * @throws IOException\n     */\n    public static String detectMimeType(InputStream stream) throws IOException {\n        return tika.detect(stream);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/Ai4jException.java",
    "content": "package io.github.lnyocly.ai4j.exception;\n\n/**\n * Base runtime exception for ai4j shared abstractions.\n */\npublic class Ai4jException extends RuntimeException {\n\n    public Ai4jException(String message) {\n        super(message);\n    }\n\n    public Ai4jException(String message, Throwable cause) {\n        super(message, cause);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/CommonException.java",
    "content": "package io.github.lnyocly.ai4j.exception;\n\n/**\n * Legacy exception kept for 1.x compatibility.\n */\npublic class CommonException extends Ai4jException {\n\n    public CommonException(String msg) {\n        super(msg);\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/AbstractErrorHandler.java",
    "content": "package io.github.lnyocly.ai4j.exception.chain;\n\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\n\n/**\n * @Author cly\n * @Description 错误处理抽象\n * @Date 2024/9/18 20:57\n */\npublic abstract class AbstractErrorHandler implements IErrorHandler{\n    protected IErrorHandler nextHandler;\n\n    @Override\n    public void setNext(IErrorHandler handler) {\n        this.nextHandler = handler;\n    }\n\n    protected Error handleNext(String errorInfo) {\n        if (nextHandler != null) {\n            return nextHandler.parseError(errorInfo);\n        }\n        return null;\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/ErrorHandler.java",
    "content": "package io.github.lnyocly.ai4j.exception.chain;\n\nimport io.github.lnyocly.ai4j.exception.chain.impl.HunyuanErrorHandler;\nimport io.github.lnyocly.ai4j.exception.chain.impl.OpenAiErrorHandler;\nimport io.github.lnyocly.ai4j.exception.chain.impl.UnknownErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 创建错误处理的单例\n * @Date 2024/9/18 21:09\n */\npublic class ErrorHandler {\n    private List<IErrorHandler> handlers;\n    private IErrorHandler chain;\n\n    private ErrorHandler() {\n        handlers = new ArrayList<>();\n        // 添加错误处理器\n        handlers.add(new OpenAiErrorHandler());\n        handlers.add(new HunyuanErrorHandler());\n\n        // 兜底的错误处理\n        handlers.add(new UnknownErrorHandler());\n\n        // 组装链\n        this.assembleChain();\n    }\n\n    private void assembleChain(){\n        chain = handlers.get(0);\n        IErrorHandler curr = handlers.get(0);\n        for (int i = 1; i < handlers.size(); i++) {\n            curr.setNext(handlers.get(i));\n            curr = handlers.get(i);\n        }\n    }\n\n    private static class ErrorHandlerHolder {\n        private static final ErrorHandler INSTANCE = new ErrorHandler();\n    }\n\n    public static ErrorHandler getInstance() {\n        return ErrorHandlerHolder.INSTANCE;\n    }\n\n    public Error process(String errorSring){\n        return chain.parseError(errorSring);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/IErrorHandler.java",
    "content": "package io.github.lnyocly.ai4j.exception.chain;\n\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\n\n/**\n * @Author cly\n * @Description 错误处理接口\n * @Date 2024/9/18 20:55\n */\npublic interface IErrorHandler {\n    void setNext(IErrorHandler handler);\n    Error parseError(String errorInfo);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/impl/HunyuanErrorHandler.java",
    "content": "package io.github.lnyocly.ai4j.exception.chain.impl;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.exception.chain.AbstractErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.HunyuanError;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\nimport org.apache.commons.lang3.ObjectUtils;\n\n/**\n * @Author cly\n * @Description 混元错误处理\n * @Date 2024/9/18 23:59\n */\npublic class HunyuanErrorHandler extends AbstractErrorHandler {\n    @Override\n    public Error parseError(String errorInfo) {\n        // 解析json字符串\n        try{\n            HunyuanError hunyuanError = JSON.parseObject(errorInfo, HunyuanError.class);\n\n            HunyuanError.Response response = hunyuanError.getResponse();\n\n            if(ObjectUtils.isEmpty(response)){\n                // 交给下一个节点处理\n                return nextHandler.parseError(errorInfo);\n            }\n\n            HunyuanError.Response.Error error = response.getError();\n            if(ObjectUtils.isEmpty(error)){\n                // 交给下一个节点处理\n                return nextHandler.parseError(errorInfo);\n            }\n\n            return new Error(error.getMessage(),error.getCode(),null,error.getCode());\n        }catch (Exception e){\n            throw new CommonException(errorInfo);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/impl/OpenAiErrorHandler.java",
    "content": "package io.github.lnyocly.ai4j.exception.chain.impl;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.exception.chain.AbstractErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\nimport org.apache.commons.lang3.ObjectUtils;\n\n/**\n * @Author cly\n * @Description OpenAi错误处理\n *\n * [openai, zhipu, deepseek, lingyi, moonshot] 错误返回类似，这里共用一个处理类\n *\n * @Date 2024/9/18 21:01\n */\npublic class OpenAiErrorHandler extends AbstractErrorHandler {\n\n    @Override\n    public Error parseError(String errorInfo) {\n        // 解析json字符串\n        try{\n            OpenAiError openAiError = JSON.parseObject(errorInfo, OpenAiError.class);\n\n            Error error = openAiError.getError();\n            if(ObjectUtils.isEmpty(error)){\n                // 交给下一个节点处理\n                return nextHandler.parseError(errorInfo);\n            }\n            return error;\n        }catch (Exception e){\n            throw new CommonException(errorInfo);\n        }\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/chain/impl/UnknownErrorHandler.java",
    "content": "package io.github.lnyocly.ai4j.exception.chain.impl;\n\nimport io.github.lnyocly.ai4j.exception.chain.AbstractErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\n\n/**\n * @Author cly\n * @Description 未知的错误处理，用于兜底处理\n * @Date 2024/9/18 21:08\n */\npublic class UnknownErrorHandler extends AbstractErrorHandler {\n    @Override\n    public Error parseError(String errorInfo) {\n        Error error = new Error();\n\n        error.setParam(null);\n        error.setType(\"Unknown Type\");\n        error.setCode(\"Unknown Code\");\n        error.setMessage(errorInfo);\n\n        return error;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/error/Error.java",
    "content": "package io.github.lnyocly.ai4j.exception.error;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 基础错误实体\n * @Date 2024/9/18 23:50\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\npublic class Error {\n    private String message;\n    private String type;\n    private String param;\n    private String code;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/error/HunyuanError.java",
    "content": "package io.github.lnyocly.ai4j.exception.error;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 腾讯混元错误实体\n *\n * {\"Response\":{\"RequestId\":\"e4650694-f018-4490-b4d0-d5242cd68106\",\"Error\":{\"Code\":\"InvalidParameterValue.Model\",\"Message\":\"模型不存在\"}}}\n *\n * @Date 2024/9/18 21:28\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\npublic class HunyuanError {\n    private Response Response;\n\n    @Data\n    @AllArgsConstructor\n    @NoArgsConstructor\n    public class Response{\n        private Error Error;\n\n        @Data\n        @AllArgsConstructor\n        @NoArgsConstructor\n        public class Error{\n            private String Code;\n            private String Message;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/exception/error/OpenAiError.java",
    "content": "package io.github.lnyocly.ai4j.exception.error;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.Getter;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description OpenAi错误返回实体类\n *\n * {\n *     \"error\": {\n *         \"message\": \"Incorrect API key provided: sk-proj-*************************************************************************************************************************8YA1. You can find your API key at https://platform.openai.com/account/api-keys.\",\n *         \"type\": \"invalid_request_error\",\n *         \"param\": null,\n *         \"code\": \"invalid_api_key\"\n *     }\n * }\n *\n * @Date 2024/9/18 18:44\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\npublic class OpenAiError {\n    private Error error;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/interceptor/ContentTypeInterceptor.java",
    "content": "package io.github.lnyocly.ai4j.interceptor;\n\nimport okhttp3.Interceptor;\nimport okhttp3.MediaType;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport okio.Buffer;\nimport okio.BufferedSource;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/9/20 18:56\n */\npublic class ContentTypeInterceptor implements Interceptor {\n\n    private static final MediaType EVENT_STREAM_MEDIA_TYPE = MediaType.get(\"text/event-stream\");\n    private static final String NDJSON_CONTENT_TYPE = \"application/x-ndjson\";\n    private static final String SSE_CONTENT_TYPE = \"text/event-stream\";\n\n    @Override\n    public Response intercept(Chain chain) throws IOException {\n        Response response = chain.proceed(chain.request());\n        if (!isNdjsonResponse(response)) {\n            return response;\n        }\n\n        ResponseBody responseBody = response.body();\n        if (responseBody == null) {\n            return response;\n        }\n\n        return response.newBuilder()\n                .header(\"Content-Type\", SSE_CONTENT_TYPE)\n                .body(ResponseBody.create(toSseBody(readBody(responseBody)), EVENT_STREAM_MEDIA_TYPE))\n                .build();\n    }\n\n    private boolean isNdjsonResponse(Response response) {\n        String contentType = response.header(\"Content-Type\");\n        return contentType != null && contentType.contains(NDJSON_CONTENT_TYPE);\n    }\n\n    private String readBody(ResponseBody responseBody) throws IOException {\n        BufferedSource source = responseBody.source();\n        source.request(Long.MAX_VALUE);\n        Buffer buffer = source.getBuffer();\n        return buffer.clone().readString(StandardCharsets.UTF_8);\n    }\n\n    private String toSseBody(String ndjsonBody) {\n        StringBuilder sseBody = new StringBuilder();\n        String[] ndjsonLines = ndjsonBody.split(\"\\n\");\n        for (String jsonLine : ndjsonLines) {\n            if (!jsonLine.trim().isEmpty()) {\n                sseBody.append(\"data: \").append(jsonLine).append(\"\\n\\n\");\n            }\n        }\n        return sseBody.toString();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/interceptor/ErrorInterceptor.java",
    "content": "package io.github.lnyocly.ai4j.interceptor;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.exception.chain.ErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.Interceptor;\nimport okhttp3.MediaType;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport org.jetbrains.annotations.NotNull;\nimport okio.Buffer;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\n\n/**\n * Intercepts API responses and raises provider-specific exceptions only when\n * the payload contains structured error objects.\n */\n@Slf4j\npublic class ErrorInterceptor implements Interceptor {\n\n    @NotNull\n    @Override\n    public Response intercept(@NotNull Chain chain) throws IOException {\n        Request original = chain.request();\n        Response response = chain.proceed(original);\n\n        if (isStreamingResponse(response)) {\n            return response;\n        }\n\n        ResponseBody responseBody = response.body();\n        byte[] contentBytes = getResponseBodyBytes(responseBody);\n        String content = new String(contentBytes, StandardCharsets.UTF_8);\n        boolean streamingRequest = isStreamingRequest(original);\n\n        if (!response.isSuccessful() && response.code() != 100 && response.code() != 101) {\n            if (streamingRequest) {\n                return rebuildResponse(response, responseBody, contentBytes);\n            }\n            throw buildCommonException(response.code(), response.message(), content);\n        }\n\n        if (containsStructuredError(content)) {\n            if (streamingRequest) {\n                return rebuildResponse(response, responseBody, contentBytes);\n            }\n            ErrorHandler errorHandler = ErrorHandler.getInstance();\n            Error error = errorHandler.process(content);\n            log.error(\"AI request failed: {}\", error.getMessage());\n            throw new CommonException(error.getMessage());\n        }\n\n        return rebuildResponse(response, responseBody, contentBytes);\n    }\n\n    private CommonException buildCommonException(int code, String message, String payload) {\n        String errorMsg = payload == null ? \"\" : payload;\n        if (errorMsg.trim().isEmpty()) {\n            errorMsg = code + \" \" + message;\n            log.error(\"AI request failed: {}\", errorMsg);\n            return new CommonException(errorMsg);\n        }\n\n        try {\n            JSONObject object = JSON.parseObject(errorMsg);\n            if (object != null) {\n                ErrorHandler errorHandler = ErrorHandler.getInstance();\n                Error error = errorHandler.process(errorMsg);\n                log.error(\"AI request failed: {}\", error.getMessage());\n                return new CommonException(error.getMessage());\n            }\n        } catch (Exception ignored) {\n            // Keep raw payload for unknown providers or non-JSON responses.\n        }\n\n        log.error(\"AI request failed: {}\", errorMsg);\n        return new CommonException(errorMsg);\n    }\n\n    private boolean containsStructuredError(String content) {\n        if (content == null || content.trim().isEmpty()) {\n            return false;\n        }\n\n        JSONObject object;\n        try {\n            object = JSON.parseObject(content);\n        } catch (Exception e) {\n            return false;\n        }\n        if (object == null) {\n            return false;\n        }\n\n        Object openAiError = object.get(\"error\");\n        if (openAiError instanceof JSONObject) {\n            return true;\n        }\n        if (openAiError instanceof String && !((String) openAiError).trim().isEmpty()) {\n            return true;\n        }\n\n        JSONObject hunyuanResponse = object.getJSONObject(\"Response\");\n        if (hunyuanResponse != null) {\n            Object hunyuanError = hunyuanResponse.get(\"Error\");\n            if (hunyuanError instanceof JSONObject) {\n                return true;\n            }\n            if (hunyuanError instanceof String && !((String) hunyuanError).trim().isEmpty()) {\n                return true;\n            }\n        }\n\n        String status = object.getString(\"status\");\n        return \"failed\".equalsIgnoreCase(status);\n    }\n\n    private boolean isStreamingResponse(Response response) {\n        ResponseBody body = response.body();\n        if (body == null) {\n            return false;\n        }\n        MediaType contentType = body.contentType();\n        if (contentType == null) {\n            return false;\n        }\n        String type = contentType.toString();\n        return type.contains(\"text/event-stream\") || type.contains(\"application/x-ndjson\");\n    }\n\n    private boolean isStreamingRequest(Request request) {\n        if (request == null) {\n            return false;\n        }\n        String accept = request.header(\"Accept\");\n        if (accept != null) {\n            String normalizedAccept = accept.toLowerCase();\n            if (normalizedAccept.contains(\"text/event-stream\") || normalizedAccept.contains(\"application/x-ndjson\")) {\n                return true;\n            }\n        }\n        if (request.body() == null) {\n            return false;\n        }\n        try {\n            Buffer buffer = new Buffer();\n            request.body().writeTo(buffer);\n            String body = buffer.readString(StandardCharsets.UTF_8);\n            String normalizedBody = body == null ? \"\" : body.toLowerCase();\n            return normalizedBody.contains(\"\\\"stream\\\":true\") || normalizedBody.contains(\"\\\"stream\\\": true\");\n        } catch (Exception ignored) {\n            return false;\n        }\n    }\n\n    private Response rebuildResponse(Response response, ResponseBody responseBody, byte[] contentBytes) {\n        MediaType contentType = responseBody == null ? null : responseBody.contentType();\n        ResponseBody newBody = ResponseBody.create(contentType, contentBytes);\n        return response.newBuilder().body(newBody).build();\n    }\n\n    private byte[] getResponseBodyBytes(ResponseBody responseBody) throws IOException {\n        if (responseBody == null) {\n            return new byte[0];\n        }\n        return responseBody.bytes();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/listener/AbstractManagedStreamListener.java",
    "content": "package io.github.lnyocly.ai4j.listener;\n\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport lombok.Getter;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\n\nabstract class AbstractManagedStreamListener extends EventSourceListener implements ManagedStreamListener {\n\n    @Getter\n    private volatile CountDownLatch countDownLatch = new CountDownLatch(1);\n\n    @Getter\n    private EventSource eventSource;\n\n    private volatile boolean cancelRequested = false;\n    private volatile Throwable failure;\n    private volatile Response failureResponse;\n    private volatile long openedAtEpochMs;\n    private volatile long lastActivityAtEpochMs;\n    private volatile boolean receivedEvent;\n\n    @Override\n    public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n        attachEventSource(eventSource);\n        cancelRequested = false;\n        openedAtEpochMs = System.currentTimeMillis();\n        lastActivityAtEpochMs = openedAtEpochMs;\n        receivedEvent = false;\n    }\n\n    @Override\n    public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n        attachEventSource(eventSource);\n        if (cancelRequested) {\n            finishAttempt();\n            return;\n        }\n        recordFailure(resolveFailure(t, response), response);\n        finishAttempt();\n    }\n\n    public void cancelStream() {\n        cancelRequested = true;\n        cancelActiveEventSource();\n        finishAttempt();\n    }\n\n    @Override\n    public void awaitCompletion(StreamExecutionOptions options) throws InterruptedException {\n        CountDownLatch latch = countDownLatch;\n        long firstTokenTimeoutMs = options == null ? 0L : options.normalizedFirstTokenTimeoutMs();\n        long idleTimeoutMs = options == null ? 0L : options.normalizedIdleTimeoutMs();\n        if (openedAtEpochMs <= 0L) {\n            long now = System.currentTimeMillis();\n            openedAtEpochMs = now;\n            lastActivityAtEpochMs = now;\n        }\n        while (true) {\n            if (latch.await(100L, TimeUnit.MILLISECONDS)) {\n                return;\n            }\n            if (cancelRequested) {\n                return;\n            }\n            if (Thread.currentThread().isInterrupted()) {\n                cancelStream();\n                throw new InterruptedException(\"Model stream interrupted\");\n            }\n            long now = System.currentTimeMillis();\n            if (!receivedEvent\n                    && firstTokenTimeoutMs > 0L\n                    && openedAtEpochMs > 0L\n                    && now - openedAtEpochMs >= firstTokenTimeoutMs) {\n                recordFailure(new TimeoutException(\n                        \"Timed out waiting for first model stream event after \" + firstTokenTimeoutMs + \" ms\"\n                ));\n                cancelActiveEventSource();\n                finishAttempt(latch);\n                return;\n            }\n            if (receivedEvent\n                    && idleTimeoutMs > 0L\n                    && lastActivityAtEpochMs > 0L\n                    && now - lastActivityAtEpochMs >= idleTimeoutMs) {\n                recordFailure(new TimeoutException(\n                        \"Timed out waiting for model stream activity after \" + idleTimeoutMs + \" ms\"\n                ));\n                cancelActiveEventSource();\n                finishAttempt(latch);\n                return;\n            }\n        }\n    }\n\n    @Override\n    public Throwable getFailure() {\n        return failure;\n    }\n\n    @Override\n    public void recordFailure(Throwable failure) {\n        recordFailure(failure, null);\n    }\n\n    public void dispatchFailure() {\n        Throwable currentFailure = failure;\n        if (currentFailure == null) {\n            return;\n        }\n        Response response = failureResponse;\n        clearFailure();\n        error(currentFailure, response);\n    }\n\n    @Override\n    public void clearFailure() {\n        failure = null;\n        failureResponse = null;\n    }\n\n    @Override\n    public void prepareForRetry() {\n        clearFailure();\n        eventSource = null;\n        cancelRequested = false;\n        openedAtEpochMs = 0L;\n        lastActivityAtEpochMs = 0L;\n        receivedEvent = false;\n        resetRetryState();\n    }\n\n    @Override\n    public boolean hasReceivedEvent() {\n        return receivedEvent;\n    }\n\n    @Override\n    public boolean isCancelRequested() {\n        return cancelRequested;\n    }\n\n    @Override\n    public void onRetrying(Throwable failure, int attempt, int maxAttempts) {\n        retry(failure, attempt, maxAttempts);\n    }\n\n    protected void error(Throwable t, Response response) {\n    }\n\n    protected void retry(Throwable t, int attempt, int maxAttempts) {\n    }\n\n    protected void resetRetryState() {\n    }\n\n    protected Throwable resolveFailure(@Nullable Throwable t, @Nullable Response response) {\n        if (t != null && t.getMessage() != null && !t.getMessage().trim().isEmpty()) {\n            return t;\n        }\n        if (response != null) {\n            String message = (response.code() + \" \" + (response.message() == null ? \"\" : response.message())).trim();\n            if (!message.isEmpty()) {\n                return new CommonException(message);\n            }\n        }\n        return t == null ? new CommonException(\"stream request failed\") : t;\n    }\n\n    protected final void attachEventSource(EventSource eventSource) {\n        this.eventSource = eventSource;\n    }\n\n    protected final void clearCancelRequested() {\n        cancelRequested = false;\n    }\n\n    protected final void markActivity() {\n        long now = System.currentTimeMillis();\n        if (openedAtEpochMs <= 0L) {\n            openedAtEpochMs = now;\n        }\n        lastActivityAtEpochMs = now;\n        receivedEvent = true;\n    }\n\n    protected final void finishAttempt() {\n        finishAttempt(countDownLatch);\n    }\n\n    protected final void finishAttempt(CountDownLatch latch) {\n        CountDownLatch release = advanceLatch(latch);\n        release.countDown();\n    }\n\n    protected final void cancelActiveEventSource() {\n        EventSource activeEventSource = eventSource;\n        if (activeEventSource != null) {\n            try {\n                activeEventSource.cancel();\n            } catch (Exception ignored) {\n                // Best effort cancel so the waiting caller can continue unwinding.\n            }\n        }\n    }\n\n    private void recordFailure(Throwable failure, Response response) {\n        this.failure = failure;\n        this.failureResponse = response;\n    }\n\n    private synchronized CountDownLatch advanceLatch(CountDownLatch expected) {\n        CountDownLatch current = countDownLatch;\n        if (expected != null && current != expected) {\n            return expected;\n        }\n        countDownLatch = new CountDownLatch(1);\n        return current;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/listener/ImageSseListener.java",
    "content": "package io.github.lnyocly.ai4j.listener;\n\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageData;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamEvent;\nimport lombok.Getter;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.CountDownLatch;\n\n/**\n * @Author cly\n * @Description 图片生成流式监听器\n * @Date 2026/1/31\n */\npublic abstract class ImageSseListener extends EventSourceListener {\n\n    /**\n     * 异常回调\n     */\n    protected void error(Throwable t, Response response) {}\n\n    /**\n     * 事件回调\n     */\n    protected abstract void onEvent();\n\n    @Getter\n    private final List<ImageStreamEvent> events = new ArrayList<>();\n\n    @Getter\n    private ImageStreamEvent currEvent;\n\n    @Getter\n    private final ImageGenerationResponse response = new ImageGenerationResponse();\n\n    @Getter\n    private CountDownLatch countDownLatch = new CountDownLatch(1);\n\n    public void accept(ImageStreamEvent event) {\n        this.currEvent = event;\n        this.events.add(event);\n        appendResponse(event);\n        this.onEvent();\n    }\n\n    private void appendResponse(ImageStreamEvent event) {\n        if (event == null) {\n            return;\n        }\n        if (event.getUsage() != null) {\n            response.setUsage(event.getUsage());\n        }\n        if (event.getCreatedAt() != null && response.getCreated() == null) {\n            response.setCreated(event.getCreatedAt());\n        }\n        if (shouldAppendImage(event)) {\n            if (response.getData() == null) {\n                response.setData(new ArrayList<>());\n            }\n            ImageData imageData = new ImageData();\n            imageData.setUrl(event.getUrl());\n            imageData.setB64Json(event.getB64Json());\n            imageData.setSize(event.getSize());\n            response.getData().add(imageData);\n        }\n    }\n\n    private boolean shouldAppendImage(ImageStreamEvent event) {\n        if (event == null) {\n            return false;\n        }\n        if (event.getUrl() == null && event.getB64Json() == null) {\n            return false;\n        }\n        String type = event.getType();\n        return type == null || !type.contains(\"partial_image\");\n    }\n\n    public void complete() {\n        countDownLatch.countDown();\n        countDownLatch = new CountDownLatch(1);\n    }\n\n    public void onError(Throwable t, Response response) {\n        this.error(t, response);\n    }\n\n    @Override\n    public void onFailure(@NotNull okhttp3.sse.EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n        this.error(t, response);\n        complete();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/listener/ManagedStreamListener.java",
    "content": "package io.github.lnyocly.ai4j.listener;\n\npublic interface ManagedStreamListener {\n\n    void awaitCompletion(StreamExecutionOptions options) throws InterruptedException;\n\n    Throwable getFailure();\n\n    void recordFailure(Throwable failure);\n\n    void clearFailure();\n\n    void prepareForRetry();\n\n    boolean hasReceivedEvent();\n\n    boolean isCancelRequested();\n\n    void onRetrying(Throwable failure, int attempt, int maxAttempts);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/listener/RealtimeListener.java",
    "content": "package io.github.lnyocly.ai4j.listener;\n\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.Response;\nimport okhttp3.WebSocket;\nimport okhttp3.WebSocketListener;\nimport okio.ByteString;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\n/**\n * @Author cly\n * @Description RealtimeListener, 用于统一处理WebSocket的事件（Realtime客户端专用）\n * @Date 2024/10/12 16:33\n */\n@Slf4j\npublic abstract class RealtimeListener extends WebSocketListener {\n\n    protected abstract void onOpen(WebSocket webSocket);\n    protected abstract void onMessage(ByteString bytes);\n    protected abstract void onMessage(String text);\n    protected abstract void onFailure();\n\n    @Override\n    public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {\n        log.info(\"WebSocket Opened: \" + response.message());\n        this.onOpen(webSocket);\n    }\n\n    @Override\n    public void onMessage(@NotNull WebSocket webSocket, @NotNull ByteString bytes) {\n        log.info(\"Receive Byte Message: \" + bytes.toString());\n        this.onMessage(bytes);\n    }\n\n    @Override\n    public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {\n        log.info(\"Receive String Message: \" + text);\n        this.onMessage(text);\n    }\n\n    @Override\n    public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) {\n        log.error(\"WebSocket Error: \", t);\n    }\n\n    @Override\n    public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {\n    }\n\n    @Override\n    public void onClosed(@NotNull WebSocket webSocket, int code, @NotNull String reason) {\n        log.info(\"WebSocket Closed: \" + reason);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/listener/ResponseSseListener.java",
    "content": "package io.github.lnyocly.ai4j.listener;\n\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent;\nimport lombok.Getter;\nimport okhttp3.sse.EventSource;\nimport org.jetbrains.annotations.NotNull;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n\npublic abstract class ResponseSseListener extends AbstractManagedStreamListener {\n\n    \n    @Override\n    protected void error(Throwable t, okhttp3.Response response) {\n    }\n\n    \n    protected abstract void onEvent();\n\n    @Getter\n    private final List<ResponseStreamEvent> events = new ArrayList<>();\n\n    @Getter\n    private ResponseStreamEvent currEvent;\n\n    @Getter\n    private final Response response = new Response();\n\n    @Getter\n    private final StringBuilder outputText = new StringBuilder();\n\n    @Getter\n    private final StringBuilder reasoningSummary = new StringBuilder();\n\n    @Getter\n    private final StringBuilder functionArguments = new StringBuilder();\n\n    @Getter\n    private String currText = \"\";\n\n    @Getter\n    private String currFunctionArguments = \"\";\n\n    public void accept(ResponseStreamEvent event) {\n        this.currEvent = event;\n        this.events.add(event);\n        this.currText = \"\";\n        this.currFunctionArguments = \"\";\n        markActivity();\n        applyEvent(event);\n        this.onEvent();\n    }\n\n    private void applyEvent(ResponseStreamEvent event) {\n        if (event == null) {\n            return;\n        }\n        if (event.getResponse() != null) {\n            mergeResponse(event.getResponse());\n        }\n        String type = event.getType();\n        if (type == null) {\n            return;\n        }\n\n        switch (type) {\n            case \"response.output_text.delta\":\n                if (event.getDelta() != null) {\n                    outputText.append(event.getDelta());\n                    currText = event.getDelta();\n                }\n                break;\n            case \"response.output_text.done\":\n                if (outputText.length() == 0 && event.getText() != null) {\n                    outputText.append(event.getText());\n                    currText = event.getText();\n                }\n                break;\n            case \"response.reasoning_summary_text.delta\":\n                if (event.getDelta() != null) {\n                    reasoningSummary.append(event.getDelta());\n                }\n                break;\n            case \"response.reasoning_summary_text.done\":\n                if (reasoningSummary.length() == 0 && event.getText() != null) {\n                    reasoningSummary.append(event.getText());\n                }\n                break;\n            case \"response.function_call_arguments.delta\":\n                if (event.getDelta() != null) {\n                    functionArguments.append(event.getDelta());\n                    currFunctionArguments = event.getDelta();\n                }\n                break;\n            case \"response.function_call_arguments.done\":\n                if (functionArguments.length() == 0 && event.getArguments() != null) {\n                    functionArguments.append(event.getArguments());\n                    currFunctionArguments = event.getArguments();\n                }\n                break;\n            default:\n                break;\n        }\n    }\n\n    private void mergeResponse(Response source) {\n        if (source == null) {\n            return;\n        }\n        if (source.getId() != null) {\n            response.setId(source.getId());\n        }\n        if (source.getObject() != null) {\n            response.setObject(source.getObject());\n        }\n        if (source.getCreatedAt() != null) {\n            response.setCreatedAt(source.getCreatedAt());\n        }\n        if (source.getModel() != null) {\n            response.setModel(source.getModel());\n        }\n        if (source.getStatus() != null) {\n            response.setStatus(source.getStatus());\n        }\n        if (source.getOutput() != null) {\n            response.setOutput(source.getOutput());\n        }\n        if (source.getError() != null) {\n            response.setError(source.getError());\n        }\n        if (source.getIncompleteDetails() != null) {\n            response.setIncompleteDetails(source.getIncompleteDetails());\n        }\n        if (source.getInstructions() != null) {\n            response.setInstructions(source.getInstructions());\n        }\n        if (source.getMaxOutputTokens() != null) {\n            response.setMaxOutputTokens(source.getMaxOutputTokens());\n        }\n        if (source.getPreviousResponseId() != null) {\n            response.setPreviousResponseId(source.getPreviousResponseId());\n        }\n        if (source.getUsage() != null) {\n            response.setUsage(source.getUsage());\n        }\n        if (source.getMetadata() != null) {\n            response.setMetadata(source.getMetadata());\n        }\n        if (source.getContextManagement() != null) {\n            response.setContextManagement(source.getContextManagement());\n        }\n    }\n\n    public void complete() {\n        finishAttempt();\n    }\n\n    public void onError(Throwable t, okhttp3.Response response) {\n        this.error(t, response);\n    }\n\n    @Override\n    public void onClosed(@NotNull EventSource eventSource) {\n        attachEventSource(eventSource);\n        complete();\n    }\n\n    @Override\n    protected void retry(Throwable t, int attempt, int maxAttempts) {\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/listener/SseListener.java",
    "content": "package io.github.lnyocly.ai4j.listener;\n\nimport cn.hutool.core.util.StrUtil;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.Getter;\nimport lombok.Setter;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport org.apache.commons.lang3.StringUtils;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description SseListener\n * @Date 2024/8/13 23:25\n */\n\n@Slf4j\npublic abstract class SseListener extends AbstractManagedStreamListener {\n    /**\n     * 异常回调\n     */\n    protected void error(Throwable t, Response response) {}\n\n    protected abstract void send();\n    /**\n     * 最终的消息输出\n     */\n    @Getter\n    private final StringBuilder output = new StringBuilder();\n\n    /**\n     * 流式输出，当前消息的内容(回答消息、函数参数)\n     */\n    @Getter\n    private String currStr = \"\";\n\n    /**\n     * 流式输出，当前单条SSE消息对象，即ChatCompletionResponse对象\n     */\n    @Getter\n    private String currData = \"\";\n\n    /**\n     * 记录当前所调用函数工具的名称\n     */\n    @Getter\n    private String currToolName = \"\";\n\n    /**\n     * 记录当前是否为思考状态reasoning\n     */\n    @Getter\n    private boolean isReasoning = false;\n\n    /**\n     * 思考内容的输出\n     */\n    @Getter\n    private final StringBuilder reasoningOutput = new StringBuilder();\n\n    /**\n     * 是否显示每个函数调用输出的参数文本\n     */\n    @Getter\n    @Setter\n    private boolean showToolArgs = false;\n\n    /**\n     * 花费token\n     */\n    @Getter\n    private final Usage usage = new Usage();\n\n    @Setter\n    @Getter\n    private List<ToolCall> toolCalls = new ArrayList<>();\n\n    @Setter\n    @Getter\n    private ToolCall toolCall;\n\n    /**\n     * 最终的函数调用参数\n     */\n    private final StringBuilder argument = new StringBuilder();\n    @Getter\n    @Setter\n    private String finishReason = null;\n\n    @Override\n    public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n        // 封装SSE消息对象\n        currData = data;\n        markActivity();\n        if (getEventSource() == null) {\n            attachEventSource(eventSource);\n        }\n\n        if (\"[DONE]\".equalsIgnoreCase(data)) {\n            // 整个对话结束，结束前将SSE最后一条“DONE”消息发送出去\n            currStr = \"\";\n            this.send();\n\n            return;\n        }\n\n        ObjectMapper objectMapper = new ObjectMapper();\n        ChatCompletionResponse chatCompletionResponse = null;\n        try {\n            chatCompletionResponse = objectMapper.readValue(data, ChatCompletionResponse.class);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"read data error\");\n        }\n\n        // 统计token，当设置include_usage = true时，最后一条消息会携带usage, 其他消息中usage为null\n        Usage currUsage = chatCompletionResponse.getUsage();\n        if(currUsage != null){\n            usage.setPromptTokens(usage.getPromptTokens() + currUsage.getPromptTokens());\n            usage.setCompletionTokens(usage.getCompletionTokens() + currUsage.getCompletionTokens());\n            usage.setTotalTokens(usage.getTotalTokens() + currUsage.getTotalTokens());\n        }\n\n\n        List<Choice> choices = chatCompletionResponse.getChoices();\n\n        if((choices == null || choices.isEmpty()) && chatCompletionResponse.getUsage() != null){\n            this.currStr = \"\";\n            this.send();\n            return;\n        }\n\n        if(choices == null || choices.isEmpty()){\n            return;\n        }\n        ChatMessage responseMessage = choices.get(0).getDelta();\n        if (responseMessage == null) {\n            return;\n        }\n        List<ToolCall> messageToolCalls = responseMessage.getToolCalls();\n        ToolCall firstMessageToolCall = firstToolCall(messageToolCalls);\n\n        finishReason = choices.get(0).getFinishReason();\n\n        // Ollama 在工具调用时返回 stop 而非 tool_calls\n        if(\"stop\".equals(finishReason)\n                && responseMessage.getContent()!=null\n                && \"\".equals(responseMessage.getContent().getText())\n                && !toolCalls.isEmpty()){\n            finishReason = \"tool_calls\";\n        }\n\n\n        // tool_calls回答已经结束\n        if(\"tool_calls\".equals(finishReason)){\n            if (toolCall != null) {\n                consumeFragmentedToolCalls(messageToolCalls);\n                finalizeCurrentToolCall();\n            } else if (shouldTreatAsCompleteToolCalls(responseMessage, messageToolCalls)) {\n                addCompleteToolCalls(messageToolCalls);\n            } else {\n                consumeFragmentedToolCalls(messageToolCalls);\n                finalizeCurrentToolCall();\n            }\n            return;\n        }\n        // 消息回答完毕\n        if (\"stop\".equals(finishReason)) {\n\n            // ollama 最后一条消息只到stop\n            if(responseMessage.getContent() != null && responseMessage.getContent().getText() != null) {\n                currStr = responseMessage.getContent().getText();\n                output.append(currStr);\n            }else {\n                currStr = \"\";\n            }\n            this.send();\n\n\n            return;\n        }\n\n        if(ChatMessageType.ASSISTANT.getRole().equals(responseMessage.getRole())\n                && (responseMessage.getContent()==null || StringUtils.isEmpty(responseMessage.getContent().getText()))\n                && StringUtils.isEmpty(responseMessage.getReasoningContent())\n                && isEmpty(messageToolCalls)){\n            // 空消息忽略\n            return;\n        }\n\n\n        if(isEmpty(messageToolCalls)) {\n\n\n            // 判断是否为混元的tool最后一条说明性content，用于忽略\n            // :{\"Role\":\"assistant\",\"Content\":\"计划使用get_current_weather工具来获取北京和深圳的当前天气。\\n\\t\\n\\t用户想要知道北京和深圳今天的天气情况。用户的请求是关于天气的查询，需要使用天气查询工具来获取信息。\"}\n            if(toolCall !=null && StringUtils.isNotEmpty(argument)&& \"assistant\".equals(responseMessage.getRole()) && (responseMessage.getContent()!=null && StringUtils.isNotEmpty(responseMessage.getContent().getText())) ){\n                return;\n            }\n\n\n            // 响应回答\n            // 包括content和reasoning_content\n            if(StringUtils.isNotEmpty(responseMessage.getReasoningContent())){\n                isReasoning = true;\n                // reasoningOutput 与 output 分离，目前仅用于deepseek\n                reasoningOutput.append(responseMessage.getReasoningContent());\n                //output.append(responseMessage.getReasoningContent());\n                currStr = responseMessage.getReasoningContent();\n\n            }else {\n                isReasoning = false;\n                if (responseMessage.getContent() == null) {\n                    this.send();\n                    return;\n                }\n                output.append(responseMessage.getContent().getText());\n                currStr = responseMessage.getContent().getText();\n            }\n\n            this.send();\n\n\n        }else{\n            // 函数调用回答\n            if (shouldTreatAsCompleteToolCalls(responseMessage, messageToolCalls)) {\n                addCompleteToolCalls(messageToolCalls);\n            } else {\n                consumeFragmentedToolCalls(messageToolCalls);\n            }\n        }\n\n\n\n        //log.info(\"测试结果：{}\", chatCompletionResponse);\n    }\n\n    @Override\n    public void onClosed(@NotNull EventSource eventSource) {\n        attachEventSource(eventSource);\n        finishAttempt();\n        clearCancelRequested();\n\n    }\n\n    @Override\n    protected void resetRetryState() {\n        finishReason = null;\n        currData = \"\";\n        currStr = \"\";\n        currToolName = \"\";\n    }\n\n    private ToolCall firstToolCall(List<ToolCall> calls) {\n        if (isEmpty(calls)) {\n            return null;\n        }\n        return calls.get(0);\n    }\n\n    private String safeToolArguments(ToolCall call) {\n        if (call == null || call.getFunction() == null) {\n            return \"\";\n        }\n        return StrUtil.emptyIfNull(call.getFunction().getArguments());\n    }\n\n    private String safeToolName(ToolCall call) {\n        if (call == null || call.getFunction() == null) {\n            return \"\";\n        }\n        return StrUtil.emptyIfNull(call.getFunction().getName());\n    }\n\n    private boolean isEmpty(List<?> values) {\n        return values == null || values.isEmpty();\n    }\n\n    private boolean shouldTreatAsCompleteToolCalls(ChatMessage responseMessage, List<ToolCall> messageToolCalls) {\n        if (responseMessage == null || responseMessage.getContent() == null) {\n            return false;\n        }\n        if (!\"\".equals(responseMessage.getContent().getText()) || isEmpty(messageToolCalls)) {\n            return false;\n        }\n        for (ToolCall call : messageToolCalls) {\n            if (!hasToolName(call) || !hasStructuredJsonObjectArguments(call)) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n    private void addCompleteToolCalls(List<ToolCall> completeToolCalls) {\n        if (isEmpty(completeToolCalls)) {\n            return;\n        }\n        for (ToolCall completeToolCall : completeToolCalls) {\n            if (completeToolCall == null || completeToolCall.getFunction() == null || !hasToolName(completeToolCall)) {\n                continue;\n            }\n            currToolName = safeToolName(completeToolCall);\n            toolCalls.add(completeToolCall);\n            if (showToolArgs) {\n                this.currStr = StrUtil.emptyIfNull(completeToolCall.getFunction().getArguments());\n                this.send();\n            }\n        }\n        argument.setLength(0);\n        currToolName = \"\";\n    }\n\n    private void consumeFragmentedToolCalls(List<ToolCall> messageToolCalls) {\n        if (isEmpty(messageToolCalls)) {\n            return;\n        }\n        for (ToolCall currentToolCall : messageToolCalls) {\n            if (currentToolCall == null || currentToolCall.getFunction() == null) {\n                continue;\n            }\n            String argumentsDelta = StrUtil.emptyIfNull(safeToolArguments(currentToolCall));\n            if (hasToolIdentity(currentToolCall)) {\n                if (toolCall == null) {\n                    startToolCall(currentToolCall, argumentsDelta);\n                } else if (isSameToolCall(toolCall, currentToolCall)) {\n                    mergeToolIdentity(toolCall, currentToolCall);\n                    argument.append(argumentsDelta);\n                } else {\n                    finalizeCurrentToolCall();\n                    startToolCall(currentToolCall, argumentsDelta);\n                }\n                if (showToolArgs) {\n                    this.currStr = argumentsDelta;\n                    this.send();\n                }\n                continue;\n            }\n            if (toolCall != null) {\n                argument.append(argumentsDelta);\n                if (showToolArgs) {\n                    this.currStr = argumentsDelta;\n                    this.send();\n                }\n            }\n        }\n    }\n\n    private void startToolCall(ToolCall currentToolCall, String argumentsDelta) {\n        toolCall = currentToolCall;\n        argument.setLength(0);\n        argument.append(StrUtil.emptyIfNull(argumentsDelta));\n        currToolName = safeToolName(currentToolCall);\n    }\n\n    private void finalizeCurrentToolCall() {\n        if (toolCall == null) {\n            argument.setLength(0);\n            currToolName = \"\";\n            return;\n        }\n        if (toolCall.getFunction() != null) {\n            toolCall.getFunction().setArguments(argument.toString());\n        }\n        toolCalls.add(toolCall);\n        toolCall = null;\n        argument.setLength(0);\n        currToolName = \"\";\n    }\n\n    private void mergeToolIdentity(ToolCall target, ToolCall source) {\n        if (target == null || source == null) {\n            return;\n        }\n        if (StrUtil.isBlank(target.getId()) && StrUtil.isNotBlank(source.getId())) {\n            target.setId(source.getId());\n        }\n        if (StrUtil.isBlank(target.getType()) && StrUtil.isNotBlank(source.getType())) {\n            target.setType(source.getType());\n        }\n        if (target.getFunction() == null || source.getFunction() == null) {\n            return;\n        }\n        if (StrUtil.isBlank(target.getFunction().getName()) && StrUtil.isNotBlank(source.getFunction().getName())) {\n            target.getFunction().setName(source.getFunction().getName());\n        }\n    }\n\n    private boolean hasToolIdentity(ToolCall call) {\n        return call != null\n                && (StrUtil.isNotBlank(call.getId()) || hasToolName(call));\n    }\n\n    private boolean hasToolName(ToolCall call) {\n        return call != null\n                && call.getFunction() != null\n                && StrUtil.isNotBlank(call.getFunction().getName());\n    }\n\n    private boolean isSameToolCall(ToolCall left, ToolCall right) {\n        if (left == null || right == null) {\n            return false;\n        }\n        if (StrUtil.isNotBlank(left.getId()) && StrUtil.isNotBlank(right.getId())) {\n            return left.getId().equals(right.getId());\n        }\n        if (left.getFunction() == null || right.getFunction() == null) {\n            return false;\n        }\n        return StrUtil.isNotBlank(left.getFunction().getName())\n                && left.getFunction().getName().equals(right.getFunction().getName());\n    }\n\n    private boolean hasStructuredJsonObjectArguments(ToolCall call) {\n        String arguments = safeToolArguments(call);\n        if (StringUtils.isBlank(arguments)) {\n            return false;\n        }\n        try {\n            JsonNode node = new ObjectMapper().readTree(arguments);\n            return node != null && node.isObject();\n        } catch (Exception ignored) {\n            return false;\n        }\n    }\n\n    @Override\n    protected Throwable resolveFailure(@Nullable Throwable t, @Nullable Response response) {\n        if (t != null && StringUtils.isNotBlank(t.getMessage())) {\n            return t;\n        }\n        String message = resolveFailureMessage(t, response);\n        if (StringUtils.isBlank(message)) {\n            return t == null ? new CommonException(\"stream request failed\") : t;\n        }\n        return new CommonException(message);\n    }\n\n    protected String resolveFailureMessage(@Nullable Throwable t, @Nullable Response response) {\n        if (t != null && StringUtils.isNotBlank(t.getMessage())) {\n            return t.getMessage().trim();\n        }\n        String responseMessage = responseMessage(response);\n        if (StringUtils.isNotBlank(responseMessage)) {\n            return responseMessage;\n        }\n        if (response != null) {\n            String statusLine = (response.code() + \" \" + StrUtil.emptyIfNull(response.message())).trim();\n            if (StringUtils.isNotBlank(statusLine)) {\n                return statusLine;\n            }\n        }\n        return t == null ? \"stream request failed\" : t.getClass().getSimpleName();\n    }\n\n    private String responseMessage(@Nullable Response response) {\n        if (response == null) {\n            return null;\n        }\n        try {\n            okhttp3.ResponseBody peekedBody = response.peekBody(8192L);\n            if (peekedBody == null) {\n                return null;\n            }\n            String payload = StringUtils.trimToNull(peekedBody.string());\n            if (payload == null) {\n                return null;\n            }\n            String extracted = extractStructuredErrorMessage(payload);\n            return extracted == null ? StringUtils.abbreviate(payload, 320) : extracted;\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    private String extractStructuredErrorMessage(String payload) {\n        if (StringUtils.isBlank(payload)) {\n            return null;\n        }\n        try {\n            JsonNode root = new ObjectMapper().readTree(payload);\n            String message = firstJsonText(\n                    root.path(\"error\").path(\"message\"),\n                    root.path(\"error\"),\n                    root.path(\"message\"),\n                    root.path(\"msg\"),\n                    root.path(\"detail\"),\n                    root.path(\"Response\").path(\"Error\").path(\"Message\"),\n                    root.path(\"Response\").path(\"Error\"),\n                    root.path(\"error_msg\")\n            );\n            return StringUtils.trimToNull(message);\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    private String firstJsonText(JsonNode... nodes) {\n        if (nodes == null) {\n            return null;\n        }\n        for (JsonNode node : nodes) {\n            if (node == null || node.isMissingNode() || node.isNull()) {\n                continue;\n            }\n            if (node.isTextual()) {\n                String text = StringUtils.trimToNull(node.asText());\n                if (text != null) {\n                    return text;\n                }\n                continue;\n            }\n            if (node.isObject()) {\n                String text = firstJsonText(node.path(\"message\"), node.path(\"Message\"), node.path(\"msg\"), node.path(\"detail\"));\n                if (text != null) {\n                    return text;\n                }\n            }\n        }\n        return null;\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/listener/StreamExecutionOptions.java",
    "content": "package io.github.lnyocly.ai4j.listener;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class StreamExecutionOptions {\n\n    @Builder.Default\n    private long firstTokenTimeoutMs = 0L;\n\n    @Builder.Default\n    private long idleTimeoutMs = 0L;\n\n    @Builder.Default\n    private int maxRetries = 0;\n\n    @Builder.Default\n    private long retryBackoffMs = 0L;\n\n    public long normalizedFirstTokenTimeoutMs() {\n        return Math.max(0L, firstTokenTimeoutMs);\n    }\n\n    public long normalizedIdleTimeoutMs() {\n        return Math.max(0L, idleTimeoutMs);\n    }\n\n    public int normalizedMaxRetries() {\n        return Math.max(0, maxRetries);\n    }\n\n    public long normalizedRetryBackoffMs() {\n        return Math.max(0L, retryBackoffMs);\n    }\n\n    public int totalAttempts() {\n        return normalizedMaxRetries() + 1;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/listener/StreamExecutionSupport.java",
    "content": "package io.github.lnyocly.ai4j.listener;\n\npublic final class StreamExecutionSupport {\n\n    public static final String DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY = \"ai4j.stream.default.first-token-timeout-ms\";\n    public static final String DEFAULT_IDLE_TIMEOUT_PROPERTY = \"ai4j.stream.default.idle-timeout-ms\";\n    public static final String DEFAULT_MAX_RETRIES_PROPERTY = \"ai4j.stream.default.max-retries\";\n    public static final String DEFAULT_RETRY_BACKOFF_PROPERTY = \"ai4j.stream.default.retry-backoff-ms\";\n\n    public static final long DEFAULT_FIRST_TOKEN_TIMEOUT_MS = 30_000L;\n    public static final long DEFAULT_IDLE_TIMEOUT_MS = 30_000L;\n    public static final int DEFAULT_MAX_RETRIES = 0;\n    public static final long DEFAULT_RETRY_BACKOFF_MS = 0L;\n\n    private StreamExecutionSupport() {\n    }\n\n    public interface StreamStarter {\n        void start() throws Exception;\n    }\n\n    public static void execute(ManagedStreamListener listener,\n                               StreamExecutionOptions options,\n                               StreamStarter starter) throws Exception {\n        if (listener == null) {\n            throw new IllegalArgumentException(\"listener is required\");\n        }\n        if (starter == null) {\n            throw new IllegalArgumentException(\"starter is required\");\n        }\n\n        StreamExecutionOptions resolved = resolveOptions(options);\n\n        int maxAttempts = Math.max(1, resolved.totalAttempts());\n        int attempt = 1;\n        while (true) {\n            listener.clearFailure();\n            try {\n                starter.start();\n            } catch (Exception ex) {\n                listener.recordFailure(ex);\n            }\n\n            listener.awaitCompletion(resolved);\n\n            Throwable failure = listener.getFailure();\n            if (failure == null || listener.isCancelRequested() || Thread.currentThread().isInterrupted()) {\n                return;\n            }\n            if (listener.hasReceivedEvent() || attempt >= maxAttempts) {\n                return;\n            }\n\n            int nextAttempt = attempt + 1;\n            listener.onRetrying(failure, nextAttempt, maxAttempts);\n            listener.prepareForRetry();\n            long backoffMs = resolved.normalizedRetryBackoffMs();\n            if (backoffMs > 0L) {\n                try {\n                    Thread.sleep(backoffMs);\n                } catch (InterruptedException ex) {\n                    Thread.currentThread().interrupt();\n                    return;\n                }\n            }\n            attempt = nextAttempt;\n        }\n    }\n\n    static StreamExecutionOptions resolveOptions(StreamExecutionOptions options) {\n        if (options != null) {\n            return options;\n        }\n        return StreamExecutionOptions.builder()\n                .firstTokenTimeoutMs(longProperty(DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY, DEFAULT_FIRST_TOKEN_TIMEOUT_MS))\n                .idleTimeoutMs(longProperty(DEFAULT_IDLE_TIMEOUT_PROPERTY, DEFAULT_IDLE_TIMEOUT_MS))\n                .maxRetries(intProperty(DEFAULT_MAX_RETRIES_PROPERTY, DEFAULT_MAX_RETRIES))\n                .retryBackoffMs(longProperty(DEFAULT_RETRY_BACKOFF_PROPERTY, DEFAULT_RETRY_BACKOFF_MS))\n                .build();\n    }\n\n    private static long longProperty(String key, long fallback) {\n        String value = System.getProperty(key);\n        if (value == null || value.trim().isEmpty()) {\n            return fallback;\n        }\n        try {\n            return Long.parseLong(value.trim());\n        } catch (NumberFormatException ignored) {\n            return fallback;\n        }\n    }\n\n    private static int intProperty(String key, int fallback) {\n        String value = System.getProperty(key);\n        if (value == null || value.trim().isEmpty()) {\n            return fallback;\n        }\n        try {\n            return Integer.parseInt(value.trim());\n        } catch (NumberFormatException ignored) {\n            return fallback;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpParameter.java",
    "content": "package io.github.lnyocly.ai4j.mcp.annotation;\n\nimport java.lang.annotation.*;\n\n/**\n * @Author cly\n * @Description MCP参数注解，用于标记MCP工具方法的参数\n */\n@Target(ElementType.PARAMETER)\n@Retention(RetentionPolicy.RUNTIME)\n@Documented\npublic @interface McpParameter {\n\n    /**\n     * 参数名称，如果不指定则使用参数名\n     */\n    String name() default \"\";\n\n    /**\n     * 参数描述\n     */\n    String description() default \"\";\n\n    /**\n     * 是否必需参数\n     */\n    boolean required() default true;\n\n    /**\n     * 参数默认值\n     * 在MCP工具调用时使用\n     */\n    String defaultValue() default \"\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpPrompt.java",
    "content": "package io.github.lnyocly.ai4j.mcp.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * @Author cly\n * @Description MCP提示词注解，用于标记MCP服务中的提示词方法\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface McpPrompt {\n    \n    /**\n     * 提示词名称\n     */\n    String name();\n    \n    /**\n     * 提示词描述\n     */\n    String description() default \"\";\n    \n    /**\n     * 提示词类型 (user, assistant, system)\n     */\n    String role() default \"user\";\n    \n    /**\n     * 是否支持动态参数\n     */\n    boolean dynamic() default true;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpPromptParameter.java",
    "content": "package io.github.lnyocly.ai4j.mcp.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * @Author cly\n * @Description MCP提示词参数注解，用于标记MCP提示词方法的参数\n */\n@Target(ElementType.PARAMETER)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface McpPromptParameter {\n    \n    /**\n     * 参数名称\n     */\n    String name();\n    \n    /**\n     * 参数描述\n     */\n    String description() default \"\";\n    \n    /**\n     * 是否必需\n     */\n    boolean required() default true;\n\n    /**\n     * 默认值\n     */\n    String defaultValue() default \"\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpResource.java",
    "content": "package io.github.lnyocly.ai4j.mcp.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * @Author cly\n * @Description MCP资源注解，用于标记MCP服务中的资源方法\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface McpResource {\n    \n    /**\n     * 资源URI模板，支持参数占位符\n     * 例如: \"file://{path}\", \"database://users/{id}\"\n     */\n    String uri();\n    \n    /**\n     * 资源名称\n     */\n    String name();\n    \n    /**\n     * 资源描述\n     */\n    String description() default \"\";\n    \n    /**\n     * 资源MIME类型\n     */\n    String mimeType() default \"\";\n    \n    /**\n     * 是否支持订阅变更通知\n     */\n    boolean subscribable() default false;\n    \n    /**\n     * 资源大小（字节），-1表示未知\n     */\n    long size() default -1L;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpResourceParameter.java",
    "content": "package io.github.lnyocly.ai4j.mcp.annotation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * @Author cly\n * @Description MCP资源参数注解，用于标记MCP资源方法的参数\n */\n@Target(ElementType.PARAMETER)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface McpResourceParameter {\n    \n    /**\n     * 参数名称，对应URI模板中的占位符\n     */\n    String name();\n    \n    /**\n     * 参数描述\n     */\n    String description() default \"\";\n    \n    /**\n     * 是否必需\n     */\n    boolean required() default true;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpService.java",
    "content": "package io.github.lnyocly.ai4j.mcp.annotation;\n\nimport java.lang.annotation.*;\n\n/**\n * @Author cly\n * @Description MCP服务注解，用于标记MCP服务类\n */\n@Target(ElementType.TYPE)\n@Retention(RetentionPolicy.RUNTIME)\n@Documented\npublic @interface McpService {\n\n    /**\n     * 服务名称\n     */\n    String name() default \"\";\n\n    /**\n     * 服务版本\n     */\n    String version() default \"1.0.0\";\n\n    /**\n     * 服务描述\n     */\n    String description() default \"\";\n\n    /**\n     * 服务端口（仅HTTP传输时使用）\n     */\n    int port() default 3000;\n\n    /**\n     * 传输类型：stdio, sse, streamable_http\n     */\n    String transport() default \"stdio\";\n\n    /**\n     * 是否自动启动\n     */\n    boolean autoStart() default true;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/annotation/McpTool.java",
    "content": "package io.github.lnyocly.ai4j.mcp.annotation;\n\nimport java.lang.annotation.*;\n\n/**\n * @Author cly\n * @Description MCP工具注解，用于标记MCP服务中的工具方法\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\n@Documented\npublic @interface McpTool {\n\n    /**\n     * 工具名称，如果不指定则使用方法名\n     */\n    String name() default \"\";\n\n    /**\n     * 工具描述\n     */\n    String description() default \"\";\n\n    /**\n     * 工具输入Schema的JSON字符串\n     * 如果不指定，将根据方法参数自动生成\n     */\n    String inputSchema() default \"\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/client/McpClient.java",
    "content": "package io.github.lnyocly.ai4j.mcp.client;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.entity.*;\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransport;\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransportSupport;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.*;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicLong;\n\n/**\n * @Author cly\n * @Description MCP客户端核心类\n */\npublic class McpClient implements McpTransport.McpMessageHandler {\n\n    private static final Logger log = LoggerFactory.getLogger(McpClient.class);\n    private static final long HEARTBEAT_INTERVAL_MINUTES = 10L;\n\n    private final String clientName;\n    private final String clientVersion;\n    private final McpTransport transport;\n    private final boolean autoReconnect;\n    private final AtomicBoolean initialized;\n    private final AtomicBoolean connected;\n    private final AtomicLong messageIdCounter;\n    // 在 McpClient 类中添加以下成员变量\n    private final AtomicBoolean isReconnecting = new AtomicBoolean(false);\n    private final ScheduledExecutorService reconnectExecutor = Executors.newSingleThreadScheduledExecutor();\n\n    // 存储待响应的请求\n    private final Map<Object, CompletableFuture<McpMessage>> pendingRequests;\n\n    // 缓存的服务器工具列表\n    private volatile List<McpToolDefinition> availableTools;\n    private volatile List<McpResource> availableResources;\n    private volatile List<McpPrompt> availablePrompts;\n\n    private ScheduledExecutorService heartbeatExecutor;\n\n    public McpClient(String clientName, String clientVersion, McpTransport transport) {\n        this(clientName, clientVersion, transport, true);\n    }\n\n    public McpClient(String clientName, String clientVersion, McpTransport transport, boolean autoReconnect) {\n        this.clientName = clientName;\n        this.clientVersion = clientVersion;\n        this.transport = transport;\n        this.autoReconnect = autoReconnect;\n        this.initialized = new AtomicBoolean(false);\n        this.connected = new AtomicBoolean(false);\n        this.messageIdCounter = new AtomicLong(0);\n        this.pendingRequests = new ConcurrentHashMap<>();\n\n        // 设置传输层消息处理器\n        transport.setMessageHandler(this);\n    }\n\n    /**\n     * 连接到MCP服务器\n     */\n    public CompletableFuture<Void> connect() {\n        return CompletableFuture.runAsync(() -> {\n            if (!connected.get()) {\n                log.info(\"连接到MCP服务器: {} v{}\", clientName, clientVersion);\n\n                try {\n                    // 启动传输层，添加超时\n                    transport.start().get(30, java.util.concurrent.TimeUnit.SECONDS);\n                    log.debug(\"传输层启动成功\");\n\n                    // 发送初始化请求，添加超时\n                    initialize().get(30, java.util.concurrent.TimeUnit.SECONDS);\n                    log.debug(\"初始化请求完成\");\n\n                    connected.set(true); // 在所有步骤成功后再设置\n                    log.debug(\"MCP客户端连接成功\");\n                    if (transport.needsHeartbeat()) {\n                        log.debug(\"正在启动心跳服务...\");\n                        startHeartbeat();\n                    }\n                } catch (Exception e) {\n                    log.debug(\"连接MCP服务器失败: {}\", McpTransportSupport.safeMessage(e), e);\n                    // 确保在失败时状态被重置\n                    connected.set(false);\n                    initialized.set(false);\n                    throw new RuntimeException(McpTransportSupport.safeMessage(e), e);\n                }\n            }\n        });\n    }\n\n    /**\n     * 断开连接\n     */\n    public CompletableFuture<Void> disconnect() {\n        return CompletableFuture.runAsync(() -> {\n            if (connected.get() || transport.isConnected()) {\n                if (transport.needsHeartbeat() && connected.get()) {\n                    log.debug(\"正在停止心跳服务...\");\n                    stopHeartbeat();\n                }\n                connected.set(false);\n                initialized.set(false);\n                availableTools = null;\n                availableResources = null;\n                availablePrompts = null;\n                try {\n                    // 先停止传输层，避免触发新的回调\n                    transport.stop().join();\n\n                    // 取消所有待响应的请求\n                    pendingRequests.values().forEach(future ->\n                            future.completeExceptionally(new RuntimeException(\"连接已断开\")));\n                    pendingRequests.clear();\n\n                    log.debug(\"MCP客户端已断开连接\");\n                } catch (Exception e) {\n                    log.error(\"断开连接时发生错误\", e);\n                }\n            }\n\n            // 最后关闭重连调度器，确保传输层已完全停止\n            reconnectExecutor.shutdownNow();\n        });\n    }\n\n    private void startHeartbeat() {\n        if (heartbeatExecutor == null || heartbeatExecutor.isShutdown()) {\n            heartbeatExecutor = Executors.newSingleThreadScheduledExecutor();\n        }\n\n        // 长连接传输由底层连接自行保活；这里仅保留低频兜底检查。\n        heartbeatExecutor.scheduleAtFixedRate(() -> {\n            if (isConnected()) {\n                log.debug(\"执行MCP心跳检查\");\n                this.getAvailableTools().exceptionally(e -> {\n                    log.warn(\"心跳请求失败: {}\", e.getMessage());\n                    // 如果心跳失败，也可以考虑触发重连\n                    this.onError(new IOException(\"Heartbeat failed, connection assumed lost.\", e));\n                    return null;\n                });\n            }\n        }, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_INTERVAL_MINUTES, TimeUnit.MINUTES);\n    }\n\n    private void stopHeartbeat() {\n        if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) {\n            heartbeatExecutor.shutdown();\n            heartbeatExecutor = null;\n        }\n    }\n    /**\n     * 检查是否已连接\n     */\n    public boolean isConnected() {\n        return connected.get() && transport.isConnected();\n    }\n\n    /**\n     * 检查是否已初始化\n     */\n    public boolean isInitialized() {\n        return initialized.get();\n    }\n\n    /**\n     * 获取可用工具列表\n     */\n    public CompletableFuture<List<McpToolDefinition>> getAvailableTools() {\n        if (availableTools != null) {\n            return CompletableFuture.completedFuture(availableTools);\n        }\n\n        return sendRequest(\"tools/list\", null)\n                .thenApply(response -> {\n                    try {\n                        if (response.isSuccessResponse() && response.getResult() != null) {\n                            List<McpToolDefinition> parsedTools = McpClientResponseSupport.parseToolsListResponse(response.getResult());\n                            availableTools = parsedTools;\n                            return parsedTools;\n                        } else {\n                            log.warn(\"获取工具列表失败: {}\", response.getError());\n                            return new ArrayList<McpToolDefinition>();\n                        }\n                    } catch (Exception e) {\n                        log.error(\"解析工具列表响应失败\", e);\n                        return new ArrayList<McpToolDefinition>();\n                    }\n                });\n    }\n\n    /**\n     * 获取可用资源列表\n     */\n    public CompletableFuture<List<McpResource>> getAvailableResources() {\n        if (availableResources != null) {\n            return CompletableFuture.completedFuture(availableResources);\n        }\n\n        return sendRequest(\"resources/list\", null)\n                .thenApply(response -> {\n                    try {\n                        if (response.isSuccessResponse() && response.getResult() != null) {\n                            List<McpResource> parsedResources = McpClientResponseSupport.parseResourcesListResponse(response.getResult());\n                            availableResources = parsedResources;\n                            return parsedResources;\n                        } else {\n                            log.warn(\"获取资源列表失败: {}\", response.getError());\n                            return new ArrayList<McpResource>();\n                        }\n                    } catch (Exception e) {\n                        log.error(\"解析资源列表响应失败\", e);\n                        return new ArrayList<McpResource>();\n                    }\n                });\n    }\n\n    /**\n     * 读取资源内容\n     */\n    public CompletableFuture<McpResourceContent> readResource(String uri) {\n        if (uri == null || uri.trim().isEmpty()) {\n            CompletableFuture<McpResourceContent> future = new CompletableFuture<McpResourceContent>();\n            future.completeExceptionally(new IllegalArgumentException(\"uri is required\"));\n            return future;\n        }\n\n        Map<String, Object> params = new HashMap<String, Object>();\n        params.put(\"uri\", uri);\n\n        return sendRequest(\"resources/read\", params)\n                .thenApply(response -> {\n                    try {\n                        if (response.isSuccessResponse() && response.getResult() != null) {\n                            return McpClientResponseSupport.parseResourceReadResponse(response.getResult());\n                        } else {\n                            log.warn(\"读取资源失败: {}\", response.getError());\n                            return null;\n                        }\n                    } catch (Exception e) {\n                        log.error(\"解析资源读取响应失败\", e);\n                        return null;\n                    }\n                });\n    }\n\n    /**\n     * 获取可用提示模板列表\n     */\n    public CompletableFuture<List<McpPrompt>> getAvailablePrompts() {\n        if (availablePrompts != null) {\n            return CompletableFuture.completedFuture(availablePrompts);\n        }\n\n        return sendRequest(\"prompts/list\", null)\n                .thenApply(response -> {\n                    try {\n                        if (response.isSuccessResponse() && response.getResult() != null) {\n                            List<McpPrompt> parsedPrompts = McpClientResponseSupport.parsePromptsListResponse(response.getResult());\n                            availablePrompts = parsedPrompts;\n                            return parsedPrompts;\n                        } else {\n                            log.warn(\"获取提示模板列表失败: {}\", response.getError());\n                            return new ArrayList<McpPrompt>();\n                        }\n                    } catch (Exception e) {\n                        log.error(\"解析提示模板列表响应失败\", e);\n                        return new ArrayList<McpPrompt>();\n                    }\n                });\n    }\n\n    /**\n     * 获取提示模板渲染结果\n     */\n    public CompletableFuture<McpPromptResult> getPrompt(String name) {\n        return getPrompt(name, null);\n    }\n\n    /**\n     * 获取提示模板渲染结果\n     */\n    public CompletableFuture<McpPromptResult> getPrompt(String name, Map<String, Object> arguments) {\n        if (name == null || name.trim().isEmpty()) {\n            CompletableFuture<McpPromptResult> future = new CompletableFuture<McpPromptResult>();\n            future.completeExceptionally(new IllegalArgumentException(\"name is required\"));\n            return future;\n        }\n\n        Map<String, Object> params = new HashMap<String, Object>();\n        params.put(\"name\", name);\n        if (arguments != null && !arguments.isEmpty()) {\n            params.put(\"arguments\", arguments);\n        }\n\n        return sendRequest(\"prompts/get\", params)\n                .thenApply(response -> {\n                    try {\n                        if (response.isSuccessResponse() && response.getResult() != null) {\n                            return McpClientResponseSupport.parsePromptGetResponse(name, response.getResult());\n                        } else {\n                            log.warn(\"获取提示模板失败: {}\", response.getError());\n                            return null;\n                        }\n                    } catch (Exception e) {\n                        log.error(\"解析提示模板响应失败\", e);\n                        return null;\n                    }\n                });\n    }\n\n\n    /**\n     * 调用工具\n     */\n    public CompletableFuture<String> callTool(String toolName, Object arguments) {\n        if (!isConnected() || !isInitialized()) {\n            CompletableFuture<String> future = new CompletableFuture<>();\n            String errorMsg = \"客户端未连接或未初始化，无法调用工具。Connected: \" + isConnected() + \", Initialized: \" + isInitialized();\n            log.warn(errorMsg);\n            future.completeExceptionally(new IllegalStateException(errorMsg));\n            return future;\n        }\n\n        Map<String, Object> params = new HashMap<>();\n        params.put(\"name\", toolName);\n        params.put(\"arguments\", arguments);\n\n        return sendRequest(\"tools/call\", params)\n                .thenApply(response -> {\n                    try {\n                        if (response.isSuccessResponse() && response.getResult() != null) {\n                            // 解析工具调用结果\n                            return McpClientResponseSupport.parseToolCallResponse(response.getResult());\n                        } else {\n                            log.warn(\"工具调用失败: {}\", response.getError());\n                            return \"工具调用失败: \" + (response.getError() != null ? response.getError().getMessage() : \"未知错误\");\n                        }\n                    } catch (Exception e) {\n                        log.error(\"解析工具调用响应失败\", e);\n                        return \"工具调用解析失败: \" + e.getMessage();\n                    }\n                }) .exceptionally(ex -> {\n                    log.error(\"调用工具 '{}' 失败，根本原因: {}\", toolName, ex.getMessage());\n                    // 将原始异常重新包装或转换为一个更具体的业务异常\n                    throw new RuntimeException(ex.getMessage(), ex);\n                });\n    }\n\n    @Override\n    public void handleMessage(McpMessage message) {\n        try {\n            log.debug(\"处理消息: {}\", message);\n\n            if (message.isResponse()) {\n                handleResponse(message);\n            } else if (message.isNotification()) {\n                handleNotification(message);\n            } else if (message.isRequest()) {\n                handleRequest(message);\n            } else {\n                log.warn(\"收到未知类型的消息: {}\", message);\n            }\n\n        } catch (Exception e) {\n            log.error(\"处理消息时发生错误\", e);\n        }\n    }\n\n    @Override\n    public void onConnected() {\n        log.debug(\"MCP传输层连接已建立\");\n        connected.set(true);\n    }\n\n    @Override\n    public void onDisconnected(String reason) {\n        log.debug(\"MCP传输层连接已断开: {}\", reason);\n        if (connected.get()) { // 增加一个判断，避免重复执行\n            connected.set(false);\n            initialized.set(false);\n\n            // 清除缓存，因为重新连接后工具列表可能变化\n            availableTools = null;\n            // 停止心跳\n            stopHeartbeat();\n\n            // 停止并清理传输层，这将清除旧的 endpointUrl\n            try {\n                transport.stop().join(); // 使用 join() 确保传输层已完全停止\n            } catch (Exception e) {\n                log.warn(\"在 onDisconnected 期间停止传输层失败\", e);\n            }\n\n            // 取消所有待处理的请求\n            pendingRequests.values().forEach(future ->\n                    future.completeExceptionally(new RuntimeException(reason)));\n            pendingRequests.clear();\n\n            // 触发异步重连\n            if (autoReconnect) {\n                scheduleReconnection();\n            } else {\n                log.debug(\"自动重连已禁用，跳过MCP重连: {}\", clientName);\n            }\n        }\n    }\n\n    @Override\n    public void onError(Throwable error) {\n        log.debug(\"MCP传输层发生错误: {}\", McpTransportSupport.safeMessage(error), error);\n        // 将传输层错误视为一次断开连接事件。\n        this.onDisconnected(McpTransportSupport.safeMessage(error));\n    }\n    /**\n     * 调度一个异步的重连任务\n     */\n    private void scheduleReconnection() {\n        // 检查线程池是否已关闭\n        if (reconnectExecutor.isShutdown()) {\n            log.debug(\"重连调度器已关闭，跳过重连\");\n            return;\n        }\n\n        if (isReconnecting.compareAndSet(false, true)) {\n            log.debug(\"将在5秒后尝试重新连接...\");\n            try {\n                reconnectExecutor.schedule(() -> {\n                    try {\n                        log.debug(\"开始执行重连...\");\n                        connect().get(60, TimeUnit.SECONDS); // 使用 get() 来等待重连完成\n                        log.debug(\"MCP客户端重连成功\");\n                    } catch (Exception e) {\n                        log.error(\"重连失败，将安排下一次重连\", e);\n                        // 如果这次重连失败，再次调度\n                        isReconnecting.set(false); // 重置标志以便下次可以重连\n                        scheduleReconnection(); // 简单起见，这里直接再次调度。实际中可引入指数退避策略。\n                    } finally {\n                        // 无论成功与否，最终都重置标志位\n                        // 如果成功，下一次断线时可以再次触发重连\n                        // 如果失败，下一次调度时也可以继续\n                        isReconnecting.set(false);\n                    }\n                }, 5, TimeUnit.SECONDS); // 延迟5秒后执行\n            } catch (RejectedExecutionException e) {\n                log.debug(\"重连调度器已关闭，无法调度重连任务\");\n                isReconnecting.set(false);\n            }\n        }\n    }\n    /**\n     * 发送初始化请求\n     */\n    private CompletableFuture<Void> initialize() {\n        log.debug(\"开始MCP初始化流程\");\n\n        // 构建客户端能力 - 使用更完整的配置\n        Map<String, Object> capabilities = new HashMap<>();\n        // 添加sampling能力\n        capabilities.put(\"sampling\", new HashMap<>());\n\n        // 添加roots能力\n        Map<String, Object> roots = new HashMap<>();\n        roots.put(\"listChanged\", true);\n        capabilities.put(\"roots\", roots);\n\n        // 添加tools能力\n        Map<String, Object> tools = new HashMap<>();\n        tools.put(\"listChanged\", true);\n        capabilities.put(\"tools\", tools);\n\n        // 添加resources能力\n        Map<String, Object> resources = new HashMap<>();\n        resources.put(\"listChanged\", true);\n        resources.put(\"subscribe\", true);\n        capabilities.put(\"resources\", resources);\n\n        // 添加prompts能力\n        Map<String, Object> prompts = new HashMap<>();\n        prompts.put(\"listChanged\", true);\n        capabilities.put(\"prompts\", prompts);\n\n        Map<String, Object> clientInfo = new HashMap<>();\n        clientInfo.put(\"name\", clientName);\n        clientInfo.put(\"version\", clientVersion);\n\n        Map<String, Object> params = new HashMap<>();\n        // 使用与服务器兼容的协议版本\n        params.put(\"protocolVersion\", \"2025-03-26\");\n        params.put(\"capabilities\", capabilities);\n        params.put(\"clientInfo\", clientInfo);\n\n        return sendRequest(\"initialize\", params)\n                .thenCompose(response -> {\n                    log.debug(\"收到初始化响应，发送initialized通知\");\n                    // 发送初始化完成通知 - 使用空对象而不是null\n                    return sendNotification(\"notifications/initialized\", new HashMap<>());\n                })\n                .thenRun(() -> {\n                    initialized.set(true);\n                    log.debug(\"MCP客户端初始化完成\");\n                });\n    }\n\n    /**\n     * 发送请求消息\n     */\n    private CompletableFuture<McpMessage> sendRequest(String method, Object params) {\n        long messageId = nextMessageId();\n\n        // 创建请求消息\n        McpRequest request = new McpRequest(method, messageId, params);\n\n        // 添加详细日志\n        log.debug(\"发送MCP请求: method={}, id={}, params={}\", method, messageId, JSON.toJSONString(params));\n\n        CompletableFuture<McpMessage> future = new CompletableFuture<>();\n        pendingRequests.put(messageId, future);\n\n        // 发送消息\n        transport.sendMessage(request);\n\n        return future;\n    }\n\n\n    /**\n     * 发送通知消息\n     */\n    private CompletableFuture<Void> sendNotification(String method, Object params) {\n        // 创建通知消息\n        McpNotification notification = new McpNotification(method, params);\n\n        log.debug(\"发送通知: method={}\", method);\n\n        return transport.sendMessage(notification)\n                .thenRun(() -> {\n                    log.debug(\"通知发送完成: method={}\", method);\n                })\n                .exceptionally(throwable -> {\n                    log.error(\"发送通知失败: method={}\", method, throwable);\n                    throw new RuntimeException(\"发送通知失败\", throwable);\n                });\n    }\n\n    /**\n     * 处理响应消息\n     */\n    private void handleResponse(McpMessage message) {\n        Object messageId = message.getId();\n\n        // 记录完整的响应消息用于调试\n        log.debug(\"收到MCP响应: id={}, 完整消息={}\", messageId, JSON.toJSONString(message));\n\n        // 尝试不同的ID类型匹配\n        CompletableFuture<McpMessage> future = pendingRequests.remove(messageId);\n        if (future == null && messageId instanceof Number) {\n            // 如果直接匹配失败，尝试转换为long类型匹配\n            long longId = ((Number) messageId).longValue();\n            future = pendingRequests.remove(longId);\n        }\n\n        if (future != null) {\n            if (message.isSuccessResponse()) {\n                future.complete(message);\n            } else {\n                log.error(\"MCP请求失败详情: id={}, error={}, 完整错误消息={}\",\n                    messageId, message.getError(), JSON.toJSONString(message));\n                future.completeExceptionally(new RuntimeException(\"请求失败: \" + message.getError()));\n            }\n        } else {\n            log.warn(\"收到未知请求的响应: {}\", messageId);\n        }\n    }\n\n    /**\n     * 处理通知消息\n     */\n    private void handleNotification(McpMessage message) {\n        String method = message.getMethod();\n        log.debug(\"收到通知: {}\", method);\n\n        // 处理各种标准MCP通知\n        switch (method) {\n            case \"notifications/tools/list_changed\":\n                // 工具列表变更通知\n                log.debug(\"服务器工具列表已变更，清除缓存\");\n                availableTools = null; // 清除缓存\n                break;\n            case \"notifications/resources/list_changed\":\n                // 资源列表变更通知\n                log.debug(\"服务器资源列表已变更\");\n                availableResources = null;\n                break;\n            case \"notifications/prompts/list_changed\":\n                // 提示列表变更通知\n                log.debug(\"服务器提示列表已变更\");\n                availablePrompts = null;\n                break;\n            case \"notifications/progress\":\n                // 进度通知\n                log.debug(\"收到进度通知: {}\", message.getParams());\n                break;\n            default:\n                log.debug(\"收到未处理的通知: {}\", method);\n                break;\n        }\n    }\n\n    /**\n     * 处理请求消息（服务器向客户端发送的请求）\n     */\n    private void handleRequest(McpMessage message) {\n        String method = message.getMethod();\n        log.debug(\"收到服务器请求: {}\", method);\n\n        // 这里可以处理服务器向客户端发送的请求\n        // 比如sampling/createMessage等\n    }\n\n    /**\n     * 生成下一个消息ID\n     */\n    private long nextMessageId() {\n        return messageIdCounter.incrementAndGet();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/client/McpClientResponseSupport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.client;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.entity.McpPrompt;\nimport io.github.lnyocly.ai4j.mcp.entity.McpPromptResult;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResource;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResourceContent;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * MCP 客户端响应解析辅助类\n */\npublic final class McpClientResponseSupport {\n\n    private McpClientResponseSupport() {\n    }\n\n    public static List<McpToolDefinition> parseToolsListResponse(Object result) {\n        List<McpToolDefinition> tools = new ArrayList<McpToolDefinition>();\n\n        try {\n            Map<String, Object> resultMap = asMap(result);\n            if (resultMap != null) {\n                List<Object> toolsList = asList(resultMap.get(\"tools\"));\n                for (Object toolObj : toolsList) {\n                    Map<String, Object> toolMap = asMap(toolObj);\n                    if (toolMap != null) {\n                        McpToolDefinition tool = McpToolDefinition.builder()\n                                .name((String) toolMap.get(\"name\"))\n                                .description((String) toolMap.get(\"description\"))\n                                .inputSchema(asMap(toolMap.get(\"inputSchema\")))\n                                .build();\n                        tools.add(tool);\n                    }\n                }\n            }\n        } catch (Exception ignored) {\n        }\n\n        return tools;\n    }\n\n    public static String parseToolCallResponse(Object result) {\n        try {\n            Map<String, Object> resultMap = asMap(result);\n            if (resultMap != null) {\n                Object content = resultMap.get(\"content\");\n                if (content != null) {\n                    List<Object> contentList = asList(content);\n                    if (!contentList.isEmpty()) {\n                        StringBuilder resultText = new StringBuilder();\n                        for (Object item : contentList) {\n                            Map<String, Object> itemMap = asMap(item);\n                            if (itemMap != null) {\n                                Object text = itemMap.get(\"text\");\n                                if (text != null) {\n                                    resultText.append(text.toString());\n                                }\n                            }\n                        }\n                        return resultText.toString();\n                    }\n                    return content.toString();\n                }\n\n                return JSON.toJSONString(result);\n            }\n\n            return result != null ? result.toString() : \"\";\n        } catch (Exception e) {\n            return \"解析结果失败: \" + e.getMessage();\n        }\n    }\n\n    public static List<McpResource> parseResourcesListResponse(Object result) {\n        List<McpResource> resources = new ArrayList<McpResource>();\n        try {\n            Map<String, Object> resultMap = asMap(result);\n            if (resultMap == null) {\n                return resources;\n            }\n            List<Object> resourceList = asList(resultMap.get(\"resources\"));\n            for (Object resourceObj : resourceList) {\n                Map<String, Object> resourceMap = asMap(resourceObj);\n                if (resourceMap == null) {\n                    continue;\n                }\n                resources.add(McpResource.builder()\n                        .uri(stringValue(resourceMap.get(\"uri\")))\n                        .name(stringValue(resourceMap.get(\"name\")))\n                        .description(stringValue(resourceMap.get(\"description\")))\n                        .mimeType(stringValue(resourceMap.get(\"mimeType\")))\n                        .size(longValue(resourceMap.get(\"size\")))\n                        .build());\n            }\n        } catch (Exception ignored) {\n        }\n        return resources;\n    }\n\n    public static McpResourceContent parseResourceReadResponse(Object result) {\n        try {\n            Map<String, Object> resultMap = asMap(result);\n            if (resultMap == null) {\n                return null;\n            }\n            List<Object> contentList = asList(resultMap.get(\"contents\"));\n            if (contentList.isEmpty()) {\n                return null;\n            }\n            if (contentList.size() == 1) {\n                return parseSingleResourceContent(contentList.get(0));\n            }\n\n            List<Object> contents = new ArrayList<Object>(contentList.size());\n            String uri = null;\n            String mimeType = null;\n            for (Object contentObj : contentList) {\n                Map<String, Object> itemMap = asMap(contentObj);\n                if (itemMap == null) {\n                    continue;\n                }\n                if (uri == null) {\n                    uri = stringValue(itemMap.get(\"uri\"));\n                }\n                if (mimeType == null) {\n                    mimeType = stringValue(itemMap.get(\"mimeType\"));\n                }\n                Object extracted = extractResourceContent(itemMap);\n                contents.add(extracted == null ? itemMap : extracted);\n            }\n            return McpResourceContent.builder()\n                    .uri(uri)\n                    .mimeType(mimeType)\n                    .contents(contents)\n                    .build();\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    public static List<McpPrompt> parsePromptsListResponse(Object result) {\n        List<McpPrompt> prompts = new ArrayList<McpPrompt>();\n        try {\n            Map<String, Object> resultMap = asMap(result);\n            if (resultMap == null) {\n                return prompts;\n            }\n            List<Object> promptList = asList(resultMap.get(\"prompts\"));\n            for (Object promptObj : promptList) {\n                Map<String, Object> promptMap = asMap(promptObj);\n                if (promptMap == null) {\n                    continue;\n                }\n                prompts.add(McpPrompt.builder()\n                        .name(stringValue(promptMap.get(\"name\")))\n                        .description(stringValue(promptMap.get(\"description\")))\n                        .arguments(asMap(promptMap.get(\"arguments\")))\n                        .build());\n            }\n        } catch (Exception ignored) {\n        }\n        return prompts;\n    }\n\n    public static McpPromptResult parsePromptGetResponse(String name, Object result) {\n        try {\n            Map<String, Object> resultMap = asMap(result);\n            if (resultMap == null) {\n                return null;\n            }\n            List<Object> messages = asList(resultMap.get(\"messages\"));\n            StringBuilder content = new StringBuilder();\n            for (Object messageObj : messages) {\n                Map<String, Object> messageMap = asMap(messageObj);\n                if (messageMap == null) {\n                    continue;\n                }\n                appendPromptMessageText(content, messageMap.get(\"content\"));\n            }\n            return McpPromptResult.builder()\n                    .name(name)\n                    .description(stringValue(resultMap.get(\"description\")))\n                    .content(content.toString())\n                    .build();\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    private static McpResourceContent parseSingleResourceContent(Object contentObj) {\n        Map<String, Object> contentMap = asMap(contentObj);\n        if (contentMap == null) {\n            return null;\n        }\n        Object contents = extractResourceContent(contentMap);\n        return McpResourceContent.builder()\n                .uri(stringValue(contentMap.get(\"uri\")))\n                .mimeType(stringValue(contentMap.get(\"mimeType\")))\n                .contents(contents == null ? contentMap : contents)\n                .build();\n    }\n\n    private static Object extractResourceContent(Map<String, Object> contentMap) {\n        if (contentMap == null) {\n            return null;\n        }\n        if (contentMap.containsKey(\"text\")) {\n            return contentMap.get(\"text\");\n        }\n        if (contentMap.containsKey(\"blob\")) {\n            return contentMap.get(\"blob\");\n        }\n        if (contentMap.containsKey(\"contents\")) {\n            return contentMap.get(\"contents\");\n        }\n        return null;\n    }\n\n    private static void appendPromptMessageText(StringBuilder builder, Object content) {\n        if (content == null) {\n            return;\n        }\n        if (content instanceof String) {\n            builder.append(content);\n            return;\n        }\n        Map<String, Object> contentMap = asMap(content);\n        if (contentMap != null) {\n            Object text = contentMap.get(\"text\");\n            if (text != null) {\n                builder.append(text);\n                return;\n            }\n        }\n        List<Object> parts = asList(content);\n        for (Object partObj : parts) {\n            Map<String, Object> partMap = asMap(partObj);\n            if (partMap != null && partMap.get(\"text\") != null) {\n                builder.append(partMap.get(\"text\"));\n            }\n        }\n    }\n\n    private static Map<String, Object> asMap(Object value) {\n        if (!(value instanceof Map<?, ?>)) {\n            return null;\n        }\n        Map<String, Object> result = new HashMap<String, Object>();\n        for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {\n            if (entry.getKey() != null) {\n                result.put(String.valueOf(entry.getKey()), entry.getValue());\n            }\n        }\n        return result;\n    }\n\n    private static List<Object> asList(Object value) {\n        if (!(value instanceof List<?>)) {\n            return new ArrayList<Object>();\n        }\n        return new ArrayList<Object>((List<?>) value);\n    }\n\n    private static String stringValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        String text = String.valueOf(value);\n        return text.isEmpty() ? null : text;\n    }\n\n    private static Long longValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).longValue();\n        }\n        try {\n            return Long.parseLong(String.valueOf(value));\n        } catch (NumberFormatException ignored) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/FileMcpConfigSource.java",
    "content": "package io.github.lnyocly.ai4j.mcp.config;\n\nimport com.alibaba.fastjson2.JSON;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.CopyOnWriteArrayList;\n\n/**\n * @Author cly\n * @Description 基于文件的MCP配置源实现\n */\npublic class FileMcpConfigSource implements McpConfigSource {\n    \n    private static final Logger log = LoggerFactory.getLogger(FileMcpConfigSource.class);\n    \n    private final String configFile;\n    private final List<ConfigChangeListener> listeners = new CopyOnWriteArrayList<>();\n    private volatile Map<String, McpServerConfig.McpServerInfo> configs = new HashMap<>();\n    \n    public FileMcpConfigSource(String configFile) {\n        this.configFile = configFile;\n        loadConfigs();\n    }\n    \n    @Override\n    public Map<String, McpServerConfig.McpServerInfo> getAllConfigs() {\n        return new HashMap<>(configs);\n    }\n    \n    @Override\n    public McpServerConfig.McpServerInfo getConfig(String serverId) {\n        return configs.get(serverId);\n    }\n    \n    @Override\n    public void addConfigChangeListener(ConfigChangeListener listener) {\n        listeners.add(listener);\n    }\n    \n    @Override\n    public void removeConfigChangeListener(ConfigChangeListener listener) {\n        listeners.remove(listener);\n    }\n    \n    /**\n     * 重新加载配置文件（支持热更新）\n     */\n    public void reloadConfigs() {\n        Map<String, McpServerConfig.McpServerInfo> oldConfigs = new HashMap<>(configs);\n        loadConfigs();\n        Map<String, McpServerConfig.McpServerInfo> newConfigs = configs;\n        \n        // 检测配置变更并通知监听器\n        detectAndNotifyChanges(oldConfigs, newConfigs);\n    }\n    \n    /**\n     * 从文件加载配置\n     */\n    private void loadConfigs() {\n        try {\n            McpServerConfig serverConfig = McpConfigIO.loadServerConfig(configFile, getClass().getClassLoader());\n            Map<String, McpServerConfig.McpServerInfo> enabledConfigs =\n                    McpConfigIO.extractEnabledConfigs(serverConfig);\n            if (!enabledConfigs.isEmpty()) {\n                this.configs = enabledConfigs;\n                log.info(\"成功加载MCP配置文件: {}, 共 {} 个启用的服务\", configFile, enabledConfigs.size());\n            } else {\n                this.configs = new HashMap<>();\n                if (serverConfig == null) {\n                    log.info(\"未找到MCP配置文件: {}\", configFile);\n                } else {\n                    log.warn(\"MCP配置文件为空或没有启用的服务: {}\", configFile);\n                }\n            }\n        } catch (Exception e) {\n            this.configs = new HashMap<>();\n            log.error(\"加载MCP配置文件失败: {}\", configFile, e);\n        }\n    }\n    \n    /**\n     * 检测配置变更并通知监听器\n     */\n    private void detectAndNotifyChanges(Map<String, McpServerConfig.McpServerInfo> oldConfigs, \n                                       Map<String, McpServerConfig.McpServerInfo> newConfigs) {\n        \n        // 检测新增的配置\n        newConfigs.forEach((serverId, config) -> {\n            if (!oldConfigs.containsKey(serverId)) {\n                notifyConfigAdded(serverId, config);\n            } else if (!configEquals(oldConfigs.get(serverId), config)) {\n                notifyConfigUpdated(serverId, config);\n            }\n        });\n        \n        // 检测删除的配置\n        oldConfigs.forEach((serverId, config) -> {\n            if (!newConfigs.containsKey(serverId)) {\n                notifyConfigRemoved(serverId);\n            }\n        });\n    }\n    \n    /**\n     * 比较两个配置是否相等\n     */\n    private boolean configEquals(McpServerConfig.McpServerInfo config1, McpServerConfig.McpServerInfo config2) {\n        if (config1 == null && config2 == null) return true;\n        if (config1 == null || config2 == null) return false;\n        \n        // 简单的JSON序列化比较\n        try {\n            String json1 = JSON.toJSONString(config1);\n            String json2 = JSON.toJSONString(config2);\n            return json1.equals(json2);\n        } catch (Exception e) {\n            log.warn(\"比较配置时发生错误\", e);\n            return false;\n        }\n    }\n    \n    private void notifyConfigAdded(String serverId, McpServerConfig.McpServerInfo config) {\n        listeners.forEach(listener -> {\n            try {\n                listener.onConfigAdded(serverId, config);\n            } catch (Exception e) {\n                log.error(\"通知配置添加失败: {}\", serverId, e);\n            }\n        });\n    }\n    \n    private void notifyConfigRemoved(String serverId) {\n        listeners.forEach(listener -> {\n            try {\n                listener.onConfigRemoved(serverId);\n            } catch (Exception e) {\n                log.error(\"通知配置删除失败: {}\", serverId, e);\n            }\n        });\n    }\n    \n    private void notifyConfigUpdated(String serverId, McpServerConfig.McpServerInfo config) {\n        listeners.forEach(listener -> {\n            try {\n                listener.onConfigUpdated(serverId, config);\n            } catch (Exception e) {\n                log.error(\"通知配置更新失败: {}\", serverId, e);\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/McpConfigIO.java",
    "content": "package io.github.lnyocly.ai4j.mcp.config;\n\nimport com.alibaba.fastjson2.JSON;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * MCP 配置读取辅助类\n */\npublic final class McpConfigIO {\n\n    private McpConfigIO() {\n    }\n\n    public static McpServerConfig loadServerConfig(String configFile, ClassLoader classLoader) throws IOException {\n        String configContent = loadConfigContent(configFile, classLoader);\n        if (configContent == null || configContent.trim().isEmpty()) {\n            return null;\n        }\n        return JSON.parseObject(configContent, McpServerConfig.class);\n    }\n\n    public static Map<String, McpServerConfig.McpServerInfo> loadEnabledConfigs(\n            String configFile,\n            ClassLoader classLoader) throws IOException {\n        return extractEnabledConfigs(loadServerConfig(configFile, classLoader));\n    }\n\n    public static Map<String, McpServerConfig.McpServerInfo> extractEnabledConfigs(McpServerConfig serverConfig) {\n        Map<String, McpServerConfig.McpServerInfo> enabledConfigs =\n                new HashMap<String, McpServerConfig.McpServerInfo>();\n\n        if (serverConfig == null || serverConfig.getMcpServers() == null) {\n            return enabledConfigs;\n        }\n\n        serverConfig.getMcpServers().forEach((serverId, serverInfo) -> {\n            if (serverInfo.getEnabled() == null || serverInfo.getEnabled()) {\n                enabledConfigs.put(serverId, serverInfo);\n            }\n        });\n        return enabledConfigs;\n    }\n\n    public static String loadConfigContent(String configFile, ClassLoader classLoader) throws IOException {\n        InputStream inputStream = classLoader.getResourceAsStream(configFile);\n        if (inputStream != null) {\n            try {\n                byte[] bytes = readAllBytes(inputStream);\n                return new String(bytes, StandardCharsets.UTF_8);\n            } finally {\n                inputStream.close();\n            }\n        }\n\n        Path configPath = Paths.get(configFile);\n        if (Files.exists(configPath)) {\n            return new String(Files.readAllBytes(configPath), StandardCharsets.UTF_8);\n        }\n\n        return null;\n    }\n\n    private static byte[] readAllBytes(InputStream inputStream) throws IOException {\n        byte[] buffer = new byte[8192];\n        int bytesRead;\n        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n        while ((bytesRead = inputStream.read(buffer)) != -1) {\n            outputStream.write(buffer, 0, bytesRead);\n        }\n        return outputStream.toByteArray();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/McpConfigManager.java",
    "content": "package io.github.lnyocly.ai4j.mcp.config;\n\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransportFactory;\nimport io.github.lnyocly.ai4j.mcp.transport.TransportConfig;\nimport io.github.lnyocly.ai4j.mcp.util.McpTypeSupport;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.CopyOnWriteArrayList;\n\n/**\n * MCP配置管理器 - 支持动态配置和事件通知\n */\npublic class McpConfigManager implements McpConfigSource {\n    private static final Logger log = LoggerFactory.getLogger(McpConfigManager.class);\n\n    private final Map<String, McpServerConfig.McpServerInfo> configs = new ConcurrentHashMap<>();\n    private final List<McpConfigSource.ConfigChangeListener> listeners =\n            new CopyOnWriteArrayList<McpConfigSource.ConfigChangeListener>();\n\n    /**\n     * 兼容旧版监听器类型\n     */\n    @Deprecated\n    public interface ConfigChangeListener extends McpConfigSource.ConfigChangeListener {\n    }\n\n    /**\n     * 添加配置变更监听器\n     */\n    @Override\n    public void addConfigChangeListener(McpConfigSource.ConfigChangeListener listener) {\n        listeners.add(listener);\n    }\n\n    /**\n     * 移除配置变更监听器\n     */\n    @Override\n    public void removeConfigChangeListener(McpConfigSource.ConfigChangeListener listener) {\n        listeners.remove(listener);\n    }\n\n    /**\n     * 添加兼容旧版监听器\n     */\n    public void addConfigChangeListener(ConfigChangeListener listener) {\n        addConfigChangeListener((McpConfigSource.ConfigChangeListener) listener);\n    }\n\n    /**\n     * 移除兼容旧版监听器\n     */\n    public void removeConfigChangeListener(ConfigChangeListener listener) {\n        removeConfigChangeListener((McpConfigSource.ConfigChangeListener) listener);\n    }\n\n    /**\n     * 添加MCP服务器配置（支持运行时动态添加）\n     */\n    public void addConfig(String serverId, McpServerConfig.McpServerInfo config) {\n        McpServerConfig.McpServerInfo oldConfig = configs.put(serverId, config);\n        log.info(\"添加MCP服务器配置: {}\", serverId);\n\n        // 通知监听器\n        if (oldConfig == null) {\n            notifyConfigAdded(serverId, config);\n        } else {\n            notifyConfigUpdated(serverId, config);\n        }\n    }\n    \n    /**\n     * 删除MCP服务器配置（支持运行时动态删除）\n     */\n    public void removeConfig(String serverId) {\n        McpServerConfig.McpServerInfo removedConfig = configs.remove(serverId);\n        if (removedConfig != null) {\n            log.info(\"删除MCP服务器配置: {}\", serverId);\n            notifyConfigRemoved(serverId);\n        }\n    }\n    \n    /**\n     * 获取MCP服务器配置\n     */\n    @Override\n    public McpServerConfig.McpServerInfo getConfig(String serverId) {\n        return configs.get(serverId);\n    }\n\n    /**\n     * 获取所有配置\n     */\n    @Override\n    public Map<String, McpServerConfig.McpServerInfo> getAllConfigs() {\n        return new ConcurrentHashMap<>(configs);\n    }\n\n    /**\n     * 更新配置\n     */\n    public void updateConfig(String serverId, McpServerConfig.McpServerInfo config) {\n        addConfig(serverId, config);\n    }\n    \n    /**\n     * 检查配置是否存在\n     */\n    public boolean hasConfig(String serverId) {\n        return configs.containsKey(serverId);\n    }\n\n    /**\n     * 验证配置有效性\n     */\n    public boolean validateConfig(McpServerConfig.McpServerInfo config) {\n        if (config == null) {\n            return false;\n        }\n        if (config.getName() == null || config.getName().trim().isEmpty()) {\n            return false;\n        }\n\n        String normalizedType = McpTypeSupport.resolveType(config);\n        String rawType = config.getType() != null ? config.getType() : config.getTransport();\n        if (rawType != null && !rawType.trim().isEmpty() && !McpTypeSupport.isKnownType(rawType)) {\n            return false;\n        }\n\n        try {\n            TransportConfig transportConfig = TransportConfig.fromServerInfo(config);\n            McpTransportFactory.TransportType factoryType =\n                    McpTransportFactory.TransportType.fromString(normalizedType);\n            McpTransportFactory.validateConfig(factoryType, transportConfig);\n            return true;\n        } catch (Exception e) {\n            log.debug(\"MCP配置校验失败: {}\", config.getName(), e);\n            return false;\n        }\n    }\n\n    // 通知方法\n    private void notifyConfigAdded(String serverId, McpServerConfig.McpServerInfo config) {\n        for (McpConfigSource.ConfigChangeListener listener : listeners) {\n            try {\n                listener.onConfigAdded(serverId, config);\n            } catch (Exception e) {\n                log.error(\"通知配置添加事件失败: {}\", serverId, e);\n            }\n        }\n    }\n\n    private void notifyConfigRemoved(String serverId) {\n        for (McpConfigSource.ConfigChangeListener listener : listeners) {\n            try {\n                listener.onConfigRemoved(serverId);\n            } catch (Exception e) {\n                log.error(\"通知配置删除事件失败: {}\", serverId, e);\n            }\n        }\n    }\n\n    private void notifyConfigUpdated(String serverId, McpServerConfig.McpServerInfo config) {\n        for (McpConfigSource.ConfigChangeListener listener : listeners) {\n            try {\n                listener.onConfigUpdated(serverId, config);\n            } catch (Exception e) {\n                log.error(\"通知配置更新事件失败: {}\", serverId, e);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/McpConfigSource.java",
    "content": "package io.github.lnyocly.ai4j.mcp.config;\n\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description MCP配置源接口，支持多种配置来源（文件、数据库、Redis等）\n */\npublic interface McpConfigSource {\n    \n    /**\n     * 获取所有配置\n     */\n    Map<String, McpServerConfig.McpServerInfo> getAllConfigs();\n    \n    /**\n     * 获取指定配置\n     */\n    McpServerConfig.McpServerInfo getConfig(String serverId);\n    \n    /**\n     * 添加配置变更监听器\n     */\n    void addConfigChangeListener(ConfigChangeListener listener);\n    \n    /**\n     * 移除配置变更监听器\n     */\n    void removeConfigChangeListener(ConfigChangeListener listener);\n    \n    /**\n     * 配置变更监听器\n     */\n    interface ConfigChangeListener {\n        /**\n         * 配置添加事件\n         */\n        void onConfigAdded(String serverId, McpServerConfig.McpServerInfo config);\n        \n        /**\n         * 配置移除事件\n         */\n        void onConfigRemoved(String serverId);\n        \n        /**\n         * 配置更新事件\n         */\n        void onConfigUpdated(String serverId, McpServerConfig.McpServerInfo config);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/config/McpServerConfig.java",
    "content": "package io.github.lnyocly.ai4j.mcp.config;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description MCP服务器配置，对应mcp-servers-config.json文件格式\n */\n@Data\npublic class McpServerConfig {\n    \n    /**\n     * MCP服务器配置映射\n     */\n    @JsonProperty(\"mcpServers\")\n    private Map<String, McpServerInfo> mcpServers;\n    \n    /**\n     * 单个MCP服务器信息\n     */\n    @Data\n    public static class McpServerInfo {\n        \n        /**\n         * 服务器名称\n         */\n        private String name;\n        \n        /**\n         * 服务器描述\n         */\n        private String description;\n        \n        /**\n         * 启动命令\n         */\n        private String command;\n        \n        /**\n         * 命令参数\n         */\n        private List<String> args;\n        \n        /**\n         * 环境变量\n         */\n        private Map<String, String> env;\n        \n        /**\n         * 工作目录\n         */\n        private String cwd;\n\n        /**\n         * 传输类型：stdio, http (已弃用，使用type字段)\n         * @deprecated 使用 {@link #type} 字段替代\n         */\n        @Deprecated\n        private String transport = \"stdio\";\n\n        /**\n         * 传输类型：stdio, streamable_http, http, sse\n         */\n        private String type;\n\n        /**\n         * HTTP/SSE传输时的完整URL（包含端点路径）\n         * 例如：http://localhost:8080/mcp\n         */\n        private String url;\n\n        /**\n         * 自定义HTTP头（用于认证等）\n         */\n        private Map<String, String> headers;\n\n        // Getters and Setters for new fields\n        public String getType() {\n            return type;\n        }\n\n        public void setType(String type) {\n            this.type = type;\n        }\n\n        public Map<String, String> getHeaders() {\n            return headers;\n        }\n\n        public void setHeaders(Map<String, String> headers) {\n            this.headers = headers;\n        }\n        \n        /**\n         * 是否启用\n         */\n        private Boolean enabled = true;\n        \n        /**\n         * 是否自动重连\n         */\n        private Boolean autoReconnect = true;\n        \n        /**\n         * 重连间隔（毫秒）\n         */\n        private Long reconnectInterval = 5000L;\n        \n        /**\n         * 最大重连次数\n         */\n        private Integer maxReconnectAttempts = 3;\n        \n        /**\n         * 连接超时（毫秒）\n         */\n        private Long connectTimeout = 30000L;\n        \n        /**\n         * 标签\n         */\n        private List<String> tags;\n        \n        /**\n         * 优先级\n         */\n        private Integer priority = 0;\n\n        /**\n         * 配置版本（用于配置变更检测）\n         */\n        private Long version = System.currentTimeMillis();\n\n        /**\n         * 创建时间\n         */\n        private Long createdTime = System.currentTimeMillis();\n\n        /**\n         * 最后更新时间\n         */\n        private Long lastUpdatedTime = System.currentTimeMillis();\n\n        /**\n         * 是否需要用户认证\n         */\n        private Boolean requiresAuth = false;\n\n        /**\n         * 支持的认证类型\n         */\n        private List<String> authTypes;\n\n        /**\n         * 更新版本号\n         */\n        public void updateVersion() {\n            this.version = System.currentTimeMillis();\n            this.lastUpdatedTime = System.currentTimeMillis();\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpError.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description MCP错误信息\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpError {\n    \n    /**\n     * 错误代码\n     */\n    @JsonProperty(\"code\")\n    private Integer code;\n    \n    /**\n     * 错误消息\n     */\n    @JsonProperty(\"message\")\n    private String message;\n    \n    /**\n     * 错误详细数据\n     */\n    @JsonProperty(\"data\")\n    private Object data;\n    \n    /**\n     * 标准错误代码枚举\n     */\n    public enum ErrorCode {\n        // JSON-RPC标准错误代码\n        PARSE_ERROR(-32700, \"Parse error\"),\n        INVALID_REQUEST(-32600, \"Invalid Request\"),\n        METHOD_NOT_FOUND(-32601, \"Method not found\"),\n        INVALID_PARAMS(-32602, \"Invalid params\"),\n        INTERNAL_ERROR(-32603, \"Internal error\"),\n        \n        // MCP特定错误代码\n        INITIALIZATION_FAILED(-32000, \"Initialization failed\"),\n        CONNECTION_FAILED(-32001, \"Connection failed\"),\n        AUTHENTICATION_FAILED(-32002, \"Authentication failed\"),\n        RESOURCE_NOT_FOUND(-32003, \"Resource not found\"),\n        TOOL_NOT_FOUND(-32004, \"Tool not found\"),\n        PROMPT_NOT_FOUND(-32005, \"Prompt not found\"),\n        PERMISSION_DENIED(-32006, \"Permission denied\"),\n        TIMEOUT(-32007, \"Operation timeout\"),\n        RATE_LIMITED(-32008, \"Rate limited\"),\n        SERVER_UNAVAILABLE(-32009, \"Server unavailable\");\n        \n        private final int code;\n        private final String message;\n        \n        ErrorCode(int code, String message) {\n            this.code = code;\n            this.message = message;\n        }\n        \n        public int getCode() {\n            return code;\n        }\n        \n        public String getMessage() {\n            return message;\n        }\n        \n        public static ErrorCode fromCode(int code) {\n            for (ErrorCode errorCode : values()) {\n                if (errorCode.code == code) {\n                    return errorCode;\n                }\n            }\n            return null;\n        }\n    }\n    \n    /**\n     * 创建标准错误\n     */\n    public static McpError of(ErrorCode errorCode) {\n        return McpError.builder()\n                .code(errorCode.getCode())\n                .message(errorCode.getMessage())\n                .build();\n    }\n    \n    /**\n     * 创建自定义错误\n     */\n    public static McpError of(ErrorCode errorCode, String customMessage) {\n        return McpError.builder()\n                .code(errorCode.getCode())\n                .message(customMessage)\n                .build();\n    }\n    \n    /**\n     * 创建带详细数据的错误\n     */\n    public static McpError of(ErrorCode errorCode, String customMessage, Object data) {\n        return McpError.builder()\n                .code(errorCode.getCode())\n                .message(customMessage)\n                .data(data)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpInitializeResponse.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description MCP初始化响应\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpInitializeResponse {\n    \n    /**\n     * 协议版本\n     */\n    @JsonProperty(\"protocolVersion\")\n    private String protocolVersion;\n    \n    /**\n     * 服务器能力\n     */\n    @JsonProperty(\"capabilities\")\n    private Map<String, Object> capabilities;\n    \n    /**\n     * 服务器信息\n     */\n    @JsonProperty(\"serverInfo\")\n    private McpServerInfo serverInfo;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpMessage.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport lombok.experimental.SuperBuilder;\n\n/**\n * @Author cly\n * @Description MCP消息基类，基于JSON-RPC 2.0\n */\n@Data\n@SuperBuilder\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic abstract class McpMessage {\n    \n    /**\n     * JSON-RPC版本，固定为\"2.0\"\n     */\n    @Builder.Default\n    @JsonProperty(\"jsonrpc\")\n    private String jsonrpc = \"2.0\";\n    \n    /**\n     * 消息方法名\n     */\n    @JsonProperty(\"method\")\n    private String method;\n    \n    /**\n     * 消息ID（请求和响应时使用）\n     */\n    @JsonProperty(\"id\")\n    private Object id;\n    \n    /**\n     * 消息参数\n     */\n    @JsonProperty(\"params\")\n    private Object params;\n    \n    /**\n     * 响应结果（仅响应消息使用）\n     */\n    @JsonProperty(\"result\")\n    private Object result;\n    \n    /**\n     * 错误信息（仅错误响应使用）\n     */\n    @JsonProperty(\"error\")\n    private McpError error;\n    \n    /**\n     * 消息时间戳\n     */\n    @JsonIgnore\n    private Long timestamp;\n\n    /**\n     * 判断是否为请求消息\n     */\n    @JsonIgnore\n    public boolean isRequest() {\n        return method != null && id != null && result == null && error == null;\n    }\n\n    /**\n     * 判断是否为通知消息\n     */\n    @JsonIgnore\n    public boolean isNotification() {\n        return method != null && id == null;\n    }\n\n    /**\n     * 判断是否为响应消息\n     */\n    @JsonIgnore\n    public boolean isResponse() {\n        return method == null && id != null && (result != null || error != null);\n    }\n\n    /**\n     * 判断是否为成功响应\n     */\n    @JsonIgnore\n    public boolean isSuccessResponse() {\n        return isResponse() && error == null;\n    }\n\n    /**\n     * 判断是否为错误响应\n     */\n    @JsonIgnore\n    public boolean isErrorResponse() {\n        return isResponse() && error != null;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpNotification.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport lombok.Data;\nimport lombok.EqualsAndHashCode;\nimport lombok.experimental.SuperBuilder;\n\n/**\n * @Author cly\n * @Description MCP通知消息\n */\n@Data\n@SuperBuilder\n@EqualsAndHashCode(callSuper = true)\npublic class McpNotification extends McpMessage {\n    \n    public McpNotification() {\n        super();\n    }\n    \n    public McpNotification(String method, Object params) {\n        super();\n        setJsonrpc(\"2.0\");\n        setMethod(method);\n        setParams(params);\n        setId(null); // 通知消息没有ID\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpPrompt.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description MCP提示词模板\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpPrompt {\n    \n    /**\n     * 提示词名称\n     */\n    @JsonProperty(\"name\")\n    private String name;\n    \n    /**\n     * 提示词描述\n     */\n    @JsonProperty(\"description\")\n    private String description;\n    \n    /**\n     * 参数Schema\n     */\n    @JsonProperty(\"arguments\")\n    private Map<String, Object> arguments;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpPromptResult.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description MCP提示词结果\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpPromptResult {\n    \n    /**\n     * 提示词名称\n     */\n    @JsonProperty(\"name\")\n    private String name;\n    \n    /**\n     * 生成的提示词内容\n     */\n    @JsonProperty(\"content\")\n    private String content;\n    \n    /**\n     * 提示词描述\n     */\n    @JsonProperty(\"description\")\n    private String description;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpRequest.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport lombok.Data;\nimport lombok.EqualsAndHashCode;\nimport lombok.experimental.SuperBuilder;\n\n/**\n * @Author cly\n * @Description MCP请求消息\n */\n@Data\n@SuperBuilder\n@EqualsAndHashCode(callSuper = true)\npublic class McpRequest extends McpMessage {\n    \n    public McpRequest() {\n        super();\n    }\n    \n    public McpRequest(String method, Object id, Object params) {\n        super();\n        setJsonrpc(\"2.0\");\n        setMethod(method);\n        setId(id);\n        setParams(params);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpResource.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description MCP资源定义\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpResource {\n    \n    /**\n     * 资源URI\n     */\n    @JsonProperty(\"uri\")\n    private String uri;\n    \n    /**\n     * 资源名称\n     */\n    @JsonProperty(\"name\")\n    private String name;\n    \n    /**\n     * 资源描述\n     */\n    @JsonProperty(\"description\")\n    private String description;\n    \n    /**\n     * 资源MIME类型\n     */\n    @JsonProperty(\"mimeType\")\n    private String mimeType;\n    \n    /**\n     * 资源大小（字节）\n     */\n    @JsonProperty(\"size\")\n    private Long size;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpResourceContent.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description MCP资源内容\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpResourceContent {\n    \n    /**\n     * 资源URI\n     */\n    @JsonProperty(\"uri\")\n    private String uri;\n    \n    /**\n     * 资源内容\n     */\n    @JsonProperty(\"contents\")\n    private Object contents;\n    \n    /**\n     * 资源MIME类型\n     */\n    @JsonProperty(\"mimeType\")\n    private String mimeType;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpResponse.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport lombok.Data;\nimport lombok.EqualsAndHashCode;\nimport lombok.experimental.SuperBuilder;\n\n/**\n * @Author cly\n * @Description MCP响应消息\n */\n@Data\n@SuperBuilder\n@EqualsAndHashCode(callSuper = true)\npublic class McpResponse extends McpMessage {\n    \n    public McpResponse() {\n        super();\n    }\n    \n    public McpResponse(Object id, Object result) {\n        super();\n        setJsonrpc(\"2.0\");\n        setId(id);\n        setResult(result);\n    }\n    \n    public McpResponse(Object id, McpError error) {\n        super();\n        setJsonrpc(\"2.0\");\n        setId(id);\n        setError(error);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpRoot.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description MCP根目录\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpRoot {\n    \n    /**\n     * 根目录URI\n     */\n    @JsonProperty(\"uri\")\n    private String uri;\n    \n    /**\n     * 根目录名称\n     */\n    @JsonProperty(\"name\")\n    private String name;\n    \n    /**\n     * 根目录描述\n     */\n    @JsonProperty(\"description\")\n    private String description;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpSamplingRequest.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description MCP采样请求\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpSamplingRequest {\n    \n    /**\n     * 消息列表\n     */\n    @JsonProperty(\"messages\")\n    private List<Object> messages;\n    \n    /**\n     * 模型参数\n     */\n    @JsonProperty(\"modelPreferences\")\n    private Object modelPreferences;\n    \n    /**\n     * 系统提示词\n     */\n    @JsonProperty(\"systemPrompt\")\n    private String systemPrompt;\n    \n    /**\n     * 包含上下文\n     */\n    @JsonProperty(\"includeContext\")\n    private String includeContext;\n    \n    /**\n     * 最大令牌数\n     */\n    @JsonProperty(\"maxTokens\")\n    private Integer maxTokens;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpSamplingResult.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description MCP采样结果\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpSamplingResult {\n    \n    /**\n     * 生成的内容\n     */\n    @JsonProperty(\"content\")\n    private String content;\n    \n    /**\n     * 使用的模型\n     */\n    @JsonProperty(\"model\")\n    private String model;\n    \n    /**\n     * 停止原因\n     */\n    @JsonProperty(\"stopReason\")\n    private String stopReason;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpServerInfo.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description MCP服务器信息\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpServerInfo {\n    \n    /**\n     * 服务器名称\n     */\n    @JsonProperty(\"name\")\n    private String name;\n    \n    /**\n     * 服务器版本\n     */\n    @JsonProperty(\"version\")\n    private String version;\n    \n    /**\n     * 服务器描述\n     */\n    @JsonProperty(\"description\")\n    private String description;\n    \n    /**\n     * 服务器作者\n     */\n    @JsonProperty(\"author\")\n    private String author;\n    \n    /**\n     * 服务器主页\n     */\n    @JsonProperty(\"homepage\")\n    private String homepage;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpServerReference.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport io.github.lnyocly.ai4j.mcp.util.McpTypeSupport;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description MCP服务器引用对象，用于ChatCompletion中指定MCP服务\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpServerReference {\n    \n    /**\n     * 服务器ID或名称\n     */\n    private String name;\n    \n    /**\n     * 服务器描述\n     */\n    private String description;\n    \n    /**\n     * 启动命令（用于stdio传输）\n     */\n    private String command;\n    \n    /**\n     * 命令参数\n     */\n    private List<String> args;\n    \n    /**\n     * 环境变量\n     */\n    private Map<String, String> env;\n    \n    /**\n     * 工作目录\n     */\n    private String cwd;\n    \n    /**\n     * 传输类型：stdio, sse, streamable_http\n     */\n    private String type;\n\n    /**\n     * 旧版传输类型字段，保留兼容\n     */\n    @Deprecated\n    @Builder.Default\n    private String transport = McpTypeSupport.TYPE_STDIO;\n\n    /**\n     * HTTP/SSE传输时的URL\n     */\n    private String url;\n\n    /**\n     * 自定义HTTP头\n     */\n    private Map<String, String> headers;\n    \n    /**\n     * 是否启用\n     */\n    @Builder.Default\n    private Boolean enabled = true;\n    \n    /**\n     * 连接超时（毫秒）\n     */\n    @Builder.Default\n    private Long connectTimeout = 30000L;\n    \n    /**\n     * 标签\n     */\n    private List<String> tags;\n    \n    /**\n     * 优先级\n     */\n    @Builder.Default\n    private Integer priority = 0;\n    \n    /**\n     * 创建简单的服务器引用（仅指定名称）\n     */\n    public static McpServerReference of(String name) {\n        return McpServerReference.builder()\n                .name(name)\n                .build();\n    }\n    \n    /**\n     * 创建stdio传输的服务器引用\n     */\n    public static McpServerReference stdio(String name, String command, List<String> args) {\n        return McpServerReference.builder()\n                .name(name)\n                .command(command)\n                .args(args)\n                .type(McpTypeSupport.TYPE_STDIO)\n                .transport(McpTypeSupport.TYPE_STDIO)\n                .build();\n    }\n    \n    /**\n     * 创建Streamable HTTP传输的服务器引用\n     */\n    public static McpServerReference http(String name, String url) {\n        return McpServerReference.builder()\n                .name(name)\n                .url(url)\n                .type(McpTypeSupport.TYPE_STREAMABLE_HTTP)\n                .transport(\"http\")\n                .build();\n    }\n\n    /**\n     * 创建SSE传输的服务器引用\n     */\n    public static McpServerReference sse(String name, String url) {\n        return McpServerReference.builder()\n                .name(name)\n                .url(url)\n                .type(McpTypeSupport.TYPE_SSE)\n                .transport(McpTypeSupport.TYPE_SSE)\n                .build();\n    }\n\n    /**\n     * 获取归一化后的传输类型\n     */\n    public String getResolvedType() {\n        return McpTypeSupport.resolveType(this);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpTool.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description MCP工具定义\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpTool {\n    \n    /**\n     * 工具名称\n     */\n    @JsonProperty(\"name\")\n    private String name;\n    \n    /**\n     * 工具描述\n     */\n    @JsonProperty(\"description\")\n    private String description;\n    \n    /**\n     * 输入参数Schema（JSON Schema格式）\n     */\n    @JsonProperty(\"inputSchema\")\n    private Map<String, Object> inputSchema;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpToolDefinition.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description MCP工具定义，基于现有Function设计扩展\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpToolDefinition {\n    \n    /**\n     * 工具名称\n     */\n    @JsonProperty(\"name\")\n    private String name;\n    \n    /**\n     * 工具描述\n     */\n    @JsonProperty(\"description\")\n    private String description;\n    \n    /**\n     * 输入参数Schema（JSON Schema格式）\n     */\n    @JsonProperty(\"inputSchema\")\n    private Map<String, Object> inputSchema;\n    \n    /**\n     * 工具类的Class对象（用于反射调用）\n     */\n    private Class<?> functionClass;\n    \n    /**\n     * 请求参数类的Class对象\n     */\n    private Class<?> requestClass;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/entity/McpToolResult.java",
    "content": "package io.github.lnyocly.ai4j.mcp.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description MCP工具执行结果\n */\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class McpToolResult {\n    \n    /**\n     * 工具名称\n     */\n    @JsonProperty(\"toolName\")\n    private String toolName;\n    \n    /**\n     * 执行结果\n     */\n    @JsonProperty(\"result\")\n    private Object result;\n    \n    /**\n     * 是否成功\n     */\n    @JsonProperty(\"success\")\n    private Boolean success;\n    \n    /**\n     * 错误信息\n     */\n    @JsonProperty(\"error\")\n    private String error;\n    \n    /**\n     * 执行时间（毫秒）\n     */\n    @JsonProperty(\"executionTime\")\n    private Long executionTime;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGateway.java",
    "content": "package io.github.lnyocly.ai4j.mcp.gateway;\n\nimport io.github.lnyocly.ai4j.mcp.client.McpClient;\nimport io.github.lnyocly.ai4j.mcp.config.McpConfigIO;\nimport io.github.lnyocly.ai4j.mcp.config.McpConfigSource;\nimport io.github.lnyocly.ai4j.mcp.config.McpServerConfig;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.mcp.util.McpToolConversionSupport;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.*;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.stream.Collectors;\n\n/**\n * @Author cly\n * @Description 独立的MCP网关，管理多个MCP客户端连接，提供统一的工具调用接口\n */\npublic class McpGateway {\n\n    private static final Logger log = LoggerFactory.getLogger(McpGateway.class);\n    private static final String DEFAULT_CONFIG_FILE = \"mcp-servers-config.json\";\n\n    // 全局实例管理\n    private static volatile McpGateway globalInstance = null;\n    private static final Object instanceLock = new Object();\n\n    // 管理的MCP客户端\n    // Key格式：\n    // - 全局服务：serviceId (如 \"github\", \"filesystem\")\n    // - 用户服务：user_{userId}_service_{serviceId} (如 \"user_123_service_github\")\n    private final Map<String, McpClient> mcpClients;\n\n    // 工具名称到客户端的映射\n    // Key格式：\n    // - 全局工具：toolName (如 \"search_repositories\")\n    // - 用户工具：user_{userId}_tool_{toolName} (如 \"user_123_tool_search_repositories\")\n    private final McpGatewayToolRegistry toolRegistry;\n\n    // MCP服务器配置\n    private McpServerConfig serverConfig;\n\n    // 配置源（用于动态配置管理）\n    private McpConfigSource configSource;\n\n    private final McpGatewayClientFactory clientFactory;\n    private final McpGatewayConfigSourceBinding configSourceBinding;\n\n    // 网关是否已初始化\n    private volatile boolean initialized = false;\n\n    /**\n     * 获取全局MCP网关实例\n     */\n    public static McpGateway getInstance() {\n        if (globalInstance == null) {\n            synchronized (instanceLock) {\n                if (globalInstance == null) {\n                    globalInstance = new McpGateway();\n                    log.info(\"创建全局MCP网关实例\");\n                }\n            }\n        }\n        return globalInstance;\n    }\n\n    /**\n     * 设置全局MCP网关实例\n     */\n    public static void setGlobalInstance(McpGateway instance) {\n        synchronized (instanceLock) {\n            globalInstance = instance;\n            log.info(\"设置全局MCP网关实例\");\n        }\n    }\n\n    /**\n     * 清除全局实例（用于测试）\n     */\n    public static void clearGlobalInstance() {\n        synchronized (instanceLock) {\n            if (globalInstance != null) {\n                try {\n                    globalInstance.shutdown();\n                } catch (Exception e) {\n                    log.warn(\"关闭全局MCP网关实例时发生错误\", e);\n                }\n                globalInstance = null;\n                log.info(\"清除全局MCP网关实例\");\n            }\n        }\n    }\n\n    /**\n     * 检查网关是否已初始化\n     */\n    public boolean isInitialized() {\n        return initialized;\n    }\n\n    public McpGateway() {\n        this(new McpGatewayClientFactory());\n    }\n\n    McpGateway(McpGatewayClientFactory clientFactory) {\n        this.mcpClients = new ConcurrentHashMap<>();\n        this.toolRegistry = new McpGatewayToolRegistry();\n        this.clientFactory = clientFactory;\n        this.configSourceBinding = new McpGatewayConfigSourceBinding(this, clientFactory);\n    }\n\n    /**\n     * 设置配置源（用于动态配置管理）\n     */\n    public void setConfigSource(McpConfigSource configSource) {\n        McpConfigSource previousConfigSource = this.configSource;\n        this.configSource = configSource;\n        configSourceBinding.rebind(previousConfigSource, configSource, initialized);\n    }\n\n    /**\n     * 从配置源加载所有配置\n     */\n    private void loadConfigsFromSource() {\n        configSourceBinding.loadAll(configSource);\n    }\n\n    /**\n     * 初始化MCP网关，从配置文件加载MCP服务器配置\n     */\n    public CompletableFuture<Void> initialize() {\n        return initialize(DEFAULT_CONFIG_FILE);\n    }\n\n    /**\n     * 初始化MCP网关，从指定配置文件加载MCP服务器配置\n     */\n    public CompletableFuture<Void> initialize(String configFile) {\n        return CompletableFuture.runAsync(() -> {\n            if (initialized) {\n                log.warn(\"MCP网关已经初始化\");\n                return;\n            }\n\n            log.info(\"初始化MCP网关，配置文件: {}\", configFile);\n\n            try {\n                // 如果没有设置配置源，则从配置文件加载\n                if (configSource == null) {\n                    loadServerConfig(configFile);\n\n                    // 启动配置的MCP服务器\n                    if (serverConfig != null && serverConfig.getMcpServers() != null) {\n                        startConfiguredServers();\n                    }\n                } else {\n                    // 使用配置源\n                    loadConfigsFromSource();\n                }\n\n                initialized = true;\n\n                // 自动设置为全局实例（如果还没有全局实例的话）\n                if (globalInstance == null) {\n                    setGlobalInstance(this);\n                }\n\n                log.info(\"MCP网关初始化完成\");\n\n            } catch (Exception e) {\n                log.error(\"初始化MCP网关失败\", e);\n                throw new RuntimeException(\"初始化MCP网关失败\", e);\n            }\n        });\n    }\n    \n    /**\n     * 加载服务器配置文件\n     */\n    private void loadServerConfig(String configFile) {\n        try {\n            serverConfig = McpConfigIO.loadServerConfig(configFile, getClass().getClassLoader());\n            if (serverConfig != null) {\n                log.info(\"成功加载MCP服务器配置，共 {} 个服务器\",\n                    serverConfig.getMcpServers() != null ? serverConfig.getMcpServers().size() : 0);\n            } else {\n                log.info(\"未找到MCP服务器配置文件: {}\", configFile);\n            }\n        } catch (Exception e) {\n            log.warn(\"加载MCP服务器配置失败: {}\", configFile, e);\n        }\n    }\n\n    /**\n     * 启动配置文件中的MCP服务器\n     */\n    private void startConfiguredServers() {\n        List<CompletableFuture<Void>> futures = new ArrayList<>();\n\n        serverConfig.getMcpServers().forEach((serverId, serverInfo) -> {\n            if (serverInfo.getEnabled() != null && serverInfo.getEnabled()) {\n                CompletableFuture<Void> future = startMcpServer(serverId, serverInfo)\n                        .exceptionally(throwable -> {\n                            log.error(\"启动MCP服务器失败: {}\", serverId, throwable);\n                            return null;\n                        });\n                futures.add(future);\n            }\n        });\n\n        // 等待所有服务器启动完成\n        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();\n    }\n\n    /**\n     * 启动单个MCP服务器\n     */\n    private CompletableFuture<Void> startMcpServer(String serverId, McpServerConfig.McpServerInfo serverInfo) {\n        return CompletableFuture.runAsync(() -> {\n            log.info(\"启动MCP服务器: {} ({})\", serverId, serverInfo.getName());\n\n            try {\n                McpClient client = clientFactory.create(serverId, serverInfo);\n                addMcpClient(serverId, client).join();\n\n                log.info(\"MCP服务器启动成功: {}\", serverId);\n            } catch (Exception e) {\n                log.error(\"启动MCP服务器失败: {}\", serverId, e);\n                throw new RuntimeException(\"启动MCP服务器失败: \" + serverId, e);\n            }\n        });\n    }\n\n    /**\n     * 添加全局MCP客户端\n     */\n    public CompletableFuture<Void> addMcpClient(String serviceId, McpClient client) {\n        return addMcpClientInternal(serviceId, client);\n    }\n\n    /**\n     * 添加用户级别的MCP客户端\n     */\n    public CompletableFuture<Void> addUserMcpClient(String userId, String serviceId, McpClient client) {\n        String userClientKey = McpGatewayKeySupport.buildUserClientKey(userId, serviceId);\n        return addMcpClientInternal(userClientKey, client);\n    }\n\n    /**\n     * 内部统一的客户端添加方法\n     */\n    private CompletableFuture<Void> addMcpClientInternal(String clientKey, McpClient client) {\n        return CompletableFuture.runAsync(() -> {\n            log.info(\"添加MCP客户端: {}\", clientKey);\n\n            McpClient previousClient = mcpClients.put(clientKey, client);\n\n            // 连接客户端并获取工具列表\n            try {\n                client.connect().join();\n                toolRegistry.refresh(mcpClients).join();\n\n                if (previousClient != null && previousClient != client) {\n                    disconnectClientQuietly(clientKey, previousClient);\n                }\n\n                log.info(\"MCP客户端 {} 添加成功\", clientKey);\n            } catch (Exception e) {\n                mcpClients.remove(clientKey, client);\n                if (previousClient != null && previousClient != client) {\n                    mcpClients.put(clientKey, previousClient);\n                }\n                disconnectClientQuietly(clientKey, client);\n                log.error(\"添加MCP客户端失败: {}\", clientKey, e);\n                throw new RuntimeException(\"添加MCP客户端失败\", e);\n            }\n        });\n    }\n    \n    /**\n     * 动态添加MCP服务器\n     */\n    public CompletableFuture<Void> addMcpServer(String serverId, McpServerConfig.McpServerInfo serverInfo) {\n        return CompletableFuture.runAsync(() -> {\n            log.info(\"动态添加MCP服务器: {}\", serverId);\n\n            try {\n                McpClient client = clientFactory.create(serverId, serverInfo);\n                addMcpClient(serverId, client).join();\n\n                log.info(\"MCP服务器添加成功: {}\", serverId);\n            } catch (Exception e) {\n                log.error(\"添加MCP服务器失败: {}\", serverId, e);\n                throw new RuntimeException(\"添加MCP服务器失败\", e);\n            }\n        });\n    }\n\n    /**\n     * 移除全局MCP客户端\n     */\n    public CompletableFuture<Void> removeMcpClient(String serviceId) {\n        return removeMcpClientInternal(serviceId);\n    }\n\n    /**\n     * 移除用户级别的MCP客户端\n     */\n    public CompletableFuture<Void> removeUserMcpClient(String userId, String serviceId) {\n        String userClientKey = McpGatewayKeySupport.buildUserClientKey(userId, serviceId);\n        return removeMcpClientInternal(userClientKey);\n    }\n\n    /**\n     * 内部统一的客户端移除方法\n     */\n    private CompletableFuture<Void> removeMcpClientInternal(String clientKey) {\n        return CompletableFuture.runAsync(() -> {\n            McpClient client = mcpClients.remove(clientKey);\n            if (client != null) {\n                log.info(\"移除MCP客户端: {}\", clientKey);\n\n                try {\n                    client.disconnect().join();\n                    toolRegistry.refresh(mcpClients).join();\n\n                    log.info(\"MCP客户端 {} 移除成功\", clientKey);\n                } catch (Exception e) {\n                    log.error(\"移除MCP客户端时发生错误: {}\", clientKey, e);\n                }\n            }\n        });\n    }\n    \n    /**\n     * 获取所有可用的工具（转换为OpenAI Tool格式）\n     */\n    public CompletableFuture<List<Tool.Function>> getAvailableTools() {\n        if (toolRegistry.getAvailableToolsCache() != null) {\n            return CompletableFuture.completedFuture(toolRegistry.getAvailableToolsCache());\n        }\n\n        return toolRegistry.refresh(mcpClients)\n                .thenApply(v -> toolRegistry.getAvailableToolsCache());\n    }\n\n    /**\n     * 获取所有可用的工具（转换为OpenAI Tool格式）\n     */\n    public CompletableFuture<List<Tool.Function>> getAvailableTools(List<String> serviceIds) {\n        if (serviceIds != null && !serviceIds.isEmpty()) {\n            return CompletableFuture.completedFuture(mcpClients.entrySet().stream().filter(entry -> serviceIds.contains(entry.getKey())).map(entry -> {\n                return entry.getValue().getAvailableTools().join();\n            }).flatMap(list -> list.stream().map(McpToolConversionSupport::convertToOpenAiTool)).collect(Collectors.toList()));\n        }\n        if (toolRegistry.getAvailableToolsCache() != null) {\n            return CompletableFuture.completedFuture(toolRegistry.getAvailableToolsCache());\n        }\n\n        return toolRegistry.refresh(mcpClients)\n                .thenApply(v -> toolRegistry.getAvailableToolsCache());\n    }\n\n    /**\n     * 获取用户的可用工具（包括用户专属工具和全局工具）\n     */\n    public CompletableFuture<List<Tool.Function>> getUserAvailableTools(List<String> serviceIds, String userId) {\n        return CompletableFuture.supplyAsync(() -> {\n            List<Tool.Function> userTools = new ArrayList<>();\n            String userPrefix = McpGatewayKeySupport.buildUserPrefix(userId);\n\n            // 获取用户专属工具\n            mcpClients.entrySet().stream()\n                .filter(entry -> entry.getKey().startsWith(userPrefix))\n                .forEach(entry -> {\n                    try {\n                        List<McpToolDefinition> clientTools = entry.getValue().getAvailableTools().join();\n                        for (McpToolDefinition tool : clientTools) {\n                            userTools.add(McpToolConversionSupport.convertToOpenAiTool(tool));\n                        }\n                    } catch (Exception e) {\n                        log.error(\"获取用户工具列表失败: clientKey={}\", entry.getKey(), e);\n                    }\n                });\n\n            List<String> filterIds = serviceIds != null ? serviceIds : new ArrayList<>();\n            // 获取全局工具\n            mcpClients.entrySet().stream()\n                .filter(entry -> {\n                    if (McpGatewayKeySupport.isUserClientKey(entry.getKey())) {\n                        return false;\n                    }\n                    if (filterIds.isEmpty()) {\n                        return true;\n                    }\n                    return filterIds.contains(entry.getKey());\n                })\n                .forEach(entry -> {\n                    try {\n                        List<McpToolDefinition> clientTools = entry.getValue().getAvailableTools().join();\n                        for (McpToolDefinition tool : clientTools) {\n                            userTools.add(McpToolConversionSupport.convertToOpenAiTool(tool));\n                        }\n                    } catch (Exception e) {\n                        log.error(\"获取全局工具列表失败: clientKey={}\", entry.getKey(), e);\n                    }\n                });\n\n            return userTools;\n        });\n    }\n\n    /**\n     * 调用全局工具\n     */\n    public CompletableFuture<String> callTool(String toolName, Object arguments) {\n        return callToolInternal(toolName, arguments);\n    }\n\n    /**\n     * 调用用户工具（优先使用用户专属，回退到全局）\n     */\n    public CompletableFuture<String> callUserTool(String userId, String toolName, Object arguments) {\n        String userToolKey = McpGatewayKeySupport.buildUserToolKey(userId, toolName);\n\n        // 先尝试用户专属工具\n        String clientId = toolRegistry.getClientId(userToolKey);\n        if (clientId != null) {\n            log.debug(\"找到用户专属工具: {} -> {}\", userToolKey, clientId);\n            return callToolInternal(toolName, arguments, clientId);\n        }\n\n        // 回退到全局工具\n        log.debug(\"未找到用户专属工具，尝试全局工具: {}\", toolName);\n        return callToolInternal(toolName, arguments);\n    }\n\n    /**\n     * 内部工具调用方法（查找全局工具）\n     */\n    private CompletableFuture<String> callToolInternal(String toolName, Object arguments) {\n        String clientId = toolRegistry.getClientId(toolName);\n        if (clientId == null) {\n            CompletableFuture<String> future = new CompletableFuture<>();\n            future.completeExceptionally(new IllegalArgumentException(\"工具不存在: \" + toolName));\n            return future;\n        }\n\n        return callToolInternal(toolName, arguments, clientId);\n    }\n\n    /**\n     * 内部工具调用方法（指定客户端）\n     */\n    private CompletableFuture<String> callToolInternal(String toolName, Object arguments, String clientId) {\n        McpClient client = mcpClients.get(clientId);\n        if (client == null || !client.isConnected()) {\n            CompletableFuture<String> future = new CompletableFuture<>();\n            future.completeExceptionally(new IllegalStateException(\"MCP客户端不可用: \" + clientId));\n            return future;\n        }\n\n        log.info(\"调用MCP工具: {} 通过客户端: {}\", toolName, clientId);\n\n        return client.callTool(toolName, arguments)\n                .whenComplete((result, throwable) -> {\n                    if (throwable != null) {\n                        log.error(\"调用MCP工具失败: {}\", toolName, throwable);\n                    } else {\n                        log.debug(\"MCP工具调用成功: {}\", toolName);\n                    }\n                });\n    }\n\n    /**\n     * 清除用户的所有MCP客户端\n     */\n    public CompletableFuture<Void> clearUserMcpClients(String userId) {\n        return CompletableFuture.runAsync(() -> {\n            String userPrefix = McpGatewayKeySupport.buildUserPrefix(userId);\n\n            List<String> userClientKeys = mcpClients.keySet().stream()\n                .filter(key -> key.startsWith(userPrefix))\n                .collect(Collectors.toList());\n\n            for (String clientKey : userClientKeys) {\n                try {\n                    removeMcpClientInternal(clientKey).join();\n                } catch (Exception e) {\n                    log.error(\"清除用户MCP客户端失败: clientKey={}\", clientKey, e);\n                }\n            }\n\n            log.info(\"清除用户所有MCP客户端完成: userId={}, count={}\", userId, userClientKeys.size());\n        });\n    }\n    \n    /**\n     * 获取网关状态信息\n     */\n    public Map<String, Object> getGatewayStatus() {\n        Map<String, Object> status = new HashMap<>();\n\n        // 统计全局和用户客户端\n        long globalClients = mcpClients.keySet().stream()\n            .filter(key -> !McpGatewayKeySupport.isUserClientKey(key))\n            .count();\n\n        long userClients = mcpClients.keySet().stream()\n            .filter(McpGatewayKeySupport::isUserClientKey)\n            .count();\n\n        status.put(\"totalClients\", mcpClients.size());\n        status.put(\"globalClients\", globalClients);\n        status.put(\"userClients\", userClients);\n        status.put(\"connectedClients\", mcpClients.values().stream()\n                .mapToLong(client -> client.isConnected() ? 1 : 0)\n                .sum());\n        status.put(\"totalTools\", toolRegistry.snapshotMappings().size());\n\n        Map<String, Object> clientStatus = new HashMap<>();\n        mcpClients.forEach((id, client) -> {\n            Map<String, Object> info = new HashMap<>();\n            info.put(\"connected\", client.isConnected());\n            info.put(\"initialized\", client.isInitialized());\n            info.put(\"type\", McpGatewayKeySupport.isUserClientKey(id) ? \"user\" : \"global\");\n            clientStatus.put(id, info);\n        });\n        status.put(\"clients\", clientStatus);\n\n        return status;\n    }\n\n    /**\n     * 获取工具名称到客户端ID的映射\n     */\n    public Map<String, String> getToolToClientMap() {\n        return toolRegistry.snapshotMappings();\n    }\n    \n    /**\n     * 关闭网关，断开所有客户端连接\n     */\n    public CompletableFuture<Void> shutdown() {\n        return CompletableFuture.runAsync(() -> {\n            log.info(\"关闭MCP网关\");\n            \n            List<CompletableFuture<Void>> futures = mcpClients.values().stream()\n                    .map(McpClient::disconnect)\n                    .collect(Collectors.toList());\n            \n            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();\n            \n            mcpClients.clear();\n            toolRegistry.clearAll();\n            \n            log.info(\"MCP网关已关闭\");\n        });\n    }\n\n    private void disconnectClientQuietly(String clientKey, McpClient client) {\n        if (client == null) {\n            return;\n        }\n        try {\n            client.disconnect().join();\n        } catch (Exception e) {\n            log.warn(\"关闭旧MCP客户端失败: {}\", clientKey, e);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayClientFactory.java",
    "content": "package io.github.lnyocly.ai4j.mcp.gateway;\n\nimport io.github.lnyocly.ai4j.mcp.client.McpClient;\nimport io.github.lnyocly.ai4j.mcp.config.McpServerConfig;\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransport;\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransportFactory;\nimport io.github.lnyocly.ai4j.mcp.transport.TransportConfig;\nimport io.github.lnyocly.ai4j.mcp.util.McpTypeSupport;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * MCP Gateway 客户端创建器\n */\nclass McpGatewayClientFactory {\n\n    private static final Logger log = LoggerFactory.getLogger(McpGatewayClientFactory.class);\n    private static final String DEFAULT_CLIENT_VERSION = \"1.0.0\";\n\n    private final String clientVersion;\n\n    McpGatewayClientFactory() {\n        this(DEFAULT_CLIENT_VERSION);\n    }\n\n    McpGatewayClientFactory(String clientVersion) {\n        this.clientVersion = clientVersion;\n    }\n\n    public McpClient create(String serverId, McpServerConfig.McpServerInfo serverInfo) {\n        String transportType = McpTypeSupport.resolveType(serverInfo);\n\n        try {\n            TransportConfig config = TransportConfig.fromServerInfo(serverInfo);\n            McpTransportFactory.TransportType factoryType = McpTransportFactory.TransportType.fromString(transportType);\n            McpTransportFactory.validateConfig(factoryType, config);\n            McpTransport transport = McpTransportFactory.createTransport(factoryType, config);\n            return new McpClient(serverId, clientVersion, transport);\n        } catch (Exception e) {\n            log.error(\"创建MCP客户端失败: serverId={}, transportType={}\", serverId, transportType, e);\n            throw new RuntimeException(\"创建MCP客户端失败: \" + e.getMessage(), e);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayConfigSourceBinding.java",
    "content": "package io.github.lnyocly.ai4j.mcp.gateway;\n\nimport io.github.lnyocly.ai4j.mcp.client.McpClient;\nimport io.github.lnyocly.ai4j.mcp.config.McpConfigSource;\nimport io.github.lnyocly.ai4j.mcp.config.McpServerConfig;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.Map;\n\n/**\n * MCP Gateway 与配置源之间的桥接器\n */\nclass McpGatewayConfigSourceBinding {\n\n    private static final Logger log = LoggerFactory.getLogger(McpGatewayConfigSourceBinding.class);\n\n    private final McpGateway gateway;\n    private final McpGatewayClientFactory clientFactory;\n    private final McpConfigSource.ConfigChangeListener configChangeListener;\n\n    McpGatewayConfigSourceBinding(McpGateway gateway, McpGatewayClientFactory clientFactory) {\n        this.gateway = gateway;\n        this.clientFactory = clientFactory;\n        this.configChangeListener = new McpConfigSource.ConfigChangeListener() {\n            @Override\n            public void onConfigAdded(String serverId, McpServerConfig.McpServerInfo config) {\n                tryAddOrReplace(serverId, config, \"动态添加MCP服务成功\");\n            }\n\n            @Override\n            public void onConfigRemoved(String serverId) {\n                gateway.removeMcpClient(serverId).join();\n                log.info(\"动态移除MCP服务: {}\", serverId);\n            }\n\n            @Override\n            public void onConfigUpdated(String serverId, McpServerConfig.McpServerInfo config) {\n                tryAddOrReplace(serverId, config, \"动态更新MCP服务成功\");\n            }\n        };\n    }\n\n    public void rebind(McpConfigSource currentSource, McpConfigSource nextSource, boolean initialized) {\n        if (currentSource == nextSource) {\n            return;\n        }\n        if (currentSource != null) {\n            currentSource.removeConfigChangeListener(configChangeListener);\n        }\n        if (nextSource != null) {\n            nextSource.addConfigChangeListener(configChangeListener);\n            if (initialized) {\n                loadAll(nextSource);\n            }\n        }\n    }\n\n    public void loadAll(McpConfigSource configSource) {\n        if (configSource == null) {\n            return;\n        }\n        try {\n            Map<String, McpServerConfig.McpServerInfo> configs = configSource.getAllConfigs();\n            configs.forEach((serverId, config) -> {\n                try {\n                    McpClient client = clientFactory.create(serverId, config);\n                    gateway.addMcpClient(serverId, client).join();\n                } catch (Exception e) {\n                    log.error(\"从配置源加载MCP服务失败: {}\", serverId, e);\n                }\n            });\n            log.info(\"从配置源加载了 {} 个MCP服务\", configs.size());\n        } catch (Exception e) {\n            log.error(\"从配置源加载配置失败\", e);\n        }\n    }\n\n    private void tryAddOrReplace(String serverId, McpServerConfig.McpServerInfo config, String successLog) {\n        try {\n            McpClient client = clientFactory.create(serverId, config);\n            gateway.addMcpClient(serverId, client).join();\n            log.info(\"{}: {}\", successLog, serverId);\n        } catch (Exception e) {\n            log.error(\"{}: {}\", successLog.replace(\"成功\", \"失败\"), serverId, e);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayKeySupport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.gateway;\n\n/**\n * MCP gateway key 规则辅助类\n */\npublic final class McpGatewayKeySupport {\n\n    private McpGatewayKeySupport() {\n    }\n\n    public static String buildUserClientKey(String userId, String serviceId) {\n        return \"user_\" + userId + \"_service_\" + serviceId;\n    }\n\n    public static String buildUserToolKey(String userId, String toolName) {\n        return \"user_\" + userId + \"_tool_\" + toolName;\n    }\n\n    public static String buildUserPrefix(String userId) {\n        return \"user_\" + userId + \"_\";\n    }\n\n    public static boolean isUserClientKey(String clientKey) {\n        return clientKey != null && clientKey.startsWith(\"user_\") && clientKey.contains(\"_service_\");\n    }\n\n    public static String extractUserIdFromClientKey(String clientKey) {\n        if (!isUserClientKey(clientKey)) {\n            throw new IllegalArgumentException(\"不是有效的用户客户端Key: \" + clientKey);\n        }\n\n        String withoutPrefix = clientKey.substring(5);\n        int serviceIndex = withoutPrefix.indexOf(\"_service_\");\n        if (serviceIndex > 0) {\n            return withoutPrefix.substring(0, serviceIndex);\n        }\n\n        throw new IllegalArgumentException(\"无法从客户端Key中提取用户ID: \" + clientKey);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayToolRegistry.java",
    "content": "package io.github.lnyocly.ai4j.mcp.gateway;\n\nimport io.github.lnyocly.ai4j.mcp.client.McpClient;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.mcp.util.McpToolConversionSupport;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * MCP gateway 工具注册表，负责工具缓存与客户端映射\n */\npublic class McpGatewayToolRegistry {\n\n    private static final Logger log = LoggerFactory.getLogger(McpGatewayToolRegistry.class);\n\n    private final Map<String, String> toolToClientMap = new ConcurrentHashMap<String, String>();\n    private volatile List<Tool.Function> availableTools;\n\n    public List<Tool.Function> getAvailableToolsCache() {\n        return availableTools;\n    }\n\n    public String getClientId(String toolKey) {\n        return toolToClientMap.get(toolKey);\n    }\n\n    public Map<String, String> snapshotMappings() {\n        return new HashMap<String, String>(toolToClientMap);\n    }\n\n    public void clearClientMappings(String clientKey) {\n        toolToClientMap.entrySet().removeIf(entry -> clientKey.equals(entry.getValue()));\n        availableTools = null;\n        log.debug(\"清理客户端工具映射: {}\", clientKey);\n    }\n\n    public void clearAll() {\n        toolToClientMap.clear();\n        availableTools = null;\n    }\n\n    public CompletableFuture<Void> refresh(Map<String, McpClient> mcpClients) {\n        return CompletableFuture.runAsync(() -> {\n            log.info(\"刷新MCP工具映射\");\n\n            List<CompletableFuture<Void>> futures = new ArrayList<CompletableFuture<Void>>();\n            List<Tool.Function> refreshedTools = Collections.synchronizedList(new ArrayList<Tool.Function>());\n            Map<String, String> refreshedMappings = new ConcurrentHashMap<String, String>();\n\n            mcpClients.forEach((clientId, client) -> {\n                if (client.isConnected()) {\n                    CompletableFuture<Void> future = client.getAvailableTools()\n                            .thenAccept(tools -> registerClientTools(clientId, tools, refreshedTools, refreshedMappings))\n                            .exceptionally(throwable -> {\n                                log.error(\"获取客户端工具列表失败: {}\", clientId, throwable);\n                                return null;\n                            });\n                    futures.add(future);\n                }\n            });\n\n            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();\n\n            toolToClientMap.clear();\n            toolToClientMap.putAll(refreshedMappings);\n            availableTools = new ArrayList<Tool.Function>(refreshedTools);\n            log.info(\"工具映射刷新完成，共 {} 个工具\", refreshedTools.size());\n        });\n    }\n\n    private void registerClientTools(\n            String clientId,\n            List<McpToolDefinition> tools,\n            List<Tool.Function> refreshedTools,\n            Map<String, String> refreshedMappings) {\n        for (McpToolDefinition tool : tools) {\n            refreshedTools.add(McpToolConversionSupport.convertToOpenAiTool(tool));\n\n            if (McpGatewayKeySupport.isUserClientKey(clientId)) {\n                String userId = McpGatewayKeySupport.extractUserIdFromClientKey(clientId);\n                String userToolKey = McpGatewayKeySupport.buildUserToolKey(userId, tool.getName());\n                refreshedMappings.put(userToolKey, clientId);\n                log.debug(\"建立用户工具映射: {} -> {}\", userToolKey, clientId);\n            } else {\n                refreshedMappings.put(tool.getName(), clientId);\n                log.debug(\"建立全局工具映射: {} -> {}\", tool.getName(), clientId);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpHttpServerSupport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.sun.net.httpserver.HttpExchange;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * MCP HTTP 服务端辅助方法\n */\npublic final class McpHttpServerSupport {\n\n    private McpHttpServerSupport() {\n    }\n\n    public static void setCorsHeaders(HttpExchange exchange, String allowMethods, String allowHeaders) {\n        exchange.getResponseHeaders().add(\"Access-Control-Allow-Origin\", \"*\");\n        exchange.getResponseHeaders().add(\"Access-Control-Allow-Methods\", allowMethods);\n        exchange.getResponseHeaders().add(\"Access-Control-Allow-Headers\", allowHeaders);\n    }\n\n    public static String readRequestBody(HttpExchange exchange) throws IOException {\n        BufferedReader reader = new BufferedReader(\n                new InputStreamReader(exchange.getRequestBody(), StandardCharsets.UTF_8));\n        try {\n            StringBuilder sb = new StringBuilder();\n            String line;\n            while ((line = reader.readLine()) != null) {\n                sb.append(line);\n            }\n            return sb.toString();\n        } finally {\n            reader.close();\n        }\n    }\n\n    public static void writeJsonResponse(HttpExchange exchange, int statusCode, Object payload) throws IOException {\n        writeJsonResponse(exchange, statusCode, payload, null);\n    }\n\n    public static void writeJsonResponse(\n            HttpExchange exchange,\n            int statusCode,\n            Object payload,\n            Map<String, String> extraHeaders) throws IOException {\n        byte[] responseBytes = JSON.toJSONString(payload).getBytes(StandardCharsets.UTF_8);\n        exchange.getResponseHeaders().add(\"Content-Type\", \"application/json\");\n        if (extraHeaders != null) {\n            for (Map.Entry<String, String> entry : extraHeaders.entrySet()) {\n                exchange.getResponseHeaders().add(entry.getKey(), entry.getValue());\n            }\n        }\n        exchange.sendResponseHeaders(statusCode, responseBytes.length);\n\n        OutputStream os = exchange.getResponseBody();\n        try {\n            os.write(responseBytes);\n        } finally {\n            os.close();\n        }\n    }\n\n    public static void sendError(HttpExchange exchange, int statusCode, String message) throws IOException {\n        Map<String, Object> errorData = new HashMap<String, Object>();\n        errorData.put(\"code\", statusCode);\n        errorData.put(\"message\", message);\n\n        Map<String, Object> payload = new HashMap<String, Object>();\n        payload.put(\"error\", errorData);\n\n        writeJsonResponse(exchange, statusCode, payload);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServer.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport java.util.concurrent.CompletableFuture;\n\n/**\n * MCP服务器公共接口\n * 定义所有MCP服务器的基本操作\n * \n * @Author cly\n */\npublic interface McpServer {\n    \n    /**\n     * 启动MCP服务器\n     * \n     * @return CompletableFuture，完成时表示服务器启动完成\n     */\n    CompletableFuture<Void> start();\n    \n    /**\n     * 停止MCP服务器\n     * \n     * @return CompletableFuture，完成时表示服务器停止完成\n     */\n    CompletableFuture<Void> stop();\n    \n    /**\n     * 检查服务器是否正在运行\n     * \n     * @return true表示服务器正在运行，false表示已停止\n     */\n    boolean isRunning();\n    \n    /**\n     * 获取服务器信息\n     * \n     * @return 服务器信息字符串，包含名称、版本等\n     */\n    String getServerInfo();\n    \n    /**\n     * 获取服务器名称\n     * \n     * @return 服务器名称\n     */\n    String getServerName();\n    \n    /**\n     * 获取服务器版本\n     * \n     * @return 服务器版本\n     */\n    String getServerVersion();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServerEngine.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.entity.McpError;\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.entity.McpPrompt;\nimport io.github.lnyocly.ai4j.mcp.entity.McpPromptResult;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResource;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResourceContent;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResponse;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.mcp.util.McpPromptAdapter;\nimport io.github.lnyocly.ai4j.mcp.util.McpResourceAdapter;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * MCP 服务端公共协议处理引擎\n */\npublic class McpServerEngine {\n\n    private static final Logger log = LoggerFactory.getLogger(McpServerEngine.class);\n    private static final String DEFAULT_PROTOCOL_VERSION = \"2024-11-05\";\n\n    private final String serverName;\n    private final String serverVersion;\n    private final List<String> supportedProtocolVersions;\n    private final String defaultProtocolVersion;\n    private final boolean initializationRequired;\n    private final boolean pingEnabled;\n    private final boolean toolsListChanged;\n\n    public McpServerEngine(\n            String serverName,\n            String serverVersion,\n            List<String> supportedProtocolVersions,\n            String defaultProtocolVersion,\n            boolean initializationRequired,\n            boolean pingEnabled,\n            boolean toolsListChanged) {\n        this.serverName = serverName;\n        this.serverVersion = serverVersion;\n        this.supportedProtocolVersions = supportedProtocolVersions != null\n                ? new ArrayList<String>(supportedProtocolVersions)\n                : new ArrayList<String>();\n        this.defaultProtocolVersion = defaultProtocolVersion != null\n                ? defaultProtocolVersion\n                : DEFAULT_PROTOCOL_VERSION;\n        this.initializationRequired = initializationRequired;\n        this.pingEnabled = pingEnabled;\n        this.toolsListChanged = toolsListChanged;\n    }\n\n    public McpMessage processMessage(McpMessage message, McpServerSessionState session) {\n        if (message == null) {\n            return createErrorResponse(null, -32600, \"Invalid Request\");\n        }\n\n        if (message.isRequest()) {\n            String method = message.getMethod();\n\n            if (\"initialize\".equals(method)) {\n                return handleInitialize(message, session);\n            }\n            if (\"tools/list\".equals(method)) {\n                return handleToolsList(message, session);\n            }\n            if (\"tools/call\".equals(method)) {\n                return handleToolsCall(message, session);\n            }\n            if (\"resources/list\".equals(method)) {\n                return handleResourcesList(message, session);\n            }\n            if (\"resources/read\".equals(method)) {\n                return handleResourcesRead(message, session);\n            }\n            if (\"prompts/list\".equals(method)) {\n                return handlePromptsList(message, session);\n            }\n            if (\"prompts/get\".equals(method)) {\n                return handlePromptsGet(message, session);\n            }\n            if (\"ping\".equals(method) && pingEnabled) {\n                return handlePing(message);\n            }\n\n            return createErrorResponse(message.getId(), -32601, \"Method not found: \" + method);\n        }\n\n        if (message.isNotification()) {\n            handleNotification(message, session);\n            return null;\n        }\n\n        return createErrorResponse(message.getId(), -32600, \"Invalid Request\");\n    }\n\n    private McpMessage handleInitialize(McpMessage message, McpServerSessionState session) {\n        try {\n            Map<String, Object> params = asMap(message.getParams());\n            String requestedVersion = params != null ? stringValue(params.get(\"protocolVersion\")) : null;\n            String protocolVersion = resolveProtocolVersion(requestedVersion);\n            Map<String, Object> capabilities = buildCapabilities();\n\n            Map<String, Object> serverInfo = new HashMap<String, Object>();\n            serverInfo.put(\"name\", serverName);\n            serverInfo.put(\"version\", serverVersion);\n\n            Map<String, Object> result = new HashMap<String, Object>();\n            result.put(\"protocolVersion\", protocolVersion);\n            result.put(\"capabilities\", capabilities);\n            result.put(\"serverInfo\", serverInfo);\n\n            if (session != null) {\n                session.setInitialized(true);\n                session.getCapabilities().clear();\n                session.getCapabilities().putAll(capabilities);\n            }\n\n            McpResponse response = new McpResponse();\n            response.setId(message.getId());\n            response.setResult(result);\n            return response;\n        } catch (Exception e) {\n            log.error(\"处理初始化请求失败\", e);\n            return createErrorResponse(message.getId(), -32603, \"Internal error: \" + e.getMessage());\n        }\n    }\n\n    private McpMessage handleToolsList(McpMessage message, McpServerSessionState session) {\n        McpMessage initError = requireInitialization(message, session);\n        if (initError != null) {\n            return initError;\n        }\n\n        try {\n            Map<String, Object> result = new HashMap<String, Object>();\n            result.put(\"tools\", convertToMcpToolDefinitions());\n\n            McpResponse response = new McpResponse();\n            response.setId(message.getId());\n            response.setResult(result);\n            return response;\n        } catch (Exception e) {\n            log.error(\"处理工具列表请求失败\", e);\n            return createErrorResponse(message.getId(), -32603, \"Internal error: \" + e.getMessage());\n        }\n    }\n\n    private McpMessage handleToolsCall(McpMessage message, McpServerSessionState session) {\n        McpMessage initError = requireInitialization(message, session);\n        if (initError != null) {\n            return initError;\n        }\n\n        try {\n            Map<String, Object> params = asMap(message.getParams());\n            if (params == null) {\n                return createErrorResponse(message.getId(), -32602, \"Invalid params: object is required\");\n            }\n\n            String toolName = stringValue(params.get(\"name\"));\n            if (toolName == null || toolName.isEmpty()) {\n                return createErrorResponse(message.getId(), -32602, \"Invalid params: name is required\");\n            }\n\n            Object arguments = params.get(\"arguments\");\n            String result = ToolUtil.invoke(toolName, arguments != null ? JSON.toJSONString(arguments) : \"{}\");\n\n            Map<String, Object> textContent = new HashMap<String, Object>();\n            textContent.put(\"type\", \"text\");\n            textContent.put(\"text\", result != null ? result : \"\");\n\n            Map<String, Object> responseData = new HashMap<String, Object>();\n            responseData.put(\"content\", Arrays.asList(textContent));\n            responseData.put(\"isError\", false);\n\n            McpResponse response = new McpResponse();\n            response.setId(message.getId());\n            response.setResult(responseData);\n            return response;\n        } catch (Exception e) {\n            log.error(\"处理工具调用请求失败\", e);\n            return createErrorResponse(message.getId(), -32603, \"Internal error: \" + e.getMessage());\n        }\n    }\n\n    private McpMessage handleResourcesList(McpMessage message, McpServerSessionState session) {\n        McpMessage initError = requireInitialization(message, session);\n        if (initError != null) {\n            return initError;\n        }\n\n        try {\n            List<McpResource> resources = McpResourceAdapter.getAllMcpResources();\n\n            Map<String, Object> result = new HashMap<String, Object>();\n            result.put(\"resources\", resources);\n\n            McpResponse response = new McpResponse();\n            response.setId(message.getId());\n            response.setResult(result);\n            return response;\n        } catch (Exception e) {\n            log.error(\"处理资源列表请求失败\", e);\n            return createErrorResponse(message.getId(), -32603, \"Internal error: \" + e.getMessage());\n        }\n    }\n\n    private McpMessage handleResourcesRead(McpMessage message, McpServerSessionState session) {\n        McpMessage initError = requireInitialization(message, session);\n        if (initError != null) {\n            return initError;\n        }\n\n        try {\n            Map<String, Object> params = asMap(message.getParams());\n            String uri = params != null ? stringValue(params.get(\"uri\")) : null;\n\n            if (uri == null || uri.isEmpty()) {\n                return createErrorResponse(message.getId(), -32602, \"Invalid params: uri is required\");\n            }\n\n            McpResourceContent resourceContent = McpResourceAdapter.readMcpResource(uri);\n\n            Map<String, Object> content = new HashMap<String, Object>();\n            content.put(\"uri\", resourceContent.getUri());\n            content.put(\"mimeType\", resourceContent.getMimeType());\n\n            Object contents = resourceContent.getContents();\n            if (contents instanceof String) {\n                content.put(\"text\", contents);\n            } else {\n                content.put(\"text\", JSON.toJSONString(contents));\n            }\n\n            Map<String, Object> result = new HashMap<String, Object>();\n            result.put(\"contents\", Arrays.asList(content));\n\n            McpResponse response = new McpResponse();\n            response.setId(message.getId());\n            response.setResult(result);\n            return response;\n        } catch (Exception e) {\n            log.error(\"处理资源读取请求失败\", e);\n            return createErrorResponse(message.getId(), -32603, \"Internal error: \" + e.getMessage());\n        }\n    }\n\n    private McpMessage handlePromptsList(McpMessage message, McpServerSessionState session) {\n        McpMessage initError = requireInitialization(message, session);\n        if (initError != null) {\n            return initError;\n        }\n\n        try {\n            List<McpPrompt> prompts = McpPromptAdapter.getAllMcpPrompts();\n\n            Map<String, Object> result = new HashMap<String, Object>();\n            result.put(\"prompts\", prompts);\n\n            McpResponse response = new McpResponse();\n            response.setId(message.getId());\n            response.setResult(result);\n            return response;\n        } catch (Exception e) {\n            log.error(\"处理提示词列表请求失败\", e);\n            return createErrorResponse(message.getId(), -32603, \"Internal error: \" + e.getMessage());\n        }\n    }\n\n    private McpMessage handlePromptsGet(McpMessage message, McpServerSessionState session) {\n        McpMessage initError = requireInitialization(message, session);\n        if (initError != null) {\n            return initError;\n        }\n\n        try {\n            Map<String, Object> params = asMap(message.getParams());\n            String name = params != null ? stringValue(params.get(\"name\")) : null;\n            Map<String, Object> arguments = params != null ? asMap(params.get(\"arguments\")) : null;\n\n            if (name == null || name.isEmpty()) {\n                return createErrorResponse(message.getId(), -32602, \"Invalid params: name is required\");\n            }\n\n            McpPromptResult promptResult = McpPromptAdapter.getMcpPrompt(name, arguments);\n\n            Map<String, Object> content = new HashMap<String, Object>();\n            content.put(\"type\", \"text\");\n            content.put(\"text\", promptResult.getContent());\n\n            Map<String, Object> userMessage = new HashMap<String, Object>();\n            userMessage.put(\"role\", \"user\");\n            userMessage.put(\"content\", content);\n\n            Map<String, Object> result = new HashMap<String, Object>();\n            result.put(\"description\", promptResult.getDescription());\n            result.put(\"messages\", Arrays.asList(userMessage));\n\n            McpResponse response = new McpResponse();\n            response.setId(message.getId());\n            response.setResult(result);\n            return response;\n        } catch (Exception e) {\n            log.error(\"处理提示词获取请求失败\", e);\n            return createErrorResponse(message.getId(), -32603, \"Internal error: \" + e.getMessage());\n        }\n    }\n\n    private McpMessage handlePing(McpMessage message) {\n        try {\n            Map<String, Object> result = new HashMap<String, Object>();\n            result.put(\"status\", \"pong\");\n\n            McpResponse response = new McpResponse();\n            response.setId(message.getId());\n            response.setResult(result);\n            return response;\n        } catch (Exception e) {\n            log.error(\"处理 ping 请求失败\", e);\n            return createErrorResponse(message.getId(), -32603, \"Internal error: \" + e.getMessage());\n        }\n    }\n\n    private void handleNotification(McpMessage message, McpServerSessionState session) {\n        if (\"notifications/initialized\".equals(message.getMethod()) && session != null) {\n            session.setInitialized(true);\n        }\n    }\n\n    private McpMessage requireInitialization(McpMessage message, McpServerSessionState session) {\n        if (initializationRequired && (session == null || !session.isInitialized())) {\n            return createErrorResponse(message != null ? message.getId() : null, -32002, \"Server not initialized\");\n        }\n        return null;\n    }\n\n    private String resolveProtocolVersion(String requestedVersion) {\n        if (requestedVersion != null && supportedProtocolVersions.contains(requestedVersion)) {\n            return requestedVersion;\n        }\n        if (!supportedProtocolVersions.isEmpty() && supportedProtocolVersions.contains(defaultProtocolVersion)) {\n            return defaultProtocolVersion;\n        }\n        if (!supportedProtocolVersions.isEmpty()) {\n            return supportedProtocolVersions.get(0);\n        }\n        return requestedVersion != null ? requestedVersion : defaultProtocolVersion;\n    }\n\n    private Map<String, Object> buildCapabilities() {\n        Map<String, Object> capabilities = new HashMap<String, Object>();\n\n        Map<String, Object> toolsCapability = new HashMap<String, Object>();\n        if (toolsListChanged) {\n            toolsCapability.put(\"listChanged\", true);\n        }\n        capabilities.put(\"tools\", toolsCapability);\n\n        Map<String, Object> resourcesCapability = new HashMap<String, Object>();\n        resourcesCapability.put(\"subscribe\", true);\n        resourcesCapability.put(\"listChanged\", true);\n        capabilities.put(\"resources\", resourcesCapability);\n\n        Map<String, Object> promptsCapability = new HashMap<String, Object>();\n        promptsCapability.put(\"listChanged\", true);\n        capabilities.put(\"prompts\", promptsCapability);\n\n        return capabilities;\n    }\n\n    private McpResponse createErrorResponse(Object id, int code, String message) {\n        McpError error = new McpError();\n        error.setCode(code);\n        error.setMessage(message);\n\n        McpResponse response = new McpResponse();\n        response.setId(id);\n        response.setError(error);\n        return response;\n    }\n\n    private List<McpToolDefinition> convertToMcpToolDefinitions() {\n        List<McpToolDefinition> mcpTools = new ArrayList<McpToolDefinition>();\n\n        try {\n            List<Tool> tools = ToolUtil.getLocalMcpTools();\n            for (Tool tool : tools) {\n                if (tool.getFunction() != null) {\n                    McpToolDefinition mcpTool = McpToolDefinition.builder()\n                            .name(tool.getFunction().getName())\n                            .description(tool.getFunction().getDescription())\n                            .inputSchema(convertParametersToInputSchema(tool.getFunction().getParameters()))\n                            .build();\n                    mcpTools.add(mcpTool);\n                }\n            }\n        } catch (Exception e) {\n            log.error(\"转换工具列表失败\", e);\n        }\n\n        return mcpTools;\n    }\n\n    private Map<String, Object> convertParametersToInputSchema(Tool.Function.Parameter parameters) {\n        Map<String, Object> schema = new HashMap<String, Object>();\n\n        if (parameters != null) {\n            schema.put(\"type\", parameters.getType());\n            if (parameters.getProperties() != null) {\n                schema.put(\"properties\", parameters.getProperties());\n            }\n            if (parameters.getRequired() != null) {\n                schema.put(\"required\", parameters.getRequired());\n            }\n        }\n\n        return schema;\n    }\n\n    private Map<String, Object> asMap(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (!(value instanceof Map<?, ?>)) {\n            throw new IllegalArgumentException(\"MCP消息参数必须为对象\");\n        }\n\n        Map<String, Object> result = new HashMap<String, Object>();\n        for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {\n            if (entry.getKey() != null) {\n                result.put(String.valueOf(entry.getKey()), entry.getValue());\n            }\n        }\n        return result;\n    }\n\n    private String stringValue(Object value) {\n        return value != null ? String.valueOf(value) : null;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServerFactory.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport io.github.lnyocly.ai4j.mcp.util.McpTypeSupport;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * MCP服务器工厂类\n * 统一创建不同类型的MCP服务器\n * \n * @Author cly\n */\npublic class McpServerFactory {\n    \n    private static final Logger log = LoggerFactory.getLogger(McpServerFactory.class);\n    \n    /**\n     * 服务器类型枚举\n     */\n    public enum ServerType {\n        STDIO(\"stdio\"),\n        SSE(\"sse\"),\n        STREAMABLE_HTTP(\"streamable_http\"),\n        @Deprecated\n        HTTP(\"http\"); // 向后兼容，映射到streamable_http\n        \n        private final String value;\n        \n        ServerType(String value) {\n            this.value = value;\n        }\n        \n        public String getValue() {\n            return value;\n        }\n        \n        /**\n         * 从字符串创建服务器类型\n         */\n        public static ServerType fromString(String value) {\n            if (!McpTypeSupport.isKnownType(value)) {\n                log.warn(\"未知的服务器类型: {}, 使用默认的stdio\", value);\n            }\n\n            String normalizedType = McpTypeSupport.normalizeType(value);\n            switch (normalizedType) {\n                case McpTypeSupport.TYPE_SSE:\n                    return SSE;\n                case McpTypeSupport.TYPE_STREAMABLE_HTTP:\n                    return STREAMABLE_HTTP;\n                case McpTypeSupport.TYPE_STDIO:\n                default:\n                    return STDIO;\n            }\n        }\n    }\n    \n    /**\n     * 服务器配置类\n     */\n    public static class ServerConfig {\n        private String name;\n        private String version;\n        private Integer port;\n        private String host;\n        \n        public ServerConfig(String name, String version) {\n            this.name = name;\n            this.version = version;\n            this.port = 8080; // 默认端口\n            this.host = \"localhost\"; // 默认主机\n        }\n        \n        public ServerConfig withPort(int port) {\n            this.port = port;\n            return this;\n        }\n        \n        public ServerConfig withHost(String host) {\n            this.host = host;\n            return this;\n        }\n        \n        // Getters\n        public String getName() { return name; }\n        public String getVersion() { return version; }\n        public Integer getPort() { return port; }\n        public String getHost() { return host; }\n        \n        // Setters\n        public void setName(String name) { this.name = name; }\n        public void setVersion(String version) { this.version = version; }\n        public void setPort(Integer port) { this.port = port; }\n        public void setHost(String host) { this.host = host; }\n        \n        @Override\n        public String toString() {\n            return \"ServerConfig{\" +\n                    \"name='\" + name + '\\'' +\n                    \", version='\" + version + '\\'' +\n                    \", port=\" + port +\n                    \", host='\" + host + '\\'' +\n                    '}';\n        }\n    }\n    \n    /**\n     * 创建MCP服务器\n     *\n     * @param type 服务器类型\n     * @param config 服务器配置\n     * @return MCP服务器实例\n     */\n    public static McpServer createServer(ServerType type, ServerConfig config) {\n        if (config == null) {\n            throw new IllegalArgumentException(\"服务器配置不能为空\");\n        }\n        \n        log.debug(\"创建MCP服务器: type={}, config={}\", type, config);\n        \n        switch (type) {\n            case STDIO:\n                return createStdioServer(config);\n            case SSE:\n                return createSseServer(config);\n            case STREAMABLE_HTTP:\n            case HTTP:\n                return createStreamableHttpServer(config);\n            default:\n                throw new IllegalArgumentException(\"不支持的服务器类型: \" + type);\n        }\n    }\n    \n    /**\n     * 便捷方法：从字符串类型创建服务器\n     */\n    public static McpServer createServer(String typeString, ServerConfig config) {\n        ServerType type = ServerType.fromString(typeString);\n        return createServer(type, config);\n    }\n\n    /**\n     * 便捷方法：使用默认配置创建服务器\n     */\n    public static McpServer createServer(String typeString, String name, String version) {\n        return createServer(typeString, new ServerConfig(name, version));\n    }\n\n    /**\n     * 便捷方法：创建带端口的服务器\n     */\n    public static McpServer createServer(String typeString, String name, String version, int port) {\n        return createServer(typeString, new ServerConfig(name, version).withPort(port));\n    }\n    \n    /**\n     * 创建Stdio服务器\n     */\n    private static StdioMcpServer createStdioServer(ServerConfig config) {\n        return new StdioMcpServer(config.getName(), config.getVersion());\n    }\n    \n    /**\n     * 创建SSE服务器\n     */\n    private static SseMcpServer createSseServer(ServerConfig config) {\n        return new SseMcpServer(config.getName(), config.getVersion(), config.getPort());\n    }\n    \n    /**\n     * 创建Streamable HTTP服务器\n     */\n    private static StreamableHttpMcpServer createStreamableHttpServer(ServerConfig config) {\n        return new StreamableHttpMcpServer(config.getName(), config.getVersion(), config.getPort());\n    }\n    \n    /**\n     * 验证服务器配置\n     */\n    public static void validateConfig(ServerType type, ServerConfig config) {\n        if (config == null) {\n            throw new IllegalArgumentException(\"服务器配置不能为空\");\n        }\n        \n        if (config.getName() == null || config.getName().trim().isEmpty()) {\n            throw new IllegalArgumentException(\"服务器名称不能为空\");\n        }\n        \n        if (config.getVersion() == null || config.getVersion().trim().isEmpty()) {\n            throw new IllegalArgumentException(\"服务器版本不能为空\");\n        }\n        \n        switch (type) {\n            case SSE:\n            case STREAMABLE_HTTP:\n            case HTTP:\n                if (config.getPort() == null || config.getPort() <= 0 || config.getPort() > 65535) {\n                    throw new IllegalArgumentException(\"HTTP/SSE服务器需要有效的端口号 (1-65535)\");\n                }\n                break;\n            case STDIO:\n                // Stdio服务器不需要端口\n                break;\n        }\n    }\n    \n    /**\n     * 获取所有支持的服务器类型\n     */\n    public static ServerType[] getSupportedTypes() {\n        return ServerType.values();\n    }\n    \n    /**\n     * 检查服务器类型是否支持\n     */\n    public static boolean isSupported(String typeString) {\n        return McpTypeSupport.isKnownType(typeString);\n    }\n    \n    /**\n     * 启动服务器的通用方法\n     */\n    public static void startServer(McpServer server) {\n        try {\n            server.start().join();\n        } catch (Exception e) {\n            log.error(\"启动服务器失败\", e);\n            throw new RuntimeException(\"启动服务器失败\", e);\n        }\n    }\n\n    /**\n     * 停止服务器的通用方法\n     */\n    public static void stopServer(McpServer server) {\n        try {\n            server.stop().join();\n        } catch (Exception e) {\n            log.error(\"停止服务器失败\", e);\n            throw new RuntimeException(\"停止服务器失败\", e);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServerSessionState.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * MCP 服务端会话状态\n */\npublic class McpServerSessionState {\n\n    private final String sessionId;\n    private volatile boolean initialized;\n    private final Map<String, Object> capabilities = new ConcurrentHashMap<String, Object>();\n\n    public McpServerSessionState(String sessionId) {\n        this.sessionId = sessionId;\n    }\n\n    public String getSessionId() {\n        return sessionId;\n    }\n\n    public boolean isInitialized() {\n        return initialized;\n    }\n\n    public void setInitialized(boolean initialized) {\n        this.initialized = initialized;\n    }\n\n    public Map<String, Object> getCapabilities() {\n        return capabilities;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/McpServerSessionSupport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport java.util.Map;\nimport java.util.Random;\nimport java.util.function.Function;\n\n/**\n * MCP 服务端会话辅助方法\n */\npublic final class McpServerSessionSupport {\n\n    private McpServerSessionSupport() {\n    }\n\n    public static String generateSessionId(String prefix) {\n        return prefix + \"_\" + System.currentTimeMillis() + \"_\" + Integer.toHexString(new Random().nextInt());\n    }\n\n    public static <T extends McpServerSessionState> T getOrCreateSession(\n            Map<String, T> sessions,\n            String sessionId,\n            String sessionPrefix,\n            Function<String, T> sessionFactory) {\n        String resolvedSessionId = sessionId;\n        if (resolvedSessionId == null) {\n            resolvedSessionId = generateSessionId(sessionPrefix);\n        }\n\n        T existing = sessions.get(resolvedSessionId);\n        if (existing != null) {\n            return existing;\n        }\n\n        T newSession = sessionFactory.apply(resolvedSessionId);\n        sessions.put(resolvedSessionId, newSession);\n        return newSession;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/SseMcpServer.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.sun.net.httpserver.HttpExchange;\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.util.McpMessageCodec;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.io.OutputStreamWriter;\nimport java.io.PrintWriter;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\n/**\n *\n * 1. 提供两个独立端点：/sse (SSE连接) 和 /message (HTTP POST)\n * 2. 客户端首先连接/sse端点建立SSE连接\n * 3. 服务器发送'endpoint'事件，告知客户端POST端点URL\n * 4. 客户端使用POST端点发送JSON-RPC消息\n * 5. 服务器通过SSE发送'message'事件响应\n */\npublic class SseMcpServer implements McpServer {\n\n    private static final Logger log = LoggerFactory.getLogger(SseMcpServer.class);\n\n    private final String serverName;\n    private final String serverVersion;\n    private final int port;\n    private final AtomicBoolean running = new AtomicBoolean(false);\n    private final ConcurrentHashMap<String, PrintWriter> sseClients = new ConcurrentHashMap<String, PrintWriter>();\n    private final ConcurrentHashMap<String, SessionContext> sessions = new ConcurrentHashMap<String, SessionContext>();\n    private final McpServerEngine serverEngine;\n    private HttpServer httpServer;\n\n    public SseMcpServer(String serverName, String serverVersion, int port) {\n        this.serverName = serverName;\n        this.serverVersion = serverVersion;\n        this.port = port;\n        this.serverEngine = new McpServerEngine(\n                serverName,\n                serverVersion,\n                Collections.singletonList(\"2024-11-05\"),\n                \"2024-11-05\",\n                true,\n                true,\n                false);\n    }\n\n    public CompletableFuture<Void> start() {\n        return CompletableFuture.runAsync(new Runnable() {\n            @Override\n            public void run() {\n                if (running.compareAndSet(false, true)) {\n                    try {\n                        log.info(\"启动SSE MCP服务器: {} v{}, 端口: {}\", serverName, serverVersion, port);\n\n                        httpServer = HttpServer.create(new InetSocketAddress(port), 0);\n                        httpServer.createContext(\"/sse\", new SseHandler());\n                        httpServer.createContext(\"/message\", new MessageHandler());\n                        httpServer.createContext(\"/health\", new HealthHandler());\n                        httpServer.createContext(\"/\", new RootHandler());\n                        httpServer.setExecutor(Executors.newCachedThreadPool());\n                        httpServer.start();\n\n                        log.info(\"SSE MCP服务器启动成功\");\n                        log.info(\"SSE端点: http://localhost:{}/sse\", port);\n                        log.info(\"POST端点: http://localhost:{}/message\", port);\n                        log.info(\"健康检查: http://localhost:{}/health\", port);\n                    } catch (Exception e) {\n                        running.set(false);\n                        log.error(\"启动SSE MCP服务器失败\", e);\n                        throw new RuntimeException(\"启动SSE MCP服务器失败\", e);\n                    }\n                }\n            }\n        });\n    }\n\n    public CompletableFuture<Void> stop() {\n        return CompletableFuture.runAsync(new Runnable() {\n            @Override\n            public void run() {\n                if (running.compareAndSet(true, false)) {\n                    log.info(\"停止SSE MCP服务器\");\n                    if (httpServer != null) {\n                        httpServer.stop(5);\n                    }\n                    sseClients.clear();\n                    sessions.clear();\n                    log.info(\"SSE MCP服务器已停止\");\n                }\n            }\n        });\n    }\n\n    public boolean isRunning() {\n        return running.get();\n    }\n\n    public String getServerInfo() {\n        return String.format(\"%s v%s (sse)\", serverName, serverVersion);\n    }\n\n    public String getServerName() {\n        return serverName;\n    }\n\n    public String getServerVersion() {\n        return serverVersion;\n    }\n\n    /**\n     * 会话上下文\n     */\n    private static class SessionContext extends McpServerSessionState {\n        private final long createdTime;\n\n        public SessionContext(String sessionId) {\n            super(sessionId);\n            this.createdTime = System.currentTimeMillis();\n        }\n\n        public long getCreatedTime() {\n            return createdTime;\n        }\n    }\n\n    /**\n     * SSE处理器 - 只处理GET请求建立SSE连接\n     */\n    private class SseHandler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            McpHttpServerSupport.setCorsHeaders(exchange, \"GET, POST, OPTIONS\", \"Content-Type, Mcp-Session-Id, Accept\");\n\n            String method = exchange.getRequestMethod();\n            String sessionId = exchange.getRequestHeaders().getFirst(\"mcp-session-id\");\n\n            log.debug(\"SSE端点收到请求: {} {}, Session: {}\", method, exchange.getRequestURI().getPath(), sessionId);\n\n            if (\"OPTIONS\".equals(method)) {\n                exchange.sendResponseHeaders(200, 0);\n                exchange.close();\n                return;\n            }\n\n            if (!\"GET\".equals(method)) {\n                log.warn(\"SSE端点收到非GET请求: {}\", method);\n                McpHttpServerSupport.sendError(exchange, 405, \"Method Not Allowed: SSE endpoint only supports GET\");\n                return;\n            }\n\n            String acceptHeader = exchange.getRequestHeaders().getFirst(\"Accept\");\n            if (acceptHeader == null || !acceptHeader.contains(\"text/event-stream\")) {\n                McpHttpServerSupport.sendError(exchange, 400, \"Bad Request: Must accept text/event-stream\");\n                return;\n            }\n\n            establishSseConnection(exchange);\n        }\n    }\n\n    /**\n     * 消息处理器 - 处理客户端发送的消息\n     */\n    private class MessageHandler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            McpHttpServerSupport.setCorsHeaders(exchange, \"GET, POST, OPTIONS\", \"Content-Type, Mcp-Session-Id, Accept\");\n\n            String method = exchange.getRequestMethod();\n            String sessionId = exchange.getRequestHeaders().getFirst(\"mcp-session-id\");\n\n            log.debug(\"POST端点收到请求: {} {}, Session: {}\", method, exchange.getRequestURI().getPath(), sessionId);\n\n            if (\"OPTIONS\".equals(method)) {\n                exchange.sendResponseHeaders(200, 0);\n                exchange.close();\n                return;\n            }\n\n            if (!\"POST\".equals(method)) {\n                log.warn(\"POST端点收到非POST请求: {} - 客户端实现可能有误\", method);\n\n                if (\"GET\".equals(method)) {\n                    Map<String, Object> error = new HashMap<String, Object>();\n                    error.put(\"error\", \"Method Not Allowed\");\n                    error.put(\"message\", \"The /message endpoint only accepts POST requests with JSON-RPC messages\");\n                    error.put(\"receivedMethod\", method);\n                    error.put(\"expectedMethod\", \"POST\");\n                    error.put(\"hint\", \"This suggests a client implementation issue. Check your MCP SSE client configuration.\");\n                    error.put(\"correctFlow\", new String[]{\n                            \"1. Connect to GET /sse to establish SSE connection\",\n                            \"2. Receive 'endpoint' event with message URL\",\n                            \"3. Send POST requests to /message endpoint\"\n                    });\n\n                    String errorResponse = JSON.toJSONString(error);\n                    exchange.getResponseHeaders().add(\"Content-Type\", \"application/json\");\n                    byte[] errorBytes = errorResponse.getBytes(StandardCharsets.UTF_8);\n                    exchange.sendResponseHeaders(405, errorBytes.length);\n\n                    OutputStream os = exchange.getResponseBody();\n                    try {\n                        os.write(errorBytes);\n                    } finally {\n                        os.close();\n                    }\n                } else {\n                    McpHttpServerSupport.sendError(exchange, 405, \"Method Not Allowed: Message endpoint only supports POST\");\n                }\n                return;\n            }\n\n            try {\n                handleMessageRequest(exchange);\n            } catch (Exception e) {\n                log.error(\"处理消息请求失败\", e);\n                McpHttpServerSupport.sendError(exchange, 500, \"Internal Server Error: \" + e.getMessage());\n            }\n        }\n\n        private void handleMessageRequest(HttpExchange exchange) throws IOException {\n            String requestBody = McpHttpServerSupport.readRequestBody(exchange);\n            log.debug(\"收到消息请求: {}\", requestBody);\n\n            McpMessage message = McpMessageCodec.parseMessage(requestBody);\n            String sessionId = findSessionForRequest();\n\n            if (sessionId == null) {\n                McpHttpServerSupport.sendError(exchange, 400, \"No active SSE connection found. Please establish SSE connection first.\");\n                return;\n            }\n\n            SessionContext session = sessions.get(sessionId);\n            if (session == null) {\n                McpHttpServerSupport.sendError(exchange, 404, \"Session not found\");\n                return;\n            }\n\n            McpMessage response = serverEngine.processMessage(message, session);\n            if (response != null) {\n                sendSseMessage(sessionId, response);\n            }\n\n            exchange.sendResponseHeaders(204, 0);\n            exchange.close();\n        }\n\n        /**\n         * 查找请求对应的会话ID\n         * 根据MCP SSE协议，如果有多个活跃连接，使用最近建立的连接\n         */\n        private String findSessionForRequest() {\n            if (sseClients.size() == 1) {\n                return sseClients.keySet().iterator().next();\n            }\n\n            String latestSessionId = null;\n            long latestTime = 0L;\n\n            for (String sessionId : sseClients.keySet()) {\n                SessionContext session = sessions.get(sessionId);\n                if (session != null && session.getCreatedTime() > latestTime) {\n                    latestTime = session.getCreatedTime();\n                    latestSessionId = sessionId;\n                }\n            }\n\n            if (latestSessionId != null) {\n                log.debug(\"使用最近的SSE会话: {}\", latestSessionId);\n            }\n            return latestSessionId;\n        }\n    }\n\n    /**\n     * 健康检查处理器\n     */\n    private class HealthHandler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            McpHttpServerSupport.setCorsHeaders(exchange, \"GET, POST, OPTIONS\", \"Content-Type, Mcp-Session-Id, Accept\");\n\n            Map<String, Object> health = new HashMap<String, Object>();\n            health.put(\"status\", \"healthy\");\n            health.put(\"server\", serverName);\n            health.put(\"version\", serverVersion);\n            health.put(\"timestamp\", java.time.Instant.now().toString());\n            health.put(\"sessions\", sessions.size());\n            health.put(\"sseClients\", sseClients.size());\n\n            Map<String, String> endpoints = new HashMap<String, String>();\n            endpoints.put(\"sse\", \"/sse\");\n            endpoints.put(\"message\", \"/message\");\n            endpoints.put(\"health\", \"/health\");\n            health.put(\"endpoints\", endpoints);\n\n            McpHttpServerSupport.writeJsonResponse(exchange, 200, health);\n        }\n    }\n\n    /**\n     * 获取或创建会话\n     */\n    private SessionContext getOrCreateSession(String sessionId) {\n        return McpServerSessionSupport.getOrCreateSession(sessions, sessionId, \"sse_session\", SessionContext::new);\n    }\n\n    /**\n     * 生成会话ID\n     */\n    private String generateSessionId() {\n        return McpServerSessionSupport.generateSessionId(\"sse_session\");\n    }\n\n    public int getPort() {\n        return port;\n    }\n\n    /**\n     * 建立SSE连接\n     */\n    private void establishSseConnection(HttpExchange exchange) throws IOException {\n        String sessionId = generateSessionId();\n        SessionContext session = getOrCreateSession(sessionId);\n\n        log.info(\"建立SSE连接，会话: {}\", sessionId);\n\n        exchange.getResponseHeaders().add(\"Content-Type\", \"text/event-stream\");\n        exchange.getResponseHeaders().add(\"Cache-Control\", \"no-cache\");\n        exchange.getResponseHeaders().add(\"Connection\", \"keep-alive\");\n        exchange.sendResponseHeaders(200, 0);\n\n        PrintWriter writer = new PrintWriter(\n                new OutputStreamWriter(exchange.getResponseBody(), StandardCharsets.UTF_8), true);\n\n        sseClients.put(sessionId, writer);\n\n        String endpointUrl = \"http://localhost:\" + port + \"/message\";\n        writer.println(\"event: endpoint\");\n        writer.println(\"data: \" + endpointUrl);\n        writer.println();\n        writer.flush();\n\n        log.info(\"发送endpoint事件: {}\", endpointUrl);\n\n        try {\n            while (running.get() && !writer.checkError()) {\n                Thread.sleep(30000);\n                if (running.get() && !writer.checkError()) {\n                    writer.println(\"event: ping\");\n                    writer.println(\"data: {}\");\n                    writer.println();\n                    writer.flush();\n                }\n            }\n        } catch (InterruptedException e) {\n            Thread.currentThread().interrupt();\n        } finally {\n            sseClients.remove(sessionId);\n            sessions.remove(sessionId);\n            try {\n                writer.close();\n            } catch (Exception e) {\n                log.debug(\"关闭SSE连接失败\", e);\n            }\n            log.info(\"SSE连接断开，会话: {}\", sessionId);\n        }\n    }\n\n    /**\n     * 通过SSE发送消息 - 符合MCP协议\n     */\n    private void sendSseMessage(String sessionId, McpMessage message) {\n        PrintWriter writer = sseClients.get(sessionId);\n        if (writer != null && !writer.checkError()) {\n            try {\n                String messageJson = JSON.toJSONString(message);\n                writer.println(\"event: message\");\n                writer.println(\"data: \" + messageJson);\n                writer.println();\n                writer.flush();\n                log.debug(\"通过SSE发送message事件到会话 {}: {}\", sessionId, messageJson);\n            } catch (Exception e) {\n                log.error(\"发送SSE消息失败\", e);\n                sseClients.remove(sessionId);\n                sessions.remove(sessionId);\n            }\n        } else {\n            log.warn(\"会话 {} 的SSE连接不可用\", sessionId);\n            sseClients.remove(sessionId);\n            sessions.remove(sessionId);\n        }\n    }\n\n    /**\n     * 根路径处理器 - 提供端点信息和错误诊断\n     */\n    private class RootHandler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            McpHttpServerSupport.setCorsHeaders(exchange, \"GET, POST, OPTIONS\", \"Content-Type, Mcp-Session-Id, Accept\");\n\n            String method = exchange.getRequestMethod();\n            String path = exchange.getRequestURI().getPath();\n\n            if (\"OPTIONS\".equals(method)) {\n                exchange.sendResponseHeaders(200, 0);\n                exchange.close();\n                return;\n            }\n\n            if (\"POST\".equals(method)) {\n                log.warn(\"收到POST请求到根路径，可能是客户端配置错误。正确的端点是: /sse (GET) 和 /message (POST)\");\n\n                Map<String, Object> error = new HashMap<String, Object>();\n                error.put(\"error\", \"Invalid endpoint for POST requests\");\n                error.put(\"message\", \"POST requests should be sent to /message endpoint\");\n                error.put(\"correctEndpoints\", new String[]{\n                        \"GET /sse - 建立SSE连接\",\n                        \"POST /message - 发送MCP消息\"\n                });\n\n                McpHttpServerSupport.writeJsonResponse(exchange, 400, error);\n                return;\n            }\n\n            Map<String, Object> info = new HashMap<String, Object>();\n            info.put(\"server\", serverName);\n            info.put(\"version\", serverVersion);\n            info.put(\"protocol\", \"MCP SSE Transport (2024-11-05)\");\n            info.put(\"endpoints\", new String[]{\n                    \"GET /sse - SSE连接端点\",\n                    \"POST /message - 消息发送端点\",\n                    \"GET /health - 健康检查\",\n                    \"GET / - 端点信息\"\n            });\n            info.put(\"usage\", \"First connect to /sse endpoint, then send messages to /message endpoint\");\n\n            McpHttpServerSupport.writeJsonResponse(exchange, 200, info);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/StdioMcpServer.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResponse;\nimport io.github.lnyocly.ai4j.mcp.util.McpMessageCodec;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.BufferedReader;\nimport java.io.InputStreamReader;\nimport java.util.Collections;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\n/**\n * Stdio MCP服务器实现\n * 直接处理标准输入输出的MCP服务器，不使用传输层抽象\n *\n * @Author cly\n */\npublic class StdioMcpServer implements McpServer {\n\n    private static final Logger log = LoggerFactory.getLogger(StdioMcpServer.class);\n\n    private final String serverName;\n    private final String serverVersion;\n    private final AtomicBoolean running;\n    private final McpServerSessionState sessionState;\n    private final McpServerEngine serverEngine;\n\n    public StdioMcpServer(String serverName, String serverVersion) {\n        this.serverName = serverName;\n        this.serverVersion = serverVersion;\n        this.running = new AtomicBoolean(false);\n        this.sessionState = new McpServerSessionState(\"stdio\");\n        this.serverEngine = new McpServerEngine(\n                serverName,\n                serverVersion,\n                Collections.singletonList(\"2024-11-05\"),\n                \"2024-11-05\",\n                false,\n                false,\n                false);\n\n        log.info(\"Stdio MCP服务器已创建: {} v{}\", serverName, serverVersion);\n    }\n\n    /**\n     * 启动MCP服务器\n     */\n    public CompletableFuture<Void> start() {\n        return CompletableFuture.runAsync(() -> {\n            if (running.compareAndSet(false, true)) {\n                log.info(\"启动Stdio MCP服务器: {} v{}\", serverName, serverVersion);\n\n                try {\n                    BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));\n                    String line;\n\n                    log.info(\"Stdio MCP服务器启动成功，等待stdin输入...\");\n\n                    while (running.get() && (line = reader.readLine()) != null) {\n                        try {\n                            if (!line.trim().isEmpty()) {\n                                McpMessage message = McpMessageCodec.parseMessage(line);\n                                handleMessage(message);\n                            }\n                        } catch (Exception e) {\n                            log.error(\"处理stdin消息失败: {}\", line, e);\n                            sendResponse(createInternalErrorResponse(null, e));\n                        }\n                    }\n\n                } catch (Exception e) {\n                    running.set(false);\n                    log.error(\"启动Stdio MCP服务器失败\", e);\n                    throw new RuntimeException(\"启动Stdio MCP服务器失败\", e);\n                }\n            }\n        });\n    }\n\n    /**\n     * 停止MCP服务器\n     */\n    public CompletableFuture<Void> stop() {\n        return CompletableFuture.runAsync(() -> {\n            if (running.compareAndSet(true, false)) {\n                log.info(\"停止Stdio MCP服务器\");\n                sessionState.setInitialized(false);\n                sessionState.getCapabilities().clear();\n                log.info(\"Stdio MCP服务器已停止\");\n            }\n        });\n    }\n\n    /**\n     * 检查服务器是否正在运行\n     */\n    public boolean isRunning() {\n        return running.get();\n    }\n\n    /**\n     * 获取服务器信息\n     */\n    public String getServerInfo() {\n        return String.format(\"%s v%s (stdio)\", serverName, serverVersion);\n    }\n\n    /**\n     * 获取服务器名称\n     */\n    public String getServerName() {\n        return serverName;\n    }\n\n    /**\n     * 获取服务器版本\n     */\n    public String getServerVersion() {\n        return serverVersion;\n    }\n\n    /**\n     * 处理MCP消息\n     */\n    private void handleMessage(McpMessage message) {\n        try {\n            log.debug(\"处理Stdio消息: {}\", message);\n            McpMessage response = serverEngine.processMessage(message, sessionState);\n            if (response != null) {\n                sendResponse(response);\n            }\n        } catch (Exception e) {\n            log.error(\"处理Stdio消息时发生错误\", e);\n            sendResponse(createInternalErrorResponse(message, e));\n        }\n    }\n\n    /**\n     * 发送响应\n     */\n    private void sendResponse(McpMessage response) {\n        try {\n            String jsonResponse = JSON.toJSONString(response);\n            System.out.println(jsonResponse);\n            System.out.flush();\n            log.debug(\"发送响应到stdout: {}\", jsonResponse);\n        } catch (Exception e) {\n            log.error(\"发送响应失败\", e);\n        }\n    }\n\n    private McpResponse createInternalErrorResponse(McpMessage originalMessage, Exception error) {\n        McpResponse errorResponse = new McpResponse();\n        if (originalMessage != null) {\n            errorResponse.setId(originalMessage.getId());\n        }\n\n        io.github.lnyocly.ai4j.mcp.entity.McpError mcpError = new io.github.lnyocly.ai4j.mcp.entity.McpError();\n        mcpError.setCode(-32603);\n        mcpError.setMessage(\"Internal error: \" + error.getMessage());\n        errorResponse.setError(mcpError);\n        return errorResponse;\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/server/StreamableHttpMcpServer.java",
    "content": "package io.github.lnyocly.ai4j.mcp.server;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.sun.net.httpserver.HttpExchange;\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.util.McpMessageCodec;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.io.OutputStreamWriter;\nimport java.io.PrintWriter;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\n/**\n * Streamable HTTP MCP服务器实现\n * 支持MCP 2025-03-26规范\n */\npublic class StreamableHttpMcpServer implements McpServer {\n\n    private static final Logger log = LoggerFactory.getLogger(StreamableHttpMcpServer.class);\n\n    private final String serverName;\n    private final String serverVersion;\n    private final int port;\n    private final AtomicBoolean running = new AtomicBoolean(false);\n    private final ConcurrentHashMap<String, PrintWriter> sseClients = new ConcurrentHashMap<String, PrintWriter>();\n    private final ConcurrentHashMap<String, SessionContext> sessions = new ConcurrentHashMap<String, SessionContext>();\n    private final McpServerEngine serverEngine;\n    private HttpServer httpServer;\n\n    public StreamableHttpMcpServer(String serverName, String serverVersion, int port) {\n        this.serverName = serverName;\n        this.serverVersion = serverVersion;\n        this.port = port;\n        this.serverEngine = new McpServerEngine(\n                serverName,\n                serverVersion,\n                Arrays.asList(\"2025-03-26\", \"2024-11-05\"),\n                \"2025-03-26\",\n                true,\n                false,\n                true);\n    }\n\n    public CompletableFuture<Void> start() {\n        return CompletableFuture.runAsync(new Runnable() {\n            @Override\n            public void run() {\n                if (running.compareAndSet(false, true)) {\n                    try {\n                        log.info(\"启动Streamable HTTP MCP服务器: {} v{}, 端口: {}\", serverName, serverVersion, port);\n\n                        httpServer = HttpServer.create(new InetSocketAddress(port), 0);\n                        httpServer.createContext(\"/mcp\", new McpHandler());\n                        httpServer.createContext(\"/\", new RootHandler());\n                        httpServer.createContext(\"/health\", new HealthHandler());\n                        httpServer.setExecutor(Executors.newCachedThreadPool());\n                        httpServer.start();\n\n                        log.info(\"Streamable HTTP MCP服务器启动成功\");\n                        log.info(\"MCP端点: http://localhost:{}/mcp\", port);\n                        log.info(\"根路径: http://localhost:{}/\", port);\n                        log.info(\"健康检查: http://localhost:{}/health\", port);\n                    } catch (Exception e) {\n                        running.set(false);\n                        log.error(\"启动Streamable HTTP MCP服务器失败\", e);\n                        throw new RuntimeException(\"启动Streamable HTTP MCP服务器失败\", e);\n                    }\n                }\n            }\n        });\n    }\n\n    public CompletableFuture<Void> stop() {\n        return CompletableFuture.runAsync(new Runnable() {\n            @Override\n            public void run() {\n                if (running.compareAndSet(true, false)) {\n                    log.info(\"停止Streamable HTTP MCP服务器\");\n                    if (httpServer != null) {\n                        httpServer.stop(5);\n                    }\n                    sseClients.clear();\n                    sessions.clear();\n                    log.info(\"Streamable HTTP MCP服务器已停止\");\n                }\n            }\n        });\n    }\n\n    public boolean isRunning() {\n        return running.get();\n    }\n\n    public String getServerInfo() {\n        return String.format(\"%s v%s (streamable_http)\", serverName, serverVersion);\n    }\n\n    public String getServerName() {\n        return serverName;\n    }\n\n    public String getServerVersion() {\n        return serverVersion;\n    }\n\n    /**\n     * 会话上下文\n     */\n    private static class SessionContext extends McpServerSessionState {\n        private final long createdTime;\n\n        public SessionContext(String sessionId) {\n            super(sessionId);\n            this.createdTime = System.currentTimeMillis();\n        }\n\n        public long getCreatedTime() {\n            return createdTime;\n        }\n    }\n\n    /**\n     * MCP处理器 - 支持POST和GET\n     */\n    private class McpHandler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            McpHttpServerSupport.setCorsHeaders(\n                    exchange,\n                    \"GET, POST, DELETE, OPTIONS\",\n                    \"Content-Type, mcp-session-id, last-event-id, Accept\");\n\n            if (\"OPTIONS\".equals(exchange.getRequestMethod())) {\n                exchange.sendResponseHeaders(200, 0);\n                exchange.close();\n                return;\n            }\n\n            String method = exchange.getRequestMethod();\n            String acceptHeader = exchange.getRequestHeaders().getFirst(\"Accept\");\n            String sessionId = exchange.getRequestHeaders().getFirst(\"mcp-session-id\");\n\n            log.debug(\"收到请求: {} {}, Accept: {}, Session: {}\",\n                    method, exchange.getRequestURI().getPath(), acceptHeader, sessionId);\n\n            try {\n                if (\"POST\".equals(method)) {\n                    handlePostRequest(exchange);\n                } else if (\"GET\".equals(method)) {\n                    handleGetRequest(exchange);\n                } else if (\"DELETE\".equals(method)) {\n                    handleDeleteRequest(exchange);\n                } else {\n                    McpHttpServerSupport.sendError(exchange, 405, \"Method Not Allowed\");\n                }\n            } catch (Exception e) {\n                log.error(\"处理MCP请求失败\", e);\n                McpHttpServerSupport.sendError(exchange, 500, \"Internal Server Error: \" + e.getMessage());\n            }\n        }\n\n        /**\n         * 处理POST请求 - 客户端到服务器的消息\n         */\n        private void handlePostRequest(HttpExchange exchange) throws IOException {\n            String requestBody = McpHttpServerSupport.readRequestBody(exchange);\n            log.debug(\"收到POST请求: {}\", requestBody);\n            processClientMessage(exchange, requestBody);\n        }\n\n        /**\n         * 处理GET请求 - 建立SSE连接或返回服务器信息\n         */\n        private void handleGetRequest(HttpExchange exchange) throws IOException {\n            String acceptHeader = exchange.getRequestHeaders().getFirst(\"Accept\");\n\n            if (acceptHeader != null && acceptHeader.contains(\"text/event-stream\")) {\n                establishSseConnection(exchange);\n            } else {\n                Map<String, Object> info = new HashMap<String, Object>();\n                info.put(\"server\", serverName);\n                info.put(\"version\", serverVersion);\n                info.put(\"protocol\", \"MCP Streamable HTTP\");\n                info.put(\"message\", \"MCP Server is running\");\n                info.put(\"supportedVersions\", Arrays.asList(\"2024-11-05\", \"2025-03-26\"));\n\n                McpHttpServerSupport.writeJsonResponse(exchange, 200, info);\n            }\n        }\n\n        /**\n         * 处理DELETE请求 - 终止会话\n         */\n        private void handleDeleteRequest(HttpExchange exchange) throws IOException {\n            String sessionId = exchange.getRequestHeaders().getFirst(\"mcp-session-id\");\n\n            if (sessionId != null && sessions.containsKey(sessionId)) {\n                sessions.remove(sessionId);\n                sseClients.remove(sessionId);\n                log.info(\"会话已终止: {}\", sessionId);\n                exchange.sendResponseHeaders(204, 0);\n            } else {\n                McpHttpServerSupport.sendError(exchange, 404, \"Session not found\");\n            }\n\n            exchange.close();\n        }\n    }\n\n    /**\n     * 获取或创建会话\n     */\n    private SessionContext getOrCreateSession(String sessionId) {\n        return McpServerSessionSupport.getOrCreateSession(sessions, sessionId, \"mcp_session\", SessionContext::new);\n    }\n\n    /**\n     * 生成会话ID\n     */\n    private String generateSessionId() {\n        return McpServerSessionSupport.generateSessionId(\"mcp_session\");\n    }\n\n    private void processClientMessage(HttpExchange exchange, String requestBody) throws IOException {\n        McpMessage message = McpMessageCodec.parseMessage(requestBody);\n        String sessionId = exchange.getRequestHeaders().getFirst(\"mcp-session-id\");\n        SessionContext session = getOrCreateSession(sessionId);\n        McpMessage response = serverEngine.processMessage(message, session);\n\n        if (response != null) {\n            String acceptHeader = exchange.getRequestHeaders().getFirst(\"Accept\");\n            boolean acceptsSse = acceptHeader != null && acceptHeader.contains(\"text/event-stream\");\n\n            if (acceptsSse && message.isRequest()) {\n                sendSseResponse(exchange, response, session);\n            } else {\n                sendJsonResponse(exchange, response, session);\n            }\n        } else {\n            exchange.sendResponseHeaders(202, 0);\n            exchange.close();\n        }\n    }\n\n    /**\n     * 发送SSE流响应\n     */\n    private void sendSseResponse(HttpExchange exchange, McpMessage response, SessionContext session) throws IOException {\n        log.debug(\"发送SSE流响应: {}\", JSON.toJSONString(response));\n\n        exchange.getResponseHeaders().add(\"Content-Type\", \"text/event-stream\");\n        exchange.getResponseHeaders().add(\"Cache-Control\", \"no-cache\");\n        exchange.getResponseHeaders().add(\"Connection\", \"keep-alive\");\n\n        if (session != null) {\n            exchange.getResponseHeaders().add(\"mcp-session-id\", session.getSessionId());\n        }\n\n        exchange.sendResponseHeaders(200, 0);\n\n        PrintWriter writer = new PrintWriter(\n                new OutputStreamWriter(exchange.getResponseBody(), StandardCharsets.UTF_8), true);\n\n        try {\n            String responseJson = JSON.toJSONString(response);\n            writer.println(\"data: \" + responseJson);\n            writer.println();\n            writer.flush();\n            writer.close();\n        } catch (Exception e) {\n            log.error(\"发送SSE响应失败\", e);\n            writer.close();\n        }\n    }\n\n    /**\n     * 发送JSON响应\n     */\n    private void sendJsonResponse(HttpExchange exchange, McpMessage response, SessionContext session) throws IOException {\n        log.debug(\"发送JSON响应: {}\", JSON.toJSONString(response));\n\n        Map<String, String> headers = null;\n        if (session != null) {\n            headers = new HashMap<String, String>();\n            headers.put(\"mcp-session-id\", session.getSessionId());\n        }\n\n        McpHttpServerSupport.writeJsonResponse(exchange, 200, response, headers);\n    }\n\n    /**\n     * 建立SSE连接\n     */\n    private void establishSseConnection(HttpExchange exchange) throws IOException {\n        String sessionId = exchange.getRequestHeaders().getFirst(\"mcp-session-id\");\n        SessionContext session = null;\n\n        if (sessionId != null) {\n            session = sessions.get(sessionId);\n        }\n\n        if (session == null) {\n            session = getOrCreateSession(sessionId);\n            sessionId = session.getSessionId();\n            log.info(\"为SSE连接创建新会话: {}\", sessionId);\n        }\n\n        log.info(\"建立SSE连接，会话: {}\", sessionId);\n\n        exchange.getResponseHeaders().add(\"Content-Type\", \"text/event-stream\");\n        exchange.getResponseHeaders().add(\"Cache-Control\", \"no-cache\");\n        exchange.getResponseHeaders().add(\"Connection\", \"keep-alive\");\n        exchange.getResponseHeaders().add(\"mcp-session-id\", sessionId);\n\n        exchange.sendResponseHeaders(200, 0);\n\n        PrintWriter writer = new PrintWriter(\n                new OutputStreamWriter(exchange.getResponseBody(), StandardCharsets.UTF_8), true);\n\n        sseClients.put(sessionId, writer);\n\n        writer.println(\"event: connected\");\n        writer.println(\"data: {\\\"message\\\":\\\"SSE连接已建立\\\",\\\"sessionId\\\":\\\"\" + sessionId + \"\\\"}\");\n        writer.println();\n\n        try {\n            while (running.get() && !writer.checkError()) {\n                Thread.sleep(1000);\n            }\n        } catch (InterruptedException e) {\n            Thread.currentThread().interrupt();\n        } finally {\n            sseClients.remove(sessionId);\n            writer.close();\n            log.info(\"SSE连接断开，会话: {}\", sessionId);\n        }\n    }\n\n    /**\n     * 健康检查处理器\n     */\n    private class HealthHandler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            McpHttpServerSupport.setCorsHeaders(\n                    exchange,\n                    \"GET, POST, DELETE, OPTIONS\",\n                    \"Content-Type, mcp-session-id, last-event-id, Accept\");\n\n            Map<String, Object> health = new HashMap<String, Object>();\n            health.put(\"status\", \"healthy\");\n            health.put(\"server\", serverName);\n            health.put(\"version\", serverVersion);\n            health.put(\"timestamp\", java.time.Instant.now().toString());\n            health.put(\"sessions\", sessions.size());\n            health.put(\"sseClients\", sseClients.size());\n\n            Map<String, String> endpoints = new HashMap<String, String>();\n            endpoints.put(\"mcp\", \"/mcp\");\n            endpoints.put(\"health\", \"/health\");\n            health.put(\"endpoints\", endpoints);\n\n            McpHttpServerSupport.writeJsonResponse(exchange, 200, health);\n        }\n    }\n\n    public int getPort() {\n        return port;\n    }\n\n    /**\n     * 根路径处理器 - 提供端点信息或重定向到MCP端点\n     */\n    private class RootHandler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            McpHttpServerSupport.setCorsHeaders(\n                    exchange,\n                    \"GET, POST, DELETE, OPTIONS\",\n                    \"Content-Type, mcp-session-id, last-event-id, Accept\");\n\n            if (\"OPTIONS\".equals(exchange.getRequestMethod())) {\n                exchange.sendResponseHeaders(200, 0);\n                exchange.close();\n                return;\n            }\n\n            String method = exchange.getRequestMethod();\n            String path = exchange.getRequestURI().getPath();\n\n            if (\"POST\".equals(method) && \"/\".equals(path)) {\n                String acceptHeader = exchange.getRequestHeaders().getFirst(\"Accept\");\n                String contentType = exchange.getRequestHeaders().getFirst(\"Content-Type\");\n\n                if (acceptHeader != null && acceptHeader.contains(\"application/json\")\n                        && contentType != null && contentType.contains(\"application/json\")) {\n\n                    log.info(\"检测到根路径的MCP请求，转发到/mcp端点\");\n                    String requestBody = McpHttpServerSupport.readRequestBody(exchange);\n\n                    if (requestBody.contains(\"\\\"jsonrpc\\\"\") && requestBody.contains(\"\\\"method\\\"\")) {\n                        try {\n                            processClientMessage(exchange, requestBody);\n                            return;\n                        } catch (Exception e) {\n                            log.error(\"处理根路径MCP请求失败\", e);\n                            McpHttpServerSupport.sendError(exchange, 500, \"Internal Server Error: \" + e.getMessage());\n                            return;\n                        }\n                    }\n                }\n            }\n\n            Map<String, Object> info = new HashMap<String, Object>();\n            info.put(\"server\", serverName);\n            info.put(\"version\", serverVersion);\n            info.put(\"protocol\", \"MCP Streamable HTTP\");\n            info.put(\"message\", \"MCP Server is running\");\n\n            Map<String, String> endpoints = new HashMap<String, String>();\n            endpoints.put(\"mcp\", \"/mcp\");\n            endpoints.put(\"health\", \"/health\");\n            info.put(\"endpoints\", endpoints);\n\n            Map<String, String> usage = new HashMap<String, String>();\n            usage.put(\"mcp_endpoint\", \"POST/GET to /mcp - Streamable HTTP protocol\");\n            usage.put(\"health_check\", \"GET /health for server status\");\n            usage.put(\"documentation\", \"See MCP Streamable HTTP specification\");\n            info.put(\"usage\", usage);\n\n            McpHttpServerSupport.writeJsonResponse(exchange, 200, info);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/McpTransport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.transport;\n\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\n\nimport java.util.concurrent.CompletableFuture;\n\n/**\n * @Author cly\n * @Description MCP传输层接口\n */\npublic interface McpTransport {\n    \n    /**\n     * 启动传输层\n     */\n    CompletableFuture<Void> start();\n    \n    /**\n     * 停止传输层\n     */\n    CompletableFuture<Void> stop();\n    \n    /**\n     * 发送消息\n     * @param message 要发送的消息\n     * @return 发送结果\n     */\n    CompletableFuture<Void> sendMessage(McpMessage message);\n    \n    /**\n     * 设置消息接收处理器\n     * @param handler 消息处理器\n     */\n    void setMessageHandler(McpMessageHandler handler);\n    \n    /**\n     * 检查连接状态\n     * @return 是否已连接\n     */\n    boolean isConnected();\n    /**\n     *  指示此传输方式是否需要应用层的心跳来保持连接或会话活跃。\n     *  基于网络的传输（如 SSE, Streamable HTTP）通常返回 true。\n     *  基于本地进程的传输（如 stdio）通常返回 false。\n     *  @return 如果需要心跳则为 true，否则为 false\n     */\n    boolean needsHeartbeat();\n    /**\n     * 获取传输类型\n     * @return 传输类型名称\n     */\n    String getTransportType();\n\n    /**\n     * 消息处理器接口\n     */\n    interface McpMessageHandler {\n        /**\n         * 处理接收到的消息\n         * @param message 接收到的消息\n         */\n        void handleMessage(McpMessage message);\n        \n        /**\n         * 处理连接事件\n         */\n        void onConnected();\n        \n        /**\n         * 处理断开连接事件\n         * @param reason 断开原因\n         */\n        void onDisconnected(String reason);\n        \n        /**\n         * 处理错误事件\n         * @param error 错误信息\n         */\n        void onError(Throwable error);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/McpTransportFactory.java",
    "content": "package io.github.lnyocly.ai4j.mcp.transport;\n\nimport io.github.lnyocly.ai4j.mcp.util.McpTypeSupport;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * MCP传输层工厂类\n * 支持创建stdio、sse、streamable_http三种传输协议的实例\n * \n * @Author cly\n */\npublic class McpTransportFactory {\n    \n    private static final Logger log = LoggerFactory.getLogger(McpTransportFactory.class);\n    \n    /**\n     * 传输协议类型枚举\n     */\n    public enum TransportType {\n        STDIO(\"stdio\"),\n        SSE(\"sse\"), \n        STREAMABLE_HTTP(\"streamable_http\"),\n        @Deprecated\n        HTTP(\"http\"); // 向后兼容，映射到streamable_http\n        \n        private final String value;\n        \n        TransportType(String value) {\n            this.value = value;\n        }\n        \n        public String getValue() {\n            return value;\n        }\n        \n        /**\n         * 从字符串创建传输类型\n         */\n        public static TransportType fromString(String value) {\n            if (!McpTypeSupport.isKnownType(value)) {\n                log.warn(\"未知的传输类型: {}, 使用默认的stdio\", value);\n            }\n\n            String normalizedType = McpTypeSupport.normalizeType(value);\n            switch (normalizedType) {\n                case McpTypeSupport.TYPE_SSE:\n                    return SSE;\n                case McpTypeSupport.TYPE_STREAMABLE_HTTP:\n                    return STREAMABLE_HTTP;\n                case McpTypeSupport.TYPE_STDIO:\n                default:\n                    return STDIO;\n            }\n        }\n    }\n    \n    /**\n     * 创建传输层实例\n     * \n     * @param type 传输类型\n     * @param config 传输配置\n     * @return 传输层实例\n     */\n    public static McpTransport createTransport(TransportType type, TransportConfig config) {\n        if (config == null) {\n            throw new IllegalArgumentException(\"传输配置不能为空\");\n        }\n        \n        log.debug(\"创建传输层实例: type={}, config={}\", type, config);\n        \n        switch (type) {\n            case STDIO:\n                return createStdioTransport(config);\n            case SSE:\n                return createSseTransport(config);\n            case STREAMABLE_HTTP:\n            case HTTP:\n                return createStreamableHttpTransport(config);\n            default:\n                throw new IllegalArgumentException(\"不支持的传输类型: \" + type);\n        }\n    }\n    \n    /**\n     * 便捷方法：从字符串类型创建传输层\n     */\n    public static McpTransport createTransport(String typeString, TransportConfig config) {\n        TransportType type = TransportType.fromString(typeString);\n        return createTransport(type, config);\n    }\n    \n    /**\n     * 创建stdio传输层\n     */\n    private static McpTransport createStdioTransport(TransportConfig config) {\n        String command = config.getCommand();\n        if (command == null || command.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"Stdio传输需要指定command参数\");\n        }\n        \n        return new StdioTransport(\n            command.trim(), \n            config.getArgs(), \n            config.getEnv()\n        );\n    }\n    \n    /**\n     * 创建SSE传输层\n     */\n    private static McpTransport createSseTransport(TransportConfig config) {\n        String url = config.getUrl();\n        if (url == null || url.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"SSE传输需要指定url参数\");\n        }\n        \n        return new SseTransport(config);\n    }\n    \n    /**\n     * 创建Streamable HTTP传输层\n     */\n    private static McpTransport createStreamableHttpTransport(TransportConfig config) {\n        String url = config.getUrl();\n        if (url == null || url.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"Streamable HTTP传输需要指定url参数\");\n        }\n        config.setUrl(url.trim());\n        return new StreamableHttpTransport(config);\n    }\n    \n    /**\n     * 验证传输配置\n     */\n    public static void validateConfig(TransportType type, TransportConfig config) {\n        if (config == null) {\n            throw new IllegalArgumentException(\"传输配置不能为空\");\n        }\n        \n        switch (type) {\n            case STDIO:\n                if (config.getCommand() == null || config.getCommand().trim().isEmpty()) {\n                    throw new IllegalArgumentException(\"Stdio传输需要指定command参数\");\n                }\n                break;\n            case SSE:\n            case STREAMABLE_HTTP:\n            case HTTP:\n                if (config.getUrl() == null || config.getUrl().trim().isEmpty()) {\n                    throw new IllegalArgumentException(type + \"传输需要指定url参数\");\n                }\n                break;\n        }\n    }\n    \n    /**\n     * 获取所有支持的传输类型\n     */\n    public static TransportType[] getSupportedTypes() {\n        return TransportType.values();\n    }\n    \n    /**\n     * 检查传输类型是否支持\n     */\n    public static boolean isSupported(String typeString) {\n        return McpTypeSupport.isKnownType(typeString);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/McpTransportSupport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.transport;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport okhttp3.Response;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.net.HttpURLConnection;\nimport java.nio.charset.StandardCharsets;\n\n/**\n * MCP transport 辅助方法\n */\npublic final class McpTransportSupport {\n\n    private McpTransportSupport() {\n    }\n\n    public static String safeMessage(Throwable throwable) {\n        String message = null;\n        Throwable last = throwable;\n        Throwable current = throwable;\n        while (current != null) {\n            if (!isBlank(current.getMessage())) {\n                message = current.getMessage().trim();\n            }\n            last = current;\n            current = current.getCause();\n        }\n        return !isBlank(message)\n                ? message\n                : (last == null ? \"unknown transport error\" : last.getClass().getSimpleName());\n    }\n\n    public static String clip(String value, int maxLength) {\n        if (value == null || value.length() <= maxLength || maxLength < 4) {\n            return value;\n        }\n        return value.substring(0, maxLength - 3) + \"...\";\n    }\n\n    public static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    public static String buildHttpFailureMessage(int statusCode, String responseMessage, String responseBody) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"HTTP请求失败: \")\n                .append(statusCode)\n                .append(' ')\n                .append(responseMessage == null ? \"(unknown)\" : responseMessage);\n        String detail = extractErrorDetail(responseBody);\n        if (!isBlank(detail)) {\n            builder.append(\": \").append(detail);\n        }\n        return builder.toString();\n    }\n\n    public static String buildHttpFailureMessage(Response response) throws IOException {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"HTTP请求失败: \")\n                .append(response == null ? \"(unknown)\" : response.code())\n                .append(' ')\n                .append(response == null ? \"(unknown)\" : response.message());\n        String detail = extractErrorDetail(response);\n        if (!isBlank(detail)) {\n            builder.append(\": \").append(detail);\n        }\n        return builder.toString();\n    }\n\n    public static String readResponseBody(HttpURLConnection connection, int statusCode) {\n        InputStream stream = null;\n        try {\n            stream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream();\n            if (stream == null) {\n                return null;\n            }\n            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n            byte[] buffer = new byte[512];\n            int read;\n            while ((read = stream.read(buffer)) != -1) {\n                outputStream.write(buffer, 0, read);\n            }\n            return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);\n        } catch (IOException e) {\n            return null;\n        } finally {\n            closeQuietly(stream);\n        }\n    }\n\n    public static void closeQuietly(InputStream inputStream) {\n        if (inputStream == null) {\n            return;\n        }\n        try {\n            inputStream.close();\n        } catch (IOException ignored) {\n        }\n    }\n\n    private static String extractErrorDetail(Response response) throws IOException {\n        if (response == null || response.body() == null) {\n            return null;\n        }\n        return extractErrorDetail(response.body().string());\n    }\n\n    private static String extractErrorDetail(String raw) {\n        if (isBlank(raw)) {\n            return null;\n        }\n        try {\n            JSONObject json = JSON.parseObject(raw);\n            if (json != null) {\n                JSONObject error = json.getJSONObject(\"error\");\n                if (error != null && !isBlank(error.getString(\"message\"))) {\n                    return error.getString(\"message\").trim();\n                }\n                if (!isBlank(json.getString(\"message\"))) {\n                    return json.getString(\"message\").trim();\n                }\n            }\n        } catch (Exception ignored) {\n        }\n        return clip(raw.trim(), 160);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/SseTransport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.transport;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.entity.*;\nimport io.github.lnyocly.ai4j.mcp.util.McpMessageCodec;\nimport okhttp3.*;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.net.HttpURLConnection;\nimport java.net.URL;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\n\npublic class SseTransport implements McpTransport {\n\n    private static final Logger log = LoggerFactory.getLogger(SseTransport.class);\n\n    private final String sseEndpointUrl;\n    private final OkHttpClient httpClient;\n    private final AtomicBoolean running = new AtomicBoolean(false);\n    private final Map<String, String> initialQueryParams = new java.util.LinkedHashMap<>();\n\n    private McpMessageHandler messageHandler;\n    private String messageEndpointUrl; // 从endpoint事件中获取\n    private volatile boolean endpointReceived = false;\n    private volatile HttpURLConnection sseConnection;\n    private volatile InputStream sseInputStream;\n    private volatile Thread sseReaderThread;\n    /**\n     * 自定义HTTP头（用于认证等）\n     */\n    private Map<String, String> headers;\n\n    public SseTransport(String sseEndpointUrl) {\n        parseInitialQueryParams(sseEndpointUrl);\n        this.sseEndpointUrl = sseEndpointUrl;\n        this.httpClient = createDefaultHttpClient();\n    }\n\n    public SseTransport(TransportConfig config) {\n        parseInitialQueryParams(config.getUrl());\n        this.sseEndpointUrl = config.getUrl();\n        this.httpClient = createDefaultHttpClient();\n        this.headers = config.getHeaders();\n    }\n\n    public SseTransport(String sseEndpointUrl, OkHttpClient httpClient) {\n        parseInitialQueryParams(sseEndpointUrl);\n        this.sseEndpointUrl = sseEndpointUrl;\n        this.httpClient = httpClient;\n    }\n\n    private void parseInitialQueryParams(String url) {\n        try {\n            HttpUrl httpUrl = HttpUrl.get(url);\n            if (httpUrl != null) {\n                // HttpUrl.queryParameterNames() 返回 Set<String>\n                for (String name : httpUrl.queryParameterNames()) {\n                    // 取第一个值（如有多值，可按需改为 list）\n                    String value = httpUrl.queryParameter(name);\n                    if (value != null) {\n                        initialQueryParams.put(name, value);\n                    }\n                }\n            }\n        } catch (Exception e) {\n            log.debug(\"解析 SSE URL 查询参数失败：{}\", e.getMessage());\n        }\n    }\n\n    private static OkHttpClient createDefaultHttpClient() {\n        return new OkHttpClient.Builder()\n                .connectTimeout(30, TimeUnit.SECONDS)\n                .readTimeout(0, TimeUnit.SECONDS) // SSE需要长连接\n                .writeTimeout(60, TimeUnit.SECONDS)\n                .pingInterval(30, TimeUnit.SECONDS) // 每30秒发送一个 ping 帧来保持连接\n                .build();\n    }\n\n    @Override\n    public CompletableFuture<Void> start() {\n        return CompletableFuture.runAsync(() -> {\n            if (running.compareAndSet(false, true)) {\n                log.debug(\"启动SSE传输层，连接到: {}\", sseEndpointUrl);\n\n                try {\n                    // 建立SSE连接\n                    startSseConnection();\n\n                    // 等待endpoint事件\n                    waitForEndpointEvent();\n\n                    if (messageHandler != null) {\n                        messageHandler.onConnected();\n                    }\n                } catch (Exception e) {\n                    running.set(false);\n                    closeSseResources();\n                    waitForReaderThreadToExit();\n                    log.debug(\"启动SSE传输层失败: {}\", McpTransportSupport.safeMessage(e), e);\n                    if (messageHandler != null) {\n                        messageHandler.onError(e);\n                    }\n                    throw new RuntimeException(McpTransportSupport.safeMessage(e), e);\n                }\n            }\n        });\n    }\n\n    @Override\n    public CompletableFuture<Void> stop() {\n        return CompletableFuture.runAsync(() -> {\n            if (running.compareAndSet(true, false)) {\n                log.debug(\"停止SSE传输层\");\n\n                closeSseResources();\n\n                endpointReceived = false;\n                messageEndpointUrl = null;\n\n                if (messageHandler != null) {\n                    messageHandler.onDisconnected(\"Transport stopped\");\n                }\n\n                log.debug(\"SSE传输层已停止\");\n            }\n        });\n    }\n\n    @Override\n    public CompletableFuture<Void> sendMessage(McpMessage message) {\n        return CompletableFuture.runAsync(() -> {\n            if (!running.get()) {\n                throw new IllegalStateException(\"SSE传输层未启动\");\n            }\n\n            if (!endpointReceived || messageEndpointUrl == null) {\n                throw new IllegalStateException(\"尚未收到服务器endpoint事件，无法发送消息\");\n            }\n\n            try {\n                String jsonMessage = JSON.toJSONString(message);\n                log.debug(\"通过POST发送消息到 {}: {}\", messageEndpointUrl, jsonMessage);\n\n                // 从消息端点URL中提取会话ID\n                String sessionId = extractSessionId(messageEndpointUrl);\n                HttpURLConnection connection = null;\n                try {\n                    connection = openPostConnection(messageEndpointUrl, sessionId);\n                    byte[] requestBytes = jsonMessage.getBytes(StandardCharsets.UTF_8);\n                    OutputStream outputStream = connection.getOutputStream();\n                    try {\n                        outputStream.write(requestBytes);\n                        outputStream.flush();\n                    } finally {\n                        outputStream.close();\n                    }\n\n                    int statusCode = connection.getResponseCode();\n                    String responseMessage = connection.getResponseMessage();\n                    String responseBody = McpTransportSupport.readResponseBody(connection, statusCode);\n                    int responseLength = responseBody == null ? 0 : responseBody.length();\n\n                    log.debug(\"HTTP响应: 状态码={}, 消息={}, 响应体长度={}\",\n                            statusCode, responseMessage, responseLength);\n                    if (responseLength > 0) {\n                        log.debug(\"HTTP响应体: {}\", responseBody);\n                    }\n\n                    if ((statusCode < 200 || statusCode >= 300) && statusCode != 202 && statusCode != 204) {\n                        throw new IOException(McpTransportSupport.buildHttpFailureMessage(statusCode, responseMessage, responseBody));\n                    }\n\n                    if (statusCode == 202) {\n                        log.debug(\"消息已提交异步处理，状态码: {}\", statusCode);\n                    } else if (statusCode == 204) {\n                        log.debug(\"消息已接收，将通过SSE异步响应，状态码: {}\", statusCode);\n                    } else {\n                        log.debug(\"消息发送成功，状态码: {}\", statusCode);\n                    }\n                } finally {\n                    disconnectQuietly(connection);\n                }\n\n            } catch (Exception e) {\n                log.debug(\"发送消息失败: {}\", McpTransportSupport.safeMessage(e), e);\n                if (messageHandler != null) {\n                    messageHandler.onError(e);\n                }\n                throw new RuntimeException(McpTransportSupport.safeMessage(e), e);\n            }\n        });\n    }\n\n    @Override\n    public void setMessageHandler(McpMessageHandler handler) {\n        this.messageHandler = handler;\n    }\n\n    @Override\n    public boolean isConnected() {\n        return running.get() && sseReaderThread != null && sseReaderThread.isAlive() && endpointReceived;\n    }\n\n    @Override\n    public boolean needsHeartbeat() {\n        return false;\n    }\n\n    @Override\n    public String getTransportType() {\n        return \"sse\";\n    }\n\n    /**\n     * 启动SSE连接\n     */\n    private void startSseConnection() {\n        Thread readerThread = new Thread(new Runnable() {\n            @Override\n            public void run() {\n                readEventStream();\n            }\n        }, \"mcp-sse-reader-\" + Integer.toHexString(System.identityHashCode(this)));\n        readerThread.setDaemon(true);\n        this.sseReaderThread = readerThread;\n        readerThread.start();\n\n        log.debug(\"SSE连接读取线程已启动: {}\", sseEndpointUrl);\n    }\n\n    /**\n     * 等待endpoint事件\n     */\n    private void waitForEndpointEvent() {\n        int maxWaitSeconds = 10;\n        int waitedSeconds = 0;\n\n        while (!endpointReceived && waitedSeconds < maxWaitSeconds && running.get()) {\n            try {\n                Thread.sleep(1000);\n                waitedSeconds++;\n            } catch (InterruptedException e) {\n                Thread.currentThread().interrupt();\n                throw new RuntimeException(\"等待endpoint事件被中断\", e);\n            }\n        }\n\n        if (!endpointReceived) {\n            throw new RuntimeException(\"超时等待服务器endpoint事件\");\n        }\n\n        log.debug(\"已收到endpoint事件，消息端点: {}\", messageEndpointUrl);\n    }\n\n    private void readEventStream() {\n        HttpURLConnection connection = null;\n        InputStream inputStream = null;\n        BufferedReader reader = null;\n\n        try {\n            connection = openSseConnection();\n            inputStream = connection.getInputStream();\n            reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));\n\n            this.sseConnection = connection;\n            this.sseInputStream = inputStream;\n\n            log.debug(\"SSE连接已建立，状态码: {}\", connection.getResponseCode());\n            processEventStream(reader);\n            handleSseClosure(\"SSE connection closed\");\n        } catch (Exception e) {\n            if (!running.get()) {\n                log.debug(\"SSE读取线程已按停止请求退出: {}\", e.getMessage());\n                return;\n            }\n            log.debug(\"SSE连接失败: {}\", McpTransportSupport.safeMessage(e), e);\n            if (messageHandler != null) {\n                messageHandler.onError(e);\n            }\n            handleSseClosure(\"SSE connection failed: \" + e.getMessage());\n        } finally {\n            closeQuietly(reader);\n            closeQuietly(inputStream);\n            disconnectQuietly(connection);\n            this.sseInputStream = null;\n            this.sseConnection = null;\n            if (Thread.currentThread() == this.sseReaderThread) {\n                this.sseReaderThread = null;\n            }\n        }\n    }\n\n    private HttpURLConnection openSseConnection() throws IOException {\n        HttpURLConnection connection = (HttpURLConnection) new URL(sseEndpointUrl).openConnection();\n        connection.setRequestMethod(\"GET\");\n        connection.setRequestProperty(\"Accept\", \"text/event-stream\");\n        connection.setRequestProperty(\"Cache-Control\", \"no-cache\");\n        connection.setRequestProperty(\"User-Agent\", \"ai4j-mcp-client/1.0.0\");\n        connection.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(30));\n        connection.setReadTimeout(0);\n        connection.setDoInput(true);\n\n        if (headers != null) {\n            for (Map.Entry<String, String> entry : headers.entrySet()) {\n                connection.setRequestProperty(entry.getKey(), entry.getValue());\n            }\n        }\n\n        int statusCode = connection.getResponseCode();\n        if (statusCode != HttpURLConnection.HTTP_OK) {\n            throw new IOException(\"SSE连接失败，状态码: \" + statusCode);\n        }\n        return connection;\n    }\n\n    private HttpURLConnection openPostConnection(String endpointUrl, String sessionId) throws IOException {\n        HttpURLConnection connection = (HttpURLConnection) new URL(endpointUrl).openConnection();\n        connection.setRequestMethod(\"POST\");\n        connection.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(30));\n        connection.setReadTimeout((int) TimeUnit.SECONDS.toMillis(60));\n        connection.setDoOutput(true);\n        connection.setDoInput(true);\n        connection.setRequestProperty(\"Content-Type\", \"application/json\");\n        connection.setRequestProperty(\"Accept\", \"application/json\");\n        connection.setRequestProperty(\"User-Agent\", \"ai4j-mcp-client/1.0.0\");\n\n        if (sessionId != null) {\n            connection.setRequestProperty(\"mcp-session-id\", sessionId);\n        }\n        if (headers != null) {\n            for (Map.Entry<String, String> entry : headers.entrySet()) {\n                connection.setRequestProperty(entry.getKey(), entry.getValue());\n            }\n        }\n\n        return connection;\n    }\n\n    private void processEventStream(BufferedReader reader) throws IOException {\n        String eventId = null;\n        String eventType = null;\n        StringBuilder dataBuilder = new StringBuilder();\n\n        while (running.get()) {\n            String line = reader.readLine();\n            if (line == null) {\n                break;\n            }\n\n            if (line.isEmpty()) {\n                dispatchSseEvent(eventId, eventType, dataBuilder);\n                eventId = null;\n                eventType = null;\n                dataBuilder.setLength(0);\n                continue;\n            }\n\n            if (line.startsWith(\":\")) {\n                continue;\n            }\n\n            int separatorIndex = line.indexOf(':');\n            String field = separatorIndex >= 0 ? line.substring(0, separatorIndex) : line;\n            String value = separatorIndex >= 0 ? line.substring(separatorIndex + 1) : \"\";\n            if (value.startsWith(\" \")) {\n                value = value.substring(1);\n            }\n\n            if (\"event\".equals(field)) {\n                eventType = value;\n            } else if (\"data\".equals(field)) {\n                if (dataBuilder.length() > 0) {\n                    dataBuilder.append('\\n');\n                }\n                dataBuilder.append(value);\n            } else if (\"id\".equals(field)) {\n                eventId = value;\n            }\n        }\n\n        if (eventId != null || eventType != null || dataBuilder.length() > 0) {\n            dispatchSseEvent(eventId, eventType, dataBuilder);\n        }\n    }\n\n    private void dispatchSseEvent(String eventId, String eventType, StringBuilder dataBuilder) {\n        String data = dataBuilder.toString();\n        if ((eventType == null || eventType.trim().isEmpty()) && data.trim().isEmpty()) {\n            return;\n        }\n\n        String resolvedType = (eventType == null || eventType.trim().isEmpty()) ? \"message\" : eventType.trim();\n        log.debug(\"接收SSE事件: id={}, type={}, data={}\", eventId, resolvedType, data);\n\n        try {\n            if (\"endpoint\".equals(resolvedType)) {\n                handleEndpointEvent(data);\n            } else if (\"message\".equals(resolvedType)) {\n                handleMessageEvent(data);\n            } else {\n                log.debug(\"忽略未知事件类型: {}\", resolvedType);\n            }\n        } catch (Exception e) {\n            log.error(\"处理SSE事件失败\", e);\n            if (messageHandler != null) {\n                messageHandler.onError(e);\n            }\n        }\n    }\n\n    private void handleSseClosure(String reason) {\n        if (running.compareAndSet(true, false)) {\n            endpointReceived = false;\n            messageEndpointUrl = null;\n            if (messageHandler != null) {\n                messageHandler.onDisconnected(reason);\n            }\n        }\n    }\n\n    private void closeSseResources() {\n        final HttpURLConnection connection = sseConnection;\n        final InputStream inputStream = sseInputStream;\n        sseConnection = null;\n        sseInputStream = null;\n\n        Thread readerThread = sseReaderThread;\n        if (readerThread != null) {\n            readerThread.interrupt();\n        }\n\n        if (connection != null || inputStream != null) {\n            Thread closerThread = new Thread(new Runnable() {\n                @Override\n                public void run() {\n                    disconnectQuietly(connection);\n                    closeQuietly(inputStream);\n                }\n            }, \"mcp-sse-closer-\" + Integer.toHexString(System.identityHashCode(this)));\n            closerThread.setDaemon(true);\n            closerThread.start();\n        }\n    }\n\n    private void waitForReaderThreadToExit() {\n        Thread readerThread = sseReaderThread;\n        if (readerThread == null || readerThread == Thread.currentThread()) {\n            return;\n        }\n\n        try {\n            readerThread.join(TimeUnit.SECONDS.toMillis(2));\n        } catch (InterruptedException e) {\n            Thread.currentThread().interrupt();\n            log.debug(\"等待SSE读取线程退出时被中断\");\n            return;\n        }\n\n        if (readerThread.isAlive()) {\n            log.warn(\"SSE读取线程未在预期时间内退出\");\n        }\n    }\n\n    private void closeQuietly(InputStream inputStream) {\n        McpTransportSupport.closeQuietly(inputStream);\n    }\n\n    private void closeQuietly(BufferedReader reader) {\n        if (reader == null) {\n            return;\n        }\n        try {\n            reader.close();\n        } catch (IOException e) {\n            log.debug(\"关闭SSE读取器失败: {}\", e.getMessage());\n        }\n    }\n\n    private void disconnectQuietly(HttpURLConnection connection) {\n        if (connection != null) {\n            connection.disconnect();\n        }\n    }\n\n    /**\n     * 处理endpoint事件\n     */\n    private void handleEndpointEvent(String data) {\n        if (data != null && !data.trim().isEmpty()) {\n            String endpointPath = data.trim();\n            // 将相对路径转换为完整URL\n            messageEndpointUrl = buildFullUrl(endpointPath);\n            endpointReceived = true;\n            log.debug(\"收到endpoint事件，消息端点: {}\", messageEndpointUrl);\n        } else {\n            log.warn(\"收到空的endpoint事件数据\");\n        }\n    }\n\n    /**\n     * 将相对路径转换为完整URL\n     */\n    private String buildFullUrl(String endpointPath) {\n        if (endpointPath == null || endpointPath.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"端点路径不能为空\");\n        }\n\n        // 如果已经是完整URL，则合并 initialQueryParams（避免重复 key）\n        if (endpointPath.startsWith(\"http://\") || endpointPath.startsWith(\"https://\")) {\n            return mergeInitialQueriesIntoUrl(endpointPath);\n        }\n\n        // 否则将相对路径拼接到 base 上\n        String baseUrl = extractBaseUrl(sseEndpointUrl);\n        String trimmedPath = endpointPath.trim();\n        if (!trimmedPath.startsWith(\"/\")) {\n            trimmedPath = \"/\" + trimmedPath;\n        }\n        String combined = baseUrl + trimmedPath;\n        return mergeInitialQueriesIntoUrl(combined);\n    }\n\n    private String mergeInitialQueriesIntoUrl(String urlStr) {\n        try {\n            HttpUrl url = HttpUrl.get(urlStr);\n            if (url == null) return urlStr; // fallback\n\n            HttpUrl.Builder builder = url.newBuilder();\n\n            // 把初始查询参数加上，前提是 endpoint URL 没有同名参数\n            for (Map.Entry<String, String> e : initialQueryParams.entrySet()) {\n                String key = e.getKey();\n                String val = e.getValue();\n                if (url.queryParameter(key) == null && val != null) {\n                    builder.addQueryParameter(key, val);\n                }\n            }\n\n            return builder.build().toString();\n        } catch (Exception ex) {\n            log.debug(\"合并初始查询参数失败，返回原始 URL: {}, 错误: {}\", urlStr, ex.getMessage());\n            return urlStr;\n        }\n    }\n\n    /**\n     * 从完整URL中提取基础URL（协议+域名+端口）\n     */\n    private String extractBaseUrl(String fullUrl) {\n        try {\n            java.net.URL url = new java.net.URL(fullUrl);\n            return url.getProtocol() + \"://\" + url.getHost() +\n                (url.getPort() != -1 ? \":\" + url.getPort() : \"\");\n        } catch (Exception e) {\n            // 简单的字符串处理作为备选\n            int protocolEnd = fullUrl.indexOf(\"://\");\n            if (protocolEnd > 0) {\n                int pathStart = fullUrl.indexOf(\"/\", protocolEnd + 3);\n                if (pathStart > 0) {\n                    return fullUrl.substring(0, pathStart);\n                }\n            }\n            return fullUrl;\n        }\n    }\n\n    /**\n     * 从消息端点URL中提取会话ID\n     */\n    private String extractSessionId(String messageUrl) {\n        if (messageUrl == null) {\n            return null;\n        }\n\n        try {\n            // 从URL参数中提取session_id\n            // 例如: https://mcp.api-inference.modelscope.net/messages/?session_id=abc123\n            int sessionIdIndex = messageUrl.indexOf(\"session_id=\");\n            if (sessionIdIndex > 0) {\n                int startIndex = sessionIdIndex + \"session_id=\".length();\n                int endIndex = messageUrl.indexOf(\"&\", startIndex);\n                if (endIndex == -1) {\n                    endIndex = messageUrl.length();\n                }\n                return messageUrl.substring(startIndex, endIndex);\n            }\n        } catch (Exception e) {\n            log.debug(\"提取会话ID失败: {}\", messageUrl, e);\n        }\n\n        return null;\n    }\n    /**\n     * 处理message事件\n     */\n    private void handleMessageEvent(String data) {\n        if (data == null || data.trim().isEmpty()) {\n            log.warn(\"收到空的message事件数据\");\n            return;\n        }\n\n        try {\n            McpMessage message = parseMessage(data);\n            if (message != null && messageHandler != null) {\n                messageHandler.handleMessage(message);\n            }\n        } catch (Exception e) {\n            log.error(\"解析message事件失败: {}\", data, e);\n            if (messageHandler != null) {\n                messageHandler.onError(e);\n            }\n        }\n    }\n\n    /**\n     * 解析JSON消息为McpMessage对象\n     */\n    private McpMessage parseMessage(String jsonString) {\n        try {\n            return McpMessageCodec.parseMessage(jsonString);\n        } catch (Exception e) {\n            log.error(\"解析消息失败: {}\", jsonString, e);\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/StdioTransport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.transport;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.entity.*;\nimport io.github.lnyocly.ai4j.mcp.util.McpMessageCodec;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.*;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\n/**\n * @Author cly\n * @Description 进程Stdio传输层 - 启动外部MCP服务器进程\n */\npublic class StdioTransport implements McpTransport {\n    \n    private static final Logger log = LoggerFactory.getLogger(StdioTransport.class);\n    \n    private final String command;\n    private final List<String> args;\n    private final Map<String, String> env;\n    \n    private Process mcpProcess;\n    private BufferedReader reader;\n    private PrintWriter writer;\n    private final ExecutorService executor;\n    private final AtomicBoolean running;\n    private McpMessageHandler messageHandler;\n    \n    public StdioTransport(String command, List<String> args, Map<String, String> env) {\n        this.command = command;\n        this.args = args;\n        this.env = env;\n        this.executor = Executors.newSingleThreadExecutor(r -> {\n            Thread t = new Thread(r, \"MCP-Process-Stdio-Transport\");\n            t.setDaemon(true);\n            return t;\n        });\n        this.running = new AtomicBoolean(false);\n    }\n    \n    @Override\n    public CompletableFuture<Void> start() {\n        return CompletableFuture.runAsync(() -> {\n            if (running.compareAndSet(false, true)) {\n                log.info(\"启动进程Stdio传输层\");\n                \n                try {\n                    // 启动MCP服务器进程\n                    startMcpProcess();\n                    \n                    if (messageHandler != null) {\n                        messageHandler.onConnected();\n                    }\n                    \n                    // 启动消息读取循环\n                    executor.submit(this::messageReadLoop);\n                    \n                } catch (Exception e) {\n                    log.debug(\"启动MCP进程失败: {}\", McpTransportSupport.safeMessage(e), e);\n                    running.set(false);\n                    if (messageHandler != null) {\n                        messageHandler.onError(e);\n                    }\n                    throw new RuntimeException(McpTransportSupport.safeMessage(e), e);\n                }\n            }\n        });\n    }\n    \n    /**\n     * 启动MCP服务器进程\n     */\n    private void startMcpProcess() throws IOException {\n        log.info(\"启动MCP服务器进程: {} {}\", command, args);\n\n        ProcessBuilder pb = new ProcessBuilder();\n\n        // 构建完整命令\n        List<String> fullCommand = new ArrayList<>();\n\n        // Windows系统需要特殊处理\n        if (System.getProperty(\"os.name\").toLowerCase().contains(\"windows\")) {\n            if (\"npx\".equals(command)) {\n                // 在Windows上使用cmd包装npx命令\n                fullCommand.add(\"cmd\");\n                fullCommand.add(\"/c\");\n                fullCommand.add(\"npx\");\n                if (args != null) {\n                    fullCommand.addAll(args);\n                }\n            } else {\n                fullCommand.add(command);\n                if (args != null) {\n                    fullCommand.addAll(args);\n                }\n            }\n        } else {\n            fullCommand.add(command);\n            if (args != null) {\n                fullCommand.addAll(args);\n            }\n        }\n\n        pb.command(fullCommand);\n\n        log.debug(\"完整命令: {}\", fullCommand);\n\n        // 设置环境变量\n        if (env != null && !env.isEmpty()) {\n            Map<String, String> processEnv = pb.environment();\n            processEnv.putAll(env);\n        }\n        \n        // 重定向错误流到标准输出\n        pb.redirectErrorStream(true);\n        \n        // 启动进程\n        mcpProcess = pb.start();\n        \n        // 设置输入输出流\n        reader = new BufferedReader(new InputStreamReader(mcpProcess.getInputStream(), \"UTF-8\"));\n        writer = new PrintWriter(new OutputStreamWriter(mcpProcess.getOutputStream(), \"UTF-8\"), true);\n        \n        log.info(\"MCP服务器进程已启动，PID: {}\", getProcessId());\n\n        // 检查进程是否立即退出\n        try {\n            Thread.sleep(100); // 等待100ms\n            if (!mcpProcess.isAlive()) {\n                int exitCode = mcpProcess.exitValue();\n                throw new IOException(\"MCP服务器进程立即退出，退出码: \" + exitCode);\n            }\n        } catch (InterruptedException e) {\n            Thread.currentThread().interrupt();\n        }\n    }\n    \n    /**\n     * 获取进程ID（尽力而为）\n     */\n    private String getProcessId() {\n        try {\n            if (mcpProcess != null) {\n                // Java 9+ 有 pid() 方法，但我们在 JDK 8 环境\n                return mcpProcess.toString();\n            }\n        } catch (Exception e) {\n            // 忽略\n        }\n        return \"unknown\";\n    }\n    \n    @Override\n    public CompletableFuture<Void> stop() {\n        return CompletableFuture.runAsync(() -> {\n            if (running.compareAndSet(true, false)) {\n                log.info(\"停止进程Stdio传输层\");\n                \n                // 关闭流\n                try {\n                    if (writer != null) {\n                        writer.close();\n                    }\n                    if (reader != null) {\n                        reader.close();\n                    }\n                } catch (Exception e) {\n                    log.warn(\"关闭流时发生错误\", e);\n                }\n                \n                // 终止进程\n                if (mcpProcess != null) {\n                    try {\n                        mcpProcess.destroy();\n                        \n                        // 等待进程结束\n                        boolean terminated = mcpProcess.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);\n                        if (!terminated) {\n                            log.warn(\"进程未在5秒内结束，强制终止\");\n                            mcpProcess.destroyForcibly();\n                        }\n                        \n                        log.info(\"MCP服务器进程已终止\");\n                    } catch (Exception e) {\n                        log.error(\"终止MCP进程时发生错误\", e);\n                    }\n                }\n                \n                // 关闭线程池\n                executor.shutdown();\n                \n                if (messageHandler != null) {\n                    messageHandler.onDisconnected(\"Transport stopped\");\n                }\n            }\n        });\n    }\n    \n    @Override\n    public CompletableFuture<Void> sendMessage(McpMessage message) {\n        return CompletableFuture.runAsync(() -> {\n            if (!running.get()) {\n                throw new IllegalStateException(\"传输层未启动\");\n            }\n            \n            if (writer == null) {\n                throw new IllegalStateException(\"输出流未初始化\");\n            }\n            \n            try {\n                String jsonMessage = JSON.toJSONString(message);\n                log.debug(\"发送消息: {}\", jsonMessage);\n\n                // 发送JSON消息并确保换行\n                writer.println(jsonMessage);\n                writer.flush();\n\n                // 额外确保数据被发送\n                if (writer.checkError()) {\n                    throw new IOException(\"写入数据时发生错误\");\n                }\n            } catch (Exception e) {\n                log.debug(\"发送消息失败: {}\", McpTransportSupport.safeMessage(e), e);\n                if (messageHandler != null) {\n                    messageHandler.onError(e);\n                }\n                throw new RuntimeException(McpTransportSupport.safeMessage(e), e);\n            }\n        });\n    }\n    \n    @Override\n    public void setMessageHandler(McpMessageHandler handler) {\n        this.messageHandler = handler;\n    }\n    \n    @Override\n    public boolean isConnected() {\n        return running.get() && mcpProcess != null && mcpProcess.isAlive();\n    }\n\n    @Override\n    public boolean needsHeartbeat() {\n        return false;\n    }\n\n    @Override\n    public String getTransportType() {\n        return \"process-stdio\";\n    }\n\n    /**\n     * 消息读取循环\n     */\n    private void messageReadLoop() {\n        log.debug(\"开始消息读取循环\");\n        \n        while (running.get()) {\n            try {\n                String line = reader.readLine();\n                if (line == null) {\n                    // 输入流结束，检查进程状态\n                    if (mcpProcess != null && mcpProcess.isAlive()) {\n                        log.warn(\"输入流结束但进程仍在运行，可能是通信问题\");\n                    } else if (mcpProcess != null) {\n                        try {\n                            int exitCode = mcpProcess.exitValue();\n                            log.warn(\"MCP进程已退出，退出码: {}\", exitCode);\n                        } catch (IllegalThreadStateException e) {\n                            log.warn(\"无法获取进程退出码\");\n                        }\n                    }\n                    log.info(\"输入流结束，停止传输层\");\n                    stop();\n                    break;\n                }\n                \n                if (line.trim().isEmpty()) {\n                    continue;\n                }\n\n                log.debug(\"接收消息: {}\", line);\n\n                // 检查是否是JSON消息（以{开头）\n                if (!line.trim().startsWith(\"{\")) {\n                    log.info(\"MCP服务器日志: {}\", line);\n                    continue;\n                }\n\n                // 解析JSON消息\n                McpMessage message = parseMessage(line);\n                if (message != null && messageHandler != null) {\n                    messageHandler.handleMessage(message);\n                }\n                \n            } catch (IOException e) {\n                if (running.get()) {\n                    log.error(\"读取消息时发生IO错误\", e);\n                    if (messageHandler != null) {\n                        messageHandler.onError(e);\n                    }\n                }\n                break;\n            } catch (Exception e) {\n                log.error(\"处理消息时发生错误\", e);\n                if (messageHandler != null) {\n                    messageHandler.onError(e);\n                }\n            }\n        }\n        \n        log.debug(\"消息读取循环结束\");\n    }\n    \n    /**\n     * 解析JSON消息为McpMessage对象\n     */\n    private McpMessage parseMessage(String jsonString) {\n        try {\n            return McpMessageCodec.parseMessage(jsonString);\n        } catch (Exception e) {\n            log.debug(\"解析消息失败: {}\", jsonString, e);\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/StreamableHttpTransport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.transport;\n\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.util.McpMessageCodec;\nimport okhttp3.*;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport okhttp3.sse.EventSources;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\n/**\n * Streamable HTTP传输层实现\n * 支持MCP 2025-03-26规范的Streamable HTTP传输\n */\npublic class StreamableHttpTransport implements McpTransport {\n    \n    private static final Logger log = LoggerFactory.getLogger(StreamableHttpTransport.class);\n    \n    private final String mcpEndpointUrl;\n    private final OkHttpClient httpClient;\n    private final AtomicBoolean running = new AtomicBoolean(false);\n    private McpMessageHandler messageHandler;\n    private EventSource eventSource;\n    private String sessionId;\n    private String lastEventId;\n    /**\n     * 自定义HTTP头（用于认证等）\n     */\n    private Map<String, String> headers;\n    public StreamableHttpTransport(String mcpEndpointUrl) {\n        this.mcpEndpointUrl = mcpEndpointUrl;\n        this.httpClient = new OkHttpClient.Builder()\n                .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)\n                .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)\n                .build();\n    }\n    public StreamableHttpTransport(TransportConfig config) {\n        this.mcpEndpointUrl = config.getUrl();\n        this.httpClient = new OkHttpClient.Builder()\n                .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)\n                .readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)\n                .build();\n        this.headers = config.getHeaders();\n    }\n    \n    @Override\n    public CompletableFuture<Void> start() {\n        return CompletableFuture.runAsync(new Runnable() {\n            @Override\n            public void run() {\n                if (running.compareAndSet(false, true)) {\n                    log.info(\"启动Streamable HTTP传输层，连接到: {}\", mcpEndpointUrl);\n                    if (messageHandler != null) {\n                        messageHandler.onConnected();\n                    }\n                }\n            }\n        });\n    }\n    \n    @Override\n    public CompletableFuture<Void> stop() {\n        return CompletableFuture.runAsync(new Runnable() {\n            @Override\n            public void run() {\n                if (running.compareAndSet(true, false)) {\n                    log.info(\"停止Streamable HTTP传输层\");\n                    if (eventSource != null) {\n                        eventSource.cancel();\n                        eventSource = null;\n                    }\n                    if (messageHandler != null) {\n                        messageHandler.onDisconnected(\"传输层停止\");\n                    }\n                }\n            }\n        });\n    }\n    \n    @Override\n    public CompletableFuture<Void> sendMessage(McpMessage message) {\n        return CompletableFuture.runAsync(new Runnable() {\n            @Override\n            public void run() {\n                if (!running.get()) {\n                    throw new IllegalStateException(\"Streamable HTTP传输层未启动\");\n                }\n            \n            try {\n                String jsonMessage = JSON.toJSONString(message);\n                log.debug(\"发送消息到MCP端点: {}\", jsonMessage);\n                \n                RequestBody body = RequestBody.create(\n                    MediaType.get(\"application/json\"), \n                    jsonMessage\n                );\n\n                Request.Builder requestBuilder = new Request.Builder()\n                        .url(mcpEndpointUrl)\n                        .post(body)\n                        .header(\"Content-Type\", \"application/json\")\n                        .header(\"Accept\", \"application/json, text/event-stream\");\n                \n                // 添加会话ID（如果存在）\n                if (sessionId != null) {\n                    requestBuilder.header(\"mcp-session-id\", sessionId);\n                }\n                \n                // 添加Last-Event-ID用于恢复连接\n                if (lastEventId != null) {\n                    requestBuilder.header(\"last-event-id\", lastEventId);\n                }\n\n                if (headers != null) {\n                    for (Map.Entry<String, String> entry : headers.entrySet()) {\n                        requestBuilder.header(entry.getKey(), entry.getValue());\n                    }\n                }\n                \n                Request request = requestBuilder.build();\n                \n                    Response response = httpClient.newCall(request).execute();\n                    try {\n                        if (!response.isSuccessful()) {\n                        throw new IOException(McpTransportSupport.buildHttpFailureMessage(response));\n                        }\n                    \n                    // 检查会话ID\n                    String newSessionId = response.header(\"mcp-session-id\");\n                    if (newSessionId != null) {\n                        StreamableHttpTransport.this.sessionId = newSessionId;\n                        log.debug(\"收到会话ID: {}\", StreamableHttpTransport.this.sessionId);\n                    }\n                    \n                    String contentType = response.header(\"Content-Type\", \"\");\n                    \n                    if (contentType.startsWith(\"text/event-stream\")) {\n                        // 服务器选择SSE流响应\n                        handleSseResponse(response);\n                    } else if (contentType.startsWith(\"application/json\")) {\n                        // 服务器选择单一JSON响应\n                        handleJsonResponse(response);\n                    } else {\n                        log.warn(\"未知的响应内容类型: {}\", contentType);\n                    }\n                } finally {\n                    response.close();\n                }\n                \n            } catch (Exception e) {\n                log.debug(\"发送Streamable HTTP消息失败: {}\", McpTransportSupport.safeMessage(e), e);\n                if (messageHandler != null) {\n                    messageHandler.onError(e);\n                }\n                throw new RuntimeException(McpTransportSupport.safeMessage(e), e);\n                }\n            }\n        });\n    }\n    \n    /**\n     * 处理JSON响应\n     */\n    private void handleJsonResponse(Response response) throws IOException {\n        if (response.body() == null) return;\n\n        String responseBody = response.body().string();\n        log.debug(\"收到JSON响应: {}\", responseBody);\n\n        // 如果响应体是空或只含空白，直接跳过\n        // initialized 会返回202 Accepted，一个空体，不需要解析\n        if (responseBody == null || responseBody.trim().isEmpty()) {\n            log.debug(\"空的 JSON 响应，忽略\");\n            return;\n        }\n\n        try {\n            McpMessage message = parseMcpMessage(responseBody);\n            if (messageHandler != null) {\n                messageHandler.handleMessage(message);\n            }\n        } catch (Exception e) {\n            log.debug(\"解析JSON响应失败: {}\", McpTransportSupport.safeMessage(e), e);\n            if (messageHandler != null) {\n                messageHandler.onError(e);\n            }\n        }\n    }\n    \n    /**\n     * 处理SSE流响应\n     */\n    private void handleSseResponse(Response response) {\n        log.debug(\"服务器升级到SSE流\");\n\n        try {\n            // 直接处理当前响应的SSE流\n            BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream()));\n            String line;\n\n            while ((line = reader.readLine()) != null) {\n                if (line.startsWith(\"data: \")) {\n                    String data = line.substring(6);\n                    try {\n                        McpMessage message = parseMcpMessage(data);\n                        if (messageHandler != null) {\n                            messageHandler.handleMessage(message);\n                        }\n                    } catch (Exception e) {\n                        log.debug(\"解析SSE数据失败: {} -> {}\", McpTransportSupport.clip(data, 120), McpTransportSupport.safeMessage(e), e);\n                    }\n                } else if (line.startsWith(\"id: \")) {\n                    lastEventId = line.substring(4);\n                }\n            }\n        } catch (Exception e) {\n            log.debug(\"处理SSE响应失败: {}\", McpTransportSupport.safeMessage(e), e);\n            if (messageHandler != null) {\n                messageHandler.onError(e);\n            }\n        }\n    }\n    \n\n    \n    @Override\n    public void setMessageHandler(McpMessageHandler handler) {\n        this.messageHandler = handler;\n    }\n    \n    @Override\n    public boolean isConnected() {\n        return running.get();\n    }\n\n    @Override\n    public boolean needsHeartbeat() {\n        return true;\n    }\n\n    @Override\n    public String getTransportType() {\n        return \"streamable_http\";\n    }\n\n    /**\n     * 获取会话ID\n     */\n    public String getSessionId() {\n        return sessionId;\n    }\n    \n    /**\n     * 终止会话\n     */\n    public CompletableFuture<Void> terminateSession() {\n        return CompletableFuture.runAsync(new Runnable() {\n            @Override\n            public void run() {\n                if (sessionId != null) {\n                    try {\n                        Request.Builder builder = new Request.Builder();\n                        if (headers != null) {\n                            for (Map.Entry<String, String> entry : headers.entrySet()) {\n                                builder.header(entry.getKey(), entry.getValue());\n                            }\n                        }\n                        Request request = builder\n                                .url(mcpEndpointUrl)\n                                .delete()\n                                .header(\"mcp-session-id\", sessionId)\n                                .build();\n\n                        Response response = httpClient.newCall(request).execute();\n                        try {\n                            log.info(\"会话已终止: {}\", sessionId);\n                        } finally {\n                            response.close();\n                        }\n                    } catch (Exception e) {\n                        log.warn(\"终止会话失败\", e);\n                    } finally {\n                        sessionId = null;\n                    }\n                }\n            }\n        });\n    }\n\n    public static McpMessage parseMcpMessage(String jsonString) {\n        return McpMessageCodec.parseMessage(jsonString);\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/transport/TransportConfig.java",
    "content": "package io.github.lnyocly.ai4j.mcp.transport;\n\nimport io.github.lnyocly.ai4j.mcp.config.McpServerConfig;\nimport io.github.lnyocly.ai4j.mcp.util.McpTypeSupport;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * MCP传输层配置类\n * 统一管理不同传输协议的配置参数\n * \n * @Author cly\n */\npublic class TransportConfig {\n    \n    // 通用配置\n    private String type;\n    \n    // HTTP/SSE相关配置\n    private String url;\n    private Integer connectTimeout;\n    private Integer readTimeout;\n    private Integer writeTimeout;\n    \n    // Stdio相关配置\n    private String command;\n    private List<String> args;\n    private Map<String, String> env;\n    /**\n     * 自定义HTTP头（用于认证等）\n     */\n    private Map<String, String> headers;\n    \n    // 高级配置\n    private Boolean enableRetry;\n    private Integer maxRetries;\n    private Long retryDelay;\n    private Boolean enableHeartbeat;\n    private Long heartbeatInterval;\n    \n    public TransportConfig() {\n        // 设置默认值\n        this.connectTimeout = 30;\n        this.readTimeout = 60;\n        this.writeTimeout = 60;\n        this.enableRetry = true;\n        this.maxRetries = 3;\n        this.retryDelay = 1000L;\n        this.enableHeartbeat = false;\n        this.heartbeatInterval = 30000L;\n    }\n    \n    // 静态工厂方法\n    \n    /**\n     * 创建stdio传输配置\n     */\n    public static TransportConfig stdio(String command, List<String> args) {\n        TransportConfig config = new TransportConfig();\n        config.type = \"stdio\";\n        config.command = command;\n        config.args = args;\n        return config;\n    }\n    \n    /**\n     * 创建stdio传输配置（带环境变量）\n     */\n    public static TransportConfig stdio(String command, List<String> args, Map<String, String> env) {\n        TransportConfig config = stdio(command, args);\n        config.env = env;\n        return config;\n    }\n    \n    /**\n     * 创建SSE传输配置\n     */\n    public static TransportConfig sse(String url) {\n        TransportConfig config = new TransportConfig();\n        config.type = \"sse\";\n        config.url = url;\n        return config;\n    }\n\n    /**\n     * 创建SSE传输配置\n     */\n    public static TransportConfig sse(McpServerConfig.McpServerInfo serverInfo) {\n        TransportConfig config = new TransportConfig();\n        config.type = McpTypeSupport.TYPE_SSE;\n        config.url = serverInfo.getUrl();\n        config.headers = serverInfo.getHeaders();\n        return config;\n    }\n    \n    /**\n     * 创建Streamable HTTP传输配置\n     */\n    public static TransportConfig streamableHttp(String url) {\n        TransportConfig config = new TransportConfig();\n        config.type = McpTypeSupport.TYPE_STREAMABLE_HTTP;\n        config.url = url;\n        return config;\n    }\n\n    /**\n     * 创建 Streamable HTTP 传输配置\n     */\n    public static TransportConfig streamableHttp(McpServerConfig.McpServerInfo serverInfo) {\n        TransportConfig config = streamableHttp(serverInfo.getUrl());\n        config.headers = serverInfo.getHeaders();\n        return config;\n    }\n    \n    /**\n     * 创建HTTP传输配置（向后兼容）\n     */\n    public static TransportConfig http(String url) {\n        return streamableHttp(url);\n    }\n\n    /**\n     * 根据 MCP server 配置创建传输配置\n     */\n    public static TransportConfig fromServerInfo(McpServerConfig.McpServerInfo serverInfo) {\n        String normalizedType = McpTypeSupport.resolveType(serverInfo);\n\n        if (McpTypeSupport.isStdio(normalizedType)) {\n            return stdio(serverInfo.getCommand(), serverInfo.getArgs(), serverInfo.getEnv());\n        }\n        if (McpTypeSupport.isSse(normalizedType)) {\n            return sse(serverInfo);\n        }\n        return streamableHttp(serverInfo);\n    }\n    \n    // Builder模式支持\n    \n    public TransportConfig withTimeout(int connectTimeout, int readTimeout, int writeTimeout) {\n        this.connectTimeout = connectTimeout;\n        this.readTimeout = readTimeout;\n        this.writeTimeout = writeTimeout;\n        return this;\n    }\n    \n    public TransportConfig withRetry(boolean enableRetry, int maxRetries, long retryDelay) {\n        this.enableRetry = enableRetry;\n        this.maxRetries = maxRetries;\n        this.retryDelay = retryDelay;\n        return this;\n    }\n    \n    public TransportConfig withHeartbeat(boolean enableHeartbeat, long heartbeatInterval) {\n        this.enableHeartbeat = enableHeartbeat;\n        this.heartbeatInterval = heartbeatInterval;\n        return this;\n    }\n    \n    // Getter和Setter方法\n    \n    public String getType() {\n        return type;\n    }\n    \n    public void setType(String type) {\n        this.type = type;\n    }\n    \n    public String getUrl() {\n        return url;\n    }\n    \n    public void setUrl(String url) {\n        this.url = url;\n    }\n    \n    public Integer getConnectTimeout() {\n        return connectTimeout;\n    }\n    \n    public void setConnectTimeout(Integer connectTimeout) {\n        this.connectTimeout = connectTimeout;\n    }\n    \n    public Integer getReadTimeout() {\n        return readTimeout;\n    }\n    \n    public void setReadTimeout(Integer readTimeout) {\n        this.readTimeout = readTimeout;\n    }\n    \n    public Integer getWriteTimeout() {\n        return writeTimeout;\n    }\n    \n    public void setWriteTimeout(Integer writeTimeout) {\n        this.writeTimeout = writeTimeout;\n    }\n    \n    public String getCommand() {\n        return command;\n    }\n    \n    public void setCommand(String command) {\n        this.command = command;\n    }\n    \n    public List<String> getArgs() {\n        return args;\n    }\n    \n    public void setArgs(List<String> args) {\n        this.args = args;\n    }\n    \n    public Map<String, String> getEnv() {\n        return env;\n    }\n    \n    public void setEnv(Map<String, String> env) {\n        this.env = env;\n    }\n    \n    public Boolean getEnableRetry() {\n        return enableRetry;\n    }\n    \n    public void setEnableRetry(Boolean enableRetry) {\n        this.enableRetry = enableRetry;\n    }\n    \n    public Integer getMaxRetries() {\n        return maxRetries;\n    }\n    \n    public void setMaxRetries(Integer maxRetries) {\n        this.maxRetries = maxRetries;\n    }\n    \n    public Long getRetryDelay() {\n        return retryDelay;\n    }\n    \n    public void setRetryDelay(Long retryDelay) {\n        this.retryDelay = retryDelay;\n    }\n    \n    public Boolean getEnableHeartbeat() {\n        return enableHeartbeat;\n    }\n    \n    public void setEnableHeartbeat(Boolean enableHeartbeat) {\n        this.enableHeartbeat = enableHeartbeat;\n    }\n    \n    public Long getHeartbeatInterval() {\n        return heartbeatInterval;\n    }\n    \n    public void setHeartbeatInterval(Long heartbeatInterval) {\n        this.heartbeatInterval = heartbeatInterval;\n    }\n\n    public Map<String, String> getHeaders() {\n        return headers;\n    }\n\n    public void setHeaders(Map<String, String> headers) {\n        this.headers = headers;\n    }\n\n    @Override\n    public String toString() {\n        return \"TransportConfig{\" +\n                \"type='\" + type + '\\'' +\n                \", url='\" + url + '\\'' +\n                \", command='\" + command + '\\'' +\n                \", args=\" + args +\n                \", connectTimeout=\" + connectTimeout +\n                \", readTimeout=\" + readTimeout +\n                \", writeTimeout=\" + writeTimeout +\n                \", enableRetry=\" + enableRetry +\n                \", maxRetries=\" + maxRetries +\n                \", retryDelay=\" + retryDelay +\n                \", enableHeartbeat=\" + enableHeartbeat +\n                \", heartbeatInterval=\" + heartbeatInterval +\n                '}';\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpMessageCodec.java",
    "content": "package io.github.lnyocly.ai4j.mcp.util;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.entity.McpNotification;\nimport io.github.lnyocly.ai4j.mcp.entity.McpRequest;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResponse;\n\nimport java.util.Map;\n\n/**\n * MCP 消息编解码辅助类\n */\npublic final class McpMessageCodec {\n\n    private McpMessageCodec() {\n    }\n\n    public static McpMessage parseMessage(String jsonMessage) {\n        if (jsonMessage == null || jsonMessage.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"MCP message must not be empty\");\n        }\n\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> messageMap = JSON.parseObject(jsonMessage, Map.class);\n        if (messageMap == null) {\n            throw new IllegalArgumentException(\"Unable to parse MCP message\");\n        }\n\n        String method = stringValue(messageMap.get(\"method\"));\n        Object id = messageMap.get(\"id\");\n        Object result = messageMap.get(\"result\");\n        Object error = messageMap.get(\"error\");\n\n        if (method != null && id != null && result == null && error == null) {\n            return JSON.parseObject(jsonMessage, McpRequest.class);\n        }\n        if (method != null && id == null) {\n            return JSON.parseObject(jsonMessage, McpNotification.class);\n        }\n        if (method == null && id != null && (result != null || error != null)) {\n            return JSON.parseObject(jsonMessage, McpResponse.class);\n        }\n        if (method != null) {\n            return JSON.parseObject(jsonMessage, McpRequest.class);\n        }\n\n        throw new IllegalArgumentException(\"Unrecognized MCP message: \" + jsonMessage);\n    }\n\n    private static String stringValue(Object value) {\n        return value != null ? String.valueOf(value) : null;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpPromptAdapter.java",
    "content": "package io.github.lnyocly.ai4j.mcp.util;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpPrompt;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpPromptParameter;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpService;\nimport io.github.lnyocly.ai4j.mcp.entity.McpPromptResult;\nimport lombok.extern.slf4j.Slf4j;\nimport org.reflections.Reflections;\n\nimport java.lang.reflect.Method;\nimport java.lang.reflect.Parameter;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * @Author cly\n * @Description MCP提示词适配器，用于管理和调用MCP提示词\n */\n@Slf4j\npublic class McpPromptAdapter {\n    \n    private static final Reflections reflections = new Reflections(\"io.github.lnyocly\");\n    \n    // 提示词缓存：提示词名称 -> 提示词定义\n    private static final Map<String, io.github.lnyocly.ai4j.mcp.entity.McpPrompt> mcpPromptCache = new ConcurrentHashMap<>();\n    \n    // 提示词类映射：提示词名称 -> 服务类\n    private static final Map<String, Class<?>> promptClassMap = new ConcurrentHashMap<>();\n    \n    // 提示词方法映射：提示词名称 -> 方法\n    private static final Map<String, Method> promptMethodMap = new ConcurrentHashMap<>();\n    \n    /**\n     * 扫描并注册所有MCP服务中的提示词\n     */\n    public static void scanAndRegisterMcpPrompts() {\n        // 扫描@McpService注解的类\n        Set<Class<?>> mcpServiceClasses = reflections.getTypesAnnotatedWith(McpService.class);\n        \n        for (Class<?> serviceClass : mcpServiceClasses) {\n            registerMcpServicePrompts(serviceClass);\n        }\n        \n        log.info(\"MCP提示词扫描完成，共注册 {} 个提示词\", mcpPromptCache.size());\n    }\n    \n    /**\n     * 注册指定服务类中的提示词\n     */\n    private static void registerMcpServicePrompts(Class<?> serviceClass) {\n        McpService mcpService = serviceClass.getAnnotation(McpService.class);\n        String serviceName = mcpService.name();\n        \n        // 扫描类中标记了@McpPrompt的方法\n        Method[] methods = serviceClass.getDeclaredMethods();\n        for (Method method : methods) {\n            McpPrompt mcpPrompt = method.getAnnotation(McpPrompt.class);\n            if (mcpPrompt != null) {\n                String promptName = mcpPrompt.name().isEmpty() ? method.getName() : mcpPrompt.name();\n                String fullPromptName = serviceName + \".\" + promptName;\n                \n                io.github.lnyocly.ai4j.mcp.entity.McpPrompt promptDefinition = \n                    createPromptDefinitionFromMethod(method, mcpPrompt, serviceClass, fullPromptName);\n                \n                mcpPromptCache.put(fullPromptName, promptDefinition);\n                promptClassMap.put(fullPromptName, serviceClass);\n                promptMethodMap.put(fullPromptName, method);\n                \n                log.debug(\"注册MCP提示词: {} -> {}\", fullPromptName, method.getName());\n            }\n        }\n    }\n    \n    /**\n     * 从方法创建提示词定义\n     */\n    private static io.github.lnyocly.ai4j.mcp.entity.McpPrompt createPromptDefinitionFromMethod(\n            Method method, McpPrompt mcpPrompt, Class<?> serviceClass, String fullPromptName) {\n        \n        // 构建参数Schema\n        Map<String, Object> arguments = new HashMap<>();\n        Parameter[] parameters = method.getParameters();\n        \n        for (Parameter parameter : parameters) {\n            McpPromptParameter promptParam = parameter.getAnnotation(McpPromptParameter.class);\n            if (promptParam != null) {\n                String paramName = promptParam.name().isEmpty() ? parameter.getName() : promptParam.name();\n\n                Map<String, Object> paramSchema = new HashMap<>();\n                paramSchema.put(\"name\", paramName);\n                paramSchema.put(\"description\", promptParam.description());\n                paramSchema.put(\"required\", promptParam.required());\n                paramSchema.put(\"type\", getJsonSchemaType(parameter.getType()));\n\n                if (!promptParam.defaultValue().isEmpty()) {\n                    paramSchema.put(\"default\", promptParam.defaultValue());\n                }\n\n                arguments.put(paramName, paramSchema);\n            }\n        }\n        \n        return io.github.lnyocly.ai4j.mcp.entity.McpPrompt.builder()\n                .name(fullPromptName)\n                .description(mcpPrompt.description())\n                .arguments(arguments)\n                .build();\n    }\n    \n    /**\n     * 获取所有MCP提示词列表\n     */\n    public static List<io.github.lnyocly.ai4j.mcp.entity.McpPrompt> getAllMcpPrompts() {\n        return new ArrayList<>(mcpPromptCache.values());\n    }\n    \n    /**\n     * 获取指定提示词\n     */\n    public static McpPromptResult getMcpPrompt(String promptName, Map<String, Object> arguments) {\n        try {\n            // 检查提示词是否存在\n            if (!mcpPromptCache.containsKey(promptName)) {\n                throw new IllegalArgumentException(\"提示词不存在: \" + promptName);\n            }\n            \n            // 获取提示词方法和类\n            Method method = promptMethodMap.get(promptName);\n            Class<?> promptClass = promptClassMap.get(promptName);\n            \n            if (method == null || promptClass == null) {\n                throw new IllegalArgumentException(\"提示词方法未找到: \" + promptName);\n            }\n            \n            // 调用提示词方法\n            Object result = invokeMcpPromptMethod(promptClass, method, arguments);\n            \n            // 构建提示词结果\n            io.github.lnyocly.ai4j.mcp.entity.McpPrompt promptDef = mcpPromptCache.get(promptName);\n            \n            return McpPromptResult.builder()\n                    .name(promptName)\n                    .content(result != null ? result.toString() : \"\")\n                    .description(promptDef.getDescription())\n                    .build();\n                    \n        } catch (Exception e) {\n            log.error(\"获取MCP提示词失败: {}\", promptName, e);\n            throw new RuntimeException(\"获取提示词失败: \" + e.getMessage(), e);\n        }\n    }\n    \n    /**\n     * 调用MCP提示词方法\n     */\n    private static Object invokeMcpPromptMethod(Class<?> promptClass, Method method, Map<String, Object> arguments) \n            throws Exception {\n        \n        // 创建服务实例\n        Object serviceInstance = promptClass.getDeclaredConstructor().newInstance();\n        \n        // 准备方法参数\n        Object[] methodArgs = preparePromptMethodArguments(method, arguments);\n        \n        // 调用方法\n        method.setAccessible(true);\n        return method.invoke(serviceInstance, methodArgs);\n    }\n    \n    /**\n     * 准备提示词方法参数\n     */\n    private static Object[] preparePromptMethodArguments(Method method, Map<String, Object> arguments) {\n        Class<?>[] paramTypes = method.getParameterTypes();\n        Parameter[] parameters = method.getParameters();\n        Object[] result = new Object[paramTypes.length];\n        \n        for (int i = 0; i < parameters.length; i++) {\n            McpPromptParameter promptParam = parameters[i].getAnnotation(McpPromptParameter.class);\n            String paramName = promptParam != null && !promptParam.name().isEmpty()\n                    ? promptParam.name()\n                    : parameters[i].getName();\n            \n            Object value = arguments != null ? arguments.get(paramName) : null;\n            \n            // 如果没有提供值，使用默认值\n            if (value == null && promptParam != null && !promptParam.defaultValue().isEmpty()) {\n                value = promptParam.defaultValue();\n            }\n            \n            result[i] = convertValue(value, paramTypes[i]);\n        }\n        \n        return result;\n    }\n    \n    /**\n     * 获取Java类型对应的JSON Schema类型\n     */\n    private static String getJsonSchemaType(Class<?> javaType) {\n        if (javaType == String.class) {\n            return \"string\";\n        } else if (javaType == Integer.class || javaType == int.class ||\n                   javaType == Long.class || javaType == long.class) {\n            return \"integer\";\n        } else if (javaType == Double.class || javaType == double.class ||\n                   javaType == Float.class || javaType == float.class) {\n            return \"number\";\n        } else if (javaType == Boolean.class || javaType == boolean.class) {\n            return \"boolean\";\n        } else if (javaType.isArray() || java.util.List.class.isAssignableFrom(javaType)) {\n            return \"array\";\n        } else {\n            return \"object\";\n        }\n    }\n\n    /**\n     * 转换参数值类型\n     */\n    private static Object convertValue(Object value, Class<?> targetType) {\n        if (value == null) {\n            return null;\n        }\n\n        String stringValue = value.toString();\n\n        if (targetType == String.class) {\n            return stringValue;\n        } else if (targetType == Integer.class || targetType == int.class) {\n            return Integer.parseInt(stringValue);\n        } else if (targetType == Long.class || targetType == long.class) {\n            return Long.parseLong(stringValue);\n        } else if (targetType == Boolean.class || targetType == boolean.class) {\n            return Boolean.parseBoolean(stringValue);\n        } else if (targetType == Double.class || targetType == double.class) {\n            return Double.parseDouble(stringValue);\n        } else {\n            // 尝试JSON反序列化\n            return JSON.parseObject(stringValue, targetType);\n        }\n    }\n    \n    /**\n     * 检查提示词是否存在\n     */\n    public static boolean promptExists(String promptName) {\n        return mcpPromptCache.containsKey(promptName);\n    }\n    \n    /**\n     * 获取提示词定义\n     */\n    public static io.github.lnyocly.ai4j.mcp.entity.McpPrompt getPromptDefinition(String promptName) {\n        return mcpPromptCache.get(promptName);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpResourceAdapter.java",
    "content": "package io.github.lnyocly.ai4j.mcp.util;\n\nimport io.github.lnyocly.ai4j.mcp.annotation.McpResource;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpResourceParameter;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpService;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResourceContent;\nimport lombok.extern.slf4j.Slf4j;\nimport org.reflections.Reflections;\n\nimport java.lang.reflect.Method;\nimport java.lang.reflect.Parameter;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\n/**\n * @Author cly\n * @Description MCP资源适配器，用于管理和调用MCP资源\n */\n@Slf4j\npublic class McpResourceAdapter {\n    \n    private static final Reflections reflections = new Reflections(\"io.github.lnyocly\");\n    \n    // 资源缓存：URI模板 -> 资源定义\n    private static final Map<String, io.github.lnyocly.ai4j.mcp.entity.McpResource> mcpResourceCache = new ConcurrentHashMap<>();\n    \n    // 资源类映射：URI模板 -> 服务类\n    private static final Map<String, Class<?>> resourceClassMap = new ConcurrentHashMap<>();\n    \n    // 资源方法映射：URI模板 -> 方法\n    private static final Map<String, Method> resourceMethodMap = new ConcurrentHashMap<>();\n    \n    // URI模板参数提取正则\n    private static final Pattern URI_PARAM_PATTERN = Pattern.compile(\"\\\\{([^}]+)\\\\}\");\n    \n    /**\n     * 扫描并注册所有MCP服务中的资源\n     */\n    public static void scanAndRegisterMcpResources() {\n        // 扫描@McpService注解的类\n        Set<Class<?>> mcpServiceClasses = reflections.getTypesAnnotatedWith(McpService.class);\n        \n        for (Class<?> serviceClass : mcpServiceClasses) {\n            registerMcpServiceResources(serviceClass);\n        }\n        \n        log.info(\"MCP资源扫描完成，共注册 {} 个资源\", mcpResourceCache.size());\n    }\n    \n    /**\n     * 注册指定服务类中的资源\n     */\n    private static void registerMcpServiceResources(Class<?> serviceClass) {\n        McpService mcpService = serviceClass.getAnnotation(McpService.class);\n        String serviceName = mcpService.name();\n        \n        // 扫描类中标记了@McpResource的方法\n        Method[] methods = serviceClass.getDeclaredMethods();\n        for (Method method : methods) {\n            McpResource mcpResource = method.getAnnotation(McpResource.class);\n            if (mcpResource != null) {\n                String uriTemplate = mcpResource.uri();\n                \n                io.github.lnyocly.ai4j.mcp.entity.McpResource resourceDefinition = \n                    createResourceDefinitionFromMethod(method, mcpResource, serviceClass, uriTemplate);\n                \n                mcpResourceCache.put(uriTemplate, resourceDefinition);\n                resourceClassMap.put(uriTemplate, serviceClass);\n                resourceMethodMap.put(uriTemplate, method);\n                \n                log.debug(\"注册MCP资源: {} -> {}\", uriTemplate, method.getName());\n            }\n        }\n    }\n    \n    /**\n     * 从方法创建资源定义\n     */\n    private static io.github.lnyocly.ai4j.mcp.entity.McpResource createResourceDefinitionFromMethod(\n            Method method, McpResource mcpResource, Class<?> serviceClass, String uriTemplate) {\n        \n        return io.github.lnyocly.ai4j.mcp.entity.McpResource.builder()\n                .uri(uriTemplate)\n                .name(mcpResource.name())\n                .description(mcpResource.description())\n                .mimeType(mcpResource.mimeType().isEmpty() ? null : mcpResource.mimeType())\n                .size(mcpResource.size() == -1L ? null : mcpResource.size())\n                .build();\n    }\n    \n    /**\n     * 获取所有MCP资源列表\n     */\n    public static List<io.github.lnyocly.ai4j.mcp.entity.McpResource> getAllMcpResources() {\n        return new ArrayList<>(mcpResourceCache.values());\n    }\n    \n    /**\n     * 读取指定URI的资源内容\n     */\n    public static McpResourceContent readMcpResource(String uri) {\n        try {\n            // 查找匹配的URI模板\n            String matchedTemplate = findMatchingTemplate(uri);\n            if (matchedTemplate == null) {\n                throw new IllegalArgumentException(\"资源不存在: \" + uri);\n            }\n            \n            // 获取资源方法和类\n            Method method = resourceMethodMap.get(matchedTemplate);\n            Class<?> resourceClass = resourceClassMap.get(matchedTemplate);\n            \n            if (method == null || resourceClass == null) {\n                throw new IllegalArgumentException(\"资源方法未找到: \" + uri);\n            }\n            \n            // 提取URI参数\n            Map<String, String> uriParams = extractUriParameters(matchedTemplate, uri);\n            \n            // 调用资源方法\n            Object result = invokeMcpResourceMethod(resourceClass, method, uriParams);\n            \n            // 构建资源内容\n            io.github.lnyocly.ai4j.mcp.entity.McpResource resourceDef = mcpResourceCache.get(matchedTemplate);\n            \n            return McpResourceContent.builder()\n                    .uri(uri)\n                    .contents(result)\n                    .mimeType(resourceDef.getMimeType())\n                    .build();\n                    \n        } catch (Exception e) {\n            log.error(\"读取MCP资源失败: {}\", uri, e);\n            throw new RuntimeException(\"读取资源失败: \" + e.getMessage(), e);\n        }\n    }\n    \n    /**\n     * 查找匹配URI的模板\n     */\n    private static String findMatchingTemplate(String uri) {\n        for (String template : mcpResourceCache.keySet()) {\n            if (uriMatchesTemplate(uri, template)) {\n                return template;\n            }\n        }\n        return null;\n    }\n    \n    /**\n     * 检查URI是否匹配模板\n     */\n    private static boolean uriMatchesTemplate(String uri, String template) {\n        // 将模板转换为正则表达式\n        String regex = template.replaceAll(\"\\\\{[^}]+\\\\}\", \"[^/]+\");\n        return uri.matches(regex);\n    }\n    \n    /**\n     * 从URI中提取参数\n     */\n    private static Map<String, String> extractUriParameters(String template, String uri) {\n        Map<String, String> params = new HashMap<>();\n        \n        // 提取模板中的参数名\n        List<String> paramNames = new ArrayList<>();\n        Matcher matcher = URI_PARAM_PATTERN.matcher(template);\n        while (matcher.find()) {\n            paramNames.add(matcher.group(1));\n        }\n        \n        // 构建正则表达式来提取参数值\n        String regex = template;\n        for (String paramName : paramNames) {\n            regex = regex.replace(\"{\" + paramName + \"}\", \"([^/]+)\");\n        }\n        \n        // 提取参数值\n        Pattern pattern = Pattern.compile(regex);\n        Matcher uriMatcher = pattern.matcher(uri);\n        if (uriMatcher.matches()) {\n            for (int i = 0; i < paramNames.size(); i++) {\n                params.put(paramNames.get(i), uriMatcher.group(i + 1));\n            }\n        }\n        \n        return params;\n    }\n    \n    /**\n     * 调用MCP资源方法\n     */\n    private static Object invokeMcpResourceMethod(Class<?> resourceClass, Method method, Map<String, String> uriParams) \n            throws Exception {\n        \n        // 创建服务实例\n        Object serviceInstance = resourceClass.getDeclaredConstructor().newInstance();\n        \n        // 准备方法参数\n        Object[] methodArgs = prepareResourceMethodArguments(method, uriParams);\n        \n        // 调用方法\n        method.setAccessible(true);\n        return method.invoke(serviceInstance, methodArgs);\n    }\n    \n    /**\n     * 准备资源方法参数\n     */\n    private static Object[] prepareResourceMethodArguments(Method method, Map<String, String> uriParams) {\n        Class<?>[] paramTypes = method.getParameterTypes();\n        Parameter[] parameters = method.getParameters();\n        Object[] result = new Object[paramTypes.length];\n        \n        for (int i = 0; i < parameters.length; i++) {\n            McpResourceParameter resourceParam = parameters[i].getAnnotation(McpResourceParameter.class);\n            String paramName = resourceParam != null && !resourceParam.name().isEmpty()\n                    ? resourceParam.name()\n                    : parameters[i].getName();\n            \n            String value = uriParams.get(paramName);\n            result[i] = convertValue(value, paramTypes[i]);\n        }\n        \n        return result;\n    }\n    \n    /**\n     * 转换参数值类型\n     */\n    private static Object convertValue(Object value, Class<?> targetType) {\n        if (value == null) {\n            return null;\n        }\n        \n        String stringValue = value.toString();\n        \n        if (targetType == String.class) {\n            return stringValue;\n        } else if (targetType == Integer.class || targetType == int.class) {\n            return Integer.parseInt(stringValue);\n        } else if (targetType == Long.class || targetType == long.class) {\n            return Long.parseLong(stringValue);\n        } else if (targetType == Boolean.class || targetType == boolean.class) {\n            return Boolean.parseBoolean(stringValue);\n        } else if (targetType == Double.class || targetType == double.class) {\n            return Double.parseDouble(stringValue);\n        } else {\n            // 尝试JSON反序列化\n            return com.alibaba.fastjson2.JSON.parseObject(stringValue, targetType);\n        }\n    }\n    \n    /**\n     * 检查资源是否存在\n     */\n    public static boolean resourceExists(String uri) {\n        return findMatchingTemplate(uri) != null;\n    }\n    \n    /**\n     * 获取资源定义\n     */\n    public static io.github.lnyocly.ai4j.mcp.entity.McpResource getResourceDefinition(String uri) {\n        String template = findMatchingTemplate(uri);\n        return template != null ? mcpResourceCache.get(template) : null;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpToolAdapter.java",
    "content": "package io.github.lnyocly.ai4j.mcp.util;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpParameter;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpService;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpTool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.reflections.Reflections;\nimport org.reflections.scanners.Scanners;\nimport org.reflections.util.ClasspathHelper;\nimport org.reflections.util.ConfigurationBuilder;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.Method;\nimport java.lang.reflect.Parameter;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\n/**\n * @Author cly\n * @Description Local MCP tool adapter for scanning, registering and invoking local MCP tools\n */\npublic class McpToolAdapter {\n\n    private static final Logger log = LoggerFactory.getLogger(McpToolAdapter.class);\n    \n    // Reflection tool for scanning annotations\n    private static final Reflections reflections = new Reflections(new ConfigurationBuilder()\n            .setUrls(ClasspathHelper.forPackage(\"\"))\n            .setScanners(Scanners.TypesAnnotated, Scanners.MethodsAnnotated));\n    \n    // Local MCP tool cache\n    private static final Map<String, Tool> localMcpToolCache = new ConcurrentHashMap<>();\n    private static final Map<String, Method> localMcpMethodCache = new ConcurrentHashMap<>();\n    private static final Map<String, Class<?>> localMcpClassCache = new ConcurrentHashMap<>();\n    \n    // Initialization flag\n    private static volatile boolean initialized = false;\n    private static final Object initLock = new Object();\n\n    /**\n     * Scan and register all local MCP tools\n     */\n    public static void scanAndRegisterMcpTools() {\n        if (initialized) {\n            return;\n        }\n        \n        synchronized (initLock) {\n            if (initialized) {\n                return;\n            }\n            \n            log.info(\"Starting to scan local MCP tools...\");\n            long startTime = System.currentTimeMillis();\n            \n            try {\n                // Get all classes marked with @McpService\n                Set<Class<?>> mcpServiceClasses = reflections.getTypesAnnotatedWith(McpService.class);\n                \n                int toolCount = 0;\n                for (Class<?> serviceClass : mcpServiceClasses) {\n                    McpService mcpService = serviceClass.getAnnotation(McpService.class);\n                    log.debug(\"Found MCP service: {} - {}\", mcpService.name(), serviceClass.getName());\n                    \n                    // Scan tool methods in service class\n                    Method[] methods = serviceClass.getDeclaredMethods();\n                    for (Method method : methods) {\n                        McpTool mcpTool = method.getAnnotation(McpTool.class);\n                        if (mcpTool != null) {\n                            String toolName = mcpTool.name().isEmpty() ? method.getName() : mcpTool.name();\n                            \n                            // Create Tool object\n                            Tool tool = createToolFromMethod(method, mcpTool, serviceClass);\n                            if (tool != null) {\n                                // Cache tool information\n                                localMcpToolCache.put(toolName, tool);\n                                localMcpMethodCache.put(toolName, method);\n                                localMcpClassCache.put(toolName, serviceClass);\n                                \n                                toolCount++;\n                                log.debug(\"Registered local MCP tool: {} -> {}.{}\", toolName, serviceClass.getSimpleName(), method.getName());\n                            }\n                        }\n                    }\n                }\n                \n                initialized = true;\n                log.info(\"Local MCP tool scanning completed, registered {} tools, took {} ms\", \n                        toolCount, System.currentTimeMillis() - startTime);\n                \n            } catch (Exception e) {\n                log.error(\"Failed to scan local MCP tools\", e);\n                throw new RuntimeException(\"Failed to scan local MCP tools\", e);\n            }\n        }\n    }\n\n    /**\n     * Get all local MCP tools\n     */\n    public static List<Tool> getAllMcpTools() {\n        ensureInitialized();\n        return new ArrayList<>(localMcpToolCache.values());\n    }\n\n    /**\n     * Invoke local MCP tool\n     */\n    public static String invokeMcpTool(String toolName, String arguments) {\n        ensureInitialized();\n        \n        long startTime = System.currentTimeMillis();\n        log.info(\"Invoking local MCP tool: {}, arguments: {}\", toolName, arguments);\n        \n        try {\n            Method method = localMcpMethodCache.get(toolName);\n            Class<?> serviceClass = localMcpClassCache.get(toolName);\n            \n            if (method == null || serviceClass == null) {\n                throw new IllegalArgumentException(\"Local MCP tool not found: \" + toolName);\n            }\n            \n            // Create service instance\n            Object serviceInstance = serviceClass.getDeclaredConstructor().newInstance();\n            \n            // Parse arguments\n            Object[] methodArgs = parseMethodArguments(method, arguments);\n            \n            // Invoke method\n            Object result = method.invoke(serviceInstance, methodArgs);\n            \n            // Convert result to JSON string\n            String response = JSON.toJSONString(result);\n            log.info(\"Local MCP tool invocation successful: {}, response: {}, took: {} ms\", \n                    toolName, response, System.currentTimeMillis() - startTime);\n            \n            return response;\n            \n        } catch (Exception e) {\n            log.error(\"Local MCP tool invocation failed: {} - {}\", toolName, e.getMessage(), e);\n            throw new RuntimeException(\"Local MCP tool invocation failed: \" + e.getMessage(), e);\n        }\n    }\n\n    /**\n     * Check if local MCP tool exists\n     */\n    public static boolean mcpToolExists(String toolName) {\n        ensureInitialized();\n        return localMcpToolCache.containsKey(toolName);\n    }\n\n    /**\n     * Get detailed information of local MCP tool\n     */\n    public static Tool getMcpTool(String toolName) {\n        ensureInitialized();\n        return localMcpToolCache.get(toolName);\n    }\n\n    /**\n     * Get all local MCP tool names list\n     */\n    public static Set<String> getAllMcpToolNames() {\n        ensureInitialized();\n        return new HashSet<>(localMcpToolCache.keySet());\n    }\n\n    /**\n     * Clear cache and rescan\n     */\n    public static void refresh() {\n        synchronized (initLock) {\n            localMcpToolCache.clear();\n            localMcpMethodCache.clear();\n            localMcpClassCache.clear();\n            initialized = false;\n        }\n        scanAndRegisterMcpTools();\n    }\n\n    /**\n     * Ensure initialized\n     */\n    private static void ensureInitialized() {\n        if (!initialized) {\n            scanAndRegisterMcpTools();\n        }\n    }\n\n    /**\n     * Create Tool object from method\n     */\n    private static Tool createToolFromMethod(Method method, McpTool mcpTool, Class<?> serviceClass) {\n        try {\n            String toolName = mcpTool.name().isEmpty() ? method.getName() : mcpTool.name();\n            String description = mcpTool.description();\n            \n            // Create Function object\n            Tool.Function function = new Tool.Function();\n            function.setName(toolName);\n            function.setDescription(description);\n            \n            // Create parameter definition\n            Tool.Function.Parameter parameters = createParametersFromMethod(method);\n            function.setParameters(parameters);\n            \n            // Create Tool object\n            Tool tool = new Tool();\n            tool.setType(\"function\");\n            tool.setFunction(function);\n            \n            return tool;\n            \n        } catch (Exception e) {\n            log.error(\"Failed to create tool definition: {}.{} - {}\", serviceClass.getSimpleName(), method.getName(), e.getMessage());\n            return null;\n        }\n    }\n\n    /**\n     * Create parameter definition from method\n     */\n    private static Tool.Function.Parameter createParametersFromMethod(Method method) {\n        Map<String, Tool.Function.Property> properties = new HashMap<>();\n        List<String> requiredParameters = new ArrayList<>();\n        \n        Parameter[] parameters = method.getParameters();\n        for (Parameter parameter : parameters) {\n            McpParameter mcpParam = parameter.getAnnotation(McpParameter.class);\n            \n            String paramName = (mcpParam != null && !mcpParam.name().isEmpty()) \n                    ? mcpParam.name() \n                    : parameter.getName();\n            \n            String paramDescription = (mcpParam != null) \n                    ? mcpParam.description() \n                    : \"\";\n            \n            boolean required = (mcpParam == null) || mcpParam.required();\n            \n            // Create property object\n            Tool.Function.Property property = createPropertyFromParameter(parameter.getType(), paramDescription);\n            properties.put(paramName, property);\n            \n            if (required) {\n                requiredParameters.add(paramName);\n            }\n        }\n        \n        return new Tool.Function.Parameter(\"object\", properties, requiredParameters);\n    }\n\n    /**\n     * Create property object from parameter type\n     */\n    private static Tool.Function.Property createPropertyFromParameter(Class<?> paramType, String description) {\n        Tool.Function.Property property = new Tool.Function.Property();\n        property.setDescription(description);\n        \n        if (paramType.isEnum()) {\n            property.setType(\"string\");\n            property.setEnumValues(getEnumValues(paramType));\n        } else if (paramType.equals(String.class)) {\n            property.setType(\"string\");\n        } else if (paramType.equals(int.class) || paramType.equals(Integer.class) ||\n                paramType.equals(long.class) || paramType.equals(Long.class) ||\n                paramType.equals(short.class) || paramType.equals(Short.class)) {\n            property.setType(\"integer\");\n        } else if (paramType.equals(float.class) || paramType.equals(Float.class) ||\n                paramType.equals(double.class) || paramType.equals(Double.class)) {\n            property.setType(\"number\");\n        } else if (paramType.equals(boolean.class) || paramType.equals(Boolean.class)) {\n            property.setType(\"boolean\");\n        } else if (paramType.isArray() || Collection.class.isAssignableFrom(paramType)) {\n            property.setType(\"array\");\n            // Add items definition for array type\n            Tool.Function.Property items = new Tool.Function.Property();\n            items.setType(\"object\"); // Default to object type\n            property.setItems(items);\n        } else if (Map.class.isAssignableFrom(paramType)) {\n            property.setType(\"object\");\n        } else {\n            property.setType(\"object\");\n        }\n        \n        return property;\n    }\n\n    /**\n     * Get all possible values of enum type\n     */\n    private static List<String> getEnumValues(Class<?> enumType) {\n        List<String> enumValues = new ArrayList<>();\n        for (Object enumConstant : enumType.getEnumConstants()) {\n            enumValues.add(enumConstant.toString());\n        }\n        return enumValues;\n    }\n\n    /**\n     * Parse method arguments\n     */\n    private static Object[] parseMethodArguments(Method method, String arguments) {\n        Parameter[] parameters = method.getParameters();\n        if (parameters.length == 0) {\n            return new Object[0];\n        }\n        \n        try {\n            // Parse JSON arguments\n            Map<String, Object> argMap = JSON.parseObject(arguments);\n            Object[] methodArgs = new Object[parameters.length];\n            \n            for (int i = 0; i < parameters.length; i++) {\n                Parameter parameter = parameters[i];\n                McpParameter mcpParam = parameter.getAnnotation(McpParameter.class);\n                \n                String paramName = (mcpParam != null && !mcpParam.name().isEmpty()) \n                        ? mcpParam.name() \n                        : parameter.getName();\n                \n                Object value = argMap.get(paramName);\n                \n                // If parameter is null and has default value, use default value\n                if (value == null && mcpParam != null && !mcpParam.defaultValue().isEmpty()) {\n                    value = mcpParam.defaultValue();\n                }\n                \n                // Type conversion\n                methodArgs[i] = convertValue(value, parameter.getType());\n            }\n            \n            return methodArgs;\n            \n        } catch (Exception e) {\n            log.error(\"Failed to parse method arguments: {} - {}\", method.getName(), e.getMessage());\n            throw new RuntimeException(\"Failed to parse method arguments: \" + e.getMessage(), e);\n        }\n    }\n\n    /**\n     * Value type conversion\n     */\n    private static Object convertValue(Object value, Class<?> targetType) {\n        if (value == null) {\n            return null;\n        }\n        \n        if (targetType.isAssignableFrom(value.getClass())) {\n            return value;\n        }\n        \n        try {\n            if (targetType == String.class) {\n                return value.toString();\n            } else if (targetType == int.class || targetType == Integer.class) {\n                return Integer.valueOf(value.toString());\n            } else if (targetType == long.class || targetType == Long.class) {\n                return Long.valueOf(value.toString());\n            } else if (targetType == double.class || targetType == Double.class) {\n                return Double.valueOf(value.toString());\n            } else if (targetType == float.class || targetType == Float.class) {\n                return Float.valueOf(value.toString());\n            } else if (targetType == boolean.class || targetType == Boolean.class) {\n                return Boolean.valueOf(value.toString());\n            } else {\n                // For complex objects, try JSON conversion\n                return JSON.parseObject(JSON.toJSONString(value), targetType);\n            }\n        } catch (Exception e) {\n            log.warn(\"Type conversion failed: {} -> {}, using original value\", value.getClass(), targetType);\n            return value;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpToolConversionSupport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.util;\n\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * MCP Tool 与 OpenAI Tool.Function 转换辅助类\n */\npublic final class McpToolConversionSupport {\n\n    private McpToolConversionSupport() {\n    }\n\n    public static Tool.Function convertToOpenAiTool(McpToolDefinition mcpTool) {\n        Tool.Function function = new Tool.Function();\n        function.setName(mcpTool.getName());\n        function.setDescription(mcpTool.getDescription());\n\n        if (mcpTool.getInputSchema() != null) {\n            function.setParameters(convertInputSchema(mcpTool.getInputSchema()));\n        }\n\n        return function;\n    }\n\n    public static Tool.Function.Parameter convertInputSchema(Map<String, Object> inputSchema) {\n        Tool.Function.Parameter parameter = new Tool.Function.Parameter();\n        parameter.setType(\"object\");\n\n        Object properties = inputSchema.get(\"properties\");\n        Object required = inputSchema.get(\"required\");\n\n        if (properties instanceof Map<?, ?>) {\n            Map<String, Tool.Function.Property> convertedProperties = new HashMap<String, Tool.Function.Property>();\n\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> propsMap = (Map<String, Object>) properties;\n            propsMap.forEach((key, value) -> {\n                if (value instanceof Map<?, ?>) {\n                    @SuppressWarnings(\"unchecked\")\n                    Map<String, Object> propMap = (Map<String, Object>) value;\n                    convertedProperties.put(key, convertToProperty(propMap));\n                }\n            });\n\n            parameter.setProperties(convertedProperties);\n        }\n\n        if (required instanceof List<?>) {\n            @SuppressWarnings(\"unchecked\")\n            List<String> requiredList = (List<String>) required;\n            parameter.setRequired(requiredList);\n        }\n\n        return parameter;\n    }\n\n    public static Tool.Function.Property convertToProperty(Map<String, Object> propMap) {\n        Tool.Function.Property property = new Tool.Function.Property();\n        property.setType((String) propMap.get(\"type\"));\n        property.setDescription((String) propMap.get(\"description\"));\n\n        Object enumObj = propMap.get(\"enum\");\n        if (enumObj instanceof List<?>) {\n            @SuppressWarnings(\"unchecked\")\n            List<String> enumValues = (List<String>) enumObj;\n            property.setEnumValues(enumValues);\n        }\n\n        Object itemsObj = propMap.get(\"items\");\n        if (itemsObj instanceof Map<?, ?>) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> itemsMap = (Map<String, Object>) itemsObj;\n            property.setItems(convertToProperty(itemsMap));\n        }\n\n        return property;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/mcp/util/McpTypeSupport.java",
    "content": "package io.github.lnyocly.ai4j.mcp.util;\n\nimport io.github.lnyocly.ai4j.mcp.config.McpServerConfig;\nimport io.github.lnyocly.ai4j.mcp.entity.McpServerReference;\n\n/**\n * MCP 传输/服务端类型辅助类\n */\npublic final class McpTypeSupport {\n\n    public static final String TYPE_STDIO = \"stdio\";\n    public static final String TYPE_SSE = \"sse\";\n    public static final String TYPE_STREAMABLE_HTTP = \"streamable_http\";\n\n    private McpTypeSupport() {\n    }\n\n    public static String resolveType(String type, String legacyTransport) {\n        return normalizeType(type != null ? type : legacyTransport);\n    }\n\n    public static String resolveType(McpServerConfig.McpServerInfo serverInfo) {\n        if (serverInfo == null) {\n            return TYPE_STDIO;\n        }\n        return resolveType(serverInfo.getType(), serverInfo.getTransport());\n    }\n\n    public static String resolveType(McpServerReference serverReference) {\n        if (serverReference == null) {\n            return TYPE_STDIO;\n        }\n        return resolveType(serverReference.getType(), serverReference.getTransport());\n    }\n\n    public static String normalizeType(String value) {\n        if (value == null || value.trim().isEmpty()) {\n            return TYPE_STDIO;\n        }\n\n        String normalizedValue = value.trim().toLowerCase();\n        if (TYPE_STDIO.equals(normalizedValue)\n                || \"process\".equals(normalizedValue)\n                || \"local\".equals(normalizedValue)) {\n            return TYPE_STDIO;\n        }\n        if (TYPE_SSE.equals(normalizedValue)\n                || \"server-sent-events\".equals(normalizedValue)\n                || \"event-stream\".equals(normalizedValue)) {\n            return TYPE_SSE;\n        }\n        if (TYPE_STREAMABLE_HTTP.equals(normalizedValue)\n                || \"http\".equals(normalizedValue)\n                || \"mcp\".equals(normalizedValue)\n                || \"streamable-http\".equals(normalizedValue)\n                || \"http-streamable\".equals(normalizedValue)) {\n            return TYPE_STREAMABLE_HTTP;\n        }\n\n        return TYPE_STDIO;\n    }\n\n    public static boolean isKnownType(String value) {\n        if (value == null || value.trim().isEmpty()) {\n            return true;\n        }\n\n        String normalizedValue = value.trim().toLowerCase();\n        return TYPE_STDIO.equals(normalizedValue)\n                || \"process\".equals(normalizedValue)\n                || \"local\".equals(normalizedValue)\n                || TYPE_SSE.equals(normalizedValue)\n                || \"server-sent-events\".equals(normalizedValue)\n                || \"event-stream\".equals(normalizedValue)\n                || TYPE_STREAMABLE_HTTP.equals(normalizedValue)\n                || \"http\".equals(normalizedValue)\n                || \"mcp\".equals(normalizedValue)\n                || \"streamable-http\".equals(normalizedValue)\n                || \"http-streamable\".equals(normalizedValue);\n    }\n\n    public static boolean isStdio(String value) {\n        return TYPE_STDIO.equals(normalizeType(value));\n    }\n\n    public static boolean isSse(String value) {\n        return TYPE_SSE.equals(normalizeType(value));\n    }\n\n    public static boolean isStreamableHttp(String value) {\n        return TYPE_STREAMABLE_HTTP.equals(normalizeType(value));\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemory.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\n\nimport java.util.List;\n\npublic interface ChatMemory {\n\n    void addSystem(String text);\n\n    void addUser(String text);\n\n    void addUser(String text, String... imageUrls);\n\n    void addAssistant(String text);\n\n    void addAssistant(String text, List<ToolCall> toolCalls);\n\n    void addAssistantToolCalls(List<ToolCall> toolCalls);\n\n    void addToolOutput(String toolCallId, String output);\n\n    void add(ChatMemoryItem item);\n\n    void addAll(List<ChatMemoryItem> items);\n\n    List<ChatMemoryItem> getItems();\n\n    List<ChatMessage> toChatMessages();\n\n    List<Object> toResponsesInput();\n\n    ChatMemorySnapshot snapshot();\n\n    void restore(ChatMemorySnapshot snapshot);\n\n    void clear();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemoryItem.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Content;\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ChatMemoryItem {\n\n    private String role;\n\n    private String text;\n\n    private List<String> imageUrls;\n\n    private String toolCallId;\n\n    private List<ToolCall> toolCalls;\n\n    private boolean summary;\n\n    public static ChatMemoryItem system(String text) {\n        return ChatMemoryItem.builder()\n                .role(ChatMessageType.SYSTEM.getRole())\n                .text(text)\n                .build();\n    }\n\n    public static ChatMemoryItem user(String text) {\n        return ChatMemoryItem.builder()\n                .role(ChatMessageType.USER.getRole())\n                .text(text)\n                .build();\n    }\n\n    public static ChatMemoryItem user(String text, String... imageUrls) {\n        List<String> urls = new ArrayList<String>();\n        if (imageUrls != null) {\n            for (String imageUrl : imageUrls) {\n                if (hasText(imageUrl)) {\n                    urls.add(imageUrl);\n                }\n            }\n        }\n        return ChatMemoryItem.builder()\n                .role(ChatMessageType.USER.getRole())\n                .text(text)\n                .imageUrls(urls)\n                .build();\n    }\n\n    public static ChatMemoryItem assistant(String text) {\n        return ChatMemoryItem.builder()\n                .role(ChatMessageType.ASSISTANT.getRole())\n                .text(text)\n                .build();\n    }\n\n    public static ChatMemoryItem assistant(String text, List<ToolCall> toolCalls) {\n        return ChatMemoryItem.builder()\n                .role(ChatMessageType.ASSISTANT.getRole())\n                .text(text)\n                .toolCalls(copyToolCalls(toolCalls))\n                .build();\n    }\n\n    public static ChatMemoryItem assistantToolCalls(List<ToolCall> toolCalls) {\n        return ChatMemoryItem.builder()\n                .role(ChatMessageType.ASSISTANT.getRole())\n                .toolCalls(copyToolCalls(toolCalls))\n                .build();\n    }\n\n    public static ChatMemoryItem tool(String toolCallId, String output) {\n        return ChatMemoryItem.builder()\n                .role(ChatMessageType.TOOL.getRole())\n                .toolCallId(toolCallId)\n                .text(output)\n                .build();\n    }\n\n    public static ChatMemoryItem summary(String role, String text) {\n        return ChatMemoryItem.builder()\n                .role(role)\n                .text(text)\n                .summary(true)\n                .build();\n    }\n\n    public ChatMessage toChatMessage() {\n        if (ChatMessageType.USER.getRole().equals(role) && imageUrls != null && !imageUrls.isEmpty()) {\n            return ChatMessage.builder()\n                    .role(ChatMessageType.USER.getRole())\n                    .content(Content.ofMultiModals(toMultiModalContent(text, imageUrls)))\n                    .build();\n        }\n        if (ChatMessageType.SYSTEM.getRole().equals(role)) {\n            return ChatMessage.withSystem(text);\n        }\n        if (ChatMessageType.USER.getRole().equals(role)) {\n            return ChatMessage.withUser(text);\n        }\n        if (ChatMessageType.ASSISTANT.getRole().equals(role)) {\n            List<ToolCall> copiedToolCalls = copyToolCalls(toolCalls);\n            if (copiedToolCalls != null && !copiedToolCalls.isEmpty()) {\n                if (hasText(text)) {\n                    return ChatMessage.withAssistant(text, copiedToolCalls);\n                }\n                return ChatMessage.withAssistant(copiedToolCalls);\n            }\n            return ChatMessage.withAssistant(text);\n        }\n        if (ChatMessageType.TOOL.getRole().equals(role)) {\n            return ChatMessage.withTool(text, toolCallId);\n        }\n        return new ChatMessage(role, text);\n    }\n\n    public Object toResponsesInput() {\n        if (ChatMessageType.TOOL.getRole().equals(role)) {\n            Map<String, Object> item = new LinkedHashMap<String, Object>();\n            item.put(\"type\", \"function_call_output\");\n            item.put(\"call_id\", toolCallId);\n            item.put(\"output\", text);\n            return item;\n        }\n\n        Map<String, Object> item = new LinkedHashMap<String, Object>();\n        item.put(\"type\", \"message\");\n        item.put(\"role\", role);\n\n        List<Map<String, Object>> content = new ArrayList<Map<String, Object>>();\n        if (hasText(text)) {\n            content.add(inputText(text));\n        }\n        if (imageUrls != null) {\n            for (String imageUrl : imageUrls) {\n                if (hasText(imageUrl)) {\n                    content.add(inputImage(imageUrl));\n                }\n            }\n        }\n        if (!content.isEmpty()) {\n            item.put(\"content\", content);\n        }\n\n        List<Map<String, Object>> serializedToolCalls = serializeToolCalls(toolCalls);\n        if (!serializedToolCalls.isEmpty()) {\n            item.put(\"tool_calls\", serializedToolCalls);\n        }\n        return item;\n    }\n\n    public boolean isEmpty() {\n        boolean hasText = hasText(text);\n        boolean hasImages = imageUrls != null && !imageUrls.isEmpty();\n        boolean hasToolCalls = toolCalls != null && !toolCalls.isEmpty();\n        boolean isToolOutput = ChatMessageType.TOOL.getRole().equals(role) && hasText(toolCallId);\n        return !hasText && !hasImages && !hasToolCalls && !isToolOutput;\n    }\n\n    public static ChatMemoryItem copyOf(ChatMemoryItem source) {\n        if (source == null) {\n            return null;\n        }\n        return ChatMemoryItem.builder()\n                .role(source.getRole())\n                .text(source.getText())\n                .imageUrls(copyStrings(source.getImageUrls()))\n                .toolCallId(source.getToolCallId())\n                .toolCalls(copyToolCalls(source.getToolCalls()))\n                .summary(source.isSummary())\n                .build();\n    }\n\n    private static List<Content.MultiModal> toMultiModalContent(String text, List<String> imageUrls) {\n        List<Content.MultiModal> parts = new ArrayList<Content.MultiModal>();\n        if (hasText(text)) {\n            parts.add(new Content.MultiModal(Content.MultiModal.Type.TEXT.getType(), text, null));\n        }\n        if (imageUrls != null) {\n            for (String imageUrl : imageUrls) {\n                if (hasText(imageUrl)) {\n                    parts.add(new Content.MultiModal(\n                            Content.MultiModal.Type.IMAGE_URL.getType(),\n                            null,\n                            new Content.MultiModal.ImageUrl(imageUrl)\n                    ));\n                }\n            }\n        }\n        return parts;\n    }\n\n    private static Map<String, Object> inputText(String text) {\n        Map<String, Object> part = new LinkedHashMap<String, Object>();\n        part.put(\"type\", \"input_text\");\n        part.put(\"text\", text);\n        return part;\n    }\n\n    private static Map<String, Object> inputImage(String imageUrl) {\n        Map<String, Object> part = new LinkedHashMap<String, Object>();\n        part.put(\"type\", \"input_image\");\n        Map<String, Object> image = new LinkedHashMap<String, Object>();\n        image.put(\"url\", imageUrl);\n        part.put(\"image_url\", image);\n        return part;\n    }\n\n    private static List<Map<String, Object>> serializeToolCalls(List<ToolCall> toolCalls) {\n        List<Map<String, Object>> serialized = new ArrayList<Map<String, Object>>();\n        if (toolCalls == null) {\n            return serialized;\n        }\n        for (ToolCall toolCall : toolCalls) {\n            if (toolCall == null) {\n                continue;\n            }\n            Map<String, Object> item = new LinkedHashMap<String, Object>();\n            item.put(\"id\", toolCall.getId());\n            item.put(\"type\", hasText(toolCall.getType()) ? toolCall.getType() : \"function\");\n\n            if (toolCall.getFunction() != null) {\n                Map<String, Object> function = new LinkedHashMap<String, Object>();\n                function.put(\"name\", toolCall.getFunction().getName());\n                function.put(\"arguments\", toolCall.getFunction().getArguments());\n                item.put(\"function\", function);\n            }\n            serialized.add(item);\n        }\n        return serialized;\n    }\n\n    private static List<ToolCall> copyToolCalls(List<ToolCall> source) {\n        if (source == null) {\n            return null;\n        }\n        List<ToolCall> copied = new ArrayList<ToolCall>(source.size());\n        for (ToolCall toolCall : source) {\n            if (toolCall == null) {\n                copied.add(null);\n                continue;\n            }\n            ToolCall.Function copiedFunction = null;\n            if (toolCall.getFunction() != null) {\n                copiedFunction = new ToolCall.Function(\n                        toolCall.getFunction().getName(),\n                        toolCall.getFunction().getArguments()\n                );\n            }\n            copied.add(new ToolCall(toolCall.getId(), toolCall.getType(), copiedFunction));\n        }\n        return copied;\n    }\n\n    private static List<String> copyStrings(List<String> source) {\n        return source == null ? null : new ArrayList<String>(source);\n    }\n\n    private static boolean hasText(String value) {\n        return value != null && !value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemoryPolicy.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport java.util.List;\n\npublic interface ChatMemoryPolicy {\n\n    List<ChatMemoryItem> apply(List<ChatMemoryItem> items);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemorySnapshot.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ChatMemorySnapshot {\n\n    private List<ChatMemoryItem> items;\n\n    public static ChatMemorySnapshot from(List<ChatMemoryItem> items) {\n        List<ChatMemoryItem> copied = new ArrayList<ChatMemoryItem>();\n        if (items != null) {\n            for (ChatMemoryItem item : items) {\n                copied.add(ChatMemoryItem.copyOf(item));\n            }\n        }\n        return ChatMemorySnapshot.builder()\n                .items(copied)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemorySummarizer.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\npublic interface ChatMemorySummarizer {\n\n    String summarize(ChatMemorySummaryRequest request);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/ChatMemorySummaryRequest.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ChatMemorySummaryRequest {\n\n    private String existingSummary;\n\n    private List<ChatMemoryItem> itemsToSummarize;\n\n    public static ChatMemorySummaryRequest from(String existingSummary, List<ChatMemoryItem> itemsToSummarize) {\n        List<ChatMemoryItem> copied = new ArrayList<ChatMemoryItem>();\n        if (itemsToSummarize != null) {\n            for (ChatMemoryItem item : itemsToSummarize) {\n                if (item != null) {\n                    copied.add(ChatMemoryItem.copyOf(item));\n                }\n            }\n        }\n        return ChatMemorySummaryRequest.builder()\n                .existingSummary(existingSummary)\n                .itemsToSummarize(copied)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/InMemoryChatMemory.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class InMemoryChatMemory implements ChatMemory {\n\n    private final List<ChatMemoryItem> items = new ArrayList<ChatMemoryItem>();\n\n    private ChatMemoryPolicy policy;\n\n    public InMemoryChatMemory() {\n        this(new UnboundedChatMemoryPolicy());\n    }\n\n    public InMemoryChatMemory(ChatMemoryPolicy policy) {\n        this.policy = policy == null ? new UnboundedChatMemoryPolicy() : policy;\n    }\n\n    public void setPolicy(ChatMemoryPolicy policy) {\n        this.policy = policy == null ? new UnboundedChatMemoryPolicy() : policy;\n        applyPolicy();\n    }\n\n    @Override\n    public void addSystem(String text) {\n        add(ChatMemoryItem.system(text));\n    }\n\n    @Override\n    public void addUser(String text) {\n        add(ChatMemoryItem.user(text));\n    }\n\n    @Override\n    public void addUser(String text, String... imageUrls) {\n        add(ChatMemoryItem.user(text, imageUrls));\n    }\n\n    @Override\n    public void addAssistant(String text) {\n        add(ChatMemoryItem.assistant(text));\n    }\n\n    @Override\n    public void addAssistant(String text, List<ToolCall> toolCalls) {\n        add(ChatMemoryItem.assistant(text, toolCalls));\n    }\n\n    @Override\n    public void addAssistantToolCalls(List<ToolCall> toolCalls) {\n        add(ChatMemoryItem.assistantToolCalls(toolCalls));\n    }\n\n    @Override\n    public void addToolOutput(String toolCallId, String output) {\n        add(ChatMemoryItem.tool(toolCallId, output));\n    }\n\n    @Override\n    public void add(ChatMemoryItem item) {\n        if (item == null || item.isEmpty()) {\n            return;\n        }\n        items.add(ChatMemoryItem.copyOf(item));\n        applyPolicy();\n    }\n\n    @Override\n    public void addAll(List<ChatMemoryItem> items) {\n        if (items == null || items.isEmpty()) {\n            return;\n        }\n        for (ChatMemoryItem item : items) {\n            add(item);\n        }\n    }\n\n    @Override\n    public List<ChatMemoryItem> getItems() {\n        List<ChatMemoryItem> copied = new ArrayList<ChatMemoryItem>(items.size());\n        for (ChatMemoryItem item : items) {\n            copied.add(ChatMemoryItem.copyOf(item));\n        }\n        return copied;\n    }\n\n    @Override\n    public List<ChatMessage> toChatMessages() {\n        List<ChatMessage> messages = new ArrayList<ChatMessage>(items.size());\n        for (ChatMemoryItem item : items) {\n            messages.add(item.toChatMessage());\n        }\n        return messages;\n    }\n\n    @Override\n    public List<Object> toResponsesInput() {\n        List<Object> input = new ArrayList<Object>(items.size());\n        for (ChatMemoryItem item : items) {\n            input.add(item.toResponsesInput());\n        }\n        return input;\n    }\n\n    @Override\n    public ChatMemorySnapshot snapshot() {\n        return ChatMemorySnapshot.from(items);\n    }\n\n    @Override\n    public void restore(ChatMemorySnapshot snapshot) {\n        items.clear();\n        if (snapshot != null && snapshot.getItems() != null) {\n            for (ChatMemoryItem item : snapshot.getItems()) {\n                if (item != null && !item.isEmpty()) {\n                    items.add(ChatMemoryItem.copyOf(item));\n                }\n            }\n        }\n        applyPolicy();\n    }\n\n    @Override\n    public void clear() {\n        items.clear();\n    }\n\n    private void applyPolicy() {\n        List<ChatMemoryItem> applied = policy == null\n                ? new UnboundedChatMemoryPolicy().apply(items)\n                : policy.apply(items);\n        items.clear();\n        if (applied != null) {\n            items.addAll(applied);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/JdbcChatMemory.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\n\nimport javax.sql.DataSource;\nimport java.sql.Connection;\nimport java.sql.DriverManager;\nimport java.sql.PreparedStatement;\nimport java.sql.ResultSet;\nimport java.sql.Statement;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class JdbcChatMemory implements ChatMemory {\n\n    private final DataSource dataSource;\n    private final String jdbcUrl;\n    private final String username;\n    private final String password;\n    private final String sessionId;\n    private final String tableName;\n\n    private ChatMemoryPolicy policy;\n\n    public JdbcChatMemory(JdbcChatMemoryConfig config) {\n        if (config == null) {\n            throw new IllegalArgumentException(\"config is required\");\n        }\n        this.dataSource = config.getDataSource();\n        this.jdbcUrl = trimToNull(config.getJdbcUrl());\n        this.username = trimToNull(config.getUsername());\n        this.password = config.getPassword();\n        this.sessionId = requiredText(config.getSessionId(), \"sessionId\");\n        this.tableName = validIdentifier(config.getTableName());\n        this.policy = config.getPolicy() == null ? new UnboundedChatMemoryPolicy() : config.getPolicy();\n        if (this.dataSource == null && this.jdbcUrl == null) {\n            throw new IllegalArgumentException(\"dataSource or jdbcUrl is required\");\n        }\n        if (config.isInitializeSchema()) {\n            initializeSchema();\n        }\n    }\n\n    public JdbcChatMemory(String jdbcUrl, String sessionId) {\n        this(JdbcChatMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .sessionId(sessionId)\n                .build());\n    }\n\n    public JdbcChatMemory(String jdbcUrl, String username, String password, String sessionId) {\n        this(JdbcChatMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .username(username)\n                .password(password)\n                .sessionId(sessionId)\n                .build());\n    }\n\n    public JdbcChatMemory(DataSource dataSource, String sessionId) {\n        this(JdbcChatMemoryConfig.builder()\n                .dataSource(dataSource)\n                .sessionId(sessionId)\n                .build());\n    }\n\n    public void setPolicy(ChatMemoryPolicy policy) {\n        this.policy = policy == null ? new UnboundedChatMemoryPolicy() : policy;\n        synchronized (this) {\n            replaceItems(applyPolicy(loadItems()));\n        }\n    }\n\n    @Override\n    public void addSystem(String text) {\n        add(ChatMemoryItem.system(text));\n    }\n\n    @Override\n    public void addUser(String text) {\n        add(ChatMemoryItem.user(text));\n    }\n\n    @Override\n    public void addUser(String text, String... imageUrls) {\n        add(ChatMemoryItem.user(text, imageUrls));\n    }\n\n    @Override\n    public void addAssistant(String text) {\n        add(ChatMemoryItem.assistant(text));\n    }\n\n    @Override\n    public void addAssistant(String text, List<ToolCall> toolCalls) {\n        add(ChatMemoryItem.assistant(text, toolCalls));\n    }\n\n    @Override\n    public void addAssistantToolCalls(List<ToolCall> toolCalls) {\n        add(ChatMemoryItem.assistantToolCalls(toolCalls));\n    }\n\n    @Override\n    public void addToolOutput(String toolCallId, String output) {\n        add(ChatMemoryItem.tool(toolCallId, output));\n    }\n\n    @Override\n    public synchronized void add(ChatMemoryItem item) {\n        if (item == null || item.isEmpty()) {\n            return;\n        }\n        List<ChatMemoryItem> items = loadItems();\n        items.add(ChatMemoryItem.copyOf(item));\n        replaceItems(applyPolicy(items));\n    }\n\n    @Override\n    public synchronized void addAll(List<ChatMemoryItem> items) {\n        if (items == null || items.isEmpty()) {\n            return;\n        }\n        List<ChatMemoryItem> merged = loadItems();\n        for (ChatMemoryItem item : items) {\n            if (item != null && !item.isEmpty()) {\n                merged.add(ChatMemoryItem.copyOf(item));\n            }\n        }\n        replaceItems(applyPolicy(merged));\n    }\n\n    @Override\n    public synchronized List<ChatMemoryItem> getItems() {\n        return copyItems(loadItems());\n    }\n\n    @Override\n    public synchronized List<ChatMessage> toChatMessages() {\n        List<ChatMemoryItem> items = loadItems();\n        List<ChatMessage> messages = new ArrayList<ChatMessage>(items.size());\n        for (ChatMemoryItem item : items) {\n            messages.add(item.toChatMessage());\n        }\n        return messages;\n    }\n\n    @Override\n    public synchronized List<Object> toResponsesInput() {\n        List<ChatMemoryItem> items = loadItems();\n        List<Object> input = new ArrayList<Object>(items.size());\n        for (ChatMemoryItem item : items) {\n            input.add(item.toResponsesInput());\n        }\n        return input;\n    }\n\n    @Override\n    public synchronized ChatMemorySnapshot snapshot() {\n        return ChatMemorySnapshot.from(loadItems());\n    }\n\n    @Override\n    public synchronized void restore(ChatMemorySnapshot snapshot) {\n        List<ChatMemoryItem> items = new ArrayList<ChatMemoryItem>();\n        if (snapshot != null && snapshot.getItems() != null) {\n            for (ChatMemoryItem item : snapshot.getItems()) {\n                if (item != null && !item.isEmpty()) {\n                    items.add(ChatMemoryItem.copyOf(item));\n                }\n            }\n        }\n        replaceItems(applyPolicy(items));\n    }\n\n    @Override\n    public synchronized void clear() {\n        replaceItems(Collections.<ChatMemoryItem>emptyList());\n    }\n\n    private void initializeSchema() {\n        String sql = \"create table if not exists \" + tableName + \" (\" +\n                \"session_id varchar(191) not null, \" +\n                \"item_index integer not null, \" +\n                \"item_json text not null, \" +\n                \"created_at bigint not null, \" +\n                \"primary key (session_id, item_index)\" +\n                \")\";\n        try (Connection connection = openConnection();\n             Statement statement = connection.createStatement()) {\n            statement.executeUpdate(sql);\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to initialize chat memory schema\", e);\n        }\n    }\n\n    private List<ChatMemoryItem> loadItems() {\n        String sql = \"select item_json from \" + tableName + \" where session_id = ? order by item_index asc\";\n        try (Connection connection = openConnection();\n             PreparedStatement statement = connection.prepareStatement(sql)) {\n            statement.setString(1, sessionId);\n            try (ResultSet resultSet = statement.executeQuery()) {\n                List<ChatMemoryItem> items = new ArrayList<ChatMemoryItem>();\n                while (resultSet.next()) {\n                    ChatMemoryItem item = JSON.parseObject(resultSet.getString(\"item_json\"), ChatMemoryItem.class);\n                    if (item != null && !item.isEmpty()) {\n                        items.add(item);\n                    }\n                }\n                return items;\n            }\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to load chat memory\", e);\n        }\n    }\n\n    private void replaceItems(List<ChatMemoryItem> items) {\n        String deleteSql = \"delete from \" + tableName + \" where session_id = ?\";\n        String insertSql = \"insert into \" + tableName + \" (session_id, item_index, item_json, created_at) values (?, ?, ?, ?)\";\n        try (Connection connection = openConnection()) {\n            boolean autoCommit = connection.getAutoCommit();\n            connection.setAutoCommit(false);\n            try {\n                try (PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) {\n                    deleteStatement.setString(1, sessionId);\n                    deleteStatement.executeUpdate();\n                }\n                if (items != null && !items.isEmpty()) {\n                    try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) {\n                        long now = System.currentTimeMillis();\n                        for (int i = 0; i < items.size(); i++) {\n                            insertStatement.setString(1, sessionId);\n                            insertStatement.setInt(2, i);\n                            insertStatement.setString(3, JSON.toJSONString(items.get(i)));\n                            insertStatement.setLong(4, now);\n                            insertStatement.addBatch();\n                        }\n                        insertStatement.executeBatch();\n                    }\n                }\n                connection.commit();\n            } catch (Exception e) {\n                connection.rollback();\n                throw e;\n            } finally {\n                connection.setAutoCommit(autoCommit);\n            }\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to persist chat memory\", e);\n        }\n    }\n\n    private List<ChatMemoryItem> applyPolicy(List<ChatMemoryItem> items) {\n        List<ChatMemoryItem> copied = copyItems(items);\n        List<ChatMemoryItem> applied = (policy == null ? new UnboundedChatMemoryPolicy() : policy).apply(copied);\n        return copyItems(applied);\n    }\n\n    private List<ChatMemoryItem> copyItems(List<ChatMemoryItem> items) {\n        List<ChatMemoryItem> copied = new ArrayList<ChatMemoryItem>();\n        if (items == null) {\n            return copied;\n        }\n        for (ChatMemoryItem item : items) {\n            if (item != null) {\n                copied.add(ChatMemoryItem.copyOf(item));\n            }\n        }\n        return copied;\n    }\n\n    private Connection openConnection() throws Exception {\n        if (dataSource != null) {\n            return dataSource.getConnection();\n        }\n        if (username == null) {\n            return DriverManager.getConnection(jdbcUrl);\n        }\n        return DriverManager.getConnection(jdbcUrl, username, password);\n    }\n\n    private String validIdentifier(String value) {\n        String identifier = requiredText(value, \"tableName\");\n        if (!identifier.matches(\"[A-Za-z_][A-Za-z0-9_]*(\\\\.[A-Za-z_][A-Za-z0-9_]*)?\")) {\n            throw new IllegalArgumentException(\"Invalid sql identifier: \" + value);\n        }\n        return identifier;\n    }\n\n    private String requiredText(String value, String fieldName) {\n        String text = trimToNull(value);\n        if (text == null) {\n            throw new IllegalArgumentException(fieldName + \" is required\");\n        }\n        return text;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/JdbcChatMemoryConfig.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport javax.sql.DataSource;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class JdbcChatMemoryConfig {\n\n    private DataSource dataSource;\n\n    private String jdbcUrl;\n\n    private String username;\n\n    private String password;\n\n    private String sessionId;\n\n    @Builder.Default\n    private String tableName = \"ai4j_chat_memory\";\n\n    @Builder.Default\n    private boolean initializeSchema = true;\n\n    @Builder.Default\n    private ChatMemoryPolicy policy = new UnboundedChatMemoryPolicy();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/MessageWindowChatMemoryPolicy.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class MessageWindowChatMemoryPolicy implements ChatMemoryPolicy {\n\n    private final int maxMessages;\n\n    public MessageWindowChatMemoryPolicy(int maxMessages) {\n        if (maxMessages < 0) {\n            throw new IllegalArgumentException(\"maxMessages must be >= 0\");\n        }\n        this.maxMessages = maxMessages;\n    }\n\n    @Override\n    public List<ChatMemoryItem> apply(List<ChatMemoryItem> items) {\n        List<ChatMemoryItem> result = new ArrayList<ChatMemoryItem>();\n        if (items == null || items.isEmpty()) {\n            return result;\n        }\n\n        boolean[] keep = new boolean[items.size()];\n        int remaining = maxMessages;\n\n        for (int i = items.size() - 1; i >= 0; i--) {\n            ChatMemoryItem item = items.get(i);\n            if (item == null) {\n                continue;\n            }\n            if (ChatMessageType.SYSTEM.getRole().equals(item.getRole())) {\n                keep[i] = true;\n                continue;\n            }\n            if (remaining > 0) {\n                keep[i] = true;\n                remaining--;\n            }\n        }\n\n        for (int i = 0; i < items.size(); i++) {\n            if (keep[i]) {\n                result.add(ChatMemoryItem.copyOf(items.get(i)));\n            }\n        }\n        return result;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/SummaryChatMemoryPolicy.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\n\nimport java.util.ArrayList;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Set;\n\npublic class SummaryChatMemoryPolicy implements ChatMemoryPolicy {\n\n    private final ChatMemorySummarizer summarizer;\n    private final int maxRecentMessages;\n    private final int summaryTriggerMessages;\n    private final String summaryRole;\n    private final String summaryTextPrefix;\n\n    public SummaryChatMemoryPolicy(SummaryChatMemoryPolicyConfig config) {\n        if (config == null) {\n            throw new IllegalArgumentException(\"config is required\");\n        }\n        if (config.getSummarizer() == null) {\n            throw new IllegalArgumentException(\"summarizer is required\");\n        }\n        if (config.getMaxRecentMessages() < 0) {\n            throw new IllegalArgumentException(\"maxRecentMessages must be >= 0\");\n        }\n        if (config.getSummaryTriggerMessages() <= config.getMaxRecentMessages()) {\n            throw new IllegalArgumentException(\"summaryTriggerMessages must be > maxRecentMessages\");\n        }\n        String role = trimToNull(config.getSummaryRole());\n        if (!ChatMessageType.SYSTEM.getRole().equals(role) && !ChatMessageType.ASSISTANT.getRole().equals(role)) {\n            throw new IllegalArgumentException(\"summaryRole must be system or assistant\");\n        }\n        this.summarizer = config.getSummarizer();\n        this.maxRecentMessages = config.getMaxRecentMessages();\n        this.summaryTriggerMessages = config.getSummaryTriggerMessages();\n        this.summaryRole = role;\n        this.summaryTextPrefix = config.getSummaryTextPrefix();\n    }\n\n    public SummaryChatMemoryPolicy(ChatMemorySummarizer summarizer, int maxRecentMessages, int summaryTriggerMessages) {\n        this(SummaryChatMemoryPolicyConfig.builder()\n                .summarizer(summarizer)\n                .maxRecentMessages(maxRecentMessages)\n                .summaryTriggerMessages(summaryTriggerMessages)\n                .build());\n    }\n\n    @Override\n    public List<ChatMemoryItem> apply(List<ChatMemoryItem> items) {\n        List<ChatMemoryItem> copied = copyItems(items);\n        if (copied.isEmpty()) {\n            return copied;\n        }\n\n        List<Integer> summaryEligibleIndices = collectSummaryEligibleIndices(copied);\n        if (summaryEligibleIndices.size() <= summaryTriggerMessages) {\n            return copied;\n        }\n\n        int keepRecent = Math.min(maxRecentMessages, summaryEligibleIndices.size());\n        int summarizeCount = summaryEligibleIndices.size() - keepRecent;\n        if (summarizeCount <= 0) {\n            return copied;\n        }\n\n        String existingSummary = mergeExistingSummary(copied, summaryEligibleIndices, summarizeCount);\n        List<ChatMemoryItem> itemsToSummarize = collectItemsToSummarize(copied, summaryEligibleIndices, summarizeCount);\n        if (itemsToSummarize.isEmpty()) {\n            return copied;\n        }\n\n        String summaryText = summarizer.summarize(ChatMemorySummaryRequest.from(existingSummary, itemsToSummarize));\n        String renderedSummary = renderSummary(summaryText);\n        if (renderedSummary == null) {\n            return copied;\n        }\n\n        ChatMemoryItem summaryItem = ChatMemoryItem.summary(summaryRole, renderedSummary);\n        return rebuildWithSummary(copied, summaryEligibleIndices, summarizeCount, summaryItem);\n    }\n\n    private List<Integer> collectSummaryEligibleIndices(List<ChatMemoryItem> items) {\n        List<Integer> indices = new ArrayList<Integer>();\n        for (int i = 0; i < items.size(); i++) {\n            ChatMemoryItem item = items.get(i);\n            if (item == null) {\n                continue;\n            }\n            if (isPinnedSystemItem(item)) {\n                continue;\n            }\n            indices.add(i);\n        }\n        return indices;\n    }\n\n    private boolean isPinnedSystemItem(ChatMemoryItem item) {\n        return ChatMessageType.SYSTEM.getRole().equals(item.getRole()) && !item.isSummary();\n    }\n\n    private String mergeExistingSummary(List<ChatMemoryItem> items, List<Integer> eligibleIndices, int summarizeCount) {\n        StringBuilder merged = new StringBuilder();\n        for (int i = 0; i < summarizeCount; i++) {\n            ChatMemoryItem item = items.get(eligibleIndices.get(i));\n            if (item == null || !item.isSummary()) {\n                continue;\n            }\n            String text = stripSummaryPrefix(item.getText());\n            if (!hasText(text)) {\n                continue;\n            }\n            if (merged.length() > 0) {\n                merged.append(\"\\n\\n\");\n            }\n            merged.append(text);\n        }\n        return trimToNull(merged.toString());\n    }\n\n    private List<ChatMemoryItem> collectItemsToSummarize(List<ChatMemoryItem> items,\n                                                         List<Integer> eligibleIndices,\n                                                         int summarizeCount) {\n        List<ChatMemoryItem> collected = new ArrayList<ChatMemoryItem>();\n        for (int i = 0; i < summarizeCount; i++) {\n            ChatMemoryItem item = items.get(eligibleIndices.get(i));\n            if (item == null || item.isSummary()) {\n                continue;\n            }\n            collected.add(ChatMemoryItem.copyOf(item));\n        }\n        return collected;\n    }\n\n    private String stripSummaryPrefix(String text) {\n        String value = trimToNull(text);\n        if (value == null) {\n            return null;\n        }\n        if (hasText(summaryTextPrefix) && value.startsWith(summaryTextPrefix)) {\n            return trimToNull(value.substring(summaryTextPrefix.length()));\n        }\n        return value;\n    }\n\n    private String renderSummary(String summaryText) {\n        String value = trimToNull(summaryText);\n        if (value == null) {\n            return null;\n        }\n        if (hasText(summaryTextPrefix) && value.startsWith(summaryTextPrefix)) {\n            return value;\n        }\n        return hasText(summaryTextPrefix) ? summaryTextPrefix + value : value;\n    }\n\n    private List<ChatMemoryItem> rebuildWithSummary(List<ChatMemoryItem> items,\n                                                    List<Integer> eligibleIndices,\n                                                    int summarizeCount,\n                                                    ChatMemoryItem summaryItem) {\n        Set<Integer> summarizedIndices = new HashSet<Integer>();\n        for (int i = 0; i < summarizeCount; i++) {\n            summarizedIndices.add(eligibleIndices.get(i));\n        }\n\n        List<ChatMemoryItem> rebuilt = new ArrayList<ChatMemoryItem>();\n        int insertionIndex = eligibleIndices.get(0);\n        for (int i = 0; i < items.size(); i++) {\n            if (i == insertionIndex) {\n                rebuilt.add(ChatMemoryItem.copyOf(summaryItem));\n            }\n            if (summarizedIndices.contains(i)) {\n                continue;\n            }\n            ChatMemoryItem item = items.get(i);\n            if (item != null) {\n                rebuilt.add(ChatMemoryItem.copyOf(item));\n            }\n        }\n        return rebuilt;\n    }\n\n    private List<ChatMemoryItem> copyItems(List<ChatMemoryItem> items) {\n        List<ChatMemoryItem> copied = new ArrayList<ChatMemoryItem>();\n        if (items == null) {\n            return copied;\n        }\n        for (ChatMemoryItem item : items) {\n            if (item != null) {\n                copied.add(ChatMemoryItem.copyOf(item));\n            }\n        }\n        return copied;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private boolean hasText(String value) {\n        return trimToNull(value) != null;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/SummaryChatMemoryPolicyConfig.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class SummaryChatMemoryPolicyConfig {\n\n    private ChatMemorySummarizer summarizer;\n\n    @Builder.Default\n    private int maxRecentMessages = 12;\n\n    @Builder.Default\n    private int summaryTriggerMessages = 20;\n\n    @Builder.Default\n    private String summaryRole = ChatMessageType.ASSISTANT.getRole();\n\n    @Builder.Default\n    private String summaryTextPrefix = \"Summary of earlier conversation:\\n\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/memory/UnboundedChatMemoryPolicy.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class UnboundedChatMemoryPolicy implements ChatMemoryPolicy {\n\n    @Override\n    public List<ChatMemoryItem> apply(List<ChatMemoryItem> items) {\n        List<ChatMemoryItem> copied = new ArrayList<ChatMemoryItem>();\n        if (items == null) {\n            return copied;\n        }\n        for (ChatMemoryItem item : items) {\n            copied.add(ChatMemoryItem.copyOf(item));\n        }\n        return copied;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/network/ConnectionPoolProvider.java",
    "content": "package io.github.lnyocly.ai4j.network;\n\nimport okhttp3.ConnectionPool;\n\n/**\n * @Author cly\n * @Description ConnectionPool提供器\n * @Date 2024/10/16 23:10\n */\npublic interface ConnectionPoolProvider {\n    ConnectionPool getConnectionPool();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/network/DispatcherProvider.java",
    "content": "package io.github.lnyocly.ai4j.network;\n\nimport okhttp3.Dispatcher;\n\n/**\n * @Author cly\n * @Description Dispatcher提供器\n * @Date 2024/10/16 23:09\n */\npublic interface DispatcherProvider {\n    Dispatcher getDispatcher();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/network/OkHttpUtil.java",
    "content": "package io.github.lnyocly.ai4j.network;\n\nimport javax.net.ssl.*;\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.SecureRandom;\nimport java.security.cert.X509Certificate;\n\n/**\n *\n * @author Vania\n *\n */\npublic class OkHttpUtil {\n    /**\n     * X509TrustManager instance which ignored SSL certification\n     */\n    public static final X509TrustManager IGNORE_SSL_TRUST_MANAGER_X509 = new X509TrustManager() {\n        @Override\n        public void checkClientTrusted(X509Certificate[] chain, String authType) {\n        }\n\n        @Override\n        public void checkServerTrusted(X509Certificate[] chain, String authType) {\n        }\n\n        @Override\n        public X509Certificate[] getAcceptedIssuers() {\n            return new X509Certificate[] {};\n        }\n    };\n\n    /**\n     * Get initialized SSLContext instance which ignored SSL certification\n     *\n     * @return\n     * @throws NoSuchAlgorithmException\n     * @throws KeyManagementException\n     */\n    public static SSLContext getIgnoreInitedSslContext() throws NoSuchAlgorithmException, KeyManagementException {\n        SSLContext sslContext = SSLContext.getInstance(\"SSL\");\n        sslContext.init(null, new TrustManager[] { IGNORE_SSL_TRUST_MANAGER_X509 }, new SecureRandom());\n        return sslContext;\n    }\n\n    /**\n     * Get HostnameVerifier which ignored SSL certification\n     *\n     * @return\n     */\n    public static HostnameVerifier getIgnoreSslHostnameVerifier() {\n        return new HostnameVerifier() {\n            @Override\n            public boolean verify(String arg0, SSLSession arg1) {\n                return true;\n            }\n        };\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/network/UrlUtils.java",
    "content": "package io.github.lnyocly.ai4j.network;\n\n/**\n * @Author cly\n * @Description 用于验证、处理\n * @Date 2024/9/19 14:40\n */\npublic final class UrlUtils {\n\n    private UrlUtils() {\n    }\n\n    public static String concatUrl(String... params){\n        if(params.length == 0) {\n            throw new IllegalArgumentException(\"url params is empty\");\n        }\n\n        // 拼接字符串\n        StringBuilder sb = new StringBuilder();\n        for (int i = 0; i < params.length; i++) {\n            if (params[i].startsWith(\"/\")) {\n                params[i] = params[i].substring(1);\n            }\n            if(params[i].startsWith(\"?\") || params[i].startsWith(\"&\")){\n                // 如果sb的末尾是“/”，则删除末尾\n                if(sb.length() > 0 && sb.charAt(sb.length()-1) == '/') {\n                    sb.deleteCharAt(sb.length() - 1);\n                }\n\n            }\n            sb.append(params[i]);\n            if(!params[i].endsWith(\"/\")){\n                sb.append('/');\n            }\n        }\n\n        // 去掉最后一个/\n        if(sb.length() > 0 && sb.charAt(sb.length()-1) == '/'){\n            sb.deleteCharAt(sb.length()-1);\n        }\n        return sb.toString();\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/network/impl/DefaultConnectionPoolProvider.java",
    "content": "package io.github.lnyocly.ai4j.network.impl;\n\nimport io.github.lnyocly.ai4j.network.ConnectionPoolProvider;\nimport okhttp3.ConnectionPool;\n\n/**\n * @Author cly\n * @Description ConnectionPool默认实现\n * @Date 2024/10/16 23:11\n */\npublic class DefaultConnectionPoolProvider implements ConnectionPoolProvider {\n    @Override\n    public ConnectionPool getConnectionPool() {\n        return new ConnectionPool();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/network/impl/DefaultDispatcherProvider.java",
    "content": "package io.github.lnyocly.ai4j.network.impl;\n\nimport io.github.lnyocly.ai4j.network.DispatcherProvider;\nimport okhttp3.Dispatcher;\n\n/**\n * @Author cly\n * @Description Dispatcher默认实现\n * @Date 2024/10/16 23:11\n */\npublic class DefaultDispatcherProvider implements DispatcherProvider {\n    @Override\n    public Dispatcher getDispatcher() {\n        return new Dispatcher();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/baichuan/chat/BaichuanChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.baichuan.chat;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.BaichuanConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.baichuan.chat.entity.BaichuanChatCompletion;\nimport io.github.lnyocly.ai4j.platform.baichuan.chat.entity.BaichuanChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 百川chat服务\n * @Date 2024/8/27 17:29\n */\npublic class BaichuanChatService implements IChatService, ParameterConvert<BaichuanChatCompletion>, ResultConvert<BaichuanChatCompletionResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n    private static final String TOOL_CALLS_FINISH_REASON = \"tool_calls\";\n    private static final String FIRST_FINISH_REASON = \"first\";\n\n    private final BaichuanConfig baichuanConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n    private final ObjectMapper objectMapper;\n\n    public BaichuanChatService(Configuration configuration) {\n        this.baichuanConfig = configuration.getBaichuanConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    public BaichuanChatService(Configuration configuration, BaichuanConfig baichuanConfig) {\n        this.baichuanConfig = baichuanConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n            prepareChatCompletion(chatCompletion, false);\n\n            BaichuanChatCompletion baichuanChatCompletion = convertChatCompletionObject(chatCompletion);\n            Usage allUsage = new Usage();\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                BaichuanChatCompletionResponse response = executeChatCompletionRequest(\n                        resolvedBaseUrl,\n                        resolvedApiKey,\n                        baichuanChatCompletion\n                );\n                if (response == null) {\n                    break;\n                }\n\n                Choice choice = response.getChoices().get(0);\n                finishReason = choice.getFinishReason();\n                mergeUsage(allUsage, response.getUsage());\n\n                if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) {\n                    if (passThroughToolCalls) {\n                        response.setUsage(allUsage);\n                        response.setObject(\"chat.completion\");\n                        restoreOriginalRequest(chatCompletion, baichuanChatCompletion);\n                        return this.convertChatCompletionResponse(response);\n                    }\n                    baichuanChatCompletion.setMessages(appendToolMessages(\n                            baichuanChatCompletion.getMessages(),\n                            choice.getMessage(),\n                            choice.getMessage().getToolCalls()\n                    ));\n                    continue;\n                }\n\n                response.setUsage(allUsage);\n                response.setObject(\"chat.completion\");\n                restoreOriginalRequest(chatCompletion, baichuanChatCompletion);\n                return this.convertChatCompletionResponse(response);\n            }\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, true);\n            BaichuanChatCompletion baichuanChatCompletion = convertChatCompletionObject(chatCompletion);\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, baichuanChatCompletion);\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n                if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) {\n                    continue;\n                }\n                if (passThroughToolCalls) {\n                    return;\n                }\n\n                baichuanChatCompletion.setMessages(appendStreamToolMessages(\n                        baichuanChatCompletion.getMessages(),\n                        toolCalls\n                ));\n                resetToolCallState(eventSourceListener);\n            }\n\n            restoreOriginalRequest(chatCompletion, baichuanChatCompletion);\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    @Override\n    public BaichuanChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        BaichuanChatCompletion baichuanChatCompletion = new BaichuanChatCompletion();\n        baichuanChatCompletion.setModel(chatCompletion.getModel());\n        baichuanChatCompletion.setMessages(chatCompletion.getMessages());\n        baichuanChatCompletion.setStream(chatCompletion.getStream());\n        baichuanChatCompletion.setTemperature(chatCompletion.getTemperature() / 2);\n        baichuanChatCompletion.setTopP(chatCompletion.getTopP());\n        baichuanChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion));\n        baichuanChatCompletion.setStop(chatCompletion.getStop());\n        baichuanChatCompletion.setTools(chatCompletion.getTools());\n        baichuanChatCompletion.setFunctions(chatCompletion.getFunctions());\n        baichuanChatCompletion.setToolChoice(chatCompletion.getToolChoice());\n        baichuanChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n        return baichuanChatCompletion;\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(final SseListener eventSourceListener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n                eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data));\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(BaichuanChatCompletionResponse baichuanChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(baichuanChatCompletionResponse.getId());\n        chatCompletionResponse.setCreated(baichuanChatCompletionResponse.getCreated());\n        chatCompletionResponse.setModel(baichuanChatCompletionResponse.getModel());\n        chatCompletionResponse.setChoices(baichuanChatCompletionResponse.getChoices());\n        chatCompletionResponse.setUsage(baichuanChatCompletionResponse.getUsage());\n        return chatCompletionResponse;\n    }\n\n    private String serializeStreamResponse(String data) {\n        try {\n            BaichuanChatCompletionResponse chatCompletionResponse =\n                    objectMapper.readValue(data, BaichuanChatCompletionResponse.class);\n            chatCompletionResponse.setObject(\"chat.completion.chunk\");\n            ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse);\n            return objectMapper.writeValueAsString(response);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"Baichuan Chat 对象JSON序列化出错\");\n        }\n    }\n\n    private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) {\n        chatCompletion.setStream(stream);\n        if (!stream) {\n            chatCompletion.setStreamOptions(null);\n        }\n        attachTools(chatCompletion);\n    }\n\n    private void attachTools(ChatCompletion chatCompletion) {\n        if (hasPendingTools(chatCompletion)) {\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if (tools == null) {\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) {\n            chatCompletion.setParallelToolCalls(null);\n        }\n    }\n\n    private boolean hasPendingTools(ChatCompletion chatCompletion) {\n        return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty())\n                || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty());\n    }\n\n    private boolean requiresFollowUp(String finishReason) {\n        return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason);\n    }\n\n    private BaichuanChatCompletionResponse executeChatCompletionRequest(\n            String baseUrl,\n            String apiKey,\n            BaichuanChatCompletion baichuanChatCompletion\n    ) throws Exception {\n        Request request = buildChatCompletionRequest(baseUrl, apiKey, baichuanChatCompletion);\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return objectMapper.readValue(response.body().string(), BaichuanChatCompletionResponse.class);\n            }\n        }\n        return null;\n    }\n\n    private Request buildChatCompletionRequest(String baseUrl, String apiKey, BaichuanChatCompletion baichuanChatCompletion)\n            throws JsonProcessingException {\n        String requestBody = objectMapper.writeValueAsString(baichuanChatCompletion);\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, baichuanConfig.getChatCompletionUrl()))\n                .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE))\n                .build();\n    }\n\n    private void mergeUsage(Usage target, Usage usage) {\n        if (usage == null) {\n            return;\n        }\n        target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens());\n        target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens());\n        target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens());\n    }\n\n    private List<ChatMessage> appendToolMessages(\n            List<ChatMessage> messages,\n            ChatMessage assistantMessage,\n            List<ToolCall> toolCalls\n    ) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(assistantMessage);\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private List<ChatMessage> appendStreamToolMessages(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(ChatMessage.withAssistant(toolCalls));\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private void appendToolResponses(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        for (ToolCall toolCall : toolCalls) {\n            String functionName = toolCall.getFunction().getName();\n            String arguments = toolCall.getFunction().getArguments();\n            String functionResponse = ToolUtil.invoke(functionName, arguments);\n            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n        }\n    }\n\n    private void resetToolCallState(SseListener eventSourceListener) {\n        eventSourceListener.setToolCalls(new ArrayList<ToolCall>());\n        eventSourceListener.setToolCall(null);\n    }\n\n    private void restoreOriginalRequest(ChatCompletion chatCompletion, BaichuanChatCompletion baichuanChatCompletion) {\n        chatCompletion.setMessages(baichuanChatCompletion.getMessages());\n        chatCompletion.setTools(baichuanChatCompletion.getTools());\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        return (baseUrl == null || \"\".equals(baseUrl)) ? baichuanConfig.getApiHost() : baseUrl;\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? baichuanConfig.getApiKey() : apiKey;\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    private Integer resolveMaxTokens(ChatCompletion chatCompletion) {\n        if (chatCompletion.getMaxCompletionTokens() != null) {\n            return chatCompletion.getMaxCompletionTokens();\n        }\n        return chatCompletion.getMaxTokens();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/baichuan/chat/entity/BaichuanChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.baichuan.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport lombok.*;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class BaichuanChatCompletion {\n\n    @NonNull\n    private String model;\n    @NonNull\n    private List<ChatMessage> messages;\n\n    @JsonProperty(\"request_id\")\n    private String requestId;\n\n    @Builder.Default\n    @JsonProperty(\"do_sample\")\n    private Boolean doSample = true;\n    @Builder.Default\n    private Boolean stream = false;\n    /**\n     * 采样温度，控制输出的随机性，必须为正数。值越大，会使输出更随机\n     * [0.0, 1.0]\n     */\n    @Builder.Default\n    private Float temperature = 0.95f;\n    /**\n     * 核取样\n     * [0.0, 1.0]\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 0.7f;\n\n    @JsonProperty(\"max_tokens\")\n    private Integer maxTokens;\n\n    private List<String> stop;\n\n\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    @JsonProperty(\"user_id\")\n    private String userId;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n    public static class ZhipuChatCompletionBuilder {\n        private List<String> functions;\n\n        public ZhipuChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public ZhipuChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            if (functions != null) {\n                this.functions.addAll(functions);\n            }\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/baichuan/chat/entity/BaichuanChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.baichuan.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class BaichuanChatCompletionResponse {\n    private String id;\n    private String object;\n    private Long created;\n    private String model;\n    private List<Choice> choices;\n    private Usage usage;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/dashscope/DashScopeChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.dashscope;\n\nimport cn.hutool.core.collection.CollUtil;\nimport cn.hutool.core.util.ObjUtil;\nimport com.alibaba.dashscope.aigc.generation.*;\nimport com.alibaba.dashscope.common.Message;\nimport com.alibaba.dashscope.common.ResponseFormat;\nimport com.alibaba.dashscope.common.ResultCallback;\nimport com.alibaba.dashscope.tools.*;\nimport com.alibaba.dashscope.utils.JsonUtils;\nimport com.alibaba.fastjson2.JSON;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.DashScopeConfig;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.dashscope.entity.DashScopeResult;\nimport io.github.lnyocly.ai4j.platform.dashscope.util.MessageUtil;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.*;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport lombok.SneakyThrows;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.Request;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @Author cly\n * @Description OpenAi 聊天服务\n * @Date 2024/8/2 23:16\n */\n@Slf4j\npublic class DashScopeChatService implements IChatService, ParameterConvert<GenerationParam>, ResultConvert<DashScopeResult> {\n\n    private final DashScopeConfig dashScopeConfig;\n\n    // 创建一个空的 EventSource 对象，用于占位\n    private final EventSource eventSource = new EventSource() {\n\n        @NotNull\n        @Override\n        public Request request() {\n            return null;\n        }\n\n        @Override\n        public void cancel() {\n\n        }\n    };\n\n    public DashScopeChatService(Configuration configuration) {\n        this.dashScopeConfig = configuration.getDashScopeConfig();\n    }\n\n    public DashScopeChatService(Configuration configuration, DashScopeConfig dashScopeConfig) {\n        this.dashScopeConfig = dashScopeConfig;\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            if (apiKey == null || \"\".equals(apiKey)) apiKey = dashScopeConfig.getApiKey();\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n            chatCompletion.setStream(false);\n            chatCompletion.setStreamOptions(null);\n\n            if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){\n                List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n                chatCompletion.setTools(tools);\n                if(tools == null){\n                    chatCompletion.setParallelToolCalls(null);\n                }\n            }\n            if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){\n\n            }else{\n                chatCompletion.setParallelToolCalls(null);\n            }\n\n            // 总token消耗\n            Usage allUsage = new Usage();\n            String finishReason = \"first\";\n\n            while (\"first\".equals(finishReason) || \"tool_calls\".equals(finishReason)) {\n\n                finishReason = null;\n\n                Generation gen = new Generation();\n                GenerationParam param = convertChatCompletionObject(chatCompletion);\n                param.setApiKey(apiKey);\n                GenerationResult result = gen.call(param);\n\n                DashScopeResult dashScopeResult = new DashScopeResult();\n                dashScopeResult.setGenerationResult(result);\n                dashScopeResult.setObject(\"chat.completion\");\n                dashScopeResult.setModel(chatCompletion.getModel());\n                dashScopeResult.setCreated(System.currentTimeMillis() / 1000);\n\n                GenerationOutput.Choice choice = result.getOutput().getChoices().get(0);\n                finishReason = choice.getFinishReason();\n\n                GenerationUsage usage = result.getUsage();\n                allUsage.setCompletionTokens(allUsage.getCompletionTokens() + usage.getOutputTokens());\n                allUsage.setTotalTokens(allUsage.getTotalTokens() + usage.getTotalTokens());\n                allUsage.setPromptTokens(allUsage.getPromptTokens() + usage.getInputTokens());\n\n                // 判断是否为函数调用返回\n                if (\"tool_calls\".equals(finishReason)) {\n                    if (passThroughToolCalls) {\n                        ChatCompletionResponse chatCompletionResponse = convertChatCompletionResponse(dashScopeResult);\n                        chatCompletionResponse.setUsage(allUsage);\n                        return chatCompletionResponse;\n                    }\n                    Message message = choice.getMessage();\n                    List<ToolCallBase> toolCalls = message.getToolCalls();\n\n                    List<ChatMessage> messages = new ArrayList<>(chatCompletion.getMessages());\n                    messages.add(MessageUtil.convert(message));\n\n                    // 添加 tool 消息\n                    for (ToolCallBase toolCall : toolCalls) {\n                        if (toolCall.getType().equals(\"function\")) {\n                            String functionName = ((ToolCallFunction)toolCall).getFunction().getName();\n                            String arguments = ((ToolCallFunction) toolCall).getFunction().getArguments();\n                            String functionResponse = ToolUtil.invoke(functionName, arguments);\n                            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n                        }\n                    }\n                    chatCompletion.setMessages(messages);\n\n                } else {\n                    ChatCompletionResponse chatCompletionResponse = convertChatCompletionResponse(dashScopeResult);\n                    // 其他情况直接返回\n                    chatCompletionResponse.setUsage(allUsage);\n\n                    return chatCompletionResponse;\n\n                }\n\n            }\n\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            if (apiKey == null || \"\".equals(apiKey)) apiKey = dashScopeConfig.getApiKey();\n            chatCompletion.setStream(true);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n            StreamOptions streamOptions = chatCompletion.getStreamOptions();\n            if (streamOptions == null) {\n                chatCompletion.setStreamOptions(new StreamOptions(true));\n            }\n\n            if ((chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty())) {\n                //List<Tool> tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions());\n                List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n\n\n                chatCompletion.setTools(tools);\n                if (tools == null) {\n                    chatCompletion.setParallelToolCalls(null);\n                }\n            }\n\n            if (chatCompletion.getTools() != null && !chatCompletion.getTools().isEmpty()) {\n\n            } else {\n                chatCompletion.setParallelToolCalls(null);\n            }\n\n            String finishReason = \"first\";\n\n            while (\"first\".equals(finishReason) || \"tool_calls\".equals(finishReason)) {\n\n                finishReason = null;\n                eventSourceListener.setFinishReason(null);\n                Generation gen = new Generation();\n                GenerationParam param = convertChatCompletionObject(chatCompletion);\n                param.setApiKey(apiKey);\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> gen.streamCall(param, new ResultCallback<GenerationResult>() {\n                            @SneakyThrows\n                            @Override\n                            public void onEvent(GenerationResult message) {\n                                log.info(\"{}\", JSON.toJSONString(message));\n                                DashScopeResult dashScopeResult = new DashScopeResult();\n                                dashScopeResult.setGenerationResult(message);\n                                dashScopeResult.setObject(\"chat.completion.chunk\");\n                                dashScopeResult.setCreated(System.currentTimeMillis() / 1000);\n                                dashScopeResult.setModel(chatCompletion.getModel());\n                                ObjectMapper objectMapper = new ObjectMapper();\n                                eventSourceListener.onEvent(eventSource, message.getRequestId(), null, objectMapper.writeValueAsString(convertChatCompletionResponse(dashScopeResult)));\n                            }\n\n                            @Override\n                            public void onComplete() {\n                                eventSourceListener.onClosed(eventSource);\n                            }\n\n                            @Override\n                            public void onError(Exception e) {\n                                eventSourceListener.onFailure(eventSource, e, null);\n                            }\n                        })\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n\n                // 需要调用函数\n                if (\"tool_calls\".equals(finishReason) && !toolCalls.isEmpty()) {\n                    if (passThroughToolCalls) {\n                        return;\n                    }\n                    // 创建tool响应消息\n                    ChatMessage responseMessage = ChatMessage.withAssistant(eventSourceListener.getToolCalls());\n\n                    List<ChatMessage> messages = new ArrayList<>(chatCompletion.getMessages());\n                    messages.add(responseMessage);\n\n                    // 封装tool结果消息\n                    for (ToolCall toolCall : toolCalls) {\n                        String functionName = toolCall.getFunction().getName();\n                        String arguments = toolCall.getFunction().getArguments();\n                        String functionResponse = ToolUtil.invoke(functionName, arguments);\n\n                        messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n                    }\n                    eventSourceListener.setToolCalls(new ArrayList<>());\n                    eventSourceListener.setToolCall(null);\n                    chatCompletion.setMessages(messages);\n                }\n\n            }\n\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    @Override\n    public GenerationParam convertChatCompletionObject(ChatCompletion chatCompletion) {\n        GenerationParam.GenerationParamBuilder<?, ?> builder = GenerationParam.builder();\n        builder.model(chatCompletion.getModel())\n                .messages(MessageUtil.convertToMessage(chatCompletion.getMessages()))\n                .resultFormat(GenerationParam.ResultFormat.MESSAGE)\n                .temperature(chatCompletion.getTemperature())\n                .topP(Double.valueOf(chatCompletion.getTopP()))\n                .maxTokens(chatCompletion.getMaxCompletionTokens())\n                .toolChoice(chatCompletion.getToolChoice())\n                .parameters(chatCompletion.getParameters());\n        if (ObjUtil.isNotNull(chatCompletion.getResponseFormat()) && String.valueOf(chatCompletion.getResponseFormat()).contains(\"json_object\")) {\n            builder.responseFormat(ResponseFormat.from(ResponseFormat.JSON_OBJECT));\n        }\n\n        if (CollUtil.isNotEmpty(chatCompletion.getTools())) {\n            List<ToolBase> toolBaseList = chatCompletion.getTools().stream().map(tool -> {\n                        if (\"function\".equals(tool.getType())) {\n                            Tool.Function function = tool.getFunction();\n                            return ToolFunction.builder().function(FunctionDefinition.builder().name(function.getName()).description(function.getDescription()).parameters(JsonUtils.parse(JsonUtils.toJson(function.getParameters()))).build()).build();\n                        }\n                        return null;\n                    }\n            ).filter(ObjUtil::isNotNull).collect(Collectors.toList());\n            builder.tools(toolBaseList);\n        }\n        return builder.build();\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(SseListener eventSourceListener) {\n        return null;\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(DashScopeResult dashScopeResult) {\n        GenerationResult generationResult = dashScopeResult.getGenerationResult();\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(generationResult.getRequestId());\n        chatCompletionResponse.setCreated(dashScopeResult.getCreated());\n        chatCompletionResponse.setObject(dashScopeResult.getObject());\n        GenerationOutput.Choice srcChoice = generationResult.getOutput().getChoices().get(0);\n\n        Choice choice = new Choice();\n        ChatMessage chatMessage = MessageUtil.convert(srcChoice.getMessage());\n        choice.setMessage(chatMessage);\n        choice.setDelta(chatMessage);\n        choice.setIndex(srcChoice.getIndex());\n        choice.setFinishReason(srcChoice.getFinishReason());\n        chatCompletionResponse.setChoices(CollUtil.newArrayList(choice));\n        GenerationUsage usage = generationResult.getUsage();\n        chatCompletionResponse.setUsage(new Usage(usage.getInputTokens(), usage.getOutputTokens(), usage.getTotalTokens()));\n        return chatCompletionResponse;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/dashscope/entity/DashScopeResult.java",
    "content": "package io.github.lnyocly.ai4j.platform.dashscope.entity;\n\nimport com.alibaba.dashscope.aigc.generation.GenerationResult;\nimport lombok.Data;\n\n@Data\npublic class DashScopeResult {\n\n    private String model;\n\n    /**\n     * 创建聊天完成时的 Unix 时间戳（以秒为单位）。\n     */\n    private Long created;\n    /**\n     * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk\n     */\n    private String object;\n\n    private GenerationResult generationResult;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/dashscope/response/DashScopeResponsesService.java",
    "content": "package io.github.lnyocly.ai4j.platform.dashscope.response;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.DashScopeConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.ResponseSseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions;\nimport io.github.lnyocly.ai4j.platform.openai.response.ResponseEventParser;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\nimport io.github.lnyocly.ai4j.tool.ResponseRequestToolResolver;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\n/**\n * @Author cly\n * @Description DashScope Responses API service\n * @Date 2026/2/1\n */\npublic class DashScopeResponsesService implements IResponsesService {\n\n    private final DashScopeConfig dashScopeConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n\n    public DashScopeResponsesService(Configuration configuration) {\n        this.dashScopeConfig = configuration.getDashScopeConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n    }\n\n    @Override\n    public Response create(String baseUrl, String apiKey, ResponseRequest request) throws Exception {\n        String url = resolveUrl(baseUrl, dashScopeConfig.getResponsesUrl());\n        String key = resolveApiKey(apiKey);\n        request.setStream(false);\n        request.setStreamOptions(null);\n        request = ResponseRequestToolResolver.resolve(request);\n\n        ObjectMapper mapper = new ObjectMapper();\n        String body = mapper.writeValueAsString(request);\n\n        Request httpRequest = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + key)\n                .url(url)\n                .post(RequestBody.create(body, MediaType.get(Constants.JSON_CONTENT_TYPE)))\n                .build();\n\n        try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return mapper.readValue(response.body().string(), Response.class);\n            }\n        }\n        throw new CommonException(\"DashScope Responses request failed\");\n    }\n\n    @Override\n    public Response create(ResponseRequest request) throws Exception {\n        return create(null, null, request);\n    }\n\n    @Override\n    public void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) throws Exception {\n        String url = resolveUrl(baseUrl, dashScopeConfig.getResponsesUrl());\n        String key = resolveApiKey(apiKey);\n        if (request.getStream() == null || !request.getStream()) {\n            request.setStream(true);\n        }\n        if (request.getStreamOptions() == null) {\n            request.setStreamOptions(new StreamOptions(true));\n        }\n        request = ResponseRequestToolResolver.resolve(request);\n\n        ObjectMapper mapper = new ObjectMapper();\n        String body = mapper.writeValueAsString(request);\n\n        Request httpRequest = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + key)\n                .url(url)\n                .post(RequestBody.create(body, MediaType.get(Constants.JSON_CONTENT_TYPE)))\n                .build();\n\n        StreamExecutionSupport.execute(\n                listener,\n                request.getStreamExecution(),\n                () -> factory.newEventSource(httpRequest, convertEventSource(mapper, listener))\n        );\n    }\n\n    @Override\n    public void createStream(ResponseRequest request, ResponseSseListener listener) throws Exception {\n        createStream(null, null, request, listener);\n    }\n\n    @Override\n    public Response retrieve(String baseUrl, String apiKey, String responseId) throws Exception {\n        String url = resolveUrl(baseUrl, dashScopeConfig.getResponsesUrl() + \"/\" + responseId);\n        String key = resolveApiKey(apiKey);\n        ObjectMapper mapper = new ObjectMapper();\n\n        Request httpRequest = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + key)\n                .url(url)\n                .get()\n                .build();\n\n        try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return mapper.readValue(response.body().string(), Response.class);\n            }\n        }\n        throw new CommonException(\"DashScope Responses retrieve failed\");\n    }\n\n    @Override\n    public Response retrieve(String responseId) throws Exception {\n        return retrieve(null, null, responseId);\n    }\n\n    @Override\n    public ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) throws Exception {\n        String url = resolveUrl(baseUrl, dashScopeConfig.getResponsesUrl() + \"/\" + responseId);\n        String key = resolveApiKey(apiKey);\n        ObjectMapper mapper = new ObjectMapper();\n\n        Request httpRequest = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + key)\n                .url(url)\n                .delete()\n                .build();\n\n        try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return mapper.readValue(response.body().string(), ResponseDeleteResponse.class);\n            }\n        }\n        throw new CommonException(\"DashScope Responses delete failed\");\n    }\n\n    @Override\n    public ResponseDeleteResponse delete(String responseId) throws Exception {\n        return delete(null, null, responseId);\n    }\n\n    private String resolveUrl(String baseUrl, String path) {\n        String host = (baseUrl == null || \"\".equals(baseUrl)) ? dashScopeConfig.getApiHost() : baseUrl;\n        return UrlUtils.concatUrl(host, path);\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? dashScopeConfig.getApiKey() : apiKey;\n    }\n\n    private EventSourceListener convertEventSource(ObjectMapper mapper, ResponseSseListener listener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull okhttp3.Response response) {\n                listener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable okhttp3.Response response) {\n                listener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    listener.complete();\n                    return;\n                }\n                try {\n                    ResponseStreamEvent event = ResponseEventParser.parse(mapper, data);\n                    listener.accept(event);\n                    if (isTerminalEvent(event.getType())) {\n                        listener.complete();\n                    }\n                } catch (Exception e) {\n                    listener.onError(e, null);\n                    listener.complete();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                listener.onClosed(eventSource);\n            }\n        };\n    }\n\n    private boolean isTerminalEvent(String type) {\n        if (type == null) {\n            return false;\n        }\n        return \"response.completed\".equals(type)\n                || \"response.failed\".equals(type)\n                || \"response.incomplete\".equals(type);\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/dashscope/util/MessageUtil.java",
    "content": "package io.github.lnyocly.ai4j.platform.dashscope.util;\n\nimport cn.hutool.core.bean.BeanUtil;\nimport cn.hutool.core.collection.CollUtil;\nimport cn.hutool.core.util.ObjUtil;\nimport cn.hutool.core.util.StrUtil;\nimport com.alibaba.dashscope.common.*;\nimport com.alibaba.dashscope.tools.ToolCallFunction;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Content;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class MessageUtil {\n\n    public static ChatMessage convert(Message message) {\n        ChatMessage chatMessage = BeanUtil.copyProperties(message, ChatMessage.class, \"content\", \"toolCalls\");\n        if (StrUtil.isNotBlank(message.getContent())) {\n            chatMessage.setContent(Content.ofText(message.getContent()));\n        }\n        if (CollUtil.isNotEmpty(message.getContents())) {\n            List<Content.MultiModal> list = message.getContents().stream().map(content -> {\n                if (\"image_url\".equals(content.getType())) {\n                    return Content.MultiModal.builder().type(Content.MultiModal.Type.IMAGE_URL.getType()).imageUrl(new Content.MultiModal.ImageUrl(((MessageContentImageURL) content).getImageURL().getUrl())).build();\n                } else {\n                    return Content.MultiModal.builder().type(Content.MultiModal.Type.TEXT.getType()).text(((MessageContentText) content).getText()).build();\n                }\n            }).collect(Collectors.toList());\n            chatMessage.setContent(Content.ofMultiModals(list));\n        }\n        if (CollUtil.isNotEmpty(message.getToolCalls())) {\n            chatMessage.setToolCalls(message.getToolCalls().stream().map(toolCall -> {\n                if (\"function\".equals(toolCall.getType())) {\n                    ToolCallFunction.CallFunction src = ((ToolCallFunction) toolCall).getFunction();\n\n\n                    ToolCall tool = new ToolCall();\n                    tool.setId(toolCall.getId());\n                    tool.setType(\"function\");\n                    tool.setFunction(new ToolCall.Function(src.getName(), src.getArguments()));\n                    return tool;\n                } else {\n                    return null;\n                }\n            }).filter(ObjUtil::isNotNull).collect(Collectors.toList()));\n        }\n        return chatMessage;\n    }\n\n    public static List<ChatMessage> convertToChatMessage(List<Message> messageList) {\n        return messageList.stream().map(MessageUtil::convert).collect(Collectors.toList());\n    }\n\n    public static Message convert(ChatMessage message) {\n        Message target = BeanUtil.copyProperties(message, Message.class, \"content\", \"toolCalls\");\n        if (ObjUtil.isNotNull(message.getContent())) {\n            if (StrUtil.isNotBlank(message.getContent().getText())) {\n                target.setContent(message.getContent().getText());\n            }\n            if (CollUtil.isNotEmpty(message.getContent().getMultiModals())) {\n                target.setContents(message.getContent().getMultiModals().stream().map(multiModal -> {\n                    if (Content.MultiModal.Type.IMAGE_URL.getType().equals(multiModal.getType())) {\n                        return MessageContentImageURL.builder().imageURL(ImageURL.builder().url(multiModal.getImageUrl().getUrl()).build()).build();\n                    } else {\n                        return MessageContentText.builder().text(multiModal.getText()).build();\n                    }\n                }).collect(Collectors.toList()));\n            }\n        }\n        if (CollUtil.isNotEmpty(message.getToolCalls())) {\n            target.setToolCalls(message.getToolCalls().stream().map(toolCall -> {\n                if (\"function\".equals(toolCall.getType())) {\n                    ToolCall.Function src = toolCall.getFunction();\n\n                    ToolCallFunction toolCallFunction = new ToolCallFunction();\n                    ToolCallFunction.CallFunction callFunction = toolCallFunction.new CallFunction();\n                    callFunction.setName(src.getName());\n                    callFunction.setArguments(src.getArguments());\n                    toolCallFunction.setFunction(callFunction);\n                    toolCallFunction.setId(toolCall.getId());\n                    return toolCallFunction;\n                } else {\n                    return null;\n                }\n            }).filter(ObjUtil::isNotNull).collect(Collectors.toList()));\n        }\n        return target;\n    }\n\n    public static List<Message> convertToMessage(List<ChatMessage> messageList) {\n        return messageList.stream().map(MessageUtil::convert).collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/deepseek/chat/DeepSeekChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.deepseek.chat;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.DeepSeekConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.deepseek.chat.entity.DeepSeekChatCompletion;\nimport io.github.lnyocly.ai4j.platform.deepseek.chat.entity.DeepSeekChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description DeepSeek Chat服务\n * @Date 2024/8/29 10:26\n */\npublic class DeepSeekChatService implements IChatService, ParameterConvert<DeepSeekChatCompletion>, ResultConvert<DeepSeekChatCompletionResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n    private static final String TOOL_CALLS_FINISH_REASON = \"tool_calls\";\n    private static final String FIRST_FINISH_REASON = \"first\";\n\n    private final DeepSeekConfig deepSeekConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n    private final ObjectMapper objectMapper;\n\n    public DeepSeekChatService(Configuration configuration) {\n        this.deepSeekConfig = configuration.getDeepSeekConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    public DeepSeekChatService(Configuration configuration, DeepSeekConfig deepSeekConfig) {\n        this.deepSeekConfig = deepSeekConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    @Override\n    public DeepSeekChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        DeepSeekChatCompletion deepSeekChatCompletion = new DeepSeekChatCompletion();\n        deepSeekChatCompletion.setModel(chatCompletion.getModel());\n        deepSeekChatCompletion.setMessages(chatCompletion.getMessages());\n        deepSeekChatCompletion.setFrequencyPenalty(chatCompletion.getFrequencyPenalty());\n        deepSeekChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion));\n        deepSeekChatCompletion.setPresencePenalty(chatCompletion.getPresencePenalty());\n        deepSeekChatCompletion.setResponseFormat(chatCompletion.getResponseFormat());\n        deepSeekChatCompletion.setStop(chatCompletion.getStop());\n        deepSeekChatCompletion.setStream(chatCompletion.getStream());\n        deepSeekChatCompletion.setStreamOptions(chatCompletion.getStreamOptions());\n        deepSeekChatCompletion.setTemperature(chatCompletion.getTemperature());\n        deepSeekChatCompletion.setTopP(chatCompletion.getTopP());\n        deepSeekChatCompletion.setTools(chatCompletion.getTools());\n        deepSeekChatCompletion.setFunctions(chatCompletion.getFunctions());\n        deepSeekChatCompletion.setToolChoice(chatCompletion.getToolChoice());\n        deepSeekChatCompletion.setLogprobs(chatCompletion.getLogprobs());\n        deepSeekChatCompletion.setTopLogprobs(chatCompletion.getTopLogprobs());\n        deepSeekChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n        return deepSeekChatCompletion;\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(final SseListener eventSourceListener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n                eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data));\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(DeepSeekChatCompletionResponse deepSeekChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(deepSeekChatCompletionResponse.getId());\n        chatCompletionResponse.setObject(deepSeekChatCompletionResponse.getObject());\n        chatCompletionResponse.setCreated(deepSeekChatCompletionResponse.getCreated());\n        chatCompletionResponse.setModel(deepSeekChatCompletionResponse.getModel());\n        chatCompletionResponse.setSystemFingerprint(deepSeekChatCompletionResponse.getSystemFingerprint());\n        chatCompletionResponse.setChoices(deepSeekChatCompletionResponse.getChoices());\n        chatCompletionResponse.setUsage(deepSeekChatCompletionResponse.getUsage());\n        return chatCompletionResponse;\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n            prepareChatCompletion(chatCompletion, false);\n\n            DeepSeekChatCompletion deepSeekChatCompletion = convertChatCompletionObject(chatCompletion);\n            Usage allUsage = new Usage();\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                DeepSeekChatCompletionResponse response = executeChatCompletionRequest(\n                        resolvedBaseUrl,\n                        resolvedApiKey,\n                        deepSeekChatCompletion\n                );\n                if (response == null) {\n                    break;\n                }\n\n                Choice choice = response.getChoices().get(0);\n                finishReason = choice.getFinishReason();\n                mergeUsage(allUsage, response.getUsage());\n\n                if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) {\n                    if (passThroughToolCalls) {\n                        response.setUsage(allUsage);\n                        restoreOriginalRequest(chatCompletion, deepSeekChatCompletion);\n                        return convertChatCompletionResponse(response);\n                    }\n                    deepSeekChatCompletion.setMessages(appendToolMessages(\n                            deepSeekChatCompletion.getMessages(),\n                            choice.getMessage(),\n                            choice.getMessage().getToolCalls()\n                    ));\n                    continue;\n                }\n\n                response.setUsage(allUsage);\n                restoreOriginalRequest(chatCompletion, deepSeekChatCompletion);\n                return convertChatCompletionResponse(response);\n            }\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return this.chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, true);\n            DeepSeekChatCompletion deepSeekChatCompletion = convertChatCompletionObject(chatCompletion);\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, deepSeekChatCompletion);\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n                if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) {\n                    continue;\n                }\n                if (passThroughToolCalls) {\n                    return;\n                }\n\n                deepSeekChatCompletion.setMessages(appendStreamToolMessages(\n                        deepSeekChatCompletion.getMessages(),\n                        toolCalls\n                ));\n                resetToolCallState(eventSourceListener);\n            }\n\n            restoreOriginalRequest(chatCompletion, deepSeekChatCompletion);\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    private String serializeStreamResponse(String data) {\n        try {\n            DeepSeekChatCompletionResponse chatCompletionResponse =\n                    objectMapper.readValue(data, DeepSeekChatCompletionResponse.class);\n            ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse);\n            return objectMapper.writeValueAsString(response);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"读取DeepSeek Chat 对象JSON序列化出错\");\n        }\n    }\n\n    private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) {\n        chatCompletion.setStream(stream);\n        if (!stream) {\n            chatCompletion.setStreamOptions(null);\n        }\n        attachTools(chatCompletion);\n    }\n\n    private void attachTools(ChatCompletion chatCompletion) {\n        if (hasPendingTools(chatCompletion)) {\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if (tools == null) {\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) {\n            chatCompletion.setParallelToolCalls(null);\n        }\n    }\n\n    private boolean hasPendingTools(ChatCompletion chatCompletion) {\n        return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty())\n                || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty());\n    }\n\n    private boolean requiresFollowUp(String finishReason) {\n        return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason);\n    }\n\n    private DeepSeekChatCompletionResponse executeChatCompletionRequest(\n            String baseUrl,\n            String apiKey,\n            DeepSeekChatCompletion deepSeekChatCompletion\n    ) throws Exception {\n        Request request = buildChatCompletionRequest(baseUrl, apiKey, deepSeekChatCompletion);\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return objectMapper.readValue(response.body().string(), DeepSeekChatCompletionResponse.class);\n            }\n        }\n        return null;\n    }\n\n    private Request buildChatCompletionRequest(String baseUrl, String apiKey, DeepSeekChatCompletion deepSeekChatCompletion)\n            throws JsonProcessingException {\n        String requestBody = objectMapper.writeValueAsString(deepSeekChatCompletion);\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, deepSeekConfig.getChatCompletionUrl()))\n                .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE))\n                .build();\n    }\n\n    private void mergeUsage(Usage target, Usage usage) {\n        if (usage == null) {\n            return;\n        }\n        target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens());\n        target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens());\n        target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens());\n    }\n\n    private List<ChatMessage> appendToolMessages(\n            List<ChatMessage> messages,\n            ChatMessage assistantMessage,\n            List<ToolCall> toolCalls\n    ) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(assistantMessage);\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private List<ChatMessage> appendStreamToolMessages(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(ChatMessage.withAssistant(toolCalls));\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private void appendToolResponses(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        for (ToolCall toolCall : toolCalls) {\n            String functionName = toolCall.getFunction().getName();\n            String arguments = toolCall.getFunction().getArguments();\n            String functionResponse = ToolUtil.invoke(functionName, arguments);\n            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n        }\n    }\n\n    private void resetToolCallState(SseListener eventSourceListener) {\n        eventSourceListener.setToolCalls(new ArrayList<ToolCall>());\n        eventSourceListener.setToolCall(null);\n    }\n\n    private void restoreOriginalRequest(ChatCompletion chatCompletion, DeepSeekChatCompletion deepSeekChatCompletion) {\n        chatCompletion.setMessages(deepSeekChatCompletion.getMessages());\n        chatCompletion.setTools(deepSeekChatCompletion.getTools());\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        return (baseUrl == null || \"\".equals(baseUrl)) ? deepSeekConfig.getApiHost() : baseUrl;\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? deepSeekConfig.getApiKey() : apiKey;\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    private Integer resolveMaxTokens(ChatCompletion chatCompletion) {\n        if (chatCompletion.getMaxCompletionTokens() != null) {\n            return chatCompletion.getMaxCompletionTokens();\n        }\n        return chatCompletion.getMaxTokens();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/deepseek/chat/entity/DeepSeekChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.deepseek.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.zhipu.chat.entity.ZhipuChatCompletion;\nimport lombok.*;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description DeepSeek对话请求实体\n * @Date 2024/8/29 10:27\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class DeepSeekChatCompletion {\n\n\n    @NonNull\n    private String model;\n\n    @NonNull\n    private List<ChatMessage> messages;\n\n    /**\n     * 介于 -2.0 和 2.0 之间的数字。如果该值为正，那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚，降低模型重复相同内容的可能性。\n     */\n    @Builder.Default\n    @JsonProperty(\"frequency_penalty\")\n    private Float frequencyPenalty = 0f;\n\n    /**\n     * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。\n     */\n    @JsonProperty(\"max_tokens\")\n    private Integer maxTokens;\n\n    /**\n     * 介于 -2.0 和 2.0 之间的数字。如果该值为正，那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚，从而增加模型谈论新主题的可能性。\n     */\n    @Builder.Default\n    @JsonProperty(\"presence_penalty\")\n    private Float presencePenalty = 0f;\n\n    /**\n     * 一个 object，指定模型必须输出的格式。\n     *\n     * 设置为 { \"type\": \"json_object\" } 以启用 JSON 模式，该模式保证模型生成的消息是有效的 JSON。\n     *\n     * 注意: 使用 JSON 模式时，你还必须通过系统或用户消息指示模型生成 JSON。\n     * 否则，模型可能会生成不断的空白字符，直到生成达到令牌限制，从而导致请求长时间运行并显得“卡住”。\n     * 此外，如果 finish_reason=\"length\"，这表示生成超过了 max_tokens 或对话超过了最大上下文长度，消息内容可能会被部分截断。\n     */\n    @JsonProperty(\"response_format\")\n    private Object responseFormat;\n\n    /**\n     * 在遇到这些词时，API 将停止生成更多的 token。\n     */\n    private List<String> stop;\n\n    /**\n     * 如果设置为 True，将会以 SSE（server-sent events）的形式以流式发送消息增量。消息流以 data: [DONE] 结尾\n     */\n    @Builder.Default\n    private Boolean stream = false;\n\n    /**\n     * 流式输出相关选项。只有在 stream 参数为 true 时，才可设置此参数。\n     */\n    @Builder.Default\n    @JsonProperty(\"stream_options\")\n    private StreamOptions streamOptions = new StreamOptions();\n\n    /**\n     * 采样温度，介于 0 和 2 之间。更高的值，如 0.8，会使输出更随机，而更低的值，如 0.2，会使其更加集中和确定。\n     * 我们通常建议可以更改这个值或者更改 top_p，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    private Float temperature = 1f;\n\n    /**\n     * 作为调节采样温度的替代方案，模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。\n     * 我们通常建议修改这个值或者更改 temperature，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 1f;\n\n    /**\n     * 模型可能会调用的 tool 的列表。目前，仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。\n     */\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    /**\n     * 控制模型调用 tool 的行为。\n     * none 意味着模型不会调用任何 tool，而是生成一条消息。\n     * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。\n     * 当没有 tool 时，默认值为 none。如果有 tool 存在，默认值为 auto。\n     */\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    /**\n     * 是否返回所输出 token 的对数概率。如果为 true，则在 message 的 content 中返回每个输出 token 的对数概率。\n     */\n    @Builder.Default\n    private Boolean logprobs = false;\n\n    /**\n     * 一个介于 0 到 20 之间的整数 N，指定每个输出位置返回输出概率 top N 的 token，且返回这些 token 的对数概率。指定此参数时，logprobs 必须为 true。\n     */\n    @JsonProperty(\"top_logprobs\")\n    private Integer topLogprobs;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n    public static class DeepSeekChatCompletionBuilder {\n        private List<String> functions;\n\n        public DeepSeekChatCompletion.DeepSeekChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public DeepSeekChatCompletion.DeepSeekChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            if (functions != null) {\n                this.functions.addAll(functions);\n            }\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/deepseek/chat/entity/DeepSeekChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.deepseek.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description DeepSeek对话响应实体\n * @Date 2024/8/29 10:28\n */\n\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class DeepSeekChatCompletionResponse {\n    /**\n     * 该对话的唯一标识符。\n     */\n    private String id;\n\n    /**\n     * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk\n     */\n    private String object;\n\n    /**\n     * 创建聊天完成时的 Unix 时间戳（以秒为单位）。\n     */\n    private Long created;\n\n    /**\n     * 生成该 completion 的模型名。\n     */\n    private String model;\n\n    /**\n     * 模型生成的 completion 的选择列表。\n     */\n    private List<Choice> choices;\n\n    /**\n     * 该对话补全请求的用量信息。\n     */\n    private Usage usage;\n\n    /**\n     * 该指纹代表模型运行时使用的后端配置。\n     */\n    @JsonProperty(\"system_fingerprint\")\n    private String systemFingerprint;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/chat/DoubaoChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.doubao.chat;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.doubao.chat.entity.DoubaoChatCompletion;\nimport io.github.lnyocly.ai4j.platform.doubao.chat.entity.DoubaoChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class DoubaoChatService implements IChatService, ParameterConvert<DoubaoChatCompletion>, ResultConvert<DoubaoChatCompletionResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n    private static final String TOOL_CALLS_FINISH_REASON = \"tool_calls\";\n    private static final String FIRST_FINISH_REASON = \"first\";\n\n    private final DoubaoConfig doubaoConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n    private final ObjectMapper objectMapper;\n\n    public DoubaoChatService(Configuration configuration) {\n        this.doubaoConfig = configuration.getDoubaoConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    public DoubaoChatService(Configuration configuration, DoubaoConfig doubaoConfig) {\n        this.doubaoConfig = doubaoConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    @Override\n    public DoubaoChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        DoubaoChatCompletion doubaoChatCompletion = new DoubaoChatCompletion();\n        doubaoChatCompletion.setModel(chatCompletion.getModel());\n        doubaoChatCompletion.setMessages(chatCompletion.getMessages());\n        doubaoChatCompletion.setFrequencyPenalty(chatCompletion.getFrequencyPenalty());\n        doubaoChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion));\n        doubaoChatCompletion.setPresencePenalty(chatCompletion.getPresencePenalty());\n        doubaoChatCompletion.setResponseFormat(chatCompletion.getResponseFormat());\n        doubaoChatCompletion.setStop(chatCompletion.getStop());\n        doubaoChatCompletion.setStream(chatCompletion.getStream());\n        doubaoChatCompletion.setStreamOptions(chatCompletion.getStreamOptions());\n        doubaoChatCompletion.setTemperature(chatCompletion.getTemperature());\n        doubaoChatCompletion.setTopP(chatCompletion.getTopP());\n        doubaoChatCompletion.setTools(chatCompletion.getTools());\n        doubaoChatCompletion.setFunctions(chatCompletion.getFunctions());\n        doubaoChatCompletion.setToolChoice(chatCompletion.getToolChoice());\n        doubaoChatCompletion.setLogprobs(chatCompletion.getLogprobs());\n        doubaoChatCompletion.setTopLogprobs(chatCompletion.getTopLogprobs());\n        doubaoChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n        return doubaoChatCompletion;\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(final SseListener eventSourceListener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n                eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data));\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(DoubaoChatCompletionResponse doubaoChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(doubaoChatCompletionResponse.getId());\n        chatCompletionResponse.setObject(doubaoChatCompletionResponse.getObject());\n        chatCompletionResponse.setCreated(doubaoChatCompletionResponse.getCreated());\n        chatCompletionResponse.setModel(doubaoChatCompletionResponse.getModel());\n        chatCompletionResponse.setSystemFingerprint(doubaoChatCompletionResponse.getSystemFingerprint());\n        chatCompletionResponse.setChoices(doubaoChatCompletionResponse.getChoices());\n        chatCompletionResponse.setUsage(doubaoChatCompletionResponse.getUsage());\n        return chatCompletionResponse;\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, false);\n            DoubaoChatCompletion doubaoChatCompletion = convertChatCompletionObject(chatCompletion);\n            Usage allUsage = new Usage();\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                DoubaoChatCompletionResponse response = executeChatCompletionRequest(\n                        resolvedBaseUrl,\n                        resolvedApiKey,\n                        doubaoChatCompletion\n                );\n                if (response == null) {\n                    break;\n                }\n\n                Choice choice = response.getChoices().get(0);\n                finishReason = choice.getFinishReason();\n                mergeUsage(allUsage, response.getUsage());\n\n                if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) {\n                    if (passThroughToolCalls) {\n                        response.setUsage(allUsage);\n                        restoreOriginalRequest(chatCompletion, doubaoChatCompletion);\n                        return convertChatCompletionResponse(response);\n                    }\n                    doubaoChatCompletion.setMessages(appendToolMessages(\n                            doubaoChatCompletion.getMessages(),\n                            choice.getMessage(),\n                            choice.getMessage().getToolCalls()\n                    ));\n                    continue;\n                }\n\n                response.setUsage(allUsage);\n                restoreOriginalRequest(chatCompletion, doubaoChatCompletion);\n                return convertChatCompletionResponse(response);\n            }\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return this.chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, true);\n            DoubaoChatCompletion doubaoChatCompletion = convertChatCompletionObject(chatCompletion);\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, doubaoChatCompletion);\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n                if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) {\n                    continue;\n                }\n                if (passThroughToolCalls) {\n                    return;\n                }\n\n                doubaoChatCompletion.setMessages(appendStreamToolMessages(\n                        doubaoChatCompletion.getMessages(),\n                        toolCalls\n                ));\n                resetToolCallState(eventSourceListener);\n            }\n\n            restoreOriginalRequest(chatCompletion, doubaoChatCompletion);\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    private String serializeStreamResponse(String data) {\n        try {\n            DoubaoChatCompletionResponse chatCompletionResponse =\n                    objectMapper.readValue(data, DoubaoChatCompletionResponse.class);\n            ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse);\n            return objectMapper.writeValueAsString(response);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"读取豆包 Chat 对象JSON序列化出错\");\n        }\n    }\n\n    private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) {\n        chatCompletion.setStream(stream);\n        if (stream) {\n            if (chatCompletion.getStreamOptions() == null) {\n                chatCompletion.setStreamOptions(new StreamOptions(true));\n            }\n        } else {\n            chatCompletion.setStreamOptions(null);\n        }\n        attachTools(chatCompletion);\n    }\n\n    private void attachTools(ChatCompletion chatCompletion) {\n        if (hasPendingTools(chatCompletion)) {\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if (tools == null) {\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) {\n            chatCompletion.setParallelToolCalls(null);\n        }\n    }\n\n    private boolean hasPendingTools(ChatCompletion chatCompletion) {\n        return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty())\n                || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty());\n    }\n\n    private boolean requiresFollowUp(String finishReason) {\n        return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason);\n    }\n\n    private DoubaoChatCompletionResponse executeChatCompletionRequest(\n            String baseUrl,\n            String apiKey,\n            DoubaoChatCompletion doubaoChatCompletion\n    ) throws Exception {\n        Request request = buildChatCompletionRequest(baseUrl, apiKey, doubaoChatCompletion);\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return objectMapper.readValue(response.body().string(), DoubaoChatCompletionResponse.class);\n            }\n        }\n        return null;\n    }\n\n    private Request buildChatCompletionRequest(String baseUrl, String apiKey, DoubaoChatCompletion doubaoChatCompletion)\n            throws JsonProcessingException {\n        String requestBody = objectMapper.writeValueAsString(doubaoChatCompletion);\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, doubaoConfig.getChatCompletionUrl()))\n                .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE))\n                .build();\n    }\n\n    private void mergeUsage(Usage target, Usage usage) {\n        if (usage == null) {\n            return;\n        }\n        target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens());\n        target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens());\n        target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens());\n    }\n\n    private List<ChatMessage> appendToolMessages(\n            List<ChatMessage> messages,\n            ChatMessage assistantMessage,\n            List<ToolCall> toolCalls\n    ) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(assistantMessage);\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private List<ChatMessage> appendStreamToolMessages(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(ChatMessage.withAssistant(toolCalls));\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private void appendToolResponses(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        for (ToolCall toolCall : toolCalls) {\n            String functionName = toolCall.getFunction().getName();\n            String arguments = toolCall.getFunction().getArguments();\n            String functionResponse = ToolUtil.invoke(functionName, arguments);\n            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n        }\n    }\n\n    private void resetToolCallState(SseListener eventSourceListener) {\n        eventSourceListener.setToolCalls(new ArrayList<ToolCall>());\n        eventSourceListener.setToolCall(null);\n    }\n\n    private void restoreOriginalRequest(ChatCompletion chatCompletion, DoubaoChatCompletion doubaoChatCompletion) {\n        chatCompletion.setMessages(doubaoChatCompletion.getMessages());\n        chatCompletion.setTools(doubaoChatCompletion.getTools());\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        return (baseUrl == null || \"\".equals(baseUrl)) ? doubaoConfig.getApiHost() : baseUrl;\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? doubaoConfig.getApiKey() : apiKey;\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    private Integer resolveMaxTokens(ChatCompletion chatCompletion) {\n        if (chatCompletion.getMaxCompletionTokens() != null) {\n            return chatCompletion.getMaxCompletionTokens();\n        }\n        return chatCompletion.getMaxTokens();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/chat/entity/DoubaoChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.doubao.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport lombok.*;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description 豆包(火山引擎方舟)对话请求实体\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class DoubaoChatCompletion {\n\n\n    @NonNull\n    private String model;\n\n    @NonNull\n    private List<ChatMessage> messages;\n\n    /**\n     * 介于 -2.0 和 2.0 之间的数字。如果该值为正，那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚，降低模型重复相同内容的可能性。\n     */\n    @Builder.Default\n    @JsonProperty(\"frequency_penalty\")\n    private Float frequencyPenalty = 0f;\n\n    /**\n     * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。\n     */\n    @JsonProperty(\"max_tokens\")\n    private Integer maxTokens;\n\n    /**\n     * 介于 -2.0 和 2.0 之间的数字。如果该值为正，那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚，从而增加模型谈论新主题的可能性。\n     */\n    @Builder.Default\n    @JsonProperty(\"presence_penalty\")\n    private Float presencePenalty = 0f;\n\n    /**\n     * 一个 object，指定模型必须输出的格式。\n     *\n     * 设置为 { \"type\": \"json_object\" } 以启用 JSON 模式，该模式保证模型生成的消息是有效的 JSON。\n     */\n    @JsonProperty(\"response_format\")\n    private Object responseFormat;\n\n    /**\n     * 在遇到这些词时，API 将停止生成更多的 token。\n     */\n    private List<String> stop;\n\n    /**\n     * 如果设置为 True，将会以 SSE（server-sent events）的形式以流式发送消息增量。消息流以 data: [DONE] 结尾\n     */\n    @Builder.Default\n    private Boolean stream = false;\n\n    /**\n     * 流式输出相关选项。只有在 stream 参数为 true 时，才可设置此参数。\n     */\n    @Builder.Default\n    @JsonProperty(\"stream_options\")\n    private StreamOptions streamOptions = new StreamOptions();\n\n    /**\n     * 采样温度，介于 0 和 2 之间。更高的值，如 0.8，会使输出更随机，而更低的值，如 0.2，会使其更加集中和确定。\n     * 我们通常建议可以更改这个值或者更改 top_p，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    private Float temperature = 1f;\n\n    /**\n     * 作为调节采样温度的替代方案，模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。\n     * 我们通常建议修改这个值或者更改 temperature，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 1f;\n\n    /**\n     * 模型可能会调用的 tool 的列表。目前，仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。\n     */\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    /**\n     * 控制模型调用 tool 的行为。\n     * none 意味着模型不会调用任何 tool，而是生成一条消息。\n     * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。\n     * 当没有 tool 时，默认值为 none。如果有 tool 存在，默认值为 auto。\n     */\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    /**\n     * 是否返回所输出 token 的对数概率。如果为 true，则在 message 的 content 中返回每个输出 token 的对数概率。\n     */\n    @Builder.Default\n    private Boolean logprobs = false;\n\n    /**\n     * 一个介于 0 到 20 之间的整数 N，指定每个输出位置返回输出概率 top N 的 token，且返回这些 token 的对数概率。指定此参数时，logprobs 必须为 true。\n     */\n    @JsonProperty(\"top_logprobs\")\n    private Integer topLogprobs;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n    public static class DoubaoChatCompletionBuilder {\n        private List<String> functions;\n\n        public DoubaoChatCompletion.DoubaoChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public DoubaoChatCompletion.DoubaoChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            if (functions != null) {\n                this.functions.addAll(functions);\n            }\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/chat/entity/DoubaoChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.doubao.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class DoubaoChatCompletionResponse {\n    /**\n     * 该对话的唯一标识符。\n     */\n    private String id;\n\n    /**\n     * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk\n     */\n    private String object;\n\n    /**\n     * 创建聊天完成时的 Unix 时间戳（以秒为单位）。\n     */\n    private Long created;\n\n    /**\n     * 生成该 completion 的模型名。\n     */\n    private String model;\n\n    /**\n     * 模型生成的 completion 的选择列表。\n     */\n    private List<Choice> choices;\n\n    /**\n     * 该对话补全请求的用量信息。\n     */\n    private Usage usage;\n\n    /**\n     * 该指纹代表模型运行时使用的后端配置。\n     */\n    @JsonProperty(\"system_fingerprint\")\n    private String systemFingerprint;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/image/DoubaoImageService.java",
    "content": "package io.github.lnyocly.ai4j.platform.doubao.image;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.ImageSseListener;\nimport io.github.lnyocly.ai4j.platform.doubao.image.entity.DoubaoImageGenerationRequest;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGeneration;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamError;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamEvent;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageUsage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IImageService;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport okhttp3.sse.EventSources;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport com.fasterxml.jackson.databind.JsonNode;\n\n/**\n * @Author cly\n * @Description 豆包图片生成服务\n * @Date 2026/1/31\n */\npublic class DoubaoImageService implements IImageService {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private final DoubaoConfig doubaoConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n\n    public DoubaoImageService(Configuration configuration) {\n        this.doubaoConfig = configuration.getDoubaoConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = EventSources.createFactory(okHttpClient);\n    }\n\n    private DoubaoImageGenerationRequest convert(ImageGeneration imageGeneration) {\n        return DoubaoImageGenerationRequest.builder()\n                .model(imageGeneration.getModel())\n                .prompt(imageGeneration.getPrompt())\n                .n(imageGeneration.getN())\n                .size(imageGeneration.getSize())\n                .responseFormat(imageGeneration.getResponseFormat())\n                .stream(imageGeneration.getStream())\n                .extraBody(imageGeneration.getExtraBody())\n                .build();\n    }\n\n    @Override\n    public ImageGenerationResponse generate(String baseUrl, String apiKey, ImageGeneration imageGeneration) throws Exception {\n        if (baseUrl == null || \"\".equals(baseUrl)) {\n            baseUrl = doubaoConfig.getApiHost();\n        }\n        if (apiKey == null || \"\".equals(apiKey)) {\n            apiKey = doubaoConfig.getApiKey();\n        }\n\n        DoubaoImageGenerationRequest requestBody = convert(imageGeneration);\n\n        ObjectMapper mapper = new ObjectMapper();\n        String requestString = mapper.writeValueAsString(requestBody);\n\n        Request request = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, doubaoConfig.getImageGenerationUrl()))\n                .post(RequestBody.create(requestString, JSON_MEDIA_TYPE))\n                .build();\n\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return mapper.readValue(response.body().string(), ImageGenerationResponse.class);\n            }\n        }\n\n        throw new CommonException(\"豆包图片生成请求失败\");\n    }\n\n    @Override\n    public ImageGenerationResponse generate(ImageGeneration imageGeneration) throws Exception {\n        return this.generate(null, null, imageGeneration);\n    }\n\n    @Override\n    public void generateStream(String baseUrl, String apiKey, ImageGeneration imageGeneration, ImageSseListener listener) throws Exception {\n        if (baseUrl == null || \"\".equals(baseUrl)) {\n            baseUrl = doubaoConfig.getApiHost();\n        }\n        if (apiKey == null || \"\".equals(apiKey)) {\n            apiKey = doubaoConfig.getApiKey();\n        }\n        if (imageGeneration.getStream() == null || !imageGeneration.getStream()) {\n            imageGeneration.setStream(true);\n        }\n\n        DoubaoImageGenerationRequest requestBody = convert(imageGeneration);\n        ObjectMapper mapper = new ObjectMapper();\n        String requestString = mapper.writeValueAsString(requestBody);\n\n        Request request = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, doubaoConfig.getImageGenerationUrl()))\n                .post(RequestBody.create(requestString, JSON_MEDIA_TYPE))\n                .build();\n\n        factory.newEventSource(request, convertEventSource(mapper, listener));\n        listener.getCountDownLatch().await();\n    }\n\n    @Override\n    public void generateStream(ImageGeneration imageGeneration, ImageSseListener listener) throws Exception {\n        this.generateStream(null, null, imageGeneration, listener);\n    }\n\n    private EventSourceListener convertEventSource(ObjectMapper mapper, ImageSseListener listener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                // no-op\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                listener.onError(t, response);\n                listener.complete();\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    listener.complete();\n                    return;\n                }\n                try {\n                    ImageStreamEvent event = parseDoubaoEvent(mapper, data);\n                    listener.accept(event);\n\n                    if (\"image_generation.completed\".equals(event.getType())) {\n                        listener.complete();\n                    }\n                } catch (Exception e) {\n                    listener.onError(e, null);\n                    listener.complete();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                listener.complete();\n            }\n        };\n    }\n\n    private ImageStreamEvent parseDoubaoEvent(ObjectMapper mapper, String data) throws Exception {\n        JsonNode node = mapper.readTree(data);\n        ImageStreamEvent event = new ImageStreamEvent();\n        event.setType(asText(node, \"type\"));\n        event.setModel(asText(node, \"model\"));\n        Long createdAt = asLong(node, \"created\");\n        if (createdAt == null) {\n            createdAt = asLong(node, \"created_at\");\n        }\n        event.setCreatedAt(createdAt);\n        event.setImageIndex(asInt(node, \"image_index\"));\n        event.setPartialImageIndex(asInt(node, \"image_index\"));\n        event.setUrl(asText(node, \"url\"));\n        event.setB64Json(asText(node, \"b64_json\"));\n        event.setSize(asText(node, \"size\"));\n        if (node.has(\"usage\")) {\n            event.setUsage(mapper.treeToValue(node.get(\"usage\"), ImageUsage.class));\n        }\n        if (node.has(\"error\")) {\n            event.setError(mapper.treeToValue(node.get(\"error\"), ImageStreamError.class));\n        }\n        return event;\n    }\n\n    private String asText(JsonNode node, String field) {\n        JsonNode value = node.get(field);\n        return value == null || value.isNull() ? null : value.asText();\n    }\n\n    private Integer asInt(JsonNode node, String field) {\n        JsonNode value = node.get(field);\n        return value == null || value.isNull() ? null : value.asInt();\n    }\n\n    private Long asLong(JsonNode node, String field) {\n        JsonNode value = node.get(field);\n        return value == null || value.isNull() ? null : value.asLong();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/image/entity/DoubaoImageGenerationRequest.java",
    "content": "package io.github.lnyocly.ai4j.platform.doubao.image.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.*;\n\nimport java.util.Map;\n\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class DoubaoImageGenerationRequest {\n\n    @NonNull\n    private String model;\n\n    @NonNull\n    private String prompt;\n\n    private Integer n;\n\n    private String size;\n\n    @JsonProperty(\"response_format\")\n    private String responseFormat;\n\n    private Boolean stream;\n\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/rerank/DoubaoRerankService.java",
    "content": "package io.github.lnyocly.ai4j.platform.doubao.rerank;\n\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankDocument;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankRequest;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResponse;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResult;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankUsage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IRerankService;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class DoubaoRerankService implements IRerankService {\n\n    private final DoubaoConfig doubaoConfig;\n    private final OkHttpClient okHttpClient;\n    private final ObjectMapper objectMapper = new ObjectMapper();\n\n    public DoubaoRerankService(Configuration configuration) {\n        this(configuration, configuration == null ? null : configuration.getDoubaoConfig());\n    }\n\n    public DoubaoRerankService(Configuration configuration, DoubaoConfig doubaoConfig) {\n        this.doubaoConfig = doubaoConfig;\n        this.okHttpClient = configuration == null ? null : configuration.getOkHttpClient();\n    }\n\n    @Override\n    public RerankResponse rerank(String baseUrl, String apiKey, RerankRequest request) throws Exception {\n        String host = resolveBaseUrl(baseUrl);\n        String key = resolveApiKey(apiKey);\n        String path = resolveRerankUrl();\n\n        Map<String, Object> body = new LinkedHashMap<String, Object>();\n        body.put(\"rerank_model\", request.getModel());\n        if (StringUtils.isNotBlank(request.getInstruction())) {\n            body.put(\"rerank_instruction\", request.getInstruction());\n        }\n        List<Map<String, Object>> datas = new ArrayList<Map<String, Object>>();\n        List<RerankDocument> documents = request.getDocuments() == null\n                ? Collections.<RerankDocument>emptyList()\n                : request.getDocuments();\n        for (RerankDocument document : documents) {\n            if (document == null) {\n                continue;\n            }\n            Map<String, Object> item = new LinkedHashMap<String, Object>();\n            item.put(\"query\", request.getQuery());\n            String content = firstNonBlank(document.getContent(), document.getText());\n            if (StringUtils.isNotBlank(content)) {\n                item.put(\"content\", content);\n            }\n            if (StringUtils.isNotBlank(document.getTitle())) {\n                item.put(\"title\", document.getTitle());\n            }\n            if (document.getImage() != null) {\n                item.put(\"image\", document.getImage());\n            }\n            datas.add(item);\n        }\n        body.put(\"datas\", datas);\n        if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) {\n            body.putAll(request.getExtraBody());\n        }\n\n        Request.Builder builder = new Request.Builder()\n                .url(UrlUtils.concatUrl(host, path))\n                .post(RequestBody.create(objectMapper.writeValueAsString(body), MediaType.get(Constants.JSON_CONTENT_TYPE)));\n        if (StringUtils.isNotBlank(key)) {\n            builder.header(\"Authorization\", \"Bearer \" + key);\n        }\n\n        try (okhttp3.Response response = okHttpClient.newCall(builder.build()).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return parseResponse(objectMapper.readTree(response.body().string()), request);\n            }\n        }\n        throw new CommonException(\"Doubao rerank request failed\");\n    }\n\n    @Override\n    public RerankResponse rerank(RerankRequest request) throws Exception {\n        return rerank(null, null, request);\n    }\n\n    private RerankResponse parseResponse(JsonNode root, RerankRequest request) {\n        JsonNode dataNode = root == null ? null : root.get(\"data\");\n        JsonNode scoreArray = dataNode;\n        if (dataNode != null && dataNode.isObject() && dataNode.has(\"scores\")) {\n            scoreArray = dataNode.get(\"scores\");\n        }\n\n        List<RerankResult> results = new ArrayList<RerankResult>();\n        List<RerankDocument> documents = request.getDocuments() == null\n                ? Collections.<RerankDocument>emptyList()\n                : request.getDocuments();\n        if (scoreArray != null && scoreArray.isArray()) {\n            for (int i = 0; i < scoreArray.size(); i++) {\n                JsonNode item = scoreArray.get(i);\n                float relevanceScore;\n                Integer index = i;\n                if (item != null && item.isObject()) {\n                    relevanceScore = item.has(\"score\") ? (float) item.get(\"score\").asDouble() : 0.0f;\n                    if (item.has(\"index\")) {\n                        index = item.get(\"index\").asInt();\n                    }\n                } else {\n                    relevanceScore = item == null || item.isNull() ? 0.0f : (float) item.asDouble();\n                }\n                RerankDocument document = index != null && index >= 0 && index < documents.size()\n                        ? documents.get(index)\n                        : null;\n                results.add(RerankResult.builder()\n                        .index(index)\n                        .relevanceScore(relevanceScore)\n                        .document(document == null ? null : document.toBuilder().build())\n                        .build());\n            }\n        }\n        Collections.sort(results, new Comparator<RerankResult>() {\n            @Override\n            public int compare(RerankResult left, RerankResult right) {\n                float l = left == null || left.getRelevanceScore() == null ? 0.0f : left.getRelevanceScore();\n                float r = right == null || right.getRelevanceScore() == null ? 0.0f : right.getRelevanceScore();\n                return Float.compare(r, l);\n            }\n        });\n\n        return RerankResponse.builder()\n                .id(text(root, \"request_id\"))\n                .model(request == null ? null : request.getModel())\n                .results(results)\n                .usage(RerankUsage.builder()\n                        .inputTokens(intValue(root == null ? null : root.get(\"token_usage\")))\n                        .build())\n                .build();\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        if (StringUtils.isNotBlank(baseUrl)) {\n            return baseUrl;\n        }\n        if (doubaoConfig != null && StringUtils.isNotBlank(doubaoConfig.getRerankApiHost())) {\n            return doubaoConfig.getRerankApiHost();\n        }\n        if (doubaoConfig != null && StringUtils.isNotBlank(doubaoConfig.getApiHost())) {\n            return doubaoConfig.getApiHost();\n        }\n        throw new IllegalArgumentException(\"doubao rerank apiHost is required\");\n    }\n\n    private String resolveApiKey(String apiKey) {\n        if (StringUtils.isNotBlank(apiKey)) {\n            return apiKey;\n        }\n        return doubaoConfig == null ? null : doubaoConfig.getApiKey();\n    }\n\n    private String resolveRerankUrl() {\n        if (doubaoConfig == null || StringUtils.isBlank(doubaoConfig.getRerankUrl())) {\n            throw new IllegalArgumentException(\"doubao rerankUrl is required\");\n        }\n        return doubaoConfig.getRerankUrl();\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (StringUtils.isNotBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private Integer intValue(JsonNode node) {\n        if (node == null || node.isNull()) {\n            return null;\n        }\n        return node.asInt();\n    }\n\n    private String text(JsonNode node, String fieldName) {\n        if (node == null || fieldName == null || !node.has(fieldName) || node.get(fieldName).isNull()) {\n            return null;\n        }\n        String text = node.get(fieldName).asText();\n        return StringUtils.isBlank(text) ? null : text;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/doubao/response/DoubaoResponsesService.java",
    "content": "package io.github.lnyocly.ai4j.platform.doubao.response;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.ResponseSseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.openai.response.ResponseEventParser;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\nimport io.github.lnyocly.ai4j.tool.ResponseRequestToolResolver;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\n/**\n * @Author cly\n * @Description Doubao Responses API service\n * @Date 2026/2/1\n */\npublic class DoubaoResponsesService implements IResponsesService {\n\n    private final DoubaoConfig doubaoConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n\n    public DoubaoResponsesService(Configuration configuration) {\n        this.doubaoConfig = configuration.getDoubaoConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n    }\n\n    @Override\n    public Response create(String baseUrl, String apiKey, ResponseRequest request) throws Exception {\n        String url = resolveUrl(baseUrl, doubaoConfig.getResponsesUrl());\n        String key = resolveApiKey(apiKey);\n        request.setStream(false);\n        request.setStreamOptions(null);\n        request = ResponseRequestToolResolver.resolve(request);\n\n        ObjectMapper mapper = new ObjectMapper();\n        String body = mapper.writeValueAsString(request);\n\n        Request httpRequest = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + key)\n                .url(url)\n                .post(RequestBody.create(body, MediaType.get(Constants.JSON_CONTENT_TYPE)))\n                .build();\n\n        try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return mapper.readValue(response.body().string(), Response.class);\n            }\n        }\n        throw new CommonException(\"Doubao Responses request failed\");\n    }\n\n    @Override\n    public Response create(ResponseRequest request) throws Exception {\n        return create(null, null, request);\n    }\n\n    @Override\n    public void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) throws Exception {\n        String url = resolveUrl(baseUrl, doubaoConfig.getResponsesUrl());\n        String key = resolveApiKey(apiKey);\n        if (request.getStream() == null || !request.getStream()) {\n            request.setStream(true);\n        }\n        request.setStreamOptions(null);\n        request = ResponseRequestToolResolver.resolve(request);\n\n        ObjectMapper mapper = new ObjectMapper();\n        String body = mapper.writeValueAsString(request);\n\n        Request httpRequest = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + key)\n                .url(url)\n                .post(RequestBody.create(body, MediaType.get(Constants.JSON_CONTENT_TYPE)))\n                .build();\n\n        StreamExecutionSupport.execute(\n                listener,\n                request.getStreamExecution(),\n                () -> factory.newEventSource(httpRequest, convertEventSource(mapper, listener))\n        );\n    }\n\n    @Override\n    public void createStream(ResponseRequest request, ResponseSseListener listener) throws Exception {\n        createStream(null, null, request, listener);\n    }\n\n    @Override\n    public Response retrieve(String baseUrl, String apiKey, String responseId) throws Exception {\n        String url = resolveUrl(baseUrl, doubaoConfig.getResponsesUrl() + \"/\" + responseId);\n        String key = resolveApiKey(apiKey);\n        ObjectMapper mapper = new ObjectMapper();\n\n        Request httpRequest = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + key)\n                .url(url)\n                .get()\n                .build();\n\n        try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return mapper.readValue(response.body().string(), Response.class);\n            }\n        }\n        throw new CommonException(\"Doubao Responses retrieve failed\");\n    }\n\n    @Override\n    public Response retrieve(String responseId) throws Exception {\n        return retrieve(null, null, responseId);\n    }\n\n    @Override\n    public ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) throws Exception {\n        String url = resolveUrl(baseUrl, doubaoConfig.getResponsesUrl() + \"/\" + responseId);\n        String key = resolveApiKey(apiKey);\n        ObjectMapper mapper = new ObjectMapper();\n\n        Request httpRequest = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + key)\n                .url(url)\n                .delete()\n                .build();\n\n        try (okhttp3.Response response = okHttpClient.newCall(httpRequest).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return mapper.readValue(response.body().string(), ResponseDeleteResponse.class);\n            }\n        }\n        throw new CommonException(\"Doubao Responses delete failed\");\n    }\n\n    @Override\n    public ResponseDeleteResponse delete(String responseId) throws Exception {\n        return delete(null, null, responseId);\n    }\n\n    private String resolveUrl(String baseUrl, String path) {\n        String host = (baseUrl == null || \"\".equals(baseUrl)) ? doubaoConfig.getApiHost() : baseUrl;\n        return UrlUtils.concatUrl(host, path);\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? doubaoConfig.getApiKey() : apiKey;\n    }\n\n    private EventSourceListener convertEventSource(ObjectMapper mapper, ResponseSseListener listener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull okhttp3.Response response) {\n                listener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable okhttp3.Response response) {\n                listener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    listener.complete();\n                    return;\n                }\n                try {\n                    ResponseStreamEvent event = ResponseEventParser.parse(mapper, data);\n                    listener.accept(event);\n                    if (isTerminalEvent(event.getType())) {\n                        listener.complete();\n                    }\n                } catch (Exception e) {\n                    listener.onError(e, null);\n                    listener.complete();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                listener.onClosed(eventSource);\n            }\n        };\n    }\n\n    private boolean isTerminalEvent(String type) {\n        if (type == null) {\n            return false;\n        }\n        return \"response.completed\".equals(type)\n                || \"response.failed\".equals(type)\n                || \"response.incomplete\".equals(type);\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/HunyuanConstant.java",
    "content": "package io.github.lnyocly.ai4j.platform.hunyuan;\n\n/**\n * @Author cly\n * @Description 腾讯混元常量\n * @Date 2024/8/30 19:30\n */\npublic class HunyuanConstant {\n\n    public static final String Version = \"2023-09-01\";\n\n    public static final String ChatCompletions = \"ChatCompletions\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/chat/HunyuanChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.hunyuan.chat;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.HunyuanConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.hunyuan.HunyuanConstant;\nimport io.github.lnyocly.ai4j.platform.hunyuan.chat.entity.HunyuanChatCompletion;\nimport io.github.lnyocly.ai4j.platform.hunyuan.chat.entity.HunyuanChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.*;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.auth.BearerTokenUtils;\nimport io.github.lnyocly.ai4j.platform.hunyuan.support.HunyuanJsonUtil;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport okhttp3.*;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.apache.commons.lang3.StringUtils;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 腾讯混元 Chat 服务\n * @Date 2024/8/30 19:24\n */\npublic class HunyuanChatService implements IChatService, ParameterConvert<HunyuanChatCompletion>, ResultConvert<HunyuanChatCompletionResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private final HunyuanConfig hunyuanConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n\n    public HunyuanChatService(Configuration configuration) {\n        this.hunyuanConfig = configuration.getHunyuanConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n    }\n\n    public HunyuanChatService(Configuration configuration, HunyuanConfig hunyuanConfig) {\n        this.hunyuanConfig = hunyuanConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n    }\n\n\n    @Override\n    public HunyuanChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        HunyuanChatCompletion hunyuanChatCompletion = new HunyuanChatCompletion();\n        hunyuanChatCompletion.setModel(chatCompletion.getModel());\n        hunyuanChatCompletion.setMessages(chatCompletion.getMessages());\n        hunyuanChatCompletion.setStream(chatCompletion.getStream());\n        hunyuanChatCompletion.setTemperature(chatCompletion.getTemperature());\n        hunyuanChatCompletion.setTopP(chatCompletion.getTopP());\n        hunyuanChatCompletion.setTools(chatCompletion.getTools());\n        hunyuanChatCompletion.setFunctions(chatCompletion.getFunctions());\n        hunyuanChatCompletion.setToolChoice(chatCompletion.getToolChoice());\n        hunyuanChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n        return hunyuanChatCompletion;\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(SseListener eventSourceListener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n\n                ObjectMapper mapper = new ObjectMapper();\n                HunyuanChatCompletionResponse hunyuanChatCompletionResponse = null;\n                try {\n                    hunyuanChatCompletionResponse = mapper.readValue(HunyuanJsonUtil.toSnakeCaseJson(data), HunyuanChatCompletionResponse.class);\n                } catch (JsonProcessingException e) {\n                    throw new CommonException(\"解析混元Hunyuan Chat Completion Response失败\");\n                }\n\n\n                ChatCompletionResponse response = convertChatCompletionResponse(hunyuanChatCompletionResponse);\n                response.setObject(\"chat.completion.chunk\");\n\n                Choice choice = response.getChoices().get(0);\n                if(eventSourceListener.getToolCall()!=null){\n                    if(choice.getDelta().getToolCalls()!=null){\n                        choice.getDelta().getToolCalls().get(0).setId(null);\n                    }\n                }\n\n                if(StringUtils.isBlank(choice.getFinishReason())){\n                    response.setUsage(null);\n                }\n\n\n                if(\"tool_calls\".equals(choice.getFinishReason())){\n                    //eventSourceListener.setToolCall(null);\n                    //this.onClosed(eventSource);\n                }\n\n                eventSourceListener.onEvent(eventSource, id, type, JSON.toJSONString(response));\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(HunyuanChatCompletionResponse hunyuanChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(hunyuanChatCompletionResponse.getId());\n        chatCompletionResponse.setObject(hunyuanChatCompletionResponse.getObject());\n        chatCompletionResponse.setCreated(Long.valueOf(hunyuanChatCompletionResponse.getCreated()));\n        chatCompletionResponse.setModel(hunyuanChatCompletionResponse.getModel());\n        chatCompletionResponse.setChoices(hunyuanChatCompletionResponse.getChoices());\n        chatCompletionResponse.setUsage(hunyuanChatCompletionResponse.getUsage());\n        return chatCompletionResponse;\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n        if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = hunyuanConfig.getApiHost();\n        if(apiKey == null || \"\".equals(apiKey)) apiKey = hunyuanConfig.getApiKey();\n        boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n        chatCompletion.setStream(false);\n\n\n        if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){\n            //List<Tool> tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions());\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if(tools == null){\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){\n\n        }else{\n            chatCompletion.setParallelToolCalls(null);\n        }\n\n\n        // 转换 请求参数\n        HunyuanChatCompletion hunyuanChatCompletion = this.convertChatCompletionObject(chatCompletion);\n\n        // 如含有function，则添加tool\n/*        if(hunyuanChatCompletion.getFunctions()!=null && !hunyuanChatCompletion.getFunctions().isEmpty()){\n            List<Tool> tools = ToolUtil.getAllFunctionTools(hunyuanChatCompletion.getFunctions());\n            hunyuanChatCompletion.setTools(tools);\n        }*/\n\n        // 总token消耗\n        Usage allUsage = new Usage();\n\n        String finishReason = \"first\";\n\n        while(\"first\".equals(finishReason) || \"tool_calls\".equals(finishReason)){\n\n            finishReason = null;\n\n            // 构造请求\n            ObjectMapper mapper = new ObjectMapper();\n            String requestString = mapper.writeValueAsString(hunyuanChatCompletion);\n\n            // 整理tools\n            JSONObject jsonObject = JSON.parseObject(requestString);\n            // 获取 Tools 数组\n            JSONArray toolsArray = jsonObject.getJSONArray(\"tools\");\n            if(toolsArray!= null && !toolsArray.isEmpty()){\n                // 遍历并修改 Tools 中的每个对象\n                for (int i = 0; i < toolsArray.size(); i++) {\n                    JSONObject tool = toolsArray.getJSONObject(i);\n\n                    // 重新构建 Function 对象\n                    JSONObject function = tool.getJSONObject(\"function\");\n                    JSONObject newFunction = new JSONObject();\n\n                    newFunction.put(\"name\", function.getString(\"name\"));\n                    newFunction.put(\"description\", function.getString(\"description\"));\n                    newFunction.put(\"parameters\", function.getJSONObject(\"parameters\").toJSONString());\n\n                    // 替换旧的 Function 对象\n                    tool.put(\"function\", newFunction);\n                    tool.put(\"type\", \"function\");\n                }\n            }\n\n            /**\n             * Messages 中 Contents 字段仅 hunyuan-vision 模型支持\n             * hunyuan模型，识图多模态类只能放在Contents字段\n             */\n            if(\"hunyuan-vision\".equals(chatCompletion.getModel())){\n                // 获取所有的content字段\n                JSONArray messagesArray = jsonObject.getJSONArray(\"messages\");\n                if(messagesArray!= null && !messagesArray.isEmpty()){\n                    for (int i = 0; i < messagesArray.size(); i++) {\n                        JSONObject message = messagesArray.getJSONObject(i);\n                        // 获取当前message的content字段\n                        String content = message.getString(\"content\");\n                        // 将content内容，判断是否可以转换为ChatMessage.MultiModal类型\n                        if(content!=null && content.startsWith(\"[\") && content.endsWith(\"]\")) {\n                            List<Content.MultiModal> multiModals = JSON.parseArray(content, Content.MultiModal.class);\n                            if(multiModals!=null && !multiModals.isEmpty()){\n                                // 将当前的content转换为contents\n                                message.put(\"contents\", multiModals);\n                                // 删除原来的content key\n                                message.remove(\"content\");\n                            }\n                        }\n                    }\n                }\n            }\n\n            // 将修改后的 JSON 对象转为字符串\n            requestString = jsonObject.toJSONString();\n            requestString = HunyuanJsonUtil.toCamelCaseWithUppercaseJson(requestString);\n            String authorization = BearerTokenUtils.getAuthorization(apiKey,HunyuanConstant.ChatCompletions,requestString);\n\n            Request request = new Request.Builder()\n                    .header(\"Authorization\", authorization)\n                    .header(\"X-TC-Action\", HunyuanConstant.ChatCompletions)\n                    .header(\"X-TC-Version\", HunyuanConstant.Version)\n                    .header(\"X-TC-Timestamp\", String.valueOf(System.currentTimeMillis() / 1000))\n                    .url(baseUrl)\n                    .post(RequestBody.create(requestString, JSON_MEDIA_TYPE))\n                    .build();\n\n            Response execute = okHttpClient.newCall(request).execute();\n            if (execute.isSuccessful() && execute.body() != null){\n                String responseString = execute.body().string();\n                responseString = HunyuanJsonUtil.toSnakeCaseJson(responseString);\n                responseString = JSON.parseObject(responseString).get(\"response\").toString();\n\n                HunyuanChatCompletionResponse hunyuanChatCompletionResponse = mapper.readValue(responseString, HunyuanChatCompletionResponse.class);\n\n                Choice choice = hunyuanChatCompletionResponse.getChoices().get(0);\n                finishReason = choice.getFinishReason();\n\n                Usage usage = hunyuanChatCompletionResponse.getUsage();\n                allUsage.setCompletionTokens(allUsage.getCompletionTokens() + usage.getCompletionTokens());\n                allUsage.setTotalTokens(allUsage.getTotalTokens() + usage.getTotalTokens());\n                allUsage.setPromptTokens(allUsage.getPromptTokens() + usage.getPromptTokens());\n\n                // 判断是否为函数调用返回\n                if(\"tool_calls\".equals(finishReason)){\n                    if (passThroughToolCalls) {\n                        hunyuanChatCompletionResponse.setUsage(allUsage);\n                        hunyuanChatCompletionResponse.setObject(\"chat.completion\");\n                        hunyuanChatCompletionResponse.setModel(hunyuanChatCompletion.getModel());\n                        return this.convertChatCompletionResponse(hunyuanChatCompletionResponse);\n                    }\n                    ChatMessage message = choice.getMessage();\n                    List<ToolCall> toolCalls = message.getToolCalls();\n\n                    List<ChatMessage> messages = new ArrayList<>(hunyuanChatCompletion.getMessages());\n                    messages.add(message);\n\n                    // 添加 tool 消息\n                    for (ToolCall toolCall : toolCalls) {\n                        String functionName = toolCall.getFunction().getName();\n                        String arguments = toolCall.getFunction().getArguments();\n                        String functionResponse = ToolUtil.invoke(functionName, arguments);\n\n                        messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n                    }\n                    hunyuanChatCompletion.setMessages(messages);\n\n                }else{// 其他情况直接返回\n\n                    // 设置包含tool的总token数\n                    hunyuanChatCompletionResponse.setUsage(allUsage);\n                    hunyuanChatCompletionResponse.setObject(\"chat.completion\");\n                    hunyuanChatCompletionResponse.setModel(hunyuanChatCompletion.getModel());\n\n                    // 恢复原始请求数据\n                    chatCompletion.setMessages(hunyuanChatCompletion.getMessages());\n                    chatCompletion.setTools(hunyuanChatCompletion.getTools());\n\n                    return this.convertChatCompletionResponse(hunyuanChatCompletionResponse);\n\n                }\n\n            }\n\n        }\n\n\n\n        return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return this.chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n        if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = hunyuanConfig.getApiHost();\n        if(apiKey == null || \"\".equals(apiKey)) apiKey = hunyuanConfig.getApiKey();\n        chatCompletion.setStream(true);\n        boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n        if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){\n            //List<Tool> tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions());\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if(tools == null){\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){\n\n        }else{\n            chatCompletion.setParallelToolCalls(null);\n        }\n\n\n        // 转换 请求参数\n        HunyuanChatCompletion hunyuanChatCompletion = this.convertChatCompletionObject(chatCompletion);\n\n/*        // 如含有function，则添加tool\n        if(hunyuanChatCompletion.getFunctions()!=null && !hunyuanChatCompletion.getFunctions().isEmpty()){\n            List<Tool> tools = ToolUtil.getAllFunctionTools(hunyuanChatCompletion.getFunctions());\n            hunyuanChatCompletion.setTools(tools);\n        }*/\n\n        String finishReason = \"first\";\n\n        while(\"first\".equals(finishReason) || \"tool_calls\".equals(finishReason)){\n\n            finishReason = null;\n\n            // 构造请求\n            ObjectMapper mapper = new ObjectMapper();\n            String requestString = mapper.writeValueAsString(hunyuanChatCompletion);\n\n            // 整理tools\n            JSONObject jsonObject = JSON.parseObject(requestString);\n            // 获取 Tools 数组\n            JSONArray toolsArray = jsonObject.getJSONArray(\"tools\");\n            if(toolsArray!= null && !toolsArray.isEmpty()){\n                // 遍历并修改 Tools 中的每个对象\n                for (int i = 0; i < toolsArray.size(); i++) {\n                    JSONObject tool = toolsArray.getJSONObject(i);\n\n                    // 重新构建 Function 对象\n                    JSONObject function = tool.getJSONObject(\"function\");\n                    JSONObject newFunction = new JSONObject();\n\n                    newFunction.put(\"name\", function.getString(\"name\"));\n                    newFunction.put(\"description\", function.getString(\"description\"));\n                    newFunction.put(\"parameters\", function.getJSONObject(\"parameters\").toJSONString());\n\n                    // 替换旧的 Function 对象\n                    tool.put(\"function\", newFunction);\n                    tool.put(\"type\", \"function\");\n                }\n            }\n\n            /**\n             * Messages 中 Contents 字段仅 hunyuan-vision 模型支持\n             * hunyuan模型，识图多模态类只能放在Contents字段\n             */\n            if(\"hunyuan-vision\".equals(chatCompletion.getModel())){\n                // 获取所有的content字段\n                JSONArray messagesArray = jsonObject.getJSONArray(\"messages\");\n                if(messagesArray!= null && !messagesArray.isEmpty()){\n                    for (int i = 0; i < messagesArray.size(); i++) {\n                        JSONObject message = messagesArray.getJSONObject(i);\n                        // 获取当前message的content字段\n                        String content = message.getString(\"content\");\n                        // 将content内容，判断是否可以转换为ChatMessage.MultiModal类型\n                        if(content!=null && content.startsWith(\"[\") && content.endsWith(\"]\")) {\n                            List<Content.MultiModal> multiModals = JSON.parseArray(content, Content.MultiModal.class);\n                            if(multiModals!=null && !multiModals.isEmpty()){\n                                // 将当前的content转换为contents\n                                message.put(\"contents\", multiModals);\n                                // 删除原来的content key\n                                message.remove(\"content\");\n                            }\n                        }\n                    }\n                }\n            }\n\n            // 将修改后的 JSON 对象转为字符串\n            requestString = jsonObject.toJSONString();\n            requestString = HunyuanJsonUtil.toCamelCaseWithUppercaseJson(requestString);\n            String authorization = BearerTokenUtils.getAuthorization(apiKey,HunyuanConstant.ChatCompletions,requestString);\n\n            Request request = new Request.Builder()\n                    .header(\"Authorization\", authorization)\n                    .header(\"X-TC-Action\", HunyuanConstant.ChatCompletions)\n                    .header(\"X-TC-Version\", HunyuanConstant.Version)\n                    .header(\"X-TC-Timestamp\", String.valueOf(System.currentTimeMillis() / 1000))\n                    .header(\"Accept\", Constants.SSE_CONTENT_TYPE)\n                    .url(baseUrl)\n                    .post(RequestBody.create(requestString, JSON_MEDIA_TYPE))\n                    .build();\n\n            StreamExecutionSupport.execute(\n                    eventSourceListener,\n                    chatCompletion.getStreamExecution(),\n                    () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n            );\n\n            finishReason = eventSourceListener.getFinishReason();\n            List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n\n            // 需要调用函数\n            if(\"tool_calls\".equals(finishReason) && !toolCalls.isEmpty()){\n                if (passThroughToolCalls) {\n                    return;\n                }\n                // 创建tool响应消息\n                ChatMessage responseMessage = ChatMessage.withAssistant(eventSourceListener.getToolCalls());\n                responseMessage.setContent(Content.ofText(\" \"));\n\n                List<ChatMessage> messages = new ArrayList<>(hunyuanChatCompletion.getMessages());\n                messages.add(responseMessage);\n\n                // 封装tool结果消息\n                for (ToolCall toolCall : toolCalls) {\n                    String functionName = toolCall.getFunction().getName();\n                    String arguments = toolCall.getFunction().getArguments();\n                    String functionResponse = ToolUtil.invoke(functionName, arguments);\n\n                    messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n                }\n                eventSourceListener.setToolCalls(new ArrayList<>());\n                eventSourceListener.setToolCall(null);\n                hunyuanChatCompletion.setMessages(messages);\n            }\n\n        }\n\n        // 补全原始请求\n        chatCompletion.setMessages(hunyuanChatCompletion.getMessages());\n        chatCompletion.setTools(hunyuanChatCompletion.getTools());\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/chat/entity/HunyuanChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.hunyuan.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.deepseek.chat.entity.DeepSeekChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport lombok.Singular;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description 腾讯混元 chat请求实体类\n * @Date 2024/8/30 19:26\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class HunyuanChatCompletion {\n\n    private String model;\n    private List<ChatMessage> messages;\n\n    @Builder.Default\n    private Boolean stream = false;\n\n    /**\n     * 采样温度，介于 0 和 2 之间。更高的值，如 0.8，会使输出更随机，而更低的值，如 0.2，会使其更加集中和确定。\n     * 我们通常建议可以更改这个值或者更改 top_p，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    private Float temperature = 1f;\n\n    /**\n     * 作为调节采样温度的替代方案，模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。\n     * 我们通常建议修改这个值或者更改 temperature，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 1f;\n\n\n    /**\n     * 模型可能会调用的 tool 的列表。目前，仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。\n     */\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    /**\n     * 控制模型调用 tool 的行为。\n     * none 意味着模型不会调用任何 tool，而是生成一条消息。\n     * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。\n     * 当没有 tool 时，默认值为 none。如果有 tool 存在，默认值为 auto。\n     */\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n    public static class HunyuanChatCompletionBuilder {\n        private List<String> functions;\n\n        public HunyuanChatCompletion.HunyuanChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public HunyuanChatCompletion.HunyuanChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(functions);\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/chat/entity/HunyuanChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.hunyuan.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 腾讯混元响应实体类\n * @Date 2024/8/30 19:27\n */\n\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class HunyuanChatCompletionResponse {\n    /**\n     * Unix 时间戳，单位为秒。\n     */\n    private String created;\n\n    /**\n     * Token 统计信息。\n     * 按照总 Token 数量计费。\n     */\n    private Usage usage;\n\n    /**\n     * 免责声明。\n     * 示例值：以上内容为AI生成，不代表开发者立场，请勿删除或修改本标记\n     */\n    private String note;\n\n    /**\n     * \t本次请求的 RequestId。\n     */\n    private String id;\n\n    /**\n     * 回复内容\n     */\n    private List<Choice> choices;\n\n    @JsonProperty(\"request_id\")\n    private String requestId;\n\n    // 下面为额外补充\n    private String object;\n    private String model;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/hunyuan/support/HunyuanJsonUtil.java",
    "content": "package io.github.lnyocly.ai4j.platform.hunyuan.support;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\n\nimport java.util.Map;\nimport java.util.Set;\n\n/**\n * @Author cly\n * @Description 用于JSON字符串驼峰转换的工具类\n * @Date 2024/8/30 23:12\n */\npublic final class HunyuanJsonUtil {\n\n    // 将 JSON 字符串的字段转换为大写驼峰形式\n    public static String toCamelCaseWithUppercaseJson(String json) {\n        return convertJsonString(json, true);\n    }\n\n    // 将 JSON 字符串的字段转换为下划线形式\n    public static String toSnakeCaseJson(String json) {\n        return convertJsonString(json, false);\n    }\n\n    // 根据参数 isCamelCase 决定转换为驼峰命名还是下划线命名\n    private static String convertJsonString(String json, boolean isCamelCase) {\n        JSONObject jsonObject = JSON.parseObject(json);\n        JSONObject newJsonObject = processJsonObject(jsonObject, isCamelCase);\n        return newJsonObject.toJSONString();\n    }\n\n    private static JSONObject processJsonObject(JSONObject jsonObject, boolean isCamelCase) {\n        JSONObject newJsonObject = new JSONObject();\n        Set<Map.Entry<String, Object>> entries = jsonObject.entrySet();\n        for (Map.Entry<String, Object> entry : entries) {\n            String newKey = isCamelCase ? toCamelCaseWithUppercase(entry.getKey()) : toSnakeCase(entry.getKey());\n            Object value = entry.getValue();\n\n            if (value instanceof JSONObject) {\n                value = processJsonObject((JSONObject) value, isCamelCase);\n            } else if (value instanceof JSONArray) {\n                value = processJsonArray((JSONArray) value, isCamelCase);\n            }\n\n            newJsonObject.put(newKey, value);\n        }\n        return newJsonObject;\n    }\n\n    private static JSONArray processJsonArray(JSONArray jsonArray, boolean isCamelCase) {\n        JSONArray newArray = new JSONArray();\n        for (Object element : jsonArray) {\n            if (element instanceof JSONObject) {\n                newArray.add(processJsonObject((JSONObject) element, isCamelCase));\n            } else if (element instanceof JSONArray) {\n                newArray.add(processJsonArray((JSONArray) element, isCamelCase));\n            } else {\n                newArray.add(element);\n            }\n        }\n        return newArray;\n    }\n\n    private static String toCamelCaseWithUppercase(String key) {\n        String[] parts = key.split(\"_\");\n        StringBuilder camelCaseKey = new StringBuilder();\n        for (String part : parts) {\n            if (part.length() > 0) {\n                camelCaseKey.append(part.substring(0, 1).toUpperCase())\n                        .append(part.substring(1).toLowerCase());\n            }\n        }\n        return camelCaseKey.toString();\n    }\n\n    private static String toSnakeCase(String key) {\n        StringBuilder result = new StringBuilder();\n        for (int i = 0; i < key.length(); i++) {\n            char c = key.charAt(i);\n            if (Character.isUpperCase(c)) {\n                if (i > 0) {\n                    result.append(\"_\");\n                }\n                result.append(Character.toLowerCase(c));\n            } else {\n                result.append(c);\n            }\n        }\n        return result.toString();\n    }\n\n    private HunyuanJsonUtil() {\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/jina/rerank/JinaRerankService.java",
    "content": "package io.github.lnyocly.ai4j.platform.jina.rerank;\n\nimport io.github.lnyocly.ai4j.config.JinaConfig;\nimport io.github.lnyocly.ai4j.platform.standard.rerank.StandardRerankService;\nimport io.github.lnyocly.ai4j.service.Configuration;\n\npublic class JinaRerankService extends StandardRerankService {\n\n    public JinaRerankService(Configuration configuration) {\n        this(configuration, configuration == null ? null : configuration.getJinaConfig());\n    }\n\n    public JinaRerankService(Configuration configuration, JinaConfig jinaConfig) {\n        super(configuration == null ? null : configuration.getOkHttpClient(),\n                jinaConfig == null ? null : jinaConfig.getApiHost(),\n                jinaConfig == null ? null : jinaConfig.getApiKey(),\n                jinaConfig == null ? null : jinaConfig.getRerankUrl());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/lingyi/chat/LingyiChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.lingyi.chat;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.LingyiConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.lingyi.chat.entity.LingyiChatCompletion;\nimport io.github.lnyocly.ai4j.platform.lingyi.chat.entity.LingyiChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 零一万物 chat服务\n * @Date 2024/9/9 23:00\n */\npublic class LingyiChatService implements IChatService, ParameterConvert<LingyiChatCompletion>, ResultConvert<LingyiChatCompletionResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n    private static final String TOOL_CALLS_FINISH_REASON = \"tool_calls\";\n    private static final String FIRST_FINISH_REASON = \"first\";\n\n    private final LingyiConfig lingyiConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n    private final ObjectMapper objectMapper;\n\n    public LingyiChatService(Configuration configuration) {\n        this.lingyiConfig = configuration.getLingyiConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    public LingyiChatService(Configuration configuration, LingyiConfig lingyiConfig) {\n        this.lingyiConfig = lingyiConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    @Override\n    public LingyiChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        LingyiChatCompletion lingyiChatCompletion = new LingyiChatCompletion();\n        lingyiChatCompletion.setModel(chatCompletion.getModel());\n        lingyiChatCompletion.setMessages(chatCompletion.getMessages());\n        lingyiChatCompletion.setTools(chatCompletion.getTools());\n        lingyiChatCompletion.setFunctions(chatCompletion.getFunctions());\n        lingyiChatCompletion.setToolChoice(chatCompletion.getToolChoice());\n        lingyiChatCompletion.setTemperature(chatCompletion.getTemperature());\n        lingyiChatCompletion.setTopP(chatCompletion.getTopP());\n        lingyiChatCompletion.setStream(chatCompletion.getStream());\n        lingyiChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion));\n        lingyiChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n        return lingyiChatCompletion;\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(final SseListener eventSourceListener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n                eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data));\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(LingyiChatCompletionResponse lingyiChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(lingyiChatCompletionResponse.getId());\n        chatCompletionResponse.setObject(lingyiChatCompletionResponse.getObject());\n        chatCompletionResponse.setCreated(lingyiChatCompletionResponse.getCreated());\n        chatCompletionResponse.setModel(lingyiChatCompletionResponse.getModel());\n        chatCompletionResponse.setChoices(lingyiChatCompletionResponse.getChoices());\n        chatCompletionResponse.setUsage(lingyiChatCompletionResponse.getUsage());\n        return chatCompletionResponse;\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, false);\n            LingyiChatCompletion lingyiChatCompletion = convertChatCompletionObject(chatCompletion);\n            Usage allUsage = new Usage();\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                LingyiChatCompletionResponse response = executeChatCompletionRequest(\n                        resolvedBaseUrl,\n                        resolvedApiKey,\n                        lingyiChatCompletion\n                );\n                if (response == null) {\n                    break;\n                }\n\n                Choice choice = response.getChoices().get(0);\n                finishReason = choice.getFinishReason();\n                mergeUsage(allUsage, response.getUsage());\n\n                if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) {\n                    if (passThroughToolCalls) {\n                        response.setUsage(allUsage);\n                        restoreOriginalRequest(chatCompletion, lingyiChatCompletion);\n                        return convertChatCompletionResponse(response);\n                    }\n                    lingyiChatCompletion.setMessages(appendToolMessages(\n                            lingyiChatCompletion.getMessages(),\n                            choice.getMessage(),\n                            choice.getMessage().getToolCalls()\n                    ));\n                    continue;\n                }\n\n                response.setUsage(allUsage);\n                restoreOriginalRequest(chatCompletion, lingyiChatCompletion);\n                return convertChatCompletionResponse(response);\n            }\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return this.chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, true);\n            LingyiChatCompletion lingyiChatCompletion = convertChatCompletionObject(chatCompletion);\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, lingyiChatCompletion);\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n                if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) {\n                    continue;\n                }\n                if (passThroughToolCalls) {\n                    return;\n                }\n\n                lingyiChatCompletion.setMessages(appendStreamToolMessages(\n                        lingyiChatCompletion.getMessages(),\n                        toolCalls\n                ));\n                resetToolCallState(eventSourceListener);\n            }\n\n            restoreOriginalRequest(chatCompletion, lingyiChatCompletion);\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    private String serializeStreamResponse(String data) {\n        try {\n            LingyiChatCompletionResponse chatCompletionResponse =\n                    objectMapper.readValue(data, LingyiChatCompletionResponse.class);\n            ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse);\n            return objectMapper.writeValueAsString(response);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"Lingyi Chat 对象JSON序列化出错\");\n        }\n    }\n\n    private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) {\n        chatCompletion.setStream(stream);\n        if (!stream) {\n            chatCompletion.setStreamOptions(null);\n        }\n        attachFunctionTools(chatCompletion);\n    }\n\n    private void attachFunctionTools(ChatCompletion chatCompletion) {\n        if (chatCompletion.getFunctions() == null || chatCompletion.getFunctions().isEmpty()) {\n            return;\n        }\n        List<Tool> tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions());\n        chatCompletion.setTools(tools);\n    }\n\n    private boolean requiresFollowUp(String finishReason) {\n        return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason);\n    }\n\n    private LingyiChatCompletionResponse executeChatCompletionRequest(\n            String baseUrl,\n            String apiKey,\n            LingyiChatCompletion lingyiChatCompletion\n    ) throws Exception {\n        Request request = buildChatCompletionRequest(baseUrl, apiKey, lingyiChatCompletion);\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return objectMapper.readValue(response.body().string(), LingyiChatCompletionResponse.class);\n            }\n        }\n        return null;\n    }\n\n    private Request buildChatCompletionRequest(String baseUrl, String apiKey, LingyiChatCompletion lingyiChatCompletion)\n            throws JsonProcessingException {\n        String requestBody = objectMapper.writeValueAsString(lingyiChatCompletion);\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, lingyiConfig.getChatCompletionUrl()))\n                .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE))\n                .build();\n    }\n\n    private void mergeUsage(Usage target, Usage usage) {\n        if (usage == null) {\n            return;\n        }\n        target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens());\n        target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens());\n        target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens());\n    }\n\n    private List<ChatMessage> appendToolMessages(\n            List<ChatMessage> messages,\n            ChatMessage assistantMessage,\n            List<ToolCall> toolCalls\n    ) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(assistantMessage);\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private List<ChatMessage> appendStreamToolMessages(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(ChatMessage.withAssistant(toolCalls));\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private void appendToolResponses(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        for (ToolCall toolCall : toolCalls) {\n            String functionName = toolCall.getFunction().getName();\n            String arguments = toolCall.getFunction().getArguments();\n            String functionResponse = ToolUtil.invoke(functionName, arguments);\n            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n        }\n    }\n\n    private void resetToolCallState(SseListener eventSourceListener) {\n        eventSourceListener.setToolCalls(new ArrayList<ToolCall>());\n        eventSourceListener.setToolCall(null);\n    }\n\n    private void restoreOriginalRequest(ChatCompletion chatCompletion, LingyiChatCompletion lingyiChatCompletion) {\n        chatCompletion.setMessages(lingyiChatCompletion.getMessages());\n        chatCompletion.setTools(lingyiChatCompletion.getTools());\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        return (baseUrl == null || \"\".equals(baseUrl)) ? lingyiConfig.getApiHost() : baseUrl;\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? lingyiConfig.getApiKey() : apiKey;\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    private Integer resolveMaxTokens(ChatCompletion chatCompletion) {\n        if (chatCompletion.getMaxCompletionTokens() != null) {\n            return chatCompletion.getMaxCompletionTokens();\n        }\n        return chatCompletion.getMaxTokens();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/lingyi/chat/entity/LingyiChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.lingyi.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport lombok.*;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description 零一万物对话请求实体\n * @Date 2024/9/9 23:01\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class LingyiChatCompletion {\n\n    @NonNull\n    private String model;\n\n    @NonNull\n    private List<ChatMessage> messages;\n\n    /**\n     * 模型可能会调用的 tool 的列表。目前，仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。\n     */\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    /**\n     * 控制模型调用 tool 的行为。\n     * none 意味着模型不会调用任何 tool，而是生成一条消息。\n     * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。\n     * 当没有 tool 时，默认值为 none。如果有 tool 存在，默认值为 auto。\n     */\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    /**\n     * 采样温度，介于 0 和 2 之间。更高的值，如 0.8，会使输出更随机，而更低的值，如 0.2，会使其更加集中和确定。\n     * 我们通常建议可以更改这个值或者更改 top_p，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    private Float temperature = 1f;\n\n    /**\n     * 作为调节采样温度的替代方案，模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。\n     * 我们通常建议修改这个值或者更改 temperature，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 1f;\n\n    /**\n     * 如果设置为 True，将会以 SSE（server-sent events）的形式以流式发送消息增量。消息流以 data: [DONE] 结尾\n     */\n    @Builder.Default\n    private Boolean stream = false;\n\n\n    /**\n     * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。\n     */\n    @JsonProperty(\"max_tokens\")\n    private Integer maxTokens;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n    public static class LingyiChatCompletionBuilder {\n        private List<String> functions;\n\n        public LingyiChatCompletion.LingyiChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public LingyiChatCompletion.LingyiChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            if (functions != null) {\n                this.functions.addAll(functions);\n            }\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/lingyi/chat/entity/LingyiChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.lingyi.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 零一万物对话响应实体\n * @Date 2024/9/9 23:02\n */\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class LingyiChatCompletionResponse {\n    /**\n     * 该对话的唯一标识符。\n     */\n    private String id;\n\n    /**\n     * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk\n     */\n    private String object;\n\n    /**\n     * 创建聊天完成时的 Unix 时间戳（以秒为单位）。\n     */\n    private Long created;\n\n    /**\n     * 生成该 completion 的模型名。\n     */\n    private String model;\n\n    /**\n     * 模型生成的 completion 的选择列表。\n     */\n    private List<Choice> choices;\n\n    /**\n     * 该对话补全请求的用量信息。\n     */\n    private Usage usage;\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/minimax/chat/MinimaxChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.minimax.chat;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.MinimaxConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletion;\nimport io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Collections;\n\n/**\n * @Author : isxuwl\n * @Date: 2024/10/15 16:24\n * @Model Description:\n * @Description: Minimax\n */\npublic class MinimaxChatService implements IChatService, ParameterConvert<MinimaxChatCompletion>, ResultConvert<MinimaxChatCompletionResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n    private static final String TOOL_CALLS_FINISH_REASON = \"tool_calls\";\n    private static final String FIRST_FINISH_REASON = \"first\";\n\n    private final MinimaxConfig minimaxConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n    private final ObjectMapper objectMapper;\n\n    public MinimaxChatService(Configuration configuration) {\n        this.minimaxConfig = configuration.getMinimaxConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    public MinimaxChatService(Configuration configuration, MinimaxConfig minimaxConfig) {\n        this.minimaxConfig = minimaxConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    @Override\n    public MinimaxChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        MinimaxChatCompletion minimaxChatCompletion = new MinimaxChatCompletion();\n        minimaxChatCompletion.setModel(chatCompletion.getModel());\n        minimaxChatCompletion.setMessages(chatCompletion.getMessages());\n        minimaxChatCompletion.setTools(chatCompletion.getTools());\n        minimaxChatCompletion.setFunctions(chatCompletion.getFunctions());\n        minimaxChatCompletion.setToolChoice(chatCompletion.getToolChoice());\n        minimaxChatCompletion.setTemperature(chatCompletion.getTemperature());\n        minimaxChatCompletion.setTopP(chatCompletion.getTopP());\n        minimaxChatCompletion.setStream(chatCompletion.getStream());\n        minimaxChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion));\n        minimaxChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n        return minimaxChatCompletion;\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(final SseListener eventSourceListener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n                eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data));\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(MinimaxChatCompletionResponse minimaxChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(minimaxChatCompletionResponse.getId());\n        chatCompletionResponse.setObject(minimaxChatCompletionResponse.getObject());\n        chatCompletionResponse.setCreated(minimaxChatCompletionResponse.getCreated());\n        chatCompletionResponse.setModel(minimaxChatCompletionResponse.getModel());\n        chatCompletionResponse.setChoices(minimaxChatCompletionResponse.getChoices());\n        chatCompletionResponse.setUsage(minimaxChatCompletionResponse.getUsage());\n        return chatCompletionResponse;\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, false);\n            MinimaxChatCompletion minimaxChatCompletion = convertChatCompletionObject(chatCompletion);\n            Usage allUsage = new Usage();\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                MinimaxChatCompletionResponse response = executeChatCompletionRequest(\n                        resolvedBaseUrl,\n                        resolvedApiKey,\n                        minimaxChatCompletion\n                );\n                if (response == null) {\n                    break;\n                }\n\n                List<Choice> choices = response.getChoices();\n                if (choices == null || choices.isEmpty()) {\n                    response.setUsage(allUsage);\n                    restoreOriginalRequest(chatCompletion, minimaxChatCompletion);\n                    return convertChatCompletionResponse(response);\n                }\n\n                Choice choice = choices.get(0);\n                finishReason = choice.getFinishReason();\n                mergeUsage(allUsage, response.getUsage());\n\n                if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) {\n                    if (passThroughToolCalls) {\n                        response.setUsage(allUsage);\n                        restoreOriginalRequest(chatCompletion, minimaxChatCompletion);\n                        return convertChatCompletionResponse(response);\n                    }\n                    minimaxChatCompletion.setMessages(appendToolMessages(\n                            minimaxChatCompletion.getMessages(),\n                            choice.getMessage(),\n                            choice.getMessage() == null ? Collections.<ToolCall>emptyList() : choice.getMessage().getToolCalls()\n                    ));\n                    continue;\n                }\n\n                response.setUsage(allUsage);\n                restoreOriginalRequest(chatCompletion, minimaxChatCompletion);\n                return convertChatCompletionResponse(response);\n            }\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return this.chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, true);\n            MinimaxChatCompletion minimaxChatCompletion = convertChatCompletionObject(chatCompletion);\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, minimaxChatCompletion);\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n                if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) {\n                    continue;\n                }\n                if (passThroughToolCalls) {\n                    return;\n                }\n\n                minimaxChatCompletion.setMessages(appendStreamToolMessages(\n                        minimaxChatCompletion.getMessages(),\n                        toolCalls\n                ));\n                resetToolCallState(eventSourceListener);\n            }\n\n            restoreOriginalRequest(chatCompletion, minimaxChatCompletion);\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    private String serializeStreamResponse(String data) {\n        try {\n            MinimaxChatCompletionResponse chatCompletionResponse =\n                    objectMapper.readValue(data, MinimaxChatCompletionResponse.class);\n            ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse);\n            return objectMapper.writeValueAsString(response);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"Minimax Chat 对象JSON序列化出错\");\n        }\n    }\n\n    private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) {\n        chatCompletion.setStream(stream);\n        if (!stream) {\n            chatCompletion.setStreamOptions(null);\n        }\n        attachTools(chatCompletion);\n    }\n\n    private void attachTools(ChatCompletion chatCompletion) {\n        if (hasPendingTools(chatCompletion)) {\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if (tools == null) {\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) {\n            chatCompletion.setParallelToolCalls(null);\n        }\n    }\n\n    private boolean hasPendingTools(ChatCompletion chatCompletion) {\n        return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty())\n                || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty());\n    }\n\n    private boolean requiresFollowUp(String finishReason) {\n        return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason);\n    }\n\n    private MinimaxChatCompletionResponse executeChatCompletionRequest(\n            String baseUrl,\n            String apiKey,\n            MinimaxChatCompletion minimaxChatCompletion\n    ) throws Exception {\n        Request request = buildChatCompletionRequest(baseUrl, apiKey, minimaxChatCompletion);\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return objectMapper.readValue(response.body().string(), MinimaxChatCompletionResponse.class);\n            }\n        }\n        return null;\n    }\n\n    private Request buildChatCompletionRequest(String baseUrl, String apiKey, MinimaxChatCompletion minimaxChatCompletion)\n            throws JsonProcessingException {\n        String requestBody = objectMapper.writeValueAsString(minimaxChatCompletion);\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, minimaxConfig.getChatCompletionUrl()))\n                .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE))\n                .build();\n    }\n\n    private void mergeUsage(Usage target, Usage usage) {\n        if (usage == null) {\n            return;\n        }\n        target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens());\n        target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens());\n        target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens());\n    }\n\n    private List<ChatMessage> appendToolMessages(\n            List<ChatMessage> messages,\n            ChatMessage assistantMessage,\n            List<ToolCall> toolCalls\n    ) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(assistantMessage);\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private List<ChatMessage> appendStreamToolMessages(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(ChatMessage.withAssistant(toolCalls));\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private void appendToolResponses(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        for (ToolCall toolCall : toolCalls) {\n            if (toolCall == null || toolCall.getFunction() == null) {\n                continue;\n            }\n            String functionName = toolCall.getFunction().getName();\n            String arguments = toolCall.getFunction().getArguments();\n            String functionResponse = ToolUtil.invoke(functionName, arguments);\n            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n        }\n    }\n\n    private void resetToolCallState(SseListener eventSourceListener) {\n        eventSourceListener.setToolCalls(new ArrayList<ToolCall>());\n        eventSourceListener.setToolCall(null);\n    }\n\n    private void restoreOriginalRequest(ChatCompletion chatCompletion, MinimaxChatCompletion minimaxChatCompletion) {\n        chatCompletion.setMessages(minimaxChatCompletion.getMessages());\n        chatCompletion.setTools(minimaxChatCompletion.getTools());\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        return (baseUrl == null || \"\".equals(baseUrl)) ? minimaxConfig.getApiHost() : baseUrl;\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? minimaxConfig.getApiKey() : apiKey;\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    private Integer resolveMaxTokens(ChatCompletion chatCompletion) {\n        if (chatCompletion.getMaxCompletionTokens() != null) {\n            return chatCompletion.getMaxCompletionTokens();\n        }\n        return chatCompletion.getMaxTokens();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/minimax/chat/entity/MinimaxChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.minimax.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport lombok.*;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author : isxuwl\n * @Date: 2024/10/15 16:24\n * @Model Description:\n * @Description: Minimax对话请求实体\n */\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class MinimaxChatCompletion {\n\n    @NonNull\n    private String model;\n\n    @NonNull\n    private List<ChatMessage> messages;\n\n    /**\n     * 模型可能会调用的 tool 的列表。目前，仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。\n     */\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    /**\n     * 控制模型调用 tool 的行为。\n     * none 意味着模型不会调用任何 tool，而是生成一条消息。\n     * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。\n     * 当没有 tool 时，默认值为 none。如果有 tool 存在，默认值为 auto。\n     */\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    /**\n     * 采样温度，介于 0 和 2 之间。更高的值，如 0.8，会使输出更随机，而更低的值，如 0.2，会使其更加集中和确定。\n     * 我们通常建议可以更改这个值或者更改 top_p，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    private Float temperature = 1f;\n\n    /**\n     * 作为调节采样温度的替代方案，模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。\n     * 我们通常建议修改这个值或者更改 temperature，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 1f;\n\n    /**\n     * 如果设置为 True，将会以 SSE（server-sent events）的形式以流式发送消息增量。消息流以 data: [DONE] 结尾\n     */\n    @Builder.Default\n    private Boolean stream = false;\n\n\n    /**\n     * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。\n     */\n    @JsonProperty(\"max_tokens\")\n    private Integer maxTokens;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n    public static class MinimaxChatCompletionBuilder {\n        private List<String> functions;\n\n        public MinimaxChatCompletion.MinimaxChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public MinimaxChatCompletion.MinimaxChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            if (functions != null) {\n                this.functions.addAll(functions);\n            }\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/minimax/chat/entity/MinimaxChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.minimax.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author : isxuwl\n * @Date: 2024/10/15 16:24\n * @Model Description:\n * @Description: Minimax对话响应实体\n */\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class MinimaxChatCompletionResponse {\n    /**\n     * 该对话的唯一标识符。\n     */\n    private String id;\n\n    /**\n     * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk\n     */\n    private String object;\n\n    /**\n     * 创建聊天完成时的 Unix 时间戳（以秒为单位）。\n     */\n    private Long created;\n\n    /**\n     * 生成该 completion 的模型名。\n     */\n    private String model;\n\n    /**\n     * 模型生成的 completion 的选择列表。\n     */\n    private List<Choice> choices;\n\n    /**\n     * 该对话补全请求的用量信息。\n     */\n    private Usage usage;\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/moonshot/chat/MoonshotChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.moonshot.chat;\n\nimport com.alibaba.fastjson2.JSONObject;\nimport com.alibaba.fastjson2.JSONPath;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.MoonshotConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.moonshot.chat.entity.MoonshotChatCompletion;\nimport io.github.lnyocly.ai4j.platform.moonshot.chat.entity.MoonshotChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 月之暗面请求服务\n * @Date 2024/8/29 23:12\n */\npublic class MoonshotChatService implements IChatService, ParameterConvert<MoonshotChatCompletion>, ResultConvert<MoonshotChatCompletionResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n    private static final String TOOL_CALLS_FINISH_REASON = \"tool_calls\";\n    private static final String FIRST_FINISH_REASON = \"first\";\n\n    private final MoonshotConfig moonshotConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n    private final ObjectMapper objectMapper;\n\n    public MoonshotChatService(Configuration configuration) {\n        this.moonshotConfig = configuration.getMoonshotConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    public MoonshotChatService(Configuration configuration, MoonshotConfig moonshotConfig) {\n        this.moonshotConfig = moonshotConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    @Override\n    public MoonshotChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        MoonshotChatCompletion moonshotChatCompletion = new MoonshotChatCompletion();\n        moonshotChatCompletion.setModel(chatCompletion.getModel());\n        moonshotChatCompletion.setMessages(chatCompletion.getMessages());\n        moonshotChatCompletion.setFrequencyPenalty(chatCompletion.getFrequencyPenalty());\n        moonshotChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion));\n        moonshotChatCompletion.setPresencePenalty(chatCompletion.getPresencePenalty());\n        moonshotChatCompletion.setResponseFormat(chatCompletion.getResponseFormat());\n        moonshotChatCompletion.setStop(chatCompletion.getStop());\n        moonshotChatCompletion.setStream(chatCompletion.getStream());\n        moonshotChatCompletion.setTemperature(chatCompletion.getTemperature() / 2);\n        moonshotChatCompletion.setTopP(chatCompletion.getTopP());\n        moonshotChatCompletion.setTools(chatCompletion.getTools());\n        moonshotChatCompletion.setFunctions(chatCompletion.getFunctions());\n        moonshotChatCompletion.setToolChoice(chatCompletion.getToolChoice());\n        moonshotChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n        return moonshotChatCompletion;\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(final SseListener eventSourceListener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n                eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data));\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(MoonshotChatCompletionResponse moonshotChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(moonshotChatCompletionResponse.getId());\n        chatCompletionResponse.setObject(moonshotChatCompletionResponse.getObject());\n        chatCompletionResponse.setCreated(moonshotChatCompletionResponse.getCreated());\n        chatCompletionResponse.setModel(moonshotChatCompletionResponse.getModel());\n        chatCompletionResponse.setChoices(moonshotChatCompletionResponse.getChoices());\n        chatCompletionResponse.setUsage(moonshotChatCompletionResponse.getUsage());\n        return chatCompletionResponse;\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, false);\n            MoonshotChatCompletion moonshotChatCompletion = convertChatCompletionObject(chatCompletion);\n            Usage allUsage = new Usage();\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                MoonshotChatCompletionResponse response = executeChatCompletionRequest(\n                        resolvedBaseUrl,\n                        resolvedApiKey,\n                        moonshotChatCompletion\n                );\n                if (response == null) {\n                    break;\n                }\n\n                Choice choice = response.getChoices().get(0);\n                finishReason = choice.getFinishReason();\n                mergeUsage(allUsage, response.getUsage());\n\n                if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) {\n                    if (passThroughToolCalls) {\n                        response.setUsage(allUsage);\n                        restoreOriginalRequest(chatCompletion, moonshotChatCompletion);\n                        return convertChatCompletionResponse(response);\n                    }\n                    moonshotChatCompletion.setMessages(appendToolMessages(\n                            moonshotChatCompletion.getMessages(),\n                            choice.getMessage(),\n                            choice.getMessage().getToolCalls()\n                    ));\n                    continue;\n                }\n\n                response.setUsage(allUsage);\n                restoreOriginalRequest(chatCompletion, moonshotChatCompletion);\n                return convertChatCompletionResponse(response);\n            }\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return this.chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, true);\n            MoonshotChatCompletion moonshotChatCompletion = convertChatCompletionObject(chatCompletion);\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                Request request = buildChatCompletionRequest(resolvedBaseUrl, resolvedApiKey, moonshotChatCompletion);\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n                if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) {\n                    continue;\n                }\n                if (passThroughToolCalls) {\n                    return;\n                }\n\n                moonshotChatCompletion.setMessages(appendStreamToolMessages(\n                        moonshotChatCompletion.getMessages(),\n                        toolCalls\n                ));\n                resetToolCallState(eventSourceListener);\n            }\n\n            restoreOriginalRequest(chatCompletion, moonshotChatCompletion);\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    private String serializeStreamResponse(String data) {\n        try {\n            MoonshotChatCompletionResponse chatCompletionResponse =\n                    objectMapper.readValue(data, MoonshotChatCompletionResponse.class);\n            ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse);\n            Usage usage = extractStreamUsage(data);\n            if (usage != null) {\n                response.setUsage(usage);\n            }\n            return objectMapper.writeValueAsString(response);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"Moonshot Chat 对象JSON序列化出错\");\n        }\n    }\n\n    private Usage extractStreamUsage(String data) {\n        JSONObject object = (JSONObject) JSONPath.eval(data, \"$.choices[0].usage\");\n        if (object == null) {\n            return null;\n        }\n        return object.toJavaObject(Usage.class);\n    }\n\n    private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) {\n        chatCompletion.setStream(stream);\n        if (!stream) {\n            chatCompletion.setStreamOptions(null);\n        }\n        attachTools(chatCompletion);\n    }\n\n    private void attachTools(ChatCompletion chatCompletion) {\n        if (hasPendingTools(chatCompletion)) {\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if (tools == null) {\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) {\n            chatCompletion.setParallelToolCalls(null);\n        }\n    }\n\n    private boolean hasPendingTools(ChatCompletion chatCompletion) {\n        return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty())\n                || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty());\n    }\n\n    private boolean requiresFollowUp(String finishReason) {\n        return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason);\n    }\n\n    private MoonshotChatCompletionResponse executeChatCompletionRequest(\n            String baseUrl,\n            String apiKey,\n            MoonshotChatCompletion moonshotChatCompletion\n    ) throws Exception {\n        Request request = buildChatCompletionRequest(baseUrl, apiKey, moonshotChatCompletion);\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return objectMapper.readValue(response.body().string(), MoonshotChatCompletionResponse.class);\n            }\n        }\n        return null;\n    }\n\n    private Request buildChatCompletionRequest(String baseUrl, String apiKey, MoonshotChatCompletion moonshotChatCompletion)\n            throws JsonProcessingException {\n        String requestBody = objectMapper.writeValueAsString(moonshotChatCompletion);\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, moonshotConfig.getChatCompletionUrl()))\n                .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE))\n                .build();\n    }\n\n    private void mergeUsage(Usage target, Usage usage) {\n        if (usage == null) {\n            return;\n        }\n        target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens());\n        target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens());\n        target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens());\n    }\n\n    private List<ChatMessage> appendToolMessages(\n            List<ChatMessage> messages,\n            ChatMessage assistantMessage,\n            List<ToolCall> toolCalls\n    ) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(assistantMessage);\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private List<ChatMessage> appendStreamToolMessages(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(ChatMessage.withAssistant(toolCalls));\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private void appendToolResponses(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        for (ToolCall toolCall : toolCalls) {\n            String functionName = toolCall.getFunction().getName();\n            String arguments = toolCall.getFunction().getArguments();\n            String functionResponse = ToolUtil.invoke(functionName, arguments);\n            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n        }\n    }\n\n    private void resetToolCallState(SseListener eventSourceListener) {\n        eventSourceListener.setToolCalls(new ArrayList<ToolCall>());\n        eventSourceListener.setToolCall(null);\n    }\n\n    private void restoreOriginalRequest(ChatCompletion chatCompletion, MoonshotChatCompletion moonshotChatCompletion) {\n        chatCompletion.setMessages(moonshotChatCompletion.getMessages());\n        chatCompletion.setTools(moonshotChatCompletion.getTools());\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        return (baseUrl == null || \"\".equals(baseUrl)) ? moonshotConfig.getApiHost() : baseUrl;\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? moonshotConfig.getApiKey() : apiKey;\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    private Integer resolveMaxTokens(ChatCompletion chatCompletion) {\n        if (chatCompletion.getMaxCompletionTokens() != null) {\n            return chatCompletion.getMaxCompletionTokens();\n        }\n        return chatCompletion.getMaxTokens();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/moonshot/chat/entity/MoonshotChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.moonshot.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport lombok.*;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description 月之暗面对话请求实体\n * @Date 2024/8/29 23:13\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class MoonshotChatCompletion {\n\n\n    @NonNull\n    private String model;\n\n    @NonNull\n    private List<ChatMessage> messages;\n\n    /**\n     * 介于 -2.0 和 2.0 之间的数字。如果该值为正，那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚，降低模型重复相同内容的可能性。\n     */\n    @Builder.Default\n    @JsonProperty(\"frequency_penalty\")\n    private Float frequencyPenalty = 0f;\n\n    /**\n     * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。\n     */\n    @JsonProperty(\"max_tokens\")\n    private Integer maxTokens;\n\n    /**\n     * 介于 -2.0 和 2.0 之间的数字。如果该值为正，那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚，从而增加模型谈论新主题的可能性。\n     */\n    @Builder.Default\n    @JsonProperty(\"presence_penalty\")\n    private Float presencePenalty = 0f;\n\n    /**\n     * 一个 object，指定模型必须输出的格式。\n     *\n     * 设置为 { \"type\": \"json_object\" } 以启用 JSON 模式，该模式保证模型生成的消息是有效的 JSON。\n     *\n     * 注意: 使用 JSON 模式时，你还必须通过系统或用户消息指示模型生成 JSON。\n     * 否则，模型可能会生成不断的空白字符，直到生成达到令牌限制，从而导致请求长时间运行并显得“卡住”。\n     * 此外，如果 finish_reason=\"length\"，这表示生成超过了 max_tokens 或对话超过了最大上下文长度，消息内容可能会被部分截断。\n     */\n    @JsonProperty(\"response_format\")\n    private Object responseFormat;\n\n    /**\n     * 在遇到这些词时，API 将停止生成更多的 token。\n     */\n    private List<String> stop;\n\n    /**\n     * 如果设置为 True，将会以 SSE（server-sent events）的形式以流式发送消息增量。消息流以 data: [DONE] 结尾\n     */\n    @Builder.Default\n    private Boolean stream = false;\n\n\n\n    /**\n     * 采样温度，介于 0 和 1 之间。更高的值，如 0.8，会使输出更随机，而更低的值，如 0.2，会使其更加集中和确定。\n     * 我们通常建议可以更改这个值或者更改 top_p，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    private Float temperature = 0.3f;\n\n    /**\n     * 作为调节采样温度的替代方案，模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。\n     * 我们通常建议修改这个值或者更改 temperature，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 1f;\n\n    /**\n     * 模型可能会调用的 tool 的列表。目前，仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。\n     */\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    /**\n     * 控制模型调用 tool 的行为。\n     * none 意味着模型不会调用任何 tool，而是生成一条消息。\n     * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。\n     * 当没有 tool 时，默认值为 none。如果有 tool 存在，默认值为 auto。\n     */\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n\n    public static class DeepSeekChatCompletionBuilder {\n        private List<String> functions;\n\n        public MoonshotChatCompletion.DeepSeekChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public MoonshotChatCompletion.DeepSeekChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(functions);\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/moonshot/chat/entity/MoonshotChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.moonshot.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 月之暗面对话响应实体\n * @Date 2024/8/29 10:28\n */\n\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class MoonshotChatCompletionResponse {\n    /**\n     * 该对话的唯一标识符。\n     */\n    private String id;\n\n    /**\n     * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk\n     */\n    private String object;\n\n    /**\n     * 创建聊天完成时的 Unix 时间戳（以秒为单位）。\n     */\n    private Long created;\n\n    /**\n     * 生成该 completion 的模型名。\n     */\n    private String model;\n\n    /**\n     * 模型生成的 completion 的选择列表。\n     */\n    private List<Choice> choices;\n\n    /**\n     * 该对话补全请求的用量信息。\n     */\n    private Usage usage;\n\n    /**\n     * 该指纹代表模型运行时使用的后端配置。\n     */\n    @JsonProperty(\"system_fingerprint\")\n    private String systemFingerprint;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/OllamaAiChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.chat;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.OllamaConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.ollama.chat.entity.OllamaChatCompletion;\nimport io.github.lnyocly.ai4j.platform.ollama.chat.entity.OllamaChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.ollama.chat.entity.OllamaMessage;\nimport io.github.lnyocly.ai4j.platform.ollama.chat.entity.OllamaOptions;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.*;\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.*;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.apache.commons.lang3.StringUtils;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\n/**\n * @Author cly\n * @Description Ollama Ai聊天对话服务\n * @Date 2024/9/20 0:00\n */\npublic class OllamaAiChatService implements IChatService, ParameterConvert<OllamaChatCompletion>, ResultConvert<OllamaChatCompletionResponse> {\n    private final OllamaConfig ollamaConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n\n    public OllamaAiChatService(Configuration configuration) {\n        this.ollamaConfig = configuration.getOllamaConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n    }\n\n    public OllamaAiChatService(Configuration configuration, OllamaConfig ollamaConfig) {\n        this.ollamaConfig = ollamaConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n    }\n\n\n    @Override\n    public OllamaChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        OllamaChatCompletion ollamaChatCompletion = new OllamaChatCompletion();\n        ollamaChatCompletion.setModel(chatCompletion.getModel());\n        ollamaChatCompletion.setTools(chatCompletion.getTools());\n        ollamaChatCompletion.setFunctions(chatCompletion.getFunctions());\n        ollamaChatCompletion.setStream(chatCompletion.getStream());\n\n        OllamaOptions ollamaOptions = new OllamaOptions();\n        ollamaOptions.setTemperature(chatCompletion.getTemperature());\n        ollamaOptions.setTopP(chatCompletion.getTopP());\n        ollamaOptions.setStop(chatCompletion.getStop());\n        ollamaChatCompletion.setOptions(ollamaOptions);\n\n        List<OllamaMessage> messages = new ArrayList<>();\n        for (ChatMessage chatMessage : chatCompletion.getMessages()) {\n            OllamaMessage ollamaMessage = new OllamaMessage();\n\n            ollamaMessage.setRole(chatMessage.getRole());\n            String content = chatMessage.getContent().getText();\n\n            if (content != null){\n                // 普通消息\n                ollamaMessage.setContent(content);\n            }else if (chatMessage.getContent().getMultiModals() != null){\n                List<Content.MultiModal> multiModals = chatMessage.getContent().getMultiModals();\n                if(multiModals!=null && !multiModals.isEmpty()){\n                    List<String> images = new ArrayList<>();\n                    for (Content.MultiModal multiModal : multiModals) {\n                        String text = multiModal.getText();\n                        Content.MultiModal.ImageUrl imageUrl = multiModal.getImageUrl();\n                        if(imageUrl!=null) images.add(imageUrl.getUrl());\n                        if(StringUtils.isNotBlank(text)) ollamaMessage.setContent(text);\n                    }\n                    ollamaMessage.setImages(images);\n                }\n            }\n\n            // 设置toolcalls\n            ollamaMessage.setToolCalls(chatMessage.getToolCalls());\n\n\n            messages.add(ollamaMessage);\n        }\n        ollamaChatCompletion.setMessages(messages);\n        ollamaChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n\n        return ollamaChatCompletion;\n    }\n\n    public List<ChatMessage> ollamaMessagesToChatMessages(List<OllamaMessage> ollamaMessages){\n        List<ChatMessage> chatMessages = new ArrayList<>();\n        for (OllamaMessage ollamaMessage : ollamaMessages) {\n            chatMessages.add(ollamaMessageToChatMessage(ollamaMessage));\n        }\n        return chatMessages;\n    }\n\n    public ChatMessage ollamaMessageToChatMessage(OllamaMessage ollamaMessage){\n        String role = ollamaMessage.getRole();\n        List<ToolCall> toolCalls = ollamaMessage.getToolCalls();\n\n        if(ChatMessageType.USER.getRole().equals(role)){\n            if(ollamaMessage.getImages()!=null && !ollamaMessage.getImages().isEmpty()){\n                // 多模态\n                return ChatMessage.withUser(ollamaMessage.getContent(), ollamaMessage.getImages().toArray(new String[0]));\n            }else{\n                return ChatMessage.withUser(ollamaMessage.getContent());\n            }\n        } else if (ChatMessageType.ASSISTANT.getRole().equals(role)) {\n            if(toolCalls!=null && !toolCalls.isEmpty()) {\n                // tool调用\n                for (ToolCall toolCall : toolCalls) {\n                    toolCall.setType(\"function\");\n                    toolCall.setId(UUID.randomUUID().toString());\n                }\n                return ChatMessage.withAssistant(ollamaMessage.getContent(), toolCalls);\n            }else{\n                return ChatMessage.withAssistant(ollamaMessage.getContent());\n            }\n\n        }else{\n            // system和tool消息\n            return new ChatMessage(role,ollamaMessage.getContent());\n        }\n\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(SseListener eventSourceListener) {\n        final AtomicBoolean isThinking = new AtomicBoolean(false);\n\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n\n                OllamaChatCompletionResponse ollamaChatCompletionResponse = JSON.parseObject(data, OllamaChatCompletionResponse.class);\n                OllamaMessage message = ollamaChatCompletionResponse.getMessage();\n                String content = message != null ? message.getContent() : null;\n                String thinking = message != null ? message.getThinking() : null;\n\n                // 1. 处理 thinking 字段（Ollama Qwen 模式）\n                // thinking 字段有值时，直接映射到 reasoning_content\n                if (StringUtils.isNotEmpty(thinking)) {\n                    ChatCompletionResponse response = convertChatCompletionResponse(ollamaChatCompletionResponse);\n                    if (response.getChoices() != null && !response.getChoices().isEmpty()) {\n                        ChatMessage delta = response.getChoices().get(0).getDelta();\n                        delta.setReasoningContent(thinking);\n                        delta.setContent(null);\n                    }\n                    sendConvertedResponse(eventSourceListener, eventSource, id, type, response);\n                    return;\n                }\n\n                // 2. 处理 <think> 标签（Ollama DeepSeek 模式）\n                // 检测到 <think> 标签时，标记进入思考模式，不传递标签本身\n                if (\"<think>\".equals(content)) {\n                    isThinking.set(true);\n                    return;\n                }\n                // 检测到 </think> 标签时，标记退出思考模式，不传递标签本身\n                if (\"</think>\".equals(content)) {\n                    isThinking.set(false);\n                    return;\n                }\n\n                // 3. 转换为 OpenAI 格式\n                ChatCompletionResponse response = convertChatCompletionResponse(ollamaChatCompletionResponse);\n\n                // 4. 如果处于思考模式，将 content 转换为 reasoning_content\n                if (isThinking.get() && StringUtils.isNotEmpty(content)) {\n                    if (response.getChoices() != null && !response.getChoices().isEmpty()) {\n                        ChatMessage delta = response.getChoices().get(0).getDelta();\n                        delta.setReasoningContent(content);\n                        delta.setContent(null);\n                    }\n                }\n\n                sendConvertedResponse(eventSourceListener, eventSource, id, type, response);\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    /**\n     * 发送转换后的响应给 SseListener\n     */\n    private void sendConvertedResponse(SseListener listener, EventSource eventSource, String id, String type, ChatCompletionResponse response) {\n        ObjectMapper mapper = new ObjectMapper();\n        try {\n            String s = mapper.writeValueAsString(response);\n            listener.onEvent(eventSource, id, type, s);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"Ollama Chat Completion Response convert to JSON error\");\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(OllamaChatCompletionResponse ollamaChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setModel(ollamaChatCompletionResponse.getModel());\n        chatCompletionResponse.setId(UUID.randomUUID().toString());\n        chatCompletionResponse.setObject(\"chat.completion\");\n        Instant instant = Instant.parse(ollamaChatCompletionResponse.getCreatedAt());\n        long created = instant.getEpochSecond();\n        chatCompletionResponse.setCreated(created);\n\n        Usage usage = new Usage();\n        usage.setCompletionTokens(ollamaChatCompletionResponse.getEvalCount());\n        usage.setPromptTokens(ollamaChatCompletionResponse.getPromptEvalCount());\n        usage.setTotalTokens(ollamaChatCompletionResponse.getEvalCount() + ollamaChatCompletionResponse.getPromptEvalCount());\n        chatCompletionResponse.setUsage(usage);\n\n        ChatMessage chatMessage = ollamaMessageToChatMessage(ollamaChatCompletionResponse.getMessage());\n        List<Choice> choices = new ArrayList<>(1);\n        Choice choice = new Choice();\n        choice.setFinishReason(ollamaChatCompletionResponse.getDoneReason());\n        choice.setIndex(0);\n        choice.setMessage(chatMessage);\n        choice.setDelta(chatMessage);\n        choices.add(choice);\n        chatCompletionResponse.setChoices(choices);\n\n        return chatCompletionResponse;\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n        if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = ollamaConfig.getApiHost();\n        if(apiKey == null || \"\".equals(apiKey)) apiKey = ollamaConfig.getApiKey();\n        boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n        chatCompletion.setStream(false);\n        chatCompletion.setStreamOptions(null);\n\n        if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){\n            //List<Tool> tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions());\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if(tools == null){\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){\n\n        }else{\n            chatCompletion.setParallelToolCalls(null);\n        }\n\n        // 转换 请求参数\n        OllamaChatCompletion ollamaChatCompletion = this.convertChatCompletionObject(chatCompletion);\n\n/*        // 如含有function，则添加tool\n        if(ollamaChatCompletion.getFunctions()!=null && !ollamaChatCompletion.getFunctions().isEmpty()){\n            List<Tool> tools = ToolUtil.getAllFunctionTools(ollamaChatCompletion.getFunctions());\n            ollamaChatCompletion.setTools(tools);\n        }*/\n\n        // 总token消耗\n        Usage allUsage = new Usage();\n\n        String finishReason = \"first\";\n\n        while(\"first\".equals(finishReason) || \"tool_calls\".equals(finishReason)){\n\n            finishReason = null;\n\n            // 构造请求\n            String requestString = JSON.toJSONString(ollamaChatCompletion);\n\n            JSONObject jsonObject = JSON.parseObject(requestString);\n            // 展开 extraBody 到顶层\n            JSONObject extraBody = jsonObject.getJSONObject(\"extraBody\");\n            if (extraBody != null) {\n                for (String key : extraBody.keySet()) {\n                    jsonObject.put(key, extraBody.get(key));\n                }\n                jsonObject.remove(\"extraBody\");\n            }\n            // 遍历jsonObject的messages\n            JSONArray jsonArrayMessages = jsonObject.getJSONArray(\"messages\");\n            for (Object message : jsonArrayMessages) {\n                JSONObject messageObject = (JSONObject) message;\n                JSONArray toolCalls = messageObject.getJSONArray(\"tool_calls\");\n                if(toolCalls!=null && !toolCalls.isEmpty()){\n                    for (Object toolCall : toolCalls) {\n                        // 遍历toolCall中的function中的arguments，将arguments（JSON String）转为对象(JSON Object)\n                        JSONObject toolCallObject = (JSONObject) toolCall;\n                        JSONObject function = toolCallObject.getJSONObject(\"function\");\n                        String arguments = function.getString(\"arguments\");\n                        JSONObject argumentsObject = JSON.parseObject(arguments);\n                        function.remove(\"arguments\");\n                        function.put(\"arguments\", argumentsObject);\n                    }\n                }\n            }\n            requestString = JSON.toJSONString(jsonObject);\n\n\n            Request.Builder builder = new Request.Builder()\n                    .url(UrlUtils.concatUrl(baseUrl, ollamaConfig.getChatCompletionUrl()))\n                    .post(RequestBody.create(requestString, MediaType.get(Constants.JSON_CONTENT_TYPE)));\n\n            if(StringUtils.isNotBlank(apiKey)) {\n                builder.header(\"Authorization\", \"Bearer \" + apiKey);\n            }\n\n            Request request = builder.build();\n\n            Response execute = okHttpClient.newCall(request).execute();\n            if (execute.isSuccessful() && execute.body() != null){\n                OllamaChatCompletionResponse ollamaChatCompletionResponse = JSON.parseObject(execute.body().string(), OllamaChatCompletionResponse.class);\n\n\n                finishReason = ollamaChatCompletionResponse.getDoneReason();\n\n                allUsage.setCompletionTokens(allUsage.getCompletionTokens() + ollamaChatCompletionResponse.getEvalCount());\n                allUsage.setTotalTokens(allUsage.getTotalTokens() + ollamaChatCompletionResponse.getEvalCount() + ollamaChatCompletionResponse.getPromptEvalCount());\n                allUsage.setPromptTokens(allUsage.getPromptTokens() +  ollamaChatCompletionResponse.getPromptEvalCount());\n\n                List<ToolCall> functions = ollamaChatCompletionResponse.getMessage().getToolCalls();\n                if(functions!=null && !functions.isEmpty()){\n                    finishReason = \"tool_calls\";\n                }\n\n                // 判断是否为函数调用返回\n                if(\"tool_calls\".equals(finishReason)){\n                    if (passThroughToolCalls) {\n                        ollamaChatCompletionResponse.setDoneReason(\"tool_calls\");\n                        ollamaChatCompletionResponse.setEvalCount(allUsage.getCompletionTokens());\n                        ollamaChatCompletionResponse.setPromptEvalCount(allUsage.getPromptTokens());\n                        return this.convertChatCompletionResponse(ollamaChatCompletionResponse);\n                    }\n                    OllamaMessage message = ollamaChatCompletionResponse.getMessage();\n\n                    List<ToolCall> toolCalls = message.getToolCalls();\n\n                    List<OllamaMessage> messages = new ArrayList<>(ollamaChatCompletion.getMessages());\n                    messages.add(message);\n\n                    // 添加 tool 消息\n                    for (ToolCall toolCall : toolCalls) {\n                        String functionName = toolCall.getFunction().getName();\n                        String arguments = toolCall.getFunction().getArguments();\n                        String functionResponse = ToolUtil.invoke(functionName, arguments);\n\n                        OllamaMessage ollamaMessage = new OllamaMessage();\n                        ollamaMessage.setRole(\"tool\");\n                        ollamaMessage.setContent(functionResponse);\n\n                        messages.add(ollamaMessage);\n                    }\n                    ollamaChatCompletion.setMessages(messages);\n\n                }else{// 其他情况直接返回\n\n                    // 设置包含tool的总token数\n                    ollamaChatCompletionResponse.setEvalCount(allUsage.getCompletionTokens());\n                    ollamaChatCompletionResponse.setPromptEvalCount(allUsage.getPromptTokens());\n\n                    // 恢复原始请求数据\n                    chatCompletion.setMessages(ollamaMessagesToChatMessages(ollamaChatCompletion.getMessages()));\n                    chatCompletion.setTools(ollamaChatCompletion.getTools());\n\n                    return this.convertChatCompletionResponse(ollamaChatCompletionResponse);\n\n                }\n\n            }\n\n        }\n\n\n        return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return this.chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n        if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = ollamaConfig.getApiHost();\n        if(apiKey == null || \"\".equals(apiKey)) apiKey = ollamaConfig.getApiKey();\n        chatCompletion.setStream(true);\n        boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n        if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){\n            //List<Tool> tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions());\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if(tools == null){\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){\n\n        }else{\n            chatCompletion.setParallelToolCalls(null);\n        }\n\n        // 转换 请求参数\n        OllamaChatCompletion ollamaChatCompletion = this.convertChatCompletionObject(chatCompletion);\n\n/*        // 如含有function，则添加tool\n        if(ollamaChatCompletion.getFunctions()!=null && !ollamaChatCompletion.getFunctions().isEmpty()){\n            List<Tool> tools = ToolUtil.getAllFunctionTools(ollamaChatCompletion.getFunctions());\n            ollamaChatCompletion.setTools(tools);\n        }*/\n\n        String finishReason = \"first\";\n\n        while(\"first\".equals(finishReason) || \"tool_calls\".equals(finishReason)){\n\n            finishReason = null;\n\n            // 构造请求\n            JSON.toJSONString(ollamaChatCompletion);\n            ObjectMapper mapper = new ObjectMapper();\n            String requestString = mapper.writeValueAsString(ollamaChatCompletion);\n\n\n            JSONObject jsonObject = JSON.parseObject(requestString);\n            // 遍历jsonObject的messages\n            JSONArray jsonArrayMessages = jsonObject.getJSONArray(\"messages\");\n            for (Object message : jsonArrayMessages) {\n                JSONObject messageObject = (JSONObject) message;\n                JSONArray toolCalls = messageObject.getJSONArray(\"tool_calls\");\n                if(toolCalls!=null && !toolCalls.isEmpty()){\n                    for (Object toolCall : toolCalls) {\n                        // 遍历toolCall中的function中的arguments，将arguments（JSON String）转为对象(JSON Object)\n                        JSONObject toolCallObject = (JSONObject) toolCall;\n                        JSONObject function = toolCallObject.getJSONObject(\"function\");\n                        String arguments = function.getString(\"arguments\");\n                        JSONObject argumentsObject = JSON.parseObject(arguments);\n                        function.remove(\"arguments\");\n                        function.put(\"arguments\", argumentsObject);\n                    }\n                }\n            }\n            requestString = JSON.toJSONString(jsonObject);\n\n            Request.Builder builder = new Request.Builder()\n                    .url(UrlUtils.concatUrl(baseUrl, ollamaConfig.getChatCompletionUrl()))\n                    .post(RequestBody.create(requestString, MediaType.get(Constants.JSON_CONTENT_TYPE)));\n\n            if(StringUtils.isNotBlank(apiKey)) {\n                builder.header(\"Authorization\", \"Bearer \" + apiKey);\n            }\n\n            Request request = builder.build();\n\n            StreamExecutionSupport.execute(\n                    eventSourceListener,\n                    chatCompletion.getStreamExecution(),\n                    () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n            );\n\n            finishReason = eventSourceListener.getFinishReason();\n            List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n\n            // 需要调用函数\n            if(\"tool_calls\".equals(finishReason) && !toolCalls.isEmpty()){\n                if (passThroughToolCalls) {\n                    return;\n                }\n                // 创建tool响应消息\n                OllamaMessage responseMessage = new OllamaMessage();\n                responseMessage.setRole(ChatMessageType.ASSISTANT.getRole());\n                responseMessage.setToolCalls(eventSourceListener.getToolCalls());\n\n                List<OllamaMessage> messages = new ArrayList<>(ollamaChatCompletion.getMessages());\n                messages.add(responseMessage);\n\n                // 封装tool结果消息\n                for (ToolCall toolCall : toolCalls) {\n                    String functionName = toolCall.getFunction().getName();\n                    String arguments = toolCall.getFunction().getArguments();\n                    String functionResponse = ToolUtil.invoke(functionName, arguments);\n\n                    OllamaMessage ollamaMessage = new OllamaMessage();\n                    ollamaMessage.setRole(\"tool\");\n                    ollamaMessage.setContent(functionResponse);\n\n                    messages.add(ollamaMessage);\n                }\n                eventSourceListener.setToolCalls(new ArrayList<>());\n                eventSourceListener.setToolCall(null);\n                ollamaChatCompletion.setMessages(messages);\n            }\n\n        }\n\n        // 补全原始请求\n        chatCompletion.setMessages(ollamaMessagesToChatMessages(ollamaChatCompletion.getMessages()));\n        chatCompletion.setTools(ollamaChatCompletion.getTools());\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/entity/OllamaChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport lombok.*;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description Ollama对话请求实体\n * @Date 2024/9/20 0:19\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class OllamaChatCompletion {\n\n    @NonNull\n    private String model;\n\n    @NonNull\n    private List<OllamaMessage> messages;\n\n    /**\n     * 模型可能会调用的 tool 的列表。目前，仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。\n     * 使用tools时，需要设置stream为false\n     */\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    private OllamaOptions options;\n\n    private Boolean stream;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/entity/OllamaChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description ollama对话响应实体\n * @Date 2024/9/20 0:03\n */\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class OllamaChatCompletionResponse {\n\n    private String model;\n\n    @JsonProperty(\"created_at\")\n    private String createdAt;\n\n    private OllamaMessage message;\n\n    @JsonProperty(\"done_reason\")\n    private String doneReason;\n\n    private Boolean done;\n\n    @JsonProperty(\"total_duration\")\n    private long totalDuration;\n\n    @JsonProperty(\"load_duration\")\n    private long loadDuration;\n\n    @JsonProperty(\"prompt_eval_count\")\n    private long promptEvalCount;\n\n    @JsonProperty(\"prompt_eval_duration\")\n    private long promptEvalDuration;\n\n    @JsonProperty(\"eval_count\")\n    private long evalCount;\n\n    @JsonProperty(\"eval_duration\")\n    private long evalDuration;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/entity/OllamaMessage.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/9/20 0:25\n */\n\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\npublic class OllamaMessage {\n    private String role;\n    private String content;\n    private List<String> images;\n\n    /**\n     * Ollama Qwen 模型的思考内容字段\n     * 该字段会在 Converter 层映射到 OpenAI 格式的 reasoning_content\n     */\n    private String thinking;\n\n    @JsonProperty(\"tool_calls\")\n    private List<ToolCall> toolCalls;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/chat/entity/OllamaOptions.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/9/20 0:30\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\npublic class OllamaOptions {\n\n    /**\n     * 采样温度，介于 0 和 2 之间。更高的值，如 0.8，会使输出更随机，而更低的值，如 0.2，会使其更加集中和确定。\n     * 我们通常建议可以更改这个值或者更改 top_p，但不建议同时对两者进行修改。\n     */\n    private Float temperature = 0.8f;\n\n    /**\n     * 作为调节采样温度的替代方案，模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。\n     * 我们通常建议修改这个值或者更改 temperature，但不建议同时对两者进行修改。\n     */\n    @JsonProperty(\"top_p\")\n    private Float topP = 0.9f;\n\n\n    private List<String> stop;\n\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/embedding/OllamaEmbeddingService.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.embedding;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.OllamaConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.embedding.EmbeddingParameterConvert;\nimport io.github.lnyocly.ai4j.convert.embedding.EmbeddingResultConvert;\nimport io.github.lnyocly.ai4j.platform.ollama.embedding.entity.OllamaEmbedding;\nimport io.github.lnyocly.ai4j.platform.ollama.embedding.entity.OllamaEmbeddingResponse;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.*;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2025/2/28 15:52\n */\npublic class OllamaEmbeddingService implements IEmbeddingService, EmbeddingParameterConvert<OllamaEmbedding>, EmbeddingResultConvert<OllamaEmbeddingResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private final OllamaConfig ollamaConfig;\n    private final OkHttpClient okHttpClient;\n\n    public OllamaEmbeddingService(Configuration configuration) {\n        this.ollamaConfig = configuration.getOllamaConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n    public OllamaEmbeddingService(Configuration configuration, OllamaConfig ollamaConfig) {\n        this.ollamaConfig = ollamaConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n    @Override\n    public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) throws Exception {\n        if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = ollamaConfig.getApiHost();\n        if(apiKey == null || \"\".equals(apiKey)) apiKey = ollamaConfig.getApiKey();\n        String jsonString = JSON.toJSONString(convertEmbeddingRequest(embeddingReq));\n\n        Request.Builder builder = new Request.Builder()\n                .url(UrlUtils.concatUrl(baseUrl, ollamaConfig.getEmbeddingUrl()))\n                .post(RequestBody.create(jsonString, JSON_MEDIA_TYPE));\n        if(StringUtils.isNotBlank(apiKey)) {\n            builder.header(\"Authorization\", \"Bearer \" + apiKey);\n        }\n        Request request = builder.build();\n\n        Response execute = okHttpClient.newCall(request).execute();\n        if (execute.isSuccessful() && execute.body() != null) {\n            OllamaEmbeddingResponse ollamaEmbeddingResponse = JSON.parseObject(execute.body().string(), OllamaEmbeddingResponse.class);\n            return convertEmbeddingResponse(ollamaEmbeddingResponse);\n        }\n        return null;\n    }\n\n    @Override\n    public EmbeddingResponse embedding(Embedding embeddingReq) throws Exception {\n        return this.embedding(null, null, embeddingReq);\n    }\n\n    @Override\n    public OllamaEmbedding convertEmbeddingRequest(Embedding embeddingRequest) {\n        Object input = embeddingRequest.getInput();\n        if (input instanceof List<?>) {\n            return OllamaEmbedding.builder()\n                    .model(embeddingRequest.getModel())\n                    .input(castStringList(input))\n                    .build();\n        }\n        return OllamaEmbedding.builder()\n                .model(embeddingRequest.getModel())\n                .input((String) input)\n                .build();\n    }\n\n    @Override\n    public EmbeddingResponse convertEmbeddingResponse(OllamaEmbeddingResponse ollamaEmbeddingResponse) {\n        EmbeddingResponse.EmbeddingResponseBuilder builder = EmbeddingResponse.builder()\n                .model(ollamaEmbeddingResponse.getModel())\n                .object(\"list\")\n                .usage(new Usage(ollamaEmbeddingResponse.getPromptEvalCount(), 0, ollamaEmbeddingResponse.getPromptEvalCount()));\n        List<EmbeddingObject> embeddingObjects = new ArrayList<>();\n        List<List<Float>> embeddings = ollamaEmbeddingResponse.getEmbeddings();\n        for (int i = 0; i < embeddings.size(); i++) {\n            EmbeddingObject embeddingObject = new EmbeddingObject();\n            embeddingObject.setIndex(i);\n            embeddingObject.setEmbedding(embeddings.get(i));\n            embeddingObject.setObject(\"embedding\");\n            embeddingObjects.add(embeddingObject);\n        }\n        builder.data(embeddingObjects);\n        return builder.build();\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private List<String> castStringList(Object input) {\n        return (List<String>) input;\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/embedding/entity/OllamaEmbedding.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.embedding.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.*;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2025/2/28 18:01\n */\n@Data\n@Builder\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class OllamaEmbedding {\n    /**\n     * 向量化文本\n     */\n    @NonNull\n    private Object input;\n\n    /**\n     * 向量模型\n     */\n    @NonNull\n    private String model;\n\n    public static class OllamaEmbeddingBuilder {\n        private Object input;\n        private OllamaEmbedding.OllamaEmbeddingBuilder input(Object input){\n            this.input = input;\n            return this;\n        }\n\n        public OllamaEmbedding.OllamaEmbeddingBuilder input(String input){\n            this.input = input;\n            return this;\n        }\n\n        public OllamaEmbedding.OllamaEmbeddingBuilder input(List<String> content){\n            this.input = content;\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/embedding/entity/OllamaEmbeddingResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.embedding.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2025/2/28 18:03\n */\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@Builder\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class OllamaEmbeddingResponse {\n    private String model;\n    private List<List<Float>> embeddings;\n\n    @JsonProperty(\"total_duration\")\n    private Long totalDuration;\n    @JsonProperty(\"load_duration\")\n    private Long loadDuration;\n    @JsonProperty(\"prompt_eval_count\")\n    private Long promptEvalCount;\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/ollama/rerank/OllamaRerankService.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.rerank;\n\nimport io.github.lnyocly.ai4j.config.OllamaConfig;\nimport io.github.lnyocly.ai4j.platform.standard.rerank.StandardRerankService;\nimport io.github.lnyocly.ai4j.service.Configuration;\n\npublic class OllamaRerankService extends StandardRerankService {\n\n    public OllamaRerankService(Configuration configuration) {\n        this(configuration, configuration == null ? null : configuration.getOllamaConfig());\n    }\n\n    public OllamaRerankService(Configuration configuration, OllamaConfig ollamaConfig) {\n        super(configuration == null ? null : configuration.getOkHttpClient(),\n                ollamaConfig == null ? null : ollamaConfig.getApiHost(),\n                ollamaConfig == null ? null : ollamaConfig.getApiKey(),\n                ollamaConfig == null ? null : ollamaConfig.getRerankUrl());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/OpenAiAudioService.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.platform.openai.audio.entity.TextToSpeech;\nimport io.github.lnyocly.ai4j.platform.openai.audio.entity.Transcription;\nimport io.github.lnyocly.ai4j.platform.openai.audio.entity.TranscriptionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.audio.entity.Translation;\nimport io.github.lnyocly.ai4j.platform.openai.audio.entity.TranslationResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IAudioService;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.MultipartBody;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.io.FilterInputStream;\nimport java.io.File;\nimport java.io.IOException;\nimport java.io.InputStream;\n\n/**\n * @Author cly\n * @Description OpenAi音频服务\n * @Date 2024/10/10 23:36\n */\npublic class OpenAiAudioService implements IAudioService {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n    private static final MediaType OCTET_STREAM_MEDIA_TYPE = MediaType.get(\"application/octet-stream\");\n\n    private final OpenAiConfig openAiConfig;\n    private final OkHttpClient okHttpClient;\n\n    public OpenAiAudioService(Configuration configuration) {\n        this.openAiConfig = configuration.getOpenAiConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n    public OpenAiAudioService(Configuration configuration, OpenAiConfig openAiConfig) {\n        this.openAiConfig = openAiConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n\n    @Override\n    public InputStream textToSpeech(String baseUrl, String apiKey, TextToSpeech textToSpeech) {\n        String requestString = JSON.toJSONString(textToSpeech);\n        Request request = buildAuthorizedRequest(\n                baseUrl,\n                apiKey,\n                openAiConfig.getSpeechUrl(),\n                RequestBody.create(requestString, JSON_MEDIA_TYPE)\n        );\n\n        Response response = null;\n        try {\n            response = okHttpClient.newCall(request).execute();\n            if (!response.isSuccessful()) {\n                throw new IOException(\"Unexpected code \" + response);\n            }\n\n            ResponseBody responseBody = response.body();\n            if (responseBody != null) {\n                return new ResponseInputStream(response, responseBody.byteStream());\n            }\n        } catch (IOException e) {\n            closeQuietly(response);\n            e.printStackTrace();\n        }\n        closeQuietly(response);\n        return null;\n    }\n\n    @Override\n    public InputStream textToSpeech(TextToSpeech textToSpeech) {\n        return this.textToSpeech(null, null, textToSpeech);\n    }\n\n    @Override\n    public TranscriptionResponse transcription(String baseUrl, String apiKey, Transcription transcription) {\n        MultipartBody.Builder builder = newAudioMultipartBuilder(\n                transcription.getFile(),\n                transcription.getModel(),\n                transcription.getTemperature()\n        );\n        if(StringUtils.isNotBlank(transcription.getLanguage())){\n            builder.addFormDataPart(\"language\", transcription.getLanguage());\n        }\n        if(StringUtils.isNotBlank(transcription.getPrompt())){\n            builder.addFormDataPart(\"prompt\", transcription.getPrompt());\n        }\n        if(StringUtils.isNotBlank(transcription.getResponseFormat())){\n            builder.addFormDataPart(\"response_format\", transcription.getResponseFormat());\n        }\n\n        return executeJsonRequest(\n                buildAuthorizedRequest(baseUrl, apiKey, openAiConfig.getTranscriptionUrl(), builder.build()),\n                TranscriptionResponse.class\n        );\n    }\n\n    @Override\n    public TranscriptionResponse transcription(Transcription transcription) {\n        return this.transcription(null, null, transcription);\n    }\n\n    @Override\n    public TranslationResponse translation(String baseUrl, String apiKey, Translation translation) {\n        MultipartBody.Builder builder = newAudioMultipartBuilder(\n                translation.getFile(),\n                translation.getModel(),\n                translation.getTemperature()\n        );\n        if(StringUtils.isNotBlank(translation.getPrompt())){\n            builder.addFormDataPart(\"prompt\", translation.getPrompt());\n        }\n        if(StringUtils.isNotBlank(translation.getResponseFormat())){\n            builder.addFormDataPart(\"response_format\", translation.getResponseFormat());\n        }\n\n        return executeJsonRequest(\n                buildAuthorizedRequest(baseUrl, apiKey, openAiConfig.getTranslationUrl(), builder.build()),\n                TranslationResponse.class\n        );\n    }\n\n    @Override\n    public TranslationResponse translation(Translation translation) {\n        return this.translation(null, null, translation);\n    }\n\n    private Request buildAuthorizedRequest(String baseUrl, String apiKey, String path, RequestBody requestBody) {\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + resolveApiKey(apiKey))\n                .url(UrlUtils.concatUrl(resolveBaseUrl(baseUrl), path))\n                .post(requestBody)\n                .build();\n    }\n\n    private MultipartBody.Builder newAudioMultipartBuilder(File file, String model, Object temperature) {\n        return new MultipartBody.Builder()\n                .setType(MultipartBody.FORM)\n                .addFormDataPart(\"file\", file.getName(), RequestBody.create(file, OCTET_STREAM_MEDIA_TYPE))\n                .addFormDataPart(\"model\", model)\n                .addFormDataPart(\"temperature\", String.valueOf(temperature));\n    }\n\n    private <T> T executeJsonRequest(Request request, Class<T> responseType) {\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return JSON.parseObject(response.body().string(), responseType);\n            }\n        } catch (Exception e) {\n            e.printStackTrace();\n        }\n        return null;\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        return (baseUrl == null || \"\".equals(baseUrl)) ? openAiConfig.getApiHost() : baseUrl;\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? openAiConfig.getApiKey() : apiKey;\n    }\n\n    private static void closeQuietly(Response response) {\n        if (response != null) {\n            response.close();\n        }\n    }\n\n    /**\n     * Keep the HTTP response open until the caller finishes consuming the stream.\n     */\n    private static final class ResponseInputStream extends FilterInputStream {\n        private final Response response;\n\n        private ResponseInputStream(Response response, InputStream delegate) {\n            super(delegate);\n            this.response = response;\n        }\n\n        @Override\n        public void close() throws IOException {\n            try {\n                super.close();\n            } finally {\n                response.close();\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/Segment.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description Segment块\n * @Date 2024/10/11 11:34\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class Segment {\n        private Integer id;\n        private Integer seek;\n        private Double start;\n        private Double end;\n        private String text;\n        private List<Integer> tokens;\n        private Float temperature;\n        @JsonProperty(\"avg_logprob\")\n        private Double avgLogprob;\n        @JsonProperty(\"compression_ratio\")\n        private Double compressionRatio;\n        @JsonProperty(\"no_speech_prob\")\n        private Double noSpeechProb;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/TextToSpeech.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.audio.enums.AudioEnum;\nimport lombok.*;\n\nimport java.io.Serializable;\n\n/**\n * @Author cly\n * @Description TextToSpeech请求实体类\n * @Date 2024/10/10 23:45\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@Builder\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class TextToSpeech {\n    /**\n     * tts-1 or tts-1-hd\n     */\n    @Builder.Default\n    @NonNull\n    private String model = \"tts-1\";\n\n    /**\n     * 要为其生成音频的文本。最大长度为 4096 个字符。\n     */\n    @NonNull\n    private String input;\n\n\n    /**\n     * 生成音频时要使用的语音。支持的声音包括 alloy、echo、fable、onyx、nova 和 shimmer\n     */\n    @Builder.Default\n    @NonNull\n    private String voice = AudioEnum.Voice.ALLOY.getValue();\n\n    /**\n     * 音频输入的格式。支持的格式包括 mp3, opus, aac, flac, wav, and pcm。\n     */\n    @Builder.Default\n    @JsonProperty(\"response_format\")\n    private String responseFormat = AudioEnum.ResponseFormat.MP3.getValue();\n\n    /**\n     * 生成的音频的速度。选择一个介于 0.25 到 4.0 之间的值。默认值为 1.0。\n     */\n    @Builder.Default\n    private Double speed = 1.0d;\n}"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/Transcription.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.audio.enums.WhisperEnum;\nimport lombok.*;\n\nimport java.io.File;\n\n/**\n * 转录请求参数。\n * 可将音频文件转录为你所输入语言对应文本。语言可以自己指定\n *\n * @author cly\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@Builder\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class Transcription {\n\n    /**\n     * 要转录的音频文件对象（不是文件名），采用以下格式之一：flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。\n     */\n    @NonNull\n    private File file;\n\n    /**\n     * 要使用的模型的 ID。目前只有 whisper-1 可用。\n     */\n    @NonNull\n    @Builder.Default\n    private String model = \"whisper-1\";\n\n    /**\n     * 输入音频的语言。以 ISO-639-1 格式提供输入语言将提高准确性和延迟。\n     */\n    private String language;\n\n    /**\n     * 一个可选文本，用于指导模型的样式或继续上一个音频片段。提示应与音频语言匹配。\n     */\n    private String prompt;\n\n    /**\n     * 输出的格式，采用以下选项之一：json、text、srt、verbose_json 或 vtt。\n     */\n    @JsonProperty(\"response_format\")\n    @Builder.Default\n    private String responseFormat = WhisperEnum.ResponseFormat.JSON.getValue();\n\n    /**\n     * 采样温度，介于 0 和 1 之间。较高的值（如 0.8）将使输出更加随机，而较低的值（如 0.2）将使其更具集中性和确定性。如果设置为 0，模型将使用对数概率自动提高温度，直到达到某些阈值。\n     */\n    @Builder.Default\n    private Double temperature = 0d;\n\n\n    public static class TranscriptionBuilder {\n        private File file;\n\n        public Transcription.TranscriptionBuilder content(File file){\n            // 校验File是否为以下格式之一：flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。\n            if (file == null) {\n                throw new IllegalArgumentException(\"file is required\");\n            }\n\n            String[] allowedFormats = {\"flac\", \"mp3\", \"mp4\", \"mpeg\", \"mpga\", \"m4a\", \"ogg\", \"wav\", \"webm\"};\n            String fileName = file.getName().toLowerCase();\n            boolean isValidFormat = false;\n\n            for (String format : allowedFormats) {\n                if (fileName.endsWith(\".\" + format)) {\n                    isValidFormat = true;\n                    break;\n                }\n            }\n\n            if (!isValidFormat) {\n                throw new IllegalArgumentException(\"Invalid file format. Allowed formats are: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm.\");\n            }\n\n            this.file = file;\n            return this;\n        }\n    }\n\n    public void setFile(@NonNull File file) {\n        // 校验File是否为以下格式之一：flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。\n        if (file == null) {\n            throw new IllegalArgumentException(\"file is required\");\n        }\n\n        String[] allowedFormats = {\"flac\", \"mp3\", \"mp4\", \"mpeg\", \"mpga\", \"m4a\", \"ogg\", \"wav\", \"webm\"};\n        String fileName = file.getName().toLowerCase();\n        boolean isValidFormat = false;\n\n        for (String format : allowedFormats) {\n            if (fileName.endsWith(\".\" + format)) {\n                isValidFormat = true;\n                break;\n            }\n        }\n\n        if (!isValidFormat) {\n            throw new IllegalArgumentException(\"Invalid file format. Allowed formats are: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm.\");\n        }\n\n        this.file = file;\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/TranscriptionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/10/11 16:28\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class TranscriptionResponse {\n    private String task;\n    private String language;\n    private Double duration;\n    private String text;\n    private List<Segment> segments;\n    private List<Word> words;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/Translation.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.audio.enums.WhisperEnum;\nimport lombok.*;\n\nimport java.io.File;\n\n/**\n * 转录请求参数。\n * 可将音频文件转录为你所输入语言对应文本。语言可以自己指定\n *\n * @author cly\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@Builder\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class Translation {\n\n    /**\n     * 要转录的音频文件对象（不是文件名），采用以下格式之一：flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。\n     */\n    @NonNull\n    private File file;\n\n    /**\n     * 要使用的模型的 ID。目前只有 whisper-1 可用。\n     */\n    @NonNull\n    @Builder.Default\n    private String model = \"whisper-1\";\n\n    /**\n     * 一个可选文本，用于指导模型的样式或继续上一个音频片段。提示应与音频语言匹配。\n     */\n    private String prompt;\n\n    /**\n     * 输出的格式，采用以下选项之一：json、text、srt、verbose_json 或 vtt。\n     */\n    @JsonProperty(\"response_format\")\n    @Builder.Default\n    private String responseFormat = WhisperEnum.ResponseFormat.JSON.getValue();\n\n    /**\n     * 采样温度，介于 0 和 1 之间。较高的值（如 0.8）将使输出更加随机，而较低的值（如 0.2）将使其更具集中性和确定性。如果设置为 0，模型将使用对数概率自动提高温度，直到达到某些阈值。\n     */\n    @Builder.Default\n    private Double temperature = 0d;\n\n\n    public static class TranslationBuilder {\n        private File file;\n\n        public Translation.TranslationBuilder content(File file){\n            // 校验File是否为以下格式之一：flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。\n            if (file == null) {\n                throw new IllegalArgumentException(\"file is required\");\n            }\n\n            String[] allowedFormats = {\"flac\", \"mp3\", \"mp4\", \"mpeg\", \"mpga\", \"m4a\", \"ogg\", \"wav\", \"webm\"};\n            String fileName = file.getName().toLowerCase();\n            boolean isValidFormat = false;\n\n            for (String format : allowedFormats) {\n                if (fileName.endsWith(\".\" + format)) {\n                    isValidFormat = true;\n                    break;\n                }\n            }\n\n            if (!isValidFormat) {\n                throw new IllegalArgumentException(\"Invalid file format. Allowed formats are: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm.\");\n            }\n\n            this.file = file;\n            return this;\n        }\n    }\n\n    public void setFile(@NonNull File file) {\n        // 校验File是否为以下格式之一：flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。\n        if (file == null) {\n            throw new IllegalArgumentException(\"file is required\");\n        }\n\n        String[] allowedFormats = {\"flac\", \"mp3\", \"mp4\", \"mpeg\", \"mpga\", \"m4a\", \"ogg\", \"wav\", \"webm\"};\n        String fileName = file.getName().toLowerCase();\n        boolean isValidFormat = false;\n\n        for (String format : allowedFormats) {\n            if (fileName.endsWith(\".\" + format)) {\n                isValidFormat = true;\n                break;\n            }\n        }\n\n        if (!isValidFormat) {\n            throw new IllegalArgumentException(\"Invalid file format. Allowed formats are: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm.\");\n        }\n\n        this.file = file;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/TranslationResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/10/11 16:30\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class TranslationResponse {\n    private String task;\n    private String language;\n    private Double duration;\n    private String text;\n    private List<Segment> segments;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/entity/Word.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description word类\n * @Date 2024/10/11 12:04\n */\n\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class Word {\n    private String word;\n    private Double start;\n    private Double end;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/enums/AudioEnum.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.enums;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\nimport java.io.Serializable;\n\n/**\n * @Author cly\n * @Description 音频audio枚举类\n * @Date 2024/10/10 23:49\n */\npublic class AudioEnum {\n    @Getter\n    @AllArgsConstructor\n    public enum Voice implements Serializable {\n        ALLOY(\"alloy\"),\n        ECHO(\"echo\"),\n        FABLE(\"fable\"),\n        ONYX(\"onyx\"),\n        NOVA(\"nova\"),\n        SHIMMER(\"shimmer\"),\n        ;\n        private final String value;\n    }\n\n    @Getter\n    @AllArgsConstructor\n    public enum ResponseFormat implements Serializable {\n        MP3(\"mp3\"),\n        OPUS(\"opus\"),\n        AAC(\"aac\"),\n        FLAC(\"flac\"),\n        WAV(\"wav\"),\n        PCM(\"pcm\"),\n        ;\n        private final String value;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/audio/enums/WhisperEnum.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio.enums;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\nimport java.io.Serializable;\n\n/**\n * @Author cly\n * @Description Whisper枚举类\n * @Date 2024/10/10 23:56\n */\npublic class WhisperEnum {\n    @Getter\n    @AllArgsConstructor\n    public enum ResponseFormat implements Serializable {\n        JSON(\"json\"),\n        TEXT(\"text\"),\n        SRT(\"srt\"),\n        VERBOSE_JSON(\"verbose_json\"),\n        VTT(\"vtt\"),\n        ;\n        private final String value;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/OpenAiChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.*;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.*;\nimport okhttp3.sse.EventSource;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description OpenAi 聊天服务\n * @Date 2024/8/2 23:16\n */\n@Slf4j\npublic class OpenAiChatService implements IChatService {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private final OpenAiConfig openAiConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n\n    public OpenAiChatService(Configuration configuration) {\n        this.openAiConfig = configuration.getOpenAiConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n    }\n\n    public OpenAiChatService(Configuration configuration, OpenAiConfig openAiConfig) {\n        this.openAiConfig = openAiConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion)  throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = openAiConfig.getApiHost();\n            if(apiKey == null || \"\".equals(apiKey)) apiKey = openAiConfig.getApiKey();\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n            chatCompletion.setStream(false);\n            chatCompletion.setStreamOptions(null);\n\n            if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){\n                //List<Tool> tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions());\n                List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n                chatCompletion.setTools(tools);\n                if(tools == null){\n                    chatCompletion.setParallelToolCalls(null);\n                }\n            }\n            if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){\n\n            }else{\n                chatCompletion.setParallelToolCalls(null);\n            }\n\n\n            // 总token消耗\n            Usage allUsage = new Usage();\n            String finishReason = \"first\";\n\n            while(\"first\".equals(finishReason) || \"tool_calls\".equals(finishReason)){\n\n                finishReason = null;\n\n                // 构造请求\n                ObjectMapper mapper = new ObjectMapper();\n                String requestString = mapper.writeValueAsString(chatCompletion);\n\n                Request request = new Request.Builder()\n                        .header(\"Authorization\", \"Bearer \" + apiKey)\n                        .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getChatCompletionUrl()))\n                        .post(jsonBody(requestString))\n                        .build();\n\n                Response execute = okHttpClient.newCall(request).execute();\n                if (execute.isSuccessful() && execute.body() != null){\n                    ChatCompletionResponse chatCompletionResponse = mapper.readValue(execute.body().string(), ChatCompletionResponse.class);\n\n                    Choice choice = chatCompletionResponse.getChoices().get(0);\n                    finishReason = choice.getFinishReason();\n\n                    Usage usage = chatCompletionResponse.getUsage();\n                    allUsage.setCompletionTokens(allUsage.getCompletionTokens() + usage.getCompletionTokens());\n                    allUsage.setTotalTokens(allUsage.getTotalTokens() + usage.getTotalTokens());\n                    allUsage.setPromptTokens(allUsage.getPromptTokens() + usage.getPromptTokens());\n\n                    // 判断是否为函数调用返回\n                    if(\"tool_calls\".equals(finishReason)){\n                        if (passThroughToolCalls) {\n                            chatCompletionResponse.setUsage(allUsage);\n                            return chatCompletionResponse;\n                        }\n                        ChatMessage message = choice.getMessage();\n                        List<ToolCall> toolCalls = message.getToolCalls();\n\n                        List<ChatMessage> messages = new ArrayList<>(chatCompletion.getMessages());\n                        messages.add(message);\n\n                        // 添加 tool 消息\n                        for (ToolCall toolCall : toolCalls) {\n                            String functionName = toolCall.getFunction().getName();\n                            String arguments = toolCall.getFunction().getArguments();\n                            String functionResponse = ToolUtil.invoke(functionName, arguments);\n\n                            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n                        }\n                        chatCompletion.setMessages(messages);\n\n                    }else{\n                        // 其他情况直接返回\n                        chatCompletionResponse.setUsage(allUsage);\n\n\n                        return chatCompletionResponse;\n\n                    }\n\n                }else{\n                    return null;\n                }\n\n            }\n\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion)  throws Exception {\n        return chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = openAiConfig.getApiHost();\n            if(apiKey == null || \"\".equals(apiKey)) apiKey = openAiConfig.getApiKey();\n            chatCompletion.setStream(true);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n            StreamOptions streamOptions = chatCompletion.getStreamOptions();\n            if(streamOptions == null){\n                chatCompletion.setStreamOptions(new StreamOptions(true));\n            }\n\n            if((chatCompletion.getFunctions()!=null && !chatCompletion.getFunctions().isEmpty()) || (chatCompletion.getMcpServices()!=null && !chatCompletion.getMcpServices().isEmpty())){\n                //List<Tool> tools = ToolUtil.getAllFunctionTools(chatCompletion.getFunctions());\n                List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n\n\n                chatCompletion.setTools(tools);\n                if(tools == null){\n                    chatCompletion.setParallelToolCalls(null);\n                }\n            }\n\n            if (chatCompletion.getTools()!=null && !chatCompletion.getTools().isEmpty()){\n\n            }else{\n                chatCompletion.setParallelToolCalls(null);\n            }\n\n            String finishReason = \"first\";\n\n            while(\"first\".equals(finishReason) || \"tool_calls\".equals(finishReason)){\n\n                finishReason = null;\n                ObjectMapper mapper = new ObjectMapper();\n                String jsonString = mapper.writeValueAsString(chatCompletion);\n\n                Request request = new Request.Builder()\n                        .header(\"Authorization\", \"Bearer \" + apiKey)\n                        .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getChatCompletionUrl()))\n                        .post(jsonBody(jsonString))\n                        .build();\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> factory.newEventSource(request, eventSourceListener)\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n\n                // 需要调用函数\n                if(\"tool_calls\".equals(finishReason) && !toolCalls.isEmpty()){\n                    if (passThroughToolCalls) {\n                        return;\n                    }\n                    // 创建tool响应消息\n                    ChatMessage responseMessage = ChatMessage.withAssistant(eventSourceListener.getToolCalls());\n\n                    List<ChatMessage> messages = new ArrayList<>(chatCompletion.getMessages());\n                    messages.add(responseMessage);\n\n                    // 封装tool结果消息\n                    for (ToolCall toolCall : toolCalls) {\n                        String functionName = toolCall.getFunction().getName();\n                        String arguments = toolCall.getFunction().getArguments();\n                        String functionResponse = ToolUtil.invoke(functionName, arguments);\n\n                        messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n                    }\n                    eventSourceListener.setToolCalls(new ArrayList<>());\n                    eventSourceListener.setToolCall(null);\n                    chatCompletion.setMessages(messages);\n                }\n\n            }\n\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    private RequestBody jsonBody(String json) {\n        return RequestBody.create(json, JSON_MEDIA_TYPE);\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/ChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.tool.BuiltInToolContext;\nimport lombok.*;\n\nimport java.util.*;\n\n/**\n * @Author cly\n * @Description ChatCompletion 实体类\n * @Date 2024/8/3 18:00\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ChatCompletion {\n\n    /**\n     * 对话模型\n     */\n    @NonNull\n    private String model;\n\n    /**\n     * 消息内容\n     */\n    @NonNull\n    @Singular\n    private List<ChatMessage> messages;\n\n    /**\n     * 如果设置为 True，将会以 SSE（server-sent events）的形式以流式发送消息增量。消息流以 data: [DONE] 结尾\n     */\n    @Builder.Default\n    private Boolean stream = false;\n\n    /**\n     * 流式输出相关选项。只有在 stream 参数为 true 时，才可设置此参数。\n     */\n    @JsonProperty(\"stream_options\")\n    private StreamOptions streamOptions;\n\n    /**\n     * 介于 -2.0 和 2.0 之间的数字。如果该值为正，那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚，降低模型重复相同内容的可能性。\n     */\n    @Builder.Default\n    @JsonProperty(\"frequency_penalty\")\n    private Float frequencyPenalty = 0f;\n\n    /**\n     * 采样温度，介于 0 和 2 之间。更高的值，如 0.8，会使输出更随机，而更低的值，如 0.2，会使其更加集中和确定。\n     * 我们通常建议可以更改这个值或者更改 top_p，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    private Float temperature = 1f;\n\n    /**\n     * 作为调节采样温度的替代方案，模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。\n     * 我们通常建议修改这个值或者更改 temperature，但不建议同时对两者进行修改。\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 1f;\n\n    /**\n     * 限制一次请求中模型生成 completion 的最大 token 数。输入 token 和输出 token 的总长度受模型的上下文长度的限制。\n     */\n    @Deprecated\n    @JsonProperty(\"max_tokens\")\n    private Integer maxTokens;\n\n    @JsonProperty(\"max_completion_tokens\")\n    private Integer maxCompletionTokens;\n\n    /**\n     * 模型可能会调用的 tool 的列表。目前，仅支持 function 作为工具。使用此参数来提供以 JSON 作为输入参数的 function 列表。\n     */\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    /**\n     * MCP服务列表，用于集成MCP工具\n     * 支持字符串（服务器ID）或对象（服务器配置）\n     */\n    @JsonIgnore\n    private List<String> mcpServices;\n\n    /**\n     * 控制模型调用 tool 的行为。\n     * none 意味着模型不会调用任何 tool，而是生成一条消息。\n     * auto 意味着模型可以选择生成一条消息或调用一个或多个 tool。\n     * 当没有 tool 时，默认值为 none。如果有 tool 存在，默认值为 auto。\n     */\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    @Builder.Default\n    @JsonProperty(\"parallel_tool_calls\")\n    private Boolean parallelToolCalls = true;\n\n    /**\n     * Agent runtime helper: preserve streamed tool calls for runtime execution\n     * instead of provider-side auto invocation.\n     */\n    @JsonIgnore\n    private Boolean passThroughToolCalls;\n\n    @JsonIgnore\n    private BuiltInToolContext builtInToolContext;\n\n    /**\n     * 一个 object，指定模型必须输出的格式。\n     *\n     * 设置为 { \"type\": \"json_object\" } 以启用 JSON 模式，该模式保证模型生成的消息是有效的 JSON。\n     *\n     * 注意: 使用 JSON 模式时，你还必须通过系统或用户消息指示模型生成 JSON。\n     * 否则，模型可能会生成不断的空白字符，直到生成达到令牌限制，从而导致请求长时间运行并显得“卡住”。\n     * 此外，如果 finish_reason=\"length\"，这表示生成超过了 max_tokens 或对话超过了最大上下文长度，消息内容可能会被部分截断。\n     */\n    @JsonProperty(\"response_format\")\n    private Object responseFormat;\n\n    private String user;\n\n    @Builder.Default\n    private Integer n = 1;\n\n    /**\n     * 在遇到这些词时，API 将停止生成更多的 token。\n     */\n    private List<String> stop;\n\n    /**\n     * 介于 -2.0 和 2.0 之间的数字。如果该值为正，那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚，从而增加模型谈论新主题的可能性。\n     */\n    @Builder.Default\n    @JsonProperty(\"presence_penalty\")\n    private Float presencePenalty = 0f;\n\n    @JsonProperty(\"logit_bias\")\n    private Map logitBias;\n\n    /**\n     * 是否返回所输出 token 的对数概率。如果为 true，则在 message 的 content 中返回每个输出 token 的对数概率。\n     */\n    @Builder.Default\n    private Boolean logprobs = false;\n\n    /**\n     * 一个介于 0 到 20 之间的整数 N，指定每个输出位置返回输出概率 top N 的 token，且返回这些 token 的对数概率。指定此参数时，logprobs 必须为 true。\n     */\n    @JsonProperty(\"top_logprobs\")\n    private Integer topLogprobs;\n\n    /**\n     * 额外参数\n     */\n    @JsonProperty(\"parameters\")\n    @Singular\n    private Map<String, Object> parameters;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     * 如果 extraBody 中的字段与现有字段同名，extraBody 中的值会覆盖现有值\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonIgnore\n    private StreamExecutionOptions streamExecution;\n\n    /**\n     * Jackson 序列化时自动调用，将 extraBody 的内容展开到顶层\n     */\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n\n    public static class ChatCompletionBuilder {\n        private List<String> functions;\n        private List<String> mcpServices;\n\n        public ChatCompletion.ChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public ChatCompletion.ChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            if (functions != null) {\n                this.functions.addAll(functions);\n            }\n            return this;\n        }\n\n        public ChatCompletion.ChatCompletionBuilder mcpServices(String... mcpServices){\n            if (this.mcpServices == null) {\n                this.mcpServices = new ArrayList<>();\n            }\n            this.mcpServices.addAll(Arrays.asList(mcpServices));\n            return this;\n        }\n\n        public ChatCompletion.ChatCompletionBuilder mcpServices(List<String> mcpServices){\n            if (this.mcpServices == null) {\n                this.mcpServices = new ArrayList<>();\n            }\n            if (mcpServices != null) {\n                this.mcpServices.addAll(mcpServices);\n            }\n            return this;\n        }\n\n        public ChatCompletion.ChatCompletionBuilder mcpService(String mcpService){\n            if (this.mcpServices == null) {\n                this.mcpServices = new ArrayList<>();\n            }\n            this.mcpServices.add(mcpService);\n            return this;\n        }\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/ChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/11 19:45\n */\n\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ChatCompletionResponse {\n    /**\n     * 该对话的唯一标识符。\n     */\n    private String id;\n\n    /**\n     * 对象的类型, 其值为 chat.completion 或 chat.completion.chunk\n     */\n    private String object;\n\n    /**\n     * 创建聊天完成时的 Unix 时间戳（以秒为单位）。\n     */\n    private Long created;\n\n    /**\n     * 生成该 completion 的模型名。\n     */\n    private String model;\n\n    /**\n     * 该指纹代表模型运行时使用的后端配置。\n     */\n    @JsonProperty(\"system_fingerprint\")\n    private String systemFingerprint;\n\n    /**\n     * 模型生成的 completion 的选择列表。\n     */\n    private List<Choice> choices;\n\n    /**\n     * 该对话补全请求的用量信息。\n     */\n    private Usage usage;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/ChatMessage.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport lombok.*;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/3 18:14\n */\n@Data\n@Builder\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ChatMessage {\n    private Content content;\n    private String role;\n    private String name;\n    private String refusal;\n\n    @JsonProperty(\"reasoning_content\")\n    private String reasoningContent;\n\n    @JsonProperty(\"tool_call_id\")\n    private String toolCallId;\n\n    @JsonProperty(\"tool_calls\")\n    private List<ToolCall> toolCalls;\n\n    public ChatMessage(String userMessage) {\n        this.role = ChatMessageType.USER.getRole();\n        this.content = Content.ofText(userMessage);\n    }\n    public ChatMessage(ChatMessageType role, String message) {\n        this.role = role.getRole();\n        this.content = Content.ofText(message);\n    }\n    public ChatMessage(String role, String message) {\n        this.role = role;\n        this.content = Content.ofText(message);\n    }\n\n    public static ChatMessage withSystem(String content) {\n        return new ChatMessage(ChatMessageType.SYSTEM, content);\n    }\n\n    public static ChatMessage withUser(String content) {\n        return new ChatMessage(ChatMessageType.USER, content);\n    }\n    public static ChatMessage withUser(String content, String ...images) {\n        return ChatMessage.builder()\n                .role(ChatMessageType.USER.getRole())\n                .content(Content.ofMultiModals(Content.MultiModal.withMultiModal(content, images)))\n                .build();\n    }\n\n    public static ChatMessage withAssistant(String content) {\n        return new ChatMessage(ChatMessageType.ASSISTANT, content);\n    }\n    public static ChatMessage withAssistant(List<ToolCall> toolCalls) {\n        return ChatMessage.builder()\n                .role(ChatMessageType.ASSISTANT.getRole())\n                .toolCalls(toolCalls)\n                .build();\n    }\n\n    public static ChatMessage withAssistant(String content, List<ToolCall> toolCalls) {\n        return ChatMessage.builder()\n                .role(ChatMessageType.ASSISTANT.getRole())\n                .content(Content.ofText(content))\n                .toolCalls(toolCalls)\n                .build();\n    }\n\n    public static ChatMessage withTool(String content, String toolCallId) {\n        return ChatMessage.builder()\n                .role(ChatMessageType.TOOL.getRole())\n                .content(Content.ofText(content))\n                .toolCallId(toolCallId)\n                .build();\n    }\n\n\n\n\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/Choice.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\n/**\n * @Author cly\n * @Description 模型生成的 completion\n * @Date 2024/8/11 20:01\n */\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class Choice {\n    private Integer index;\n\n    private ChatMessage delta;\n    private ChatMessage message;\n\n    private Object logprobs;\n\n    /**\n     * 模型停止生成 token 的原因。\n     *\n     * [stop, length, content_filter, tool_calls, insufficient_system_resource]\n     *\n     * stop：模型自然停止生成，或遇到 stop 序列中列出的字符串。\n     * length：输出长度达到了模型上下文长度限制，或达到了 max_tokens 的限制。\n     * content_filter：输出内容因触发过滤策略而被过滤。\n     * tool_calls：函数调用。\n     * insufficient_system_resource：系统推理资源不足，生成被打断。\n     *\n     */\n    @JsonProperty(\"finish_reason\")\n    private String finishReason;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/Content.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport io.github.lnyocly.ai4j.platform.openai.chat.serializer.ContentDeserializer;\nimport lombok.*;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2025/2/11 0:46\n */\n@ToString\n@JsonDeserialize(using = ContentDeserializer.class)\npublic class Content {\n    private String text;       // 纯文本时使用\n    private List<MultiModal> multiModals; // 多模态时使用\n\n    // 纯文本构造方法\n    public static Content ofText(String text) {\n        Content instance = new Content();\n        instance.text = text;\n        return instance;\n    }\n\n    // 多模态构造方法\n    public static Content ofMultiModals(List<MultiModal> parts) {\n        Content instance = new Content();\n        instance.multiModals = parts;\n        return instance;\n    }\n\n    // 序列化逻辑\n    @JsonValue\n    public Object toJson() {\n        if (text != null) {\n            return text; // 直接返回\n        } else if (multiModals != null) {\n            return multiModals;\n        }\n        throw new IllegalStateException(\"Invalid content state\");\n    }\n\n    public String getText() { return text; }\n    public List<MultiModal> getMultiModals() { return multiModals; }\n\n\n    @Data\n    @Builder\n    @NoArgsConstructor\n    @AllArgsConstructor\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    @JsonInclude(JsonInclude.Include.NON_NULL)\n    public static class MultiModal {\n        @Builder.Default\n        private String type = Type.TEXT.type;\n        private String text;\n        @JsonProperty(\"image_url\")\n        private ImageUrl imageUrl;\n\n\n        @Data\n        @NoArgsConstructor\n        @AllArgsConstructor\n        public static class ImageUrl {\n            private String url;\n        }\n\n        @Getter\n        @AllArgsConstructor\n        public enum Type {\n            TEXT(\"text\", \"文本类型\"),\n            IMAGE_URL(\"image_url\", \"图片类型，可以为url或者base64\"),\n            ;\n            private final String type;\n            private final String info;\n        }\n\n        public static List<MultiModal> withMultiModal(String text, String... imageUrl) {\n            List<MultiModal> messages = new ArrayList<>();\n            messages.add(new MultiModal(MultiModal.Type.TEXT.getType(), text, null));\n            for (String url : imageUrl) {\n                messages.add(new MultiModal(MultiModal.Type.IMAGE_URL.getType(), null, new MultiModal.ImageUrl(url)));\n            }\n            return messages;\n        }\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/entity/StreamOptions.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 流式输出相关选项\n * @Date 2024/8/29 13:00\n */\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class StreamOptions {\n    @JsonProperty(\"include_usage\")\n    private Boolean includeUsage = true;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/enums/ChatMessageType.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat.enums;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/6 23:54\n */\n@Getter\n@AllArgsConstructor\npublic enum ChatMessageType {\n    SYSTEM(\"system\"),\n    USER(\"user\"),\n    ASSISTANT(\"assistant\"),\n    TOOL(\"tool\"),\n    ;\n\n    private final String role;\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/chat/serializer/ContentDeserializer.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat.serializer;\n\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Content;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2025/2/11 0:57\n */\npublic class ContentDeserializer extends JsonDeserializer<Content> {\n    @Override\n    public Content deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {\n        JsonNode node = p.getCodec().readTree(p);\n\n        if (node.isTextual()) {\n            return Content.ofText(node.asText());\n        } else if (node.isArray()) {\n            List<Content.MultiModal> parts = new ArrayList<>();\n            for (JsonNode element : node) {\n                Content.MultiModal part = p.getCodec().treeToValue(element, Content.MultiModal.class);\n                parts.add(part);\n            }\n            return Content.ofMultiModals(parts);\n        }\n        throw new IOException(\"Unsupported content format\");\n    }\n}"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/embedding/OpenAiEmbeddingService.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.embedding;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.*;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/7 17:40\n */\npublic class OpenAiEmbeddingService implements IEmbeddingService {\n\n    private final OpenAiConfig openAiConfig;\n    private final OkHttpClient okHttpClient;\n\n    public OpenAiEmbeddingService(Configuration configuration) {\n        this.openAiConfig = configuration.getOpenAiConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n    public OpenAiEmbeddingService(Configuration configuration, OpenAiConfig openAiConfig) {\n        this.openAiConfig = openAiConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n\n    @Override\n    public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq)  throws Exception  {\n        if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = openAiConfig.getApiHost();\n        if(apiKey == null || \"\".equals(apiKey)) apiKey = openAiConfig.getApiKey();\n        String jsonString = JSON.toJSONString(embeddingReq);\n\n        Request request = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getEmbeddingUrl()))\n                .post(RequestBody.create(jsonString, MediaType.get(Constants.APPLICATION_JSON)))\n                .build();\n        Response execute = okHttpClient.newCall(request).execute();\n        if (execute.isSuccessful() && execute.body() != null) {\n            return JSON.parseObject(execute.body().string(), EmbeddingResponse.class);\n        }\n        return null;\n    }\n\n    @Override\n    public EmbeddingResponse embedding(Embedding embeddingReq) throws Exception {\n        return embedding(null, null, embeddingReq);\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/embedding/entity/Embedding.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.embedding.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.*;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description Embedding 实体类\n * @Date 2024/8/7 17:20\n */\n@Data\n@Builder\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class Embedding {\n    /**\n     * 向量化文本\n     */\n    @NonNull\n    private Object input;\n\n    /**\n     * 向量模型\n     */\n    @NonNull\n    @Builder.Default\n    private String model = \"text-embedding-3-small\";\n    @JsonProperty(\"encoding_format\")\n    private String encodingFormat;\n\n    /**\n     * 向量维度 建议选择256、512、1024或2048维度\n     */\n    private String dimensions;\n    private String user;\n\n    public static class EmbeddingBuilder {\n        private Object input;\n        private Embedding.EmbeddingBuilder input(Object input){\n            this.input = input;\n            return this;\n        }\n\n        public Embedding.EmbeddingBuilder input(String input){\n            this.input = input;\n            return this;\n        }\n\n        public Embedding.EmbeddingBuilder input(List<String> content){\n            this.input = content;\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/embedding/entity/EmbeddingObject.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.embedding.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description embedding的处理结果\n * @Date 2024/8/7 17:30\n */\n@Data\n@Builder\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class EmbeddingObject {\n    /**\n     * 结果下标\n     */\n    private Integer index;\n\n    /**\n     * embedding的处理结果，返回向量化表征的数组\n     */\n    private List<Float> embedding;\n\n    /**\n     * 结果类型，目前为恒为 embedding\n     */\n    private String object;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/embedding/entity/EmbeddingResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.embedding.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description Embedding接口的返回结果\n * @Date 2024/8/7 17:44\n */\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@Builder\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class EmbeddingResponse {\n    private String object;\n    private List<EmbeddingObject> data;\n    private String model;\n    private Usage usage;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/OpenAiImageService.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.image;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.ImageSseListener;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGeneration;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamEvent;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageStreamError;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageUsage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IImageService;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport okhttp3.sse.EventSources;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport com.fasterxml.jackson.databind.JsonNode;\n\n/**\n * @Author cly\n * @Description OpenAI 图片生成服务\n * @Date 2026/1/31\n */\npublic class OpenAiImageService implements IImageService {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private final OpenAiConfig openAiConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n\n    public OpenAiImageService(Configuration configuration) {\n        this.openAiConfig = configuration.getOpenAiConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = EventSources.createFactory(okHttpClient);\n    }\n\n    @Override\n    public ImageGenerationResponse generate(String baseUrl, String apiKey, ImageGeneration imageGeneration) throws Exception {\n        if (baseUrl == null || \"\".equals(baseUrl)) {\n            baseUrl = openAiConfig.getApiHost();\n        }\n        if (apiKey == null || \"\".equals(apiKey)) {\n            apiKey = openAiConfig.getApiKey();\n        }\n\n        ObjectMapper mapper = new ObjectMapper();\n        String requestString = mapper.writeValueAsString(imageGeneration);\n\n        Request request = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getImageGenerationUrl()))\n                .post(RequestBody.create(requestString, JSON_MEDIA_TYPE))\n                .build();\n\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return mapper.readValue(response.body().string(), ImageGenerationResponse.class);\n            }\n        }\n\n        throw new CommonException(\"OpenAI 图片生成请求失败\");\n    }\n\n    @Override\n    public ImageGenerationResponse generate(ImageGeneration imageGeneration) throws Exception {\n        return this.generate(null, null, imageGeneration);\n    }\n\n    @Override\n    public void generateStream(String baseUrl, String apiKey, ImageGeneration imageGeneration, ImageSseListener listener) throws Exception {\n        if (baseUrl == null || \"\".equals(baseUrl)) {\n            baseUrl = openAiConfig.getApiHost();\n        }\n        if (apiKey == null || \"\".equals(apiKey)) {\n            apiKey = openAiConfig.getApiKey();\n        }\n        if (imageGeneration.getStream() == null || !imageGeneration.getStream()) {\n            imageGeneration.setStream(true);\n        }\n\n        ObjectMapper mapper = new ObjectMapper();\n        String requestString = mapper.writeValueAsString(imageGeneration);\n\n        Request request = new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(UrlUtils.concatUrl(baseUrl, openAiConfig.getImageGenerationUrl()))\n                .post(RequestBody.create(requestString, JSON_MEDIA_TYPE))\n                .build();\n\n        factory.newEventSource(request, convertEventSource(mapper, listener));\n        listener.getCountDownLatch().await();\n    }\n\n    @Override\n    public void generateStream(ImageGeneration imageGeneration, ImageSseListener listener) throws Exception {\n        this.generateStream(null, null, imageGeneration, listener);\n    }\n\n    private EventSourceListener convertEventSource(ObjectMapper mapper, ImageSseListener listener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                // no-op\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                listener.onError(t, response);\n                listener.complete();\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    listener.complete();\n                    return;\n                }\n                try {\n                    ImageStreamEvent event = parseOpenAiEvent(mapper, data);\n                    listener.accept(event);\n\n                    if (\"image_generation.completed\".equals(event.getType())) {\n                        listener.complete();\n                    }\n                } catch (Exception e) {\n                    listener.onError(e, null);\n                    listener.complete();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                listener.complete();\n            }\n        };\n    }\n\n    private ImageStreamEvent parseOpenAiEvent(ObjectMapper mapper, String data) throws Exception {\n        JsonNode node = mapper.readTree(data);\n        ImageStreamEvent event = new ImageStreamEvent();\n        event.setType(asText(node, \"type\"));\n        event.setModel(asText(node, \"model\"));\n        Long createdAt = asLong(node, \"created_at\");\n        if (createdAt == null) {\n            createdAt = asLong(node, \"created\");\n        }\n        event.setCreatedAt(createdAt);\n        event.setPartialImageIndex(asInt(node, \"partial_image_index\"));\n        event.setImageIndex(asInt(node, \"image_index\"));\n        event.setUrl(asText(node, \"url\"));\n        String b64 = asText(node, \"b64_json\");\n        if (b64 == null) {\n            b64 = asText(node, \"partial_image_b64\");\n        }\n        event.setB64Json(b64);\n        event.setSize(asText(node, \"size\"));\n        event.setQuality(asText(node, \"quality\"));\n        event.setBackground(asText(node, \"background\"));\n        event.setOutputFormat(asText(node, \"output_format\"));\n        if (node.has(\"usage\")) {\n            event.setUsage(mapper.treeToValue(node.get(\"usage\"), ImageUsage.class));\n        }\n        if (node.has(\"error\")) {\n            event.setError(mapper.treeToValue(node.get(\"error\"), ImageStreamError.class));\n        }\n        return event;\n    }\n\n    private String asText(JsonNode node, String field) {\n        JsonNode value = node.get(field);\n        return value == null || value.isNull() ? null : value.asText();\n    }\n\n    private Integer asInt(JsonNode node, String field) {\n        JsonNode value = node.get(field);\n        return value == null || value.isNull() ? null : value.asInt();\n    }\n\n    private Long asLong(JsonNode node, String field) {\n        JsonNode value = node.get(field);\n        return value == null || value.isNull() ? null : value.asLong();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageData.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.image.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 图片数据项\n * @Date 2026/1/31\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ImageData {\n    private String url;\n\n    @JsonProperty(\"b64_json\")\n    private String b64Json;\n\n    @JsonProperty(\"revised_prompt\")\n    private String revisedPrompt;\n\n    /**\n     * 平台扩展字段（如部分平台返回尺寸）\n     */\n    private String size;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageGeneration.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.image.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.*;\n\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description OpenAI 图片生成请求参数\n * @Date 2026/1/31\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ImageGeneration {\n\n    /**\n     * 模型 ID\n     */\n    @NonNull\n    private String model;\n\n    /**\n     * 提示词\n     */\n    @NonNull\n    private String prompt;\n\n    /**\n     * 输出数量\n     */\n    private Integer n;\n\n    /**\n     * 输出尺寸，例如 1024x1024 / 1024x1536 / 1536x1024 / auto\n     */\n    private String size;\n\n    /**\n     * 输出质量，例如 low / medium / high / auto\n     */\n    private String quality;\n\n    /**\n     * 返回格式，例如 url / b64_json\n     */\n    @JsonProperty(\"response_format\")\n    private String responseFormat;\n\n    /**\n     * 输出格式，例如 png / jpeg / webp\n     */\n    @JsonProperty(\"output_format\")\n    private String outputFormat;\n\n    /**\n     * 输出压缩质量 (0-100)\n     */\n    @JsonProperty(\"output_compression\")\n    private Integer outputCompression;\n\n    /**\n     * 背景，例如 transparent / opaque / auto\n     */\n    private String background;\n\n    /**\n     * 部分图数量，用于流式输出\n     */\n    @JsonProperty(\"partial_images\")\n    private Integer partialImages;\n\n    /**\n     * 是否流式返回\n     */\n    private Boolean stream;\n\n    /**\n     * 风险控制或审计用户标识\n     */\n    private String user;\n\n    /**\n     * 额外参数，用于平台扩展\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    /**\n     * Jackson 序列化时将 extraBody 展开到顶层\n     */\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageGenerationResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.image.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description OpenAI 图片生成响应\n * @Date 2026/1/31\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ImageGenerationResponse {\n    private Long created;\n    private List<ImageData> data;\n    private ImageUsage usage;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageStreamError.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.image.entity;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 图片生成流式错误信息\n * @Date 2026/1/31\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ImageStreamError {\n    private String code;\n    private String message;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageStreamEvent.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.image.entity;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 图片生成流式事件（统一结构）\n * @Date 2026/1/31\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ImageStreamEvent {\n    private String type;\n    private String model;\n    private Long createdAt;\n    private Integer partialImageIndex;\n    private Integer imageIndex;\n    private String url;\n    private String b64Json;\n    private String size;\n    private String quality;\n    private String background;\n    private String outputFormat;\n    private ImageUsage usage;\n    private ImageStreamError error;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageUsage.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.image.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 图片生成用量信息\n * @Date 2026/1/31\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ImageUsage {\n    @JsonProperty(\"total_tokens\")\n    private Long totalTokens;\n\n    @JsonProperty(\"input_tokens\")\n    private Long inputTokens;\n\n    @JsonProperty(\"output_tokens\")\n    private Long outputTokens;\n\n    @JsonProperty(\"generated_images\")\n    private Integer generatedImages;\n\n    @JsonProperty(\"input_tokens_details\")\n    private ImageUsageDetails inputTokensDetails;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/image/entity/ImageUsageDetails.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.image.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description 图片生成用量明细\n * @Date 2026/1/31\n */\n@Data\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ImageUsageDetails {\n    @JsonProperty(\"text_tokens\")\n    private Long textTokens;\n\n    @JsonProperty(\"image_tokens\")\n    private Long imageTokens;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/OpenAiRealtimeService.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.realtime;\n\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.listener.RealtimeListener;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IRealtimeService;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.WebSocket;\n\n/**\n * @Author cly\n * @Description OpenAiRealtimeService\n * @Date 2024/10/12 16:39\n */\npublic class OpenAiRealtimeService implements IRealtimeService {\n    private final OpenAiConfig openAiConfig;\n    private final OkHttpClient okHttpClient;\n\n    public OpenAiRealtimeService(Configuration configuration) {\n        this.openAiConfig = configuration.getOpenAiConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n    public OpenAiRealtimeService(Configuration configuration, OpenAiConfig openAiConfig) {\n        this.openAiConfig = openAiConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n\n    @Override\n    public WebSocket createRealtimeClient(String baseUrl, String apiKey, String model, RealtimeListener realtimeListener) {\n        if(baseUrl == null || \"\".equals(baseUrl)) baseUrl = openAiConfig.getApiHost(); // url为HTTPS不影响\n        if(apiKey == null || \"\".equals(apiKey)) apiKey = openAiConfig.getApiKey();\n\n        String url = UrlUtils.concatUrl(baseUrl, openAiConfig.getRealtimeUrl(), \"?model=\" + model);\n        Request request = new Request.Builder()\n                .url(url)\n                .addHeader(\"Authorization\", \"Bearer \" + apiKey)\n                .addHeader(\"OpenAI-Beta\", \"realtime=v1\")\n                .build();\n        return okHttpClient.newWebSocket(request, realtimeListener);\n\n    }\n\n    @Override\n    public WebSocket createRealtimeClient(String model, RealtimeListener realtimeListener) {\n        return this.createRealtimeClient(null, null, model, realtimeListener);\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/RealtimeConstant.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.realtime;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/10/13 13:56\n */\npublic class RealtimeConstant {\n    /**\n     * session.update\n     * input_audio_buffer.append\n     * input_audio_buffer.commit\n     * input_audio_buffer.clear\n     * conversation.item.create\n     * conversation.item.truncate\n     * conversation.item.delete\n     * response.create\n     * response.cancel\n     */\n    public static class ClientEvent {\n        public static final String SESSION_UPDATE = \"session.update\";\n        public static final String INPUT_AUDIO_BUFFER_APPEND = \"input_audio_buffer.append\";\n        public static final String INPUT_AUDIO_BUFFER_COMMIT = \"input_audio_buffer.commit\";\n        public static final String INPUT_AUDIO_BUFFER_CLEAR = \"input_audio_buffer.clear\";\n        public static final String CONVERSATION_ITEM_CREATE = \"conversation.item.create\";\n        public static final String CONVERSATION_ITEM_TRUNCATE = \"conversation.item.truncate\";\n        public static final String CONVERSATION_ITEM_DELETE = \"conversation.item.delete\";\n        /**\n         * 发送此事件可触发响应生成。\n         */\n        public static final String RESPONSE_CREATE = \"response.create\";\n        public static final String RESPONSE_CANCEL = \"response.cancel\";\n    }\n\n    /**\n     * error\n     * session.created\n     * session.updated\n     * conversation.created\n     * input_audio_buffer.committed\n     * input_audio_buffer.cleared\n     * input_audio_buffer.speech_started\n     * input_audio_buffer.speech_stopped\n     * conversation.item.created\n     * conversation.item.input_audio_transcription.completed\n     * conversation.item.input_audio_transcription.failed\n     * conversation.item.truncated\n     * conversation.item.deleted\n     * response.created\n     * response.done\n     * response.output_item.added\n     * response.output_item.done\n     * response.content_part.added\n     * response.content_part.done\n     * response.text.delta\n     * response.text.done\n     * response.audio_transcript.delta\n     * response.audio_transcript.done\n     * response.audio.delta\n     * response.audio.done\n     * response.function_call_arguments.delta\n     * response.function_call_arguments.done\n     * rate_limits.updated\n     */\n    public static class ServerEvent {\n        public static final String ERROR = \"error\";\n        public static final String SESSION_CREATED = \"session.created\";\n        public static final String SESSION_UPDATED = \"session.updated\";\n        /**\n         * 创建对话时返回。会话创建后立即发出。\n         */\n        public static final String CONVERSATION_CREATED = \"conversation.created\";\n        public static final String INPUT_AUDIO_BUFFER_COMMITTED = \"input_audio_buffer.committed\";\n        public static final String INPUT_AUDIO_BUFFER_CLEARED = \"input_audio_buffer.cleared\";\n        public static final String INPUT_AUDIO_BUFFER_SPEECH_STARTED = \"input_audio_buffer.speech_started\";\n        public static final String INPUT_AUDIO_BUFFER_SPEECH_STOPPED = \"input_audio_buffer.speech_stopped\";\n        /**\n         * 创建对话项目时返回。\n         */\n        public static final String CONVERSATION_ITEM_CREATED = \"conversation.item.created\";\n        public static final String CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_COMPLETED = \"conversation.item.input_audio_transcription.completed\";\n        public static final String CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_FAILED = \"conversation.item.input_audio_transcription.failed\";\n        public static final String CONVERSATION_ITEM_TRUNCATED = \"conversation.item.truncated\";\n        public static final String CONVERSATION_ITEM_DELETED = \"conversation.item.deleted\";\n        public static final String RESPONSE_CREATED = \"response.created\";\n        /**\n         * 当响应完成流式传输时返回。无论最终状态如何，始终发出。\n         */\n        public static final String RESPONSE_DONE = \"response.done\";\n        public static final String RESPONSE_OUTPUT_ITEM_ADDED = \"response.output_item.added\";\n        public static final String RESPONSE_OUTPUT_ITEM_DONE = \"response.output_item.done\";\n        /**\n         * 在生成回复过程中将新内容添加到助理信息项目时返回。\n         */\n        public static final String RESPONSE_CONTENT_PART_ADDED = \"response.content_part.added\";\n        /**\n         * 当助手信息项目中的内容部分完成流式传输时返回。当响应中断、不完整或取消时也会返回。\n         */\n        public static final String RESPONSE_CONTENT_PART_DONE = \"response.content_part.done\";\n        /**\n         * 当“文本”内容部分的文本值更新时返回。\n         */\n        public static final String RESPONSE_TEXT_DELTA = \"response.text.delta\";\n        /**\n         * 当 “文本 ”内容部分的文本值完成流式传输时返回。当响应被中断、不完整或取消时也会返回。\n         */\n        public static final String RESPONSE_TEXT_DONE = \"response.text.done\";\n        public static final String RESPONSE_AUDIO_TRANSCRIPT_DELTA = \"response.audio_transcript.delta\";\n        public static final String RESPONSE_AUDIO_TRANSCRIPT_DONE = \"response.audio_transcript.done\";\n        /**\n         * 当模型生成的音频更新时返回。\n         */\n        public static final String RESPONSE_AUDIO_DELTA = \"response.audio.delta\";\n        /**\n         * 当模型生成的音频完成时返回。当响应被中断、不完整或取消时也会发出。\n         */\n        public static final String RESPONSE_AUDIO_DONE = \"response.audio.done\";\n        public static final String RESPONSE_FUNCTION_CALL_ARGUMENTS_DELTA = \"response.function_call_arguments.delta\";\n        public static final String RESPONSE_FUNCTION_CALL_ARGUMENTS_DONE = \"response.function_call_arguments.done\";\n        public static final String RATE_LIMITS_UPDATED = \"rate_limits.updated\";\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/entity/ConversationCreated.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.realtime.entity;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/10/12 18:01\n */\npublic class ConversationCreated {\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/entity/Session.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.realtime.entity;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/10/12 17:24\n */\npublic class Session {\n    /**\n     * 模型可以响应的一组模式。要禁用音频，请将其设置为 [“text”]。\n     */\n    private List<String> modalities;\n\n    /**\n     * 默认系统指令添加到模型调用之前。\n     */\n    private String instructions;\n\n    /**\n     * 模型用于响应的声音 - alloy 、 echo或shimmer之一。一旦模型至少响应一次音频，就无法更改。\n     */\n    private String voice;\n\n    /**\n     * 输入音频的格式。选项为“pcm16”、“g711_ulaw”或“g711_alaw”。\n     */\n    private String input_audio_format;\n\n    /**\n     * 输出音频的格式。选项为“pcm16”、“g711_ulaw”或“g711_alaw”。\n     */\n    private String output_audio_format;\n\n\n    /**\n     * 输入音频转录的配置。可以设置为null来关闭。\n     */\n    private Object input_audio_transcription;\n\n\n    /**\n     * 转弯检测的配置。可以设置为null来关闭。\n     */\n    private Object turn_detection;\n\n\n    private Object tools;\n\n    private String tool_choice;\n    private Double temperature;\n    private Integer max_output_tokens;\n\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/entity/SessionCreated.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.realtime.entity;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/10/12 17:58\n */\npublic class SessionCreated {\n    private String event_id;\n    private String type = \"session.created\";\n    private Session session;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/realtime/entity/SessionUpdated.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.realtime.entity;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/10/12 17:58\n */\npublic class SessionUpdated {\n    private String event_id;\n    private String type = \"session.updated\";\n    private Session session;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/OpenAiResponsesService.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.ResponseSseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\nimport io.github.lnyocly.ai4j.tool.ResponseRequestToolResolver;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.Set;\n\n/**\n * @Author cly\n * @Description OpenAI Responses API service\n * @Date 2026/2/1\n */\npublic class OpenAiResponsesService implements IResponsesService {\n\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private static final Set<String> OPENAI_ALLOWED_FIELDS = new java.util.HashSet<String>(java.util.Arrays.asList(\n            \"model\",\n            \"input\",\n            \"include\",\n            \"instructions\",\n            \"max_output_tokens\",\n            \"metadata\",\n            \"parallel_tool_calls\",\n            \"previous_response_id\",\n            \"reasoning\",\n            \"store\",\n            \"stream\",\n            \"stream_options\",\n            \"temperature\",\n            \"text\",\n            \"tool_choice\",\n            \"tools\",\n            \"top_p\",\n            \"truncation\",\n            \"user\",\n            \"background\"\n    ));\n\n    private final OpenAiConfig openAiConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n    private final ObjectMapper objectMapper;\n\n    public OpenAiResponsesService(Configuration configuration) {\n        this.openAiConfig = configuration.getOpenAiConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    @Override\n    public Response create(String baseUrl, String apiKey, ResponseRequest request) throws Exception {\n        String url = resolveUrl(baseUrl, openAiConfig.getResponsesUrl());\n        String key = resolveApiKey(apiKey);\n        request.setStream(false);\n        request.setStreamOptions(null);\n        request = ResponseRequestToolResolver.resolve(request);\n\n        Request httpRequest = buildJsonPostRequest(url, key, serializeRequest(request));\n        return executeJsonRequest(httpRequest, Response.class, \"OpenAI Responses request failed\");\n    }\n\n    @Override\n    public Response create(ResponseRequest request) throws Exception {\n        return create(null, null, request);\n    }\n\n    @Override\n    public void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) throws Exception {\n        String url = resolveUrl(baseUrl, openAiConfig.getResponsesUrl());\n        String key = resolveApiKey(apiKey);\n        if (request.getStream() == null || !request.getStream()) {\n            request.setStream(true);\n        }\n        if (request.getStreamOptions() == null) {\n            request.setStreamOptions(new StreamOptions(true));\n        }\n        request = ResponseRequestToolResolver.resolve(request);\n\n        Request httpRequest = buildJsonPostRequest(url, key, serializeRequest(request));\n\n        StreamExecutionSupport.execute(\n                listener,\n                request.getStreamExecution(),\n                () -> factory.newEventSource(httpRequest, convertEventSource(listener))\n        );\n    }\n\n    @Override\n    public void createStream(ResponseRequest request, ResponseSseListener listener) throws Exception {\n        createStream(null, null, request, listener);\n    }\n\n    @Override\n    public Response retrieve(String baseUrl, String apiKey, String responseId) throws Exception {\n        String url = resolveUrl(baseUrl, openAiConfig.getResponsesUrl() + \"/\" + responseId);\n        String key = resolveApiKey(apiKey);\n        Request httpRequest = authorizedRequestBuilder(url, key)\n                .get()\n                .build();\n        return executeJsonRequest(httpRequest, Response.class, \"OpenAI Responses retrieve failed\");\n    }\n\n    @Override\n    public Response retrieve(String responseId) throws Exception {\n        return retrieve(null, null, responseId);\n    }\n\n    @Override\n    public ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) throws Exception {\n        String url = resolveUrl(baseUrl, openAiConfig.getResponsesUrl() + \"/\" + responseId);\n        String key = resolveApiKey(apiKey);\n        Request httpRequest = authorizedRequestBuilder(url, key)\n                .delete()\n                .build();\n        return executeJsonRequest(httpRequest, ResponseDeleteResponse.class, \"OpenAI Responses delete failed\");\n    }\n\n    @Override\n    public ResponseDeleteResponse delete(String responseId) throws Exception {\n        return delete(null, null, responseId);\n    }\n\n    private String resolveUrl(String baseUrl, String path) {\n        String host = (baseUrl == null || \"\".equals(baseUrl)) ? openAiConfig.getApiHost() : baseUrl;\n        return UrlUtils.concatUrl(host, path);\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? openAiConfig.getApiKey() : apiKey;\n    }\n\n    private String serializeRequest(ResponseRequest request) throws Exception {\n        return objectMapper.writeValueAsString(buildOpenAiPayload(request));\n    }\n\n    private Request buildJsonPostRequest(String url, String apiKey, String body) {\n        return authorizedRequestBuilder(url, apiKey)\n                .post(RequestBody.create(body, JSON_MEDIA_TYPE))\n                .build();\n    }\n\n    private Request.Builder authorizedRequestBuilder(String url, String apiKey) {\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + apiKey)\n                .url(url);\n    }\n\n    private <T> T executeJsonRequest(Request request, Class<T> responseType, String failureMessage) throws Exception {\n        try (okhttp3.Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return objectMapper.readValue(response.body().string(), responseType);\n            }\n        }\n        throw new CommonException(failureMessage);\n    }\n\n    private Map<String, Object> buildOpenAiPayload(ResponseRequest request) {\n        Map<String, Object> payload = new LinkedHashMap<>();\n        if (request.getModel() != null) {\n            payload.put(\"model\", request.getModel());\n        }\n        if (request.getInput() != null) {\n            payload.put(\"input\", request.getInput());\n        }\n        if (request.getInclude() != null) {\n            payload.put(\"include\", request.getInclude());\n        }\n        if (request.getInstructions() != null) {\n            payload.put(\"instructions\", request.getInstructions());\n        }\n        if (request.getMaxOutputTokens() != null) {\n            payload.put(\"max_output_tokens\", request.getMaxOutputTokens());\n        }\n        if (request.getMetadata() != null) {\n            payload.put(\"metadata\", request.getMetadata());\n        }\n        if (request.getParallelToolCalls() != null) {\n            payload.put(\"parallel_tool_calls\", request.getParallelToolCalls());\n        }\n        if (request.getPreviousResponseId() != null) {\n            payload.put(\"previous_response_id\", request.getPreviousResponseId());\n        }\n        if (request.getReasoning() != null) {\n            payload.put(\"reasoning\", request.getReasoning());\n        }\n        if (request.getStore() != null) {\n            payload.put(\"store\", request.getStore());\n        }\n        if (request.getStream() != null) {\n            payload.put(\"stream\", request.getStream());\n        }\n        if (request.getStreamOptions() != null) {\n            payload.put(\"stream_options\", request.getStreamOptions());\n        }\n        if (request.getTemperature() != null) {\n            payload.put(\"temperature\", request.getTemperature());\n        }\n        if (request.getText() != null) {\n            payload.put(\"text\", request.getText());\n        }\n        if (request.getToolChoice() != null) {\n            payload.put(\"tool_choice\", request.getToolChoice());\n        }\n        if (request.getTools() != null) {\n            payload.put(\"tools\", request.getTools());\n        }\n        if (request.getTopP() != null) {\n            payload.put(\"top_p\", request.getTopP());\n        }\n        if (request.getTruncation() != null) {\n            payload.put(\"truncation\", request.getTruncation());\n        }\n        if (request.getUser() != null) {\n            payload.put(\"user\", request.getUser());\n        }\n        if (request.getExtraBody() != null) {\n            for (Map.Entry<String, Object> entry : request.getExtraBody().entrySet()) {\n                if (OPENAI_ALLOWED_FIELDS.contains(entry.getKey()) && !payload.containsKey(entry.getKey())) {\n                    payload.put(entry.getKey(), entry.getValue());\n                }\n            }\n        }\n        return payload;\n    }\n\n    private EventSourceListener convertEventSource(ResponseSseListener listener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull okhttp3.Response response) {\n                listener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable okhttp3.Response response) {\n                listener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    listener.complete();\n                    return;\n                }\n                try {\n                    ResponseStreamEvent event = ResponseEventParser.parse(objectMapper, data);\n                    listener.accept(event);\n                    if (isTerminalEvent(event.getType())) {\n                        listener.complete();\n                    }\n                } catch (Exception e) {\n                    listener.onError(e, null);\n                    listener.complete();\n                }\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                listener.onClosed(eventSource);\n            }\n        };\n    }\n\n    private boolean isTerminalEvent(String type) {\n        if (type == null) {\n            return false;\n        }\n        return \"response.completed\".equals(type)\n                || \"response.failed\".equals(type)\n                || \"response.incomplete\".equals(type);\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/ResponseEventParser.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response;\n\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseError;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseStreamEvent;\n\n\npublic final class ResponseEventParser {\n\n    private ResponseEventParser() {\n    }\n\n    public static ResponseStreamEvent parse(ObjectMapper mapper, String data) throws Exception {\n        JsonNode node = mapper.readTree(data);\n        ResponseStreamEvent event = new ResponseStreamEvent();\n        event.setRaw(node);\n        event.setType(asText(node, \"type\"));\n        event.setSequenceNumber(asInt(node, \"sequence_number\"));\n        event.setOutputIndex(asInt(node, \"output_index\"));\n        event.setContentIndex(asInt(node, \"content_index\"));\n        event.setItemId(asText(node, \"item_id\"));\n        event.setDelta(asText(node, \"delta\"));\n        event.setText(asText(node, \"text\"));\n        event.setArguments(asText(node, \"arguments\"));\n        event.setCallId(asText(node, \"call_id\"));\n        if (node.has(\"response\") && !node.get(\"response\").isNull()) {\n            event.setResponse(mapper.treeToValue(node.get(\"response\"), Response.class));\n        }\n        if (node.has(\"error\") && !node.get(\"error\").isNull()) {\n            event.setError(mapper.treeToValue(node.get(\"error\"), ResponseError.class));\n        }\n        return event;\n    }\n\n    private static String asText(JsonNode node, String field) {\n        JsonNode value = node.get(field);\n        return value == null || value.isNull() ? null : value.asText();\n    }\n\n    private static Integer asInt(JsonNode node, String field) {\n        JsonNode value = node.get(field);\n        return value == null || value.isNull() ? null : value.asInt();\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ImagePixelLimit.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\n\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ImagePixelLimit {\n\n    @JsonProperty(\"min_pixels\")\n    private Integer minPixels;\n\n    @JsonProperty(\"max_pixels\")\n    private Integer maxPixels;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/Response.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\nimport java.util.List;\nimport java.util.Map;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class Response {\n\n    private String id;\n\n    private String object;\n\n    @JsonProperty(\"created_at\")\n    private Long createdAt;\n\n    private String model;\n\n    private String status;\n\n    private List<ResponseItem> output;\n\n    private ResponseError error;\n\n    @JsonProperty(\"incomplete_details\")\n    private ResponseIncompleteDetails incompleteDetails;\n\n    private String instructions;\n\n    @JsonProperty(\"max_output_tokens\")\n    private Integer maxOutputTokens;\n\n    @JsonProperty(\"previous_response_id\")\n    private String previousResponseId;\n\n    private ResponseUsage usage;\n\n    @JsonProperty(\"service_tier\")\n    private String serviceTier;\n\n    private Map<String, Object> metadata;\n\n    @JsonProperty(\"context_management\")\n    private ResponseContextManagement contextManagement;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseContentPart.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.*;\n\nimport java.util.Map;\n\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseContentPart {\n\n    private String type;\n\n    private String text;\n\n    @JsonProperty(\"image_url\")\n    private String imageUrl;\n\n    @JsonProperty(\"file_id\")\n    private String fileId;\n\n    @JsonProperty(\"file_url\")\n    private String fileUrl;\n\n    @JsonProperty(\"file_data\")\n    private String fileData;\n\n    @JsonProperty(\"video_url\")\n    private String videoUrl;\n\n    private String detail;\n\n    @JsonProperty(\"image_pixel_limit\")\n    private ImagePixelLimit imagePixelLimit;\n\n    @JsonProperty(\"translation_options\")\n    private TranslationOptions translationOptions;\n\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseContextEdit.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseContextEdit {\n\n    private String type;\n\n    @JsonProperty(\"cleared_thinking_turns\")\n    private Integer clearedThinkingTurns;\n\n    @JsonProperty(\"cleared_tool_uses\")\n    private Integer clearedToolUses;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseContextManagement.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\nimport java.util.List;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseContextManagement {\n\n    @JsonProperty(\"applied_edits\")\n    private List<ResponseContextEdit> appliedEdits;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseDeleteResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseDeleteResponse {\n    private String id;\n    private String object;\n    private Boolean deleted;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseError.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseError {\n    private String code;\n    private String message;\n    private String type;\n    private String param;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseIncompleteDetails.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseIncompleteDetails {\n    private String reason;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseItem.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\nimport java.util.List;\nimport java.util.Map;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseItem {\n\n    private String id;\n\n    private String type;\n\n    private String role;\n\n    private String status;\n\n    private Boolean partial;\n\n    private List<ResponseContentPart> content;\n\n    @JsonProperty(\"call_id\")\n    private String callId;\n\n    private String name;\n\n    private String arguments;\n\n    private String output;\n\n    @JsonProperty(\"server_label\")\n    private String serverLabel;\n\n    private List<ResponseSummary> summary;\n\n    @JsonIgnore\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseRequest.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.StreamOptions;\nimport lombok.*;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseRequest {\n\n    @NonNull\n    private String model;\n\n    \n    private Object input;\n\n    \n    private List<String> include;\n\n    \n    private String instructions;\n\n    @JsonProperty(\"previous_response_id\")\n    private String previousResponseId;\n\n    @JsonProperty(\"max_output_tokens\")\n    private Integer maxOutputTokens;\n\n    private Map<String, Object> metadata;\n\n    @JsonProperty(\"parallel_tool_calls\")\n    private Boolean parallelToolCalls;\n\n    \n    private Object reasoning;\n\n    private Boolean store;\n\n    private Boolean stream;\n\n    @JsonProperty(\"stream_options\")\n    private StreamOptions streamOptions;\n\n    private Double temperature;\n\n    \n    private Object text;\n\n    @JsonProperty(\"tool_choice\")\n    private Object toolChoice;\n\n    private List<Object> tools;\n\n    @JsonIgnore\n    private List<String> functions;\n\n    @JsonIgnore\n    private List<String> mcpServices;\n\n    @JsonProperty(\"top_p\")\n    private Double topP;\n\n    private String truncation;\n\n    private String user;\n\n    \n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonIgnore\n    private StreamExecutionOptions streamExecution;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n    public List<String> getFunctions() {\n        return functions;\n    }\n\n    public void setFunctions(List<String> functions) {\n        this.functions = functions;\n    }\n\n    public List<String> getMcpServices() {\n        return mcpServices;\n    }\n\n    public void setMcpServices(List<String> mcpServices) {\n        this.mcpServices = mcpServices;\n    }\n\n    public static class ResponseRequestBuilder {\n        private List<String> functions;\n        private List<String> mcpServices;\n\n        public ResponseRequestBuilder functions(String... functions) {\n            if (this.functions == null) {\n                this.functions = new ArrayList<String>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public ResponseRequestBuilder functions(List<String> functions) {\n            if (this.functions == null) {\n                this.functions = new ArrayList<String>();\n            }\n            if (functions != null) {\n                this.functions.addAll(functions);\n            }\n            return this;\n        }\n\n        public ResponseRequestBuilder mcpServices(String... mcpServices) {\n            if (this.mcpServices == null) {\n                this.mcpServices = new ArrayList<String>();\n            }\n            this.mcpServices.addAll(Arrays.asList(mcpServices));\n            return this;\n        }\n\n        public ResponseRequestBuilder mcpServices(List<String> mcpServices) {\n            if (this.mcpServices == null) {\n                this.mcpServices = new ArrayList<String>();\n            }\n            if (mcpServices != null) {\n                this.mcpServices.addAll(mcpServices);\n            }\n            return this;\n        }\n\n        public ResponseRequestBuilder mcpService(String mcpService) {\n            if (this.mcpServices == null) {\n                this.mcpServices = new ArrayList<String>();\n            }\n            this.mcpServices.add(mcpService);\n            return this;\n        }\n\n        public ResponseRequestBuilder toolRegistry(List<String> functions, List<String> mcpServices) {\n            functions(functions);\n            mcpServices(mcpServices);\n            return this;\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseStreamEvent.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport lombok.Data;\n\n\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseStreamEvent {\n\n    private String type;\n\n    @JsonProperty(\"sequence_number\")\n    private Integer sequenceNumber;\n\n    private Response response;\n\n    @JsonProperty(\"output_index\")\n    private Integer outputIndex;\n\n    @JsonProperty(\"content_index\")\n    private Integer contentIndex;\n\n    @JsonProperty(\"item_id\")\n    private String itemId;\n\n    private String delta;\n\n    private String text;\n\n    private String arguments;\n\n    @JsonProperty(\"call_id\")\n    private String callId;\n\n    private ResponseError error;\n\n    @JsonIgnore\n    private JsonNode raw;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseSummary.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseSummary {\n    private String type;\n    private String text;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseToolUsage.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseToolUsage {\n\n    @JsonProperty(\"image_process\")\n    private Integer imageProcess;\n\n    private Integer mcp;\n\n    @JsonProperty(\"web_search\")\n    private Integer webSearch;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseToolUsageDetails.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseToolUsageDetails {\n\n    @JsonProperty(\"image_process\")\n    private Object imageProcess;\n\n    private Object mcp;\n\n    @JsonProperty(\"web_search\")\n    private Object webSearch;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseUsage.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseUsage {\n\n    @JsonProperty(\"input_tokens\")\n    private Integer inputTokens;\n\n    @JsonProperty(\"output_tokens\")\n    private Integer outputTokens;\n\n    @JsonProperty(\"total_tokens\")\n    private Integer totalTokens;\n\n    @JsonProperty(\"input_tokens_details\")\n    private ResponseUsageDetails inputTokensDetails;\n\n    @JsonProperty(\"output_tokens_details\")\n    private ResponseUsageDetails outputTokensDetails;\n\n    @JsonProperty(\"tool_usage\")\n    private ResponseToolUsage toolUsage;\n\n    @JsonProperty(\"tool_usage_details\")\n    private ResponseToolUsageDetails toolUsageDetails;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/ResponseUsageDetails.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\n\n@Data\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ResponseUsageDetails {\n\n    @JsonProperty(\"cached_tokens\")\n    private Integer cachedTokens;\n\n    @JsonProperty(\"text_tokens\")\n    private Integer textTokens;\n\n    @JsonProperty(\"audio_tokens\")\n    private Integer audioTokens;\n\n    @JsonProperty(\"image_tokens\")\n    private Integer imageTokens;\n\n    @JsonProperty(\"reasoning_tokens\")\n    private Integer reasoningTokens;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/response/entity/TranslationOptions.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response.entity;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.Data;\n\n\n@Data\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class TranslationOptions {\n\n    @JsonProperty(\"source_language\")\n    private String sourceLanguage;\n\n    @JsonProperty(\"target_language\")\n    private String targetLanguage;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/tool/Tool.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.tool;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/12 14:55\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class Tool {\n\n    /**\n     * 工具类型，目前为“function”\n     */\n    private String type;\n    private Function function;\n\n    @Data\n    @AllArgsConstructor\n    @NoArgsConstructor\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    @JsonInclude(JsonInclude.Include.NON_NULL)\n    public static class Function {\n\n        /**\n         * 函数名称\n         */\n        private String name;\n\n        /**\n         * 函数描述\n         */\n        private String description;\n\n        /**\n         * 函数的参数 key为参数名称，value为参数属性\n         */\n        private Parameter parameters;\n\n\n\n        @Data\n        @AllArgsConstructor\n        @NoArgsConstructor\n        @JsonIgnoreProperties(ignoreUnknown = true)\n        @JsonInclude(JsonInclude.Include.NON_NULL)\n        public static class Parameter {\n\n            private String type = \"object\";\n\n            /**\n             * 函数的参数 key为参数名称，value为参数属性\n             */\n            private Map<String, Property> properties;\n\n            /**\n             * 必须的参数\n             */\n            private List<String> required;\n\n        }\n\n        @Data\n        @AllArgsConstructor\n        @NoArgsConstructor\n        @JsonIgnoreProperties(ignoreUnknown = true)\n        @JsonInclude(JsonInclude.Include.NON_NULL)\n        public static class Property {\n            /**\n             * 属性类型\n             */\n            private String type;\n\n            /**\n             * 属性描述\n             */\n            private String description;\n\n            /**\n             * 枚举项\n             */\n            @JsonProperty(\"enum\")\n            private List<String> enumValues;\n\n            /**\n             * 数组元素类型定义（当type为array时使用）\n             */\n            private Property items;\n        }\n\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/tool/ToolCall.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.tool;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/13 2:07\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ToolCall {\n    private String id;\n    private String type;\n    private Function function;\n\n\n    @Data\n    @AllArgsConstructor\n    @NoArgsConstructor\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    @JsonInclude(JsonInclude.Include.NON_NULL)\n    public static class Function {\n        private String name;\n        private String arguments;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/openai/usage/Usage.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.usage;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.io.Serializable;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/7 17:38\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class Usage implements Serializable {\n    @JsonProperty(\"prompt_tokens\")\n    private long promptTokens = 0L;\n    @JsonProperty(\"completion_tokens\")\n    private long completionTokens = 0L;\n    @JsonProperty(\"total_tokens\")\n    private long totalTokens = 0L;\n}"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/standard/rerank/StandardRerankService.java",
    "content": "package io.github.lnyocly.ai4j.platform.standard.rerank;\n\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankDocument;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankRequest;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResponse;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResult;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankUsage;\nimport io.github.lnyocly.ai4j.service.IRerankService;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class StandardRerankService implements IRerankService {\n\n    private final OkHttpClient okHttpClient;\n    private final String apiHost;\n    private final String apiKey;\n    private final String rerankUrl;\n    private final ObjectMapper objectMapper = new ObjectMapper();\n\n    public StandardRerankService(OkHttpClient okHttpClient, String apiHost, String apiKey, String rerankUrl) {\n        this.okHttpClient = okHttpClient;\n        this.apiHost = apiHost;\n        this.apiKey = apiKey;\n        this.rerankUrl = rerankUrl;\n    }\n\n    @Override\n    public RerankResponse rerank(String baseUrl, String apiKey, RerankRequest request) throws Exception {\n        String host = resolveBaseUrl(baseUrl);\n        String path = resolveRerankUrl();\n        Map<String, Object> body = new LinkedHashMap<String, Object>();\n        body.put(\"model\", request.getModel());\n        body.put(\"query\", request.getQuery());\n        body.put(\"documents\", toRequestDocuments(request.getDocuments()));\n        if (request.getTopN() != null) {\n            body.put(\"top_n\", request.getTopN());\n        }\n        if (request.getReturnDocuments() != null) {\n            body.put(\"return_documents\", request.getReturnDocuments());\n        }\n        if (StringUtils.isNotBlank(request.getInstruction())) {\n            body.put(\"instruction\", request.getInstruction());\n        }\n        if (request.getExtraBody() != null && !request.getExtraBody().isEmpty()) {\n            body.putAll(request.getExtraBody());\n        }\n\n        Request.Builder builder = new Request.Builder()\n                .url(UrlUtils.concatUrl(host, path))\n                .post(RequestBody.create(objectMapper.writeValueAsString(body), MediaType.get(Constants.JSON_CONTENT_TYPE)));\n        String key = resolveApiKey(apiKey);\n        if (StringUtils.isNotBlank(key)) {\n            builder.header(\"Authorization\", \"Bearer \" + key);\n        }\n\n        try (okhttp3.Response response = okHttpClient.newCall(builder.build()).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                JsonNode root = objectMapper.readTree(response.body().string());\n                return toResponse(root, request);\n            }\n        }\n        throw new CommonException(\"Standard rerank request failed\");\n    }\n\n    @Override\n    public RerankResponse rerank(RerankRequest request) throws Exception {\n        return rerank(null, null, request);\n    }\n\n    protected RerankResponse toResponse(JsonNode root, RerankRequest request) {\n        List<RerankResult> results = new ArrayList<RerankResult>();\n        JsonNode resultsNode = root == null ? null : root.get(\"results\");\n        if (resultsNode != null && resultsNode.isArray()) {\n            for (JsonNode item : resultsNode) {\n                if (item == null || item.isNull()) {\n                    continue;\n                }\n                results.add(RerankResult.builder()\n                        .index(item.has(\"index\") ? item.get(\"index\").asInt() : null)\n                        .relevanceScore(item.has(\"relevance_score\") ? (float) item.get(\"relevance_score\").asDouble() : null)\n                        .document(parseDocument(item.get(\"document\"), request))\n                        .build());\n            }\n        }\n        return RerankResponse.builder()\n                .id(text(root, \"id\"))\n                .model(firstNonBlank(text(root, \"model\"), request == null ? null : request.getModel()))\n                .results(results)\n                .usage(parseUsage(root == null ? null : root.get(\"usage\")))\n                .build();\n    }\n\n    protected RerankUsage parseUsage(JsonNode usageNode) {\n        if (usageNode == null || usageNode.isNull()) {\n            return null;\n        }\n        return RerankUsage.builder()\n                .promptTokens(intValue(usageNode.get(\"prompt_tokens\")))\n                .totalTokens(intValue(usageNode.get(\"total_tokens\")))\n                .inputTokens(intValue(usageNode.get(\"input_tokens\")))\n                .build();\n    }\n\n    protected RerankDocument parseDocument(JsonNode documentNode, RerankRequest request) {\n        if (documentNode == null || documentNode.isNull()) {\n            return null;\n        }\n        if (documentNode.isTextual()) {\n            String text = documentNode.asText();\n            return RerankDocument.builder().text(text).content(text).build();\n        }\n        Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n        JsonNode metadataNode = documentNode.get(\"metadata\");\n        if (metadataNode != null && metadataNode.isObject()) {\n            java.util.Iterator<Map.Entry<String, JsonNode>> iterator = metadataNode.fields();\n            while (iterator.hasNext()) {\n                Map.Entry<String, JsonNode> entry = iterator.next();\n                metadata.put(entry.getKey(), parseJsonValue(entry.getValue()));\n            }\n        }\n        String text = firstNonBlank(text(documentNode, \"text\"), text(documentNode, \"content\"));\n        return RerankDocument.builder()\n                .id(text(documentNode, \"id\"))\n                .text(text)\n                .content(text)\n                .title(text(documentNode, \"title\"))\n                .metadata(metadata.isEmpty() ? null : metadata)\n                .build();\n    }\n\n    protected List<Object> toRequestDocuments(List<RerankDocument> documents) {\n        if (documents == null || documents.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Object> payload = new ArrayList<Object>(documents.size());\n        for (RerankDocument document : documents) {\n            if (document == null) {\n                continue;\n            }\n            String text = firstNonBlank(document.getText(), document.getContent());\n            if (StringUtils.isNotBlank(text)\n                    && StringUtils.isBlank(document.getId())\n                    && StringUtils.isBlank(document.getTitle())\n                    && (document.getMetadata() == null || document.getMetadata().isEmpty())\n                    && document.getImage() == null) {\n                payload.add(text);\n                continue;\n            }\n            Map<String, Object> item = new LinkedHashMap<String, Object>();\n            if (StringUtils.isNotBlank(document.getId())) {\n                item.put(\"id\", document.getId());\n            }\n            if (StringUtils.isNotBlank(text)) {\n                item.put(\"text\", text);\n            }\n            if (StringUtils.isNotBlank(document.getTitle())) {\n                item.put(\"title\", document.getTitle());\n            }\n            if (document.getImage() != null) {\n                item.put(\"image\", document.getImage());\n            }\n            if (document.getMetadata() != null && !document.getMetadata().isEmpty()) {\n                item.put(\"metadata\", document.getMetadata());\n            }\n            payload.add(item);\n        }\n        return payload;\n    }\n\n    protected String resolveBaseUrl(String baseUrl) {\n        String host = StringUtils.isBlank(baseUrl) ? apiHost : baseUrl;\n        if (StringUtils.isBlank(host)) {\n            throw new IllegalArgumentException(\"rerank apiHost is required\");\n        }\n        return host;\n    }\n\n    protected String resolveApiKey(String baseUrlApiKey) {\n        return StringUtils.isBlank(baseUrlApiKey) ? apiKey : baseUrlApiKey;\n    }\n\n    protected String resolveRerankUrl() {\n        if (StringUtils.isBlank(rerankUrl)) {\n            throw new IllegalArgumentException(\"rerankUrl is required\");\n        }\n        return rerankUrl;\n    }\n\n    protected String text(JsonNode node, String fieldName) {\n        if (node == null || fieldName == null || !node.has(fieldName) || node.get(fieldName).isNull()) {\n            return null;\n        }\n        String text = node.get(fieldName).asText();\n        return StringUtils.isBlank(text) ? null : text;\n    }\n\n    protected Integer intValue(JsonNode node) {\n        if (node == null || node.isNull()) {\n            return null;\n        }\n        return node.asInt();\n    }\n\n    protected Object parseJsonValue(JsonNode node) {\n        if (node == null || node.isNull()) {\n            return null;\n        }\n        if (node.isTextual()) {\n            return node.asText();\n        }\n        if (node.isInt() || node.isLong()) {\n            return node.asLong();\n        }\n        if (node.isFloat() || node.isDouble() || node.isBigDecimal()) {\n            return node.asDouble();\n        }\n        if (node.isBoolean()) {\n            return node.asBoolean();\n        }\n        if (node.isArray()) {\n            List<Object> values = new ArrayList<Object>();\n            for (JsonNode child : node) {\n                values.add(parseJsonValue(child));\n            }\n            return values;\n        }\n        if (node.isObject()) {\n            Map<String, Object> value = new LinkedHashMap<String, Object>();\n            java.util.Iterator<Map.Entry<String, JsonNode>> iterator = node.fields();\n            while (iterator.hasNext()) {\n                Map.Entry<String, JsonNode> entry = iterator.next();\n                value.put(entry.getKey(), parseJsonValue(entry.getValue()));\n            }\n            return value;\n        }\n        return node.asText();\n    }\n\n    protected String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (StringUtils.isNotBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/zhipu/chat/ZhipuChatService.java",
    "content": "package io.github.lnyocly.ai4j.platform.zhipu.chat;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.config.ZhipuConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.convert.chat.ParameterConvert;\nimport io.github.lnyocly.ai4j.convert.chat.ResultConvert;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.platform.zhipu.chat.entity.ZhipuChatCompletion;\nimport io.github.lnyocly.ai4j.platform.zhipu.chat.entity.ZhipuChatCompletionResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.auth.BearerTokenUtils;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSourceListener;\nimport org.jetbrains.annotations.NotNull;\nimport org.jetbrains.annotations.Nullable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 智谱chat服务\n * @Date 2024/8/27 17:29\n */\npublic class ZhipuChatService implements IChatService, ParameterConvert<ZhipuChatCompletion>, ResultConvert<ZhipuChatCompletionResponse> {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n    private static final String TOOL_CALLS_FINISH_REASON = \"tool_calls\";\n    private static final String FIRST_FINISH_REASON = \"first\";\n\n    private final ZhipuConfig zhipuConfig;\n    private final OkHttpClient okHttpClient;\n    private final EventSource.Factory factory;\n    private final ObjectMapper objectMapper;\n\n    public ZhipuChatService(Configuration configuration) {\n        this.zhipuConfig = configuration.getZhipuConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    public ZhipuChatService(Configuration configuration, ZhipuConfig zhipuConfig) {\n        this.zhipuConfig = zhipuConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.factory = configuration.createRequestFactory();\n        this.objectMapper = new ObjectMapper();\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, false);\n            ZhipuChatCompletion zhipuChatCompletion = convertChatCompletionObject(chatCompletion);\n            Usage allUsage = new Usage();\n            String token = resolveToken(resolvedApiKey);\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                ZhipuChatCompletionResponse response = executeChatCompletionRequest(\n                        resolvedBaseUrl,\n                        token,\n                        zhipuChatCompletion\n                );\n                if (response == null) {\n                    break;\n                }\n\n                Choice choice = response.getChoices().get(0);\n                finishReason = choice.getFinishReason();\n                mergeUsage(allUsage, response.getUsage());\n\n                if (TOOL_CALLS_FINISH_REASON.equals(finishReason)) {\n                    if (passThroughToolCalls) {\n                        response.setUsage(allUsage);\n                        response.setObject(\"chat.completion\");\n                        return convertChatCompletionResponse(response);\n                    }\n\n                    zhipuChatCompletion.setMessages(appendToolMessages(\n                            zhipuChatCompletion.getMessages(),\n                            choice.getMessage(),\n                            choice.getMessage().getToolCalls()\n                    ));\n                    continue;\n                }\n\n                response.setUsage(allUsage);\n                response.setObject(\"chat.completion\");\n                restoreOriginalRequest(chatCompletion, zhipuChatCompletion);\n                return convertChatCompletionResponse(response);\n            }\n\n            return null;\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return chatCompletion(null, null, chatCompletion);\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        ToolUtil.pushBuiltInToolContext(chatCompletion.getBuiltInToolContext());\n        try {\n            String resolvedBaseUrl = resolveBaseUrl(baseUrl);\n            String resolvedApiKey = resolveApiKey(apiKey);\n            boolean passThroughToolCalls = Boolean.TRUE.equals(chatCompletion.getPassThroughToolCalls());\n\n            prepareChatCompletion(chatCompletion, true);\n            ZhipuChatCompletion zhipuChatCompletion = convertChatCompletionObject(chatCompletion);\n            String token = resolveToken(resolvedApiKey);\n            String finishReason = FIRST_FINISH_REASON;\n\n            while (requiresFollowUp(finishReason)) {\n                Request request = buildChatCompletionRequest(resolvedBaseUrl, token, zhipuChatCompletion);\n                StreamExecutionSupport.execute(\n                        eventSourceListener,\n                        chatCompletion.getStreamExecution(),\n                        () -> factory.newEventSource(request, convertEventSource(eventSourceListener))\n                );\n\n                finishReason = eventSourceListener.getFinishReason();\n                List<ToolCall> toolCalls = eventSourceListener.getToolCalls();\n                if (!TOOL_CALLS_FINISH_REASON.equals(finishReason) || toolCalls.isEmpty()) {\n                    continue;\n                }\n                if (passThroughToolCalls) {\n                    return;\n                }\n\n                zhipuChatCompletion.setMessages(appendStreamToolMessages(\n                        zhipuChatCompletion.getMessages(),\n                        toolCalls\n                ));\n                resetToolCallState(eventSourceListener);\n            }\n\n            restoreOriginalRequest(chatCompletion, zhipuChatCompletion);\n        } finally {\n            ToolUtil.popBuiltInToolContext();\n        }\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        this.chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n    }\n\n    @Override\n    public ZhipuChatCompletion convertChatCompletionObject(ChatCompletion chatCompletion) {\n        ZhipuChatCompletion zhipuChatCompletion = new ZhipuChatCompletion();\n        zhipuChatCompletion.setModel(chatCompletion.getModel());\n        zhipuChatCompletion.setMessages(chatCompletion.getMessages());\n        zhipuChatCompletion.setStream(chatCompletion.getStream());\n        if (chatCompletion.getTemperature() != null) {\n            zhipuChatCompletion.setTemperature(chatCompletion.getTemperature() / 2);\n        }\n        if (chatCompletion.getTopP() != null) {\n            zhipuChatCompletion.setTopP(chatCompletion.getTopP());\n        }\n        zhipuChatCompletion.setMaxTokens(resolveMaxTokens(chatCompletion));\n        zhipuChatCompletion.setStop(chatCompletion.getStop());\n        zhipuChatCompletion.setTools(chatCompletion.getTools());\n        zhipuChatCompletion.setFunctions(chatCompletion.getFunctions());\n        zhipuChatCompletion.setToolChoice(chatCompletion.getToolChoice());\n        zhipuChatCompletion.setExtraBody(chatCompletion.getExtraBody());\n        return zhipuChatCompletion;\n    }\n\n    @Override\n    public EventSourceListener convertEventSource(final SseListener eventSourceListener) {\n        return new EventSourceListener() {\n            @Override\n            public void onOpen(@NotNull EventSource eventSource, @NotNull Response response) {\n                eventSourceListener.onOpen(eventSource, response);\n            }\n\n            @Override\n            public void onFailure(@NotNull EventSource eventSource, @Nullable Throwable t, @Nullable Response response) {\n                eventSourceListener.onFailure(eventSource, t, response);\n            }\n\n            @Override\n            public void onEvent(@NotNull EventSource eventSource, @Nullable String id, @Nullable String type, @NotNull String data) {\n                if (\"[DONE]\".equalsIgnoreCase(data)) {\n                    eventSourceListener.onEvent(eventSource, id, type, data);\n                    return;\n                }\n                eventSourceListener.onEvent(eventSource, id, type, serializeStreamResponse(data));\n            }\n\n            @Override\n            public void onClosed(@NotNull EventSource eventSource) {\n                eventSourceListener.onClosed(eventSource);\n            }\n        };\n    }\n\n    @Override\n    public ChatCompletionResponse convertChatCompletionResponse(ZhipuChatCompletionResponse zhipuChatCompletionResponse) {\n        ChatCompletionResponse chatCompletionResponse = new ChatCompletionResponse();\n        chatCompletionResponse.setId(zhipuChatCompletionResponse.getId());\n        chatCompletionResponse.setCreated(zhipuChatCompletionResponse.getCreated());\n        chatCompletionResponse.setModel(zhipuChatCompletionResponse.getModel());\n        chatCompletionResponse.setChoices(zhipuChatCompletionResponse.getChoices());\n        chatCompletionResponse.setUsage(zhipuChatCompletionResponse.getUsage());\n        return chatCompletionResponse;\n    }\n\n    private String serializeStreamResponse(String data) {\n        try {\n            ZhipuChatCompletionResponse chatCompletionResponse =\n                    objectMapper.readValue(data, ZhipuChatCompletionResponse.class);\n            chatCompletionResponse.setObject(\"chat.completion.chunk\");\n            ChatCompletionResponse response = convertChatCompletionResponse(chatCompletionResponse);\n            return objectMapper.writeValueAsString(response);\n        } catch (JsonProcessingException e) {\n            throw new CommonException(\"Zhipu Chat 对象JSON序列化出错\");\n        }\n    }\n\n    private void prepareChatCompletion(ChatCompletion chatCompletion, boolean stream) {\n        chatCompletion.setStream(stream);\n        if (!stream) {\n            chatCompletion.setStreamOptions(null);\n        }\n        attachTools(chatCompletion);\n    }\n\n    private void attachTools(ChatCompletion chatCompletion) {\n        if (hasPendingTools(chatCompletion)) {\n            List<Tool> tools = ToolUtil.getAllTools(chatCompletion.getFunctions(), chatCompletion.getMcpServices());\n            chatCompletion.setTools(tools);\n            if (tools == null) {\n                chatCompletion.setParallelToolCalls(null);\n            }\n        }\n        if (chatCompletion.getTools() == null || chatCompletion.getTools().isEmpty()) {\n            chatCompletion.setParallelToolCalls(null);\n        }\n    }\n\n    private boolean hasPendingTools(ChatCompletion chatCompletion) {\n        return (chatCompletion.getFunctions() != null && !chatCompletion.getFunctions().isEmpty())\n                || (chatCompletion.getMcpServices() != null && !chatCompletion.getMcpServices().isEmpty());\n    }\n\n    private boolean requiresFollowUp(String finishReason) {\n        return FIRST_FINISH_REASON.equals(finishReason) || TOOL_CALLS_FINISH_REASON.equals(finishReason);\n    }\n\n    private ZhipuChatCompletionResponse executeChatCompletionRequest(\n            String baseUrl,\n            String token,\n            ZhipuChatCompletion zhipuChatCompletion\n    ) throws Exception {\n        Request request = buildChatCompletionRequest(baseUrl, token, zhipuChatCompletion);\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (response.isSuccessful() && response.body() != null) {\n                return objectMapper.readValue(response.body().string(), ZhipuChatCompletionResponse.class);\n            }\n        }\n        return null;\n    }\n\n    private Request buildChatCompletionRequest(String baseUrl, String token, ZhipuChatCompletion zhipuChatCompletion)\n            throws JsonProcessingException {\n        String requestBody = objectMapper.writeValueAsString(zhipuChatCompletion);\n        return new Request.Builder()\n                .header(\"Authorization\", \"Bearer \" + token)\n                .url(UrlUtils.concatUrl(baseUrl, zhipuConfig.getChatCompletionUrl()))\n                .post(RequestBody.create(requestBody, JSON_MEDIA_TYPE))\n                .build();\n    }\n\n    private void mergeUsage(Usage target, Usage usage) {\n        if (usage == null) {\n            return;\n        }\n        target.setCompletionTokens(target.getCompletionTokens() + usage.getCompletionTokens());\n        target.setTotalTokens(target.getTotalTokens() + usage.getTotalTokens());\n        target.setPromptTokens(target.getPromptTokens() + usage.getPromptTokens());\n    }\n\n    private List<ChatMessage> appendToolMessages(\n            List<ChatMessage> messages,\n            ChatMessage assistantMessage,\n            List<ToolCall> toolCalls\n    ) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(assistantMessage);\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private List<ChatMessage> appendStreamToolMessages(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        List<ChatMessage> updatedMessages = new ArrayList<ChatMessage>(messages);\n        updatedMessages.add(ChatMessage.withAssistant(toolCalls));\n        appendToolResponses(updatedMessages, toolCalls);\n        return updatedMessages;\n    }\n\n    private void appendToolResponses(List<ChatMessage> messages, List<ToolCall> toolCalls) {\n        for (ToolCall toolCall : toolCalls) {\n            String functionName = toolCall.getFunction().getName();\n            String arguments = toolCall.getFunction().getArguments();\n            String functionResponse = ToolUtil.invoke(functionName, arguments);\n            messages.add(ChatMessage.withTool(functionResponse, toolCall.getId()));\n        }\n    }\n\n    private void resetToolCallState(SseListener eventSourceListener) {\n        eventSourceListener.setToolCalls(new ArrayList<ToolCall>());\n        eventSourceListener.setToolCall(null);\n    }\n\n    private void restoreOriginalRequest(ChatCompletion chatCompletion, ZhipuChatCompletion zhipuChatCompletion) {\n        chatCompletion.setMessages(zhipuChatCompletion.getMessages());\n        chatCompletion.setTools(zhipuChatCompletion.getTools());\n    }\n\n    private String resolveBaseUrl(String baseUrl) {\n        return (baseUrl == null || \"\".equals(baseUrl)) ? zhipuConfig.getApiHost() : baseUrl;\n    }\n\n    private String resolveApiKey(String apiKey) {\n        return (apiKey == null || \"\".equals(apiKey)) ? zhipuConfig.getApiKey() : apiKey;\n    }\n\n    private String resolveToken(String apiKey) {\n        return BearerTokenUtils.getToken(apiKey);\n    }\n\n    @SuppressWarnings(\"deprecation\")\n    private Integer resolveMaxTokens(ChatCompletion chatCompletion) {\n        if (chatCompletion.getMaxCompletionTokens() != null) {\n            return chatCompletion.getMaxCompletionTokens();\n        }\n        return chatCompletion.getMaxTokens();\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/zhipu/chat/entity/ZhipuChatCompletion.java",
    "content": "package io.github.lnyocly.ai4j.platform.zhipu.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport lombok.*;\n\nimport java.io.Serializable;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description 智谱对话实体类\n * @Date 2024/8/27 17:39\n */\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ZhipuChatCompletion {\n\n    @NonNull\n    private String model;\n    @NonNull\n    private List<ChatMessage> messages;\n\n    @JsonProperty(\"request_id\")\n    private String requestId;\n\n    @Builder.Default\n    @JsonProperty(\"do_sample\")\n    private Boolean doSample = true;\n    @Builder.Default\n    private Boolean stream = false;\n    /**\n     * 采样温度，控制输出的随机性，必须为正数。值越大，会使输出更随机\n     * [0.0, 1.0]\n     */\n    @Builder.Default\n    private Float temperature = 0.95f;\n    /**\n     * 核取样\n     * [0.0, 1.0]\n     */\n    @Builder.Default\n    @JsonProperty(\"top_p\")\n    private Float topP = 0.7f;\n\n    @JsonProperty(\"max_tokens\")\n    private Integer maxTokens;\n\n    private List<String> stop;\n\n\n    private List<Tool> tools;\n\n    /**\n     * 辅助属性\n     */\n    @JsonIgnore\n    private List<String> functions;\n\n    @JsonProperty(\"tool_choice\")\n    private String toolChoice;\n\n    @JsonProperty(\"user_id\")\n    private String userId;\n\n    /**\n     * 额外的请求体参数，用于扩展不同平台的特定字段\n     * 使用 @JsonAnyGetter 使其内容在序列化时展开到 JSON 顶层\n     */\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n\n    public static class ZhipuChatCompletionBuilder {\n        private List<String> functions;\n\n        public ZhipuChatCompletion.ZhipuChatCompletionBuilder functions(String... functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            this.functions.addAll(Arrays.asList(functions));\n            return this;\n        }\n\n        public ZhipuChatCompletion.ZhipuChatCompletionBuilder functions(List<String> functions){\n            if (this.functions == null) {\n                this.functions = new ArrayList<>();\n            }\n            if (functions != null) {\n                this.functions.addAll(functions);\n            }\n            return this;\n        }\n\n\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/platform/zhipu/chat/entity/ZhipuChatCompletionResponse.java",
    "content": "package io.github.lnyocly.ai4j.platform.zhipu.chat.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description 智谱聊天请求响应实体类\n * @Date 2024/8/27 20:34\n */\n\n@Data\n@NoArgsConstructor()\n@AllArgsConstructor()\n@JsonIgnoreProperties(ignoreUnknown = true)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class ZhipuChatCompletionResponse {\n    private String id;\n    private String object;\n    private Long created;\n    private String model;\n    private List<Choice> choices;\n    private Usage usage;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/AbstractScoreFusionStrategy.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\nabstract class AbstractScoreFusionStrategy implements FusionStrategy {\n\n    @Override\n    public List<Double> scoreContributions(List<RagHit> hits) {\n        if (hits == null || hits.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Double> rawScores = new ArrayList<Double>(hits.size());\n        for (RagHit hit : hits) {\n            if (hit == null || hit.getScore() == null) {\n                return fallbackRankScores(hits.size());\n            }\n            rawScores.add((double) hit.getScore());\n        }\n        if (!hasVariance(rawScores)) {\n            return fallbackRankScores(hits.size());\n        }\n        return scoreWithRawScores(rawScores);\n    }\n\n    protected abstract List<Double> scoreWithRawScores(List<Double> rawScores);\n\n    protected List<Double> fallbackRankScores(int size) {\n        List<Double> scores = new ArrayList<Double>(size);\n        for (int i = 0; i < size; i++) {\n            scores.add(1.0d / (i + 1));\n        }\n        return scores;\n    }\n\n    private boolean hasVariance(List<Double> rawScores) {\n        if (rawScores == null || rawScores.size() <= 1) {\n            return false;\n        }\n        double min = Double.POSITIVE_INFINITY;\n        double max = Double.NEGATIVE_INFINITY;\n        for (Double rawScore : rawScores) {\n            if (rawScore == null) {\n                continue;\n            }\n            if (rawScore < min) {\n                min = rawScore;\n            }\n            if (rawScore > max) {\n                max = rawScore;\n            }\n        }\n        return Double.isFinite(min) && Double.isFinite(max) && Math.abs(max - min) > 1.0e-9d;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/Bm25Retriever.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.HashMap;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class Bm25Retriever implements Retriever {\n\n    private final List<RagHit> corpus;\n    private final TextTokenizer tokenizer;\n    private final double k1;\n    private final double b;\n    private final Map<Integer, Map<String, Integer>> termFrequencies;\n    private final Map<String, Integer> documentFrequencies;\n    private final double averageDocumentLength;\n\n    public Bm25Retriever(List<RagHit> corpus) {\n        this(corpus, new DefaultTextTokenizer(), 1.5d, 0.75d);\n    }\n\n    public Bm25Retriever(List<RagHit> corpus, TextTokenizer tokenizer, double k1, double b) {\n        this.corpus = corpus == null ? Collections.<RagHit>emptyList() : new ArrayList<RagHit>(corpus);\n        this.tokenizer = tokenizer == null ? new DefaultTextTokenizer() : tokenizer;\n        this.k1 = k1;\n        this.b = b;\n        this.termFrequencies = new HashMap<Integer, Map<String, Integer>>();\n        this.documentFrequencies = new LinkedHashMap<String, Integer>();\n        this.averageDocumentLength = buildIndex();\n    }\n\n    @Override\n    public List<RagHit> retrieve(RagQuery query) {\n        if (query == null || query.getQuery() == null || query.getQuery().trim().isEmpty() || corpus.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> terms = tokenizer.tokenize(query.getQuery());\n        if (terms.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<RagHit> hits = new ArrayList<RagHit>();\n        for (int i = 0; i < corpus.size(); i++) {\n            RagHit hit = corpus.get(i);\n            double score = scoreDocument(i, terms);\n            if (score <= 0.0d) {\n                continue;\n            }\n            hits.add(copyWithScore(hit, (float) score));\n        }\n        Collections.sort(hits, new Comparator<RagHit>() {\n            @Override\n            public int compare(RagHit left, RagHit right) {\n                float l = left == null || left.getScore() == null ? 0.0f : left.getScore();\n                float r = right == null || right.getScore() == null ? 0.0f : right.getScore();\n                return Float.compare(r, l);\n            }\n        });\n        int limit = query.getTopK() == null || query.getTopK() <= 0 ? hits.size() : Math.min(query.getTopK(), hits.size());\n        return RagHitSupport.prepareRetrievedHits(new ArrayList<RagHit>(hits.subList(0, limit)), retrieverSource());\n    }\n\n    @Override\n    public String retrieverSource() {\n        return \"bm25\";\n    }\n\n    private double buildIndex() {\n        if (corpus.isEmpty()) {\n            return 0.0d;\n        }\n        int totalLength = 0;\n        for (int i = 0; i < corpus.size(); i++) {\n            RagHit hit = corpus.get(i);\n            List<String> tokens = tokenizer.tokenize(hit == null ? null : hit.getContent());\n            totalLength += tokens.size();\n            Map<String, Integer> frequencies = new HashMap<String, Integer>();\n            for (String token : tokens) {\n                Integer count = frequencies.get(token);\n                frequencies.put(token, count == null ? 1 : count + 1);\n            }\n            termFrequencies.put(i, frequencies);\n            for (String token : frequencies.keySet()) {\n                Integer count = documentFrequencies.get(token);\n                documentFrequencies.put(token, count == null ? 1 : count + 1);\n            }\n        }\n        return totalLength == 0 ? 0.0d : (double) totalLength / (double) corpus.size();\n    }\n\n    private double scoreDocument(int index, List<String> terms) {\n        Map<String, Integer> frequencies = termFrequencies.get(index);\n        if (frequencies == null || frequencies.isEmpty()) {\n            return 0.0d;\n        }\n        int documentLength = 0;\n        for (Integer value : frequencies.values()) {\n            documentLength += value == null ? 0 : value;\n        }\n        double score = 0.0d;\n        for (String term : terms) {\n            Integer tf = frequencies.get(term);\n            Integer df = documentFrequencies.get(term);\n            if (tf == null || tf <= 0 || df == null || df <= 0) {\n                continue;\n            }\n            double idf = Math.log(1.0d + (corpus.size() - df + 0.5d) / (df + 0.5d));\n            double numerator = tf * (k1 + 1.0d);\n            double denominator = tf + k1 * (1.0d - b + b * documentLength / Math.max(1.0d, averageDocumentLength));\n            score += idf * (numerator / denominator);\n        }\n        return score;\n    }\n\n    private RagHit copyWithScore(RagHit hit, float score) {\n        if (hit == null) {\n            return null;\n        }\n        return RagHit.builder()\n                .id(hit.getId())\n                .score(score)\n                .retrievalScore(score)\n                .content(hit.getContent())\n                .metadata(hit.getMetadata())\n                .documentId(hit.getDocumentId())\n                .sourceName(hit.getSourceName())\n                .sourcePath(hit.getSourcePath())\n                .sourceUri(hit.getSourceUri())\n                .pageNumber(hit.getPageNumber())\n                .sectionTitle(hit.getSectionTitle())\n                .chunkIndex(hit.getChunkIndex())\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DbsfFusionStrategy.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class DbsfFusionStrategy extends AbstractScoreFusionStrategy {\n\n    @Override\n    protected List<Double> scoreWithRawScores(List<Double> rawScores) {\n        double mean = 0.0d;\n        for (Double rawScore : rawScores) {\n            mean += rawScore;\n        }\n        mean = mean / rawScores.size();\n\n        double variance = 0.0d;\n        for (Double rawScore : rawScores) {\n            double delta = rawScore - mean;\n            variance += delta * delta;\n        }\n        double standardDeviation = Math.sqrt(variance / rawScores.size());\n\n        List<Double> scores = new ArrayList<Double>(rawScores.size());\n        for (Double rawScore : rawScores) {\n            double zScore = (rawScore - mean) / standardDeviation;\n            scores.add(1.0d / (1.0d + Math.exp(-zScore)));\n        }\n        return scores;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DefaultRagContextAssembler.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class DefaultRagContextAssembler implements RagContextAssembler {\n\n    @Override\n    public RagContext assemble(RagQuery query, List<RagHit> hits) {\n        if (hits == null || hits.isEmpty()) {\n            return RagContext.builder()\n                    .text(\"\")\n                    .citations(Collections.<RagCitation>emptyList())\n                    .build();\n        }\n\n        String delimiter = query == null || query.getDelimiter() == null ? \"\\n\\n\" : query.getDelimiter();\n        boolean includeCitations = query == null || query.isIncludeCitations();\n\n        List<RagCitation> citations = new ArrayList<RagCitation>();\n        StringBuilder context = new StringBuilder();\n        int index = 1;\n        for (RagHit hit : hits) {\n            if (hit == null || hit.getContent() == null || hit.getContent().trim().isEmpty()) {\n                continue;\n            }\n            RagCitation citation = RagCitation.builder()\n                    .citationId(\"S\" + index)\n                    .sourceName(hit.getSourceName())\n                    .sourcePath(hit.getSourcePath())\n                    .sourceUri(hit.getSourceUri())\n                    .pageNumber(hit.getPageNumber())\n                    .sectionTitle(hit.getSectionTitle())\n                    .snippet(hit.getContent())\n                    .build();\n            citations.add(citation);\n\n            if (context.length() > 0) {\n                context.append(delimiter);\n            }\n            if (includeCitations) {\n                context.append(\"[\").append(citation.getCitationId()).append(\"] \");\n                appendSourceLabel(context, citation);\n                context.append(\"\\n\");\n            }\n            context.append(hit.getContent());\n            index++;\n        }\n\n        return RagContext.builder()\n                .text(context.toString())\n                .citations(citations)\n                .build();\n    }\n\n    private void appendSourceLabel(StringBuilder builder, RagCitation citation) {\n        String sourceName = trimToNull(citation.getSourceName());\n        String sourcePath = trimToNull(citation.getSourcePath());\n        if (sourceName != null) {\n            builder.append(sourceName);\n        } else if (sourcePath != null) {\n            builder.append(sourcePath);\n        } else {\n            builder.append(\"source\");\n        }\n        if (citation.getPageNumber() != null) {\n            builder.append(\" / p.\").append(citation.getPageNumber());\n        }\n        String sectionTitle = trimToNull(citation.getSectionTitle());\n        if (sectionTitle != null) {\n            builder.append(\" / \").append(sectionTitle);\n        }\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DefaultRagService.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class DefaultRagService implements RagService {\n\n    private final Retriever retriever;\n    private final Reranker reranker;\n    private final RagContextAssembler contextAssembler;\n\n    public DefaultRagService(Retriever retriever) {\n        this(retriever, new NoopReranker(), new DefaultRagContextAssembler());\n    }\n\n    public DefaultRagService(Retriever retriever, Reranker reranker, RagContextAssembler contextAssembler) {\n        if (retriever == null) {\n            throw new IllegalArgumentException(\"retriever is required\");\n        }\n        this.retriever = retriever;\n        this.reranker = reranker == null ? new NoopReranker() : reranker;\n        this.contextAssembler = contextAssembler == null ? new DefaultRagContextAssembler() : contextAssembler;\n    }\n\n    @Override\n    public RagResult search(RagQuery query) throws Exception {\n        List<RagHit> hits = RagHitSupport.prepareRetrievedHits(retriever.retrieve(query), retriever.retrieverSource());\n        List<RagHit> rerankInput = RagHitSupport.copyList(hits);\n        List<RagHit> reranked = RagHitSupport.prepareRerankedHits(\n                hits,\n                reranker.rerank(query == null ? null : query.getQuery(), rerankInput),\n                !(reranker instanceof NoopReranker)\n        );\n        List<RagHit> finalHits = trim(reranked, query == null ? null : query.getFinalTopK());\n        RagContext context = contextAssembler.assemble(query, finalHits);\n        return RagResult.builder()\n                .query(query == null ? null : query.getQuery())\n                .hits(finalHits)\n                .context(context == null ? \"\" : context.getText())\n                .citations(context == null ? Collections.<RagCitation>emptyList() : context.getCitations())\n                .sources(context == null ? Collections.<RagCitation>emptyList() : context.getCitations())\n                .trace(query != null && query.isIncludeTrace()\n                        ? RagTrace.builder().retrievedHits(hits).rerankedHits(reranked).build()\n                        : null)\n                .build();\n    }\n\n    private List<RagHit> trim(List<RagHit> hits, Integer finalTopK) {\n        if (hits == null || hits.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (finalTopK == null || finalTopK <= 0 || hits.size() <= finalTopK) {\n            return new ArrayList<RagHit>(hits);\n        }\n        return new ArrayList<RagHit>(hits.subList(0, finalTopK));\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DefaultTextTokenizer.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\n\npublic class DefaultTextTokenizer implements TextTokenizer {\n\n    @Override\n    public List<String> tokenize(String text) {\n        if (text == null || text.trim().isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> tokens = new ArrayList<String>();\n        StringBuilder latin = new StringBuilder();\n        char[] chars = text.toLowerCase(Locale.ROOT).toCharArray();\n        for (char ch : chars) {\n            if (Character.isLetterOrDigit(ch) && !isCjk(ch)) {\n                latin.append(ch);\n                continue;\n            }\n            flushLatin(tokens, latin);\n            if (isCjk(ch)) {\n                tokens.add(String.valueOf(ch));\n            }\n        }\n        flushLatin(tokens, latin);\n        return tokens;\n    }\n\n    private void flushLatin(List<String> tokens, StringBuilder latin) {\n        if (latin.length() == 0) {\n            return;\n        }\n        tokens.add(latin.toString());\n        latin.setLength(0);\n    }\n\n    private boolean isCjk(char ch) {\n        Character.UnicodeBlock block = Character.UnicodeBlock.of(ch);\n        return Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS.equals(block)\n                || Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A.equals(block)\n                || Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B.equals(block)\n                || Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS.equals(block)\n                || Character.UnicodeBlock.HIRAGANA.equals(block)\n                || Character.UnicodeBlock.KATAKANA.equals(block)\n                || Character.UnicodeBlock.HANGUL_SYLLABLES.equals(block);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/DenseRetriever.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\n\npublic class DenseRetriever implements Retriever {\n\n    private final IEmbeddingService embeddingService;\n    private final VectorStore vectorStore;\n\n    public DenseRetriever(IEmbeddingService embeddingService, VectorStore vectorStore) {\n        if (embeddingService == null) {\n            throw new IllegalArgumentException(\"embeddingService is required\");\n        }\n        if (vectorStore == null) {\n            throw new IllegalArgumentException(\"vectorStore is required\");\n        }\n        this.embeddingService = embeddingService;\n        this.vectorStore = vectorStore;\n    }\n\n    @Override\n    public List<RagHit> retrieve(RagQuery query) throws Exception {\n        if (query == null || query.getQuery() == null || query.getQuery().trim().isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (query.getEmbeddingModel() == null || query.getEmbeddingModel().trim().isEmpty()) {\n            throw new IllegalArgumentException(\"embeddingModel is required\");\n        }\n\n        EmbeddingResponse response = embeddingService.embedding(Embedding.builder()\n                .model(query.getEmbeddingModel())\n                .input(query.getQuery())\n                .build());\n        List<EmbeddingObject> data = response == null ? null : response.getData();\n        if (data == null || data.isEmpty() || data.get(0) == null || data.get(0).getEmbedding() == null) {\n            throw new IllegalStateException(\"Failed to generate query embedding\");\n        }\n\n        List<VectorSearchResult> searchResults = vectorStore.search(VectorSearchRequest.builder()\n                .dataset(query.getDataset())\n                .vector(data.get(0).getEmbedding())\n                .topK(query.getTopK())\n                .filter(query.getFilter())\n                .includeMetadata(Boolean.TRUE)\n                .includeVector(Boolean.FALSE)\n                .build());\n        if (searchResults == null || searchResults.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<RagHit> hits = new ArrayList<RagHit>();\n        for (VectorSearchResult result : searchResults) {\n            if (result == null) {\n                continue;\n            }\n            Map<String, Object> metadata = result.getMetadata();\n            hits.add(RagHit.builder()\n                    .id(result.getId())\n                    .score(result.getScore())\n                    .retrievalScore(result.getScore())\n                    .content(firstNonBlank(result.getContent(), metadataValue(metadata, RagMetadataKeys.CONTENT)))\n                    .metadata(metadata)\n                    .documentId(metadataValue(metadata, RagMetadataKeys.DOCUMENT_ID))\n                    .sourceName(firstNonBlank(\n                            metadataValue(metadata, RagMetadataKeys.SOURCE_NAME),\n                            metadataValue(metadata, \"source\"),\n                            metadataValue(metadata, \"fileName\")))\n                    .sourcePath(metadataValue(metadata, RagMetadataKeys.SOURCE_PATH))\n                    .sourceUri(metadataValue(metadata, RagMetadataKeys.SOURCE_URI))\n                    .pageNumber(intValue(metadata == null ? null : metadata.get(RagMetadataKeys.PAGE_NUMBER)))\n                    .sectionTitle(metadataValue(metadata, RagMetadataKeys.SECTION_TITLE))\n                    .chunkIndex(intValue(metadata == null ? null : metadata.get(RagMetadataKeys.CHUNK_INDEX)))\n                    .build());\n        }\n        return RagHitSupport.prepareRetrievedHits(hits, retrieverSource());\n    }\n\n    @Override\n    public String retrieverSource() {\n        return \"dense\";\n    }\n\n    private String metadataValue(Map<String, Object> metadata, String key) {\n        if (metadata == null || key == null) {\n            return null;\n        }\n        Object value = metadata.get(key);\n        if (value == null) {\n            return null;\n        }\n        String text = String.valueOf(value).trim();\n        return text.isEmpty() ? null : text;\n    }\n\n    private Integer intValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).intValue();\n        }\n        try {\n            return Integer.parseInt(String.valueOf(value).trim());\n        } catch (Exception ignore) {\n            return null;\n        }\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/FusionStrategy.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.List;\n\npublic interface FusionStrategy {\n\n    List<Double> scoreContributions(List<RagHit> hits);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/HybridRetriever.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class HybridRetriever implements Retriever {\n\n    private final List<Retriever> retrievers;\n    private final FusionStrategy fusionStrategy;\n\n    public HybridRetriever(List<Retriever> retrievers) {\n        this(retrievers, new RrfFusionStrategy());\n    }\n\n    public HybridRetriever(List<Retriever> retrievers, int rankConstant) {\n        this(retrievers, new RrfFusionStrategy(rankConstant));\n    }\n\n    public HybridRetriever(List<Retriever> retrievers, FusionStrategy fusionStrategy) {\n        this.retrievers = retrievers == null ? Collections.<Retriever>emptyList() : new ArrayList<Retriever>(retrievers);\n        this.fusionStrategy = fusionStrategy == null ? new RrfFusionStrategy() : fusionStrategy;\n    }\n\n    @Override\n    public List<RagHit> retrieve(RagQuery query) throws Exception {\n        if (retrievers.isEmpty()) {\n            return Collections.emptyList();\n        }\n        Map<String, RankedHit> merged = new LinkedHashMap<String, RankedHit>();\n        for (Retriever retriever : retrievers) {\n            if (retriever == null) {\n                continue;\n            }\n            List<RagHit> hits = RagHitSupport.prepareRetrievedHits(retriever.retrieve(query), retriever.retrieverSource());\n            if (hits == null) {\n                continue;\n            }\n            List<Double> contributions = fusionStrategy.scoreContributions(hits);\n            for (int i = 0; i < hits.size(); i++) {\n                RagHit hit = hits.get(i);\n                if (hit == null) {\n                    continue;\n                }\n                String key = keyOf(hit, i);\n                RankedHit ranked = merged.get(key);\n                if (ranked == null) {\n                    ranked = new RankedHit(hit);\n                    merged.put(key, ranked);\n                }\n                double contribution = contributionOf(contributions, i);\n                ranked.score += contribution;\n                ranked.addDetail(retriever.retrieverSource(), i + 1, retrievalScoreOf(hit), (float) contribution);\n                if (ranked.hit.getScore() == null || (hit.getScore() != null && hit.getScore() > ranked.hit.getScore())) {\n                    ranked.hit = RagHitSupport.copy(hit);\n                }\n            }\n        }\n        List<RagHit> result = new ArrayList<RagHit>();\n        for (RankedHit ranked : merged.values()) {\n            RagHit hit = RagHitSupport.copy(ranked.hit);\n            hit.setRetrieverSource(retrieverSource());\n            hit.setRetrievalScore(ranked.bestRetrievalScore);\n            hit.setFusionScore((float) ranked.score);\n            hit.setScore((float) ranked.score);\n            hit.setScoreDetails(ranked.details);\n            result.add(hit);\n        }\n        Collections.sort(result, new Comparator<RagHit>() {\n            @Override\n            public int compare(RagHit left, RagHit right) {\n                float l = left == null || left.getScore() == null ? 0.0f : left.getScore();\n                float r = right == null || right.getScore() == null ? 0.0f : right.getScore();\n                return Float.compare(r, l);\n            }\n        });\n        int limit = query == null || query.getTopK() == null || query.getTopK() <= 0\n                ? result.size()\n                : Math.min(query.getTopK(), result.size());\n        return RagHitSupport.prepareRetrievedHits(new ArrayList<RagHit>(result.subList(0, limit)), retrieverSource());\n    }\n\n    @Override\n    public String retrieverSource() {\n        return \"hybrid\";\n    }\n\n    private double contributionOf(List<Double> contributions, int index) {\n        if (contributions == null || index < 0 || index >= contributions.size()) {\n            return 0.0d;\n        }\n        Double contribution = contributions.get(index);\n        if (contribution == null || Double.isNaN(contribution) || Double.isInfinite(contribution)) {\n            return 0.0d;\n        }\n        return contribution;\n    }\n\n    private String keyOf(RagHit hit, int fallbackIndex) {\n        if (hit.getId() != null && !hit.getId().trim().isEmpty()) {\n            return hit.getId().trim();\n        }\n        if (hit.getDocumentId() != null && !hit.getDocumentId().trim().isEmpty()) {\n            return hit.getDocumentId().trim() + \"#\" + normalizeIndex(hit.getChunkIndex());\n        }\n        if (hit.getSourcePath() != null && !hit.getSourcePath().trim().isEmpty()) {\n            return hit.getSourcePath().trim() + \"#\" + normalizeIndex(hit.getChunkIndex());\n        }\n        if (hit.getSourceUri() != null && !hit.getSourceUri().trim().isEmpty()) {\n            return hit.getSourceUri().trim() + \"#\" + normalizeIndex(hit.getChunkIndex());\n        }\n        if (hit.getSourceName() != null && !hit.getSourceName().trim().isEmpty()\n                && hit.getSectionTitle() != null && !hit.getSectionTitle().trim().isEmpty()) {\n            return hit.getSourceName().trim() + \"#\" + hit.getSectionTitle().trim() + \"#\" + normalizeIndex(hit.getChunkIndex());\n        }\n        String content = hit.getContent() == null ? \"\" : hit.getContent();\n        if (!content.trim().isEmpty()) {\n            return content.trim();\n        }\n        return String.valueOf(fallbackIndex);\n    }\n\n    private String normalizeIndex(Integer chunkIndex) {\n        return chunkIndex == null ? \"-\" : String.valueOf(chunkIndex);\n    }\n\n    private Float retrievalScoreOf(RagHit hit) {\n        if (hit == null) {\n            return null;\n        }\n        if (hit.getRetrievalScore() != null) {\n            return hit.getRetrievalScore();\n        }\n        return hit.getScore();\n    }\n\n    private static class RankedHit {\n        private RagHit hit;\n        private double score;\n        private Float bestRetrievalScore;\n        private List<RagScoreDetail> details;\n\n        private RankedHit(RagHit hit) {\n            this.hit = copyStatic(hit);\n            this.bestRetrievalScore = hit == null ? null : (hit.getRetrievalScore() != null ? hit.getRetrievalScore() : hit.getScore());\n            this.details = new ArrayList<RagScoreDetail>();\n        }\n\n        private void addDetail(String source, int rank, Float retrievalScore, Float fusionContribution) {\n            details.add(RagScoreDetail.builder()\n                    .source(source)\n                    .rank(rank)\n                    .retrievalScore(retrievalScore)\n                    .fusionContribution(fusionContribution)\n                    .build());\n            if (retrievalScore != null && (bestRetrievalScore == null || retrievalScore > bestRetrievalScore)) {\n                bestRetrievalScore = retrievalScore;\n            }\n        }\n\n        private static RagHit copyStatic(RagHit hit) {\n            return RagHitSupport.copy(hit);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ModelReranker.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport io.github.lnyocly.ai4j.rerank.entity.RerankDocument;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankRequest;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResponse;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResult;\nimport io.github.lnyocly.ai4j.service.IRerankService;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class ModelReranker implements Reranker {\n\n    private final IRerankService rerankService;\n    private final String model;\n    private final Integer topN;\n    private final String instruction;\n    private final boolean returnDocuments;\n    private final boolean appendRemainingHits;\n\n    public ModelReranker(IRerankService rerankService, String model) {\n        this(rerankService, model, null, null, false, true);\n    }\n\n    public ModelReranker(IRerankService rerankService,\n                         String model,\n                         Integer topN,\n                         String instruction,\n                         boolean returnDocuments,\n                         boolean appendRemainingHits) {\n        if (rerankService == null) {\n            throw new IllegalArgumentException(\"rerankService is required\");\n        }\n        if (model == null || model.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"rerank model is required\");\n        }\n        this.rerankService = rerankService;\n        this.model = model.trim();\n        this.topN = topN;\n        this.instruction = instruction;\n        this.returnDocuments = returnDocuments;\n        this.appendRemainingHits = appendRemainingHits;\n    }\n\n    @Override\n    public List<RagHit> rerank(String query, List<RagHit> hits) throws Exception {\n        if (hits == null || hits.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (query == null || query.trim().isEmpty()) {\n            return RagHitSupport.copyList(hits);\n        }\n\n        List<RagHit> sourceHits = RagHitSupport.copyList(hits);\n        int rerankTopN = topN == null || topN <= 0 ? sourceHits.size() : Math.min(topN, sourceHits.size());\n        List<RerankDocument> documents = new ArrayList<RerankDocument>(sourceHits.size());\n        for (RagHit hit : sourceHits) {\n            if (hit == null) {\n                continue;\n            }\n            documents.add(RerankDocument.builder()\n                    .id(RagHitSupport.stableKey(hit))\n                    .text(hit.getContent())\n                    .content(hit.getContent())\n                    .title(hit.getSectionTitle())\n                    .metadata(hit.getMetadata())\n                    .build());\n        }\n\n        RerankResponse response = rerankService.rerank(RerankRequest.builder()\n                .model(model)\n                .query(query)\n                .documents(documents)\n                .topN(rerankTopN)\n                .returnDocuments(returnDocuments)\n                .instruction(instruction)\n                .build());\n        List<RerankResult> results = response == null ? null : response.getResults();\n        if (results == null || results.isEmpty()) {\n            return sourceHits;\n        }\n\n        Map<Integer, RagHit> byIndex = new LinkedHashMap<Integer, RagHit>();\n        for (int i = 0; i < sourceHits.size(); i++) {\n            byIndex.put(i, sourceHits.get(i));\n        }\n\n        List<RagHit> reranked = new ArrayList<RagHit>();\n        Set<Integer> consumed = new LinkedHashSet<Integer>();\n        for (RerankResult result : results) {\n            if (result == null || result.getIndex() == null) {\n                continue;\n            }\n            RagHit hit = byIndex.get(result.getIndex());\n            if (hit == null) {\n                continue;\n            }\n            RagHit copy = RagHitSupport.copy(hit);\n            copy.setRerankScore(result.getRelevanceScore());\n            if (returnDocuments && result.getDocument() != null && result.getDocument().getContent() != null) {\n                copy.setContent(result.getDocument().getContent());\n            }\n            copy.setScore(result.getRelevanceScore());\n            reranked.add(copy);\n            consumed.add(result.getIndex());\n        }\n\n        if (appendRemainingHits) {\n            for (int i = 0; i < sourceHits.size(); i++) {\n                if (consumed.contains(i)) {\n                    continue;\n                }\n                reranked.add(RagHitSupport.copy(sourceHits.get(i)));\n            }\n        }\n\n        return reranked;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/NoopReranker.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class NoopReranker implements Reranker {\n\n    @Override\n    public List<RagHit> rerank(String query, List<RagHit> hits) {\n        if (hits == null || hits.isEmpty()) {\n            return Collections.emptyList();\n        }\n        return new ArrayList<RagHit>(hits);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagChunk.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagChunk {\n\n    private String chunkId;\n\n    private String documentId;\n\n    private String content;\n\n    private Integer chunkIndex;\n\n    private Integer pageNumber;\n\n    private String sectionTitle;\n\n    private Map<String, Object> metadata;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagCitation.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagCitation {\n\n    private String citationId;\n\n    private String sourceName;\n\n    private String sourcePath;\n\n    private String sourceUri;\n\n    private Integer pageNumber;\n\n    private String sectionTitle;\n\n    private String snippet;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagContext.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagContext {\n\n    private String text;\n\n    @Builder.Default\n    private List<RagCitation> citations = Collections.emptyList();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagContextAssembler.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.List;\n\npublic interface RagContextAssembler {\n\n    RagContext assemble(RagQuery query, List<RagHit> hits);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagDocument.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagDocument {\n\n    private String documentId;\n\n    private String sourceName;\n\n    private String sourcePath;\n\n    private String sourceUri;\n\n    private String title;\n\n    private String tenant;\n\n    private String biz;\n\n    private String version;\n\n    private Map<String, Object> metadata;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagEvaluation.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagEvaluation {\n\n    private Integer evaluatedAtK;\n\n    private Integer retrievedCount;\n\n    private Integer relevantCount;\n\n    private Integer truePositiveCount;\n\n    private Double precisionAtK;\n\n    private Double recallAtK;\n\n    private Double f1AtK;\n\n    private Double mrr;\n\n    private Double ndcg;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagEvaluator.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Set;\n\npublic class RagEvaluator {\n\n    public RagEvaluation evaluate(List<RagHit> hits, Collection<String> relevantIds) {\n        int topK = hits == null ? 0 : hits.size();\n        return evaluate(hits, relevantIds, topK);\n    }\n\n    public RagEvaluation evaluate(List<RagHit> hits, Collection<String> relevantIds, int topK) {\n        List<RagHit> safeHits = hits == null ? Collections.<RagHit>emptyList() : hits;\n        Set<String> relevant = normalize(relevantIds);\n        int limit = topK <= 0 ? safeHits.size() : Math.min(topK, safeHits.size());\n        int truePositiveCount = 0;\n        double reciprocalRank = 0.0d;\n        double dcg = 0.0d;\n        for (int i = 0; i < limit; i++) {\n            RagHit hit = safeHits.get(i);\n            String key = RagHitSupport.stableKey(hit, i);\n            if (!relevant.contains(key)) {\n                continue;\n            }\n            truePositiveCount++;\n            if (reciprocalRank == 0.0d) {\n                reciprocalRank = 1.0d / (i + 1);\n            }\n            dcg += 1.0d / log2(i + 2);\n        }\n        int idealCount = Math.min(limit, relevant.size());\n        double idcg = 0.0d;\n        for (int i = 0; i < idealCount; i++) {\n            idcg += 1.0d / log2(i + 2);\n        }\n        double denominator = limit <= 0 ? 1.0d : (double) limit;\n        double precision = limit <= 0 ? 0.0d : truePositiveCount / denominator;\n        double recall = relevant.isEmpty() ? 0.0d : (double) truePositiveCount / (double) relevant.size();\n        double f1 = precision + recall == 0.0d ? 0.0d : 2.0d * precision * recall / (precision + recall);\n        double ndcg = idcg == 0.0d ? 0.0d : dcg / idcg;\n        return RagEvaluation.builder()\n                .evaluatedAtK(limit)\n                .retrievedCount(safeHits.size())\n                .relevantCount(relevant.size())\n                .truePositiveCount(truePositiveCount)\n                .precisionAtK(precision)\n                .recallAtK(recall)\n                .f1AtK(f1)\n                .mrr(reciprocalRank)\n                .ndcg(ndcg)\n                .build();\n    }\n\n    private Set<String> normalize(Collection<String> relevantIds) {\n        if (relevantIds == null || relevantIds.isEmpty()) {\n            return Collections.emptySet();\n        }\n        Set<String> normalized = new LinkedHashSet<String>();\n        for (String relevantId : relevantIds) {\n            if (relevantId == null || relevantId.trim().isEmpty()) {\n                continue;\n            }\n            normalized.add(relevantId.trim());\n        }\n        return normalized;\n    }\n\n    private double log2(int value) {\n        return Math.log(value) / Math.log(2.0d);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagHit.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagHit {\n\n    private String id;\n\n    private Float score;\n\n    private Integer rank;\n\n    private String retrieverSource;\n\n    private Float retrievalScore;\n\n    private Float fusionScore;\n\n    private Float rerankScore;\n\n    private String content;\n\n    private Map<String, Object> metadata;\n\n    private String documentId;\n\n    private String sourceName;\n\n    private String sourcePath;\n\n    private String sourceUri;\n\n    private Integer pageNumber;\n\n    private String sectionTitle;\n\n    private Integer chunkIndex;\n\n    private List<RagScoreDetail> scoreDetails;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagHitSupport.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\nfinal class RagHitSupport {\n\n    private RagHitSupport() {\n    }\n\n    static List<RagHit> copyList(List<RagHit> hits) {\n        if (hits == null || hits.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<RagHit> copies = new ArrayList<RagHit>(hits.size());\n        for (RagHit hit : hits) {\n            if (hit == null) {\n                continue;\n            }\n            copies.add(copy(hit));\n        }\n        return copies;\n    }\n\n    static RagHit copy(RagHit hit) {\n        if (hit == null) {\n            return null;\n        }\n        return RagHit.builder()\n                .id(hit.getId())\n                .score(hit.getScore())\n                .rank(hit.getRank())\n                .retrieverSource(hit.getRetrieverSource())\n                .retrievalScore(hit.getRetrievalScore())\n                .fusionScore(hit.getFusionScore())\n                .rerankScore(hit.getRerankScore())\n                .content(hit.getContent())\n                .metadata(copyMetadata(hit.getMetadata()))\n                .documentId(hit.getDocumentId())\n                .sourceName(hit.getSourceName())\n                .sourcePath(hit.getSourcePath())\n                .sourceUri(hit.getSourceUri())\n                .pageNumber(hit.getPageNumber())\n                .sectionTitle(hit.getSectionTitle())\n                .chunkIndex(hit.getChunkIndex())\n                .scoreDetails(copyScoreDetails(hit.getScoreDetails()))\n                .build();\n    }\n\n    static List<RagHit> prepareRetrievedHits(List<RagHit> hits, String retrieverSource) {\n        List<RagHit> prepared = copyList(hits);\n        assignRanks(prepared);\n        for (RagHit hit : prepared) {\n            if (hit == null) {\n                continue;\n            }\n            if (isBlank(hit.getRetrieverSource())) {\n                hit.setRetrieverSource(retrieverSource);\n            }\n            if (hit.getRetrievalScore() == null && hit.getScore() != null) {\n                hit.setRetrievalScore(hit.getScore());\n            }\n            normalizeEffectiveScore(hit);\n            if ((hit.getScoreDetails() == null || hit.getScoreDetails().isEmpty())\n                    && hit.getRetrievalScore() != null\n                    && !isBlank(hit.getRetrieverSource())\n                    && !\"hybrid\".equalsIgnoreCase(hit.getRetrieverSource())) {\n                hit.setScoreDetails(Collections.singletonList(RagScoreDetail.builder()\n                        .source(hit.getRetrieverSource())\n                        .rank(hit.getRank())\n                        .retrievalScore(hit.getRetrievalScore())\n                        .build()));\n            }\n        }\n        return prepared;\n    }\n\n    static List<RagHit> prepareRerankedHits(List<RagHit> retrievedHits, List<RagHit> rerankedHits, boolean rerankApplied) {\n        List<RagHit> safeReranked = rerankedHits == null ? Collections.<RagHit>emptyList() : rerankedHits;\n        Map<String, RagHit> retrievedIndex = new LinkedHashMap<String, RagHit>();\n        for (int i = 0; i < retrievedHits.size(); i++) {\n            RagHit hit = retrievedHits.get(i);\n            if (hit == null) {\n                continue;\n            }\n            retrievedIndex.put(stableKey(hit, i), hit);\n        }\n        List<RagHit> merged = new ArrayList<RagHit>(safeReranked.size());\n        for (int i = 0; i < safeReranked.size(); i++) {\n            RagHit current = safeReranked.get(i);\n            if (current == null) {\n                continue;\n            }\n            RagHit original = retrievedIndex.get(stableKey(current, i));\n            RagHit mergedHit = merge(original, current);\n            if (rerankApplied && current.getScore() != null) {\n                mergedHit.setRerankScore(current.getScore());\n            }\n            merged.add(mergedHit);\n        }\n        assignRanks(merged);\n        for (RagHit hit : merged) {\n            normalizeEffectiveScore(hit);\n        }\n        return merged;\n    }\n\n    static void assignRanks(List<RagHit> hits) {\n        if (hits == null || hits.isEmpty()) {\n            return;\n        }\n        for (int i = 0; i < hits.size(); i++) {\n            RagHit hit = hits.get(i);\n            if (hit != null) {\n                hit.setRank(i + 1);\n            }\n        }\n    }\n\n    static String stableKey(RagHit hit, int fallbackIndex) {\n        String key = stableKey(hit);\n        return key == null ? String.valueOf(fallbackIndex) : key;\n    }\n\n    static String stableKey(RagHit hit) {\n        if (hit == null) {\n            return null;\n        }\n        if (!isBlank(hit.getId())) {\n            return hit.getId().trim();\n        }\n        if (!isBlank(hit.getDocumentId())) {\n            return hit.getDocumentId().trim() + \"#\" + normalizeIndex(hit.getChunkIndex());\n        }\n        if (!isBlank(hit.getSourcePath())) {\n            return hit.getSourcePath().trim() + \"#\" + normalizeIndex(hit.getChunkIndex());\n        }\n        if (!isBlank(hit.getSourceUri())) {\n            return hit.getSourceUri().trim() + \"#\" + normalizeIndex(hit.getChunkIndex());\n        }\n        if (!isBlank(hit.getSourceName()) && !isBlank(hit.getSectionTitle())) {\n            return hit.getSourceName().trim() + \"#\" + hit.getSectionTitle().trim() + \"#\" + normalizeIndex(hit.getChunkIndex());\n        }\n        if (!isBlank(hit.getContent())) {\n            return hit.getContent().trim();\n        }\n        return null;\n    }\n\n    static void normalizeEffectiveScore(RagHit hit) {\n        if (hit == null) {\n            return;\n        }\n        Float effectiveScore = hit.getRerankScore();\n        if (effectiveScore == null) {\n            effectiveScore = hit.getFusionScore();\n        }\n        if (effectiveScore == null) {\n            effectiveScore = hit.getRetrievalScore();\n        }\n        if (effectiveScore == null) {\n            effectiveScore = hit.getScore();\n        }\n        hit.setScore(effectiveScore);\n    }\n\n    private static RagHit merge(RagHit original, RagHit current) {\n        RagHit base = original == null ? copy(current) : copy(original);\n        if (base == null) {\n            return null;\n        }\n        if (current == null) {\n            return base;\n        }\n        if (!isBlank(current.getId())) {\n            base.setId(current.getId());\n        }\n        if (current.getScore() != null) {\n            base.setScore(current.getScore());\n        }\n        if (current.getRank() != null) {\n            base.setRank(current.getRank());\n        }\n        if (!isBlank(current.getRetrieverSource())) {\n            base.setRetrieverSource(current.getRetrieverSource());\n        }\n        if (current.getRetrievalScore() != null) {\n            base.setRetrievalScore(current.getRetrievalScore());\n        }\n        if (current.getFusionScore() != null) {\n            base.setFusionScore(current.getFusionScore());\n        }\n        if (current.getRerankScore() != null) {\n            base.setRerankScore(current.getRerankScore());\n        }\n        if (!isBlank(current.getContent())) {\n            base.setContent(current.getContent());\n        }\n        if (current.getMetadata() != null && !current.getMetadata().isEmpty()) {\n            base.setMetadata(copyMetadata(current.getMetadata()));\n        }\n        if (!isBlank(current.getDocumentId())) {\n            base.setDocumentId(current.getDocumentId());\n        }\n        if (!isBlank(current.getSourceName())) {\n            base.setSourceName(current.getSourceName());\n        }\n        if (!isBlank(current.getSourcePath())) {\n            base.setSourcePath(current.getSourcePath());\n        }\n        if (!isBlank(current.getSourceUri())) {\n            base.setSourceUri(current.getSourceUri());\n        }\n        if (current.getPageNumber() != null) {\n            base.setPageNumber(current.getPageNumber());\n        }\n        if (!isBlank(current.getSectionTitle())) {\n            base.setSectionTitle(current.getSectionTitle());\n        }\n        if (current.getChunkIndex() != null) {\n            base.setChunkIndex(current.getChunkIndex());\n        }\n        if (current.getScoreDetails() != null && !current.getScoreDetails().isEmpty()) {\n            base.setScoreDetails(copyScoreDetails(current.getScoreDetails()));\n        }\n        return base;\n    }\n\n    private static Map<String, Object> copyMetadata(Map<String, Object> metadata) {\n        if (metadata == null || metadata.isEmpty()) {\n            return metadata;\n        }\n        return new LinkedHashMap<String, Object>(metadata);\n    }\n\n    private static List<RagScoreDetail> copyScoreDetails(List<RagScoreDetail> details) {\n        if (details == null || details.isEmpty()) {\n            return details;\n        }\n        List<RagScoreDetail> copies = new ArrayList<RagScoreDetail>(details.size());\n        for (RagScoreDetail detail : details) {\n            if (detail == null) {\n                continue;\n            }\n            copies.add(RagScoreDetail.builder()\n                    .source(detail.getSource())\n                    .rank(detail.getRank())\n                    .retrievalScore(detail.getRetrievalScore())\n                    .fusionContribution(detail.getFusionContribution())\n                    .build());\n        }\n        return copies;\n    }\n\n    private static String normalizeIndex(Integer chunkIndex) {\n        return chunkIndex == null ? \"-\" : String.valueOf(chunkIndex);\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagMetadataKeys.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport io.github.lnyocly.ai4j.constant.Constants;\n\npublic final class RagMetadataKeys {\n\n    public static final String CONTENT = Constants.METADATA_KEY;\n    public static final String DOCUMENT_ID = \"documentId\";\n    public static final String CHUNK_ID = \"chunkId\";\n    public static final String SOURCE_NAME = \"sourceName\";\n    public static final String SOURCE_PATH = \"sourcePath\";\n    public static final String SOURCE_URI = \"sourceUri\";\n    public static final String PAGE_NUMBER = \"pageNumber\";\n    public static final String SECTION_TITLE = \"sectionTitle\";\n    public static final String CHUNK_INDEX = \"chunkIndex\";\n    public static final String TENANT = \"tenant\";\n    public static final String BIZ = \"biz\";\n    public static final String VERSION = \"version\";\n\n    private RagMetadataKeys() {\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagQuery.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagQuery {\n\n    private String query;\n\n    private String dataset;\n\n    private String embeddingModel;\n\n    @Builder.Default\n    private Integer topK = 5;\n\n    private Integer finalTopK;\n\n    private Map<String, Object> filter;\n\n    @Builder.Default\n    private String delimiter = \"\\n\\n\";\n\n    @Builder.Default\n    private boolean includeCitations = true;\n\n    @Builder.Default\n    private boolean includeTrace = true;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagResult.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagResult {\n\n    private String query;\n\n    @Builder.Default\n    private List<RagHit> hits = Collections.emptyList();\n\n    private String context;\n\n    @Builder.Default\n    private List<RagCitation> citations = Collections.emptyList();\n\n    @Builder.Default\n    private List<RagCitation> sources = Collections.emptyList();\n\n    private RagTrace trace;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagScoreDetail.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagScoreDetail {\n\n    private String source;\n\n    private Integer rank;\n\n    private Float retrievalScore;\n\n    private Float fusionContribution;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagService.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\npublic interface RagService {\n\n    RagResult search(RagQuery query) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RagTrace.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class RagTrace {\n\n    @Builder.Default\n    private List<RagHit> retrievedHits = Collections.emptyList();\n\n    @Builder.Default\n    private List<RagHit> rerankedHits = Collections.emptyList();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/Reranker.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.List;\n\npublic interface Reranker {\n\n    List<RagHit> rerank(String query, List<RagHit> hits) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/Retriever.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.List;\n\npublic interface Retriever {\n\n    List<RagHit> retrieve(RagQuery query) throws Exception;\n\n    default String retrieverSource() {\n        String simpleName = getClass().getSimpleName();\n        if (simpleName == null || simpleName.trim().isEmpty()) {\n            return \"retriever\";\n        }\n        return simpleName;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RrfFusionStrategy.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class RrfFusionStrategy implements FusionStrategy {\n\n    private final int rankConstant;\n\n    public RrfFusionStrategy() {\n        this(60);\n    }\n\n    public RrfFusionStrategy(int rankConstant) {\n        this.rankConstant = rankConstant <= 0 ? 60 : rankConstant;\n    }\n\n    @Override\n    public List<Double> scoreContributions(List<RagHit> hits) {\n        if (hits == null || hits.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Double> scores = new ArrayList<Double>(hits.size());\n        for (int i = 0; i < hits.size(); i++) {\n            scores.add(1.0d / (rankConstant + i + 1));\n        }\n        return scores;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/RsfFusionStrategy.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class RsfFusionStrategy extends AbstractScoreFusionStrategy {\n\n    @Override\n    protected List<Double> scoreWithRawScores(List<Double> rawScores) {\n        double min = Double.POSITIVE_INFINITY;\n        double max = Double.NEGATIVE_INFINITY;\n        for (Double rawScore : rawScores) {\n            if (rawScore == null) {\n                continue;\n            }\n            if (rawScore < min) {\n                min = rawScore;\n            }\n            if (rawScore > max) {\n                max = rawScore;\n            }\n        }\n        double denominator = max - min;\n        List<Double> scores = new ArrayList<Double>(rawScores.size());\n        for (Double rawScore : rawScores) {\n            scores.add((rawScore - min) / denominator);\n        }\n        return scores;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/TextTokenizer.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport java.util.List;\n\npublic interface TextTokenizer {\n\n    List<String> tokenize(String text);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/Chunker.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport io.github.lnyocly.ai4j.rag.RagChunk;\nimport io.github.lnyocly.ai4j.rag.RagDocument;\n\nimport java.util.List;\n\n/**\n * Splits a loaded document into retrieval chunks.\n */\npublic interface Chunker {\n\n    List<RagChunk> chunk(RagDocument document, String content) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/DefaultMetadataEnricher.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport io.github.lnyocly.ai4j.rag.RagChunk;\nimport io.github.lnyocly.ai4j.rag.RagDocument;\nimport io.github.lnyocly.ai4j.rag.RagMetadataKeys;\n\nimport java.util.Map;\n\n/**\n * Writes the canonical RAG metadata keys used by retrieval and citation layers.\n */\npublic class DefaultMetadataEnricher implements MetadataEnricher {\n\n    @Override\n    public void enrich(RagDocument document, RagChunk chunk, Map<String, Object> metadata) {\n        if (document == null || chunk == null || metadata == null) {\n            return;\n        }\n        putIfNotBlank(metadata, RagMetadataKeys.CONTENT, chunk.getContent());\n        putIfNotBlank(metadata, RagMetadataKeys.DOCUMENT_ID, document.getDocumentId());\n        putIfNotBlank(metadata, RagMetadataKeys.CHUNK_ID, chunk.getChunkId());\n        putIfNotBlank(metadata, RagMetadataKeys.SOURCE_NAME, document.getSourceName());\n        putIfNotBlank(metadata, RagMetadataKeys.SOURCE_PATH, document.getSourcePath());\n        putIfNotBlank(metadata, RagMetadataKeys.SOURCE_URI, document.getSourceUri());\n        putIfNotBlank(metadata, RagMetadataKeys.SECTION_TITLE, chunk.getSectionTitle());\n        putIfNotBlank(metadata, RagMetadataKeys.TENANT, document.getTenant());\n        putIfNotBlank(metadata, RagMetadataKeys.BIZ, document.getBiz());\n        putIfNotBlank(metadata, RagMetadataKeys.VERSION, document.getVersion());\n        if (chunk.getChunkIndex() != null) {\n            metadata.put(RagMetadataKeys.CHUNK_INDEX, chunk.getChunkIndex());\n        }\n        if (chunk.getPageNumber() != null) {\n            metadata.put(RagMetadataKeys.PAGE_NUMBER, chunk.getPageNumber());\n        }\n    }\n\n    private void putIfNotBlank(Map<String, Object> metadata, String key, String value) {\n        if (key == null || value == null || value.trim().isEmpty()) {\n            return;\n        }\n        metadata.put(key, value.trim());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/DocumentLoader.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\n/**\n * Converts a raw ingestion source into normalized text plus source metadata.\n */\npublic interface DocumentLoader {\n\n    boolean supports(IngestionSource source);\n\n    LoadedDocument load(IngestionSource source) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/IngestionPipeline.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.rag.RagChunk;\nimport io.github.lnyocly.ai4j.rag.RagDocument;\nimport io.github.lnyocly.ai4j.rag.RagMetadataKeys;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\n/**\n * Thin orchestration layer for RAG ingestion:\n * source -> text -> chunks -> metadata -> embeddings -> vector upsert.\n */\npublic class IngestionPipeline {\n\n    private static final int DEFAULT_BATCH_SIZE = 32;\n\n    private final IEmbeddingService embeddingService;\n    private final VectorStore vectorStore;\n    private final List<DocumentLoader> documentLoaders;\n    private final Chunker defaultChunker;\n    private final List<LoadedDocumentProcessor> defaultDocumentProcessors;\n    private final List<MetadataEnricher> defaultMetadataEnrichers;\n\n    public IngestionPipeline(IEmbeddingService embeddingService, VectorStore vectorStore) {\n        this(\n                embeddingService,\n                vectorStore,\n                Arrays.<DocumentLoader>asList(new TextDocumentLoader(), new TikaDocumentLoader()),\n                new RecursiveTextChunker(1000, 200),\n                Collections.<LoadedDocumentProcessor>singletonList(new WhitespaceNormalizingDocumentProcessor()),\n                Collections.<MetadataEnricher>singletonList(new DefaultMetadataEnricher())\n        );\n    }\n\n    public IngestionPipeline(IEmbeddingService embeddingService,\n                             VectorStore vectorStore,\n                             List<DocumentLoader> documentLoaders,\n                             Chunker defaultChunker,\n                             List<MetadataEnricher> defaultMetadataEnrichers) {\n        this(embeddingService, vectorStore, documentLoaders, defaultChunker, null, defaultMetadataEnrichers);\n    }\n\n    public IngestionPipeline(IEmbeddingService embeddingService,\n                             VectorStore vectorStore,\n                             List<DocumentLoader> documentLoaders,\n                             Chunker defaultChunker,\n                             List<LoadedDocumentProcessor> defaultDocumentProcessors,\n                             List<MetadataEnricher> defaultMetadataEnrichers) {\n        if (embeddingService == null) {\n            throw new IllegalArgumentException(\"embeddingService is required\");\n        }\n        if (vectorStore == null) {\n            throw new IllegalArgumentException(\"vectorStore is required\");\n        }\n        this.embeddingService = embeddingService;\n        this.vectorStore = vectorStore;\n        this.documentLoaders = documentLoaders == null ? Collections.<DocumentLoader>emptyList() : new ArrayList<DocumentLoader>(documentLoaders);\n        this.defaultChunker = defaultChunker == null ? new RecursiveTextChunker(1000, 200) : defaultChunker;\n        this.defaultDocumentProcessors = defaultDocumentProcessors == null\n                ? Collections.<LoadedDocumentProcessor>singletonList(new WhitespaceNormalizingDocumentProcessor())\n                : new ArrayList<LoadedDocumentProcessor>(defaultDocumentProcessors);\n        this.defaultMetadataEnrichers = defaultMetadataEnrichers == null\n                ? Collections.<MetadataEnricher>singletonList(new DefaultMetadataEnricher())\n                : new ArrayList<MetadataEnricher>(defaultMetadataEnrichers);\n    }\n\n    public IngestionResult ingest(IngestionRequest request) throws Exception {\n        if (request == null) {\n            throw new IllegalArgumentException(\"request is required\");\n        }\n        if (isBlank(request.getDataset())) {\n            throw new IllegalArgumentException(\"dataset is required\");\n        }\n        if (isBlank(request.getEmbeddingModel())) {\n            throw new IllegalArgumentException(\"embeddingModel is required\");\n        }\n        LoadedDocument loadedDocument = load(request.getSource());\n        loadedDocument = processLoadedDocument(request.getSource(), loadedDocument, mergeDocumentProcessors(request.getDocumentProcessors()));\n        if (loadedDocument == null || isBlank(loadedDocument.getContent())) {\n            throw new IllegalStateException(\"Loaded document content is empty\");\n        }\n        RagDocument document = resolveDocument(request.getDocument(), request.getSource(), loadedDocument);\n        List<RagChunk> chunks = normalizeChunks(\n                document,\n                (request.getChunker() == null ? defaultChunker : request.getChunker()).chunk(document, loadedDocument.getContent())\n        );\n        if (chunks.isEmpty()) {\n            return IngestionResult.builder()\n                    .dataset(request.getDataset())\n                    .embeddingModel(request.getEmbeddingModel())\n                    .source(request.getSource())\n                    .document(document)\n                    .chunks(Collections.<RagChunk>emptyList())\n                    .records(Collections.<VectorRecord>emptyList())\n                    .upsertedCount(0)\n                    .build();\n        }\n\n        List<MetadataEnricher> enrichers = mergeEnrichers(request.getMetadataEnrichers());\n        List<VectorRecord> records = buildRecords(request, document, chunks, enrichers);\n        int upsertedCount = Boolean.FALSE.equals(request.getUpsert())\n                ? 0\n                : vectorStore.upsert(VectorUpsertRequest.builder()\n                        .dataset(request.getDataset())\n                        .records(records)\n                        .build());\n\n        return IngestionResult.builder()\n                .dataset(request.getDataset())\n                .embeddingModel(request.getEmbeddingModel())\n                .source(request.getSource())\n                .document(document)\n                .chunks(chunks)\n                .records(records)\n                .upsertedCount(upsertedCount)\n                .build();\n    }\n\n    private LoadedDocument load(IngestionSource source) throws Exception {\n        if (source == null) {\n            throw new IllegalArgumentException(\"source is required\");\n        }\n        for (DocumentLoader loader : documentLoaders) {\n            if (loader != null && loader.supports(source)) {\n                return loader.load(source);\n            }\n        }\n        throw new IllegalArgumentException(\"No DocumentLoader can handle the provided source\");\n    }\n\n    private RagDocument resolveDocument(RagDocument requestedDocument, IngestionSource source, LoadedDocument loadedDocument) {\n        RagDocument base = requestedDocument == null ? new RagDocument() : requestedDocument;\n        Map<String, Object> mergedMetadata = new LinkedHashMap<String, Object>();\n        if (loadedDocument != null && loadedDocument.getMetadata() != null) {\n            mergedMetadata.putAll(loadedDocument.getMetadata());\n        }\n        if (source != null && source.getMetadata() != null) {\n            mergedMetadata.putAll(source.getMetadata());\n        }\n        if (base.getMetadata() != null) {\n            mergedMetadata.putAll(base.getMetadata());\n        }\n\n        String sourceName = firstNonBlank(\n                base.getSourceName(),\n                loadedDocument == null ? null : loadedDocument.getSourceName(),\n                source == null ? null : source.getName(),\n                source != null && source.getFile() != null ? source.getFile().getName() : null\n        );\n        String sourcePath = firstNonBlank(\n                base.getSourcePath(),\n                loadedDocument == null ? null : loadedDocument.getSourcePath(),\n                source == null ? null : source.getPath(),\n                source != null && source.getFile() != null ? source.getFile().getAbsolutePath() : null\n        );\n        String sourceUri = firstNonBlank(\n                base.getSourceUri(),\n                loadedDocument == null ? null : loadedDocument.getSourceUri(),\n                source == null ? null : source.getUri(),\n                source != null && source.getFile() != null ? source.getFile().toURI().toString() : null\n        );\n\n        String documentId = firstNonBlank(\n                base.getDocumentId(),\n                stringValue(mergedMetadata.get(RagMetadataKeys.DOCUMENT_ID))\n        );\n        if (isBlank(documentId)) {\n            String seed = firstNonBlank(sourceUri, sourcePath, sourceName);\n            documentId = isBlank(seed)\n                    ? UUID.randomUUID().toString()\n                    : UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8)).toString();\n        }\n\n        return RagDocument.builder()\n                .documentId(documentId)\n                .sourceName(sourceName)\n                .sourcePath(sourcePath)\n                .sourceUri(sourceUri)\n                .title(firstNonBlank(base.getTitle(), sourceName))\n                .tenant(firstNonBlank(base.getTenant(), stringValue(mergedMetadata.get(RagMetadataKeys.TENANT))))\n                .biz(firstNonBlank(base.getBiz(), stringValue(mergedMetadata.get(RagMetadataKeys.BIZ))))\n                .version(firstNonBlank(base.getVersion(), stringValue(mergedMetadata.get(RagMetadataKeys.VERSION))))\n                .metadata(mergedMetadata)\n                .build();\n    }\n\n    private List<RagChunk> normalizeChunks(RagDocument document, List<RagChunk> chunks) {\n        if (chunks == null || chunks.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<RagChunk> normalized = new ArrayList<RagChunk>(chunks.size());\n        int ordinal = 0;\n        for (RagChunk chunk : chunks) {\n            if (chunk == null || isBlank(chunk.getContent())) {\n                continue;\n            }\n            Integer chunkIndex = chunk.getChunkIndex() == null ? ordinal : chunk.getChunkIndex();\n            normalized.add(RagChunk.builder()\n                    .chunkId(firstNonBlank(chunk.getChunkId(), buildChunkId(document.getDocumentId(), chunkIndex)))\n                    .documentId(firstNonBlank(chunk.getDocumentId(), document.getDocumentId()))\n                    .content(chunk.getContent().trim())\n                    .chunkIndex(chunkIndex)\n                    .pageNumber(chunk.getPageNumber())\n                    .sectionTitle(chunk.getSectionTitle())\n                    .metadata(copyMetadata(chunk.getMetadata()))\n                    .build());\n            ordinal++;\n        }\n        return normalized;\n    }\n\n    private List<VectorRecord> buildRecords(IngestionRequest request,\n                                            RagDocument document,\n                                            List<RagChunk> chunks,\n                                            List<MetadataEnricher> enrichers) throws Exception {\n        List<String> contents = new ArrayList<String>(chunks.size());\n        for (RagChunk chunk : chunks) {\n            contents.add(chunk.getContent());\n        }\n        List<List<Float>> vectors = embed(contents, request.getEmbeddingModel(), request.getBatchSize());\n        if (vectors.size() != chunks.size()) {\n            throw new IllegalStateException(\"Embedding vector count does not match chunk count\");\n        }\n        List<VectorRecord> records = new ArrayList<VectorRecord>(chunks.size());\n        for (int i = 0; i < chunks.size(); i++) {\n            RagChunk chunk = chunks.get(i);\n            Map<String, Object> metadata = buildChunkMetadata(document, chunk, enrichers);\n            chunk.setMetadata(metadata);\n            records.add(VectorRecord.builder()\n                    .id(chunk.getChunkId())\n                    .vector(vectors.get(i))\n                    .content(chunk.getContent())\n                    .metadata(metadata)\n                    .build());\n        }\n        return records;\n    }\n\n    private Map<String, Object> buildChunkMetadata(RagDocument document,\n                                                   RagChunk chunk,\n                                                   List<MetadataEnricher> enrichers) throws Exception {\n        Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n        if (document != null && document.getMetadata() != null) {\n            metadata.putAll(document.getMetadata());\n        }\n        if (chunk != null && chunk.getMetadata() != null) {\n            metadata.putAll(chunk.getMetadata());\n        }\n        for (MetadataEnricher enricher : enrichers) {\n            if (enricher != null) {\n                enricher.enrich(document, chunk, metadata);\n            }\n        }\n        return metadata;\n    }\n\n    private List<MetadataEnricher> mergeEnrichers(List<MetadataEnricher> enrichers) {\n        List<MetadataEnricher> merged = new ArrayList<MetadataEnricher>(defaultMetadataEnrichers);\n        if (enrichers != null && !enrichers.isEmpty()) {\n            merged.addAll(enrichers);\n        }\n        return merged;\n    }\n\n    private List<LoadedDocumentProcessor> mergeDocumentProcessors(List<LoadedDocumentProcessor> processors) {\n        List<LoadedDocumentProcessor> merged = new ArrayList<LoadedDocumentProcessor>(defaultDocumentProcessors);\n        if (processors != null && !processors.isEmpty()) {\n            merged.addAll(processors);\n        }\n        return merged;\n    }\n\n    private LoadedDocument processLoadedDocument(IngestionSource source,\n                                                 LoadedDocument loadedDocument,\n                                                 List<LoadedDocumentProcessor> processors) throws Exception {\n        LoadedDocument current = loadedDocument;\n        if (processors == null || processors.isEmpty()) {\n            return current;\n        }\n        for (LoadedDocumentProcessor processor : processors) {\n            if (processor == null || current == null) {\n                continue;\n            }\n            LoadedDocument processed = processor.process(source, current);\n            if (processed != null) {\n                current = processed;\n            }\n        }\n        return current;\n    }\n\n    private List<List<Float>> embed(List<String> texts, String model, Integer batchSize) throws Exception {\n        if (texts == null || texts.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int effectiveBatchSize = batchSize == null || batchSize <= 0 ? DEFAULT_BATCH_SIZE : batchSize;\n        List<List<Float>> vectors = new ArrayList<List<Float>>(texts.size());\n        for (int start = 0; start < texts.size(); start += effectiveBatchSize) {\n            int end = Math.min(start + effectiveBatchSize, texts.size());\n            List<String> batch = new ArrayList<String>(texts.subList(start, end));\n            EmbeddingResponse response = embeddingService.embedding(Embedding.builder()\n                    .model(model)\n                    .input(batch)\n                    .build());\n            vectors.addAll(extractEmbeddings(response, batch.size()));\n        }\n        return vectors;\n    }\n\n    private List<List<Float>> extractEmbeddings(EmbeddingResponse response, int expectedSize) {\n        List<EmbeddingObject> data = response == null ? null : response.getData();\n        if (data == null || data.isEmpty()) {\n            throw new IllegalStateException(\"Failed to generate embeddings for ingestion pipeline\");\n        }\n        Map<Integer, List<Float>> indexed = new LinkedHashMap<Integer, List<Float>>();\n        int fallbackIndex = 0;\n        for (EmbeddingObject object : data) {\n            if (object == null || object.getEmbedding() == null) {\n                continue;\n            }\n            Integer index = object.getIndex();\n            indexed.put(index == null ? fallbackIndex : index, object.getEmbedding());\n            fallbackIndex++;\n        }\n        if (indexed.size() < expectedSize) {\n            throw new IllegalStateException(\"Embedding response size is smaller than the requested batch size\");\n        }\n        List<List<Float>> vectors = new ArrayList<List<Float>>(expectedSize);\n        for (int i = 0; i < expectedSize; i++) {\n            List<Float> vector = indexed.get(i);\n            if (vector == null) {\n                throw new IllegalStateException(\"Missing embedding vector at index \" + i);\n            }\n            vectors.add(new ArrayList<Float>(vector));\n        }\n        return vectors;\n    }\n\n    private String buildChunkId(String documentId, Integer chunkIndex) {\n        return documentId + \"#chunk-\" + (chunkIndex == null ? 0 : chunkIndex);\n    }\n\n    private Map<String, Object> copyMetadata(Map<String, Object> metadata) {\n        if (metadata == null || metadata.isEmpty()) {\n            return null;\n        }\n        return new LinkedHashMap<String, Object>(metadata);\n    }\n\n    private String stringValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        String text = String.valueOf(value).trim();\n        return text.isEmpty() ? null : text;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/IngestionRequest.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport io.github.lnyocly.ai4j.rag.RagDocument;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class IngestionRequest {\n\n    private String dataset;\n\n    private String embeddingModel;\n\n    private RagDocument document;\n\n    private IngestionSource source;\n\n    private Chunker chunker;\n\n    @Builder.Default\n    private List<LoadedDocumentProcessor> documentProcessors = Collections.emptyList();\n\n    @Builder.Default\n    private List<MetadataEnricher> metadataEnrichers = Collections.emptyList();\n\n    @Builder.Default\n    private Integer batchSize = 32;\n\n    @Builder.Default\n    private Boolean upsert = Boolean.TRUE;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/IngestionResult.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport io.github.lnyocly.ai4j.rag.RagChunk;\nimport io.github.lnyocly.ai4j.rag.RagDocument;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class IngestionResult {\n\n    private String dataset;\n\n    private String embeddingModel;\n\n    private IngestionSource source;\n\n    private RagDocument document;\n\n    @Builder.Default\n    private List<RagChunk> chunks = Collections.emptyList();\n\n    @Builder.Default\n    private List<VectorRecord> records = Collections.emptyList();\n\n    private int upsertedCount;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/IngestionSource.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.io.File;\nimport java.util.Collections;\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class IngestionSource {\n\n    private String name;\n\n    private String path;\n\n    private String uri;\n\n    private File file;\n\n    private String content;\n\n    @Builder.Default\n    private Map<String, Object> metadata = Collections.emptyMap();\n\n    public static IngestionSource text(String content) {\n        return IngestionSource.builder().content(content).build();\n    }\n\n    public static IngestionSource file(File file) {\n        return IngestionSource.builder().file(file).build();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/LoadedDocument.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class LoadedDocument {\n\n    private String content;\n\n    private String sourceName;\n\n    private String sourcePath;\n\n    private String sourceUri;\n\n    @Builder.Default\n    private Map<String, Object> metadata = Collections.emptyMap();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/LoadedDocumentProcessor.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\n/**\n * Post-processes loaded documents before chunking.\n * Useful for OCR fallback, scanned-document cleanup, and complex text normalization.\n */\npublic interface LoadedDocumentProcessor {\n\n    LoadedDocument process(IngestionSource source, LoadedDocument document) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/MetadataEnricher.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport io.github.lnyocly.ai4j.rag.RagChunk;\nimport io.github.lnyocly.ai4j.rag.RagDocument;\n\nimport java.util.Map;\n\n/**\n * Adds or normalizes metadata before records are written into the vector store.\n */\npublic interface MetadataEnricher {\n\n    void enrich(RagDocument document, RagChunk chunk, Map<String, Object> metadata) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/OcrNoiseCleaningDocumentProcessor.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\n/**\n * Cleans common OCR artifacts such as hyphenated line breaks and letter-by-letter spacing.\n */\npublic class OcrNoiseCleaningDocumentProcessor implements LoadedDocumentProcessor {\n\n    @Override\n    public LoadedDocument process(IngestionSource source, LoadedDocument document) {\n        if (document == null || isBlank(document.getContent())) {\n            return document;\n        }\n        String cleaned = clean(document.getContent());\n        if (cleaned.equals(document.getContent())) {\n            return document;\n        }\n        Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n        if (document.getMetadata() != null) {\n            metadata.putAll(document.getMetadata());\n        }\n        metadata.put(\"ocrNoiseCleaned\", Boolean.TRUE);\n        return document.toBuilder()\n                .content(cleaned)\n                .metadata(metadata)\n                .build();\n    }\n\n    String clean(String content) {\n        String normalized = content.replace(\"\\r\\n\", \"\\n\").replace('\\r', '\\n');\n        String hyphenJoined = normalized.replaceAll(\"([\\\\p{L}\\\\p{N}])\\\\s*-\\\\s*\\\\n\\\\s*([\\\\p{L}\\\\p{N}])\", \"$1$2\");\n        String[] lines = hyphenJoined.split(\"\\\\n\", -1);\n        StringBuilder cleaned = new StringBuilder();\n        int blankCount = 0;\n        for (String line : lines) {\n            String normalizedLine = collapseInnerWhitespace(line);\n            if (looksLikeSpacedWord(normalizedLine)) {\n                normalizedLine = normalizedLine.replace(\" \", \"\");\n            }\n            normalizedLine = normalizedLine.trim();\n            if (normalizedLine.isEmpty()) {\n                blankCount++;\n                if (blankCount > 1) {\n                    continue;\n                }\n            } else {\n                blankCount = 0;\n            }\n            if (cleaned.length() > 0) {\n                cleaned.append('\\n');\n            }\n            cleaned.append(normalizedLine);\n        }\n        return cleaned.toString().trim();\n    }\n\n    private String collapseInnerWhitespace(String value) {\n        return value == null ? \"\" : value.replaceAll(\"[\\\\t\\\\x0B\\\\f ]+\", \" \");\n    }\n\n    private boolean looksLikeSpacedWord(String line) {\n        if (line == null) {\n            return false;\n        }\n        String trimmed = line.trim();\n        if (trimmed.isEmpty()) {\n            return false;\n        }\n        String[] parts = trimmed.split(\" \");\n        if (parts.length < 3) {\n            return false;\n        }\n        for (String part : parts) {\n            if (part.length() != 1 || !Character.isLetterOrDigit(part.charAt(0))) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/OcrTextExtractingDocumentProcessor.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\n/**\n * Uses an external OCR extractor to populate text for scanned or non-text documents.\n */\npublic class OcrTextExtractingDocumentProcessor implements LoadedDocumentProcessor {\n\n    private final OcrTextExtractor extractor;\n\n    public OcrTextExtractingDocumentProcessor(OcrTextExtractor extractor) {\n        if (extractor == null) {\n            throw new IllegalArgumentException(\"extractor is required\");\n        }\n        this.extractor = extractor;\n    }\n\n    @Override\n    public LoadedDocument process(IngestionSource source, LoadedDocument document) throws Exception {\n        if (document == null || !isBlank(document.getContent())) {\n            return document;\n        }\n        if (!extractor.supports(source, document)) {\n            return document;\n        }\n        String extracted = extractor.extractText(source, document);\n        if (isBlank(extracted)) {\n            return document;\n        }\n        Map<String, Object> metadata = copyMetadata(document.getMetadata());\n        metadata.put(\"ocrApplied\", Boolean.TRUE);\n        return document.toBuilder()\n                .content(extracted)\n                .metadata(metadata)\n                .build();\n    }\n\n    private Map<String, Object> copyMetadata(Map<String, Object> metadata) {\n        Map<String, Object> copied = new LinkedHashMap<String, Object>();\n        if (metadata != null) {\n            copied.putAll(metadata);\n        }\n        return copied;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/OcrTextExtractor.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\n/**\n * Extension point for OCR engines used during ingestion.\n */\npublic interface OcrTextExtractor {\n\n    boolean supports(IngestionSource source, LoadedDocument document);\n\n    String extractText(IngestionSource source, LoadedDocument document) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/RecursiveTextChunker.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport io.github.lnyocly.ai4j.document.RecursiveCharacterTextSplitter;\nimport io.github.lnyocly.ai4j.rag.RagChunk;\nimport io.github.lnyocly.ai4j.rag.RagDocument;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\n/**\n * Default chunker backed by the existing recursive character splitter.\n */\npublic class RecursiveTextChunker implements Chunker {\n\n    private final RecursiveCharacterTextSplitter splitter;\n\n    public RecursiveTextChunker(int chunkSize, int chunkOverlap) {\n        this(new RecursiveCharacterTextSplitter(chunkSize, chunkOverlap));\n    }\n\n    public RecursiveTextChunker(RecursiveCharacterTextSplitter splitter) {\n        if (splitter == null) {\n            throw new IllegalArgumentException(\"splitter is required\");\n        }\n        this.splitter = splitter;\n    }\n\n    @Override\n    public List<RagChunk> chunk(RagDocument document, String content) {\n        if (content == null || content.trim().isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> parts = splitter.splitText(content);\n        if (parts == null || parts.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<RagChunk> chunks = new ArrayList<RagChunk>(parts.size());\n        int index = 0;\n        for (String part : parts) {\n            if (part == null || part.trim().isEmpty()) {\n                continue;\n            }\n            chunks.add(RagChunk.builder()\n                    .documentId(document == null ? null : document.getDocumentId())\n                    .content(part)\n                    .chunkIndex(index++)\n                    .build());\n        }\n        return chunks;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/TextDocumentLoader.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\n/**\n * Loader for already extracted text content.\n */\npublic class TextDocumentLoader implements DocumentLoader {\n\n    @Override\n    public boolean supports(IngestionSource source) {\n        return source != null && source.getContent() != null && !source.getContent().trim().isEmpty();\n    }\n\n    @Override\n    public LoadedDocument load(IngestionSource source) {\n        return LoadedDocument.builder()\n                .content(source.getContent())\n                .sourceName(source.getName())\n                .sourcePath(source.getPath())\n                .sourceUri(source.getUri())\n                .metadata(source.getMetadata())\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/TikaDocumentLoader.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport io.github.lnyocly.ai4j.document.TikaUtil;\n\nimport java.io.File;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\n/**\n * Loader for local files using Apache Tika.\n */\npublic class TikaDocumentLoader implements DocumentLoader {\n\n    @Override\n    public boolean supports(IngestionSource source) {\n        return source != null && source.getFile() != null;\n    }\n\n    @Override\n    public LoadedDocument load(IngestionSource source) throws Exception {\n        File file = source.getFile();\n        Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n        if (source.getMetadata() != null) {\n            metadata.putAll(source.getMetadata());\n        }\n        metadata.put(\"mimeType\", TikaUtil.detectMimeType(file));\n        return LoadedDocument.builder()\n                .content(TikaUtil.parseFile(file))\n                .sourceName(source.getName() == null ? file.getName() : source.getName())\n                .sourcePath(source.getPath() == null ? file.getAbsolutePath() : source.getPath())\n                .sourceUri(source.getUri() == null ? file.toURI().toString() : source.getUri())\n                .metadata(metadata)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rag/ingestion/WhitespaceNormalizingDocumentProcessor.java",
    "content": "package io.github.lnyocly.ai4j.rag.ingestion;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\n/**\n * Normalizes line endings and trims noisy whitespace without changing semantic content.\n */\npublic class WhitespaceNormalizingDocumentProcessor implements LoadedDocumentProcessor {\n\n    @Override\n    public LoadedDocument process(IngestionSource source, LoadedDocument document) {\n        if (document == null || document.getContent() == null) {\n            return document;\n        }\n        String normalized = normalize(document.getContent());\n        if (normalized.equals(document.getContent())) {\n            return document;\n        }\n        Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n        if (document.getMetadata() != null) {\n            metadata.putAll(document.getMetadata());\n        }\n        metadata.put(\"whitespaceNormalized\", Boolean.TRUE);\n        return document.toBuilder()\n                .content(normalized)\n                .metadata(metadata)\n                .build();\n    }\n\n    String normalize(String text) {\n        String normalized = text.replace(\"\\r\\n\", \"\\n\").replace('\\r', '\\n');\n        String[] lines = normalized.split(\"\\\\n\", -1);\n        StringBuilder builder = new StringBuilder();\n        int blankCount = 0;\n        for (String line : lines) {\n            String cleaned = line == null ? \"\" : line.replaceAll(\"[\\\\t\\\\x0B\\\\f ]+\", \" \").trim();\n            if (cleaned.isEmpty()) {\n                blankCount++;\n                if (blankCount > 1) {\n                    continue;\n                }\n            } else {\n                blankCount = 0;\n            }\n            if (builder.length() > 0) {\n                builder.append('\\n');\n            }\n            builder.append(cleaned);\n        }\n        return builder.toString().trim();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankDocument.java",
    "content": "package io.github.lnyocly.ai4j.rerank.entity;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class RerankDocument {\n\n    private String id;\n\n    private String text;\n\n    private String content;\n\n    private String title;\n\n    private Object image;\n\n    private Map<String, Object> metadata;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankRequest.java",
    "content": "package io.github.lnyocly.ai4j.rerank.entity;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AccessLevel;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport lombok.NonNull;\nimport lombok.Singular;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor(access = AccessLevel.PRIVATE)\n@AllArgsConstructor(access = AccessLevel.PRIVATE)\n@JsonInclude(JsonInclude.Include.NON_NULL)\npublic class RerankRequest {\n\n    @NonNull\n    private String model;\n\n    @NonNull\n    private Object query;\n\n    @Builder.Default\n    private List<RerankDocument> documents = java.util.Collections.<RerankDocument>emptyList();\n\n    @JsonProperty(\"top_n\")\n    private Integer topN;\n\n    @JsonProperty(\"return_documents\")\n    private Boolean returnDocuments;\n\n    private String instruction;\n\n    @JsonIgnore\n    @Singular(\"extraBody\")\n    private Map<String, Object> extraBody;\n\n    @JsonAnyGetter\n    public Map<String, Object> getExtraBody() {\n        return extraBody;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankResponse.java",
    "content": "package io.github.lnyocly.ai4j.rerank.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class RerankResponse {\n\n    private String id;\n\n    private String model;\n\n    @Builder.Default\n    private List<RerankResult> results = Collections.emptyList();\n\n    private RerankUsage usage;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankResult.java",
    "content": "package io.github.lnyocly.ai4j.rerank.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class RerankResult {\n\n    private Integer index;\n\n    @JsonProperty(\"relevance_score\")\n    private Float relevanceScore;\n\n    private RerankDocument document;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/rerank/entity/RerankUsage.java",
    "content": "package io.github.lnyocly.ai4j.rerank.entity;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\n@JsonInclude(JsonInclude.Include.NON_NULL)\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class RerankUsage {\n\n    @JsonProperty(\"prompt_tokens\")\n    private Integer promptTokens;\n\n    @JsonProperty(\"total_tokens\")\n    private Integer totalTokens;\n\n    @JsonProperty(\"input_tokens\")\n    private Integer inputTokens;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/AiConfig.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.config.AiPlatform;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\npublic class AiConfig {\n\n    private List<AiPlatform> platforms;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/Configuration.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.config.*;\nimport io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig;\nimport lombok.Data;\nimport okhttp3.OkHttpClient;\nimport okhttp3.sse.EventSource;\nimport okhttp3.sse.EventSources;\n\n\n/**\n * @Author cly\n * @Description 统一的配置管理\n * @Date 2024/8/8 23:44\n */\n\n@Data\npublic class Configuration {\n\n    private OkHttpClient okHttpClient;\n\n    public EventSource.Factory createRequestFactory() {\n        return EventSources.createFactory(okHttpClient);\n    }\n\n    private OpenAiConfig openAiConfig;\n    private ZhipuConfig zhipuConfig;\n    private DeepSeekConfig deepSeekConfig;\n    private MoonshotConfig moonshotConfig;\n    private HunyuanConfig hunyuanConfig;\n    private LingyiConfig lingyiConfig;\n    private OllamaConfig ollamaConfig;\n    private MinimaxConfig minimaxConfig;\n    private BaichuanConfig baichuanConfig;\n\n    private PineconeConfig pineconeConfig;\n\n    private QdrantConfig qdrantConfig;\n\n    private MilvusConfig milvusConfig;\n\n    private PgVectorConfig pgVectorConfig;\n\n    private SearXNGConfig searXNGConfig;\n\n    private McpConfig mcpConfig;\n\n    private DashScopeConfig dashScopeConfig;\n\n    private DoubaoConfig doubaoConfig;\n\n    private JinaConfig jinaConfig;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/IAudioService.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.platform.openai.audio.entity.*;\n\nimport java.io.InputStream;\n\n/**\n * @Author cly\n * @Description 音频audio接口服务\n * @Date 2024/10/10 23:39\n */\npublic interface IAudioService {\n\n    InputStream textToSpeech(String baseUrl, String apiKey, TextToSpeech textToSpeech);\n    InputStream textToSpeech(TextToSpeech textToSpeech);\n\n    TranscriptionResponse transcription(String baseUrl, String apiKey, Transcription transcription);\n    TranscriptionResponse transcription(Transcription transcription);\n\n    TranslationResponse translation(String baseUrl, String apiKey, Translation translation);\n    TranslationResponse translation(Translation translation);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/IChatService.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/2 23:15\n */\npublic interface IChatService {\n\n    ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception;\n    ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception;\n    void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception;\n    void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception;\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/IEmbeddingService.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/2 23:15\n */\npublic interface IEmbeddingService {\n\n    EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq)  throws Exception ;\n    EmbeddingResponse embedding(Embedding embeddingReq)  throws Exception ;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/IImageService.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGeneration;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse;\nimport io.github.lnyocly.ai4j.listener.ImageSseListener;\n\n/**\n * @Author cly\n * @Description 图片生成服务接口\n * @Date 2026/1/31\n */\npublic interface IImageService {\n\n    ImageGenerationResponse generate(String baseUrl, String apiKey, ImageGeneration imageGeneration) throws Exception;\n\n    ImageGenerationResponse generate(ImageGeneration imageGeneration) throws Exception;\n\n    void generateStream(String baseUrl, String apiKey, ImageGeneration imageGeneration, ImageSseListener listener) throws Exception;\n\n    void generateStream(ImageGeneration imageGeneration, ImageSseListener listener) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/IRealtimeService.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.listener.RealtimeListener;\nimport okhttp3.WebSocket;\n\n/**\n * @Author cly\n * @Description realtime服务接口\n * @Date 2024/10/12 16:30\n */\npublic interface IRealtimeService {\n    WebSocket createRealtimeClient(String baseUrl, String apiKey, String model, RealtimeListener realtimeListener);\n    WebSocket createRealtimeClient(String model, RealtimeListener realtimeListener);\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/IRerankService.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.rerank.entity.RerankRequest;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResponse;\n\npublic interface IRerankService {\n\n    RerankResponse rerank(String baseUrl, String apiKey, RerankRequest request) throws Exception;\n\n    RerankResponse rerank(RerankRequest request) throws Exception;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/IResponsesService.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport io.github.lnyocly.ai4j.listener.ResponseSseListener;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\n\n\npublic interface IResponsesService {\n\n    Response create(String baseUrl, String apiKey, ResponseRequest request) throws Exception;\n\n    Response create(ResponseRequest request) throws Exception;\n\n    void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) throws Exception;\n\n    void createStream(ResponseRequest request, ResponseSseListener listener) throws Exception;\n\n    Response retrieve(String baseUrl, String apiKey, String responseId) throws Exception;\n\n    Response retrieve(String responseId) throws Exception;\n\n    ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) throws Exception;\n\n    ResponseDeleteResponse delete(String responseId) throws Exception;\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/ModelType.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n@AllArgsConstructor\n@Getter\npublic enum ModelType {\n    REALTIME(\"realtime\"),\n    EMBEDDING(\"embedding\"),\n    CHAT(\"chat\"),\n    ;\n    private final String type;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/PlatformType.java",
    "content": "package io.github.lnyocly.ai4j.service;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Getter;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/8 17:29\n */\n@AllArgsConstructor\n@Getter\npublic enum PlatformType {\n    OPENAI(\"openai\"),\n    ZHIPU(\"zhipu\"),\n    DEEPSEEK(\"deepseek\"),\n    MOONSHOT(\"moonshot\"),\n    HUNYUAN(\"hunyuan\"),\n    LINGYI(\"lingyi\"),\n    OLLAMA(\"ollama\"),\n    MINIMAX(\"minimax\"),\n    BAICHUAN(\"baichuan\"),\n    DASHSCOPE(\"dashscope\"),\n    DOUBAO(\"doubao\"),\n    JINA(\"jina\"),\n    ;\n    private final String platform;\n\n    public static PlatformType getPlatform(String value) {\n        String target = value.toLowerCase();\n        for (PlatformType platformType : PlatformType.values()) {\n            if (platformType.getPlatform().equals(target)) {\n                return platformType;\n            }\n        }\n        return PlatformType.OPENAI;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/AiService.java",
    "content": "package io.github.lnyocly.ai4j.service.factory;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlow;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowConfig;\nimport io.github.lnyocly.ai4j.platform.baichuan.chat.BaichuanChatService;\nimport io.github.lnyocly.ai4j.platform.dashscope.DashScopeChatService;\nimport io.github.lnyocly.ai4j.platform.deepseek.chat.DeepSeekChatService;\nimport io.github.lnyocly.ai4j.platform.doubao.chat.DoubaoChatService;\nimport io.github.lnyocly.ai4j.platform.doubao.image.DoubaoImageService;\nimport io.github.lnyocly.ai4j.platform.doubao.rerank.DoubaoRerankService;\nimport io.github.lnyocly.ai4j.platform.hunyuan.chat.HunyuanChatService;\nimport io.github.lnyocly.ai4j.platform.jina.rerank.JinaRerankService;\nimport io.github.lnyocly.ai4j.platform.lingyi.chat.LingyiChatService;\nimport io.github.lnyocly.ai4j.platform.minimax.chat.MinimaxChatService;\nimport io.github.lnyocly.ai4j.platform.moonshot.chat.MoonshotChatService;\nimport io.github.lnyocly.ai4j.platform.ollama.chat.OllamaAiChatService;\nimport io.github.lnyocly.ai4j.platform.ollama.embedding.OllamaEmbeddingService;\nimport io.github.lnyocly.ai4j.platform.ollama.rerank.OllamaRerankService;\nimport io.github.lnyocly.ai4j.platform.openai.audio.OpenAiAudioService;\nimport io.github.lnyocly.ai4j.platform.openai.chat.OpenAiChatService;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.OpenAiEmbeddingService;\nimport io.github.lnyocly.ai4j.platform.openai.image.OpenAiImageService;\nimport io.github.lnyocly.ai4j.platform.openai.realtime.OpenAiRealtimeService;\nimport io.github.lnyocly.ai4j.platform.zhipu.chat.ZhipuChatService;\nimport io.github.lnyocly.ai4j.rag.DefaultRagContextAssembler;\nimport io.github.lnyocly.ai4j.rag.DefaultRagService;\nimport io.github.lnyocly.ai4j.rag.DenseRetriever;\nimport io.github.lnyocly.ai4j.rag.ModelReranker;\nimport io.github.lnyocly.ai4j.rag.NoopReranker;\nimport io.github.lnyocly.ai4j.rag.RagService;\nimport io.github.lnyocly.ai4j.rag.Reranker;\nimport io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline;\nimport io.github.lnyocly.ai4j.service.*;\nimport io.github.lnyocly.ai4j.vector.service.PineconeService;\nimport io.github.lnyocly.ai4j.vector.store.milvus.MilvusVectorStore;\nimport io.github.lnyocly.ai4j.vector.store.pgvector.PgVectorStore;\nimport io.github.lnyocly.ai4j.vector.store.qdrant.QdrantVectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.pinecone.PineconeVectorStore;\nimport io.github.lnyocly.ai4j.websearch.ChatWithWebSearchEnhance;\n\n/**\n * @Author cly\n * @Description AI鏈嶅姟宸ュ巶锛屽垱寤哄悇绉岮I搴旂敤\n * @Date 2024/8/7 18:10\n */\npublic class AiService {\n   // private final ConcurrentMap<PlatformType, IChatService> chatServiceCache = new ConcurrentHashMap<>();\n    //private final ConcurrentMap<PlatformType, IEmbeddingService> embeddingServiceCache = new ConcurrentHashMap<>();\n\n    private final Configuration configuration;\n\n    public AiService(Configuration configuration) {\n        this.configuration = configuration;\n    }\n\n    public Configuration getConfiguration() {\n        return configuration;\n    }\n\n    public AgentFlow getAgentFlow(AgentFlowConfig agentFlowConfig) {\n        return new AgentFlow(configuration, agentFlowConfig);\n    }\n\n    public IChatService getChatService(PlatformType platform) {\n        //return chatServiceCache.computeIfAbsent(platform, this::createChatService);\n        return createChatService(platform);\n    }\n\n    public IChatService webSearchEnhance(IChatService chatService) {\n        //IChatService chatService = getChatService(platform);\n        return new ChatWithWebSearchEnhance(chatService, configuration);\n    }\n\n    private IChatService createChatService(PlatformType platform) {\n        switch (platform) {\n            case OPENAI:\n                return new OpenAiChatService(configuration);\n            case ZHIPU:\n                return new ZhipuChatService(configuration);\n            case DEEPSEEK:\n                return new DeepSeekChatService(configuration);\n            case MOONSHOT:\n                return new MoonshotChatService(configuration);\n            case HUNYUAN:\n                return new HunyuanChatService(configuration);\n            case LINGYI:\n                return new LingyiChatService(configuration);\n            case OLLAMA:\n                return new OllamaAiChatService(configuration);\n            case MINIMAX:\n                return new MinimaxChatService(configuration);\n            case BAICHUAN:\n                return new BaichuanChatService(configuration);\n            case DASHSCOPE:\n                return new DashScopeChatService(configuration);\n            case DOUBAO:\n                return new DoubaoChatService(configuration);\n            default:\n                throw new IllegalArgumentException(\"Unknown platform: \" + platform);\n        }\n    }\n\n\n\n    public IEmbeddingService getEmbeddingService(PlatformType platform) {\n        //return embeddingServiceCache.computeIfAbsent(platform, this::createEmbeddingService);\n        return createEmbeddingService(platform);\n    }\n\n    private IEmbeddingService createEmbeddingService(PlatformType platform) {\n        switch (platform) {\n            case OPENAI:\n                return new OpenAiEmbeddingService(configuration);\n            case OLLAMA:\n                return new OllamaEmbeddingService(configuration);\n            default:\n                throw new IllegalArgumentException(\"Unknown platform: \" + platform);\n        }\n    }\n\n    public IAudioService getAudioService(PlatformType platform) {\n        return createAudioService(platform);\n    }\n\n    private IAudioService createAudioService(PlatformType platform) {\n        switch (platform) {\n            case OPENAI:\n                return new OpenAiAudioService(configuration);\n            default:\n                throw new IllegalArgumentException(\"Unknown platform: \" + platform);\n        }\n    }\n\n    public IRealtimeService getRealtimeService(PlatformType platform) {\n        return createRealtimeService(platform);\n    }\n\n    private IRealtimeService createRealtimeService(PlatformType platform) {\n        switch (platform) {\n            case OPENAI:\n                return new OpenAiRealtimeService(configuration);\n            default:\n                throw new IllegalArgumentException(\"Unknown platform: \" + platform);\n        }\n    }\n\n    public PineconeService getPineconeService() {\n        return new PineconeService(configuration);\n    }\n\n    public VectorStore getPineconeVectorStore() {\n        return new PineconeVectorStore(getPineconeService());\n    }\n\n    public VectorStore getQdrantVectorStore() {\n        return new QdrantVectorStore(configuration);\n    }\n\n    public VectorStore getMilvusVectorStore() {\n        return new MilvusVectorStore(configuration);\n    }\n\n    public VectorStore getPgVectorStore() {\n        return new PgVectorStore(configuration);\n    }\n\n    public IImageService getImageService(PlatformType platform) {\n        return createImageService(platform);\n    }\n\n    private IImageService createImageService(PlatformType platform) {\n        switch (platform) {\n            case OPENAI:\n                return new OpenAiImageService(configuration);\n            case DOUBAO:\n                return new DoubaoImageService(configuration);\n            default:\n                throw new IllegalArgumentException(\"Unknown platform: \" + platform);\n        }\n    }\n\n\n    public IResponsesService getResponsesService(PlatformType platform) {\n        return createResponsesService(platform);\n    }\n\n    private IResponsesService createResponsesService(PlatformType platform) {\n        switch (platform) {\n            case OPENAI:\n                return new io.github.lnyocly.ai4j.platform.openai.response.OpenAiResponsesService(configuration);\n            case DOUBAO:\n                return new io.github.lnyocly.ai4j.platform.doubao.response.DoubaoResponsesService(configuration);\n            case DASHSCOPE:\n                return new io.github.lnyocly.ai4j.platform.dashscope.response.DashScopeResponsesService(configuration);\n            default:\n                throw new IllegalArgumentException(\"Unknown platform: \" + platform);\n        }\n    }\n\n    public IRerankService getRerankService(PlatformType platform) {\n        return createRerankService(platform);\n    }\n\n    private IRerankService createRerankService(PlatformType platform) {\n        switch (platform) {\n            case JINA:\n                return new JinaRerankService(configuration);\n            case OLLAMA:\n                return new OllamaRerankService(configuration);\n            case DOUBAO:\n                return new DoubaoRerankService(configuration);\n            default:\n                throw new IllegalArgumentException(\"Unknown platform: \" + platform);\n        }\n    }\n\n    public RagService getRagService(PlatformType platform, VectorStore vectorStore) {\n        return new DefaultRagService(\n                new DenseRetriever(getEmbeddingService(platform), vectorStore),\n                new NoopReranker(),\n                new DefaultRagContextAssembler()\n        );\n    }\n\n    public IngestionPipeline getIngestionPipeline(PlatformType platform, VectorStore vectorStore) {\n        return new IngestionPipeline(getEmbeddingService(platform), vectorStore);\n    }\n\n    public Reranker getModelReranker(PlatformType platform, String model) {\n        return new ModelReranker(getRerankService(platform), model);\n    }\n\n    public Reranker getModelReranker(PlatformType platform,\n                                     String model,\n                                     Integer topN,\n                                     String instruction) {\n        return getModelReranker(platform, model, topN, instruction, false, true);\n    }\n\n    public Reranker getModelReranker(PlatformType platform,\n                                     String model,\n                                     Integer topN,\n                                     String instruction,\n                                     boolean returnDocuments,\n                                     boolean appendRemainingHits) {\n        return new ModelReranker(\n                getRerankService(platform),\n                model,\n                topN,\n                instruction,\n                returnDocuments,\n                appendRemainingHits\n        );\n    }\n\n    public RagService getPineconeRagService(PlatformType platform) {\n        return getRagService(platform, getPineconeVectorStore());\n    }\n\n    public IngestionPipeline getPineconeIngestionPipeline(PlatformType platform) {\n        return getIngestionPipeline(platform, getPineconeVectorStore());\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/AiServiceFactory.java",
    "content": "package io.github.lnyocly.ai4j.service.factory;\n\nimport io.github.lnyocly.ai4j.service.Configuration;\n\n/**\n * {@link AiService} 的创建工厂。\n */\npublic interface AiServiceFactory {\n\n    AiService create(Configuration configuration);\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/AiServiceRegistration.java",
    "content": "package io.github.lnyocly.ai4j.service.factory;\n\nimport io.github.lnyocly.ai4j.service.PlatformType;\n\n/**\n * 多实例注册表中的单个注册项。\n */\npublic class AiServiceRegistration {\n\n    private final String id;\n    private final PlatformType platformType;\n    private final AiService aiService;\n\n    public AiServiceRegistration(String id, PlatformType platformType, AiService aiService) {\n        this.id = id;\n        this.platformType = platformType;\n        this.aiService = aiService;\n    }\n\n    public String getId() {\n        return id;\n    }\n\n    public PlatformType getPlatformType() {\n        return platformType;\n    }\n\n    public AiService getAiService() {\n        return aiService;\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/AiServiceRegistry.java",
    "content": "package io.github.lnyocly.ai4j.service.factory;\n\nimport io.github.lnyocly.ai4j.rag.RagService;\nimport io.github.lnyocly.ai4j.service.IAudioService;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.service.IImageService;\nimport io.github.lnyocly.ai4j.service.IRerankService;\nimport io.github.lnyocly.ai4j.service.IRealtimeService;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\nimport io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.rag.Reranker;\n\nimport java.util.Set;\n\n/**\n * 按 id 管理多套 {@link AiService} 的正式抽象。\n */\npublic interface AiServiceRegistry {\n\n    AiServiceRegistration find(String id);\n\n    Set<String> ids();\n\n    default boolean contains(String id) {\n        return find(id) != null;\n    }\n\n    default AiServiceRegistration get(String id) {\n        AiServiceRegistration registration = find(id);\n        if (registration == null) {\n            throw new IllegalArgumentException(\"Unknown ai service id: \" + id);\n        }\n        return registration;\n    }\n\n    default AiService getAiService(String id) {\n        return get(id).getAiService();\n    }\n\n    default IChatService getChatService(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getChatService(registration.getPlatformType());\n    }\n\n    default IEmbeddingService getEmbeddingService(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getEmbeddingService(registration.getPlatformType());\n    }\n\n    default IAudioService getAudioService(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getAudioService(registration.getPlatformType());\n    }\n\n    default IRealtimeService getRealtimeService(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getRealtimeService(registration.getPlatformType());\n    }\n\n    default IImageService getImageService(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getImageService(registration.getPlatformType());\n    }\n\n    default IResponsesService getResponsesService(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getResponsesService(registration.getPlatformType());\n    }\n\n    default IRerankService getRerankService(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getRerankService(registration.getPlatformType());\n    }\n\n    default RagService getRagService(String id, VectorStore vectorStore) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getRagService(registration.getPlatformType(), vectorStore);\n    }\n\n    default RagService getPineconeRagService(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getPineconeRagService(registration.getPlatformType());\n    }\n\n    default IngestionPipeline getIngestionPipeline(String id, VectorStore vectorStore) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getIngestionPipeline(registration.getPlatformType(), vectorStore);\n    }\n\n    default IngestionPipeline getPineconeIngestionPipeline(String id) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getPineconeIngestionPipeline(registration.getPlatformType());\n    }\n\n    default Reranker getModelReranker(String id, String model) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getModelReranker(registration.getPlatformType(), model);\n    }\n\n    default Reranker getModelReranker(String id,\n                                      String model,\n                                      Integer topN,\n                                      String instruction) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getModelReranker(\n                registration.getPlatformType(),\n                model,\n                topN,\n                instruction\n        );\n    }\n\n    default Reranker getModelReranker(String id,\n                                      String model,\n                                      Integer topN,\n                                      String instruction,\n                                      boolean returnDocuments,\n                                      boolean appendRemainingHits) {\n        AiServiceRegistration registration = get(id);\n        return registration.getAiService().getModelReranker(\n                registration.getPlatformType(),\n                model,\n                topN,\n                instruction,\n                returnDocuments,\n                appendRemainingHits\n        );\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/DefaultAiServiceFactory.java",
    "content": "package io.github.lnyocly.ai4j.service.factory;\n\nimport io.github.lnyocly.ai4j.service.Configuration;\n\n/**\n * 默认的 {@link AiService} 工厂实现。\n */\npublic class DefaultAiServiceFactory implements AiServiceFactory {\n\n    @Override\n    public AiService create(Configuration configuration) {\n        return new AiService(configuration);\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/DefaultAiServiceRegistry.java",
    "content": "package io.github.lnyocly.ai4j.service.factory;\n\nimport cn.hutool.core.bean.BeanUtil;\nimport cn.hutool.core.bean.copier.CopyOptions;\nimport cn.hutool.core.collection.CollUtil;\nimport io.github.lnyocly.ai4j.config.AiPlatform;\nimport io.github.lnyocly.ai4j.config.BaichuanConfig;\nimport io.github.lnyocly.ai4j.config.DashScopeConfig;\nimport io.github.lnyocly.ai4j.config.DeepSeekConfig;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.config.HunyuanConfig;\nimport io.github.lnyocly.ai4j.config.JinaConfig;\nimport io.github.lnyocly.ai4j.config.LingyiConfig;\nimport io.github.lnyocly.ai4j.config.MinimaxConfig;\nimport io.github.lnyocly.ai4j.config.MoonshotConfig;\nimport io.github.lnyocly.ai4j.config.OllamaConfig;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.config.ZhipuConfig;\nimport io.github.lnyocly.ai4j.service.AiConfig;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\n\nimport java.lang.reflect.InvocationTargetException;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.Map;\nimport java.util.Set;\n\n/**\n * 默认的多实例 {@link AiService} 注册表实现。\n */\npublic class DefaultAiServiceRegistry implements AiServiceRegistry {\n\n    private final Map<String, AiServiceRegistration> registrations;\n\n    public DefaultAiServiceRegistry(Map<String, AiServiceRegistration> registrations) {\n        this.registrations = Collections.unmodifiableMap(new LinkedHashMap<String, AiServiceRegistration>(registrations));\n    }\n\n    public static DefaultAiServiceRegistry empty() {\n        return new DefaultAiServiceRegistry(Collections.<String, AiServiceRegistration>emptyMap());\n    }\n\n    public static DefaultAiServiceRegistry from(Configuration configuration, AiConfig aiConfig) {\n        return from(configuration, aiConfig, new DefaultAiServiceFactory());\n    }\n\n    public static DefaultAiServiceRegistry from(Configuration configuration, AiConfig aiConfig, AiServiceFactory aiServiceFactory) {\n        if (configuration == null || aiConfig == null || CollUtil.isEmpty(aiConfig.getPlatforms())) {\n            return empty();\n        }\n\n        Map<String, AiServiceRegistration> registrations = new LinkedHashMap<String, AiServiceRegistration>();\n        for (AiPlatform aiPlatform : aiConfig.getPlatforms()) {\n            if (aiPlatform == null) {\n                continue;\n            }\n            String id = aiPlatform.getId();\n            if (id == null || \"\".equals(id.trim())) {\n                throw new IllegalArgumentException(\"Ai platform id must not be blank\");\n            }\n            PlatformType platformType = resolvePlatformType(aiPlatform.getPlatform(), id);\n            Configuration scopedConfiguration = createScopedConfiguration(configuration, aiPlatform, platformType);\n            registrations.put(id, new AiServiceRegistration(id, platformType, aiServiceFactory.create(scopedConfiguration)));\n        }\n        return new DefaultAiServiceRegistry(registrations);\n    }\n\n    @Override\n    public AiServiceRegistration find(String id) {\n        return registrations.get(id);\n    }\n\n    @Override\n    public Set<String> ids() {\n        return Collections.unmodifiableSet(new LinkedHashSet<String>(registrations.keySet()));\n    }\n\n    private static Configuration createScopedConfiguration(Configuration source, AiPlatform aiPlatform, PlatformType platformType) {\n        Configuration target = new Configuration();\n        BeanUtil.copyProperties(source, target, CopyOptions.create());\n        applyPlatformConfig(target, aiPlatform, platformType);\n        return target;\n    }\n\n    private static PlatformType resolvePlatformType(String rawPlatform, String id) {\n        if (rawPlatform == null || \"\".equals(rawPlatform.trim())) {\n            throw new IllegalArgumentException(\"Ai platform '\" + id + \"' platform must not be blank\");\n        }\n\n        String target = rawPlatform.trim();\n        for (PlatformType platformType : PlatformType.values()) {\n            if (platformType.getPlatform().equalsIgnoreCase(target)) {\n                return platformType;\n            }\n        }\n        throw new IllegalArgumentException(\"Unsupported ai platform '\" + rawPlatform + \"' for id '\" + id + \"'\");\n    }\n\n    private static void applyPlatformConfig(Configuration target, AiPlatform aiPlatform, PlatformType platformType) {\n        switch (platformType) {\n            case OPENAI:\n                target.setOpenAiConfig(copy(aiPlatform, OpenAiConfig.class));\n                break;\n            case ZHIPU:\n                target.setZhipuConfig(copy(aiPlatform, ZhipuConfig.class));\n                break;\n            case DEEPSEEK:\n                target.setDeepSeekConfig(copy(aiPlatform, DeepSeekConfig.class));\n                break;\n            case MOONSHOT:\n                target.setMoonshotConfig(copy(aiPlatform, MoonshotConfig.class));\n                break;\n            case HUNYUAN:\n                target.setHunyuanConfig(copy(aiPlatform, HunyuanConfig.class));\n                break;\n            case LINGYI:\n                target.setLingyiConfig(copy(aiPlatform, LingyiConfig.class));\n                break;\n            case OLLAMA:\n                target.setOllamaConfig(copy(aiPlatform, OllamaConfig.class));\n                break;\n            case MINIMAX:\n                target.setMinimaxConfig(copy(aiPlatform, MinimaxConfig.class));\n                break;\n            case BAICHUAN:\n                target.setBaichuanConfig(copy(aiPlatform, BaichuanConfig.class));\n                break;\n            case DASHSCOPE:\n                target.setDashScopeConfig(copy(aiPlatform, DashScopeConfig.class));\n                break;\n            case DOUBAO:\n                target.setDoubaoConfig(copy(aiPlatform, DoubaoConfig.class));\n                break;\n            case JINA:\n                target.setJinaConfig(copy(aiPlatform, JinaConfig.class));\n                break;\n            default:\n                throw new IllegalArgumentException(\"Unsupported platform type: \" + platformType);\n        }\n    }\n\n    private static <T> T copy(AiPlatform aiPlatform, Class<T> type) {\n        try {\n            T target = type.getDeclaredConstructor().newInstance();\n            BeanUtil.copyProperties(aiPlatform, target, CopyOptions.create().ignoreNullValue());\n            return target;\n        } catch (InstantiationException e) {\n            throw new IllegalStateException(\"Cannot instantiate config type: \" + type.getName(), e);\n        } catch (IllegalAccessException e) {\n            throw new IllegalStateException(\"Cannot access config type: \" + type.getName(), e);\n        } catch (InvocationTargetException e) {\n            throw new IllegalStateException(\"Cannot invoke config constructor: \" + type.getName(), e);\n        } catch (NoSuchMethodException e) {\n            throw new IllegalStateException(\"Missing no-args constructor for config type: \" + type.getName(), e);\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/factory/FreeAiService.java",
    "content": "package io.github.lnyocly.ai4j.service.factory;\n\nimport io.github.lnyocly.ai4j.rag.RagService;\nimport io.github.lnyocly.ai4j.rag.Reranker;\nimport io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline;\nimport io.github.lnyocly.ai4j.service.AiConfig;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IAudioService;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.service.IImageService;\nimport io.github.lnyocly.ai4j.service.IRerankService;\nimport io.github.lnyocly.ai4j.service.IRealtimeService;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\n\nimport java.util.Collections;\nimport java.util.Set;\n\n/**\n * 兼容旧版本的多实例聊天入口。\n *\n * <p>主线入口仍然是 {@link AiService}。如果需要正式的多实例管理，请使用\n * {@link AiServiceRegistry}。本类保留旧构造方式和静态获取方式，仅作为兼容壳。</p>\n */\n@Deprecated\npublic class FreeAiService {\n    private static volatile AiServiceRegistry registry = DefaultAiServiceRegistry.empty();\n\n    private final Configuration configuration;\n    private final AiConfig aiConfig;\n    private final AiServiceFactory aiServiceFactory;\n\n    public FreeAiService(Configuration configuration, AiConfig aiConfig) {\n        this(configuration, aiConfig, new DefaultAiServiceFactory());\n    }\n\n    public FreeAiService(Configuration configuration, AiConfig aiConfig, AiServiceFactory aiServiceFactory) {\n        this.configuration = configuration;\n        this.aiConfig = aiConfig;\n        this.aiServiceFactory = aiServiceFactory;\n        init();\n    }\n\n    public FreeAiService(AiServiceRegistry registry) {\n        this.configuration = null;\n        this.aiConfig = null;\n        this.aiServiceFactory = null;\n        setRegistry(registry);\n    }\n\n    public void init() {\n        if (configuration == null) {\n            return;\n        }\n\n\n        setRegistry(DefaultAiServiceRegistry.from(configuration, aiConfig, aiServiceFactory));\n    }\n\n    public static IChatService getChatService(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getChatService(id);\n    }\n\n    public static AiService getAiService(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registration.getAiService();\n    }\n\n    public static IEmbeddingService getEmbeddingService(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getEmbeddingService(id);\n    }\n\n    public static IAudioService getAudioService(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getAudioService(id);\n    }\n\n    public static IRealtimeService getRealtimeService(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getRealtimeService(id);\n    }\n\n    public static IImageService getImageService(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getImageService(id);\n    }\n\n    public static IResponsesService getResponsesService(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getResponsesService(id);\n    }\n\n    public static IRerankService getRerankService(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getRerankService(id);\n    }\n\n    public static RagService getRagService(String id, VectorStore vectorStore) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getRagService(id, vectorStore);\n    }\n\n    public static IngestionPipeline getIngestionPipeline(String id, VectorStore vectorStore) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getIngestionPipeline(id, vectorStore);\n    }\n\n    public static IngestionPipeline getPineconeIngestionPipeline(String id) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getPineconeIngestionPipeline(id);\n    }\n\n    public static Reranker getModelReranker(String id, String model) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getModelReranker(id, model);\n    }\n\n    public static Reranker getModelReranker(String id,\n                                            String model,\n                                            Integer topN,\n                                            String instruction) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null ? null : registry.getModelReranker(id, model, topN, instruction);\n    }\n\n    public static Reranker getModelReranker(String id,\n                                            String model,\n                                            Integer topN,\n                                            String instruction,\n                                            boolean returnDocuments,\n                                            boolean appendRemainingHits) {\n        AiServiceRegistration registration = registry.find(id);\n        return registration == null\n                ? null\n                : registry.getModelReranker(id, model, topN, instruction, returnDocuments, appendRemainingHits);\n    }\n\n    public static boolean contains(String id) {\n        return registry.contains(id);\n    }\n\n    public static Set<String> ids() {\n        return registry.ids();\n    }\n\n    public static AiServiceRegistry getRegistry() {\n        return registry;\n    }\n\n    private static void setRegistry(AiServiceRegistry registry) {\n        FreeAiService.registry = registry == null ? DefaultAiServiceRegistry.empty() : registry;\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/service/spi/ServiceLoaderUtil.java",
    "content": "package io.github.lnyocly.ai4j.service.spi;\n\nimport lombok.extern.slf4j.Slf4j;\n\nimport java.util.ServiceLoader;\n\n/**\n * @Author cly\n * @Description SPI服务加载类\n * @Date 2024/10/16 23:25\n */\n@Slf4j\npublic class ServiceLoaderUtil {\n    public static <T> T load(Class<T> service) {\n        ServiceLoader<T> loader = ServiceLoader.load(service);\n        for (T impl : loader) {\n            log.info(\"Loaded SPI implementation: {}\", impl.getClass().getSimpleName());\n            return impl;\n        }\n        throw new IllegalStateException(\"No implementation found for \" + service.getName());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/skill/SkillDescriptor.java",
    "content": "package io.github.lnyocly.ai4j.skill;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class SkillDescriptor {\n\n    private String name;\n    private String description;\n    private String skillFilePath;\n    private String source;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/skill/Skills.java",
    "content": "package io.github.lnyocly.ai4j.skill;\n\nimport io.github.lnyocly.ai4j.tool.BuiltInToolContext;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.DirectoryStream;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\n\npublic final class Skills {\n\n    private static final String SKILL_FILE_NAME = \"SKILL.md\";\n\n    private Skills() {\n    }\n\n    public static DiscoveryResult discoverDefault(Path workspaceRoot) {\n        return discoverDefault(workspaceRoot, null);\n    }\n\n    public static DiscoveryResult discoverDefault(String workspaceRoot, List<String> skillDirectories) {\n        return discoverDefault(isBlank(workspaceRoot) ? null : Paths.get(workspaceRoot), skillDirectories);\n    }\n\n    public static DiscoveryResult discoverDefault(Path workspaceRoot, List<String> skillDirectories) {\n        Path resolvedWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot);\n        List<Path> roots = resolveSkillRoots(resolvedWorkspaceRoot, skillDirectories);\n        return discover(resolvedWorkspaceRoot, roots);\n    }\n\n    public static DiscoveryResult discover(Path workspaceRoot, List<Path> roots) {\n        Path resolvedWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot);\n        Map<String, SkillDescriptor> byName = new LinkedHashMap<String, SkillDescriptor>();\n        Set<String> allowedReadRoots = new LinkedHashSet<String>();\n        if (roots != null) {\n            for (Path root : roots) {\n                if (root == null || !Files.isDirectory(root)) {\n                    continue;\n                }\n                allowedReadRoots.add(root.toAbsolutePath().normalize().toString());\n                for (SkillDescriptor descriptor : discoverFromRoot(root, resolvedWorkspaceRoot)) {\n                    String normalizedName = normalizeKey(descriptor.getName());\n                    if (!byName.containsKey(normalizedName)) {\n                        byName.put(normalizedName, descriptor);\n                    }\n                }\n            }\n        }\n        return new DiscoveryResult(\n                new ArrayList<SkillDescriptor>(byName.values()),\n                new ArrayList<String>(allowedReadRoots)\n        );\n    }\n\n    public static BuiltInToolContext createToolContext(Path workspaceRoot) {\n        DiscoveryResult discovery = discoverDefault(workspaceRoot);\n        return createToolContext(workspaceRoot, discovery);\n    }\n\n    public static BuiltInToolContext createToolContext(Path workspaceRoot, DiscoveryResult discovery) {\n        Path resolvedWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot);\n        List<String> allowedReadRoots = discovery == null\n                ? Collections.<String>emptyList()\n                : discovery.getAllowedReadRoots();\n        return BuiltInToolContext.builder()\n                .workspaceRoot(resolvedWorkspaceRoot.toString())\n                .allowedReadRoots(new ArrayList<String>(allowedReadRoots))\n                .build();\n    }\n\n    public static String appendAvailableSkillsPrompt(String basePrompt,\n                                                     List<? extends SkillDescriptor> availableSkills) {\n        String skillPrompt = buildAvailableSkillsPrompt(availableSkills);\n        if (isBlank(basePrompt)) {\n            return skillPrompt;\n        }\n        if (isBlank(skillPrompt)) {\n            return basePrompt;\n        }\n        return basePrompt + \"\\n\\n\" + skillPrompt;\n    }\n\n    public static void appendAvailableSkillsPrompt(StringBuilder builder,\n                                                   List<? extends SkillDescriptor> availableSkills) {\n        if (builder == null) {\n            return;\n        }\n        String skillPrompt = buildAvailableSkillsPrompt(availableSkills);\n        if (isBlank(skillPrompt)) {\n            return;\n        }\n        if (builder.length() > 0 && !endsWithBlankLine(builder)) {\n            builder.append(\"\\n\\n\");\n        }\n        builder.append(skillPrompt);\n    }\n\n    public static String buildAvailableSkillsPrompt(List<? extends SkillDescriptor> availableSkills) {\n        if (availableSkills == null || availableSkills.isEmpty()) {\n            return null;\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"Some reusable skills are installed. Do not read every skill file up front. \")\n                .append(\"When the task clearly matches a skill, read that SKILL.md with read_file first and then follow it.\\n\");\n        builder.append(\"<available_skills>\\n\");\n        for (SkillDescriptor skill : availableSkills) {\n            if (skill == null) {\n                continue;\n            }\n            builder.append(\"- name: \").append(firstNonBlank(skill.getName(), \"skill\")).append(\"\\n\");\n            builder.append(\"  path: \").append(firstNonBlank(skill.getSkillFilePath(), \"(missing)\")).append(\"\\n\");\n            builder.append(\"  description: \").append(firstNonBlank(skill.getDescription(), \"No description available.\")).append(\"\\n\");\n        }\n        builder.append(\"</available_skills>\\n\");\n        builder.append(\"Only use a skill after reading its SKILL.md. Prefer the smallest relevant skill set and reuse read_file instead of asking for a dedicated skill tool.\");\n        return builder.toString().trim();\n    }\n\n    private static List<Path> resolveSkillRoots(Path workspaceRoot, List<String> skillDirectories) {\n        Set<Path> roots = new LinkedHashSet<Path>();\n        roots.add(workspaceRoot.resolve(\".ai4j\").resolve(\"skills\").toAbsolutePath().normalize());\n        String userHome = System.getProperty(\"user.home\");\n        if (!isBlank(userHome)) {\n            roots.add(Paths.get(userHome).resolve(\".ai4j\").resolve(\"skills\").toAbsolutePath().normalize());\n        }\n        if (skillDirectories != null) {\n            for (String configuredRoot : skillDirectories) {\n                if (isBlank(configuredRoot)) {\n                    continue;\n                }\n                Path root = Paths.get(configuredRoot);\n                if (!root.isAbsolute()) {\n                    root = workspaceRoot.resolve(configuredRoot);\n                }\n                roots.add(root.toAbsolutePath().normalize());\n            }\n        }\n        return new ArrayList<Path>(roots);\n    }\n\n    private static List<SkillDescriptor> discoverFromRoot(Path root, Path workspaceRoot) {\n        Path directSkillFile = resolveSkillFile(root);\n        if (directSkillFile != null) {\n            return Collections.singletonList(buildDescriptor(directSkillFile, root, workspaceRoot));\n        }\n\n        List<SkillDescriptor> descriptors = new ArrayList<SkillDescriptor>();\n        try (DirectoryStream<Path> stream = Files.newDirectoryStream(root)) {\n            for (Path child : stream) {\n                if (!Files.isDirectory(child)) {\n                    continue;\n                }\n                Path skillFile = resolveSkillFile(child);\n                if (skillFile == null) {\n                    continue;\n                }\n                descriptors.add(buildDescriptor(skillFile, root, workspaceRoot));\n            }\n        } catch (IOException ignored) {\n        }\n        return descriptors;\n    }\n\n    private static Path resolveSkillFile(Path directory) {\n        Path upper = directory.resolve(SKILL_FILE_NAME);\n        if (Files.isRegularFile(upper)) {\n            return upper;\n        }\n        Path lower = directory.resolve(\"skill.md\");\n        return Files.isRegularFile(lower) ? lower : null;\n    }\n\n    private static SkillDescriptor buildDescriptor(Path skillFile, Path skillRoot, Path workspaceRoot) {\n        String content = readQuietly(skillFile);\n        String name = firstNonBlank(\n                parseFrontMatterValue(content, \"name\"),\n                parseHeading(content),\n                inferName(skillFile)\n        );\n        String description = firstNonBlank(\n                parseFrontMatterValue(content, \"description\"),\n                parseFirstParagraph(content),\n                \"No description available.\"\n        );\n        return SkillDescriptor.builder()\n                .name(name)\n                .description(description)\n                .skillFilePath(skillFile.toAbsolutePath().normalize().toString())\n                .source(resolveSource(skillRoot, workspaceRoot))\n                .build();\n    }\n\n    private static String resolveSource(Path skillRoot, Path workspaceRoot) {\n        if (skillRoot != null && workspaceRoot != null && skillRoot.startsWith(workspaceRoot)) {\n            return \"workspace\";\n        }\n        return \"global\";\n    }\n\n    private static Path normalizeWorkspaceRoot(Path workspaceRoot) {\n        if (workspaceRoot == null) {\n            return Paths.get(\".\").toAbsolutePath().normalize();\n        }\n        return workspaceRoot.toAbsolutePath().normalize();\n    }\n\n    private static String readQuietly(Path skillFile) {\n        try {\n            return new String(Files.readAllBytes(skillFile), StandardCharsets.UTF_8);\n        } catch (IOException ex) {\n            return \"\";\n        }\n    }\n\n    private static String parseFrontMatterValue(String content, String key) {\n        if (isBlank(content) || isBlank(key)) {\n            return null;\n        }\n        boolean inFrontMatter = false;\n        for (String line : content.split(\"\\\\r?\\\\n\")) {\n            String trimmed = line.trim();\n            if (\"---\".equals(trimmed)) {\n                if (!inFrontMatter) {\n                    inFrontMatter = true;\n                    continue;\n                }\n                return null;\n            }\n            if (!inFrontMatter) {\n                break;\n            }\n            String prefix = key + \":\";\n            if (trimmed.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(Locale.ROOT))) {\n                return stripQuotes(trimmed.substring(prefix.length()).trim());\n            }\n        }\n        return null;\n    }\n\n    private static String parseHeading(String content) {\n        if (isBlank(content)) {\n            return null;\n        }\n        for (String line : content.split(\"\\\\r?\\\\n\")) {\n            String trimmed = line.trim();\n            if (trimmed.startsWith(\"#\")) {\n                return trimmed.replaceFirst(\"^#+\\\\s*\", \"\").trim();\n            }\n        }\n        return null;\n    }\n\n    private static String parseFirstParagraph(String content) {\n        if (isBlank(content)) {\n            return null;\n        }\n        String[] lines = content.split(\"\\\\r?\\\\n\");\n        StringBuilder paragraph = new StringBuilder();\n        boolean inFrontMatter = false;\n        for (String line : lines) {\n            String trimmed = line.trim();\n            if (\"---\".equals(trimmed) && paragraph.length() == 0) {\n                inFrontMatter = !inFrontMatter;\n                continue;\n            }\n            if (inFrontMatter || trimmed.isEmpty() || trimmed.startsWith(\"#\")) {\n                if (paragraph.length() > 0) {\n                    break;\n                }\n                continue;\n            }\n            if (paragraph.length() > 0) {\n                paragraph.append(' ');\n            }\n            paragraph.append(trimmed);\n        }\n        return paragraph.length() == 0 ? null : paragraph.toString().trim();\n    }\n\n    private static String inferName(Path skillFile) {\n        Path parent = skillFile == null ? null : skillFile.getParent();\n        if (parent == null) {\n            return \"skill\";\n        }\n        return parent.getFileName().toString();\n    }\n\n    private static boolean endsWithBlankLine(StringBuilder builder) {\n        int length = builder.length();\n        return length >= 2\n                && builder.charAt(length - 1) == '\\n'\n                && builder.charAt(length - 2) == '\\n';\n    }\n\n    private static String normalizeKey(String value) {\n        return isBlank(value) ? \"\" : value.trim().toLowerCase(Locale.ROOT);\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private static String stripQuotes(String value) {\n        if (isBlank(value)) {\n            return null;\n        }\n        String normalized = value.trim();\n        if ((normalized.startsWith(\"\\\"\") && normalized.endsWith(\"\\\"\"))\n                || (normalized.startsWith(\"'\") && normalized.endsWith(\"'\"))) {\n            return normalized.substring(1, normalized.length() - 1).trim();\n        }\n        return normalized;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    public static final class DiscoveryResult {\n\n        private final List<SkillDescriptor> skills;\n        private final List<String> allowedReadRoots;\n\n        public DiscoveryResult(List<SkillDescriptor> skills, List<String> allowedReadRoots) {\n            this.skills = skills == null ? Collections.<SkillDescriptor>emptyList() : skills;\n            this.allowedReadRoots = allowedReadRoots == null ? Collections.<String>emptyList() : allowedReadRoots;\n        }\n\n        public List<SkillDescriptor> getSkills() {\n            return skills;\n        }\n\n        public List<String> getAllowedReadRoots() {\n            return allowedReadRoots;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/token/TikTokensUtil.java",
    "content": "package io.github.lnyocly.ai4j.token;\n\n\nimport com.knuddels.jtokkit.Encodings;\nimport com.knuddels.jtokkit.api.Encoding;\nimport com.knuddels.jtokkit.api.EncodingRegistry;\nimport com.knuddels.jtokkit.api.EncodingType;\nimport com.knuddels.jtokkit.api.ModelType;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport lombok.extern.slf4j.Slf4j;\nimport org.apache.commons.lang3.ObjectUtils;\nimport org.apache.commons.lang3.StringUtils;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\n\n/**\n * @author cly\n */\n@Slf4j\npublic class TikTokensUtil {\n\n    /**\n     * 模型名称对应Encoding\n     */\n    private static final Map<String, Encoding> modelMap = new HashMap<>();\n    /**\n     * registry实例\n     */\n    private static final EncodingRegistry registry = Encodings.newDefaultEncodingRegistry();\n\n\n    static {\n        for (ModelType model : ModelType.values()){\n            Optional<Encoding> encodingForModel = registry.getEncodingForModel(model.getName());\n            encodingForModel.ifPresent(encoding -> modelMap.put(model.getName(), encoding));\n        }\n    }\n\n    /**\n     * 可以简单点用，直接传入list.toString()\n     * @param encodingType\n     * @param content\n     * @return\n     */\n    public static int tokens(EncodingType encodingType, String content){\n        Encoding encoding = registry.getEncoding(encodingType);\n        return encoding.countTokens(content);\n    }\n    public static int tokens(String modelName, String content)  {\n        if (StringUtils.isEmpty(content)) {\n            return 0;\n        }\n        Encoding encoding = modelMap.get(modelName);\n        return encoding.countTokens(content);\n    }\n    public static int tokens(String modelName, List<ChatMessage> messages) {\n        Encoding encoding = modelMap.get(modelName);\n        if (ObjectUtils.isEmpty(encoding)) {\n            throw new IllegalArgumentException(\"不支持计算Token的模型: \" + modelName);\n        }\n\n        int tokensPerMessage = 0;\n        int tokensPerName = 0;\n        if (modelName.startsWith(\"gpt-4\")) {\n            tokensPerMessage = 3;\n            tokensPerName = 1;\n        } else if (modelName.startsWith(\"gpt-3.5\")) {\n            tokensPerMessage = 4;\n            tokensPerName = -1;\n        }\n        int sum = 0;\n        for (ChatMessage message : messages) {\n\n            sum += tokensPerMessage;\n            sum += encoding.countTokens(message.getContent().getText());\n            sum += encoding.countTokens(message.getRole());\n            if(StringUtils.isNotEmpty(message.getName())){\n                sum += encoding.countTokens(message.getName());\n                sum += tokensPerName;\n            }\n\n\n        }\n        sum += 3; // every reply is primed with <|start|>assistant<|message|>\n        return sum;\n    }\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInProcessRegistry.java",
    "content": "package io.github.lnyocly.ai4j.tool;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Comparator;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.TimeUnit;\n\nfinal class BuiltInProcessRegistry {\n\n    private final BuiltInToolContext context;\n    private final Map<String, ManagedProcess> processes = new ConcurrentHashMap<String, ManagedProcess>();\n\n    BuiltInProcessRegistry(BuiltInToolContext context) {\n        this.context = context;\n    }\n\n    Map<String, Object> start(String command, String cwd) throws IOException {\n        if (isBlank(command)) {\n            throw new IllegalArgumentException(\"command is required\");\n        }\n        Path workingDirectory = context.resolveWorkspacePath(cwd);\n        ProcessBuilder processBuilder = new ProcessBuilder(buildShellCommand(command));\n        processBuilder.directory(workingDirectory.toFile());\n        Process process = processBuilder.start();\n        Charset charset = resolveShellCharset();\n\n        String processId = \"proc_\" + UUID.randomUUID().toString().replace(\"-\", \"\");\n        ManagedProcess managed = new ManagedProcess(\n                processId,\n                command,\n                workingDirectory.toString(),\n                process,\n                context.getMaxProcessOutputChars(),\n                charset,\n                context.getProcessStopGraceMs()\n        );\n        processes.put(processId, managed);\n        managed.startReaders();\n        managed.startWatcher();\n        return managed.snapshot();\n    }\n\n    Map<String, Object> status(String processId) {\n        return getManagedProcess(processId).snapshot();\n    }\n\n    List<Map<String, Object>> list() {\n        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();\n        for (ManagedProcess managed : processes.values()) {\n            result.add(managed.snapshot());\n        }\n        result.sort(new Comparator<Map<String, Object>>() {\n            @Override\n            public int compare(Map<String, Object> left, Map<String, Object> right) {\n                Long leftStartedAt = toLong(left.get(\"startedAt\"));\n                Long rightStartedAt = toLong(right.get(\"startedAt\"));\n                return leftStartedAt.compareTo(rightStartedAt);\n            }\n        });\n        return result;\n    }\n\n    Map<String, Object> logs(String processId, Long offset, Integer limit) {\n        ManagedProcess managed = getManagedProcess(processId);\n        long effectiveOffset = offset == null || offset.longValue() < 0L ? 0L : offset.longValue();\n        int effectiveLimit = limit == null || limit.intValue() <= 0 ? context.getDefaultBashLogChars() : limit.intValue();\n        return managed.readLogs(effectiveOffset, effectiveLimit);\n    }\n\n    int write(String processId, String input) throws IOException {\n        ManagedProcess managed = getManagedProcess(processId);\n        byte[] bytes = (input == null ? \"\" : input).getBytes(managed.charset);\n        OutputStream outputStream = managed.process.getOutputStream();\n        outputStream.write(bytes);\n        outputStream.flush();\n        return bytes.length;\n    }\n\n    Map<String, Object> stop(String processId) {\n        ManagedProcess managed = getManagedProcess(processId);\n        managed.stop();\n        return managed.snapshot();\n    }\n\n    private ManagedProcess getManagedProcess(String processId) {\n        ManagedProcess managed = processes.get(processId);\n        if (managed == null) {\n            throw new IllegalArgumentException(\"Unknown processId: \" + processId);\n        }\n        return managed;\n    }\n\n    private List<String> buildShellCommand(String command) {\n        if (isWindows()) {\n            return Arrays.asList(\"cmd.exe\", \"/c\", command);\n        }\n        return Arrays.asList(\"sh\", \"-lc\", command);\n    }\n\n    private Charset resolveShellCharset() {\n        Charset explicit = firstSupportedCharset(new String[]{\n                System.getProperty(\"ai4j.shell.encoding\"),\n                System.getenv(\"AI4J_SHELL_ENCODING\")\n        });\n        if (explicit != null) {\n            return explicit;\n        }\n        if (!isWindows()) {\n            return StandardCharsets.UTF_8;\n        }\n        Charset platform = firstSupportedCharset(new String[]{\n                System.getProperty(\"native.encoding\"),\n                System.getProperty(\"sun.jnu.encoding\"),\n                System.getProperty(\"file.encoding\"),\n                Charset.defaultCharset().name()\n        });\n        return platform == null ? Charset.defaultCharset() : platform;\n    }\n\n    private Charset firstSupportedCharset(String[] candidates) {\n        if (candidates == null) {\n            return null;\n        }\n        for (String candidate : candidates) {\n            if (isBlank(candidate)) {\n                continue;\n            }\n            try {\n                return Charset.forName(candidate.trim());\n            } catch (Exception ignored) {\n            }\n        }\n        return null;\n    }\n\n    private boolean isWindows() {\n        return System.getProperty(\"os.name\", \"\").toLowerCase(Locale.ROOT).contains(\"win\");\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private Long toLong(Object value) {\n        if (value instanceof Number) {\n            return ((Number) value).longValue();\n        }\n        return 0L;\n    }\n\n    private static final class ManagedProcess {\n\n        private final String processId;\n        private final String command;\n        private final String workingDirectory;\n        private final Process process;\n        private final ProcessOutputBuffer outputBuffer;\n        private final long startedAt;\n        private final Long pid;\n        private final Charset charset;\n        private final long stopGraceMs;\n\n        private volatile String status;\n        private volatile Integer exitCode;\n        private volatile Long endedAt;\n\n        private ManagedProcess(String processId,\n                               String command,\n                               String workingDirectory,\n                               Process process,\n                               int maxOutputChars,\n                               Charset charset,\n                               long stopGraceMs) {\n            this.processId = processId;\n            this.command = command;\n            this.workingDirectory = workingDirectory;\n            this.process = process;\n            this.outputBuffer = new ProcessOutputBuffer(maxOutputChars);\n            this.startedAt = System.currentTimeMillis();\n            this.pid = safePid(process);\n            this.charset = charset;\n            this.stopGraceMs = stopGraceMs;\n            this.status = \"RUNNING\";\n        }\n\n        private void startReaders() {\n            Thread stdoutThread = new Thread(\n                    new StreamCollector(process.getInputStream(), outputBuffer, \"[stdout] \", charset),\n                    processId + \"-stdout\"\n            );\n            Thread stderrThread = new Thread(\n                    new StreamCollector(process.getErrorStream(), outputBuffer, \"[stderr] \", charset),\n                    processId + \"-stderr\"\n            );\n            stdoutThread.setDaemon(true);\n            stderrThread.setDaemon(true);\n            stdoutThread.start();\n            stderrThread.start();\n        }\n\n        private void startWatcher() {\n            Thread watcher = new Thread(new Runnable() {\n                @Override\n                public void run() {\n                    try {\n                        int code = process.waitFor();\n                        exitCode = code;\n                        endedAt = System.currentTimeMillis();\n                        if (!\"STOPPED\".equals(status)) {\n                            status = \"EXITED\";\n                        }\n                    } catch (InterruptedException ignored) {\n                        Thread.currentThread().interrupt();\n                    }\n                }\n            }, processId + \"-watcher\");\n            watcher.setDaemon(true);\n            watcher.start();\n        }\n\n        private Map<String, Object> snapshot() {\n            Map<String, Object> result = new LinkedHashMap<String, Object>();\n            result.put(\"processId\", processId);\n            result.put(\"command\", command);\n            result.put(\"workingDirectory\", workingDirectory);\n            result.put(\"status\", status);\n            result.put(\"pid\", pid);\n            result.put(\"exitCode\", exitCode);\n            result.put(\"startedAt\", startedAt);\n            result.put(\"endedAt\", endedAt);\n            result.put(\"restored\", false);\n            result.put(\"controlAvailable\", true);\n            return result;\n        }\n\n        private Map<String, Object> readLogs(long offset, int limit) {\n            return outputBuffer.read(processId, offset, limit, status, exitCode);\n        }\n\n        private void stop() {\n            if (!process.isAlive()) {\n                if (exitCode == null) {\n                    try {\n                        exitCode = process.exitValue();\n                    } catch (IllegalThreadStateException ignored) {\n                        exitCode = -1;\n                    }\n                }\n                if (endedAt == null) {\n                    endedAt = System.currentTimeMillis();\n                }\n                status = \"STOPPED\";\n                return;\n            }\n            process.destroy();\n            try {\n                if (!process.waitFor(stopGraceMs, TimeUnit.MILLISECONDS)) {\n                    process.destroyForcibly();\n                    process.waitFor(5L, TimeUnit.SECONDS);\n                }\n            } catch (InterruptedException ignored) {\n                Thread.currentThread().interrupt();\n            }\n            status = \"STOPPED\";\n            endedAt = System.currentTimeMillis();\n            try {\n                exitCode = process.exitValue();\n            } catch (IllegalThreadStateException ignored) {\n                exitCode = -1;\n            }\n        }\n\n        private static Long safePid(Process process) {\n            try {\n                return (Long) process.getClass().getMethod(\"pid\").invoke(process);\n            } catch (Exception ignored) {\n                return null;\n            }\n        }\n    }\n\n    private static final class StreamCollector implements Runnable {\n\n        private final InputStream inputStream;\n        private final ProcessOutputBuffer outputBuffer;\n        private final String prefix;\n        private final Charset charset;\n\n        private StreamCollector(InputStream inputStream,\n                                ProcessOutputBuffer outputBuffer,\n                                String prefix,\n                                Charset charset) {\n            this.inputStream = inputStream;\n            this.outputBuffer = outputBuffer;\n            this.prefix = prefix;\n            this.charset = charset;\n        }\n\n        @Override\n        public void run() {\n            try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) {\n                String line;\n                while ((line = reader.readLine()) != null) {\n                    outputBuffer.append(prefix + line + \"\\n\");\n                }\n            } catch (IOException ignored) {\n            }\n        }\n    }\n\n    private static final class ProcessOutputBuffer {\n\n        private final int maxChars;\n        private final StringBuilder buffer = new StringBuilder();\n        private long startOffset = 0L;\n\n        private ProcessOutputBuffer(int maxChars) {\n            this.maxChars = Math.max(1024, maxChars);\n        }\n\n        private synchronized void append(String text) {\n            if (text == null || text.isEmpty()) {\n                return;\n            }\n            buffer.append(text);\n            int overflow = buffer.length() - maxChars;\n            if (overflow > 0) {\n                buffer.delete(0, overflow);\n                startOffset += overflow;\n            }\n        }\n\n        private synchronized Map<String, Object> read(String processId,\n                                                      long offset,\n                                                      int limit,\n                                                      String status,\n                                                      Integer exitCode) {\n            long effectiveOffset = Math.max(offset, startOffset);\n            int from = (int) Math.max(0L, effectiveOffset - startOffset);\n            int to = Math.min(buffer.length(), from + Math.max(1, limit));\n\n            Map<String, Object> result = new LinkedHashMap<String, Object>();\n            result.put(\"processId\", processId);\n            result.put(\"offset\", effectiveOffset);\n            result.put(\"nextOffset\", startOffset + to);\n            result.put(\"truncated\", offset < startOffset);\n            result.put(\"content\", buffer.substring(Math.min(from, buffer.length()), Math.min(to, buffer.length())));\n            result.put(\"status\", status);\n            result.put(\"exitCode\", exitCode);\n            return result;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInToolContext.java",
    "content": "package io.github.lnyocly.ai4j.tool;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class BuiltInToolContext {\n\n    @Builder.Default\n    private String workspaceRoot = Paths.get(\".\").toAbsolutePath().normalize().toString();\n\n    @Builder.Default\n    private boolean allowOutsideWorkspace = false;\n\n    @Builder.Default\n    private List<String> allowedReadRoots = new ArrayList<String>();\n\n    @Builder.Default\n    private int defaultReadMaxChars = 12000;\n\n    @Builder.Default\n    private long defaultCommandTimeoutMs = 30000L;\n\n    @Builder.Default\n    private int defaultBashLogChars = 12000;\n\n    @Builder.Default\n    private int maxProcessOutputChars = 120000;\n\n    @Builder.Default\n    private long processStopGraceMs = 1000L;\n\n    private transient BuiltInProcessRegistry processRegistry;\n\n    public Path getWorkspaceRootPath() {\n        if (isBlank(workspaceRoot)) {\n            return Paths.get(\".\").toAbsolutePath().normalize();\n        }\n        return Paths.get(workspaceRoot).toAbsolutePath().normalize();\n    }\n\n    public Path resolveWorkspacePath(String path) {\n        Path root = getWorkspaceRootPath();\n        if (isBlank(path)) {\n            return root;\n        }\n        Path candidate = Paths.get(path);\n        if (!candidate.isAbsolute()) {\n            candidate = root.resolve(path);\n        }\n        candidate = candidate.toAbsolutePath().normalize();\n        if (!allowOutsideWorkspace && !candidate.startsWith(root)) {\n            throw new IllegalArgumentException(\"Path escapes workspace root: \" + path);\n        }\n        return candidate;\n    }\n\n    public Path resolveReadablePath(String path) {\n        Path root = getWorkspaceRootPath();\n        if (isBlank(path)) {\n            return root;\n        }\n        Path candidate = Paths.get(path);\n        if (!candidate.isAbsolute()) {\n            candidate = root.resolve(path);\n        }\n        candidate = candidate.toAbsolutePath().normalize();\n        if (allowOutsideWorkspace || candidate.startsWith(root)) {\n            return candidate;\n        }\n        for (Path allowedRoot : getAllowedReadRootPaths()) {\n            if (candidate.startsWith(allowedRoot)) {\n                return candidate;\n            }\n        }\n        throw new IllegalArgumentException(\"Path escapes workspace root: \" + path);\n    }\n\n    public List<Path> getAllowedReadRootPaths() {\n        if (allowedReadRoots == null || allowedReadRoots.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Path> paths = new ArrayList<Path>();\n        for (String allowedReadRoot : allowedReadRoots) {\n            if (isBlank(allowedReadRoot)) {\n                continue;\n            }\n            paths.add(Paths.get(allowedReadRoot).toAbsolutePath().normalize());\n        }\n        return paths;\n    }\n\n    BuiltInProcessRegistry getOrCreateProcessRegistry() {\n        if (processRegistry != null) {\n            return processRegistry;\n        }\n        synchronized (this) {\n            if (processRegistry == null) {\n                processRegistry = new BuiltInProcessRegistry(this);\n            }\n            return processRegistry;\n        }\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.tool;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.OpenOption;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.TimeUnit;\n\npublic final class BuiltInToolExecutor {\n\n    private static final String BEGIN_PATCH = \"*** Begin Patch\";\n    private static final String END_PATCH = \"*** End Patch\";\n    private static final String ADD_FILE = \"*** Add File:\";\n    private static final String ADD_FILE_ALIAS = \"*** Add:\";\n    private static final String UPDATE_FILE = \"*** Update File:\";\n    private static final String UPDATE_FILE_ALIAS = \"*** Update:\";\n    private static final String DELETE_FILE = \"*** Delete File:\";\n    private static final String DELETE_FILE_ALIAS = \"*** Delete:\";\n\n    private BuiltInToolExecutor() {\n    }\n\n    public static boolean supports(String functionName) {\n        return BuiltInTools.allCodingToolNames().contains(functionName);\n    }\n\n    public static String invoke(String functionName, String argument, BuiltInToolContext context) throws Exception {\n        if (!supports(functionName)) {\n            return null;\n        }\n        BuiltInToolContext effectiveContext = context == null\n                ? BuiltInToolContext.builder().build()\n                : context;\n\n        JSONObject arguments = parseArguments(argument);\n        if (BuiltInTools.READ_FILE.equals(functionName)) {\n            return JSON.toJSONString(readFile(effectiveContext, arguments));\n        }\n        if (BuiltInTools.WRITE_FILE.equals(functionName)) {\n            return JSON.toJSONString(writeFile(effectiveContext, arguments));\n        }\n        if (BuiltInTools.APPLY_PATCH.equals(functionName)) {\n            return JSON.toJSONString(applyPatch(effectiveContext, arguments));\n        }\n        if (BuiltInTools.BASH.equals(functionName)) {\n            return JSON.toJSONString(runBash(effectiveContext, arguments));\n        }\n        throw new IllegalArgumentException(\"Unsupported built-in tool: \" + functionName);\n    }\n\n    private static Map<String, Object> readFile(BuiltInToolContext context, JSONObject arguments) throws IOException {\n        String path = arguments.getString(\"path\");\n        if (isBlank(path)) {\n            throw new IllegalArgumentException(\"path is required\");\n        }\n        Path file = context.resolveReadablePath(path);\n        if (!Files.exists(file)) {\n            throw new IllegalArgumentException(\"File does not exist: \" + path);\n        }\n        if (Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"Path is a directory: \" + path);\n        }\n\n        List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);\n        int startLine = arguments.getInteger(\"startLine\") == null || arguments.getInteger(\"startLine\").intValue() < 1\n                ? 1\n                : arguments.getInteger(\"startLine\").intValue();\n        int endLine = arguments.getInteger(\"endLine\") == null || arguments.getInteger(\"endLine\").intValue() > lines.size()\n                ? lines.size()\n                : arguments.getInteger(\"endLine\").intValue();\n        if (endLine < startLine) {\n            endLine = startLine - 1;\n        }\n\n        StringBuilder contentBuilder = new StringBuilder();\n        for (int i = startLine; i <= endLine; i++) {\n            if (i > lines.size()) {\n                break;\n            }\n            if (contentBuilder.length() > 0) {\n                contentBuilder.append('\\n');\n            }\n            contentBuilder.append(lines.get(i - 1));\n        }\n\n        int maxChars = arguments.getInteger(\"maxChars\") == null || arguments.getInteger(\"maxChars\").intValue() <= 0\n                ? context.getDefaultReadMaxChars()\n                : arguments.getInteger(\"maxChars\").intValue();\n        String content = contentBuilder.toString();\n        boolean truncated = false;\n        if (content.length() > maxChars) {\n            content = content.substring(0, maxChars);\n            truncated = true;\n        }\n\n        Map<String, Object> result = new LinkedHashMap<String, Object>();\n        result.put(\"path\", toDisplayPath(context, file));\n        result.put(\"content\", content);\n        result.put(\"startLine\", startLine);\n        result.put(\"endLine\", endLine);\n        result.put(\"truncated\", truncated);\n        return result;\n    }\n\n    private static Map<String, Object> writeFile(BuiltInToolContext context, JSONObject arguments) throws IOException {\n        String path = safeTrim(arguments.getString(\"path\"));\n        if (isBlank(path)) {\n            throw new IllegalArgumentException(\"path is required\");\n        }\n        String content = arguments.containsKey(\"content\") && arguments.get(\"content\") != null\n                ? arguments.getString(\"content\")\n                : \"\";\n        String mode = firstNonBlank(safeTrim(arguments.getString(\"mode\")), \"overwrite\").toLowerCase(Locale.ROOT);\n        Path file = context.resolveWorkspacePath(path);\n        if (Files.exists(file) && Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"Target is a directory: \" + path);\n        }\n\n        boolean existed = Files.exists(file);\n        boolean appended = false;\n        boolean created;\n        byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8);\n\n        Path parent = file.getParent();\n        if (parent != null) {\n            Files.createDirectories(parent);\n        }\n\n        if (\"create\".equals(mode)) {\n            if (existed) {\n                throw new IllegalArgumentException(\"File already exists: \" + path);\n            }\n            Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE});\n            created = true;\n        } else if (\"overwrite\".equals(mode)) {\n            Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE});\n            created = !existed;\n        } else if (\"append\".equals(mode)) {\n            Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE});\n            created = !existed;\n            appended = true;\n        } else {\n            throw new IllegalArgumentException(\"Unsupported write mode: \" + mode);\n        }\n\n        Map<String, Object> result = new LinkedHashMap<String, Object>();\n        result.put(\"path\", path);\n        result.put(\"resolvedPath\", file.toString());\n        result.put(\"mode\", mode);\n        result.put(\"created\", created);\n        result.put(\"appended\", appended);\n        result.put(\"bytesWritten\", bytes.length);\n        return result;\n    }\n\n    private static Map<String, Object> runBash(BuiltInToolContext context, JSONObject arguments) throws Exception {\n        String action = firstNonBlank(arguments.getString(\"action\"), \"exec\");\n        if (\"exec\".equals(action)) {\n            return exec(context, arguments);\n        }\n        BuiltInProcessRegistry processRegistry = context.getOrCreateProcessRegistry();\n        if (\"start\".equals(action)) {\n            return processRegistry.start(arguments.getString(\"command\"), arguments.getString(\"cwd\"));\n        }\n        if (\"status\".equals(action)) {\n            return processRegistry.status(arguments.getString(\"processId\"));\n        }\n        if (\"logs\".equals(action)) {\n            return processRegistry.logs(\n                    arguments.getString(\"processId\"),\n                    arguments.getLong(\"offset\"),\n                    arguments.getInteger(\"limit\")\n            );\n        }\n        if (\"write\".equals(action)) {\n            String processId = arguments.getString(\"processId\");\n            int bytesWritten = processRegistry.write(processId, arguments.getString(\"input\"));\n            Map<String, Object> result = new LinkedHashMap<String, Object>();\n            result.put(\"process\", processRegistry.status(processId));\n            result.put(\"bytesWritten\", bytesWritten);\n            return result;\n        }\n        if (\"stop\".equals(action)) {\n            return processRegistry.stop(arguments.getString(\"processId\"));\n        }\n        if (\"list\".equals(action)) {\n            Map<String, Object> result = new LinkedHashMap<String, Object>();\n            result.put(\"processes\", processRegistry.list());\n            return result;\n        }\n        throw new IllegalArgumentException(\"Unsupported bash action: \" + action);\n    }\n\n    private static Map<String, Object> exec(BuiltInToolContext context, JSONObject arguments) throws Exception {\n        String command = arguments.getString(\"command\");\n        if (isBlank(command)) {\n            throw new IllegalArgumentException(\"command is required\");\n        }\n        Path workingDirectory = context.resolveWorkspacePath(arguments.getString(\"cwd\"));\n        long timeoutMs = arguments.getLong(\"timeoutMs\") == null || arguments.getLong(\"timeoutMs\").longValue() <= 0L\n                ? context.getDefaultCommandTimeoutMs()\n                : arguments.getLong(\"timeoutMs\").longValue();\n\n        ProcessBuilder processBuilder;\n        if (System.getProperty(\"os.name\", \"\").toLowerCase(Locale.ROOT).contains(\"win\")) {\n            processBuilder = new ProcessBuilder(\"cmd.exe\", \"/c\", command);\n        } else {\n            processBuilder = new ProcessBuilder(\"sh\", \"-lc\", command);\n        }\n        processBuilder.directory(workingDirectory.toFile());\n        Process process = processBuilder.start();\n\n        Charset shellCharset = resolveShellCharset();\n        StringBuilder stdout = new StringBuilder();\n        StringBuilder stderr = new StringBuilder();\n        Thread stdoutThread = new Thread(new StreamCollector(process.getInputStream(), stdout, shellCharset), \"ai4j-built-in-stdout\");\n        Thread stderrThread = new Thread(new StreamCollector(process.getErrorStream(), stderr, shellCharset), \"ai4j-built-in-stderr\");\n        stdoutThread.start();\n        stderrThread.start();\n\n        boolean finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS);\n        int exitCode;\n        if (finished) {\n            exitCode = process.exitValue();\n        } else {\n            process.destroyForcibly();\n            process.waitFor(5L, TimeUnit.SECONDS);\n            exitCode = -1;\n            if (stderr.length() > 0) {\n                stderr.append('\\n');\n            }\n            stderr.append(\"Command timed out before exit. If it is interactive or long-running, use bash action=start and then bash action=logs/status/write/stop instead of bash action=exec.\");\n        }\n\n        stdoutThread.join();\n        stderrThread.join();\n\n        Map<String, Object> result = new LinkedHashMap<String, Object>();\n        result.put(\"command\", command);\n        result.put(\"workingDirectory\", workingDirectory.toString());\n        result.put(\"stdout\", stdout.toString());\n        result.put(\"stderr\", stderr.toString());\n        result.put(\"exitCode\", exitCode);\n        result.put(\"timedOut\", !finished);\n        return result;\n    }\n\n    private static Charset resolveShellCharset() {\n        try {\n            String explicit = System.getProperty(\"ai4j.shell.encoding\");\n            if (!isBlank(explicit)) {\n                return Charset.forName(explicit.trim());\n            }\n        } catch (Exception ignored) {\n        }\n        try {\n            String env = System.getenv(\"AI4J_SHELL_ENCODING\");\n            if (!isBlank(env)) {\n                return Charset.forName(env.trim());\n            }\n        } catch (Exception ignored) {\n        }\n        if (!System.getProperty(\"os.name\", \"\").toLowerCase(Locale.ROOT).contains(\"win\")) {\n            return StandardCharsets.UTF_8;\n        }\n        String[] candidates = new String[]{\n                System.getProperty(\"native.encoding\"),\n                System.getProperty(\"sun.jnu.encoding\"),\n                System.getProperty(\"file.encoding\"),\n                Charset.defaultCharset().name()\n        };\n        for (String candidate : candidates) {\n            if (isBlank(candidate)) {\n                continue;\n            }\n            try {\n                return Charset.forName(candidate.trim());\n            } catch (Exception ignored) {\n            }\n        }\n        return Charset.defaultCharset();\n    }\n\n    private static Map<String, Object> applyPatch(BuiltInToolContext context, JSONObject arguments) throws IOException {\n        String patch = arguments.getString(\"patch\");\n        if (isBlank(patch)) {\n            throw new IllegalArgumentException(\"patch is required\");\n        }\n        return applyPatch(context, patch);\n    }\n\n    private static Map<String, Object> applyPatch(BuiltInToolContext context, String patchText) throws IOException {\n        List<String> lines = normalizeLines(patchText);\n        if (lines.size() < 2 || !BEGIN_PATCH.equals(lines.get(0)) || !END_PATCH.equals(lines.get(lines.size() - 1))) {\n            throw new IllegalArgumentException(\"Invalid patch envelope\");\n        }\n\n        int index = 1;\n        int operationsApplied = 0;\n        Set<String> changedFiles = new LinkedHashSet<String>();\n        List<Map<String, Object>> fileChanges = new ArrayList<Map<String, Object>>();\n        while (index < lines.size() - 1) {\n            String line = lines.get(index);\n            PatchDirective directive = parseDirective(line);\n            if (directive == null) {\n                if (line.trim().isEmpty()) {\n                    index++;\n                    continue;\n                }\n                throw new IllegalArgumentException(\"Unsupported patch line: \" + line);\n            }\n            if (\"add\".equals(directive.operation)) {\n                PatchOperation operation = applyAddFile(context, lines, index + 1, directive.path);\n                index = operation.nextIndex;\n                operationsApplied++;\n                changedFiles.add(operation.path);\n                fileChanges.add(operation.fileChange);\n                continue;\n            }\n            if (\"update\".equals(directive.operation)) {\n                PatchOperation operation = applyUpdateFile(context, lines, index + 1, directive.path);\n                index = operation.nextIndex;\n                operationsApplied++;\n                changedFiles.add(operation.path);\n                fileChanges.add(operation.fileChange);\n                continue;\n            }\n            if (\"delete\".equals(directive.operation)) {\n                PatchOperation operation = applyDeleteFile(context, directive.path, index + 1);\n                index = operation.nextIndex;\n                operationsApplied++;\n                changedFiles.add(operation.path);\n                fileChanges.add(operation.fileChange);\n            }\n        }\n\n        Map<String, Object> result = new LinkedHashMap<String, Object>();\n        result.put(\"filesChanged\", changedFiles.size());\n        result.put(\"operationsApplied\", operationsApplied);\n        result.put(\"changedFiles\", new ArrayList<String>(changedFiles));\n        result.put(\"fileChanges\", fileChanges);\n        return result;\n    }\n\n    private static PatchOperation applyAddFile(BuiltInToolContext context,\n                                               List<String> lines,\n                                               int startIndex,\n                                               String path) throws IOException {\n        Path file = context.resolveWorkspacePath(path);\n        if (Files.exists(file)) {\n            throw new IllegalArgumentException(\"File already exists: \" + path);\n        }\n        List<String> contentLines = new ArrayList<String>();\n        int index = startIndex;\n        while (index < lines.size() - 1 && !lines.get(index).startsWith(\"*** \")) {\n            String line = lines.get(index);\n            if (!line.startsWith(\"+\")) {\n                throw new IllegalArgumentException(\"Add file lines must start with '+': \" + line);\n            }\n            contentLines.add(line.substring(1));\n            index++;\n        }\n        writePatchFile(file, joinLines(contentLines));\n        return new PatchOperation(index, normalizeRelativePath(path), buildFileChange(normalizeRelativePath(path), \"add\", contentLines.size(), 0));\n    }\n\n    private static PatchOperation applyUpdateFile(BuiltInToolContext context,\n                                                  List<String> lines,\n                                                  int startIndex,\n                                                  String path) throws IOException {\n        Path file = context.resolveWorkspacePath(path);\n        if (!Files.exists(file) || Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"File does not exist: \" + path);\n        }\n\n        List<String> body = new ArrayList<String>();\n        int index = startIndex;\n        while (index < lines.size() - 1 && !lines.get(index).startsWith(\"*** \")) {\n            body.add(lines.get(index));\n            index++;\n        }\n\n        List<String> normalizedBody = normalizeUpdateBody(body);\n        String original = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);\n        String updated = applyUpdateBody(original, normalizedBody, path);\n        writePatchFile(file, updated);\n        return new PatchOperation(\n                index,\n                normalizeRelativePath(path),\n                buildFileChange(normalizeRelativePath(path), \"update\", countPrefixedLines(normalizedBody, '+'), countPrefixedLines(normalizedBody, '-'))\n        );\n    }\n\n    private static PatchOperation applyDeleteFile(BuiltInToolContext context,\n                                                  String path,\n                                                  int startIndex) throws IOException {\n        Path file = context.resolveWorkspacePath(path);\n        if (!Files.exists(file) || Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"File does not exist: \" + path);\n        }\n        String original = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);\n        int removed = splitContentLines(original).size();\n        Files.delete(file);\n        return new PatchOperation(startIndex, normalizeRelativePath(path), buildFileChange(normalizeRelativePath(path), \"delete\", 0, removed));\n    }\n\n    private static String applyUpdateBody(String original, List<String> body, String path) {\n        List<String> originalLines = splitContentLines(original);\n        List<List<String>> hunks = parseHunks(body);\n\n        List<String> output = new ArrayList<String>();\n        int cursor = 0;\n        for (List<String> hunk : hunks) {\n            List<String> anchor = resolveAnchor(hunk);\n            int matchIndex = findAnchor(originalLines, cursor, anchor);\n            if (matchIndex < 0) {\n                throw new IllegalArgumentException(\"Failed to locate patch hunk in file: \" + path);\n            }\n\n            appendRange(output, originalLines, cursor, matchIndex);\n            int current = matchIndex;\n            for (String line : hunk) {\n                if (line.startsWith(\"@@\")) {\n                    continue;\n                }\n                if (line.isEmpty()) {\n                    throw new IllegalArgumentException(\"Invalid empty patch line in update body\");\n                }\n                char prefix = line.charAt(0);\n                String content = line.substring(1);\n                switch (prefix) {\n                    case ' ':\n                        ensureMatch(originalLines, current, content, path);\n                        output.add(originalLines.get(current));\n                        current++;\n                        break;\n                    case '-':\n                        ensureMatch(originalLines, current, content, path);\n                        current++;\n                        break;\n                    case '+':\n                        output.add(content);\n                        break;\n                    default:\n                        throw new IllegalArgumentException(\"Unsupported update line: \" + line);\n                }\n            }\n            cursor = current;\n        }\n\n        appendRange(output, originalLines, cursor, originalLines.size());\n        return joinLines(output);\n    }\n\n    private static List<List<String>> parseHunks(List<String> body) {\n        List<List<String>> hunks = new ArrayList<List<String>>();\n        List<String> current = new ArrayList<String>();\n        for (String line : body) {\n            if (line.startsWith(\"@@\")) {\n                if (!current.isEmpty()) {\n                    hunks.add(current);\n                    current = new ArrayList<String>();\n                }\n                current.add(line);\n                continue;\n            }\n            if (line.startsWith(\" \") || line.startsWith(\"+\") || line.startsWith(\"-\")) {\n                current.add(line);\n                continue;\n            }\n            if (line.trim().isEmpty()) {\n                current.add(\" \");\n                continue;\n            }\n            throw new IllegalArgumentException(\"Unsupported update body line: \" + line);\n        }\n        if (!current.isEmpty()) {\n            hunks.add(current);\n        }\n        if (hunks.isEmpty()) {\n            throw new IllegalArgumentException(\"Update file patch must contain at least one hunk\");\n        }\n        return hunks;\n    }\n\n    private static List<String> resolveAnchor(List<String> hunk) {\n        List<String> leading = new ArrayList<String>();\n        for (String line : hunk) {\n            if (line.startsWith(\"@@\")) {\n                continue;\n            }\n            if (line.startsWith(\" \") || line.startsWith(\"-\")) {\n                leading.add(line.substring(1));\n            } else if (line.startsWith(\"+\")) {\n                break;\n            }\n        }\n        if (!leading.isEmpty()) {\n            return leading;\n        }\n        for (String line : hunk) {\n            if (line.startsWith(\"+\")) {\n                return java.util.Collections.singletonList(line.substring(1));\n            }\n        }\n        return new ArrayList<String>();\n    }\n\n    private static int findAnchor(List<String> originalLines, int cursor, List<String> anchor) {\n        if (anchor.isEmpty()) {\n            return cursor;\n        }\n        for (int i = cursor; i <= originalLines.size() - anchor.size(); i++) {\n            boolean match = true;\n            for (int j = 0; j < anchor.size(); j++) {\n                if (!originalLines.get(i + j).equals(anchor.get(j))) {\n                    match = false;\n                    break;\n                }\n            }\n            if (match) {\n                return i;\n            }\n        }\n        return -1;\n    }\n\n    private static void appendRange(List<String> output, List<String> originalLines, int fromInclusive, int toExclusive) {\n        for (int i = fromInclusive; i < toExclusive; i++) {\n            output.add(originalLines.get(i));\n        }\n    }\n\n    private static void ensureMatch(List<String> originalLines, int current, String expected, String path) {\n        if (current >= originalLines.size()) {\n            throw new IllegalArgumentException(\"Patch exceeds file length: \" + path);\n        }\n        String actual = originalLines.get(current);\n        if (!actual.equals(expected)) {\n            throw new IllegalArgumentException(\"Patch context mismatch in file \" + path + \": expected [\" + expected + \"] but found [\" + actual + \"]\");\n        }\n    }\n\n    private static int countPrefixedLines(List<String> lines, char prefix) {\n        int count = 0;\n        for (String line : lines) {\n            if (!line.isEmpty() && line.charAt(0) == prefix) {\n                count++;\n            }\n        }\n        return count;\n    }\n\n    private static List<String> normalizeUpdateBody(List<String> body) {\n        List<String> normalized = new ArrayList<String>();\n        for (String line : body) {\n            if (line != null) {\n                normalized.add(line);\n            }\n        }\n        return normalized;\n    }\n\n    private static void writePatchFile(Path file, String content) throws IOException {\n        Path parent = file.getParent();\n        if (parent != null) {\n            Files.createDirectories(parent);\n        }\n        byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8);\n        Files.write(file, bytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);\n    }\n\n    private static List<String> splitContentLines(String content) {\n        String normalized = content == null ? \"\" : content.replace(\"\\r\\n\", \"\\n\");\n        List<String> lines = new ArrayList<String>();\n        if (normalized.isEmpty()) {\n            return lines;\n        }\n        String[] split = normalized.split(\"\\n\", -1);\n        for (int i = 0; i < split.length; i++) {\n            if (i == split.length - 1 && normalized.endsWith(\"\\n\") && split[i].isEmpty()) {\n                continue;\n            }\n            lines.add(split[i]);\n        }\n        return lines;\n    }\n\n    private static String joinLines(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < lines.size(); i++) {\n            if (i > 0) {\n                builder.append('\\n');\n            }\n            builder.append(lines.get(i));\n        }\n        return builder.toString();\n    }\n\n    private static List<String> normalizeLines(String text) {\n        String normalized = text == null ? \"\" : text.replace(\"\\r\\n\", \"\\n\");\n        String[] split = normalized.split(\"\\n\", -1);\n        List<String> lines = new ArrayList<String>(split.length);\n        for (String line : split) {\n            lines.add(line);\n        }\n        if (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) {\n            lines.remove(lines.size() - 1);\n        }\n        return lines;\n    }\n\n    private static PatchDirective parseDirective(String line) {\n        if (line.startsWith(ADD_FILE)) {\n            return new PatchDirective(\"add\", safeTrim(line.substring(ADD_FILE.length())));\n        }\n        if (line.startsWith(ADD_FILE_ALIAS)) {\n            return new PatchDirective(\"add\", safeTrim(line.substring(ADD_FILE_ALIAS.length())));\n        }\n        if (line.startsWith(UPDATE_FILE)) {\n            return new PatchDirective(\"update\", safeTrim(line.substring(UPDATE_FILE.length())));\n        }\n        if (line.startsWith(UPDATE_FILE_ALIAS)) {\n            return new PatchDirective(\"update\", safeTrim(line.substring(UPDATE_FILE_ALIAS.length())));\n        }\n        if (line.startsWith(DELETE_FILE)) {\n            return new PatchDirective(\"delete\", safeTrim(line.substring(DELETE_FILE.length())));\n        }\n        if (line.startsWith(DELETE_FILE_ALIAS)) {\n            return new PatchDirective(\"delete\", safeTrim(line.substring(DELETE_FILE_ALIAS.length())));\n        }\n        return null;\n    }\n\n    private static JSONObject parseArguments(String rawArguments) {\n        if (rawArguments == null || rawArguments.trim().isEmpty()) {\n            return new JSONObject();\n        }\n        return JSON.parseObject(rawArguments);\n    }\n\n    private static Map<String, Object> buildFileChange(String path, String operation, int linesAdded, int linesRemoved) {\n        Map<String, Object> change = new LinkedHashMap<String, Object>();\n        change.put(\"path\", path);\n        change.put(\"operation\", operation);\n        change.put(\"linesAdded\", linesAdded);\n        change.put(\"linesRemoved\", linesRemoved);\n        return change;\n    }\n\n    private static String normalizeRelativePath(String path) {\n        return path == null ? \"\" : path.replace('\\\\', '/');\n    }\n\n    private static String toDisplayPath(BuiltInToolContext context, Path file) {\n        Path root = context.getWorkspaceRootPath();\n        if (file == null) {\n            return \"\";\n        }\n        if (!file.startsWith(root)) {\n            return file.toString().replace('\\\\', '/');\n        }\n        if (file.equals(root)) {\n            return \".\";\n        }\n        return root.relativize(file).toString().replace('\\\\', '/');\n    }\n\n    private static String safeTrim(String value) {\n        return value == null ? null : value.trim();\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static final class PatchDirective {\n\n        private final String operation;\n        private final String path;\n\n        private PatchDirective(String operation, String path) {\n            this.operation = operation;\n            this.path = path;\n        }\n    }\n\n    private static final class PatchOperation {\n\n        private final int nextIndex;\n        private final String path;\n        private final Map<String, Object> fileChange;\n\n        private PatchOperation(int nextIndex, String path, Map<String, Object> fileChange) {\n            this.nextIndex = nextIndex;\n            this.path = path;\n            this.fileChange = fileChange;\n        }\n    }\n\n    private static final class StreamCollector implements Runnable {\n\n        private final InputStream inputStream;\n        private final StringBuilder target;\n        private final Charset charset;\n\n        private StreamCollector(InputStream inputStream, StringBuilder target, Charset charset) {\n            this.inputStream = inputStream;\n            this.target = target;\n            this.charset = charset;\n        }\n\n        @Override\n        public void run() {\n            try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) {\n                String line;\n                while ((line = reader.readLine()) != null) {\n                    synchronized (target) {\n                        if (target.length() > 0) {\n                            target.append('\\n');\n                        }\n                        target.append(line);\n                    }\n                }\n            } catch (IOException ignored) {\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInTools.java",
    "content": "package io.github.lnyocly.ai4j.tool;\n\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\npublic final class BuiltInTools {\n\n    public static final String BASH = \"bash\";\n    public static final String READ_FILE = \"read_file\";\n    public static final String WRITE_FILE = \"write_file\";\n    public static final String APPLY_PATCH = \"apply_patch\";\n\n    private static final Set<String> CODING_TOOL_NAMES = Collections.unmodifiableSet(\n            new LinkedHashSet<String>(Arrays.asList(BASH, READ_FILE, WRITE_FILE, APPLY_PATCH))\n    );\n\n    private static final Set<String> READ_ONLY_CODING_TOOL_NAMES = Collections.unmodifiableSet(\n            new LinkedHashSet<String>(Arrays.asList(BASH, READ_FILE))\n    );\n\n    private static final List<Tool> CODING_TOOLS = Collections.unmodifiableList(Arrays.asList(\n            bashTool(),\n            readFileTool(),\n            writeFileTool(),\n            applyPatchTool()\n    ));\n\n    private BuiltInTools() {\n    }\n\n    public static Tool readFileTool() {\n        Map<String, Tool.Function.Property> properties = new LinkedHashMap<String, Tool.Function.Property>();\n        properties.put(\"path\", property(\"string\", \"Relative file path inside the workspace, or an absolute path inside an approved read-only skill root.\"));\n        properties.put(\"startLine\", property(\"integer\", \"First line number to read, starting from 1.\"));\n        properties.put(\"endLine\", property(\"integer\", \"Last line number to read, inclusive.\"));\n        properties.put(\"maxChars\", property(\"integer\", \"Maximum characters to return.\"));\n        return tool(\n                READ_FILE,\n                \"Read a text file from the workspace or from an approved read-only skill directory.\",\n                properties,\n                Collections.singletonList(\"path\")\n        );\n    }\n\n    public static Tool bashTool() {\n        Map<String, Tool.Function.Property> properties = new LinkedHashMap<String, Tool.Function.Property>();\n        properties.put(\"action\", property(\"string\", \"bash action to perform.\", Arrays.asList(\"exec\", \"start\", \"status\", \"logs\", \"write\", \"stop\", \"list\")));\n        properties.put(\"command\", property(\"string\", \"Command string to execute. Use exec for self-terminating commands; use start for interactive or long-running commands.\"));\n        properties.put(\"cwd\", property(\"string\", \"Relative working directory inside the workspace.\"));\n        properties.put(\"timeoutMs\", property(\"integer\", \"Execution timeout in milliseconds for exec.\"));\n        properties.put(\"processId\", property(\"string\", \"Background process identifier.\"));\n        properties.put(\"offset\", property(\"integer\", \"Log cursor offset.\"));\n        properties.put(\"limit\", property(\"integer\", \"Maximum log characters to return.\"));\n        properties.put(\"input\", property(\"string\", \"Text written to stdin for a background process started with action=start.\"));\n        return tool(\n                BASH,\n                \"Execute non-interactive shell commands or manage interactive/background shell processes inside the workspace.\",\n                properties,\n                Collections.singletonList(\"action\")\n        );\n    }\n\n    public static Tool writeFileTool() {\n        Map<String, Tool.Function.Property> properties = new LinkedHashMap<String, Tool.Function.Property>();\n        properties.put(\"path\", property(\"string\", \"File path to write. Relative paths resolve from the workspace root; absolute paths are allowed.\"));\n        properties.put(\"content\", property(\"string\", \"Full text content to write.\"));\n        properties.put(\"mode\", property(\"string\", \"Write mode.\", Arrays.asList(\"create\", \"overwrite\", \"append\")));\n        return tool(\n                WRITE_FILE,\n                \"Create, overwrite, or append a text file.\",\n                properties,\n                Arrays.asList(\"path\", \"content\")\n        );\n    }\n\n    public static Tool applyPatchTool() {\n        Map<String, Tool.Function.Property> properties = new LinkedHashMap<String, Tool.Function.Property>();\n        properties.put(\"patch\", property(\"string\", \"Patch text to apply. Must include *** Begin Patch and *** End Patch envelope.\"));\n        return tool(\n                APPLY_PATCH,\n                \"Apply a structured patch to workspace files.\",\n                properties,\n                Collections.singletonList(\"patch\")\n        );\n    }\n\n    public static List<Tool> codingTools() {\n        return new ArrayList<Tool>(CODING_TOOLS);\n    }\n\n    public static List<Tool> tools(String... names) {\n        List<Tool> tools = new ArrayList<Tool>();\n        if (names == null || names.length == 0) {\n            return tools;\n        }\n        for (String name : names) {\n            Tool tool = toolByName(name);\n            if (tool != null) {\n                tools.add(tool);\n            }\n        }\n        return tools;\n    }\n\n    public static Set<String> allCodingToolNames() {\n        return CODING_TOOL_NAMES;\n    }\n\n    public static Set<String> readOnlyCodingToolNames() {\n        return READ_ONLY_CODING_TOOL_NAMES;\n    }\n\n    private static Tool tool(String name,\n                             String description,\n                             Map<String, Tool.Function.Property> properties,\n                             List<String> required) {\n        Tool.Function.Parameter parameter = new Tool.Function.Parameter(\"object\", properties, required);\n        Tool.Function function = new Tool.Function(name, description, parameter);\n        return new Tool(\"function\", function);\n    }\n\n    private static Tool.Function.Property property(String type, String description) {\n        return property(type, description, null);\n    }\n\n    private static Tool.Function.Property property(String type, String description, List<String> enumValues) {\n        return new Tool.Function.Property(type, description, enumValues, null);\n    }\n\n    private static Tool toolByName(String name) {\n        if (READ_FILE.equals(name)) {\n            return readFileTool();\n        }\n        if (WRITE_FILE.equals(name)) {\n            return writeFileTool();\n        }\n        if (APPLY_PATCH.equals(name)) {\n            return applyPatchTool();\n        }\n        if (BASH.equals(name)) {\n            return bashTool();\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tool/ResponseRequestToolResolver.java",
    "content": "package io.github.lnyocly.ai4j.tool;\n\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic final class ResponseRequestToolResolver {\n\n    private ResponseRequestToolResolver() {\n    }\n\n    public static ResponseRequest resolve(ResponseRequest request) {\n        if (request == null) {\n            return null;\n        }\n        boolean hasFunctionRegistry = request.getFunctions() != null && !request.getFunctions().isEmpty();\n        boolean hasMcpRegistry = request.getMcpServices() != null && !request.getMcpServices().isEmpty();\n        if (!hasFunctionRegistry && !hasMcpRegistry) {\n            return request;\n        }\n\n        List<Object> mergedTools = new ArrayList<Object>();\n        if (request.getTools() != null && !request.getTools().isEmpty()) {\n            mergedTools.addAll(request.getTools());\n        }\n\n        List<Tool> resolvedTools = ToolUtil.getAllTools(request.getFunctions(), request.getMcpServices());\n        if (resolvedTools != null && !resolvedTools.isEmpty()) {\n            mergedTools.addAll(resolvedTools);\n        }\n\n        return request.toBuilder()\n                .tools(mergedTools)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tool/ToolUtil.java",
    "content": "package io.github.lnyocly.ai4j.tool;\n\n/**\n * @Author cly\n * @Description 统一工具管理器，支持 built-in tool、传统Function工具、本地MCP工具和远程MCP服务\n */\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpService;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpTool;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpParameter;\nimport io.github.lnyocly.ai4j.mcp.gateway.McpGateway;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.reflections.Reflections;\nimport org.reflections.scanners.Scanners;\nimport org.reflections.util.ClasspathHelper;\nimport org.reflections.util.ConfigurationBuilder;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.lang.reflect.Field;\nimport java.lang.reflect.Method;\nimport java.lang.reflect.Parameter;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class ToolUtil {\n\n    private static final Logger log = LoggerFactory.getLogger(ToolUtil.class);\n\n    // 反射扫描器，支持注解和方法扫描\n    private static final Reflections reflections = new Reflections(new ConfigurationBuilder()\n            .setUrls(ClasspathHelper.forPackage(\"\"))\n            .setScanners(Scanners.TypesAnnotated, Scanners.MethodsAnnotated));\n\n    // === 传统Function工具缓存 ===\n    public static final Map<String, Tool> toolEntityMap = new ConcurrentHashMap<>();\n    public static final Map<String, Class<?>> toolClassMap = new ConcurrentHashMap<>();\n    public static final Map<String, Class<?>> toolRequestMap = new ConcurrentHashMap<>();\n\n    // === 本地MCP工具缓存 ===\n    private static final Map<String, McpToolInfo> mcpToolCache = new ConcurrentHashMap<>();\n\n    // === 初始化状态 ===\n    private static volatile boolean initialized = false;\n\n    private static final ThreadLocal<Deque<BuiltInToolContext>> builtInToolContextStack =\n            new ThreadLocal<Deque<BuiltInToolContext>>();\n\n    /**\n     * 本地MCP工具信息类\n     */\n    private static class McpToolInfo {\n        final String toolId;  // 工具唯一标识符（符合OpenAI API命名规范）\n        final String description;\n        final Class<?> serviceClass;\n        final Method method;\n\n        McpToolInfo(String toolId, String description, Class<?> serviceClass, Method method) {\n            this.toolId = toolId;\n            this.description = description;\n            this.serviceClass = serviceClass;\n            this.method = method;\n        }\n    }\n\n    /**\n     * 统一工具调用入口（支持自动识别用户上下文）\n     */\n    public static String invoke(String functionName, String argument) {\n        return invoke(functionName, argument, currentBuiltInToolContext());\n    }\n\n    public static String invoke(String functionName, String argument, BuiltInToolContext builtInToolContext) {\n        ensureInitialized();\n\n        try {\n            String builtInResult = BuiltInToolExecutor.invoke(functionName, argument, builtInToolContext);\n            if (builtInResult != null) {\n                return builtInResult;\n            }\n\n            // 1. 检查是否为用户工具调用\n            String userId = extractUserIdFromFunctionName(functionName);\n            \n            if (userId != null) {\n                // 用户工具调用：user_123_tool_create_issue -> userId=123, toolName=create_issue\n                String actualToolName = extractActualToolName(functionName);\n                return callUserMcpTool(userId, actualToolName, argument);\n            }\n            \n            // 2. 全局工具调用流程\n            return callGlobalTool(functionName, argument);\n            \n        } catch (Exception e) {\n            throw new RuntimeException(\"工具调用失败: \" + functionName + \" - \" + e.getMessage(), e);\n        }\n    }\n    \n    /**\n     * 显式指定用户ID的调用方式\n     */\n    public static String invoke(String functionName, String argument, String userId) {\n        if (userId != null && !userId.isEmpty()) {\n            return callUserMcpTool(userId, functionName, argument);\n        } else {\n            return invoke(functionName, argument);\n        }\n    }\n\n    public static void pushBuiltInToolContext(BuiltInToolContext context) {\n        if (context == null) {\n            return;\n        }\n        Deque<BuiltInToolContext> stack = builtInToolContextStack.get();\n        if (stack == null) {\n            stack = new ArrayDeque<BuiltInToolContext>();\n            builtInToolContextStack.set(stack);\n        }\n        stack.push(context);\n    }\n\n    public static void popBuiltInToolContext() {\n        Deque<BuiltInToolContext> stack = builtInToolContextStack.get();\n        if (stack == null || stack.isEmpty()) {\n            builtInToolContextStack.remove();\n            return;\n        }\n        stack.pop();\n        if (stack.isEmpty()) {\n            builtInToolContextStack.remove();\n        }\n    }\n\n    private static BuiltInToolContext currentBuiltInToolContext() {\n        Deque<BuiltInToolContext> stack = builtInToolContextStack.get();\n        if (stack == null || stack.isEmpty()) {\n            return null;\n        }\n        return stack.peek();\n    }\n    \n\n    /**\n     * 从函数名中提取用户ID\n     * @param functionName 如 \"user_123_tool_create_issue\" 或 \"create_issue\"\n     * @return 用户ID或null\n     */\n    private static String extractUserIdFromFunctionName(String functionName) {\n        if (functionName != null && functionName.startsWith(\"user_\") && functionName.contains(\"_tool_\")) {\n            // 格式：user_{userId}_tool_{toolName}\n            String[] parts = functionName.split(\"_tool_\");\n            if (parts.length == 2) {\n                String userPart = parts[0]; // \"user_123\"\n                if (userPart.startsWith(\"user_\")) {\n                    return userPart.substring(5); // 提取 \"123\"\n                }\n            }\n        }\n        return null;\n    }\n\n    /**\n     * 调用用户MCP工具\n     */\n    private static String callUserMcpTool(String userId, String toolName, String argument) {\n        McpGateway gateway = McpGateway.getInstance();\n        \n        if (gateway == null || !gateway.isInitialized()) {\n            throw new RuntimeException(\"MCP网关未初始化\");\n        }\n        \n        try {\n            Object argumentObject = JSON.parseObject(argument);\n            return gateway.callUserTool(userId, toolName, argumentObject).join();\n        } catch (Exception e) {\n            throw new RuntimeException(\"用户MCP工具调用失败: \" + toolName, e);\n        }\n    }\n\n    /**\n     * 调用全局工具\n     */\n    private static String callGlobalTool(String functionName, String argument) {\n        // 1. 优先检查本地MCP工具\n        if (mcpToolCache.containsKey(functionName)) {\n            return invokeMcpTool(functionName, argument);\n        }\n\n        // 2. 检查传统Function工具\n        if (toolClassMap.containsKey(functionName) && toolRequestMap.containsKey(functionName)) {\n            return invokeFunctionTool(functionName, argument);\n        }\n\n        // 3. 尝试调用全局MCP服务\n        McpGateway gateway = McpGateway.getInstance();\n        if (gateway != null && gateway.isInitialized()) {\n            try {\n                Object argumentObject = JSON.parseObject(argument);\n                return gateway.callTool(functionName, argumentObject).join();\n            } catch (Exception e) {\n                // MCP调用失败，继续其他逻辑\n                log.debug(\"全局MCP工具调用失败: {}\", functionName, e);\n                throw new RuntimeException(e.getMessage());\n            }\n        }\n\n        throw new RuntimeException(\"工具未找到: \" + functionName);\n    }\n    /**\n     * 从用户工具名中提取实际工具名\n     * @param functionName 如 \"user_123_tool_create_issue\"\n     * @return 实际工具名 如 \"create_issue\"\n     */\n    private static String extractActualToolName(String functionName) {\n        if (functionName != null && functionName.contains(\"_tool_\")) {\n            String[] parts = functionName.split(\"_tool_\");\n            if (parts.length == 2) {\n                return parts[1]; // \"create_issue\"\n            }\n        }\n        return functionName;\n    }\n\n\n    /**\n     * 确保工具已初始化\n     */\n    private static void ensureInitialized() {\n        if (!initialized) {\n            synchronized (ToolUtil.class) {\n                if (!initialized) {\n                    scanAndRegisterAllTools();\n                    initialized = true;\n                }\n            }\n        }\n    }\n\n    /**\n     * 扫描并注册所有工具\n     */\n    private static void scanAndRegisterAllTools() {\n        log.info(\"开始扫描并注册所有工具...\");\n\n        // 扫描传统Function工具\n        scanFunctionTools();\n\n        // 扫描本地MCP工具\n        scanMcpTools();\n\n        log.info(\"工具扫描完成 - Function工具: {}, 本地MCP工具: {}\",\n                toolClassMap.size(), mcpToolCache.size());\n    }\n\n    /**\n     * 调用本地MCP工具\n     */\n    private static String invokeMcpTool(String functionName, String argument) {\n        McpToolInfo toolInfo = mcpToolCache.get(functionName);\n        if (toolInfo == null) {\n            throw new RuntimeException(\"本地MCP工具未找到: \" + functionName);\n        }\n\n        try {\n            log.info(\"调用本地MCP工具: {}, 参数: {}\", toolInfo.toolId, argument);\n\n            // 创建服务实例\n            Object serviceInstance = toolInfo.serviceClass.getDeclaredConstructor().newInstance();\n\n            // 解析参数\n            Map<String, Object> argumentMap = JSON.parseObject(argument);\n            if (argumentMap == null) {\n                argumentMap = new HashMap<>();\n            }\n\n            // 准备方法参数\n            Parameter[] parameters = toolInfo.method.getParameters();\n            Object[] methodArgs = new Object[parameters.length];\n\n            for (int i = 0; i < parameters.length; i++) {\n                Parameter param = parameters[i];\n                String paramName = getParameterName(param);\n                Object value = argumentMap.get(paramName);\n\n                // 类型转换\n                methodArgs[i] = convertParameterValue(value, param.getType());\n            }\n\n            // 调用方法\n            Object result = toolInfo.method.invoke(serviceInstance, methodArgs);\n            String response = JSON.toJSONString(result);\n\n            log.info(\"本地MCP工具调用成功: {} -> {}\", toolInfo.toolId, response);\n            return response;\n\n        } catch (Exception e) {\n            log.error(\"本地MCP工具调用失败: {}\", toolInfo.toolId, e);\n            throw new RuntimeException(\"本地MCP工具调用失败: \" + toolInfo.toolId, e);\n        }\n    }\n\n    /**\n     * 调用传统Function工具\n     */\n    private static String invokeFunctionTool(String functionName, String argument) {\n        Class<?> functionClass = toolClassMap.get(functionName);\n        Class<?> functionRequestClass = toolRequestMap.get(functionName);\n\n        log.info(\"Call Function Name: {}, arguments: {}\", functionName, argument);\n\n        try {\n            // 获取调用函数\n            Method apply = functionClass.getMethod(\"apply\", functionRequestClass);\n\n            // 解析参数\n            Object arg = JSON.parseObject(argument, functionRequestClass);\n\n            // 调用函数\n            Object functionInstance = functionClass.getDeclaredConstructor().newInstance();\n            Object result = apply.invoke(functionInstance, arg);\n\n            String response = JSON.toJSONString(result);\n            log.info(\"Call Function Success: {} -> {}\", functionName, response);\n            return response;\n\n        } catch (Exception e) {\n            log.error(\"Call Function Name: {} failed\", functionName, e);\n            throw new RuntimeException(\"Call Function Error: \" + functionName, e);\n        }\n    }\n\n    /**\n     * 调用远程MCP服务\n     */\n    private static String callRemoteMcpService(String functionName, String argument) {\n        try {\n            // 使用反射调用McpGateway，避免直接依赖\n            Class<?> mcpGatewayClass = Class.forName(\"io.github.lnyocly.ai4j.mcp.gateway.McpGateway\");\n            Method getInstanceMethod = mcpGatewayClass.getMethod(\"getInstance\");\n            Object mcpGateway = getInstanceMethod.invoke(null);\n\n            if (mcpGateway != null) {\n                Method isInitializedMethod = mcpGatewayClass.getMethod(\"isInitialized\");\n                Boolean isInitialized = (Boolean) isInitializedMethod.invoke(mcpGateway);\n\n                if (isInitialized) {\n                    Method callToolMethod = mcpGatewayClass.getMethod(\"callTool\", String.class, Object.class);\n\n                    // 解析参数为对象\n                    Object argumentObject;\n                    try {\n                        argumentObject = JSON.parseObject(argument);\n                    } catch (Exception e) {\n                        argumentObject = argument;\n                    }\n\n                    Object futureResult = callToolMethod.invoke(mcpGateway, functionName, argumentObject);\n\n                    if (futureResult instanceof java.util.concurrent.CompletableFuture) {\n                        return (String) ((java.util.concurrent.CompletableFuture<?>) futureResult)\n                                .get(30, java.util.concurrent.TimeUnit.SECONDS);\n                    }\n                }\n            }\n        } catch (ClassNotFoundException e) {\n            log.debug(\"未找到McpGateway类\");\n        } catch (Exception e) {\n            log.debug(\"远程MCP服务调用异常: {}\", e.getMessage());\n        }\n        return null;\n    }\n\n\n    /**\n     * 获取传统Function工具列表（保持向后兼容）\n     *\n     * @param functionList 需要获取的Function工具名称列表\n     * @return 工具列表\n     */\n    public static List<Tool> getAllFunctionTools(List<String> functionList) {\n        ensureInitialized();\n\n        List<Tool> tools = new ArrayList<>();\n\n        if (functionList == null || functionList.isEmpty()) {\n            return tools;\n        }\n\n        log.debug(\"获取{}个Function工具\", functionList.size());\n\n        for (String functionName : functionList) {\n            if (functionName == null || functionName.trim().isEmpty()) {\n                continue;\n            }\n\n            try {\n                Tool tool = toolEntityMap.get(functionName);\n                if (tool == null) {\n                    tool = getToolEntity(functionName);\n                    if (tool != null) {\n                        toolEntityMap.put(functionName, tool);\n                    }\n                }\n\n                if (tool != null) {\n                    tools.add(tool);\n                }\n            } catch (Exception e) {\n                log.error(\"获取Function工具失败: {}\", functionName, e);\n            }\n        }\n\n        log.info(\"获取Function工具完成: 请求{}个，成功{}个\", functionList.size(), tools.size());\n        return tools;\n    }\n\n    /**\n     * 根据MCP服务ID列表获取所有MCP工具\n     *\n     * @param mcpServerIds MCP服务ID列表\n     * @return 工具列表\n     */\n    public static List<Tool> getAllMcpTools(List<String> mcpServerIds) {\n        List<Tool> tools = new ArrayList<>();\n\n        if (mcpServerIds == null || mcpServerIds.isEmpty()) {\n            return tools;\n        }\n\n        log.debug(\"获取{}个MCP服务的工具\", mcpServerIds.size());\n\n        try {\n            // 使用反射调用McpGateway获取工具\n            Class<?> mcpGatewayClass = Class.forName(\"io.github.lnyocly.ai4j.mcp.gateway.McpGateway\");\n            Method getInstanceMethod = mcpGatewayClass.getMethod(\"getInstance\");\n            Object mcpGateway = getInstanceMethod.invoke(null);\n\n            if (mcpGateway != null) {\n                Method isInitializedMethod = mcpGatewayClass.getMethod(\"isInitialized\");\n                Boolean isInitialized = (Boolean) isInitializedMethod.invoke(mcpGateway);\n\n                if (isInitialized) {\n                    Method getAvailableToolsMethod = mcpGatewayClass.getMethod(\"getAvailableTools\");\n                    Object futureResult = getAvailableToolsMethod.invoke(mcpGateway);\n\n                    if (futureResult instanceof java.util.concurrent.CompletableFuture) {\n                        List<Tool.Function> mcpTools = castToolFunctions(\n                                ((java.util.concurrent.CompletableFuture<?>) futureResult)\n                                        .get(10, java.util.concurrent.TimeUnit.SECONDS)\n                        );\n\n                        // 转换为Tool对象\n                        for (Tool.Function function : mcpTools) {\n                            Tool tool = new Tool();\n                            tool.setType(\"function\");\n                            tool.setFunction(function);\n                            tools.add(tool);\n                        }\n                    }\n                }\n            }\n        } catch (Exception e) {\n            log.error(\"获取MCP工具失败\", e);\n        }\n\n        log.info(\"获取MCP工具完成: 请求{}个服务，获得{}个工具\", mcpServerIds.size(), tools.size());\n        return tools;\n    }\n\n   /**\n     * 获取所有工具（自动识别用户上下文）\n     */\n    public static List<Tool> getAllTools(List<String> functionList, List<String> mcpServerIds) {\n        // fix 启动MCP服务工具未初始化\n        ensureInitialized();\n\n        List<Tool> allTools = new ArrayList<>();\n\n        if (functionList != null && !functionList.isEmpty()) {\n            allTools.addAll(getAllFunctionTools(functionList));\n        }\n\n        if (mcpServerIds != null && !mcpServerIds.isEmpty()) {\n            String userId = extractUserIdFromServiceIds(mcpServerIds);\n            if (userId != null) {\n                List<String> actualServiceIds = extractActualServiceIds(mcpServerIds);\n                allTools.addAll(getUserMcpTools(actualServiceIds, userId));\n            } else {\n                allTools.addAll(getGlobalMcpTools(mcpServerIds));\n            }\n        }\n\n        return allTools;\n    }\n\n    /**\n     * 显式指定用户ID的工具获取\n     */\n    public static List<Tool> getAllTools(List<String> functionList, List<String> mcpServerIds, String userId) {\n        ensureInitialized();\n        List<Tool> allTools = new ArrayList<>();\n\n        if (functionList != null && !functionList.isEmpty()) {\n            allTools.addAll(getAllFunctionTools(functionList));\n        }\n\n        if (mcpServerIds != null && !mcpServerIds.isEmpty()) {\n            if (userId != null && !userId.isEmpty()) {\n                allTools.addAll(getUserMcpTools(mcpServerIds, userId));\n            } else {\n                allTools.addAll(getGlobalMcpTools(mcpServerIds));\n            }\n        }\n\n        return allTools;\n    }\n\n    /**\n     * Get all local MCP tools.\n     */\n    public static List<Tool> getLocalMcpTools() {\n        ensureInitialized();\n        List<Tool> tools = new ArrayList<>();\n        for (McpToolInfo mcpTool : mcpToolCache.values()) {\n            Tool tool = createMcpToolEntity(mcpTool);\n            if (tool != null) {\n                tools.add(tool);\n            }\n        }\n        return tools;\n    }\n\n    /**\n     * 获取全局MCP工具\n     */\n    private static List<Tool> getGlobalMcpTools(List<String> serviceIds) {\n        List<Tool> tools = new ArrayList<>();\n        \n        McpGateway gateway = McpGateway.getInstance();\n        if (gateway == null || !gateway.isInitialized()) {\n            log.warn(\"MCP网关未初始化，无法获取全局MCP工具\");\n            return tools;\n        }\n        \n        try {\n            List<Tool.Function> mcpTools = gateway.getAvailableTools(serviceIds).join();\n            \n            // 转换为Tool对象\n            for (Tool.Function function : mcpTools) {\n                Tool tool = new Tool();\n                tool.setType(\"function\");\n                tool.setFunction(function);\n                tools.add(tool);\n            }\n        } catch (Exception e) {\n            log.error(\"获取全局MCP工具失败\", e);\n        }\n\n        return tools;\n    }\n    /**\n     * 从服务ID列表中提取用户ID\n     * @param serviceIds 如 [\"user_123_service_github\", \"user_123_service_slack\"]\n     * @return 用户ID或null\n     */\n    private static String extractUserIdFromServiceIds(List<String> serviceIds) {\n        for (String serviceId : serviceIds) {\n            if (serviceId != null && serviceId.startsWith(\"user_\") && serviceId.contains(\"_service_\")) {\n                String[] parts = serviceId.split(\"_service_\");\n                if (parts.length == 2) {\n                    String userPart = parts[0]; // \"user_123\"\n                    if (userPart.startsWith(\"user_\")) {\n                        return userPart.substring(5); // 提取 \"123\"\n                    }\n                }\n            }\n        }\n        return null;\n    }\n\n    /**\n     * 从用户服务ID列表中提取实际服务ID\n     * @param serviceIds 如 [\"user_123_service_github\", \"user_123_service_slack\"]\n     * @return 实际服务ID列表 如 [\"github\", \"slack\"]\n     */\n    private static List<String> extractActualServiceIds(List<String> serviceIds) {\n        List<String> actualIds = new ArrayList<>();\n        for (String serviceId : serviceIds) {\n            if (serviceId != null && serviceId.contains(\"_service_\")) {\n                String[] parts = serviceId.split(\"_service_\");\n                if (parts.length == 2) {\n                    actualIds.add(parts[1]); // 提取实际的serviceId\n                }\n            } else {\n                actualIds.add(serviceId); // 全局服务ID\n            }\n        }\n        return actualIds;\n    }\n\n   /**\n     * 获取用户MCP工具\n     */\n    private static List<Tool> getUserMcpTools(List<String> serviceIds, String userId) {\n        List<Tool> tools = new ArrayList<>();\n        \n        McpGateway gateway = McpGateway.getInstance();\n        if (gateway == null || !gateway.isInitialized()) {\n            log.warn(\"MCP网关未初始化，无法获取用户MCP工具\");\n            return tools;\n        }\n        \n        try {\n            List<Tool.Function> mcpTools = gateway.getUserAvailableTools(serviceIds, userId).join();\n            \n            // 转换为Tool对象\n            for (Tool.Function function : mcpTools) {\n                Tool tool = new Tool();\n                tool.setType(\"function\");\n                tool.setFunction(function);\n                tools.add(tool);\n            }\n        } catch (Exception e) {\n            log.error(\"获取用户MCP工具失败: userId={}\", userId, e);\n        }\n\n        return tools;\n    }\n    \n    /**\n     * 扫描传统Function工具\n     */\n    private static void scanFunctionTools() {\n        try {\n            Set<Class<?>> functionSet = reflections.getTypesAnnotatedWith(FunctionCall.class);\n\n            for (Class<?> functionClass : functionSet) {\n                try {\n                    FunctionCall functionCall = functionClass.getAnnotation(FunctionCall.class);\n                    if (functionCall != null) {\n                        String functionName = functionCall.name();\n                        toolClassMap.put(functionName, functionClass);\n\n                        // 查找Request类\n                        Class<?>[] innerClasses = functionClass.getDeclaredClasses();\n                        for (Class<?> innerClass : innerClasses) {\n                            if (innerClass.getAnnotation(FunctionRequest.class) != null) {\n                                toolRequestMap.put(functionName, innerClass);\n                                break;\n                            }\n                        }\n\n                        log.debug(\"注册Function工具: {}\", functionName);\n                    }\n                } catch (Exception e) {\n                    log.error(\"处理Function类失败: {}\", functionClass.getName(), e);\n                }\n            }\n\n            log.info(\"扫描Function工具完成: {}个\", toolClassMap.size());\n        } catch (Exception e) {\n            log.error(\"扫描Function工具失败\", e);\n        }\n    }\n\n    /**\n     * 扫描本地MCP工具\n     */\n    private static void scanMcpTools() {\n        try {\n            Set<Class<?>> mcpServiceClasses = reflections.getTypesAnnotatedWith(McpService.class);\n\n            for (Class<?> serviceClass : mcpServiceClasses) {\n                try {\n                    McpService mcpService = serviceClass.getAnnotation(McpService.class);\n                    String serviceName = mcpService.name().isEmpty() ?\n                            serviceClass.getSimpleName() : mcpService.name();\n\n                    // 扫描方法\n                    Method[] methods = serviceClass.getDeclaredMethods();\n                    for (Method method : methods) {\n                        McpTool mcpTool = method.getAnnotation(McpTool.class);\n                        if (mcpTool != null) {\n                            String toolName = mcpTool.name().isEmpty() ?\n                                    method.getName() : mcpTool.name();\n\n                            // 统一使用API友好的命名方式（下划线分隔）\n                            String toolId = generateApiFunctionName(serviceName, toolName);\n\n                            McpToolInfo toolInfo = new McpToolInfo(\n                                    toolId, mcpTool.description(), serviceClass, method);\n                            mcpToolCache.put(toolId, toolInfo);\n\n                            log.debug(\"注册本地MCP工具: {}\", toolId);\n                        }\n                    }\n                } catch (Exception e) {\n                    log.error(\"处理MCP服务类失败: {}\", serviceClass.getName(), e);\n                }\n            }\n\n            log.info(\"扫描本地MCP工具完成: {}个\", mcpToolCache.size());\n        } catch (Exception e) {\n            log.error(\"扫描本地MCP工具失败\", e);\n        }\n    }\n\n    /**\n     * 获取工具实体对象\n     */\n    public static Tool getToolEntity(String functionName) {\n        if (functionName == null || functionName.trim().isEmpty()) {\n            return null;\n        }\n\n        try {\n            Tool.Function functionEntity = getFunctionEntity(functionName);\n            if (functionEntity != null) {\n                Tool tool = new Tool();\n                tool.setType(\"function\");\n                tool.setFunction(functionEntity);\n                return tool;\n            }\n        } catch (Exception e) {\n            log.error(\"创建工具实体失败: {}\", functionName, e);\n        }\n        return null;\n    }\n\n\n    /**\n     * 创建本地MCP工具实体\n     */\n    private static Tool createMcpToolEntity(McpToolInfo mcpTool) {\n        try {\n            Tool.Function function = new Tool.Function();\n            function.setName(mcpTool.toolId);  // 使用工具ID作为函数名\n            function.setDescription(mcpTool.description);\n\n            // 生成参数定义\n            Map<String, Tool.Function.Property> parameters = new HashMap<>();\n            List<String> requiredParameters = new ArrayList<>();\n\n            Parameter[] methodParams = mcpTool.method.getParameters();\n            for (Parameter param : methodParams) {\n                McpParameter mcpParam = param.getAnnotation(McpParameter.class);\n                String paramName = getParameterName(param);\n\n                Tool.Function.Property property = createPropertyFromType(param.getType(),\n                        mcpParam != null ? mcpParam.description() : \"\");\n                parameters.put(paramName, property);\n\n                if (mcpParam == null || mcpParam.required()) {\n                    requiredParameters.add(paramName);\n                }\n            }\n\n            Tool.Function.Parameter parameter = new Tool.Function.Parameter(\"object\", parameters, requiredParameters);\n            function.setParameters(parameter);\n\n            Tool tool = new Tool();\n            tool.setType(\"function\");\n            tool.setFunction(function);\n            return tool;\n\n        } catch (Exception e) {\n            log.error(\"创建MCP工具实体失败: {}\", mcpTool.toolId, e);\n            return null;\n        }\n    }\n\n    /**\n     * 获取Function实体定义\n     */\n    public static Tool.Function getFunctionEntity(String functionName) {\n        if (functionName == null || functionName.trim().isEmpty()) {\n            return null;\n        }\n\n        try {\n            Set<Class<?>> functionSet = reflections.getTypesAnnotatedWith(FunctionCall.class);\n\n            for (Class<?> functionClass : functionSet) {\n                try {\n                    FunctionCall functionCall = functionClass.getAnnotation(FunctionCall.class);\n                    if (functionCall != null && functionCall.name().equals(functionName)) {\n                        Tool.Function function = new Tool.Function();\n                        function.setName(functionCall.name());\n                        function.setDescription(functionCall.description());\n\n                        setFunctionParameters(function, functionClass);\n                        toolClassMap.put(functionName, functionClass);\n                        return function;\n                    }\n                } catch (Exception e) {\n                    log.error(\"处理Function类失败: {}\", functionClass.getName(), e);\n                }\n            }\n        } catch (Exception e) {\n            log.error(\"获取Function实体失败: {}\", functionName, e);\n        }\n        return null;\n    }\n\n\n\n    /**\n     * 生成符合OpenAI API规范的函数名\n     * 规则：只能包含字母、数字、下划线和连字符，长度不超过64个字符\n     */\n    private static String generateApiFunctionName(String serviceName, String toolName) {\n        // 将服务名和工具名组合，使用下划线连接\n        String combined = serviceName + \"_\" + toolName;\n\n        // 替换不符合规范的字符\n        String normalized = combined\n                .replaceAll(\"[^a-zA-Z0-9_-]\", \"_\")  // 替换非法字符为下划线\n                .replaceAll(\"_{2,}\", \"_\")           // 多个连续下划线替换为单个\n                .replaceAll(\"^_+|_+$\", \"\");         // 移除开头和结尾的下划线\n\n        // 确保长度不超过64个字符\n        if (normalized.length() > 64) {\n            normalized = normalized.substring(0, 64);\n        }\n\n        // 确保不为空且以字母开头\n        if (normalized.isEmpty() || !Character.isLetter(normalized.charAt(0))) {\n            normalized = \"tool_\" + normalized;\n        }\n\n        return normalized;\n    }\n\n    /**\n     * 获取参数名称\n     */\n    private static String getParameterName(Parameter param) {\n        McpParameter mcpParam = param.getAnnotation(McpParameter.class);\n        if (mcpParam != null && !mcpParam.name().isEmpty()) {\n            return mcpParam.name();\n        }\n        return param.getName();\n    }\n\n    /**\n     * 参数值类型转换\n     */\n    private static Object convertParameterValue(Object value, Class<?> targetType) {\n        if (value == null) {\n            return null;\n        }\n\n        if (targetType.isAssignableFrom(value.getClass())) {\n            return value;\n        }\n\n        String strValue = value.toString();\n\n        if (targetType == String.class) {\n            return strValue;\n        } else if (targetType == Integer.class || targetType == int.class) {\n            return Integer.valueOf(strValue);\n        } else if (targetType == Long.class || targetType == long.class) {\n            return Long.valueOf(strValue);\n        } else if (targetType == Boolean.class || targetType == boolean.class) {\n            return Boolean.valueOf(strValue);\n        } else if (targetType == Double.class || targetType == double.class) {\n            return Double.valueOf(strValue);\n        } else if (targetType == Float.class || targetType == float.class) {\n            return Float.valueOf(strValue);\n        }\n\n        return value;\n    }\n\n    /**\n     * 设置Function参数定义\n     */\n    private static void setFunctionParameters(Tool.Function function, Class<?> functionClass) {\n        try {\n            Class<?>[] classes = functionClass.getDeclaredClasses();\n            Map<String, Tool.Function.Property> parameters = new HashMap<>();\n            List<String> requiredParameters = new ArrayList<>();\n\n            for (Class<?> clazz : classes) {\n                FunctionRequest request = clazz.getAnnotation(FunctionRequest.class);\n                if (request == null) {\n                    continue;\n                }\n\n                toolRequestMap.put(function.getName(), clazz);\n\n                Field[] fields = clazz.getDeclaredFields();\n                for (Field field : fields) {\n                    FunctionParameter parameter = field.getAnnotation(FunctionParameter.class);\n                    if (parameter == null) {\n                        continue;\n                    }\n\n                    Tool.Function.Property property = createPropertyFromType(field.getType(), parameter.description());\n                    parameters.put(field.getName(), property);\n\n                    if (parameter.required()) {\n                        requiredParameters.add(field.getName());\n                    }\n                }\n            }\n\n            Tool.Function.Parameter parameter = new Tool.Function.Parameter(\"object\", parameters, requiredParameters);\n            function.setParameters(parameter);\n\n        } catch (Exception e) {\n            log.error(\"设置Function参数失败: {}\", function.getName(), e);\n            throw new RuntimeException(\"设置Function参数失败: \" + function.getName(), e);\n        }\n    }\n\n    /**\n     * 从类型创建属性对象\n     */\n    private static Tool.Function.Property createPropertyFromType(Class<?> fieldType, String description) {\n        Tool.Function.Property property = new Tool.Function.Property();\n\n        if (fieldType.isEnum()) {\n            property.setType(\"string\");\n            property.setEnumValues(getEnumValues(fieldType));\n        } else if (fieldType.equals(String.class)) {\n            property.setType(\"string\");\n        } else if (fieldType.equals(int.class) || fieldType.equals(Integer.class) ||\n                fieldType.equals(long.class) || fieldType.equals(Long.class) ||\n                fieldType.equals(short.class) || fieldType.equals(Short.class)) {\n            property.setType(\"integer\");\n        } else if (fieldType.equals(float.class) || fieldType.equals(Float.class) ||\n                fieldType.equals(double.class) || fieldType.equals(Double.class)) {\n            property.setType(\"number\");\n        } else if (fieldType.equals(boolean.class) || fieldType.equals(Boolean.class)) {\n            property.setType(\"boolean\");\n        } else if (fieldType.isArray() || Collection.class.isAssignableFrom(fieldType)) {\n            property.setType(\"array\");\n\n            Tool.Function.Property items = new Tool.Function.Property();\n            Class<?> elementType = getArrayElementType(fieldType);\n            if (elementType != null) {\n                if (elementType == String.class) {\n                    items.setType(\"string\");\n                } else if (elementType == Integer.class || elementType == int.class ||\n                           elementType == Long.class || elementType == long.class) {\n                    items.setType(\"integer\");\n                } else if (elementType == Double.class || elementType == double.class ||\n                           elementType == Float.class || elementType == float.class) {\n                    items.setType(\"number\");\n                } else if (elementType == Boolean.class || elementType == boolean.class) {\n                    items.setType(\"boolean\");\n                } else {\n                    items.setType(\"object\");\n                }\n            } else {\n                items.setType(\"object\");\n            }\n            property.setItems(items);\n        } else if (Map.class.isAssignableFrom(fieldType)) {\n            property.setType(\"object\");\n        } else {\n            property.setType(\"object\");\n        }\n\n        property.setDescription(description);\n        return property;\n    }\n\n    /**\n     * 获取数组元素类型\n     */\n    private static Class<?> getArrayElementType(Class<?> arrayType) {\n        if (arrayType.isArray()) {\n            return arrayType.getComponentType();\n        } else if (Collection.class.isAssignableFrom(arrayType)) {\n            return null; // 泛型擦除，无法获取确切类型\n        }\n        return null;\n    }\n\n    /**\n     * 获取枚举类型的所有可能值\n     */\n    private static List<String> getEnumValues(Class<?> enumType) {\n        List<String> enumValues = new ArrayList<>();\n        for (Object enumConstant : enumType.getEnumConstants()) {\n            enumValues.add(enumConstant.toString());\n        }\n        return enumValues;\n    }\n\n    // ========== 向后兼容方法 ==========\n\n    /**\n     * 向后兼容的统一工具调用方法\n     * @deprecated 请使用 invoke(String, String) 方法\n     */\n    @Deprecated\n    public static String invokeUnified(String functionName, String argument) {\n        return invoke(functionName, argument);\n    }\n\n    private static List<Tool.Function> castToolFunctions(Object value) {\n        List<Tool.Function> functions = new ArrayList<>();\n        if (!(value instanceof List<?>)) {\n            return functions;\n        }\n        for (Object item : (List<?>) value) {\n            if (item instanceof Tool.Function) {\n                functions.add((Tool.Function) item);\n            }\n        }\n        return functions;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tools/ApplyPatchFunction.java",
    "content": "package io.github.lnyocly.ai4j.tools;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport io.github.lnyocly.ai4j.tool.BuiltInToolExecutor;\nimport io.github.lnyocly.ai4j.tool.BuiltInTools;\nimport lombok.Data;\n\nimport java.util.function.Function;\n\n@FunctionCall(name = \"apply_patch\", description = \"Apply a structured patch to workspace files.\")\npublic class ApplyPatchFunction implements Function<ApplyPatchFunction.Request, String> {\n\n    @Override\n    public String apply(Request request) {\n        try {\n            return BuiltInToolExecutor.invoke(BuiltInTools.APPLY_PATCH, JSON.toJSONString(request), null);\n        } catch (Exception ex) {\n            throw new RuntimeException(\"apply_patch failed\", ex);\n        }\n    }\n\n    @Data\n    @FunctionRequest\n    public static class Request {\n        @FunctionParameter(description = \"Patch text to apply. Must include *** Begin Patch and *** End Patch envelope.\")\n        private String patch;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tools/BashFunction.java",
    "content": "package io.github.lnyocly.ai4j.tools;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport io.github.lnyocly.ai4j.tool.BuiltInToolExecutor;\nimport io.github.lnyocly.ai4j.tool.BuiltInTools;\nimport lombok.Data;\n\nimport java.util.function.Function;\n\n@FunctionCall(name = \"bash\", description = \"Execute non-interactive shell commands or manage interactive/background shell processes inside the workspace.\")\npublic class BashFunction implements Function<BashFunction.Request, String> {\n\n    @Override\n    public String apply(Request request) {\n        try {\n            return BuiltInToolExecutor.invoke(BuiltInTools.BASH, JSON.toJSONString(request), null);\n        } catch (Exception ex) {\n            throw new RuntimeException(\"bash failed\", ex);\n        }\n    }\n\n    @Data\n    @FunctionRequest\n    public static class Request {\n        @FunctionParameter(description = \"bash action to perform: exec, start, status, logs, write, stop, list.\")\n        private String action;\n        @FunctionParameter(description = \"Command string to execute.\")\n        private String command;\n        @FunctionParameter(description = \"Relative working directory inside the workspace.\")\n        private String cwd;\n        @FunctionParameter(description = \"Execution timeout in milliseconds for exec.\")\n        private Long timeoutMs;\n        @FunctionParameter(description = \"Background process identifier.\")\n        private String processId;\n        @FunctionParameter(description = \"Log cursor offset.\")\n        private Long offset;\n        @FunctionParameter(description = \"Maximum log characters to return.\")\n        private Integer limit;\n        @FunctionParameter(description = \"Text written to stdin for a background process.\")\n        private String input;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tools/QueryTrainInfoFunction.java",
    "content": "package io.github.lnyocly.ai4j.tools;\n\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport lombok.Data;\n\nimport java.util.function.Function;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/12 14:45\n */\n@FunctionCall(name = \"queryTrainInfo\", description = \"查询火车是否发车信息\")\npublic class QueryTrainInfoFunction implements Function<QueryTrainInfoFunction.Request, String> {\n\n    @Data\n    @FunctionRequest\n    public static class Request {\n        @FunctionParameter(description = \"根据天气的情况进行查询是否发车，此参数为天气的最高气温\")\n        Integer type;\n    }\n    public enum Type{\n        hao,\n        cha\n    }\n\n    @Override\n    public String apply(Request request) {\n        if (request.type > 35) {\n            return \"天气情况正常，允许发车\";\n        }\n        return \"天气情况较差，不允许发车\";\n    }\n    @Data\n    public static class Response {\n        String orderId;\n    }\n\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tools/QueryWeatherFunction.java",
    "content": "package io.github.lnyocly.ai4j.tools;\n\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport lombok.Data;\nimport okhttp3.*;\n\nimport java.util.function.Function;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/13 17:40\n */\n@FunctionCall(name = \"queryWeather\", description = \"查询目标地点的天气预报\")\npublic class QueryWeatherFunction implements Function<QueryWeatherFunction.Request, String> {\n    @Override\n    public String apply(Request request) {\n        final String key = \"S3zzVyAdJjEeB18Gw\";\n        // https://api.seniverse.com/v3/weather/hourly.json?key=your_api_key&location=beijing&start=0&hours=24\n        // https://api.seniverse.com/v3/weather/daily.json?key=your_api_key&location=beijing&start=0&days=5\n        String url = String.format(\"https://api.seniverse.com/v3/weather/%s.json?key=%s&location=%s&days=%d\",\n                request.type.name(),\n                key,\n                request.location,\n                request.days);\n\n\n        OkHttpClient client = new OkHttpClient();\n\n        okhttp3.Request http = new okhttp3.Request.Builder()\n                .url(url)\n                .get()\n                .build();\n\n        try (Response response = client.newCall(http).execute()) {\n            if (response.isSuccessful()) {\n                // 解析响应体\n                return response.body() != null ? response.body().string() : \"\";\n            } else {\n                return \"获取天气失败 当前天气未知\";\n            }\n        } catch (Exception e) {\n            // 处理异常\n            e.printStackTrace();\n            return \"获取天气失败 当前天气未知\";\n        }\n    }\n\n    @Data\n    @FunctionRequest\n    public static class Request{\n        @FunctionParameter(description = \"需要查询天气的目标位置, 可以是城市中文名、城市拼音/英文名、省市名称组合、IP 地址、经纬度\")\n        private String location;\n        @FunctionParameter(description = \"需要查询未来天气的天数, 最多15日\")\n        private int days = 15;\n        @FunctionParameter(description = \"预报的天气类型，daily表示预报多天天气、hourly表示预测当天24天气、now为当前天气实况\")\n        private Type type;\n    }\n\n    public enum Type{\n        daily,\n        hourly,\n        now\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tools/ReadFileFunction.java",
    "content": "package io.github.lnyocly.ai4j.tools;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport io.github.lnyocly.ai4j.tool.BuiltInToolExecutor;\nimport io.github.lnyocly.ai4j.tool.BuiltInTools;\nimport lombok.Data;\n\nimport java.util.function.Function;\n\n@FunctionCall(name = \"read_file\", description = \"Read a text file from the workspace or from an approved read-only skill directory.\")\npublic class ReadFileFunction implements Function<ReadFileFunction.Request, String> {\n\n    @Override\n    public String apply(Request request) {\n        try {\n            return BuiltInToolExecutor.invoke(BuiltInTools.READ_FILE, JSON.toJSONString(request), null);\n        } catch (Exception ex) {\n            throw new RuntimeException(\"read_file failed\", ex);\n        }\n    }\n\n    @Data\n    @FunctionRequest\n    public static class Request {\n        @FunctionParameter(description = \"Relative file path inside the workspace, or an absolute path inside an approved read-only skill root.\")\n        private String path;\n        @FunctionParameter(description = \"First line number to read, starting from 1.\")\n        private Integer startLine;\n        @FunctionParameter(description = \"Last line number to read, inclusive.\")\n        private Integer endLine;\n        @FunctionParameter(description = \"Maximum characters to return.\")\n        private Integer maxChars;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/tools/WriteFileFunction.java",
    "content": "package io.github.lnyocly.ai4j.tools;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport io.github.lnyocly.ai4j.tool.BuiltInToolExecutor;\nimport io.github.lnyocly.ai4j.tool.BuiltInTools;\nimport lombok.Data;\n\nimport java.util.function.Function;\n\n@FunctionCall(name = \"write_file\", description = \"Create, overwrite, or append a text file.\")\npublic class WriteFileFunction implements Function<WriteFileFunction.Request, String> {\n\n    @Override\n    public String apply(Request request) {\n        try {\n            return BuiltInToolExecutor.invoke(BuiltInTools.WRITE_FILE, JSON.toJSONString(request), null);\n        } catch (Exception ex) {\n            throw new RuntimeException(\"write_file failed\", ex);\n        }\n    }\n\n    @Data\n    @FunctionRequest\n    public static class Request {\n        @FunctionParameter(description = \"File path to write. Relative paths resolve from the workspace root; absolute paths are allowed.\")\n        private String path;\n        @FunctionParameter(description = \"Full text content to write.\")\n        private String content;\n        @FunctionParameter(description = \"Write mode: create, overwrite, append.\")\n        private String mode;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/VectorDataEntity.java",
    "content": "package io.github.lnyocly.ai4j.vector;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/14 18:23\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\npublic class VectorDataEntity {\n\n    /**\n     * 分段后的每一段的向量\n     */\n    private List<List<Float>> vector;\n\n    /**\n     *  每一段的内容\n     */\n    private List<String> content;\n\n    /**\n     *  总共token数量\n     */\n    //private Integer total_token;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeDelete.java",
    "content": "package io.github.lnyocly.ai4j.vector.pinecone;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/15 0:00\n */\n\n@Builder\n@Data\npublic class PineconeDelete {\n    private List<String> ids;\n\n    private boolean deleteAll;\n\n    private String namespace;\n\n    private Map<String, String> filter;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeInsert.java",
    "content": "package io.github.lnyocly.ai4j.vector.pinecone;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/14 20:08\n */\n\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@Builder\npublic class PineconeInsert {\n    /**\n     *  需要插入的文本的向量库\n     */\n    private List<PineconeVectors> vectors;\n\n    /**\n     *  命名空间，用于区分每个文本\n     */\n    private String namespace;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeInsertResponse.java",
    "content": "package io.github.lnyocly.ai4j.vector.pinecone;\n\nimport lombok.Data;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/16 17:28\n */\n@Data\npublic class PineconeInsertResponse {\n    private Integer upsertedCount;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeQuery.java",
    "content": "package io.github.lnyocly.ai4j.vector.pinecone;\n\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NonNull;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/14 23:59\n */\n\n@Data\n@Builder\npublic class PineconeQuery {\n\n    /**\n     *  命名空间\n     */\n    @NonNull\n    private String namespace;\n\n    /**\n     *  需要最相似的前K条向量\n     */\n    @Builder.Default\n    private Integer topK = 10;\n\n    /**\n     *  可用于对metadata进行过滤\n     */\n    private Map<String, String> filter;\n\n    /**\n     *  指示响应中是否包含向量值\n\n     */\n    @Builder.Default\n    private Boolean includeValues = true;\n\n    /**\n     *  指示响应中是否包含元数据以及id\n     */\n    @Builder.Default\n    private Boolean includeMetadata = true;\n\n    /**\n     *  查询向量\n     */\n    @NonNull\n    private List<Float> vector;\n\n    /**\n     *  向量稀疏数据。表示为索引列表和对应值列表，它们必须具有相同的长度\n     */\n    private Map<String, String> sparseVector;\n\n    /**\n     *  每条向量独一无二的id\n     */\n    private String id;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeQueryResponse.java",
    "content": "package io.github.lnyocly.ai4j.vector.pinecone;\n\nimport lombok.Data;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/15 0:05\n */\n\n@Data\npublic class PineconeQueryResponse {\n    private List<String> results;\n\n    /**\n     *  匹配的结果\n     */\n    private List<Match> matches;\n\n    /**\n     *  命名空间\n     */\n    private String namespace;\n\n    @Data\n    public static class Match {\n        /**\n         * 向量id\n         */\n        private String id;\n\n        /**\n         *  相似度分数\n         */\n        private Float score;\n\n        /**\n         *  向量\n         */\n        private List<Float> values;\n\n        /**\n         *  向量的元数据，存放对应文本\n         */\n        private Map<String, String> metadata;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/pinecone/PineconeVectors.java",
    "content": "package io.github.lnyocly.ai4j.vector.pinecone;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/14 20:07\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@Builder\npublic class PineconeVectors {\n    /**\n     *  每条向量的id\n     */\n    private String id;\n\n    /**\n     *  分段后每一段的向量\n     */\n    private List<Float> values;\n\n    /**\n     * 向量稀疏数据。表示为索引列表和对应值列表，它们必须具有相同的长度。\n     */\n    //private Map<String, String> sparseValues;\n\n    /**\n     *  元数据，可以用来存储向量对应的文本 { key: \"content\", value: \"对应文本\" }\n     */\n    private Map<String, String> metadata;\n\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/service/PineconeService.java",
    "content": "package io.github.lnyocly.ai4j.vector.service;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.PineconeConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport io.github.lnyocly.ai4j.vector.VectorDataEntity;\nimport io.github.lnyocly.ai4j.vector.pinecone.*;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.*;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.stream.Collectors;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/16 17:09\n */\n@Slf4j\npublic class PineconeService {\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private final PineconeConfig pineconeConfig;\n    private final OkHttpClient okHttpClient;\n\n\n    public PineconeService(Configuration configuration) {\n        this.pineconeConfig = configuration.getPineconeConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n    public PineconeService(Configuration configuration, PineconeConfig pineconeConfig) {\n        this.pineconeConfig = pineconeConfig;\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n    // 插入Pinecone向量库\n    public Integer insert(PineconeInsert pineconeInsertReq){\n        Request request = new Request.Builder()\n                .url(UrlUtils.concatUrl(pineconeConfig.getHost(), pineconeConfig.getUpsert()))\n                .post(jsonBody(pineconeInsertReq))\n                .header(\"accept\", Constants.APPLICATION_JSON)\n                .header(\"content-type\", Constants.APPLICATION_JSON)\n                .header(\"Api-Key\", pineconeConfig.getKey())\n                .build();\n\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (!response.isSuccessful()) {\n                log.error(\"Error inserting into Pinecone vector store: {}\", response.message());\n                throw new CommonException(\"Error inserting into Pinecone: \" + response.message());\n            }\n\n            // {\"upsertedCount\":3}\n            return JSON.parseObject(response.body().string(), PineconeInsertResponse.class).getUpsertedCount();\n        } catch (Exception e) {\n            log.error(\"OkHttpClient exception! {}\", e.getMessage(), e);\n            throw new CommonException(\"Failed to insert into Pinecone due to network error.\" + e.getMessage());\n        }\n    }\n\n    public Integer insert(VectorDataEntity vectorDataEntity, String namespace) {\n        int count = vectorDataEntity.getContent().size();\n        List<PineconeVectors> pineconeVectors = new ArrayList<>();\n        // 生成每个向量的id\n        List<String> ids = generateIDs(count);\n        // 生成每个向量对应的文本,元数据，kv\n        List<Map<String, String>> metadatas = generateContent(vectorDataEntity.getContent());\n\n        for(int i = 0;i < count; ++i){\n            pineconeVectors.add(new PineconeVectors(ids.get(i), vectorDataEntity.getVector().get(i), metadatas.get(i)));\n        }\n        PineconeInsert pineconeInsert = new PineconeInsert(pineconeVectors, namespace);\n        return this.insert(pineconeInsert);\n    }\n\n    // 从Pinecone向量库中查询相似向量\n    public PineconeQueryResponse query(PineconeQuery pineconeQueryReq){\n        Request request = new Request.Builder()\n                .url(UrlUtils.concatUrl(pineconeConfig.getHost(), pineconeConfig.getQuery()))\n                .post(jsonBody(pineconeQueryReq))\n                .header(\"accept\", Constants.APPLICATION_JSON)\n                .header(\"content-type\", Constants.APPLICATION_JSON)\n                .header(\"Api-Key\", pineconeConfig.getKey())\n                .build();\n\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (!response.isSuccessful()) {\n                log.error(\"Error querying Pinecone vector store: {}\", response.message());\n                throw new CommonException(\"Error querying Pinecone: \" + response.message());\n            }\n\n            String body = response.body().string();\n            return JSON.parseObject(body, PineconeQueryResponse.class);\n        } catch (IOException e) {\n            log.error(\"OkHttpClient exception! {}\", e.getMessage(), e);\n            throw new CommonException(\"Failed to query Pinecone due to network error.\" + e.getMessage());\n        }\n    }\n\n    public String query(PineconeQuery pineconeQuery, String delimiter){\n        PineconeQueryResponse queryResponse = this.query(pineconeQuery);\n        if(delimiter == null) delimiter = \"\";\n        return queryResponse.getMatches().stream().map(match -> match.getMetadata().get(Constants.METADATA_KEY)).collect(Collectors.joining(delimiter));\n    }\n\n    // 从Pinecone向量库中删除向量\n    public Boolean delete(PineconeDelete pineconeDeleteReq){\n        Request request = new Request.Builder()\n                .url(UrlUtils.concatUrl(pineconeConfig.getHost(), pineconeConfig.getDelete()))\n                .post(jsonBody(pineconeDeleteReq))\n                .header(\"accept\", Constants.APPLICATION_JSON)\n                .header(\"content-type\", Constants.APPLICATION_JSON)\n                .header(\"Api-Key\", pineconeConfig.getKey())\n                .build();\n\n        try (Response response = okHttpClient.newCall(request).execute()) {\n            if (!response.isSuccessful()) {\n                log.error(\"Error deleting from Pinecone vector store: {}\", response.message());\n                throw new CommonException(\"Error deleting from Pinecone: \" + response.message());\n            }\n            return true;\n        } catch (IOException e) {\n            log.error(\"OkHttpClient exception! {}\", e.getMessage(), e);\n            throw new CommonException(\"Failed to delete from Pinecone due to network error.\" + e.getMessage());\n        }\n    }\n\n    // 生成每个向量的id\n    public List<String> generateIDs(int count){\n        List<String> ids = new ArrayList<>();\n        for (long i = 0L; i < count; ++i) {\n            ids.add(\"id_\" + i);\n        }\n        return ids;\n    }\n\n\n    // 生成每个向量对应的文本\n    public List<Map<String, String>> generateContent(List<String> contents){\n        List<Map<String, String>> finalcontents = new ArrayList<>();\n\n        for(int i = 0; i < contents.size(); i++){\n            HashMap<String, String> map = new HashMap<>();\n            map.put(Constants.METADATA_KEY, contents.get(i));\n            finalcontents.add(map);\n        }\n        return finalcontents;\n    }\n\n    private RequestBody jsonBody(Object payload) {\n        return RequestBody.create(JSON.toJSONString(payload), JSON_MEDIA_TYPE);\n    }\n\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorDeleteRequest.java",
    "content": "package io.github.lnyocly.ai4j.vector.store;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class VectorDeleteRequest {\n\n    private String dataset;\n\n    private List<String> ids;\n\n    @Builder.Default\n    private boolean deleteAll = false;\n\n    private Map<String, Object> filter;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorRecord.java",
    "content": "package io.github.lnyocly.ai4j.vector.store;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class VectorRecord {\n\n    private String id;\n\n    private List<Float> vector;\n\n    private String content;\n\n    private Map<String, Object> metadata;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorSearchRequest.java",
    "content": "package io.github.lnyocly.ai4j.vector.store;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class VectorSearchRequest {\n\n    private String dataset;\n\n    private List<Float> vector;\n\n    @Builder.Default\n    private Integer topK = 10;\n\n    private Map<String, Object> filter;\n\n    @Builder.Default\n    private Boolean includeMetadata = Boolean.TRUE;\n\n    @Builder.Default\n    private Boolean includeVector = Boolean.FALSE;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorSearchResult.java",
    "content": "package io.github.lnyocly.ai4j.vector.store;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class VectorSearchResult {\n\n    private String id;\n\n    private Float score;\n\n    private String content;\n\n    private List<Float> vector;\n\n    private Map<String, Object> metadata;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorStore.java",
    "content": "package io.github.lnyocly.ai4j.vector.store;\n\nimport java.util.List;\n\n/**\n * Unified vector store abstraction for RAG retrieval.\n */\npublic interface VectorStore {\n\n    int upsert(VectorUpsertRequest request) throws Exception;\n\n    List<VectorSearchResult> search(VectorSearchRequest request) throws Exception;\n\n    boolean delete(VectorDeleteRequest request) throws Exception;\n\n    VectorStoreCapabilities capabilities();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorStoreCapabilities.java",
    "content": "package io.github.lnyocly.ai4j.vector.store;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class VectorStoreCapabilities {\n\n    private boolean dataset;\n\n    private boolean metadataFilter;\n\n    private boolean deleteByFilter;\n\n    private boolean returnStoredVector;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/VectorUpsertRequest.java",
    "content": "package io.github.lnyocly.ai4j.vector.store;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class VectorUpsertRequest {\n\n    private String dataset;\n\n    @Builder.Default\n    private List<VectorRecord> records = Collections.emptyList();\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/milvus/MilvusVectorStore.java",
    "content": "package io.github.lnyocly.ai4j.vector.store.milvus;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.config.MilvusConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class MilvusVectorStore implements VectorStore {\n\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private final MilvusConfig config;\n    private final OkHttpClient okHttpClient;\n\n    public MilvusVectorStore(Configuration configuration) {\n        this(configuration, configuration == null ? null : configuration.getMilvusConfig());\n    }\n\n    public MilvusVectorStore(Configuration configuration, MilvusConfig config) {\n        if (configuration == null || configuration.getOkHttpClient() == null) {\n            throw new IllegalArgumentException(\"OkHttpClient configuration is required\");\n        }\n        if (config == null) {\n            throw new IllegalArgumentException(\"milvusConfig is required\");\n        }\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.config = config;\n    }\n\n    @Override\n    public int upsert(VectorUpsertRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        List<VectorRecord> records = request == null || request.getRecords() == null\n                ? Collections.<VectorRecord>emptyList()\n                : request.getRecords();\n        if (records.isEmpty()) {\n            return 0;\n        }\n\n        JSONArray rows = new JSONArray();\n        int index = 0;\n        for (VectorRecord record : records) {\n            if (record == null || record.getVector() == null || record.getVector().isEmpty()) {\n                index++;\n                continue;\n            }\n            JSONObject row = new JSONObject();\n            row.put(config.getIdField(), resolveId(record.getId(), index));\n            row.put(config.getVectorField(), record.getVector());\n            String content = trimToNull(record.getContent());\n            if (content != null) {\n                row.put(config.getContentField(), content);\n                row.put(Constants.METADATA_KEY, content);\n            }\n            if (record.getMetadata() != null) {\n                for (Map.Entry<String, Object> entry : record.getMetadata().entrySet()) {\n                    if (entry != null && entry.getKey() != null && entry.getValue() != null) {\n                        row.put(entry.getKey(), entry.getValue());\n                    }\n                }\n            }\n            rows.add(row);\n            index++;\n        }\n        if (rows.isEmpty()) {\n            return 0;\n        }\n\n        JSONObject body = new JSONObject();\n        applyCollectionScope(body, dataset);\n        body.put(\"data\", rows);\n        executePost(config.getUpsert(), body);\n        return rows.size();\n    }\n\n    @Override\n    public List<VectorSearchResult> search(VectorSearchRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        if (request == null || request.getVector() == null || request.getVector().isEmpty()) {\n            return Collections.emptyList();\n        }\n\n        JSONObject body = new JSONObject();\n        applyCollectionScope(body, dataset);\n        body.put(\"data\", Collections.singletonList(request.getVector()));\n        body.put(\"annsField\", config.getVectorField());\n        body.put(\"limit\", request.getTopK() == null || request.getTopK() <= 0 ? 10 : request.getTopK());\n        body.put(\"outputFields\", config.getOutputFields());\n        String filter = toFilterExpression(request.getFilter());\n        if (filter != null) {\n            body.put(\"filter\", filter);\n        }\n\n        JSONObject response = executePost(config.getSearch(), body);\n        JSONArray results = response == null ? null : response.getJSONArray(\"data\");\n        if (results == null || results.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<VectorSearchResult> vectorResults = new ArrayList<VectorSearchResult>();\n        for (int i = 0; i < results.size(); i++) {\n            JSONObject item = results.getJSONObject(i);\n            if (item == null) {\n                continue;\n            }\n            Map<String, Object> metadata = metadataFromResult(item);\n            Object idValue = metadata.remove(config.getIdField());\n            vectorResults.add(VectorSearchResult.builder()\n                    .id(stringValue(idValue))\n                    .score(scoreValue(item))\n                    .content(firstNonBlank(\n                            stringValue(metadata.get(config.getContentField())),\n                            stringValue(metadata.get(Constants.METADATA_KEY))))\n                    .metadata(metadata)\n                    .build());\n        }\n        return vectorResults;\n    }\n\n    @Override\n    public boolean delete(VectorDeleteRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        if (request == null) {\n            return false;\n        }\n        JSONObject body = new JSONObject();\n        applyCollectionScope(body, dataset);\n        if (request.getIds() != null && !request.getIds().isEmpty()) {\n            body.put(\"ids\", request.getIds());\n        } else {\n            String filter = toFilterExpression(request.getFilter());\n            if (filter == null && request.isDeleteAll()) {\n                filter = config.getIdField() + \" != \\\"\\\"\";\n            }\n            if (filter != null) {\n                body.put(\"filter\", filter);\n            }\n        }\n        executePost(config.getDelete(), body);\n        return true;\n    }\n\n    @Override\n    public VectorStoreCapabilities capabilities() {\n        return VectorStoreCapabilities.builder()\n                .dataset(true)\n                .metadataFilter(true)\n                .deleteByFilter(true)\n                .returnStoredVector(false)\n                .build();\n    }\n\n    private void applyCollectionScope(JSONObject body, String dataset) {\n        body.put(\"collectionName\", dataset);\n        if (trimToNull(config.getPartitionName()) != null) {\n            body.put(\"partitionName\", config.getPartitionName().trim());\n        }\n        if (trimToNull(config.getDbName()) != null) {\n            body.put(\"dbName\", config.getDbName().trim());\n        }\n    }\n\n    private JSONObject executePost(String path, JSONObject payload) throws Exception {\n        Request.Builder builder = new Request.Builder()\n                .url(UrlUtils.concatUrl(config.getHost(), path))\n                .post(RequestBody.create(JSON.toJSONString(payload), JSON_MEDIA_TYPE))\n                .header(\"accept\", Constants.APPLICATION_JSON)\n                .header(\"content-type\", Constants.APPLICATION_JSON);\n        if (trimToNull(config.getToken()) != null) {\n            builder.header(\"Authorization\", \"Bearer \" + config.getToken().trim());\n        }\n\n        try (Response response = okHttpClient.newCall(builder.build()).execute()) {\n            if (!response.isSuccessful()) {\n                throw new IOException(\"Milvus request failed: \" + response.message());\n            }\n            String body = response.body() == null ? \"{}\" : response.body().string();\n            return JSON.parseObject(body);\n        }\n    }\n\n    private Map<String, Object> metadataFromResult(JSONObject item) {\n        Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n        JSONObject entity = item.getJSONObject(\"entity\");\n        if (entity != null) {\n            for (String key : entity.keySet()) {\n                metadata.put(key, entity.get(key));\n            }\n        }\n        for (String key : item.keySet()) {\n            if (\"distance\".equals(key) || \"score\".equals(key) || \"id\".equals(key) || \"entity\".equals(key)) {\n                continue;\n            }\n            metadata.put(key, item.get(key));\n        }\n        if (!metadata.containsKey(config.getIdField()) && item.containsKey(\"id\")) {\n            metadata.put(config.getIdField(), item.get(\"id\"));\n        }\n        return metadata;\n    }\n\n    private Float scoreValue(JSONObject item) {\n        if (item.containsKey(\"score\")) {\n            return item.getFloat(\"score\");\n        }\n        if (item.containsKey(\"distance\")) {\n            Float distance = item.getFloat(\"distance\");\n            return distance == null ? null : 1.0f - distance;\n        }\n        return null;\n    }\n\n    private String toFilterExpression(Map<String, Object> filter) {\n        if (filter == null || filter.isEmpty()) {\n            return null;\n        }\n        List<String> clauses = new ArrayList<String>();\n        for (Map.Entry<String, Object> entry : filter.entrySet()) {\n            if (entry == null || trimToNull(entry.getKey()) == null || entry.getValue() == null) {\n                continue;\n            }\n            String key = entry.getKey().trim();\n            Object value = entry.getValue();\n            if (value instanceof Collection) {\n                List<String> items = new ArrayList<String>();\n                for (Object item : (Collection<?>) value) {\n                    items.add(formatFilterValue(item));\n                }\n                clauses.add(key + \" in [\" + join(items) + \"]\");\n            } else {\n                clauses.add(key + \" == \" + formatFilterValue(value));\n            }\n        }\n        return clauses.isEmpty() ? null : joinWithAnd(clauses);\n    }\n\n    private String formatFilterValue(Object value) {\n        if (value == null) {\n            return \"\\\"\\\"\";\n        }\n        if (value instanceof Number || value instanceof Boolean) {\n            return String.valueOf(value);\n        }\n        return \"\\\"\" + String.valueOf(value).replace(\"\\\"\", \"\\\\\\\"\") + \"\\\"\";\n    }\n\n    private String join(List<String> values) {\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < values.size(); i++) {\n            if (i > 0) {\n                builder.append(\", \");\n            }\n            builder.append(values.get(i));\n        }\n        return builder.toString();\n    }\n\n    private String joinWithAnd(List<String> values) {\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < values.size(); i++) {\n            if (i > 0) {\n                builder.append(\" and \");\n            }\n            builder.append(values.get(i));\n        }\n        return builder.toString();\n    }\n\n    private String requiredDataset(String dataset) {\n        String value = trimToNull(dataset);\n        if (value == null) {\n            throw new IllegalArgumentException(\"dataset is required\");\n        }\n        return value;\n    }\n\n    private String resolveId(String id, int index) {\n        String value = trimToNull(id);\n        return value == null ? \"id_\" + index : value;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private String stringValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        String text = String.valueOf(value).trim();\n        return text.isEmpty() ? null : text;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/pgvector/PgVectorStore.java",
    "content": "package io.github.lnyocly.ai4j.vector.store.pgvector;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.TypeReference;\nimport io.github.lnyocly.ai4j.config.PgVectorConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\n\nimport java.sql.Connection;\nimport java.sql.DriverManager;\nimport java.sql.PreparedStatement;\nimport java.sql.ResultSet;\nimport java.sql.Types;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class PgVectorStore implements VectorStore {\n\n    private final PgVectorConfig config;\n\n    public PgVectorStore(Configuration configuration) {\n        this(configuration == null ? null : configuration.getPgVectorConfig());\n    }\n\n    public PgVectorStore(PgVectorConfig config) {\n        if (config == null) {\n            throw new IllegalArgumentException(\"pgVectorConfig is required\");\n        }\n        this.config = config;\n    }\n\n    @Override\n    public int upsert(VectorUpsertRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        List<VectorRecord> records = request == null || request.getRecords() == null\n                ? Collections.<VectorRecord>emptyList()\n                : request.getRecords();\n        if (records.isEmpty()) {\n            return 0;\n        }\n\n        String sql = \"insert into \" + identifier(config.getTableName()) + \" (\" +\n                identifier(config.getIdColumn()) + \", \" +\n                identifier(config.getDatasetColumn()) + \", \" +\n                identifier(config.getContentColumn()) + \", \" +\n                identifier(config.getMetadataColumn()) + \", \" +\n                identifier(config.getVectorColumn()) +\n                \") values (?, ?, ?, cast(? as jsonb), cast(? as vector)) \" +\n                \"on conflict (\" + identifier(config.getIdColumn()) + \") do update set \" +\n                identifier(config.getDatasetColumn()) + \" = excluded.\" + identifier(config.getDatasetColumn()) + \", \" +\n                identifier(config.getContentColumn()) + \" = excluded.\" + identifier(config.getContentColumn()) + \", \" +\n                identifier(config.getMetadataColumn()) + \" = excluded.\" + identifier(config.getMetadataColumn()) + \", \" +\n                identifier(config.getVectorColumn()) + \" = excluded.\" + identifier(config.getVectorColumn());\n\n        int total = 0;\n        try (Connection connection = connection();\n             PreparedStatement statement = connection.prepareStatement(sql)) {\n            int index = 0;\n            for (VectorRecord record : records) {\n                if (record == null || record.getVector() == null || record.getVector().isEmpty()) {\n                    index++;\n                    continue;\n                }\n                statement.setString(1, resolveId(record.getId(), index));\n                statement.setString(2, dataset);\n                statement.setString(3, record.getContent());\n                String metadataJson = metadataJson(record);\n                if (metadataJson == null) {\n                    statement.setNull(4, Types.VARCHAR);\n                } else {\n                    statement.setString(4, metadataJson);\n                }\n                statement.setString(5, vectorLiteral(record.getVector()));\n                statement.addBatch();\n                index++;\n            }\n            int[] results = statement.executeBatch();\n            for (int value : results) {\n                total += value < 0 ? 1 : value;\n            }\n        }\n        return total;\n    }\n\n    @Override\n    public List<VectorSearchResult> search(VectorSearchRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        if (request == null || request.getVector() == null || request.getVector().isEmpty()) {\n            return Collections.emptyList();\n        }\n\n        StringBuilder sql = new StringBuilder();\n        sql.append(\"select \")\n                .append(identifier(config.getIdColumn())).append(\", \")\n                .append(identifier(config.getContentColumn())).append(\", \")\n                .append(identifier(config.getMetadataColumn())).append(\"::text as metadata_json, \")\n                .append(identifier(config.getVectorColumn())).append(\" \").append(config.getDistanceOperator()).append(\" cast(? as vector) as distance \")\n                .append(\"from \").append(identifier(config.getTableName()))\n                .append(\" where \").append(identifier(config.getDatasetColumn())).append(\" = ?\");\n\n        List<Object> parameters = new ArrayList<Object>();\n        parameters.add(vectorLiteral(request.getVector()));\n        parameters.add(dataset);\n        appendMetadataFilters(sql, parameters, request.getFilter());\n        sql.append(\" order by \").append(identifier(config.getVectorColumn()))\n                .append(\" \").append(config.getDistanceOperator()).append(\" cast(? as vector)\")\n                .append(\" limit ?\");\n        parameters.add(vectorLiteral(request.getVector()));\n        parameters.add(request.getTopK() == null || request.getTopK() <= 0 ? 10 : request.getTopK());\n\n        List<VectorSearchResult> results = new ArrayList<VectorSearchResult>();\n        try (Connection connection = connection();\n             PreparedStatement statement = connection.prepareStatement(sql.toString())) {\n            bindParameters(statement, parameters);\n            try (ResultSet resultSet = statement.executeQuery()) {\n                while (resultSet.next()) {\n                    String metadataJson = resultSet.getString(\"metadata_json\");\n                    Map<String, Object> metadata = parseMetadata(metadataJson);\n                    results.add(VectorSearchResult.builder()\n                            .id(resultSet.getString(identifier(config.getIdColumn())))\n                            .content(firstNonBlank(\n                                    resultSet.getString(identifier(config.getContentColumn())),\n                                    stringValue(metadata.get(Constants.METADATA_KEY))))\n                            .metadata(metadata)\n                            .score(1.0f - resultSet.getFloat(\"distance\"))\n                            .build());\n                }\n            }\n        }\n        return results;\n    }\n\n    @Override\n    public boolean delete(VectorDeleteRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        if (request == null) {\n            return false;\n        }\n\n        StringBuilder sql = new StringBuilder();\n        sql.append(\"delete from \").append(identifier(config.getTableName()))\n                .append(\" where \").append(identifier(config.getDatasetColumn())).append(\" = ?\");\n        List<Object> parameters = new ArrayList<Object>();\n        parameters.add(dataset);\n\n        if (request.getIds() != null && !request.getIds().isEmpty()) {\n            sql.append(\" and \").append(identifier(config.getIdColumn())).append(\" in (\");\n            for (int i = 0; i < request.getIds().size(); i++) {\n                if (i > 0) {\n                    sql.append(\", \");\n                }\n                sql.append(\"?\");\n                parameters.add(request.getIds().get(i));\n            }\n            sql.append(\")\");\n        } else if (request.getFilter() != null && !request.getFilter().isEmpty()) {\n            appendMetadataFilters(sql, parameters, request.getFilter());\n        }\n\n        try (Connection connection = connection();\n             PreparedStatement statement = connection.prepareStatement(sql.toString())) {\n            bindParameters(statement, parameters);\n            statement.executeUpdate();\n            return true;\n        }\n    }\n\n    @Override\n    public VectorStoreCapabilities capabilities() {\n        return VectorStoreCapabilities.builder()\n                .dataset(true)\n                .metadataFilter(true)\n                .deleteByFilter(true)\n                .returnStoredVector(false)\n                .build();\n    }\n\n    private void appendMetadataFilters(StringBuilder sql, List<Object> parameters, Map<String, Object> filter) {\n        if (filter == null || filter.isEmpty()) {\n            return;\n        }\n        for (Map.Entry<String, Object> entry : filter.entrySet()) {\n            if (entry == null || safeMetadataKey(entry.getKey()) == null || entry.getValue() == null) {\n                continue;\n            }\n            String key = safeMetadataKey(entry.getKey());\n            Object value = entry.getValue();\n            if (value instanceof Iterable) {\n                List<Object> values = new ArrayList<Object>();\n                for (Object item : (Iterable<?>) value) {\n                    values.add(item);\n                }\n                if (values.isEmpty()) {\n                    continue;\n                }\n                sql.append(\" and \").append(identifier(config.getMetadataColumn())).append(\" ->> '\").append(key).append(\"' in (\");\n                for (int i = 0; i < values.size(); i++) {\n                    if (i > 0) {\n                        sql.append(\", \");\n                    }\n                    sql.append(\"?\");\n                    parameters.add(String.valueOf(values.get(i)));\n                }\n                sql.append(\")\");\n            } else {\n                sql.append(\" and \").append(identifier(config.getMetadataColumn())).append(\" ->> '\").append(key).append(\"' = ?\");\n                parameters.add(String.valueOf(value));\n            }\n        }\n    }\n\n    private Connection connection() throws Exception {\n        if (trimToNull(config.getUsername()) == null) {\n            return DriverManager.getConnection(config.getJdbcUrl());\n        }\n        return DriverManager.getConnection(config.getJdbcUrl(), config.getUsername(), config.getPassword());\n    }\n\n    private void bindParameters(PreparedStatement statement, List<Object> parameters) throws Exception {\n        for (int i = 0; i < parameters.size(); i++) {\n            Object value = parameters.get(i);\n            if (value == null) {\n                statement.setNull(i + 1, Types.VARCHAR);\n            } else {\n                statement.setObject(i + 1, value);\n            }\n        }\n    }\n\n    private String metadataJson(VectorRecord record) {\n        Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n        if (record.getMetadata() != null) {\n            metadata.putAll(record.getMetadata());\n        }\n        if (trimToNull(record.getContent()) != null && !metadata.containsKey(Constants.METADATA_KEY)) {\n            metadata.put(Constants.METADATA_KEY, record.getContent().trim());\n        }\n        return metadata.isEmpty() ? null : JSON.toJSONString(metadata);\n    }\n\n    private Map<String, Object> parseMetadata(String metadataJson) {\n        if (metadataJson == null || metadataJson.trim().isEmpty()) {\n            return Collections.emptyMap();\n        }\n        return JSON.parseObject(metadataJson, new TypeReference<LinkedHashMap<String, Object>>() {\n        });\n    }\n\n    private String vectorLiteral(List<Float> vector) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"[\");\n        for (int i = 0; i < vector.size(); i++) {\n            if (i > 0) {\n                builder.append(\", \");\n            }\n            builder.append(vector.get(i));\n        }\n        builder.append(\"]\");\n        return builder.toString();\n    }\n\n    private String safeMetadataKey(String key) {\n        String value = trimToNull(key);\n        if (value == null) {\n            return null;\n        }\n        if (!value.matches(\"[A-Za-z_][A-Za-z0-9_]*\")) {\n            throw new IllegalArgumentException(\"Invalid metadata key: \" + key);\n        }\n        return value;\n    }\n\n    private String identifier(String identifier) {\n        String value = trimToNull(identifier);\n        if (value == null || !value.matches(\"[A-Za-z_][A-Za-z0-9_]*(\\\\.[A-Za-z_][A-Za-z0-9_]*)?\")) {\n            throw new IllegalArgumentException(\"Invalid sql identifier: \" + identifier);\n        }\n        return value;\n    }\n\n    private String requiredDataset(String dataset) {\n        String value = trimToNull(dataset);\n        if (value == null) {\n            throw new IllegalArgumentException(\"dataset is required\");\n        }\n        return value;\n    }\n\n    private String resolveId(String id, int index) {\n        String value = trimToNull(id);\n        return value == null ? \"id_\" + index : value;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private String stringValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        String text = String.valueOf(value).trim();\n        return text.isEmpty() ? null : text;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/pinecone/PineconeVectorStore.java",
    "content": "package io.github.lnyocly.ai4j.vector.store.pinecone;\n\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeDelete;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeInsert;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeQuery;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeQueryResponse;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeVectors;\nimport io.github.lnyocly.ai4j.vector.service.PineconeService;\nimport io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class PineconeVectorStore implements VectorStore {\n\n    private final PineconeService pineconeService;\n\n    public PineconeVectorStore(PineconeService pineconeService) {\n        if (pineconeService == null) {\n            throw new IllegalArgumentException(\"pineconeService is required\");\n        }\n        this.pineconeService = pineconeService;\n    }\n\n    @Override\n    public int upsert(VectorUpsertRequest request) {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        List<VectorRecord> records = request == null || request.getRecords() == null\n                ? Collections.<VectorRecord>emptyList()\n                : request.getRecords();\n        if (records.isEmpty()) {\n            return 0;\n        }\n\n        List<PineconeVectors> vectors = new ArrayList<PineconeVectors>();\n        int index = 0;\n        for (VectorRecord record : records) {\n            if (record == null || record.getVector() == null || record.getVector().isEmpty()) {\n                index++;\n                continue;\n            }\n            Map<String, String> metadata = stringifyMetadata(record.getMetadata());\n            String content = trimToNull(record.getContent());\n            if (content != null && !metadata.containsKey(Constants.METADATA_KEY)) {\n                metadata.put(Constants.METADATA_KEY, content);\n            }\n            vectors.add(PineconeVectors.builder()\n                    .id(resolveId(record.getId(), index))\n                    .values(record.getVector())\n                    .metadata(metadata)\n                    .build());\n            index++;\n        }\n        if (vectors.isEmpty()) {\n            return 0;\n        }\n        return pineconeService.insert(new PineconeInsert(vectors, dataset));\n    }\n\n    @Override\n    public List<VectorSearchResult> search(VectorSearchRequest request) {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        if (request == null || request.getVector() == null || request.getVector().isEmpty()) {\n            return Collections.emptyList();\n        }\n        PineconeQueryResponse response = pineconeService.query(PineconeQuery.builder()\n                .namespace(dataset)\n                .topK(request.getTopK() == null || request.getTopK() <= 0 ? 10 : request.getTopK())\n                .filter(stringifyMetadata(request.getFilter()))\n                .includeMetadata(request.getIncludeMetadata() == null ? Boolean.TRUE : request.getIncludeMetadata())\n                .includeValues(request.getIncludeVector() == null ? Boolean.FALSE : request.getIncludeVector())\n                .vector(request.getVector())\n                .build());\n        if (response == null || response.getMatches() == null || response.getMatches().isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<VectorSearchResult> results = new ArrayList<VectorSearchResult>();\n        for (PineconeQueryResponse.Match match : response.getMatches()) {\n            if (match == null) {\n                continue;\n            }\n            Map<String, Object> metadata = objectMetadata(match.getMetadata());\n            String content = metadata == null ? null : stringValue(metadata.get(Constants.METADATA_KEY));\n            results.add(VectorSearchResult.builder()\n                    .id(match.getId())\n                    .score(match.getScore())\n                    .content(content)\n                    .vector(match.getValues())\n                    .metadata(metadata)\n                    .build());\n        }\n        return results;\n    }\n\n    @Override\n    public boolean delete(VectorDeleteRequest request) {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        if (request == null) {\n            return false;\n        }\n        return pineconeService.delete(PineconeDelete.builder()\n                .ids(request.getIds())\n                .deleteAll(request.isDeleteAll())\n                .namespace(dataset)\n                .filter(stringifyMetadata(request.getFilter()))\n                .build());\n    }\n\n    @Override\n    public VectorStoreCapabilities capabilities() {\n        return VectorStoreCapabilities.builder()\n                .dataset(true)\n                .metadataFilter(true)\n                .deleteByFilter(true)\n                .returnStoredVector(true)\n                .build();\n    }\n\n    private String requiredDataset(String dataset) {\n        String value = trimToNull(dataset);\n        if (value == null) {\n            throw new IllegalArgumentException(\"dataset is required\");\n        }\n        return value;\n    }\n\n    private String resolveId(String id, int index) {\n        String value = trimToNull(id);\n        return value == null ? \"id_\" + index : value;\n    }\n\n    private Map<String, String> stringifyMetadata(Map<String, ?> metadata) {\n        if (metadata == null || metadata.isEmpty()) {\n            return null;\n        }\n        Map<String, String> result = new LinkedHashMap<String, String>();\n        for (Map.Entry<String, ?> entry : metadata.entrySet()) {\n            if (entry == null || entry.getKey() == null) {\n                continue;\n            }\n            String value = stringValue(entry.getValue());\n            if (value != null) {\n                result.put(entry.getKey(), value);\n            }\n        }\n        return result.isEmpty() ? null : result;\n    }\n\n    private Map<String, Object> objectMetadata(Map<String, String> metadata) {\n        if (metadata == null || metadata.isEmpty()) {\n            return Collections.emptyMap();\n        }\n        Map<String, Object> result = new LinkedHashMap<String, Object>();\n        for (Map.Entry<String, String> entry : metadata.entrySet()) {\n            if (entry != null && entry.getKey() != null) {\n                result.put(entry.getKey(), entry.getValue());\n            }\n        }\n        return result;\n    }\n\n    private String stringValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        String text = String.valueOf(value).trim();\n        return text.isEmpty() ? null : text;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String text = value.trim();\n        return text.isEmpty() ? null : text;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/vector/store/qdrant/QdrantVectorStore.java",
    "content": "package io.github.lnyocly.ai4j.vector.store.qdrant;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.config.QdrantConfig;\nimport io.github.lnyocly.ai4j.constant.Constants;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class QdrantVectorStore implements VectorStore {\n\n    private static final MediaType JSON_MEDIA_TYPE = MediaType.get(Constants.APPLICATION_JSON);\n\n    private final QdrantConfig config;\n    private final OkHttpClient okHttpClient;\n\n    public QdrantVectorStore(Configuration configuration) {\n        this(configuration, configuration == null ? null : configuration.getQdrantConfig());\n    }\n\n    public QdrantVectorStore(Configuration configuration, QdrantConfig config) {\n        if (configuration == null || configuration.getOkHttpClient() == null) {\n            throw new IllegalArgumentException(\"OkHttpClient configuration is required\");\n        }\n        if (config == null) {\n            throw new IllegalArgumentException(\"qdrantConfig is required\");\n        }\n        this.okHttpClient = configuration.getOkHttpClient();\n        this.config = config;\n    }\n\n    @Override\n    public int upsert(VectorUpsertRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        List<VectorRecord> records = request == null || request.getRecords() == null\n                ? Collections.<VectorRecord>emptyList()\n                : request.getRecords();\n        if (records.isEmpty()) {\n            return 0;\n        }\n\n        JSONArray points = new JSONArray();\n        int index = 0;\n        for (VectorRecord record : records) {\n            if (record == null || record.getVector() == null || record.getVector().isEmpty()) {\n                index++;\n                continue;\n            }\n            JSONObject point = new JSONObject();\n            point.put(\"id\", resolveId(record.getId(), index));\n            point.put(\"vector\", namedVector(record.getVector()));\n            point.put(\"payload\", payload(record));\n            points.add(point);\n            index++;\n        }\n        if (points.isEmpty()) {\n            return 0;\n        }\n\n        JSONObject body = new JSONObject();\n        body.put(\"points\", points);\n        executePost(url(config.getUpsert(), dataset), body);\n        return points.size();\n    }\n\n    @Override\n    public List<VectorSearchResult> search(VectorSearchRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        if (request == null || request.getVector() == null || request.getVector().isEmpty()) {\n            return Collections.emptyList();\n        }\n\n        JSONObject body = new JSONObject();\n        body.put(\"query\", request.getVector());\n        body.put(\"limit\", request.getTopK() == null || request.getTopK() <= 0 ? 10 : request.getTopK());\n        body.put(\"with_payload\", request.getIncludeMetadata() == null ? Boolean.TRUE : request.getIncludeMetadata());\n        body.put(\"with_vector\", request.getIncludeVector() == null ? Boolean.FALSE : request.getIncludeVector());\n        if (trimToNull(config.getVectorName()) != null) {\n            body.put(\"using\", trimToNull(config.getVectorName()));\n        }\n        JSONObject filter = toFilter(request.getFilter());\n        if (filter != null) {\n            body.put(\"filter\", filter);\n        }\n\n        JSONObject response = executePost(url(config.getQuery(), dataset), body);\n        JSONObject result = response == null ? null : response.getJSONObject(\"result\");\n        JSONArray points = result == null ? null : result.getJSONArray(\"points\");\n        if (points == null || points.isEmpty()) {\n            return Collections.emptyList();\n        }\n\n        List<VectorSearchResult> results = new ArrayList<VectorSearchResult>();\n        for (int i = 0; i < points.size(); i++) {\n            JSONObject point = points.getJSONObject(i);\n            if (point == null) {\n                continue;\n            }\n            Map<String, Object> metadata = payloadMap(point.getJSONObject(\"payload\"));\n            results.add(VectorSearchResult.builder()\n                    .id(stringValue(point.get(\"id\")))\n                    .score(point.getFloat(\"score\"))\n                    .content(stringValue(metadata.get(Constants.METADATA_KEY)))\n                    .vector(request.getIncludeVector() != null && request.getIncludeVector() ? vectorValue(point.get(\"vector\")) : null)\n                    .metadata(metadata)\n                    .build());\n        }\n        return results;\n    }\n\n    @Override\n    public boolean delete(VectorDeleteRequest request) throws Exception {\n        String dataset = requiredDataset(request == null ? null : request.getDataset());\n        if (request == null) {\n            return false;\n        }\n\n        JSONObject body = new JSONObject();\n        if (request.getIds() != null && !request.getIds().isEmpty()) {\n            body.put(\"points\", request.getIds());\n        } else {\n            JSONObject filter = toFilter(request.getFilter());\n            if (filter == null && request.isDeleteAll()) {\n                filter = new JSONObject();\n                filter.put(\"must\", new JSONArray());\n            }\n            if (filter != null) {\n                body.put(\"filter\", filter);\n            }\n        }\n        executePost(url(config.getDelete(), dataset), body);\n        return true;\n    }\n\n    @Override\n    public VectorStoreCapabilities capabilities() {\n        return VectorStoreCapabilities.builder()\n                .dataset(true)\n                .metadataFilter(true)\n                .deleteByFilter(true)\n                .returnStoredVector(true)\n                .build();\n    }\n\n    private JSONObject payload(VectorRecord record) {\n        JSONObject payload = new JSONObject();\n        if (record.getMetadata() != null) {\n            for (Map.Entry<String, Object> entry : record.getMetadata().entrySet()) {\n                if (entry != null && entry.getKey() != null && entry.getValue() != null) {\n                    payload.put(entry.getKey(), entry.getValue());\n                }\n            }\n        }\n        String content = trimToNull(record.getContent());\n        if (content != null && !payload.containsKey(Constants.METADATA_KEY)) {\n            payload.put(Constants.METADATA_KEY, content);\n        }\n        return payload;\n    }\n\n    private Object namedVector(List<Float> vector) {\n        if (trimToNull(config.getVectorName()) == null) {\n            return vector;\n        }\n        JSONObject named = new JSONObject();\n        named.put(config.getVectorName().trim(), vector);\n        return named;\n    }\n\n    private JSONObject toFilter(Map<String, Object> filter) {\n        if (filter == null || filter.isEmpty()) {\n            return null;\n        }\n        JSONArray must = new JSONArray();\n        for (Map.Entry<String, Object> entry : filter.entrySet()) {\n            if (entry == null || trimToNull(entry.getKey()) == null || entry.getValue() == null) {\n                continue;\n            }\n            JSONObject condition = new JSONObject();\n            condition.put(\"key\", entry.getKey());\n            JSONObject match = new JSONObject();\n            Object value = entry.getValue();\n            if (value instanceof Collection) {\n                JSONArray any = new JSONArray();\n                for (Object item : (Collection<?>) value) {\n                    any.add(item);\n                }\n                match.put(\"any\", any);\n            } else {\n                match.put(\"value\", value);\n            }\n            condition.put(\"match\", match);\n            must.add(condition);\n        }\n        if (must.isEmpty()) {\n            return null;\n        }\n        JSONObject result = new JSONObject();\n        result.put(\"must\", must);\n        return result;\n    }\n\n    private JSONObject executePost(String url, JSONObject payload) throws Exception {\n        Request.Builder builder = new Request.Builder()\n                .url(url)\n                .post(RequestBody.create(JSON.toJSONString(payload), JSON_MEDIA_TYPE))\n                .header(\"accept\", Constants.APPLICATION_JSON)\n                .header(\"content-type\", Constants.APPLICATION_JSON);\n        if (trimToNull(config.getApiKey()) != null) {\n            builder.header(\"api-key\", config.getApiKey().trim());\n        }\n\n        try (Response response = okHttpClient.newCall(builder.build()).execute()) {\n            if (!response.isSuccessful()) {\n                throw new IOException(\"Qdrant request failed: \" + response.message());\n            }\n            String body = response.body() == null ? \"{}\" : response.body().string();\n            return JSON.parseObject(body);\n        }\n    }\n\n    private List<Float> vectorValue(Object rawVector) {\n        if (rawVector == null) {\n            return null;\n        }\n        JSONArray values = null;\n        if (rawVector instanceof JSONArray) {\n            values = (JSONArray) rawVector;\n        } else if (rawVector instanceof JSONObject && trimToNull(config.getVectorName()) != null) {\n            values = ((JSONObject) rawVector).getJSONArray(config.getVectorName().trim());\n        }\n        if (values == null) {\n            return null;\n        }\n        List<Float> vector = new ArrayList<Float>();\n        for (int i = 0; i < values.size(); i++) {\n            vector.add(values.getFloat(i));\n        }\n        return vector;\n    }\n\n    private Map<String, Object> payloadMap(JSONObject payload) {\n        if (payload == null || payload.isEmpty()) {\n            return Collections.emptyMap();\n        }\n        Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n        for (String key : payload.keySet()) {\n            metadata.put(key, payload.get(key));\n        }\n        return metadata;\n    }\n\n    private String url(String template, String dataset) {\n        return UrlUtils.concatUrl(config.getHost(), String.format(template, dataset));\n    }\n\n    private String requiredDataset(String dataset) {\n        String value = trimToNull(dataset);\n        if (value == null) {\n            throw new IllegalArgumentException(\"dataset is required\");\n        }\n        return value;\n    }\n\n    private String resolveId(String id, int index) {\n        String value = trimToNull(id);\n        return value == null ? \"id_\" + index : value;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private String stringValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        String text = String.valueOf(value).trim();\n        return text.isEmpty() ? null : text;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/websearch/ChatWithWebSearchEnhance.java",
    "content": "package io.github.lnyocly.ai4j.websearch;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Content;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.network.UrlUtils;\nimport io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig;\nimport io.github.lnyocly.ai4j.websearch.searxng.SearXNGRequest;\nimport io.github.lnyocly.ai4j.websearch.searxng.SearXNGResponse;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport org.apache.commons.lang3.StringUtils;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/12/11 22:32\n */\npublic class ChatWithWebSearchEnhance implements IChatService {\n    private final IChatService chatService;\n    private final SearXNGConfig searXNGConfig;\n    private final OkHttpClient okHttpClient;\n    public ChatWithWebSearchEnhance(IChatService chatService, Configuration configuration) {\n        this.chatService = chatService;\n        this.searXNGConfig = configuration.getSearXNGConfig();\n        this.okHttpClient = configuration.getOkHttpClient();\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) throws Exception {\n        return chatService.chatCompletion(baseUrl, apiKey, addWebSearchResults(chatCompletion));\n    }\n\n    @Override\n    public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) throws Exception {\n        return chatService.chatCompletion(addWebSearchResults(chatCompletion));\n    }\n\n    @Override\n    public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        chatService.chatCompletionStream(baseUrl, apiKey, addWebSearchResults(chatCompletion), eventSourceListener);\n    }\n\n    @Override\n    public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n        chatService.chatCompletionStream(addWebSearchResults(chatCompletion), eventSourceListener);\n    }\n\n\n    private ChatCompletion addWebSearchResults(ChatCompletion chatCompletion) {\n        int chatLen = chatCompletion.getMessages().size();\n        String prompt = chatCompletion.getMessages().get(chatLen - 1).getContent().getText();\n        // 执行联网搜索并将结果附加到提示词中\n        String searchResults = performWebSearch(prompt);\n        chatCompletion.getMessages().get(chatLen - 1).setContent(Content.ofText(\"我将提供一段来自互联网的资料信息, 请根据这段资料以及用户提出的问题来给出回答。请确保在回答中使用Markdown格式，并在回答末尾列出参考资料。如果资料中的信息不足以回答用户的问题，可以根据自身知识库进行补充，或者说明无法提供确切的答案。\\n\" +\n                \"网络资料:\\n\"\n                + \"============\\n\"\n                + searchResults\n                + \"============\\n\"\n                + \"用户问题:\\n\"\n                + \"============\\n\"\n                + prompt\n                + \"============\\n\"));\n        return chatCompletion;\n    }\n\n    private String performWebSearch(String query) {\n\n        SearXNGRequest searXNGRequest = SearXNGRequest.builder()\n                .q(query)\n                .engines(searXNGConfig.getEngines())\n                .build();\n\n\n        if(StringUtils.isBlank(searXNGConfig.getUrl())){\n            throw new CommonException(\"SearXNG url is not configured\");\n        }\n\n\n        Request request = new Request.Builder()\n                .url(UrlUtils.concatUrl(searXNGConfig.getUrl(), \"?format=json&q=\" + query + \"&engines=\" + searXNGConfig.getEngines()))\n                .get()\n                .build();\n\n\n        try(Response execute = okHttpClient.newCall(request).execute()) {\n            if (execute.isSuccessful() && execute.body() != null){\n                SearXNGResponse searXNGResponse = JSON.parseObject(execute.body().string(), SearXNGResponse.class);\n\n                if(searXNGResponse.getResults().size() > searXNGConfig.getNums()) {\n                    return JSON.toJSONString(searXNGResponse.getResults().subList(0, searXNGConfig.getNums()));\n                }\n                return JSON.toJSONString(searXNGResponse.getResults());\n\n\n            }else{\n                throw new CommonException(\"SearXNG request failed\");\n            }\n\n\n        } catch (Exception e) {\n            throw new CommonException(\"SearXNG request failed\");\n        }\n\n\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/websearch/searxng/SearXNGConfig.java",
    "content": "package io.github.lnyocly.ai4j.websearch.searxng;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport lombok.NonNull;\n\n/**\n * @Author cly\n * @Description SearXNG网路搜索配置信息\n * @Date 2024/12/11 23:05\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\npublic class SearXNGConfig {\n    private String url;\n    private String engines = \"duckduckgo,google,bing,brave,mojeek,presearch,qwant,startpage,yahoo,arxiv,crossref,google_scholar,internetarchivescholar,semantic_scholar\";\n    private int nums = 20;\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/websearch/searxng/SearXNGRequest.java",
    "content": "package io.github.lnyocly.ai4j.websearch.searxng;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/12/11 21:41\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\n@Builder\npublic class SearXNGRequest {\n    @Builder.Default\n    private final String format = \"json\";\n\n    private String q;\n\n    @Builder.Default\n    private String engines = \"duckduckgo,google,bing,brave,mojeek,presearch,qwant,startpage,yahoo,arxiv,crossref,google_scholar,internetarchivescholar,semantic_scholar\";\n}\n"
  },
  {
    "path": "ai4j/src/main/java/io/github/lnyocly/ai4j/websearch/searxng/SearXNGResponse.java",
    "content": "package io.github.lnyocly.ai4j.websearch.searxng;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport lombok.AllArgsConstructor;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Date;\nimport java.util.List;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/12/11 21:39\n */\n@Data\n@AllArgsConstructor\n@NoArgsConstructor\npublic class SearXNGResponse {\n    private String query;\n\n    @JsonProperty(\"number_of_results\")\n    private String numberOfResults;\n\n    private List<Result> results;\n\n\n    @Data\n    @AllArgsConstructor\n    @NoArgsConstructor\n    public static class Result {\n        private String url;\n        private String title;\n        private String content;\n        //@JsonProperty(\"parsed_url\")\n        //private List<String> parsedUrl;\n        //private Date publishedDate;\n        //private List<String> engines;\n        //private String category;\n        //private float score;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.ConnectionPoolProvider",
    "content": "io.github.lnyocly.ai4j.network.impl.DefaultConnectionPoolProvider"
  },
  {
    "path": "ai4j/src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.DispatcherProvider",
    "content": "io.github.lnyocly.ai4j.network.impl.DefaultDispatcherProvider"
  },
  {
    "path": "ai4j/src/main/resources/mcp-servers-config.json",
    "content": "{\n  \"mcpServers\": {\n    \"test_weather_http\": {\n      \"type\": \"http\",\n      \"url\": \"http://127.0.0.1:8000/mcp\"\n    }\n  }\n}"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/BaichuanTest.java",
    "content": "package io.github.lnyocly;\n\nimport io.github.lnyocly.ai4j.config.BaichuanConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description 智谱测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class BaichuanTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        BaichuanConfig baichuanConfig = new BaichuanConfig();\n        baichuanConfig.setApiKey(\"sk-4e5717ac51cacaf5d590cff13630cfce\");\n\n        Configuration configuration = new Configuration();\n        configuration.setBaichuanConfig(baichuanConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.BAICHUAN);\n    }\n\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"Baichuan4\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-vision\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-large-fc\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/DashScopeTest.java",
    "content": "package io.github.lnyocly;\n\nimport cn.hutool.core.util.SystemPropsUtil;\nimport io.github.lnyocly.ai4j.config.DashScopeConfig;\nimport io.github.lnyocly.ai4j.config.ZhipuConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description 智谱测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class DashScopeTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        DashScopeConfig dashScopeConfig = new DashScopeConfig();\n        dashScopeConfig.setApiKey(SystemPropsUtil.get(\"DASHSCOPE_API_KEY\"));\n\n        Configuration configuration = new Configuration();\n        configuration.setDashScopeConfig(dashScopeConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.DASHSCOPE);\n\n    }\n\n\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen3-32b\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .parameter(\"enable_thinking\", false)\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen3-32b\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen3-32b\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .parameter(\"enable_thinking\", false)\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen3-32b\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/DeepSeekTest.java",
    "content": "package io.github.lnyocly;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.*;\nimport io.github.lnyocly.ai4j.exception.chain.ErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport io.github.lnyocly.ai4j.document.RecursiveCharacterTextSplitter;\nimport io.github.lnyocly.ai4j.document.TikaUtil;\nimport io.github.lnyocly.ai4j.vector.VectorDataEntity;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeDelete;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeInsert;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeQuery;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeVectors;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.apache.commons.lang3.ObjectUtils;\nimport org.apache.tika.exception.TikaException;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.reflections.Reflections;\nimport org.xml.sax.SAXException;\n\nimport java.io.File;\nimport java.io.FileInputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.net.InetSocketAddress;\nimport java.net.Proxy;\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Collectors;\n\n/**\n * @Author cly\n * @Description deepseek测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class DeepSeekTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        DeepSeekConfig deepSeekConfig = new DeepSeekConfig();\n        deepSeekConfig.setApiKey(\"sk-123456789\");\n\n        Configuration configuration = new Configuration();\n        configuration.setDeepSeekConfig(deepSeekConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.DEEPSEEK);\n\n    }\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"deepseek-chat\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"deepseek-chat\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"deepseek-reasoner\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(\"思考内容：\");\n        System.out.println(sseListener.getReasoningOutput());\n        System.out.println(\"回答内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"deepseek-chat\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"deepseek-chat\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n\n}\n\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/DoubaoImageTest.java",
    "content": "package io.github.lnyocly;\n\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGeneration;\nimport io.github.lnyocly.ai4j.platform.openai.image.entity.ImageGenerationResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IImageService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.junit.Assume;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description 豆包图片生成测试\n * @Date 2026/1/31\n */\npublic class DoubaoImageTest {\n\n    private IImageService imageService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ARK_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getenv(\"DOUBAO_API_KEY\");\n        }\n        Assume.assumeTrue(apiKey != null && !apiKey.isEmpty());\n\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        doubaoConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setDoubaoConfig(doubaoConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n        imageService = aiService.getImageService(PlatformType.DOUBAO);\n    }\n\n    @Test\n    public void test_image_generate() throws Exception {\n        ImageGeneration request = ImageGeneration.builder()\n                .model(\"doubao-seedream-4-5-251128\")\n                .prompt(\"一只戴着飞行员护目镜的小猫，卡通风格，明亮配色\")\n                .size(\"2K\")\n                .responseFormat(\"url\")\n                .build();\n\n        ImageGenerationResponse response = imageService.generate(request);\n        System.out.println(response);\n    }\n\n    @Test\n    public void test_image_generate_stream() throws Exception {\n        ImageGeneration request = ImageGeneration.builder()\n                .model(\"doubao-seedream-4-5-251128\")\n                .prompt(\"一只戴着飞行员护目镜的小猫，卡通风格，明亮配色\")\n                .size(\"2K\")\n                .responseFormat(\"url\")\n                .stream(true)\n                .build();\n\n        imageService.generateStream(request, new io.github.lnyocly.ai4j.listener.ImageSseListener() {\n            @Override\n            protected void onEvent() {\n                System.out.println(getCurrEvent());\n            }\n        });\n\n        System.out.println(\"stream finished\");\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/DoubaoResponsesTest.java",
    "content": "package io.github.lnyocly;\n\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.ResponseSseListener;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n\npublic class DoubaoResponsesTest {\n\n    private IResponsesService responsesService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ARK_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getenv(\"DOUBAO_API_KEY\");\n        }\n        Assume.assumeTrue(apiKey != null && !apiKey.isEmpty());\n\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        doubaoConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setDoubaoConfig(doubaoConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n        responsesService = aiService.getResponsesService(PlatformType.DOUBAO);\n    }\n\n    @Test\n    public void test_responses_create() throws Exception {\n        ResponseRequest request = ResponseRequest.builder()\n                .model(\"doubao-seed-1-8-251228\")\n                .input(\"Summarize the Responses API in one sentence\")\n                .build();\n\n        Response response = responsesService.create(request);\n        System.out.println(response);\n    }\n\n    @Test\n    public void test_responses_stream() throws Exception {\n        ResponseRequest request = ResponseRequest.builder()\n                .model(\"doubao-seed-1-8-251228\")\n                .input(\"Describe the Responses API in one sentence\")\n                .build();\n\n        ResponseSseListener listener = new ResponseSseListener() {\n            @Override\n            protected void onEvent() {\n                if (!getCurrText().isEmpty()) {\n                    System.out.print(getCurrText());\n                }\n            }\n        };\n\n        responsesService.createStream(request, listener);\n        System.out.println();\n        System.out.println(\"stream finished\");\n        System.out.println(listener.getResponse());\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/DoubaoTest.java",
    "content": "package io.github.lnyocly;\n\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.doubao.chat.DoubaoChatService;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n\n@Slf4j\npublic class DoubaoTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        String apiKey = System.getenv(\"ARK_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getenv(\"DOUBAO_API_KEY\");\n        }\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = \"************\";\n        }\n        doubaoConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setDoubaoConfig(doubaoConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        chatService = new DoubaoChatService(configuration);\n    }\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"doubao-seed-1-6-250615\")\n                .message(ChatMessage.withUser(\"你好，请介绍一下你自己\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"doubao-seed-1-6-250615\")\n                .message(ChatMessage.withUser(\"先有鸡还是先有蛋？\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n\n                // 当前流式输出的data的数据，即当前输出的token的内容，可能为content，reasoning_content， function call\n                log.info(this.getCurrStr());\n\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"\\n请求成功\");\n        System.out.println(\"完整回答内容：\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"Token使用：\");\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"doubao-seed-1-6-250615\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                log.info(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/HunyuanTest.java",
    "content": "package io.github.lnyocly;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.DeepSeekConfig;\nimport io.github.lnyocly.ai4j.config.HunyuanConfig;\nimport io.github.lnyocly.ai4j.exception.chain.ErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.apache.commons.lang3.ObjectUtils;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.reflections.Reflections;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description 腾讯混元测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class HunyuanTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"HUNYUAN_API_KEY\");\n        if (isBlank(apiKey)) {\n            apiKey = System.getProperty(\"hunyuan.api.key\");\n        }\n        Assume.assumeTrue(\"Skip because Hunyuan API key is not configured\", !isBlank(apiKey));\n\n        HunyuanConfig hunyuanConfig = new HunyuanConfig();\n        hunyuanConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setHunyuanConfig(hunyuanConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.HUNYUAN);\n\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"hunyuan-lite\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-vision\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"hunyuan-lite\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"hunyuan-lite\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"hunyuan-lite\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/LingyiTest.java",
    "content": "package io.github.lnyocly;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.DeepSeekConfig;\nimport io.github.lnyocly.ai4j.config.LingyiConfig;\nimport io.github.lnyocly.ai4j.exception.chain.ErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.apache.commons.lang3.ObjectUtils;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.reflections.Reflections;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description 01万物测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class LingyiTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        LingyiConfig lingyiConfig = new LingyiConfig();\n        lingyiConfig.setApiKey(\"sk-123456789\");\n\n        Configuration configuration = new Configuration();\n        configuration.setLingyiConfig(lingyiConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.LINGYI);\n\n    }\n\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-vision\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-large-fc\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/MinimaxTest.java",
    "content": "package io.github.lnyocly;\n\nimport io.github.lnyocly.ai4j.config.MinimaxConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author : isxuwl\n * @Date: 2024/10/15 16:08\n * @Model Description: minimax 测试类\n * @Description:\n */\n@Slf4j\npublic class MinimaxTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        MinimaxConfig minimaxConfig = new MinimaxConfig();\n        // minimaxConfig.setApiKey(\"sk-123456789\");\n\n        Configuration configuration = new Configuration();\n        configuration.setMinimaxConfig(minimaxConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.MINIMAX);\n\n    }\n\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"abab6.5s-chat\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-vision\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"abab6.5s-chat\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-large-fc\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/MoonshotTest.java",
    "content": "package io.github.lnyocly;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.DeepSeekConfig;\nimport io.github.lnyocly.ai4j.config.MoonshotConfig;\nimport io.github.lnyocly.ai4j.exception.chain.ErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.apache.commons.lang3.ObjectUtils;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.reflections.Reflections;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description Moonshot测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class MoonshotTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        MoonshotConfig moonshotConfig = new MoonshotConfig();\n        moonshotConfig.setApiKey(\"**********\");\n\n        Configuration configuration = new Configuration();\n        configuration.setMoonshotConfig(moonshotConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.MOONSHOT);\n\n    }\n\n\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"moonshot-v1-8k\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n\n    // kimi 不支持http url，且需要携带base64头\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"moonshot-v1-8k-vision-preview\")\n                .message(ChatMessage.withUser(\"图片中有什么？\",\n                        \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAG0AAABmCAYAAADBPx+VAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA3VSURBVHgB7Z27r0zdG8fX743i1bi1ikMoFMQloXRpKFFIqI7LH4BEQ+NWIkjQuSWCRIEoULk0gsK1kCBI0IhrQVT7tz/7zZo888yz1r7MnDl7z5xvsjkzs2fP3uu71nNfa7lkAsm7d++Sffv2JbNmzUqcc8m0adOSzZs3Z+/XES4ZckAWJEGWPiCxjsQNLWmQsWjRIpMseaxcuTKpG/7HP27I8P79e7dq1ars/yL4/v27S0ejqwv+cUOGEGGpKHR37tzJCEpHV9tnT58+dXXCJDdECBE2Ojrqjh071hpNECjx4cMHVycM1Uhbv359B2F79+51586daxN/+pyRkRFXKyRDAqxEp4yMlDDzXG1NPnnyJKkThoK0VFd1ELZu3TrzXKxKfW7dMBQ6bcuWLW2v0VlHjx41z717927ba22U9APcw7Nnz1oGEPeL3m3p2mTAYYnFmMOMXybPPXv2bNIPpFZr1NHn4HMw0KRBjg9NuRw95s8PEcz/6DZELQd/09C9QGq5RsmSRybqkwHGjh07OsJSsYYm3ijPpyHzoiacg35MLdDSIS/O1yM778jOTwYUkKNHWUzUWaOsylE00MyI0fcnOwIdjvtNdW/HZwNLGg+sR1kMepSNJXmIwxBZiG8tDTpEZzKg0GItNsosY8USkxDhD0Rinuiko2gfL/RbiD2LZAjU9zKQJj8RDR0vJBR1/Phx9+PHj9Z7REF4nTZkxzX4LCXHrV271qXkBAPGfP/atWvu/PnzHe4C97F48eIsRLZ9+3a3f/9+87dwP1JxaF7/3r17ba+5l4EcaVo0lj3SBq5kGTJSQmLWMjgYNei2GPT1MuMqGTDEFHzeQSP2wi/jGnkmPJ/nhccs44jvDAxpVcxnq0F6eT8h4ni/iIWpR5lPyA6ETkNXoSukvpJAD3AsXLiwpZs49+fPn5ke4j10TqYvegSfn0OnafC+Tv9ooA/JPkgQysqQNBzagXY55nO/oa1F7qvIPWkRL12WRpMWUvpVDYmxAPehxWSe8ZEXL20sadYIozfmNch4QJPAfeJgW3rNsnzphBKNJM2KKODo1rVOMRYik5ETy3ix4qWNI81qAAirizgMIc+yhTytx0JWZuNI03qsrgWlGtwjoS9XwgUhWGyhUaRZZQNNIEwCiXD16tXcAHUs79co0vSD8rrJCIW98pzvxpAWyyo3HYwqS0+H0BjStClcZJT5coMm6D2LOF8TolGJtK9fvyZpyiC5ePFi9nc/oJU4eiEP0jVoAnHa9wyJycITMP78+eMeP37sXrx44d6+fdt6f82aNdkx1pg9e3Zb5W+RSRE+n+VjksQWifvVaTKFhn5O8my63K8Qabdv33b379/PiAP//vuvW7BggZszZ072/+TJk91YgkafPn166zXB1rQHFvouAWHq9z3SEevSUerqCn2/dDCeta2jxYbr69evk4MHDyY7d+7MjhMnTiTPnz9Pfv/+nfQT2ggpO2dMF8cghuoM7Ygj5iWCqRlGFml0QC/ftGmTmzt3rmsaKDsgBSPh0/8yPeLLBihLkOKJc0jp8H8vUzcxIA1k6QJ/c78tWEyj5P3o4u9+jywNPdJi5rAH9x0KHcl4Hg570eQp3+vHXGyrmEeigzQsQsjavXt38ujRo44LQuDDhw+TW7duRS1HGgMxhNXHgflaNTOsHyKvHK5Ijo2jbFjJBQK9YwFd6RVMzfgRBmEfP37suBBm/p49e1qjEP2mwTViNRo0VJWH1deMXcNK08uUjVUu7s/zRaL+oLNxz1bpANco4npUgX4G2eFbpDFyQoQxojBCpEGSytmOH8qrH5Q9vuzD6ofQylkCUmh8DBAr+q8JCyVNtWQIidKQE9wNtLSQnS4jDSsxNHogzFuQBw4cyM61UKVsjfr3ooBkPSqqQHesUPWVtzi9/vQi1T+rJj7WiTz4Pt/l3LxUkr5P2VYZaZ4URpsE+st/dujQoaBBYokbrz/8TJNQYLSonrPS9kUaSkPeZyj1AWSj+d+VBoy1pIWVNed8P0Ll/ee5HdGRhrHhR5GGN0r4LGZBaj8oFDJitBTJzIZgFcmU0Y8ytWMZMzJOaXUSrUs5RxKnrxmbb5YXO9VGUhtpXldhEUogFr3IzIsvlpmdosVcGVGXFWp2oU9kLFL3dEkSz6NHEY1sjSRdIuDFWEhd8KxFqsRi1uM/nz9/zpxnwlESONdg6dKlbsaMGS4EHFHtjFIDHwKOo46l4TxSuxgDzi+rE2jg+BaFruOX4HXa0Nnf1lwAPufZeF8/r6zD97WK2qFnGjBxTw5qNGPxT+5T/r7/7RawFC3j4vTp09koCxkeHjqbHJqArmH5UrFKKksnxrK7FuRIs8STfBZv+luugXZ2pR/pP9Ois4z+TiMzUUkUjD0iEi1fzX8GmXyuxUBRcaUfykV0YZnlJGKQpOiGB76x5GeWkWWJc3mOrK6S7xdND+W5N6XyaRgtWJFe13GkaZnKOsYqGdOVVVbGupsyA/l7emTLHi7vwTdirNEt0qxnzAvBFcnQF16xh/TMpUuXHDowhlA9vQVraQhkudRdzOnK+04ZSP3DUhVSP61YsaLtd/ks7ZgtPcXqPqEafHkdqa84X6aCeL7YWlv6edGFHb+ZFICPlljHhg0bKuk0CSvVznWsotRu433alNdFrqG45ejoaPCaUkWERpLXjzFL2Rpllp7PJU2a/v7Ab8N05/9t27Z16KUqoFGsxnI9EosS2niSYg9SpU6B4JgTrvVW1flt1sT+0ADIJU2maXzcUTraGCRaL1Wp9rUMk16PMom8QhruxzvZIegJjFU7LLCePfS8uaQdPny4jTTL0dbee5mYokQsXTIWNY46kuMbnt8Kmec+LGWtOVIl9cT1rCB0V8WqkjAsRwta93TbwNYoGKsUSChN44lgBNCoHLHzquYKrU6qZ8lolCIN0Rh6cP0Q3U6I6IXILYOQI513hJaSKAorFpuHXJNfVlpRtmYBk1Su1obZr5dnKAO+L10Hrj3WZW+E3qh6IszE37F6EB+68mGpvKm4eb9bFrlzrok7fvr0Kfv727dvWRmdVTJHw0qiiCUSZ6wCK+7XL/AcsgNyL74DQQ730sv78Su7+t/A36MdY0sW5o40ahslXr58aZ5HtZB8GH64m9EmMZ7FpYw4T6QnrZfgenrhFxaSiSGXtPnz57e9TkNZLvTjeqhr734CNtrK41L40sUQckmj1lGKQ0rC37x544r8eNXRpnVE3ZZY7zXo8NomiO0ZUCj2uHz58rbXoZ6gc0uA+F6ZeKS/jhRDUq8MKrTho9fEkihMmhxtBI1DxKFY9XLpVcSkfoi8JGnToZO5sU5aiDQIW716ddt7ZLYtMQlhECdBGXZZMWldY5BHm5xgAroWj4C0hbYkSc/jBmggIrXJWlZM6pSETsEPGqZOndr2uuuR5rF169a2HoHPdurUKZM4CO1WTPqaDaAd+GFGKdIQkxAn9RuEWcTRyN2KSUgiSgF5aWzPTeA/lN5rZubMmR2bE4SIC4nJoltgAV/dVefZm72AtctUCJU2CMJ327hxY9t7EHbkyJFseq+EJSY16RPo3Dkq1kkr7+q0bNmyDuLQcZBEPYmHVdOBiJyIlrRDq41YPWfXOxUysi5fvtyaj+2BpcnsUV/oSoEMOk2CQGlr4ckhBwaetBhjCwH0ZHtJROPJkyc7UjcYLDjmrH7ADTEBXFfOYmB0k9oYBOjJ8b4aOYSe7QkKcYhFlq3QYLQhSidNmtS2RATwy8YOM3EQJsUjKiaWZ+vZToUQgzhkHXudb/PW5YMHD9yZM2faPsMwoc7RciYJXbGuBqJ1UIGKKLv915jsvgtJxCZDubdXr165mzdvtr1Hz5LONA8jrUwKPqsmVesKa49S3Q4WxmRPUEYdTjgiUcfUwLx589ySJUva3oMkP6IYddq6HMS4o55xBJBUeRjzfa4Zdeg56QZ43LhxoyPo7Lf1kNt7oO8wWAbNwaYjIv5lhyS7kRf96dvm5Jah8vfvX3flyhX35cuX6HfzFHOToS1H4BenCaHvO8pr8iDuwoUL7tevX+b5ZdbBair0xkFIlFDlW4ZknEClsp/TzXyAKVOmmHWFVSbDNw1l1+4f90U6IY/q4V27dpnE9bJ+v87QEydjqx/UamVVPRG+mwkNTYN+9tjkwzEx+atCm/X9WvWtDtAb68Wy9LXa1UmvCDDIpPkyOQ5ZwSzJ4jMrvFcr0rSjOUh+GcT4LSg5ugkW1Io0/SCDQBojh0hPlaJdah+tkVYrnTZowP8iq1F1TgMBBauufyB33x1v+NWFYmT5KmppgHC+NkAgbmRkpD3yn9QIseXymoTQFGQmIOKTxiZIWpvAatenVqRVXf2nTrAWMsPnKrMZHz6bJq5jvce6QK8J1cQNgKxlJapMPdZSR64/UivS9NztpkVEdKcrs5alhhWP9NeqlfWopzhZScI6QxseegZRGeg5a8C3Re1Mfl1ScP36ddcUaMuv24iOJtz7sbUjTS4qBvKmstYJoUauiuD3k5qhyr7QdUHMeCgLa1Ear9NquemdXgmum4fvJ6w1lqsuDhNrg1qSpleJK7K3TF0Q2jSd94uSZ60kK1e3qyVpQK6PVWXp2/FC3mp6jBhKKOiY2h3gtUV64TWM6wDETRPLDfSakXmH3w8g9Jlug8ZtTt4kVF0kLUYYmCCtD/DrQ5YhMGbA9L3ucdjh0y8kOHW5gU/VEEmJTcL4Pz/f7mgoAbYkAAAAAElFTkSuQmCC\")\n                ).build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"moonshot-v1-8k\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"moonshot-v1-8k\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"moonshot-v1-8k\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/OllamaTest.java",
    "content": "package io.github.lnyocly;\n\nimport io.github.lnyocly.ai4j.config.DeepSeekConfig;\nimport io.github.lnyocly.ai4j.config.OllamaConfig;\nimport io.github.lnyocly.ai4j.interceptor.ContentTypeInterceptor;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.net.InetSocketAddress;\nimport java.net.Proxy;\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description ollama测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class OllamaTest {\n\n    private IChatService chatService;\n\n    private IChatService webEnhance;\n\n    private IEmbeddingService embeddingService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        SearXNGConfig searXNGConfig = new SearXNGConfig();\n        searXNGConfig.setUrl(\"http://127.0.0.1:8080/search\");\n\n        OllamaConfig ollamaConfig = new OllamaConfig();\n\n        Configuration configuration = new Configuration();\n        configuration.setOllamaConfig(ollamaConfig);\n        configuration.setSearXNGConfig(searXNGConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .addInterceptor(new ContentTypeInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                //.sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                //.hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.OLLAMA);\n        webEnhance = aiService.webSearchEnhance(chatService);\n\n        embeddingService = aiService.getEmbeddingService(PlatformType.OLLAMA);\n    }\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen2.5:7b\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n    @Test\n    public void test_chatCompletions_common_websearch_enhance() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen2.5:7b\")\n                .message(ChatMessage.withUser(\"鸡你太美是什么梗\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = webEnhance.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        // ollama只支持传输base64图片\n\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"llava\")\n                .message(ChatMessage.withUser(\"图片中有什么？\",\n                        \"iVBORw0KGgoAAAANSUhEUgAAAG0AAABmCAYAAADBPx+VAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA3VSURBVHgB7Z27r0zdG8fX743i1bi1ikMoFMQloXRpKFFIqI7LH4BEQ+NWIkjQuSWCRIEoULk0gsK1kCBI0IhrQVT7tz/7zZo888yz1r7MnDl7z5xvsjkzs2fP3uu71nNfa7lkAsm7d++Sffv2JbNmzUqcc8m0adOSzZs3Z+/XES4ZckAWJEGWPiCxjsQNLWmQsWjRIpMseaxcuTKpG/7HP27I8P79e7dq1ars/yL4/v27S0ejqwv+cUOGEGGpKHR37tzJCEpHV9tnT58+dXXCJDdECBE2Ojrqjh071hpNECjx4cMHVycM1Uhbv359B2F79+51586daxN/+pyRkRFXKyRDAqxEp4yMlDDzXG1NPnnyJKkThoK0VFd1ELZu3TrzXKxKfW7dMBQ6bcuWLW2v0VlHjx41z717927ba22U9APcw7Nnz1oGEPeL3m3p2mTAYYnFmMOMXybPPXv2bNIPpFZr1NHn4HMw0KRBjg9NuRw95s8PEcz/6DZELQd/09C9QGq5RsmSRybqkwHGjh07OsJSsYYm3ijPpyHzoiacg35MLdDSIS/O1yM778jOTwYUkKNHWUzUWaOsylE00MyI0fcnOwIdjvtNdW/HZwNLGg+sR1kMepSNJXmIwxBZiG8tDTpEZzKg0GItNsosY8USkxDhD0Rinuiko2gfL/RbiD2LZAjU9zKQJj8RDR0vJBR1/Phx9+PHj9Z7REF4nTZkxzX4LCXHrV271qXkBAPGfP/atWvu/PnzHe4C97F48eIsRLZ9+3a3f/9+87dwP1JxaF7/3r17ba+5l4EcaVo0lj3SBq5kGTJSQmLWMjgYNei2GPT1MuMqGTDEFHzeQSP2wi/jGnkmPJ/nhccs44jvDAxpVcxnq0F6eT8h4ni/iIWpR5lPyA6ETkNXoSukvpJAD3AsXLiwpZs49+fPn5ke4j10TqYvegSfn0OnafC+Tv9ooA/JPkgQysqQNBzagXY55nO/oa1F7qvIPWkRL12WRpMWUvpVDYmxAPehxWSe8ZEXL20sadYIozfmNch4QJPAfeJgW3rNsnzphBKNJM2KKODo1rVOMRYik5ETy3ix4qWNI81qAAirizgMIc+yhTytx0JWZuNI03qsrgWlGtwjoS9XwgUhWGyhUaRZZQNNIEwCiXD16tXcAHUs79co0vSD8rrJCIW98pzvxpAWyyo3HYwqS0+H0BjStClcZJT5coMm6D2LOF8TolGJtK9fvyZpyiC5ePFi9nc/oJU4eiEP0jVoAnHa9wyJycITMP78+eMeP37sXrx44d6+fdt6f82aNdkx1pg9e3Zb5W+RSRE+n+VjksQWifvVaTKFhn5O8my63K8Qabdv33b379/PiAP//vuvW7BggZszZ072/+TJk91YgkafPn166zXB1rQHFvouAWHq9z3SEevSUerqCn2/dDCeta2jxYbr69evk4MHDyY7d+7MjhMnTiTPnz9Pfv/+nfQT2ggpO2dMF8cghuoM7Ygj5iWCqRlGFml0QC/ftGmTmzt3rmsaKDsgBSPh0/8yPeLLBihLkOKJc0jp8H8vUzcxIA1k6QJ/c78tWEyj5P3o4u9+jywNPdJi5rAH9x0KHcl4Hg570eQp3+vHXGyrmEeigzQsQsjavXt38ujRo44LQuDDhw+TW7duRS1HGgMxhNXHgflaNTOsHyKvHK5Ijo2jbFjJBQK9YwFd6RVMzfgRBmEfP37suBBm/p49e1qjEP2mwTViNRo0VJWH1deMXcNK08uUjVUu7s/zRaL+oLNxz1bpANco4npUgX4G2eFbpDFyQoQxojBCpEGSytmOH8qrH5Q9vuzD6ofQylkCUmh8DBAr+q8JCyVNtWQIidKQE9wNtLSQnS4jDSsxNHogzFuQBw4cyM61UKVsjfr3ooBkPSqqQHesUPWVtzi9/vQi1T+rJj7WiTz4Pt/l3LxUkr5P2VYZaZ4URpsE+st/dujQoaBBYokbrz/8TJNQYLSonrPS9kUaSkPeZyj1AWSj+d+VBoy1pIWVNed8P0Ll/ee5HdGRhrHhR5GGN0r4LGZBaj8oFDJitBTJzIZgFcmU0Y8ytWMZMzJOaXUSrUs5RxKnrxmbb5YXO9VGUhtpXldhEUogFr3IzIsvlpmdosVcGVGXFWp2oU9kLFL3dEkSz6NHEY1sjSRdIuDFWEhd8KxFqsRi1uM/nz9/zpxnwlESONdg6dKlbsaMGS4EHFHtjFIDHwKOo46l4TxSuxgDzi+rE2jg+BaFruOX4HXa0Nnf1lwAPufZeF8/r6zD97WK2qFnGjBxTw5qNGPxT+5T/r7/7RawFC3j4vTp09koCxkeHjqbHJqArmH5UrFKKksnxrK7FuRIs8STfBZv+luugXZ2pR/pP9Ois4z+TiMzUUkUjD0iEi1fzX8GmXyuxUBRcaUfykV0YZnlJGKQpOiGB76x5GeWkWWJc3mOrK6S7xdND+W5N6XyaRgtWJFe13GkaZnKOsYqGdOVVVbGupsyA/l7emTLHi7vwTdirNEt0qxnzAvBFcnQF16xh/TMpUuXHDowhlA9vQVraQhkudRdzOnK+04ZSP3DUhVSP61YsaLtd/ks7ZgtPcXqPqEafHkdqa84X6aCeL7YWlv6edGFHb+ZFICPlljHhg0bKuk0CSvVznWsotRu433alNdFrqG45ejoaPCaUkWERpLXjzFL2Rpllp7PJU2a/v7Ab8N05/9t27Z16KUqoFGsxnI9EosS2niSYg9SpU6B4JgTrvVW1flt1sT+0ADIJU2maXzcUTraGCRaL1Wp9rUMk16PMom8QhruxzvZIegJjFU7LLCePfS8uaQdPny4jTTL0dbee5mYokQsXTIWNY46kuMbnt8Kmec+LGWtOVIl9cT1rCB0V8WqkjAsRwta93TbwNYoGKsUSChN44lgBNCoHLHzquYKrU6qZ8lolCIN0Rh6cP0Q3U6I6IXILYOQI513hJaSKAorFpuHXJNfVlpRtmYBk1Su1obZr5dnKAO+L10Hrj3WZW+E3qh6IszE37F6EB+68mGpvKm4eb9bFrlzrok7fvr0Kfv727dvWRmdVTJHw0qiiCUSZ6wCK+7XL/AcsgNyL74DQQ730sv78Su7+t/A36MdY0sW5o40ahslXr58aZ5HtZB8GH64m9EmMZ7FpYw4T6QnrZfgenrhFxaSiSGXtPnz57e9TkNZLvTjeqhr734CNtrK41L40sUQckmj1lGKQ0rC37x544r8eNXRpnVE3ZZY7zXo8NomiO0ZUCj2uHz58rbXoZ6gc0uA+F6ZeKS/jhRDUq8MKrTho9fEkihMmhxtBI1DxKFY9XLpVcSkfoi8JGnToZO5sU5aiDQIW716ddt7ZLYtMQlhECdBGXZZMWldY5BHm5xgAroWj4C0hbYkSc/jBmggIrXJWlZM6pSETsEPGqZOndr2uuuR5rF169a2HoHPdurUKZM4CO1WTPqaDaAd+GFGKdIQkxAn9RuEWcTRyN2KSUgiSgF5aWzPTeA/lN5rZubMmR2bE4SIC4nJoltgAV/dVefZm72AtctUCJU2CMJ327hxY9t7EHbkyJFseq+EJSY16RPo3Dkq1kkr7+q0bNmyDuLQcZBEPYmHVdOBiJyIlrRDq41YPWfXOxUysi5fvtyaj+2BpcnsUV/oSoEMOk2CQGlr4ckhBwaetBhjCwH0ZHtJROPJkyc7UjcYLDjmrH7ADTEBXFfOYmB0k9oYBOjJ8b4aOYSe7QkKcYhFlq3QYLQhSidNmtS2RATwy8YOM3EQJsUjKiaWZ+vZToUQgzhkHXudb/PW5YMHD9yZM2faPsMwoc7RciYJXbGuBqJ1UIGKKLv915jsvgtJxCZDubdXr165mzdvtr1Hz5LONA8jrUwKPqsmVesKa49S3Q4WxmRPUEYdTjgiUcfUwLx589ySJUva3oMkP6IYddq6HMS4o55xBJBUeRjzfa4Zdeg56QZ43LhxoyPo7Lf1kNt7oO8wWAbNwaYjIv5lhyS7kRf96dvm5Jah8vfvX3flyhX35cuX6HfzFHOToS1H4BenCaHvO8pr8iDuwoUL7tevX+b5ZdbBair0xkFIlFDlW4ZknEClsp/TzXyAKVOmmHWFVSbDNw1l1+4f90U6IY/q4V27dpnE9bJ+v87QEydjqx/UamVVPRG+mwkNTYN+9tjkwzEx+atCm/X9WvWtDtAb68Wy9LXa1UmvCDDIpPkyOQ5ZwSzJ4jMrvFcr0rSjOUh+GcT4LSg5ugkW1Io0/SCDQBojh0hPlaJdah+tkVYrnTZowP8iq1F1TgMBBauufyB33x1v+NWFYmT5KmppgHC+NkAgbmRkpD3yn9QIseXymoTQFGQmIOKTxiZIWpvAatenVqRVXf2nTrAWMsPnKrMZHz6bJq5jvce6QK8J1cQNgKxlJapMPdZSR64/UivS9NztpkVEdKcrs5alhhWP9NeqlfWopzhZScI6QxseegZRGeg5a8C3Re1Mfl1ScP36ddcUaMuv24iOJtz7sbUjTS4qBvKmstYJoUauiuD3k5qhyr7QdUHMeCgLa1Ear9NquemdXgmum4fvJ6w1lqsuDhNrg1qSpleJK7K3TF0Q2jSd94uSZ60kK1e3qyVpQK6PVWXp2/FC3mp6jBhKKOiY2h3gtUV64TWM6wDETRPLDfSakXmH3w8g9Jlug8ZtTt4kVF0kLUYYmCCtD/DrQ5YhMGbA9L3ucdjh0y8kOHW5gU/VEEmJTcL4Pz/f7mgoAbYkAAAAAElFTkSuQmCC\")\n                ).build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal_stream() throws Exception {\n\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"llava\")\n                .message(ChatMessage.withUser(\"图片中有什么？\",\n                        \"iVBORw0KGgoAAAANSUhEUgAAAG0AAABmCAYAAADBPx+VAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA3VSURBVHgB7Z27r0zdG8fX743i1bi1ikMoFMQloXRpKFFIqI7LH4BEQ+NWIkjQuSWCRIEoULk0gsK1kCBI0IhrQVT7tz/7zZo888yz1r7MnDl7z5xvsjkzs2fP3uu71nNfa7lkAsm7d++Sffv2JbNmzUqcc8m0adOSzZs3Z+/XES4ZckAWJEGWPiCxjsQNLWmQsWjRIpMseaxcuTKpG/7HP27I8P79e7dq1ars/yL4/v27S0ejqwv+cUOGEGGpKHR37tzJCEpHV9tnT58+dXXCJDdECBE2Ojrqjh071hpNECjx4cMHVycM1Uhbv359B2F79+51586daxN/+pyRkRFXKyRDAqxEp4yMlDDzXG1NPnnyJKkThoK0VFd1ELZu3TrzXKxKfW7dMBQ6bcuWLW2v0VlHjx41z717927ba22U9APcw7Nnz1oGEPeL3m3p2mTAYYnFmMOMXybPPXv2bNIPpFZr1NHn4HMw0KRBjg9NuRw95s8PEcz/6DZELQd/09C9QGq5RsmSRybqkwHGjh07OsJSsYYm3ijPpyHzoiacg35MLdDSIS/O1yM778jOTwYUkKNHWUzUWaOsylE00MyI0fcnOwIdjvtNdW/HZwNLGg+sR1kMepSNJXmIwxBZiG8tDTpEZzKg0GItNsosY8USkxDhD0Rinuiko2gfL/RbiD2LZAjU9zKQJj8RDR0vJBR1/Phx9+PHj9Z7REF4nTZkxzX4LCXHrV271qXkBAPGfP/atWvu/PnzHe4C97F48eIsRLZ9+3a3f/9+87dwP1JxaF7/3r17ba+5l4EcaVo0lj3SBq5kGTJSQmLWMjgYNei2GPT1MuMqGTDEFHzeQSP2wi/jGnkmPJ/nhccs44jvDAxpVcxnq0F6eT8h4ni/iIWpR5lPyA6ETkNXoSukvpJAD3AsXLiwpZs49+fPn5ke4j10TqYvegSfn0OnafC+Tv9ooA/JPkgQysqQNBzagXY55nO/oa1F7qvIPWkRL12WRpMWUvpVDYmxAPehxWSe8ZEXL20sadYIozfmNch4QJPAfeJgW3rNsnzphBKNJM2KKODo1rVOMRYik5ETy3ix4qWNI81qAAirizgMIc+yhTytx0JWZuNI03qsrgWlGtwjoS9XwgUhWGyhUaRZZQNNIEwCiXD16tXcAHUs79co0vSD8rrJCIW98pzvxpAWyyo3HYwqS0+H0BjStClcZJT5coMm6D2LOF8TolGJtK9fvyZpyiC5ePFi9nc/oJU4eiEP0jVoAnHa9wyJycITMP78+eMeP37sXrx44d6+fdt6f82aNdkx1pg9e3Zb5W+RSRE+n+VjksQWifvVaTKFhn5O8my63K8Qabdv33b379/PiAP//vuvW7BggZszZ072/+TJk91YgkafPn166zXB1rQHFvouAWHq9z3SEevSUerqCn2/dDCeta2jxYbr69evk4MHDyY7d+7MjhMnTiTPnz9Pfv/+nfQT2ggpO2dMF8cghuoM7Ygj5iWCqRlGFml0QC/ftGmTmzt3rmsaKDsgBSPh0/8yPeLLBihLkOKJc0jp8H8vUzcxIA1k6QJ/c78tWEyj5P3o4u9+jywNPdJi5rAH9x0KHcl4Hg570eQp3+vHXGyrmEeigzQsQsjavXt38ujRo44LQuDDhw+TW7duRS1HGgMxhNXHgflaNTOsHyKvHK5Ijo2jbFjJBQK9YwFd6RVMzfgRBmEfP37suBBm/p49e1qjEP2mwTViNRo0VJWH1deMXcNK08uUjVUu7s/zRaL+oLNxz1bpANco4npUgX4G2eFbpDFyQoQxojBCpEGSytmOH8qrH5Q9vuzD6ofQylkCUmh8DBAr+q8JCyVNtWQIidKQE9wNtLSQnS4jDSsxNHogzFuQBw4cyM61UKVsjfr3ooBkPSqqQHesUPWVtzi9/vQi1T+rJj7WiTz4Pt/l3LxUkr5P2VYZaZ4URpsE+st/dujQoaBBYokbrz/8TJNQYLSonrPS9kUaSkPeZyj1AWSj+d+VBoy1pIWVNed8P0Ll/ee5HdGRhrHhR5GGN0r4LGZBaj8oFDJitBTJzIZgFcmU0Y8ytWMZMzJOaXUSrUs5RxKnrxmbb5YXO9VGUhtpXldhEUogFr3IzIsvlpmdosVcGVGXFWp2oU9kLFL3dEkSz6NHEY1sjSRdIuDFWEhd8KxFqsRi1uM/nz9/zpxnwlESONdg6dKlbsaMGS4EHFHtjFIDHwKOo46l4TxSuxgDzi+rE2jg+BaFruOX4HXa0Nnf1lwAPufZeF8/r6zD97WK2qFnGjBxTw5qNGPxT+5T/r7/7RawFC3j4vTp09koCxkeHjqbHJqArmH5UrFKKksnxrK7FuRIs8STfBZv+luugXZ2pR/pP9Ois4z+TiMzUUkUjD0iEi1fzX8GmXyuxUBRcaUfykV0YZnlJGKQpOiGB76x5GeWkWWJc3mOrK6S7xdND+W5N6XyaRgtWJFe13GkaZnKOsYqGdOVVVbGupsyA/l7emTLHi7vwTdirNEt0qxnzAvBFcnQF16xh/TMpUuXHDowhlA9vQVraQhkudRdzOnK+04ZSP3DUhVSP61YsaLtd/ks7ZgtPcXqPqEafHkdqa84X6aCeL7YWlv6edGFHb+ZFICPlljHhg0bKuk0CSvVznWsotRu433alNdFrqG45ejoaPCaUkWERpLXjzFL2Rpllp7PJU2a/v7Ab8N05/9t27Z16KUqoFGsxnI9EosS2niSYg9SpU6B4JgTrvVW1flt1sT+0ADIJU2maXzcUTraGCRaL1Wp9rUMk16PMom8QhruxzvZIegJjFU7LLCePfS8uaQdPny4jTTL0dbee5mYokQsXTIWNY46kuMbnt8Kmec+LGWtOVIl9cT1rCB0V8WqkjAsRwta93TbwNYoGKsUSChN44lgBNCoHLHzquYKrU6qZ8lolCIN0Rh6cP0Q3U6I6IXILYOQI513hJaSKAorFpuHXJNfVlpRtmYBk1Su1obZr5dnKAO+L10Hrj3WZW+E3qh6IszE37F6EB+68mGpvKm4eb9bFrlzrok7fvr0Kfv727dvWRmdVTJHw0qiiCUSZ6wCK+7XL/AcsgNyL74DQQ730sv78Su7+t/A36MdY0sW5o40ahslXr58aZ5HtZB8GH64m9EmMZ7FpYw4T6QnrZfgenrhFxaSiSGXtPnz57e9TkNZLvTjeqhr734CNtrK41L40sUQckmj1lGKQ0rC37x544r8eNXRpnVE3ZZY7zXo8NomiO0ZUCj2uHz58rbXoZ6gc0uA+F6ZeKS/jhRDUq8MKrTho9fEkihMmhxtBI1DxKFY9XLpVcSkfoi8JGnToZO5sU5aiDQIW716ddt7ZLYtMQlhECdBGXZZMWldY5BHm5xgAroWj4C0hbYkSc/jBmggIrXJWlZM6pSETsEPGqZOndr2uuuR5rF169a2HoHPdurUKZM4CO1WTPqaDaAd+GFGKdIQkxAn9RuEWcTRyN2KSUgiSgF5aWzPTeA/lN5rZubMmR2bE4SIC4nJoltgAV/dVefZm72AtctUCJU2CMJ327hxY9t7EHbkyJFseq+EJSY16RPo3Dkq1kkr7+q0bNmyDuLQcZBEPYmHVdOBiJyIlrRDq41YPWfXOxUysi5fvtyaj+2BpcnsUV/oSoEMOk2CQGlr4ckhBwaetBhjCwH0ZHtJROPJkyc7UjcYLDjmrH7ADTEBXFfOYmB0k9oYBOjJ8b4aOYSe7QkKcYhFlq3QYLQhSidNmtS2RATwy8YOM3EQJsUjKiaWZ+vZToUQgzhkHXudb/PW5YMHD9yZM2faPsMwoc7RciYJXbGuBqJ1UIGKKLv915jsvgtJxCZDubdXr165mzdvtr1Hz5LONA8jrUwKPqsmVesKa49S3Q4WxmRPUEYdTjgiUcfUwLx589ySJUva3oMkP6IYddq6HMS4o55xBJBUeRjzfa4Zdeg56QZ43LhxoyPo7Lf1kNt7oO8wWAbNwaYjIv5lhyS7kRf96dvm5Jah8vfvX3flyhX35cuX6HfzFHOToS1H4BenCaHvO8pr8iDuwoUL7tevX+b5ZdbBair0xkFIlFDlW4ZknEClsp/TzXyAKVOmmHWFVSbDNw1l1+4f90U6IY/q4V27dpnE9bJ+v87QEydjqx/UamVVPRG+mwkNTYN+9tjkwzEx+atCm/X9WvWtDtAb68Wy9LXa1UmvCDDIpPkyOQ5ZwSzJ4jMrvFcr0rSjOUh+GcT4LSg5ugkW1Io0/SCDQBojh0hPlaJdah+tkVYrnTZowP8iq1F1TgMBBauufyB33x1v+NWFYmT5KmppgHC+NkAgbmRkpD3yn9QIseXymoTQFGQmIOKTxiZIWpvAatenVqRVXf2nTrAWMsPnKrMZHz6bJq5jvce6QK8J1cQNgKxlJapMPdZSR64/UivS9NztpkVEdKcrs5alhhWP9NeqlfWopzhZScI6QxseegZRGeg5a8C3Re1Mfl1ScP36ddcUaMuv24iOJtz7sbUjTS4qBvKmstYJoUauiuD3k5qhyr7QdUHMeCgLa1Ear9NquemdXgmum4fvJ6w1lqsuDhNrg1qSpleJK7K3TF0Q2jSd94uSZ60kK1e3qyVpQK6PVWXp2/FC3mp6jBhKKOiY2h3gtUV64TWM6wDETRPLDfSakXmH3w8g9Jlug8ZtTt4kVF0kLUYYmCCtD/DrQ5YhMGbA9L3ucdjh0y8kOHW5gU/VEEmJTcL4Pz/f7mgoAbYkAAAAAElFTkSuQmCC\")\n                ).build();\n\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen:0.5b\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen2.5:7b\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"qwen2.5:7b\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n    @Test\n    public void test_embedding() throws Exception {\n        Embedding build = Embedding.builder().model(\"all-minilm\").input(\"Why is the sky blue?\").build();\n        EmbeddingResponse embeddingResponse = embeddingService.embedding(build);\n        System.out.println(embeddingResponse);\n    }\n    @Test\n    public void test_embedding_multiple_input() throws Exception {\n        List<String> inputs =  new ArrayList<>();\n        inputs.add(\"Why is the sky blue?\");\n        inputs.add(\"Why is the grass green?\");\n\n        Embedding build = Embedding.builder().model(\"all-minilm\").input(inputs).build();\n        EmbeddingResponse embeddingResponse = embeddingService.embedding(build);\n        System.out.println(embeddingResponse);\n    }\n\n\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/OpenAiTest.java",
    "content": "package io.github.lnyocly;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.interceptor.ContentTypeInterceptor;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.RealtimeListener;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.network.ConnectionPoolProvider;\nimport io.github.lnyocly.ai4j.network.DispatcherProvider;\nimport io.github.lnyocly.ai4j.platform.openai.audio.entity.*;\nimport io.github.lnyocly.ai4j.platform.openai.audio.enums.AudioEnum;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.*;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.service.*;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport io.github.lnyocly.ai4j.document.RecursiveCharacterTextSplitter;\nimport io.github.lnyocly.ai4j.service.spi.ServiceLoaderUtil;\nimport io.github.lnyocly.ai4j.document.TikaUtil;\nimport io.github.lnyocly.ai4j.vector.VectorDataEntity;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeDelete;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeInsert;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeQuery;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeVectors;\nimport io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.*;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport okio.ByteString;\nimport org.apache.commons.io.FileUtils;\nimport org.apache.tika.exception.TikaException;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.reflections.Reflections;\nimport org.xml.sax.SAXException;\n\nimport java.io.File;\nimport java.io.FileInputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.net.InetSocketAddress;\nimport java.net.Proxy;\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.*;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Collectors;\n\n/**\n * @Author cly\n * @Description OpenAi测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class OpenAiTest {\n\n    private IEmbeddingService embeddingService;\n\n    private IChatService chatService;\n    private IChatService webEnhance;\n\n    private IAudioService audioService;\n    private IRealtimeService realtimeService;\n    Reflections reflections = new Reflections();\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        SearXNGConfig searXNGConfig = new SearXNGConfig();\n        searXNGConfig.setUrl(\"http://127.0.0.1:8080/search\");\n\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n        openAiConfig.setApiHost(\"************\");\n        openAiConfig.setApiKey(\"*************\");\n\n\n        Configuration configuration = new Configuration();\n        configuration.setOpenAiConfig(openAiConfig);\n        configuration.setSearXNGConfig(searXNGConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n        DispatcherProvider dispatcherProvider = ServiceLoaderUtil.load(DispatcherProvider.class);\n        ConnectionPoolProvider connectionPoolProvider = ServiceLoaderUtil.load(ConnectionPoolProvider.class);\n        Dispatcher dispatcher = dispatcherProvider.getDispatcher();\n        ConnectionPool connectionPool = connectionPoolProvider.getConnectionPool();\n\n\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ContentTypeInterceptor())\n                //.addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .dispatcher(dispatcher)\n                .connectionPool(connectionPool)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n\n        //chatService = aiService.getChatService(PlatformType.getPlatform(\"OPENAI\"));\n        chatService = aiService.getChatService(PlatformType.OPENAI);\n\n        audioService = aiService.getAudioService(PlatformType.OPENAI);\n\n        realtimeService = aiService.getRealtimeService(PlatformType.OPENAI);\n\n        webEnhance = aiService.webSearchEnhance(chatService);\n    }\n\n\n    @Test\n    public void test_embed() throws Exception {\n        Embedding build = Embedding.builder()\n                .input(\"The food was delicious and the waiter...\")\n                .model(\"text-embedding-ada-002\")\n                .build();\n        System.out.println(build);\n\n        EmbeddingResponse embedding = embeddingService.embedding(null, null, build);\n\n        System.out.println(embedding);\n\n\n    }\n\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_history() throws Exception {\n        List<ChatMessage> history = new ArrayList<>();\n\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 向历史中添加刚刚问过的消息\n        history.add(chatCompletion.getMessages().get(chatCompletion.getMessages().size()-1));\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse.getChoices().get(0).getMessage());\n        // 将返回的消息添加到历史中\n        history.add(chatCompletionResponse.getChoices().get(0).getMessage());\n\n\n        // 开始第二次问答\n        history.add(ChatMessage.withUser(\"我刚刚问了什么问题\"));\n        ChatCompletion chatCompletionWithHistory = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .messages(history)\n                .build();\n        ChatCompletionResponse chatCompletionResponseWithHistory = chatService.chatCompletion(chatCompletionWithHistory);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponseWithHistory);\n\n    }\n\n    @Test\n    public void test_chatCompletions_common_websearch_enhance() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"鸡你太美\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = webEnhance.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        // 当传递base64图片时的格式\n        // \"image_url\": {\"url\": f\"data:image/jpeg;base64,{base64_image}\"},\n\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n        System.out.println(new ObjectMapper().writeValueAsString(chatCompletion));\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n    @Test\n    public void test_chatCompletions_multimodal_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                log.info(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"deepseek-reasoner\")\n                .message(ChatMessage.withUser(\"请思考，先有鸡还是先有蛋\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                long aaa = System.currentTimeMillis();\n                //System.out.println(aaa - currentTimeMillis);\n                log.info(this.getCurrStr());\n            }\n        };\n\n        long currentTimeMillis = System.currentTimeMillis();\n        log.info(\"开始请求\");\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        log.info(\"请求结束\");\n        long aaa = System.currentTimeMillis();\n        System.out.println(aaa - currentTimeMillis);\n\n\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getReasoningOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream_cancel() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4.1-nano\")\n                .message(ChatMessage.withUser(\"你好，你是谁\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void error(Throwable t, Response response) {\n                log.error(\"出错了\");\n                log.error(t.getMessage());\n                log.error(response.message());\n            }\n\n            @Override\n            protected void send() {\n                long aaa = System.currentTimeMillis();\n                //System.out.println(aaa - currentTimeMillis);\n\n\n                if(\"我\".equals(this.getCurrStr())) {\n                    this.getEventSource().cancel();\n                    log.warn(\"取消\");\n                }\n                log.info(this.getCurrData());\n            }\n        };\n\n        long currentTimeMillis = System.currentTimeMillis();\n        log.info(\"开始请求\");\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        log.info(\"请求结束\");\n        long aaa = System.currentTimeMillis();\n        System.out.println(aaa - currentTimeMillis);\n\n\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getReasoningOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"获取当前的时间\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .mcpService(\"TestService\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        ObjectMapper objectMapper = new ObjectMapper();\n        System.out.println(objectMapper.writeValueAsString(chatCompletion));\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(objectMapper.writeValueAsString(chatCompletion));\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n\n    @Test\n    public void test_text_to_speech() throws IOException {\n        TextToSpeech speechRequest = TextToSpeech.builder()\n                .input(\"你好，有什么我可以帮助你的吗？\")\n                .voice(AudioEnum.Voice.ECHO.getValue())\n                .build();\n        InputStream inputStream = audioService.textToSpeech(speechRequest);\n        FileUtils.copyToFile(inputStream, new File(\"C:\\\\Users\\\\1\\\\Desktop\\\\audio.mp3\"));\n\n    }\n\n    @Test\n    public void test_transcription(){\n        Transcription request = Transcription.builder()\n                .file(new File(\"C:\\\\Users\\\\1\\\\Desktop\\\\audio.mp3\"))\n                .model(\"whisper-1\")\n                .build();\n\n        TranscriptionResponse transcription = audioService.transcription(request);\n        System.out.println(transcription);\n\n    }\n\n    @Test\n    public void test_translation(){\n        Translation request = Translation.builder()\n                .file(new File(\"C:\\\\Users\\\\1\\\\Desktop\\\\audio.mp3\"))\n                .model(\"whisper-1\")\n                .build();\n\n        TranslationResponse translation = audioService.translation(request);\n        System.out.println(translation);\n\n    }\n\n\n    @Test\n    public void test_create_websocket(){\n        CountDownLatch countDownLatch = new CountDownLatch(1);\n        WebSocket realtimeClient = realtimeService.createRealtimeClient(\"gpt-4o-realtime-preview\", new RealtimeListener() {\n            @Override\n            protected void onOpen(WebSocket webSocket) {\n                log.info(\"OpenAi Realtime 连接成功\");\n\n                log.info(\"准备发送消息\");\n                webSocket.send(\"{\\\"type\\\":\\\"response.create\\\",\\\"response\\\":{\\\"modalities\\\":[\\\"text\\\"],\\\"instructions\\\":\\\"Please assist the user.\\\"}}\");\n\n\n                webSocket.close(1000, \"OpenAi realtime client 关闭\");\n                //countDownLatch.countDown();\n\n            }\n\n            @Override\n            protected void onMessage(ByteString bytes) {\n                log.info(\"收到消息：{}\", bytes.toString());\n            }\n\n            @Override\n            protected void onMessage(String text) {\n                log.info(\"收到消息：{}\", text);\n            }\n\n            @Override\n            protected void onFailure() {\n                System.out.println(\"连接失败\");\n            }\n        });\n\n        System.out.println(11111111);\n\n\n        try {\n            countDownLatch.await();\n        } catch (InterruptedException e) {\n            e.printStackTrace();\n        }\n\n    }\n\n\n    @Test\n    public void test__() throws Exception {\n        // 读取文件\n\n        // ......\n\n        // 分割文本\n\n        String word = \" <br/> <div class=\\\"upload-container\\\"> <svg t=\\\"1693153944798\\\" class=\\\"icon\\\" viewBox=\\\"0 0 1024 1024\\\" version=\\\"1.1\\\" xmlns=\\\"http://www.w3.org/2000/svg\\\" p-id=\\\"4946\\\" width=\\\"24\\\" height=\\\"24\\\"><path d=\\\"M804.23424 866.5344 630.5792 866.5344c-72.14592 0-129.62816-58.69568-129.62816-129.62816L500.95104 494.7712c0-9.78432 8.55552-18.34496 18.34496-18.34496 9.7792 0 18.34496 8.56064 18.34496 18.34496l0 240.91136c0 51.36896 41.57952 92.94336 92.93824 92.94336l173.65504 0c86.82496 0 158.98112-64.81408 163.87072-147.968 2.44736-44.02176-12.23168-86.82496-42.8032-118.62528-30.57152-31.80032-70.92736-50.14016-116.1728-50.14016l-3.67104 0c-6.11328 0-11.00288-2.44736-14.67392-6.11328-3.66592-4.8896-4.89472-9.7792-3.66592-15.89248 7.33696-35.46624 8.56064-72.15616 1.21856-107.6224-20.79232-118.62016-119.84384-209.11616-239.68768-222.5664-77.04576-8.56064-152.86272 15.8976-211.56352 68.48C279.6032 279.53664 245.36576 352.91136 245.36576 431.17568c0 8.56064-6.11328 17.11616-15.8976 18.34496-95.3856 13.45024-165.0944 97.83808-161.42336 195.66592 3.67104 100.2752 92.94336 183.43424 198.11328 183.43424l84.3776 0c9.78432 0 18.34496 8.55552 18.34496 18.33984 0 9.78944-8.56064 17.1264-19.56864 17.1264l-84.3776 0c-123.51488 0-228.6848-97.82784-233.5744-217.6768-4.89472-111.28832 70.92736-207.8976 177.32096-231.13728 4.8896-81.93536 41.57952-158.98112 103.94624-214.01088 66.03776-58.69568 151.63904-86.82496 240.91648-77.04064 135.74144 13.45024 248.24832 117.4016 271.48288 251.91936 6.11328 34.24256 7.33696 67.26144 2.44736 100.2752 47.6928 3.67104 92.93824 25.68704 124.73344 61.14304 37.90848 40.36096 56.25344 91.7248 52.58752 146.75456C998.67648 785.82272 910.62784 866.5344 804.23424 866.5344L804.23424 866.5344z\\\" fill=\\\"#2c2c2c\\\" p-id=\\\"4947\\\"></path><path d=\\\"M663.59808 631.73632c-4.8896 0-9.78432-1.22368-13.45536-4.8896l-132.0704-133.2992L385.9968 625.61792c-7.33696 7.34208-18.34496 7.34208-25.68192 0-7.33696-7.33696-7.33696-18.34496 0-25.68192l145.52576-145.52576c7.33696-7.33696 19.56352-7.33696 25.68192 0l145.53088 145.52576c7.33696 7.33696 7.33696 18.34496 0 25.68192C673.38752 629.29408 668.48768 631.73632 663.59808 631.73632L663.59808 631.73632z\\\" fill=\\\"#2c2c2c\\\" p-id=\\\"4948\\\"></path></svg> <input type=\\\"file\\\" onchange=\\\"uploadFile()\\\" id=\\\"myFile\\\" accept=\\\".pdf\\\"><div>上传pdf</div> </div>\";\n\n        RecursiveCharacterTextSplitter recursiveCharacterTextSplitter = new RecursiveCharacterTextSplitter(1000, 200);\n\n        List<String> strings = recursiveCharacterTextSplitter.splitText(word);\n\n        System.out.println(strings.size());\n\n\n        // 转为向量\n        Embedding build = Embedding.builder()\n                .input(strings)\n                .model(\"text-embedding-3-small\")\n                .build();\n        System.out.println(build);\n\n        EmbeddingResponse embedding = embeddingService.embedding(null, null, build);\n\n        System.out.println(embedding);\n\n        List<List<Float>> vectors = embedding.getData().stream().map(EmbeddingObject::getEmbedding).collect(Collectors.toList());\n\n        // 存储转存\n        VectorDataEntity vectorDataEntity = new VectorDataEntity();\n        vectorDataEntity.setVector(vectors);\n        vectorDataEntity.setContent(strings);\n\n\n        /**\n         * {\n         *      id\n         *      [0.1, 0.1]\n         *      <k, v>\n         * }\n         *\n         *\n         */\n\n        // 封装请求类\n        int count = vectors.size();\n        List<PineconeVectors> pineconeVectors = new ArrayList<>();\n        List<String> ids = generateIDs(count); // 生成每个向量的id\n        List<Map<String, String>> contents = generateContent(strings); // 生成每个向量对应的文本,元数据，kv\n\n        for (int i = 0; i < count; ++i) {\n            pineconeVectors.add(new PineconeVectors(ids.get(i), vectors.get(i), contents.get(i)));\n        }\n        PineconeInsert pineconeInsert = new PineconeInsert(pineconeVectors, \"userId\");\n\n        // 执行插入\n        //String res = PineconeUtil.insertEmbedding(pineconeInsert, \"aa\");\n\n        //log.info(\"插入结果{}\" ,res);\n    }\n\n    @Test\n    public void test__query() throws Exception {\n\n        // Given the following conversation and a follow up question, rephrase the follow up question to be a standalone English question.\\nChat History is below:\\n%s\\nFollow Up Input: \\n%s\\nStandalone English question:\n        // 聊天历史(role:content)  ,   新消息\n\n        // String aaa\n\n        // 构建要查询的问题，转为向量\n        Embedding build = Embedding.builder()\n                .input(\"aaaaa\")\n                .model(\"text-embedding-3-small\")\n                .build();\n        EmbeddingResponse embedding = embeddingService.embedding(null, null, build);\n        List<Float> question = embedding.getData().get(0).getEmbedding();\n\n\n        // 构建向量数据库的查询对象\n        PineconeQuery pineconeQueryReq = PineconeQuery.builder()\n                .namespace(\"\")\n                .topK(20)\n                .includeMetadata(true)\n                .vector(question)\n                .build();\n\n        // 执行查询\n        // PineconeQueryResponse response = PineconeUtil.queryEmbedding(pineconeQueryReq, \"aa\");\n\n        // 从向量数据库拿出的数据, 拼接为一个String\n        //String collect = response.getMatches().stream().map(match -> match.getMetadata().get(\"content\")).collect(Collectors.joining(\" \"));\n\n        // \"You are an AI assistant providing helpful advice. You are given the following extracted parts of a long document and a part of the chat history, along with a current question. Provide a conversational answer based on the context and the chat histories provided (You can refer to the chat history to know what the user has asked and thus better answer the current question, but you are not allowed to reply to the previous question asked by the user again). If you can\\'t find the answer in the context below, just say \\\"Hmm, I\\'m not sure.\\\" Don\\'t try to make up an answer. If the question is not related to the context, politely respond that you are tuned to only answer questions that are related to the context. \\nContext information is below:\\n=========\\n%s\\n=========\\nChat history is below:\\n=========\\n%s\\n=========\\nCurrent Question: %s (Note: Remember, you only need to reply to me in Chinese and try to increase the content of the reply as much as possible to improve the user experience. I believe you can definitely)\"\n        // 上下文， 历史，原始消息\n\n        // 发送chat请求进行对话\n\n    }\n\n    @Test\n    public void test__delet() {\n        PineconeDelete request = PineconeDelete.builder()\n                .deleteAll(true)\n                .namespace(\"userId\")\n                .build();\n\n        // String res = String.valueOf(PineconeUtil.deleteEmbedding(request, \"aa\"));\n\n        // System.out.println(res);\n    }\n\n    @Test\n    public void test__tika() throws TikaException, IOException, SAXException {\n\n\n        File file = new File(\"C:\\\\Users\\\\1\\\\Desktop\\\\新建文本文档 (2).txt\");\n        InputStream inputStream = new FileInputStream(file);\n        String s = TikaUtil.parseInputStream(inputStream);\n        System.out.println(\"文本内容\");\n        System.out.println(s);\n\n        String s1 = TikaUtil.detectMimeType(file);\n\n        System.out.println(s1);\n\n\n        //AbstractListener abstractListener = new AbstractListener();\n\n    }\n\n    // 生成每个向量的id\n    private List<String> generateIDs(int count) {\n        List<String> ids = new ArrayList<>();\n        for (long i = 0L; i < count; ++i) {\n            ids.add(\"id_\" + i);\n        }\n        return ids;\n    }\n\n\n    // 生成每个向量对应的文本\n    private List<Map<String, String>> generateContent(List<String> contents) {\n        List<Map<String, String>> finalcontents = new ArrayList<>();\n\n        for (int i = 0; i < contents.size(); i++) {\n            HashMap<String, String> map = new HashMap<>();\n            map.put(\"content\", contents.get(i));\n            finalcontents.add(map);\n        }\n        return finalcontents;\n    }\n\n    @FunctionCall(name = \"test_push_files\", description = \"Test push multiple files to repository\")\n    public static class TestPushFilesFunction implements java.util.function.Function<TestPushFilesFunction.Request, String> {\n\n        @lombok.Data\n        @FunctionRequest\n        public static class Request {\n            @FunctionParameter(description = \"List of files to push\")\n            private java.util.List<String> files;\n\n            @FunctionParameter(description = \"Commit message\")\n            private String message;\n        }\n\n        @Override\n        public String apply(Request request) {\n            return \"Files pushed: \" + request.files.size();\n        }\n    }\n\n    @org.junit.Test\n    public void testArraySchemaGeneration() {\n        System.out.println(\"=== 测试数组Schema生成 ===\");\n\n        try {\n            // 测试传统Function工具\n            Tool.Function function = ToolUtil.getFunctionEntity(\"test_push_files\");\n            if (function != null) {\n                System.out.println(\"传统Function工具生成成功:\");\n                System.out.println(\"名称: \" + function.getName());\n                System.out.println(\"描述: \" + function.getDescription());\n\n                java.util.Map<String, Tool.Function.Property> properties = function.getParameters().getProperties();\n                Tool.Function.Property filesProperty = properties.get(\"files\");\n                if (filesProperty != null) {\n                    System.out.println(\"files属性类型: \" + filesProperty.getType());\n                    if (filesProperty.getItems() != null) {\n                        System.out.println(\"files.items类型: \" + filesProperty.getItems().getType());\n                        System.out.println(\"✅ 数组Schema包含items定义 - 修复成功!\");\n                    } else {\n                        System.out.println(\"❌ 数组Schema缺少items定义 - 修复失败!\");\n                    }\n                } else {\n                    System.out.println(\"❌ 未找到files属性\");\n                }\n            } else {\n                System.out.println(\"❌ 传统Function工具生成失败\");\n            }\n\n            System.out.println(\"=== 测试完成 ===\");\n\n        } catch (Exception e) {\n            System.err.println(\"测试失败: \" + e.getMessage());\n            e.printStackTrace();\n        }\n    }\n}\n\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/OtherTest.java",
    "content": "package io.github.lnyocly;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\nimport lombok.extern.slf4j.Slf4j;\nimport org.junit.Test;\n\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.UUID;\n\n/**\n * @Author cly\n * @Description TODO\n */\n@Slf4j\npublic class OtherTest {\n\n    @Test\n    public void test(){\n        long currentTimeMillis = System.currentTimeMillis();\n\n        String isoDateTime = \"2024-07-22T20:33:28.123648Z\";\n        Instant instant = Instant.parse(isoDateTime);\n        long epochSeconds = instant.getEpochSecond();\n        System.out.println(\"Epoch seconds: \" + epochSeconds);\n\n\n        System.out.println(System.currentTimeMillis() - currentTimeMillis);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ZhipuTest.java",
    "content": "package io.github.lnyocly;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.config.DeepSeekConfig;\nimport io.github.lnyocly.ai4j.config.ZhipuConfig;\nimport io.github.lnyocly.ai4j.exception.chain.ErrorHandler;\nimport io.github.lnyocly.ai4j.exception.error.Error;\nimport io.github.lnyocly.ai4j.exception.error.OpenAiError;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport lombok.extern.slf4j.Slf4j;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.apache.commons.lang3.ObjectUtils;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.reflections.Reflections;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description 智谱测试类\n * @Date 2024/8/3 18:22\n */\n@Slf4j\npublic class ZhipuTest {\n\n    private IChatService chatService;\n\n    @Before\n    public void test_init() throws NoSuchAlgorithmException, KeyManagementException {\n        ZhipuConfig zhipuConfig = new ZhipuConfig();\n        zhipuConfig.setApiKey(\"sk-123456789\");\n\n        Configuration configuration = new Configuration();\n        configuration.setZhipuConfig(zhipuConfig);\n\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                //.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\", 10809)))\n                .build();\n        configuration.setOkHttpClient(okHttpClient);\n\n        AiService aiService = new AiService(configuration);\n\n        chatService = aiService.getChatService(PlatformType.ZHIPU);\n\n    }\n\n\n\n    @Test\n    public void test_chatCompletions_common() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n    }\n\n    @Test\n    public void test_chatCompletions_multimodal() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-vision\")\n                .message(ChatMessage.withUser(\"这几张图片，分别有什么动物, 并且是什么品种\",\n                        \"https://tse2-mm.cn.bing.net/th/id/OIP-C.SVxZtXIcz3LbcE4ZeS6jEgHaE7?w=231&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7\",\n                        \"https://ts3.cn.mm.bing.net/th?id=OIP-C.BYyILFgs3ATnTEQ-B5ApFQHaFj&w=288&h=216&c=8&rs=1&qlt=90&o=6&dpr=1.3&pid=3.1&rm=2\"))\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n    }\n\n\n    @Test\n    public void test_chatCompletions_stream() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"鲁迅为什么打周树人\"))\n                .build();\n\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n\n        System.out.println(\"请求成功\");\n        System.out.println(sseListener.getOutput());\n        System.out.println(sseListener.getUsage());\n\n    }\n\n    @Test\n    public void test_chatCompletions_function() throws Exception {\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"gpt-4o-mini\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气，并告诉我火车是否发车\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n        System.out.println(\"请求参数\");\n        System.out.println(chatCompletion);\n\n        ChatCompletionResponse chatCompletionResponse = chatService.chatCompletion(chatCompletion);\n\n        System.out.println(\"请求成功\");\n        System.out.println(chatCompletionResponse);\n\n        System.out.println(chatCompletion);\n\n    }\n\n    @Test\n    public void test_chatCompletions_stream_function() throws Exception {\n\n        // 构造请求参数\n        ChatCompletion chatCompletion = ChatCompletion.builder()\n                .model(\"yi-large-fc\")\n                .message(ChatMessage.withUser(\"查询洛阳明天的天气\"))\n                .functions(\"queryWeather\", \"queryTrainInfo\")\n                .build();\n\n\n        // 构造监听器\n        SseListener sseListener = new SseListener() {\n            @Override\n            protected void send() {\n                System.out.println(this.getCurrStr());\n            }\n        };\n        // 显示函数参数，默认不显示\n        sseListener.setShowToolArgs(true);\n\n        // 发送SSE请求\n        chatService.chatCompletionStream(chatCompletion, sseListener);\n        System.out.println(\"完整内容： \");\n        System.out.println(sseListener.getOutput());\n        System.out.println(\"内容花费： \");\n        System.out.println(sseListener.getUsage());\n    }\n\n\n}\n\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/agentflow/AgentFlowTraceSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse;\nimport io.github.lnyocly.ai4j.agentflow.support.AgentFlowSupport;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceListener;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.OkHttpClient;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class AgentFlowTraceSupportTest {\n\n    @Test\n    public void test_trace_listener_receives_lifecycle_events() {\n        RecordingTraceListener listener = new RecordingTraceListener();\n        TestAgentFlowSupport support = new TestAgentFlowSupport(config(listener));\n\n        AgentFlowChatRequest request = AgentFlowChatRequest.builder()\n                .prompt(\"plan a trip\")\n                .build();\n        AgentFlowChatEvent event = AgentFlowChatEvent.builder()\n                .type(\"message\")\n                .contentDelta(\"hello\")\n                .build();\n        AgentFlowChatResponse response = AgentFlowChatResponse.builder()\n                .content(\"hello world\")\n                .taskId(\"task-1\")\n                .build();\n\n        AgentFlowTraceContext context = support.begin(\"chat\", true, request);\n        support.emitEvent(context, event);\n        support.finish(context, response);\n\n        Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(listener.contexts.size()));\n        Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(listener.events.size()));\n        Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(listener.responses.size()));\n        Assert.assertEquals(AgentFlowType.DIFY, listener.contexts.get(0).getType());\n        Assert.assertEquals(\"chat\", listener.contexts.get(0).getOperation());\n        Assert.assertTrue(listener.contexts.get(0).isStreaming());\n        Assert.assertEquals(\"task-1\", listener.responses.get(0).getTaskId());\n    }\n\n    @Test\n    public void test_trace_listener_receives_error_and_does_not_break_call_path() {\n        RecordingTraceListener listener = new RecordingTraceListener();\n        ThrowingTraceListener throwingListener = new ThrowingTraceListener();\n        TestAgentFlowSupport support = new TestAgentFlowSupport(config(listener, throwingListener));\n\n        AgentFlowTraceContext context = support.begin(\"workflow\", false, null);\n        RuntimeException failure = new RuntimeException(\"boom\");\n        support.fail(context, failure);\n\n        Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(listener.errors.size()));\n        Assert.assertEquals(\"boom\", listener.errors.get(0).getMessage());\n    }\n\n    private AgentFlowConfig config(AgentFlowTraceListener... listeners) {\n        List<AgentFlowTraceListener> values = new ArrayList<AgentFlowTraceListener>();\n        if (listeners != null) {\n            for (AgentFlowTraceListener listener : listeners) {\n                values.add(listener);\n            }\n        }\n        return AgentFlowConfig.builder()\n                .type(AgentFlowType.DIFY)\n                .baseUrl(\"http://localhost:8080\")\n                .traceListeners(values)\n                .build();\n    }\n\n    private static final class TestAgentFlowSupport extends AgentFlowSupport {\n\n        private TestAgentFlowSupport(AgentFlowConfig config) {\n            super(configuration(), config);\n        }\n\n        private AgentFlowTraceContext begin(String operation, boolean streaming, Object request) {\n            return startTrace(operation, streaming, request);\n        }\n\n        private void emitEvent(AgentFlowTraceContext context, Object event) {\n            traceEvent(context, event);\n        }\n\n        private void finish(AgentFlowTraceContext context, Object response) {\n            traceComplete(context, response);\n        }\n\n        private void fail(AgentFlowTraceContext context, Throwable throwable) {\n            traceError(context, throwable);\n        }\n\n        private static Configuration configuration() {\n            Configuration configuration = new Configuration();\n            configuration.setOkHttpClient(new OkHttpClient());\n            return configuration;\n        }\n    }\n\n    private static final class RecordingTraceListener implements AgentFlowTraceListener {\n\n        private final List<AgentFlowTraceContext> contexts = new ArrayList<AgentFlowTraceContext>();\n        private final List<AgentFlowChatEvent> events = new ArrayList<AgentFlowChatEvent>();\n        private final List<AgentFlowChatResponse> responses = new ArrayList<AgentFlowChatResponse>();\n        private final List<Throwable> errors = new ArrayList<Throwable>();\n\n        @Override\n        public void onStart(AgentFlowTraceContext context) {\n            contexts.add(context);\n        }\n\n        @Override\n        public void onEvent(AgentFlowTraceContext context, Object event) {\n            if (event instanceof AgentFlowChatEvent) {\n                events.add((AgentFlowChatEvent) event);\n            }\n        }\n\n        @Override\n        public void onComplete(AgentFlowTraceContext context, Object response) {\n            if (response instanceof AgentFlowChatResponse) {\n                responses.add((AgentFlowChatResponse) response);\n            }\n        }\n\n        @Override\n        public void onError(AgentFlowTraceContext context, Throwable throwable) {\n            errors.add(throwable);\n        }\n    }\n\n    private static final class ThrowingTraceListener implements AgentFlowTraceListener {\n\n        @Override\n        public void onStart(AgentFlowTraceContext context) {\n            throw new IllegalStateException(\"ignored\");\n        }\n\n        @Override\n        public void onEvent(AgentFlowTraceContext context, Object event) {\n            throw new IllegalStateException(\"ignored\");\n        }\n\n        @Override\n        public void onComplete(AgentFlowTraceContext context, Object response) {\n            throw new IllegalStateException(\"ignored\");\n        }\n\n        @Override\n        public void onError(AgentFlowTraceContext context, Throwable throwable) {\n            throw new IllegalStateException(\"ignored\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/agentflow/CozeAgentFlowServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatListener;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse;\nimport io.github.lnyocly.ai4j.agentflow.chat.CozeAgentFlowChatService;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowEvent;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowListener;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowRequest;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowResponse;\nimport io.github.lnyocly.ai4j.agentflow.workflow.CozeAgentFlowWorkflowService;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.OkHttpClient;\nimport okhttp3.mockwebserver.MockResponse;\nimport okhttp3.mockwebserver.MockWebServer;\nimport okhttp3.mockwebserver.RecordedRequest;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class CozeAgentFlowServiceTest {\n\n    @Test\n    public void test_coze_chat_blocking() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(jsonResponse(\"{\\\"code\\\":0,\\\"msg\\\":\\\"success\\\",\\\"data\\\":{\\\"id\\\":\\\"chat-1\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"status\\\":\\\"created\\\"}}\"));\n        server.enqueue(jsonResponse(\"{\\\"code\\\":0,\\\"msg\\\":\\\"success\\\",\\\"data\\\":{\\\"id\\\":\\\"chat-1\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"status\\\":\\\"completed\\\",\\\"usage\\\":{\\\"token_count\\\":9,\\\"input_tokens\\\":4,\\\"output_tokens\\\":5}}}\"));\n        server.enqueue(jsonResponse(\"{\\\"code\\\":0,\\\"msg\\\":\\\"success\\\",\\\"data\\\":[{\\\"id\\\":\\\"msg-user\\\",\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hello\\\"},{\\\"id\\\":\\\"msg-1\\\",\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Travel plan ready\\\"}]}\"));\n        server.start();\n        try {\n            CozeAgentFlowChatService service = new CozeAgentFlowChatService(configuration(), cozeConfig(server));\n            AgentFlowChatResponse response = service.chat(AgentFlowChatRequest.builder().prompt(\"hello\").conversationId(\"conv-1\").build());\n\n            Assert.assertEquals(\"Travel plan ready\", response.getContent());\n            Assert.assertEquals(\"conv-1\", response.getConversationId());\n            Assert.assertEquals(\"chat-1\", response.getTaskId());\n            Assert.assertEquals(Integer.valueOf(9), response.getUsage().getTotalTokens());\n\n            RecordedRequest createRequest = server.takeRequest(1, TimeUnit.SECONDS);\n            RecordedRequest retrieveRequest = server.takeRequest(1, TimeUnit.SECONDS);\n            RecordedRequest listRequest = server.takeRequest(1, TimeUnit.SECONDS);\n            Assert.assertTrue(createRequest.getPath().startsWith(\"/v3/chat\"));\n            Assert.assertTrue(retrieveRequest.getPath().startsWith(\"/v3/chat/retrieve\"));\n            Assert.assertTrue(listRequest.getPath().startsWith(\"/v1/conversation/message/list\"));\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    @Test\n    public void test_coze_chat_streaming() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(sseResponse(\n                \"event: conversation.chat.created\\n\" +\n                        \"data: {\\\"id\\\":\\\"chat-1\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"status\\\":\\\"created\\\"}\\n\\n\" +\n                        \"event: conversation.message.delta\\n\" +\n                        \"data: {\\\"id\\\":\\\"msg-1\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"chat_id\\\":\\\"chat-1\\\",\\\"role\\\":\\\"assistant\\\",\\\"type\\\":\\\"answer\\\",\\\"content\\\":\\\"Travel \\\",\\\"content_type\\\":\\\"text\\\"}\\n\\n\" +\n                        \"event: conversation.message.delta\\n\" +\n                        \"data: {\\\"id\\\":\\\"msg-1\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"chat_id\\\":\\\"chat-1\\\",\\\"role\\\":\\\"assistant\\\",\\\"type\\\":\\\"answer\\\",\\\"content\\\":\\\"ready\\\",\\\"content_type\\\":\\\"text\\\"}\\n\\n\" +\n                        \"event: conversation.chat.completed\\n\" +\n                        \"data: {\\\"id\\\":\\\"chat-1\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"status\\\":\\\"completed\\\",\\\"usage\\\":{\\\"token_count\\\":7,\\\"input_tokens\\\":3,\\\"output_tokens\\\":4}}\\n\\n\" +\n                        \"event: done\\n\" +\n                        \"data: [DONE]\\n\\n\"\n        ));\n        server.start();\n        try {\n            CozeAgentFlowChatService service = new CozeAgentFlowChatService(configuration(), cozeConfig(server));\n            final List<AgentFlowChatEvent> events = new ArrayList<AgentFlowChatEvent>();\n            final AtomicReference<AgentFlowChatResponse> completion = new AtomicReference<AgentFlowChatResponse>();\n\n            service.chatStream(AgentFlowChatRequest.builder().prompt(\"hello\").build(), new AgentFlowChatListener() {\n                @Override\n                public void onEvent(AgentFlowChatEvent event) {\n                    events.add(event);\n                }\n\n                @Override\n                public void onComplete(AgentFlowChatResponse response) {\n                    completion.set(response);\n                }\n            });\n\n            Assert.assertEquals(\"Travel ready\", completion.get().getContent());\n            Assert.assertEquals(\"chat-1\", completion.get().getTaskId());\n            Assert.assertEquals(Integer.valueOf(7), completion.get().getUsage().getTotalTokens());\n            Assert.assertTrue(events.size() >= 4);\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    @Test\n    public void test_coze_workflow_blocking() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(jsonResponse(\"{\\\"code\\\":0,\\\"msg\\\":\\\"success\\\",\\\"execute_id\\\":\\\"exec-1\\\",\\\"data\\\":\\\"{\\\\\\\"answer\\\\\\\":\\\\\\\"Workflow ready\\\\\\\",\\\\\\\"city\\\\\\\":\\\\\\\"Paris\\\\\\\"}\\\",\\\"usage\\\":{\\\"token_count\\\":6,\\\"input_tokens\\\":2,\\\"output_tokens\\\":4}}\"));\n        server.start();\n        try {\n            CozeAgentFlowWorkflowService service = new CozeAgentFlowWorkflowService(configuration(), cozeConfig(server));\n            AgentFlowWorkflowResponse response = service.run(AgentFlowWorkflowRequest.builder().workflowId(\"wf-1\").build());\n\n            Assert.assertEquals(\"Workflow ready\", response.getOutputText());\n            Assert.assertEquals(\"exec-1\", response.getWorkflowRunId());\n            Assert.assertEquals(\"Paris\", response.getOutputs().get(\"city\"));\n\n            RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS);\n            Assert.assertEquals(\"/v1/workflow/run\", request.getPath());\n            JSONObject body = JSON.parseObject(request.getBody().readUtf8());\n            Assert.assertEquals(\"wf-1\", body.getString(\"workflow_id\"));\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    @Test\n    public void test_coze_workflow_streaming() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(sseResponse(\n                \"event: Message\\n\" +\n                        \"data: {\\\"content\\\":\\\"Workflow \\\",\\\"usage\\\":{\\\"token_count\\\":3,\\\"input_tokens\\\":1,\\\"output_tokens\\\":2}}\\n\\n\" +\n                        \"event: Message\\n\" +\n                        \"data: {\\\"content\\\":\\\"ready\\\"}\\n\\n\" +\n                        \"event: Done\\n\" +\n                        \"data: {}\\n\\n\"\n        ));\n        server.start();\n        try {\n            CozeAgentFlowWorkflowService service = new CozeAgentFlowWorkflowService(configuration(), cozeConfig(server));\n            final List<AgentFlowWorkflowEvent> events = new ArrayList<AgentFlowWorkflowEvent>();\n            final AtomicReference<AgentFlowWorkflowResponse> completion = new AtomicReference<AgentFlowWorkflowResponse>();\n\n            service.runStream(AgentFlowWorkflowRequest.builder().workflowId(\"wf-1\").build(), new AgentFlowWorkflowListener() {\n                @Override\n                public void onEvent(AgentFlowWorkflowEvent event) {\n                    events.add(event);\n                }\n\n                @Override\n                public void onComplete(AgentFlowWorkflowResponse response) {\n                    completion.set(response);\n                }\n            });\n\n            Assert.assertEquals(\"Workflow ready\", completion.get().getOutputText());\n            Assert.assertEquals(Integer.valueOf(3), completion.get().getUsage().getTotalTokens());\n            Assert.assertTrue(events.get(events.size() - 1).isDone());\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    private Configuration configuration() {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(new OkHttpClient.Builder()\n                .readTimeout(0, TimeUnit.MILLISECONDS)\n                .build());\n        return configuration;\n    }\n\n    private AgentFlowConfig cozeConfig(MockWebServer server) {\n        return AgentFlowConfig.builder()\n                .type(AgentFlowType.COZE)\n                .baseUrl(server.url(\"/\").toString())\n                .apiKey(\"test-key\")\n                .botId(\"bot-1\")\n                .pollIntervalMillis(1L)\n                .pollTimeoutMillis(3_000L)\n                .build();\n    }\n\n    private MockResponse jsonResponse(String body) {\n        return new MockResponse()\n                .setResponseCode(200)\n                .setHeader(\"Content-Type\", \"application/json\")\n                .setBody(body);\n    }\n\n    private MockResponse sseResponse(String body) {\n        return new MockResponse()\n                .setResponseCode(200)\n                .setHeader(\"Content-Type\", \"text/event-stream\")\n                .setBody(body);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/agentflow/DifyAgentFlowServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatListener;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse;\nimport io.github.lnyocly.ai4j.agentflow.chat.DifyAgentFlowChatService;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowEvent;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowListener;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowRequest;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowResponse;\nimport io.github.lnyocly.ai4j.agentflow.workflow.DifyAgentFlowWorkflowService;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.OkHttpClient;\nimport okhttp3.mockwebserver.MockResponse;\nimport okhttp3.mockwebserver.MockWebServer;\nimport okhttp3.mockwebserver.RecordedRequest;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class DifyAgentFlowServiceTest {\n\n    @Test\n    public void test_dify_chat_blocking() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(jsonResponse(\"{\\\"answer\\\":\\\"Hello from Dify\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"message_id\\\":\\\"msg-1\\\",\\\"task_id\\\":\\\"task-1\\\",\\\"metadata\\\":{\\\"usage\\\":{\\\"prompt_tokens\\\":3,\\\"completion_tokens\\\":5,\\\"total_tokens\\\":8}}}\"));\n        server.start();\n        try {\n            DifyAgentFlowChatService service = new DifyAgentFlowChatService(configuration(), difyConfig(server));\n            AgentFlowChatResponse response = service.chat(AgentFlowChatRequest.builder()\n                    .prompt(\"hello\")\n                    .conversationId(\"conv-1\")\n                    .build());\n\n            Assert.assertEquals(\"Hello from Dify\", response.getContent());\n            Assert.assertEquals(\"conv-1\", response.getConversationId());\n            Assert.assertEquals(Integer.valueOf(8), response.getUsage().getTotalTokens());\n\n            RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS);\n            Assert.assertNotNull(recordedRequest);\n            Assert.assertEquals(\"/v1/chat-messages\", recordedRequest.getPath());\n            JSONObject body = JSON.parseObject(recordedRequest.getBody().readUtf8());\n            Assert.assertEquals(\"hello\", body.getString(\"query\"));\n            Assert.assertEquals(\"blocking\", body.getString(\"response_mode\"));\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    @Test\n    public void test_dify_chat_streaming() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(sseResponse(\n                \"event: message\\n\" +\n                        \"data: {\\\"event\\\":\\\"message\\\",\\\"answer\\\":\\\"Hel\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"message_id\\\":\\\"msg-1\\\",\\\"task_id\\\":\\\"task-1\\\"}\\n\\n\" +\n                        \"event: message\\n\" +\n                        \"data: {\\\"event\\\":\\\"message\\\",\\\"answer\\\":\\\"lo\\\"}\\n\\n\" +\n                        \"event: message_end\\n\" +\n                        \"data: {\\\"event\\\":\\\"message_end\\\",\\\"conversation_id\\\":\\\"conv-1\\\",\\\"message_id\\\":\\\"msg-1\\\",\\\"task_id\\\":\\\"task-1\\\",\\\"metadata\\\":{\\\"usage\\\":{\\\"prompt_tokens\\\":1,\\\"completion_tokens\\\":2,\\\"total_tokens\\\":3}}}\\n\\n\"\n        ));\n        server.start();\n        try {\n            DifyAgentFlowChatService service = new DifyAgentFlowChatService(configuration(), difyConfig(server));\n            final List<AgentFlowChatEvent> events = new ArrayList<AgentFlowChatEvent>();\n            final AtomicReference<AgentFlowChatResponse> completion = new AtomicReference<AgentFlowChatResponse>();\n\n            service.chatStream(AgentFlowChatRequest.builder().prompt(\"hello\").build(), new AgentFlowChatListener() {\n                @Override\n                public void onEvent(AgentFlowChatEvent event) {\n                    events.add(event);\n                }\n\n                @Override\n                public void onComplete(AgentFlowChatResponse response) {\n                    completion.set(response);\n                }\n            });\n\n            Assert.assertEquals(3, events.size());\n            Assert.assertEquals(\"Hel\", events.get(0).getContentDelta());\n            Assert.assertEquals(\"Hello\", completion.get().getContent());\n            Assert.assertEquals(Integer.valueOf(3), completion.get().getUsage().getTotalTokens());\n\n            RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS);\n            JSONObject body = JSON.parseObject(recordedRequest.getBody().readUtf8());\n            Assert.assertEquals(\"streaming\", body.getString(\"response_mode\"));\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    @Test\n    public void test_dify_workflow_blocking() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(jsonResponse(\"{\\\"task_id\\\":\\\"task-1\\\",\\\"workflow_run_id\\\":\\\"run-1\\\",\\\"data\\\":{\\\"status\\\":\\\"succeeded\\\",\\\"outputs\\\":{\\\"answer\\\":\\\"Plan ready\\\",\\\"city\\\":\\\"Paris\\\"},\\\"usage\\\":{\\\"prompt_tokens\\\":2,\\\"completion_tokens\\\":3,\\\"total_tokens\\\":5}}}\"));\n        server.start();\n        try {\n            DifyAgentFlowWorkflowService service = new DifyAgentFlowWorkflowService(configuration(), difyConfig(server));\n            AgentFlowWorkflowResponse response = service.run(AgentFlowWorkflowRequest.builder().build());\n\n            Assert.assertEquals(\"succeeded\", response.getStatus());\n            Assert.assertEquals(\"Plan ready\", response.getOutputText());\n            Assert.assertEquals(\"run-1\", response.getWorkflowRunId());\n            Assert.assertEquals(\"Paris\", response.getOutputs().get(\"city\"));\n\n            RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS);\n            Assert.assertEquals(\"/v1/workflows/run\", recordedRequest.getPath());\n            JSONObject body = JSON.parseObject(recordedRequest.getBody().readUtf8());\n            Assert.assertEquals(\"blocking\", body.getString(\"response_mode\"));\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    @Test\n    public void test_dify_workflow_streaming() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(sseResponse(\n                \"event: workflow_started\\n\" +\n                        \"data: {\\\"event\\\":\\\"workflow_started\\\",\\\"task_id\\\":\\\"task-1\\\",\\\"workflow_run_id\\\":\\\"run-1\\\"}\\n\\n\" +\n                        \"event: workflow_finished\\n\" +\n                        \"data: {\\\"event\\\":\\\"workflow_finished\\\",\\\"task_id\\\":\\\"task-1\\\",\\\"workflow_run_id\\\":\\\"run-1\\\",\\\"data\\\":{\\\"status\\\":\\\"succeeded\\\",\\\"outputs\\\":{\\\"answer\\\":\\\"Plan ready\\\",\\\"city\\\":\\\"Paris\\\"},\\\"usage\\\":{\\\"prompt_tokens\\\":2,\\\"completion_tokens\\\":3,\\\"total_tokens\\\":5}}}\\n\\n\"\n        ));\n        server.start();\n        try {\n            DifyAgentFlowWorkflowService service = new DifyAgentFlowWorkflowService(configuration(), difyConfig(server));\n            final List<AgentFlowWorkflowEvent> events = new ArrayList<AgentFlowWorkflowEvent>();\n            final AtomicReference<AgentFlowWorkflowResponse> completion = new AtomicReference<AgentFlowWorkflowResponse>();\n\n            service.runStream(AgentFlowWorkflowRequest.builder().build(), new AgentFlowWorkflowListener() {\n                @Override\n                public void onEvent(AgentFlowWorkflowEvent event) {\n                    events.add(event);\n                }\n\n                @Override\n                public void onComplete(AgentFlowWorkflowResponse response) {\n                    completion.set(response);\n                }\n            });\n\n            Assert.assertEquals(2, events.size());\n            Assert.assertTrue(events.get(1).isDone());\n            Assert.assertEquals(\"Plan ready\", completion.get().getOutputText());\n            Assert.assertEquals(\"Paris\", completion.get().getOutputs().get(\"city\"));\n\n            RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS);\n            JSONObject body = JSON.parseObject(recordedRequest.getBody().readUtf8());\n            Assert.assertEquals(\"streaming\", body.getString(\"response_mode\"));\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    private Configuration configuration() {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(new OkHttpClient.Builder()\n                .readTimeout(0, TimeUnit.MILLISECONDS)\n                .build());\n        return configuration;\n    }\n\n    private AgentFlowConfig difyConfig(MockWebServer server) {\n        return AgentFlowConfig.builder()\n                .type(AgentFlowType.DIFY)\n                .baseUrl(server.url(\"/\").toString())\n                .apiKey(\"test-key\")\n                .pollTimeoutMillis(3_000L)\n                .build();\n    }\n\n    private MockResponse jsonResponse(String body) {\n        return new MockResponse()\n                .setResponseCode(200)\n                .setHeader(\"Content-Type\", \"application/json\")\n                .setBody(body);\n    }\n\n    private MockResponse sseResponse(String body) {\n        return new MockResponse()\n                .setResponseCode(200)\n                .setHeader(\"Content-Type\", \"text/event-stream\")\n                .setBody(body);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/agentflow/N8nAgentFlowWorkflowServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.agentflow;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowRequest;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowResponse;\nimport io.github.lnyocly.ai4j.agentflow.workflow.N8nAgentFlowWorkflowService;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport okhttp3.OkHttpClient;\nimport okhttp3.mockwebserver.MockResponse;\nimport okhttp3.mockwebserver.MockWebServer;\nimport okhttp3.mockwebserver.RecordedRequest;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.concurrent.TimeUnit;\n\npublic class N8nAgentFlowWorkflowServiceTest {\n\n    @Test\n    public void test_n8n_workflow_blocking() throws Exception {\n        MockWebServer server = new MockWebServer();\n        server.enqueue(new MockResponse()\n                .setResponseCode(200)\n                .setHeader(\"Content-Type\", \"application/json\")\n                .setBody(\"{\\\"ok\\\":true,\\\"result\\\":\\\"Booked\\\",\\\"city\\\":\\\"Paris\\\"}\"));\n        server.start();\n        try {\n            N8nAgentFlowWorkflowService service = new N8nAgentFlowWorkflowService(configuration(), AgentFlowConfig.builder()\n                    .type(AgentFlowType.N8N)\n                    .webhookUrl(server.url(\"/travel-hook\").toString())\n                    .build());\n            AgentFlowWorkflowResponse response = service.run(AgentFlowWorkflowRequest.builder()\n                    .inputs(java.util.Collections.<String, Object>singletonMap(\"city\", \"Paris\"))\n                    .build());\n\n            Assert.assertEquals(\"Booked\", response.getOutputText());\n            Assert.assertEquals(Boolean.TRUE, response.getOutputs().get(\"ok\"));\n            Assert.assertEquals(\"Paris\", response.getOutputs().get(\"city\"));\n\n            RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS);\n            Assert.assertEquals(\"/travel-hook\", request.getPath());\n            JSONObject body = JSON.parseObject(request.getBody().readUtf8());\n            Assert.assertEquals(\"Paris\", body.getString(\"city\"));\n        } finally {\n            server.shutdown();\n        }\n    }\n\n    @Test\n    public void test_ai_service_exposes_agent_flow() {\n        Configuration configuration = configuration();\n        AiService aiService = new AiService(configuration);\n        AgentFlow agentFlow = aiService.getAgentFlow(AgentFlowConfig.builder()\n                .type(AgentFlowType.N8N)\n                .webhookUrl(\"https://example.com/hook\")\n                .build());\n\n        Assert.assertNotNull(agentFlow);\n        Assert.assertEquals(AgentFlowType.N8N, agentFlow.getConfig().getType());\n        Assert.assertNotNull(agentFlow.workflow());\n    }\n\n    private Configuration configuration() {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(new OkHttpClient.Builder()\n                .readTimeout(0, TimeUnit.MILLISECONDS)\n                .build());\n        return configuration;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/function/TestFunction.java",
    "content": "package io.github.lnyocly.ai4j.function;\n\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\n\n/**\n * 测试用的传统Function工具\n */\n@FunctionCall(name = \"weather\", description = \"获取指定城市的天气信息\")\npublic class TestFunction {\n\n    public WeatherResponse apply(WeatherRequest request) {\n        WeatherResponse response = new WeatherResponse();\n        response.city = request.city;\n        response.temperature = 25; // 模拟温度\n        response.condition = \"晴天\";\n        response.humidity = 60;\n        response.timestamp = System.currentTimeMillis();\n        return response;\n    }\n\n    @FunctionRequest\n    public static class WeatherRequest {\n        @FunctionParameter(description = \"城市名称\", required = true)\n        public String city;\n        \n        @FunctionParameter(description = \"温度单位\", required = false)\n        public String unit = \"celsius\";\n    }\n\n    public static class WeatherResponse {\n        public String city;\n        public int temperature;\n        public String condition;\n        public int humidity;\n        public long timestamp;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/McpClientResponseSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.mcp;\n\nimport io.github.lnyocly.ai4j.mcp.client.McpClientResponseSupport;\nimport io.github.lnyocly.ai4j.mcp.client.McpClient;\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.entity.McpPrompt;\nimport io.github.lnyocly.ai4j.mcp.entity.McpPromptResult;\nimport io.github.lnyocly.ai4j.mcp.entity.McpRequest;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResource;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResourceContent;\nimport io.github.lnyocly.ai4j.mcp.entity.McpResponse;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransport;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\n\npublic class McpClientResponseSupportTest {\n\n    @Test\n    public void shouldParseToolsListResponse() {\n        Map<String, Object> inputSchema = new HashMap<String, Object>();\n        inputSchema.put(\"type\", \"object\");\n\n        Map<String, Object> tool = new HashMap<String, Object>();\n        tool.put(\"name\", \"queryWeather\");\n        tool.put(\"description\", \"Query current weather\");\n        tool.put(\"inputSchema\", inputSchema);\n\n        Map<String, Object> result = new HashMap<String, Object>();\n        result.put(\"tools\", Collections.singletonList(tool));\n\n        List<McpToolDefinition> tools = McpClientResponseSupport.parseToolsListResponse(result);\n\n        Assert.assertEquals(1, tools.size());\n        Assert.assertEquals(\"queryWeather\", tools.get(0).getName());\n        Assert.assertEquals(\"Query current weather\", tools.get(0).getDescription());\n        Assert.assertEquals(\"object\", tools.get(0).getInputSchema().get(\"type\"));\n    }\n\n    @Test\n    public void shouldParseToolCallTextResponse() {\n        Map<String, Object> part1 = new HashMap<String, Object>();\n        part1.put(\"type\", \"text\");\n        part1.put(\"text\", \"Hello \");\n\n        Map<String, Object> part2 = new HashMap<String, Object>();\n        part2.put(\"type\", \"text\");\n        part2.put(\"text\", \"World\");\n\n        Map<String, Object> result = new HashMap<String, Object>();\n        result.put(\"content\", Arrays.asList(part1, part2));\n\n        String text = McpClientResponseSupport.parseToolCallResponse(result);\n\n        Assert.assertEquals(\"Hello World\", text);\n    }\n\n    @Test\n    public void shouldParseResourcesListResponse() {\n        Map<String, Object> resource = new HashMap<String, Object>();\n        resource.put(\"uri\", \"file://docs/readme.md\");\n        resource.put(\"name\", \"README\");\n        resource.put(\"description\", \"Project readme\");\n        resource.put(\"mimeType\", \"text/markdown\");\n        resource.put(\"size\", 128L);\n\n        Map<String, Object> result = new HashMap<String, Object>();\n        result.put(\"resources\", Collections.singletonList(resource));\n\n        List<McpResource> resources = McpClientResponseSupport.parseResourcesListResponse(result);\n\n        Assert.assertEquals(1, resources.size());\n        Assert.assertEquals(\"file://docs/readme.md\", resources.get(0).getUri());\n        Assert.assertEquals(\"README\", resources.get(0).getName());\n        Assert.assertEquals(\"text/markdown\", resources.get(0).getMimeType());\n        Assert.assertEquals(Long.valueOf(128L), resources.get(0).getSize());\n    }\n\n    @Test\n    public void shouldParseResourceReadResponse() {\n        Map<String, Object> content = new HashMap<String, Object>();\n        content.put(\"uri\", \"file://docs/readme.md\");\n        content.put(\"mimeType\", \"text/markdown\");\n        content.put(\"text\", \"# Hello\");\n\n        Map<String, Object> result = new HashMap<String, Object>();\n        result.put(\"contents\", Collections.singletonList(content));\n\n        McpResourceContent resourceContent = McpClientResponseSupport.parseResourceReadResponse(result);\n\n        Assert.assertNotNull(resourceContent);\n        Assert.assertEquals(\"file://docs/readme.md\", resourceContent.getUri());\n        Assert.assertEquals(\"text/markdown\", resourceContent.getMimeType());\n        Assert.assertEquals(\"# Hello\", resourceContent.getContents());\n    }\n\n    @Test\n    public void shouldParsePromptsListResponse() {\n        Map<String, Object> arguments = new HashMap<String, Object>();\n        arguments.put(\"city\", \"string\");\n\n        Map<String, Object> prompt = new HashMap<String, Object>();\n        prompt.put(\"name\", \"weather_prompt\");\n        prompt.put(\"description\", \"Build weather prompt\");\n        prompt.put(\"arguments\", arguments);\n\n        Map<String, Object> result = new HashMap<String, Object>();\n        result.put(\"prompts\", Collections.singletonList(prompt));\n\n        List<McpPrompt> prompts = McpClientResponseSupport.parsePromptsListResponse(result);\n\n        Assert.assertEquals(1, prompts.size());\n        Assert.assertEquals(\"weather_prompt\", prompts.get(0).getName());\n        Assert.assertEquals(\"Build weather prompt\", prompts.get(0).getDescription());\n        Assert.assertEquals(\"string\", prompts.get(0).getArguments().get(\"city\"));\n    }\n\n    @Test\n    public void shouldParsePromptGetResponse() {\n        Map<String, Object> content = new HashMap<String, Object>();\n        content.put(\"type\", \"text\");\n        content.put(\"text\", \"Weather for Luoyang\");\n\n        Map<String, Object> message = new HashMap<String, Object>();\n        message.put(\"role\", \"user\");\n        message.put(\"content\", content);\n\n        Map<String, Object> result = new HashMap<String, Object>();\n        result.put(\"description\", \"Weather prompt\");\n        result.put(\"messages\", Collections.singletonList(message));\n\n        McpPromptResult promptResult = McpClientResponseSupport.parsePromptGetResponse(\"weather_prompt\", result);\n\n        Assert.assertNotNull(promptResult);\n        Assert.assertEquals(\"weather_prompt\", promptResult.getName());\n        Assert.assertEquals(\"Weather prompt\", promptResult.getDescription());\n        Assert.assertEquals(\"Weather for Luoyang\", promptResult.getContent());\n    }\n\n    @Test\n    public void shouldSupportResourceAndPromptApisThroughClient() {\n        Map<String, Object> resourcesListResult = new HashMap<String, Object>();\n        resourcesListResult.put(\"resources\", Collections.singletonList(mapOf(\n                \"uri\", \"file://docs/readme.md\",\n                \"name\", \"README\",\n                \"description\", \"Project readme\",\n                \"mimeType\", \"text/markdown\",\n                \"size\", 128L\n        )));\n\n        Map<String, Object> resourceReadResult = new HashMap<String, Object>();\n        resourceReadResult.put(\"contents\", Collections.singletonList(mapOf(\n                \"uri\", \"file://docs/readme.md\",\n                \"mimeType\", \"text/markdown\",\n                \"text\", \"# Hello\"\n        )));\n\n        Map<String, Object> promptsListResult = new HashMap<String, Object>();\n        promptsListResult.put(\"prompts\", Collections.singletonList(mapOf(\n                \"name\", \"weather_prompt\",\n                \"description\", \"Build weather prompt\",\n                \"arguments\", mapOf(\"city\", \"string\")\n        )));\n\n        Map<String, Object> promptGetResult = new HashMap<String, Object>();\n        promptGetResult.put(\"description\", \"Weather prompt\");\n        promptGetResult.put(\"messages\", Collections.singletonList(mapOf(\n                \"role\", \"user\",\n                \"content\", mapOf(\"type\", \"text\", \"text\", \"Weather for Luoyang\")\n        )));\n\n        FakeTransport transport = new FakeTransport()\n                .respond(\"resources/list\", resourcesListResult)\n                .respond(\"resources/read\", resourceReadResult)\n                .respond(\"prompts/list\", promptsListResult)\n                .respond(\"prompts/get\", promptGetResult);\n\n        McpClient client = new McpClient(\"test\", \"1.0.0\", transport, false);\n\n        List<McpResource> resources = client.getAvailableResources().join();\n        McpResourceContent resourceContent = client.readResource(\"file://docs/readme.md\").join();\n        List<McpPrompt> prompts = client.getAvailablePrompts().join();\n        McpPromptResult promptResult = client.getPrompt(\"weather_prompt\", Collections.singletonMap(\"city\", \"Luoyang\")).join();\n\n        Assert.assertEquals(1, resources.size());\n        Assert.assertEquals(\"README\", resources.get(0).getName());\n        Assert.assertEquals(\"# Hello\", resourceContent.getContents());\n        Assert.assertEquals(1, prompts.size());\n        Assert.assertEquals(\"weather_prompt\", prompts.get(0).getName());\n        Assert.assertEquals(\"Weather for Luoyang\", promptResult.getContent());\n    }\n\n    private static Map<String, Object> mapOf(Object... values) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i + 1 < values.length; i += 2) {\n            map.put(String.valueOf(values[i]), values[i + 1]);\n        }\n        return map;\n    }\n\n    private static final class FakeTransport implements McpTransport {\n\n        private final Map<String, Object> responses = new HashMap<String, Object>();\n        private McpMessageHandler handler;\n\n        private FakeTransport respond(String method, Object result) {\n            responses.put(method, result);\n            return this;\n        }\n\n        @Override\n        public CompletableFuture<Void> start() {\n            return CompletableFuture.completedFuture(null);\n        }\n\n        @Override\n        public CompletableFuture<Void> stop() {\n            return CompletableFuture.completedFuture(null);\n        }\n\n        @Override\n        public CompletableFuture<Void> sendMessage(McpMessage message) {\n            if (message instanceof McpRequest && handler != null) {\n                Object result = responses.get(message.getMethod());\n                handler.handleMessage(new McpResponse(message.getId(), result));\n            }\n            return CompletableFuture.completedFuture(null);\n        }\n\n        @Override\n        public void setMessageHandler(McpMessageHandler handler) {\n            this.handler = handler;\n        }\n\n        @Override\n        public boolean isConnected() {\n            return true;\n        }\n\n        @Override\n        public boolean needsHeartbeat() {\n            return false;\n        }\n\n        @Override\n        public String getTransportType() {\n            return \"test\";\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/McpGatewaySupportTest.java",
    "content": "package io.github.lnyocly.ai4j.mcp;\n\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.mcp.gateway.McpGatewayKeySupport;\nimport io.github.lnyocly.ai4j.mcp.util.McpToolConversionSupport;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class McpGatewaySupportTest {\n\n    @Test\n    public void shouldBuildAndParseUserGatewayKeys() {\n        String clientKey = McpGatewayKeySupport.buildUserClientKey(\"123\", \"github\");\n        String toolKey = McpGatewayKeySupport.buildUserToolKey(\"123\", \"search_repositories\");\n\n        Assert.assertEquals(\"user_123_service_github\", clientKey);\n        Assert.assertEquals(\"user_123_tool_search_repositories\", toolKey);\n        Assert.assertTrue(McpGatewayKeySupport.isUserClientKey(clientKey));\n        Assert.assertEquals(\"123\", McpGatewayKeySupport.extractUserIdFromClientKey(clientKey));\n    }\n\n    @Test\n    public void shouldConvertMcpToolDefinitionToOpenAiFunction() {\n        Map<String, Object> arrayItems = new HashMap<String, Object>();\n        arrayItems.put(\"type\", \"string\");\n        arrayItems.put(\"description\", \"tag\");\n\n        Map<String, Object> tagsProperty = new HashMap<String, Object>();\n        tagsProperty.put(\"type\", \"array\");\n        tagsProperty.put(\"description\", \"Tags\");\n        tagsProperty.put(\"items\", arrayItems);\n\n        Map<String, Object> statusProperty = new HashMap<String, Object>();\n        statusProperty.put(\"type\", \"string\");\n        statusProperty.put(\"description\", \"Issue status\");\n        statusProperty.put(\"enum\", Arrays.asList(\"open\", \"closed\"));\n\n        Map<String, Object> properties = new HashMap<String, Object>();\n        properties.put(\"status\", statusProperty);\n        properties.put(\"tags\", tagsProperty);\n\n        Map<String, Object> inputSchema = new HashMap<String, Object>();\n        inputSchema.put(\"properties\", properties);\n        inputSchema.put(\"required\", Collections.singletonList(\"status\"));\n\n        McpToolDefinition definition = McpToolDefinition.builder()\n                .name(\"create_issue\")\n                .description(\"Create issue\")\n                .inputSchema(inputSchema)\n                .build();\n\n        Tool.Function function = McpToolConversionSupport.convertToOpenAiTool(definition);\n\n        Assert.assertEquals(\"create_issue\", function.getName());\n        Assert.assertEquals(\"Create issue\", function.getDescription());\n        Assert.assertEquals(\"object\", function.getParameters().getType());\n        Assert.assertEquals(Collections.singletonList(\"status\"), function.getParameters().getRequired());\n        Assert.assertEquals(Arrays.asList(\"open\", \"closed\"),\n                function.getParameters().getProperties().get(\"status\").getEnumValues());\n        Assert.assertEquals(\"string\",\n                function.getParameters().getProperties().get(\"tags\").getItems().getType());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/McpServerTest.java",
    "content": "package io.github.lnyocly.ai4j.mcp;\n\nimport io.github.lnyocly.ai4j.mcp.server.McpServer;\nimport io.github.lnyocly.ai4j.mcp.server.McpServerFactory;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * MCP服务器测试类\n * 测试创建MCP服务器并提供工具服务\n */\npublic class McpServerTest {\n    \n    private static final Logger log = LoggerFactory.getLogger(McpServerTest.class);\n    \n    public static void main(String[] args) {\n        log.info(\"开始MCP服务器测试...\");\n        \n        try {\n            // 测试1: 创建并启动SSE MCP服务器\n            //testSseMcpServer();\n            \n            // 测试2: 创建并启动Stdio MCP服务器\n            testStdioMcpServer();\n            \n            // 测试3: 创建并启动Streamable HTTP MCP服务器\n            //testStreamableHttpMcpServer();\n            \n            log.info(\"所有MCP服务器测试完成！\");\n            \n        } catch (Exception e) {\n            log.error(\"MCP服务器测试失败\", e);\n        }\n    }\n    \n    /**\n     * 测试SSE MCP服务器\n     */\n    private static void testSseMcpServer() {\n        log.info(\"=== 测试1: SSE MCP服务器 ===\");\n        \n        try {\n            // 创建SSE服务器\n            McpServer server = McpServerFactory.createServer(\"sse\", \"name\", \"1.0.0\", 5000);\n            \n            log.info(\"创建SSE MCP服务器: {}\", server.getServerInfo());\n            \n            // 启动服务器\n            CompletableFuture<Void> startFuture = server.start();\n            startFuture.get(5, TimeUnit.SECONDS);\n            \n            if (server.isRunning()) {\n                log.info(\"SSE MCP服务器启动成功，端口: 8080\");\n                log.info(\"SSE端点: http://localhost:8080/sse\");\n                log.info(\"消息端点: http://localhost:8080/message\");\n                \n                // 显示可用工具\n                showAvailableTools();\n                \n                // 运行一段时间后停止\n                Thread.sleep(2000);\n                \n                // 停止服务器\n                CompletableFuture<Void> stopFuture = server.stop();\n                stopFuture.get(5, TimeUnit.SECONDS);\n                log.info(\"SSE MCP服务器已停止\");\n            } else {\n                log.error(\"SSE MCP服务器启动失败\");\n            }\n            \n        } catch (Exception e) {\n            log.error(\"SSE MCP服务器测试失败\", e);\n        }\n    }\n    \n    /**\n     * 测试Stdio MCP服务器\n     */\n    private static void testStdioMcpServer() {\n        log.info(\"=== 测试2: Stdio MCP服务器 ===\");\n        \n        try {\n            // 创建Stdio服务器\n            McpServer server = McpServerFactory.createServer(\"stdio\", \"name\", \"1.0.0\");\n            \n            log.info(\"创建Stdio MCP服务器: {}\", server.getServerInfo());\n            \n            // 注意：Stdio服务器通常在后台运行，这里只是演示创建过程\n            log.info(\"Stdio MCP服务器创建成功，可以通过标准输入输出进行通信\");\n            \n            // 显示可用工具\n            showAvailableTools();\n            \n        } catch (Exception e) {\n            log.error(\"Stdio MCP服务器测试失败\", e);\n        }\n    }\n    \n    /**\n     * 测试Streamable HTTP MCP服务器\n     */\n    private static void testStreamableHttpMcpServer() {\n        log.info(\"=== 测试3: Streamable HTTP MCP服务器 ===\");\n        \n        try {\n            // 创建Streamable HTTP服务器\n            McpServer server = McpServerFactory.createServer(\"http\", \"name\", \"1.0.0\", 8081);\n            \n            log.info(\"创建Streamable HTTP MCP服务器: {}\", server.getServerInfo());\n            \n            // 启动服务器\n            CompletableFuture<Void> startFuture = server.start();\n            startFuture.get(5, TimeUnit.SECONDS);\n            \n            if (server.isRunning()) {\n                log.info(\"Streamable HTTP MCP服务器启动成功，端口: 8081\");\n                log.info(\"MCP端点: http://localhost:8081/mcp\");\n                \n                // 显示可用工具\n                showAvailableTools();\n                \n                // 运行一段时间后停止\n                Thread.sleep(2000);\n                \n                // 停止服务器\n                CompletableFuture<Void> stopFuture = server.stop();\n                stopFuture.get(5, TimeUnit.SECONDS);\n                log.info(\"Streamable HTTP MCP服务器已停止\");\n            } else {\n                log.error(\"Streamable HTTP MCP服务器启动失败\");\n            }\n            \n        } catch (Exception e) {\n            log.error(\"Streamable HTTP MCP服务器测试失败\", e);\n        }\n    }\n    \n    /**\n     * 显示可用工具\n     */\n    private static void showAvailableTools() {\n        try {\n            log.info(\"--- 可用工具列表 ---\");\n            \n            // 获取所有工具\n            List<Tool> allTools = ToolUtil.getAllTools(new ArrayList<>(), new ArrayList<>());\n            \n            log.info(\"总计 {} 个工具:\", allTools.size());\n            for (Tool tool : allTools) {\n                if (tool.getFunction() != null) {\n                    log.info(\"- {}: {}\", \n                            tool.getFunction().getName(), \n                            tool.getFunction().getDescription());\n                }\n            }\n            \n            // 测试调用几个工具\n            log.info(\"--- 工具调用测试 ---\");\n            testToolCall(\"TestService_greet\", \"{\\\"name\\\":\\\"MCP测试用户\\\"}\");\n            testToolCall(\"TestService_add\", \"{\\\"a\\\":100,\\\"b\\\":200}\");\n            \n        } catch (Exception e) {\n            log.error(\"显示可用工具失败\", e);\n        }\n    }\n    \n    /**\n     * 测试工具调用\n     */\n    private static void testToolCall(String toolName, String arguments) {\n        try {\n            log.info(\"调用工具: {} 参数: {}\", toolName, arguments);\n            String result = ToolUtil.invoke(toolName, arguments);\n            log.info(\"调用结果: {}\", result);\n        } catch (Exception e) {\n            log.error(\"工具调用失败: {} - {}\", toolName, e.getMessage());\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/McpTypeSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.mcp;\n\nimport io.github.lnyocly.ai4j.mcp.config.McpServerConfig;\nimport io.github.lnyocly.ai4j.mcp.entity.McpServerReference;\nimport io.github.lnyocly.ai4j.mcp.transport.TransportConfig;\nimport io.github.lnyocly.ai4j.mcp.util.McpTypeSupport;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashMap;\n\npublic class McpTypeSupportTest {\n\n    @Test\n    public void shouldNormalizeKnownAliases() {\n        Assert.assertEquals(McpTypeSupport.TYPE_STDIO, McpTypeSupport.normalizeType(\"process\"));\n        Assert.assertEquals(McpTypeSupport.TYPE_SSE, McpTypeSupport.normalizeType(\"event-stream\"));\n        Assert.assertEquals(McpTypeSupport.TYPE_STREAMABLE_HTTP, McpTypeSupport.normalizeType(\"http\"));\n        Assert.assertEquals(McpTypeSupport.TYPE_STREAMABLE_HTTP, McpTypeSupport.normalizeType(\"streamable-http\"));\n    }\n\n    @Test\n    public void shouldCreateTransportConfigFromServerInfo() {\n        McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo();\n        serverInfo.setTransport(\"server-sent-events\");\n        serverInfo.setUrl(\"http://localhost:8080/sse\");\n        serverInfo.setHeaders(new HashMap<String, String>(Collections.singletonMap(\"Authorization\", \"Bearer test\")));\n\n        TransportConfig config = TransportConfig.fromServerInfo(serverInfo);\n\n        Assert.assertEquals(McpTypeSupport.TYPE_SSE, config.getType());\n        Assert.assertEquals(\"http://localhost:8080/sse\", config.getUrl());\n        Assert.assertEquals(\"Bearer test\", config.getHeaders().get(\"Authorization\"));\n    }\n\n    @Test\n    public void shouldCreateStdioTransportConfigFromServerInfo() {\n        McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo();\n        serverInfo.setType(\"local\");\n        serverInfo.setCommand(\"npx\");\n        serverInfo.setArgs(Arrays.asList(\"-y\", \"@modelcontextprotocol/server-filesystem\"));\n\n        TransportConfig config = TransportConfig.fromServerInfo(serverInfo);\n\n        Assert.assertEquals(McpTypeSupport.TYPE_STDIO, config.getType());\n        Assert.assertEquals(\"npx\", config.getCommand());\n        Assert.assertEquals(2, config.getArgs().size());\n    }\n\n    @Test\n    public void shouldResolveTypeFromServerReference() {\n        McpServerReference serverReference = McpServerReference.http(\"demo\", \"http://localhost:8080/mcp\");\n\n        Assert.assertEquals(McpTypeSupport.TYPE_STREAMABLE_HTTP, McpTypeSupport.resolveType(serverReference));\n        Assert.assertEquals(McpTypeSupport.TYPE_STREAMABLE_HTTP, serverReference.getResolvedType());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/TestMcpService.java",
    "content": "package io.github.lnyocly.ai4j.mcp;\n\nimport io.github.lnyocly.ai4j.mcp.annotation.McpService;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpTool;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpParameter;\n\n/**\n * 测试用的MCP服务\n */\n@McpService(name = \"TestService\", description = \"测试MCP服务\")\npublic class TestMcpService {\n\n    /**\n     * 简单的问候工具\n     */\n    @McpTool(name = \"greet\", description = \"向指定的人问候\")\n    public String greet(@McpParameter(name = \"name\", description = \"要问候的人的名字\", required = true) String name) {\n        return \"Hello, \" + name + \"! Welcome to MCP service!\";\n    }\n\n    /**\n     * 数学计算工具\n     */\n    @McpTool(name = \"add\", description = \"计算两个数字的和\")\n    public int add(\n            @McpParameter(name = \"a\", description = \"第一个数字\", required = true) int a,\n            @McpParameter(name = \"b\", description = \"第二个数字\", required = true) int b) {\n        return a + b;\n    }\n\n    /**\n     * 字符串处理工具\n     */\n    @McpTool(name = \"reverse\", description = \"反转字符串\")\n    public String reverse(@McpParameter(name = \"text\", description = \"要反转的文本\", required = true) String text) {\n        if (text == null) {\n            return \"\";\n        }\n        return new StringBuilder(text).reverse().toString();\n    }\n\n    /**\n     * 获取系统信息工具\n     */\n    @McpTool(name = \"systemInfo\", description = \"获取系统信息\")\n    public SystemInfo getSystemInfo() {\n        SystemInfo info = new SystemInfo();\n        info.javaVersion = System.getProperty(\"java.version\");\n        info.osName = System.getProperty(\"os.name\");\n        info.osVersion = System.getProperty(\"os.version\");\n        info.timestamp = System.currentTimeMillis();\n        return info;\n    }\n\n    /**\n     * 系统信息类\n     */\n    public static class SystemInfo {\n        public String javaVersion;\n        public String osName;\n        public String osVersion;\n        public long timestamp;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/config/McpConfigManagerTest.java",
    "content": "package io.github.lnyocly.ai4j.mcp.config;\n\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class McpConfigManagerTest {\n\n    @Test\n    public void shouldValidateSseConfigWithModernTypeField() {\n        McpConfigManager configManager = new McpConfigManager();\n        McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo();\n        serverInfo.setName(\"weather\");\n        serverInfo.setType(\"sse\");\n        serverInfo.setUrl(\"http://localhost:8080/sse\");\n\n        Assert.assertTrue(configManager.validateConfig(serverInfo));\n    }\n\n    @Test\n    public void shouldRejectUnknownTransportType() {\n        McpConfigManager configManager = new McpConfigManager();\n        McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo();\n        serverInfo.setName(\"weather\");\n        serverInfo.setType(\"custom-bus\");\n        serverInfo.setCommand(\"npx\");\n\n        Assert.assertFalse(configManager.validateConfig(serverInfo));\n    }\n\n    @Test\n    public void shouldNotifyListenerWhenUpdatingExistingConfig() {\n        McpConfigManager configManager = new McpConfigManager();\n        AtomicInteger addedCount = new AtomicInteger(0);\n        AtomicInteger updatedCount = new AtomicInteger(0);\n\n        configManager.addConfigChangeListener(new McpConfigSource.ConfigChangeListener() {\n            @Override\n            public void onConfigAdded(String serverId, McpServerConfig.McpServerInfo config) {\n                addedCount.incrementAndGet();\n            }\n\n            @Override\n            public void onConfigRemoved(String serverId) {\n            }\n\n            @Override\n            public void onConfigUpdated(String serverId, McpServerConfig.McpServerInfo config) {\n                updatedCount.incrementAndGet();\n            }\n        });\n\n        configManager.addConfig(\"demo\", createStdioConfig(\"npx\"));\n        configManager.updateConfig(\"demo\", createStdioConfig(\"uvx\"));\n\n        Assert.assertEquals(1, addedCount.get());\n        Assert.assertEquals(1, updatedCount.get());\n        Assert.assertEquals(\"uvx\", configManager.getConfig(\"demo\").getCommand());\n    }\n\n    private McpServerConfig.McpServerInfo createStdioConfig(String command) {\n        McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo();\n        serverInfo.setName(\"filesystem\");\n        serverInfo.setType(\"stdio\");\n        serverInfo.setCommand(command);\n        return serverInfo;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/gateway/McpGatewayConfigSourceTest.java",
    "content": "package io.github.lnyocly.ai4j.mcp.gateway;\n\nimport io.github.lnyocly.ai4j.mcp.client.McpClient;\nimport io.github.lnyocly.ai4j.mcp.config.McpConfigManager;\nimport io.github.lnyocly.ai4j.mcp.config.McpServerConfig;\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransport;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class McpGatewayConfigSourceTest {\n\n    @Test\n    public void shouldLoadConfigSourceOnlyOnceDuringInitialize() {\n        AtomicInteger createCount = new AtomicInteger(0);\n        McpGateway gateway = new McpGateway(new CountingClientFactory(createCount));\n        McpConfigManager configManager = new McpConfigManager();\n        configManager.addConfig(\"demo\", createStdioConfig(\"npx\"));\n\n        gateway.setConfigSource(configManager);\n        gateway.initialize().join();\n\n        Assert.assertTrue(gateway.isInitialized());\n        Assert.assertEquals(1, createCount.get());\n        Assert.assertEquals(1, ((Number) gateway.getGatewayStatus().get(\"totalClients\")).intValue());\n\n        gateway.shutdown().join();\n    }\n\n    @Test\n    public void shouldDisconnectPreviousClientWhenReplacingSameKey() {\n        McpGateway gateway = new McpGateway(new McpGatewayClientFactory());\n        TestMcpClient firstClient = new TestMcpClient(\"demo\", \"first_tool\");\n        TestMcpClient secondClient = new TestMcpClient(\"demo\", \"second_tool\");\n\n        gateway.addMcpClient(\"demo\", firstClient).join();\n        gateway.addMcpClient(\"demo\", secondClient).join();\n\n        Assert.assertFalse(firstClient.isConnected());\n        Assert.assertTrue(secondClient.isConnected());\n        Assert.assertFalse(gateway.getToolToClientMap().containsKey(\"first_tool\"));\n        Assert.assertEquals(\"demo\", gateway.getToolToClientMap().get(\"second_tool\"));\n\n        gateway.shutdown().join();\n    }\n\n    private McpServerConfig.McpServerInfo createStdioConfig(String command) {\n        McpServerConfig.McpServerInfo serverInfo = new McpServerConfig.McpServerInfo();\n        serverInfo.setName(\"filesystem\");\n        serverInfo.setType(\"stdio\");\n        serverInfo.setCommand(command);\n        return serverInfo;\n    }\n\n    private static final class CountingClientFactory extends McpGatewayClientFactory {\n\n        private final AtomicInteger createCount;\n\n        private CountingClientFactory(AtomicInteger createCount) {\n            this.createCount = createCount;\n        }\n\n        @Override\n        public McpClient create(String serverId, McpServerConfig.McpServerInfo serverInfo) {\n            createCount.incrementAndGet();\n            return new TestMcpClient(serverId, serverId + \"_tool\");\n        }\n    }\n\n    private static final class TestMcpClient extends McpClient {\n\n        private final AtomicBoolean connected = new AtomicBoolean(false);\n        private final List<McpToolDefinition> tools;\n\n        private TestMcpClient(String clientName, String toolName) {\n            super(clientName, \"test\", new NoopTransport());\n            this.tools = Collections.singletonList(McpToolDefinition.builder()\n                    .name(toolName)\n                    .description(\"test tool\")\n                    .build());\n        }\n\n        @Override\n        public CompletableFuture<Void> connect() {\n            connected.set(true);\n            return CompletableFuture.completedFuture(null);\n        }\n\n        @Override\n        public CompletableFuture<Void> disconnect() {\n            connected.set(false);\n            return CompletableFuture.completedFuture(null);\n        }\n\n        @Override\n        public boolean isConnected() {\n            return connected.get();\n        }\n\n        @Override\n        public boolean isInitialized() {\n            return connected.get();\n        }\n\n        @Override\n        public CompletableFuture<List<McpToolDefinition>> getAvailableTools() {\n            return CompletableFuture.completedFuture(tools);\n        }\n    }\n\n    private static final class NoopTransport implements McpTransport {\n\n        @Override\n        public CompletableFuture<Void> start() {\n            return CompletableFuture.completedFuture(null);\n        }\n\n        @Override\n        public CompletableFuture<Void> stop() {\n            return CompletableFuture.completedFuture(null);\n        }\n\n        @Override\n        public CompletableFuture<Void> sendMessage(McpMessage message) {\n            return CompletableFuture.completedFuture(null);\n        }\n\n        @Override\n        public void setMessageHandler(McpMessageHandler handler) {\n        }\n\n        @Override\n        public boolean isConnected() {\n            return true;\n        }\n\n        @Override\n        public boolean needsHeartbeat() {\n            return false;\n        }\n\n        @Override\n        public String getTransportType() {\n            return \"test\";\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/transport/SseTransportTest.java",
    "content": "package io.github.lnyocly.ai4j.mcp.transport;\n\nimport io.github.lnyocly.ai4j.mcp.entity.McpMessage;\nimport io.github.lnyocly.ai4j.mcp.entity.McpRequest;\nimport org.junit.After;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.BufferedReader;\nimport java.io.Closeable;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.net.ServerSocket;\nimport java.net.Socket;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Collections;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class SseTransportTest {\n\n    private RawSseFixture fixture;\n\n    @After\n    public void tearDown() throws Exception {\n        if (fixture != null) {\n            fixture.close();\n            fixture = null;\n        }\n    }\n\n    @Test\n    public void receivesExplicitMessageEvents() throws Exception {\n        fixture = new RawSseFixture(false);\n        fixture.start();\n\n        SseTransport transport = new SseTransport(fixture.getSseUrl());\n        CapturingHandler handler = new CapturingHandler();\n        transport.setMessageHandler(handler);\n\n        transport.start().get(5, TimeUnit.SECONDS);\n        transport.sendMessage(new McpRequest(\"initialize\", 1L, Collections.<String, Object>emptyMap()))\n                .get(5, TimeUnit.SECONDS);\n\n        Assert.assertTrue(\"expected a response event\", handler.messageLatch.await(5, TimeUnit.SECONDS));\n        Assert.assertNull(\"unexpected transport error\", handler.lastError);\n        Assert.assertNotNull(handler.lastMessage);\n        Assert.assertTrue(handler.lastMessage.isResponse());\n        Assert.assertEquals(1L, ((Number) handler.lastMessage.getId()).longValue());\n        Assert.assertEquals(\"POST\", fixture.lastRequestMethod.get());\n        Assert.assertTrue(fixture.lastRequestBody.get().contains(\"\\\"initialize\\\"\"));\n        Assert.assertNull(\"fixture server failed\", fixture.failure.get());\n\n        transport.stop().get(5, TimeUnit.SECONDS);\n    }\n\n    @Test\n    public void defaultsUnnamedEventsToMessageAndJoinsMultilineData() throws Exception {\n        fixture = new RawSseFixture(true);\n        fixture.start();\n\n        SseTransport transport = new SseTransport(fixture.getSseUrl());\n        CapturingHandler handler = new CapturingHandler();\n        transport.setMessageHandler(handler);\n\n        transport.start().get(5, TimeUnit.SECONDS);\n        transport.sendMessage(new McpRequest(\"initialize\", 2L, Collections.<String, Object>emptyMap()))\n                .get(5, TimeUnit.SECONDS);\n\n        Assert.assertTrue(\"expected a response event\", handler.messageLatch.await(5, TimeUnit.SECONDS));\n        Assert.assertNull(\"unexpected transport error\", handler.lastError);\n        Assert.assertNotNull(handler.lastMessage);\n        Assert.assertTrue(handler.lastMessage.isResponse());\n        Assert.assertEquals(2L, ((Number) handler.lastMessage.getId()).longValue());\n        Assert.assertNotNull(handler.lastMessage.getResult());\n        Assert.assertTrue(String.valueOf(handler.lastMessage.getResult()).contains(\"multi-line\"));\n        Assert.assertNull(\"fixture server failed\", fixture.failure.get());\n\n        transport.stop().get(5, TimeUnit.SECONDS);\n    }\n\n    private static final class CapturingHandler implements McpTransport.McpMessageHandler {\n\n        private final CountDownLatch messageLatch = new CountDownLatch(1);\n        private volatile McpMessage lastMessage;\n        private volatile Throwable lastError;\n\n        @Override\n        public void handleMessage(McpMessage message) {\n            this.lastMessage = message;\n            messageLatch.countDown();\n        }\n\n        @Override\n        public void onConnected() {\n        }\n\n        @Override\n        public void onDisconnected(String reason) {\n        }\n\n        @Override\n        public void onError(Throwable error) {\n            this.lastError = error;\n            messageLatch.countDown();\n        }\n    }\n\n    private static final class RawSseFixture implements Closeable {\n\n        private final boolean unnamedMessageEvent;\n        private final ServerSocket serverSocket;\n        private final AtomicReference<String> lastRequestMethod = new AtomicReference<String>();\n        private final AtomicReference<String> lastRequestBody = new AtomicReference<String>();\n        private final AtomicReference<Throwable> failure = new AtomicReference<Throwable>();\n        private final CountDownLatch endpointServed = new CountDownLatch(1);\n\n        private volatile Thread serverThread;\n        private volatile Socket sseSocket;\n        private volatile Socket postSocket;\n\n        private RawSseFixture(boolean unnamedMessageEvent) throws IOException {\n            this.unnamedMessageEvent = unnamedMessageEvent;\n            this.serverSocket = new ServerSocket(0);\n        }\n\n        private void start() {\n            serverThread = new Thread(new Runnable() {\n                @Override\n                public void run() {\n                    serve();\n                }\n            }, \"raw-sse-fixture\");\n            serverThread.setDaemon(true);\n            serverThread.start();\n        }\n\n        private String getSseUrl() {\n            return \"http://127.0.0.1:\" + serverSocket.getLocalPort() + \"/sse\";\n        }\n\n        private void serve() {\n            try {\n                sseSocket = serverSocket.accept();\n                handleSseConnection(sseSocket);\n            } catch (Throwable t) {\n                if (!serverSocket.isClosed()) {\n                    failure.compareAndSet(null, t);\n                }\n            }\n        }\n\n        private void handleSseConnection(Socket socket) throws Exception {\n            BufferedReader requestReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));\n            String requestLine = requestReader.readLine();\n            if (requestLine == null || !requestLine.startsWith(\"GET /sse \")) {\n                throw new IOException(\"unexpected SSE request line: \" + requestLine);\n            }\n            drainHeaders(requestReader);\n\n            OutputStream sseOutput = socket.getOutputStream();\n            writeAscii(sseOutput,\n                    \"HTTP/1.1 200 OK\\r\\n\" +\n                    \"Content-Type: text/event-stream; charset=utf-8\\r\\n\" +\n                    \"Cache-Control: no-cache\\r\\n\" +\n                    \"Connection: keep-alive\\r\\n\\r\\n\");\n            writeAscii(sseOutput,\n                    \"event: endpoint\\n\" +\n                    \"data: /message?session_id=test-session\\n\\n\");\n            sseOutput.flush();\n            endpointServed.countDown();\n\n            postSocket = serverSocket.accept();\n            handlePostConnection(postSocket, sseOutput);\n        }\n\n        private void handlePostConnection(Socket socket, OutputStream sseOutput) throws Exception {\n            BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));\n            String requestLine = reader.readLine();\n            if (requestLine == null || !requestLine.startsWith(\"POST /message?session_id=test-session \")) {\n                throw new IOException(\"unexpected POST request line: \" + requestLine);\n            }\n            lastRequestMethod.set(requestLine.substring(0, requestLine.indexOf(' ')));\n\n            int contentLength = 0;\n            String line;\n            while ((line = reader.readLine()) != null && !line.isEmpty()) {\n                String lower = line.toLowerCase();\n                if (lower.startsWith(\"content-length:\")) {\n                    contentLength = Integer.parseInt(line.substring(line.indexOf(':') + 1).trim());\n                }\n            }\n\n            char[] body = new char[contentLength];\n            int offset = 0;\n            while (offset < contentLength) {\n                int read = reader.read(body, offset, contentLength - offset);\n                if (read == -1) {\n                    break;\n                }\n                offset += read;\n            }\n            lastRequestBody.set(new String(body, 0, offset));\n\n            OutputStream postOutput = socket.getOutputStream();\n            writeAscii(postOutput,\n                    \"HTTP/1.1 202 Accepted\\r\\n\" +\n                    \"Content-Type: text/plain\\r\\n\" +\n                    \"Content-Length: 8\\r\\n\" +\n                    \"Connection: close\\r\\n\\r\\n\" +\n                    \"Accepted\");\n            postOutput.flush();\n\n            try {\n                if (!endpointServed.await(5, TimeUnit.SECONDS)) {\n                    throw new IOException(\"endpoint event was not served\");\n                }\n            } catch (InterruptedException e) {\n                Thread.currentThread().interrupt();\n                throw new IOException(\"interrupted while waiting for endpoint event\", e);\n            }\n\n            if (unnamedMessageEvent) {\n                writeAscii(sseOutput,\n                        \"data: {\\\"jsonrpc\\\":\\\"2.0\\\",\\\"id\\\":2,\\n\" +\n                        \"data: \\\"result\\\":{\\\"value\\\":\\\"multi-line\\\"}}\\n\\n\");\n            } else {\n                writeAscii(sseOutput,\n                        \"event: message\\n\" +\n                        \"data: {\\\"jsonrpc\\\":\\\"2.0\\\",\\\"id\\\":1,\\\"result\\\":{\\\"value\\\":\\\"ok\\\"}}\\n\\n\");\n            }\n            sseOutput.flush();\n            Thread.sleep(100);\n        }\n\n        private void drainHeaders(BufferedReader reader) throws IOException {\n            String line;\n            while ((line = reader.readLine()) != null && !line.isEmpty()) {\n                // drain request headers\n            }\n        }\n\n        private void writeAscii(OutputStream outputStream, String value) throws IOException {\n            outputStream.write(value.getBytes(StandardCharsets.UTF_8));\n        }\n\n        @Override\n        public void close() throws IOException {\n            closeQuietly(postSocket);\n            closeQuietly(sseSocket);\n            serverSocket.close();\n            if (serverThread != null) {\n                try {\n                    serverThread.join(TimeUnit.SECONDS.toMillis(2));\n                } catch (InterruptedException e) {\n                    Thread.currentThread().interrupt();\n                    throw new IOException(\"interrupted while stopping raw SSE fixture\", e);\n                }\n            }\n        }\n\n        private void closeQuietly(Socket socket) {\n            if (socket != null) {\n                try {\n                    socket.close();\n                } catch (IOException ignored) {\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/mcp/util/TestMcpService.java",
    "content": "package io.github.lnyocly.ai4j.mcp.util;\n\nimport io.github.lnyocly.ai4j.mcp.annotation.McpParameter;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpService;\nimport io.github.lnyocly.ai4j.mcp.annotation.McpTool;\n\n/**\n * @Author cly\n * @Description 测试用的MCP服务\n * @Date 2024/12/29 02:30\n */\n@McpService(\n    name = \"test-service\",\n    description = \"测试MCP服务\",\n    version = \"1.0.0\"\n)\npublic class TestMcpService {\n\n    @McpTool(\n        name = \"add_numbers\",\n        description = \"计算两个数字的和\"\n    )\n    public int addNumbers(\n            @McpParameter(name = \"a\", description = \"第一个数字\", required = true) int a,\n            @McpParameter(name = \"b\", description = \"第二个数字\", required = true) int b\n    ) {\n        return a + b;\n    }\n\n    @McpTool(\n        name = \"greet_user\",\n        description = \"向用户问候\"\n    )\n    public String greetUser(\n            @McpParameter(name = \"name\", description = \"用户名称\", required = true) String name,\n            @McpParameter(name = \"greeting\", description = \"问候语\", required = false, defaultValue = \"Hello\") String greeting\n    ) {\n        return greeting + \", \" + name + \"!\";\n    }\n\n    @McpTool(\n        description = \"获取当前时间戳\"\n    )\n    public long getCurrentTimestamp() {\n        return System.currentTimeMillis();\n    }\n}"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/memory/InMemoryChatMemoryTest.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport org.junit.Test;\n\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class InMemoryChatMemoryTest {\n\n    @Test\n    public void shouldKeepAllMessagesByDefault() {\n        InMemoryChatMemory memory = new InMemoryChatMemory();\n\n        memory.addSystem(\"You are helpful\");\n        memory.addUser(\"Hello\");\n        memory.addAssistant(\"Hi\");\n\n        assertEquals(3, memory.getItems().size());\n        List<ChatMessage> messages = memory.toChatMessages();\n        assertEquals(3, messages.size());\n        assertEquals(\"system\", messages.get(0).getRole());\n        assertEquals(\"user\", messages.get(1).getRole());\n        assertEquals(\"assistant\", messages.get(2).getRole());\n    }\n\n    @Test\n    public void shouldRetainSystemMessagesAndTrimWindow() {\n        InMemoryChatMemory memory = new InMemoryChatMemory(new MessageWindowChatMemoryPolicy(2));\n\n        memory.addSystem(\"system\");\n        memory.addUser(\"u1\");\n        memory.addAssistant(\"a1\");\n        memory.addUser(\"u2\");\n\n        List<ChatMemoryItem> items = memory.getItems();\n        assertEquals(3, items.size());\n        assertEquals(\"system\", items.get(0).getRole());\n        assertEquals(\"assistant\", items.get(1).getRole());\n        assertEquals(\"u2\", items.get(2).getText());\n    }\n\n    @Test\n    @SuppressWarnings(\"unchecked\")\n    public void shouldConvertToResponsesInputWithToolCallsAndOutputs() {\n        InMemoryChatMemory memory = new InMemoryChatMemory();\n        ToolCall toolCall = new ToolCall(\n                \"call_1\",\n                \"function\",\n                new ToolCall.Function(\"queryWeather\", \"{\\\"city\\\":\\\"Luoyang\\\"}\")\n        );\n\n        memory.addAssistant(\"Let me check\", java.util.Collections.singletonList(toolCall));\n        memory.addToolOutput(\"call_1\", \"{\\\"weather\\\":\\\"sunny\\\"}\");\n\n        List<Object> input = memory.toResponsesInput();\n        assertEquals(2, input.size());\n\n        Map<String, Object> assistant = (Map<String, Object>) input.get(0);\n        assertEquals(\"message\", assistant.get(\"type\"));\n        assertEquals(\"assistant\", assistant.get(\"role\"));\n        assertNotNull(assistant.get(\"tool_calls\"));\n\n        Map<String, Object> toolOutput = (Map<String, Object>) input.get(1);\n        assertEquals(\"function_call_output\", toolOutput.get(\"type\"));\n        assertEquals(\"call_1\", toolOutput.get(\"call_id\"));\n        assertEquals(\"{\\\"weather\\\":\\\"sunny\\\"}\", toolOutput.get(\"output\"));\n    }\n\n    @Test\n    public void shouldSupportSnapshotAndRestore() {\n        InMemoryChatMemory memory = new InMemoryChatMemory();\n        memory.addUser(\"hello\", \"https://example.com/cat.png\");\n\n        ChatMemorySnapshot snapshot = memory.snapshot();\n        memory.clear();\n        assertTrue(memory.getItems().isEmpty());\n\n        memory.restore(snapshot);\n        assertEquals(1, memory.getItems().size());\n        ChatMemoryItem item = memory.getItems().get(0);\n        assertEquals(\"user\", item.getRole());\n        assertEquals(\"hello\", item.getText());\n        assertNotNull(item.getImageUrls());\n        assertEquals(1, item.getImageUrls().size());\n    }\n\n    @Test\n    public void shouldCompactMessagesWithSummaryPolicy() {\n        InMemoryChatMemory memory = new InMemoryChatMemory(\n                new SummaryChatMemoryPolicy(\n                        SummaryChatMemoryPolicyConfig.builder()\n                                .summarizer(new ChatMemorySummarizer() {\n                                    @Override\n                                    public String summarize(ChatMemorySummaryRequest request) {\n                                        StringBuilder summary = new StringBuilder();\n                                        if (request != null && request.getItemsToSummarize() != null) {\n                                            for (ChatMemoryItem item : request.getItemsToSummarize()) {\n                                                if (item == null) {\n                                                    continue;\n                                                }\n                                                summary.append(item.getRole()).append(\":\").append(item.getText()).append(\";\");\n                                            }\n                                        }\n                                        return summary.toString();\n                                    }\n                                })\n                                .maxRecentMessages(2)\n                                .summaryTriggerMessages(3)\n                                .summaryTextPrefix(\"SUMMARY:\\n\")\n                                .build()\n                )\n        );\n\n        memory.addSystem(\"system\");\n        memory.addUser(\"u1\");\n        memory.addAssistant(\"a1\");\n        memory.addUser(\"u2\");\n        memory.addAssistant(\"a2\");\n\n        List<ChatMemoryItem> items = memory.getItems();\n        assertEquals(4, items.size());\n        assertTrue(items.get(1).isSummary());\n        assertTrue(items.get(1).getText().startsWith(\"SUMMARY:\\n\"));\n        assertEquals(\"u2\", items.get(2).getText());\n        assertEquals(\"a2\", items.get(3).getText());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/memory/JdbcChatMemoryTest.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport org.junit.Test;\n\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertTrue;\n\npublic class JdbcChatMemoryTest {\n\n    @Test\n    public void shouldPersistMessagesAcrossInstances() {\n        String jdbcUrl = jdbcUrl(\"persist\");\n\n        JdbcChatMemory first = new JdbcChatMemory(JdbcChatMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .sessionId(\"chat-1\")\n                .build());\n        first.addSystem(\"You are helpful\");\n        first.addUser(\"Hello\");\n        first.addAssistant(\"Hi\");\n\n        JdbcChatMemory second = new JdbcChatMemory(JdbcChatMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .sessionId(\"chat-1\")\n                .build());\n\n        List<ChatMemoryItem> items = second.getItems();\n        assertEquals(3, items.size());\n        assertEquals(\"system\", items.get(0).getRole());\n        assertEquals(\"Hello\", items.get(1).getText());\n        assertEquals(\"Hi\", items.get(2).getText());\n    }\n\n    @Test\n    public void shouldApplyConfiguredWindowPolicy() {\n        JdbcChatMemory memory = new JdbcChatMemory(JdbcChatMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl(\"policy\"))\n                .sessionId(\"chat-2\")\n                .policy(new MessageWindowChatMemoryPolicy(2))\n                .build());\n\n        memory.addSystem(\"system\");\n        memory.addUser(\"u1\");\n        memory.addAssistant(\"a1\");\n        memory.addUser(\"u2\");\n\n        List<ChatMemoryItem> items = memory.getItems();\n        assertEquals(3, items.size());\n        assertEquals(\"system\", items.get(0).getRole());\n        assertEquals(\"a1\", items.get(1).getText());\n        assertEquals(\"u2\", items.get(2).getText());\n    }\n\n    @Test\n    public void shouldSupportSnapshotRestoreAndClear() {\n        JdbcChatMemory memory = new JdbcChatMemory(JdbcChatMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl(\"snapshot\"))\n                .sessionId(\"chat-3\")\n                .build());\n\n        memory.addUser(\"hello\", \"https://example.com/cat.png\");\n        ChatMemorySnapshot snapshot = memory.snapshot();\n\n        memory.clear();\n        assertTrue(memory.getItems().isEmpty());\n\n        memory.restore(snapshot);\n        assertEquals(1, memory.getItems().size());\n        assertEquals(\"hello\", memory.getItems().get(0).getText());\n        assertEquals(1, memory.getItems().get(0).getImageUrls().size());\n    }\n\n    @Test\n    public void shouldPersistSummaryEntriesAcrossInstances() {\n        String jdbcUrl = jdbcUrl(\"summary\");\n\n        JdbcChatMemory first = new JdbcChatMemory(JdbcChatMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .sessionId(\"chat-4\")\n                .policy(new SummaryChatMemoryPolicy(\n                        SummaryChatMemoryPolicyConfig.builder()\n                                .summarizer(new ChatMemorySummarizer() {\n                                    @Override\n                                    public String summarize(ChatMemorySummaryRequest request) {\n                                        StringBuilder summary = new StringBuilder();\n                                        if (request != null && request.getItemsToSummarize() != null) {\n                                            for (ChatMemoryItem item : request.getItemsToSummarize()) {\n                                                if (item == null) {\n                                                    continue;\n                                                }\n                                                summary.append(item.getRole()).append(\":\").append(item.getText()).append(\";\");\n                                            }\n                                        }\n                                        return summary.toString();\n                                    }\n                                })\n                                .maxRecentMessages(2)\n                                .summaryTriggerMessages(3)\n                                .summaryTextPrefix(\"SUMMARY:\\n\")\n                                .build()))\n                .build());\n\n        first.addSystem(\"system\");\n        first.addUser(\"u1\");\n        first.addAssistant(\"a1\");\n        first.addUser(\"u2\");\n        first.addAssistant(\"a2\");\n\n        JdbcChatMemory second = new JdbcChatMemory(JdbcChatMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .sessionId(\"chat-4\")\n                .build());\n\n        List<ChatMemoryItem> items = second.getItems();\n        assertEquals(4, items.size());\n        assertTrue(items.get(1).isSummary());\n        assertTrue(items.get(1).getText().startsWith(\"SUMMARY:\\n\"));\n        assertEquals(\"u2\", items.get(2).getText());\n        assertEquals(\"a2\", items.get(3).getText());\n    }\n\n    private String jdbcUrl(String suffix) {\n        return \"jdbc:h2:mem:ai4j_chat_memory_\" + suffix + \";MODE=MYSQL;DB_CLOSE_DELAY=-1\";\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/memory/SummaryChatMemoryPolicyTest.java",
    "content": "package io.github.lnyocly.ai4j.memory;\n\nimport io.github.lnyocly.ai4j.platform.openai.chat.enums.ChatMessageType;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class SummaryChatMemoryPolicyTest {\n\n    @Test\n    public void shouldSummarizeOlderMessagesAndKeepRecentWindow() {\n        SummaryChatMemoryPolicy policy = new SummaryChatMemoryPolicy(\n                SummaryChatMemoryPolicyConfig.builder()\n                        .summarizer(new RecordingSummarizer())\n                        .maxRecentMessages(2)\n                        .summaryTriggerMessages(3)\n                        .summaryTextPrefix(\"SUMMARY:\\n\")\n                        .build()\n        );\n\n        List<ChatMemoryItem> compacted = policy.apply(items(\n                ChatMemoryItem.system(\"system\"),\n                ChatMemoryItem.user(\"u1\"),\n                ChatMemoryItem.assistant(\"a1\"),\n                ChatMemoryItem.user(\"u2\"),\n                ChatMemoryItem.assistant(\"a2\")\n        ));\n\n        assertEquals(4, compacted.size());\n        assertEquals(ChatMessageType.SYSTEM.getRole(), compacted.get(0).getRole());\n        assertTrue(compacted.get(1).isSummary());\n        assertEquals(ChatMessageType.ASSISTANT.getRole(), compacted.get(1).getRole());\n        assertTrue(compacted.get(1).getText().startsWith(\"SUMMARY:\\n\"));\n        assertTrue(compacted.get(1).getText().contains(\"user:u1\"));\n        assertTrue(compacted.get(1).getText().contains(\"assistant:a1\"));\n        assertEquals(\"u2\", compacted.get(2).getText());\n        assertEquals(\"a2\", compacted.get(3).getText());\n    }\n\n    @Test\n    public void shouldMergePreviousSummaryIntoNextCompaction() {\n        RecordingSummarizer summarizer = new RecordingSummarizer();\n        SummaryChatMemoryPolicy policy = new SummaryChatMemoryPolicy(\n                SummaryChatMemoryPolicyConfig.builder()\n                        .summarizer(summarizer)\n                        .maxRecentMessages(2)\n                        .summaryTriggerMessages(3)\n                        .summaryTextPrefix(\"SUMMARY:\\n\")\n                        .build()\n        );\n\n        List<ChatMemoryItem> first = policy.apply(items(\n                ChatMemoryItem.user(\"u1\"),\n                ChatMemoryItem.assistant(\"a1\"),\n                ChatMemoryItem.user(\"u2\"),\n                ChatMemoryItem.assistant(\"a2\")\n        ));\n\n        List<ChatMemoryItem> secondInput = new ArrayList<ChatMemoryItem>(first);\n        secondInput.add(ChatMemoryItem.user(\"u3\"));\n        secondInput.add(ChatMemoryItem.assistant(\"a3\"));\n        List<ChatMemoryItem> second = policy.apply(secondInput);\n\n        assertEquals(2, summarizer.getInvocationCount());\n        ChatMemorySummaryRequest request = summarizer.getLastRequest();\n        assertNotNull(request);\n        assertEquals(\"user:u1;assistant:a1;\", request.getExistingSummary());\n        assertEquals(2, request.getItemsToSummarize().size());\n        assertEquals(\"u2\", request.getItemsToSummarize().get(0).getText());\n        assertEquals(\"a2\", request.getItemsToSummarize().get(1).getText());\n\n        assertEquals(3, second.size());\n        assertTrue(second.get(0).isSummary());\n        assertEquals(\"u3\", second.get(1).getText());\n        assertEquals(\"a3\", second.get(2).getText());\n    }\n\n    @Test\n    public void shouldKeepOriginalItemsWhenSummaryOutputIsBlank() {\n        SummaryChatMemoryPolicy policy = new SummaryChatMemoryPolicy(\n                SummaryChatMemoryPolicyConfig.builder()\n                        .summarizer(new ChatMemorySummarizer() {\n                            @Override\n                            public String summarize(ChatMemorySummaryRequest request) {\n                                return \"   \";\n                            }\n                        })\n                        .maxRecentMessages(1)\n                        .summaryTriggerMessages(2)\n                        .build()\n        );\n\n        List<ChatMemoryItem> original = items(\n                ChatMemoryItem.user(\"u1\"),\n                ChatMemoryItem.assistant(\"a1\"),\n                ChatMemoryItem.user(\"u2\")\n        );\n\n        List<ChatMemoryItem> compacted = policy.apply(original);\n\n        assertEquals(3, compacted.size());\n        assertEquals(\"u1\", compacted.get(0).getText());\n        assertEquals(\"a1\", compacted.get(1).getText());\n        assertEquals(\"u2\", compacted.get(2).getText());\n    }\n\n    private List<ChatMemoryItem> items(ChatMemoryItem... items) {\n        List<ChatMemoryItem> list = new ArrayList<ChatMemoryItem>();\n        if (items != null) {\n            for (ChatMemoryItem item : items) {\n                list.add(item);\n            }\n        }\n        return list;\n    }\n\n    private static class RecordingSummarizer implements ChatMemorySummarizer {\n\n        private int invocationCount;\n        private ChatMemorySummaryRequest lastRequest;\n\n        @Override\n        public String summarize(ChatMemorySummaryRequest request) {\n            invocationCount++;\n            lastRequest = request;\n            StringBuilder summary = new StringBuilder();\n            if (request != null && request.getExistingSummary() != null && !request.getExistingSummary().trim().isEmpty()) {\n                summary.append(\"prev=\").append(request.getExistingSummary()).append(\"|\");\n            }\n            if (request != null && request.getItemsToSummarize() != null) {\n                for (ChatMemoryItem item : request.getItemsToSummarize()) {\n                    if (item == null) {\n                        continue;\n                    }\n                    summary.append(item.getRole()).append(\":\").append(item.getText()).append(\";\");\n                }\n            }\n            return summary.toString();\n        }\n\n        public int getInvocationCount() {\n            return invocationCount;\n        }\n\n        public ChatMemorySummaryRequest getLastRequest() {\n            return lastRequest;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/platform/doubao/rerank/DoubaoRerankServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.platform.doubao.rerank;\n\nimport com.sun.net.httpserver.HttpExchange;\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankDocument;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankRequest;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.OkHttpClient;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class DoubaoRerankServiceTest {\n\n    @Test\n    public void shouldConvertDoubaoKnowledgeRerankApi() throws Exception {\n        AtomicReference<String> requestBody = new AtomicReference<String>();\n        AtomicReference<String> authorization = new AtomicReference<String>();\n\n        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);\n        server.createContext(\"/api/knowledge/service/rerank\", jsonHandler(\n                \"{\\\"request_id\\\":\\\"req-1\\\",\\\"token_usage\\\":12,\\\"data\\\":[0.12,0.82]}\",\n                requestBody,\n                authorization));\n        server.start();\n        try {\n            Configuration configuration = new Configuration();\n            configuration.setOkHttpClient(new OkHttpClient());\n            configuration.setDoubaoConfig(new DoubaoConfig(\n                    \"https://ark.cn-beijing.volces.com/api/v3/\",\n                    \"ark-key\",\n                    \"chat/completions\",\n                    \"images/generations\",\n                    \"responses\",\n                    \"http://127.0.0.1:\" + server.getAddress().getPort(),\n                    \"api/knowledge/service/rerank\"\n            ));\n\n            DoubaoRerankService service = new DoubaoRerankService(configuration);\n            RerankResponse response = service.rerank(RerankRequest.builder()\n                    .model(\"doubao-rerank\")\n                    .query(\"vacation policy\")\n                    .documents(Arrays.asList(\n                            RerankDocument.builder().content(\"doc-a\").build(),\n                            RerankDocument.builder().content(\"doc-b\").build()\n                    ))\n                    .instruction(\"只判断相关性\")\n                    .build());\n\n            Assert.assertEquals(\"Bearer ark-key\", authorization.get());\n            Assert.assertTrue(requestBody.get().contains(\"\\\"rerank_model\\\":\\\"doubao-rerank\\\"\"));\n            Assert.assertTrue(requestBody.get().contains(\"\\\"rerank_instruction\\\":\\\"只判断相关性\\\"\"));\n            Assert.assertTrue(requestBody.get().contains(\"\\\"query\\\":\\\"vacation policy\\\"\"));\n            Assert.assertTrue(requestBody.get().contains(\"\\\"content\\\":\\\"doc-a\\\"\"));\n            Assert.assertEquals(2, response.getResults().size());\n            Assert.assertEquals(Integer.valueOf(1), response.getResults().get(0).getIndex());\n            Assert.assertEquals(0.82d, response.getResults().get(0).getRelevanceScore(), 0.0001d);\n            Assert.assertEquals(Integer.valueOf(12), response.getUsage().getInputTokens());\n        } finally {\n            server.stop(0);\n        }\n    }\n\n    private HttpHandler jsonHandler(final String responseBody,\n                                    final AtomicReference<String> requestBody,\n                                    final AtomicReference<String> authorization) {\n        return new HttpHandler() {\n            @Override\n            public void handle(HttpExchange exchange) {\n                try {\n                    requestBody.set(read(exchange.getRequestBody()));\n                    authorization.set(exchange.getRequestHeaders().getFirst(\"Authorization\"));\n                    byte[] response = responseBody.getBytes(StandardCharsets.UTF_8);\n                    exchange.getResponseHeaders().add(\"Content-Type\", \"application/json\");\n                    exchange.sendResponseHeaders(200, response.length);\n                    OutputStream outputStream = exchange.getResponseBody();\n                    try {\n                        outputStream.write(response);\n                    } finally {\n                        outputStream.close();\n                    }\n                } catch (Exception ex) {\n                    throw new RuntimeException(ex);\n                }\n            }\n        };\n    }\n\n    private String read(InputStream inputStream) throws Exception {\n        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n        byte[] buffer = new byte[256];\n        int len;\n        while ((len = inputStream.read(buffer)) != -1) {\n            outputStream.write(buffer, 0, len);\n        }\n        return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/platform/jina/rerank/JinaRerankServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.platform.jina.rerank;\n\nimport com.sun.net.httpserver.HttpExchange;\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\nimport io.github.lnyocly.ai4j.config.JinaConfig;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankDocument;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankRequest;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.OkHttpClient;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class JinaRerankServiceTest {\n\n    @Test\n    public void shouldCallJinaCompatibleRerankApi() throws Exception {\n        AtomicReference<String> requestBody = new AtomicReference<String>();\n        AtomicReference<String> authorization = new AtomicReference<String>();\n\n        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);\n        server.createContext(\"/v1/rerank\", jsonHandler(\n                \"{\\\"model\\\":\\\"jina-reranker-v2-base-multilingual\\\",\\\"results\\\":[{\\\"index\\\":1,\\\"relevance_score\\\":0.91,\\\"document\\\":\\\"document-b\\\"},{\\\"index\\\":0,\\\"relevance_score\\\":0.34,\\\"document\\\":\\\"document-a\\\"}],\\\"usage\\\":{\\\"total_tokens\\\":9}}\",\n                requestBody,\n                authorization));\n        server.start();\n        try {\n            Configuration configuration = new Configuration();\n            configuration.setOkHttpClient(new OkHttpClient());\n            configuration.setJinaConfig(new JinaConfig(\n                    \"http://127.0.0.1:\" + server.getAddress().getPort(),\n                    \"jina-key\",\n                    \"v1/rerank\"\n            ));\n\n            JinaRerankService service = new JinaRerankService(configuration);\n            RerankResponse response = service.rerank(RerankRequest.builder()\n                    .model(\"jina-reranker-v2-base-multilingual\")\n                    .query(\"vacation policy\")\n                    .documents(Arrays.asList(\n                            RerankDocument.builder().text(\"document-a\").content(\"document-a\").build(),\n                            RerankDocument.builder().text(\"document-b\").content(\"document-b\").build()\n                    ))\n                    .topN(2)\n                    .returnDocuments(true)\n                    .build());\n\n            Assert.assertEquals(\"Bearer jina-key\", authorization.get());\n            Assert.assertTrue(requestBody.get().contains(\"\\\"top_n\\\":2\"));\n            Assert.assertTrue(requestBody.get().contains(\"\\\"query\\\":\\\"vacation policy\\\"\"));\n            Assert.assertTrue(requestBody.get().contains(\"document-a\"));\n            Assert.assertEquals(2, response.getResults().size());\n            Assert.assertEquals(Integer.valueOf(1), response.getResults().get(0).getIndex());\n            Assert.assertEquals(0.91d, response.getResults().get(0).getRelevanceScore(), 0.0001d);\n            Assert.assertEquals(Integer.valueOf(9), response.getUsage().getTotalTokens());\n            Assert.assertEquals(\"document-b\", response.getResults().get(0).getDocument().getContent());\n        } finally {\n            server.stop(0);\n        }\n    }\n\n    private HttpHandler jsonHandler(final String responseBody,\n                                    final AtomicReference<String> requestBody,\n                                    final AtomicReference<String> authorization) {\n        return new HttpHandler() {\n            @Override\n            public void handle(HttpExchange exchange) {\n                try {\n                    requestBody.set(read(exchange.getRequestBody()));\n                    authorization.set(exchange.getRequestHeaders().getFirst(\"Authorization\"));\n                    byte[] response = responseBody.getBytes(StandardCharsets.UTF_8);\n                    exchange.getResponseHeaders().add(\"Content-Type\", \"application/json\");\n                    exchange.sendResponseHeaders(200, response.length);\n                    OutputStream outputStream = exchange.getResponseBody();\n                    try {\n                        outputStream.write(response);\n                    } finally {\n                        outputStream.close();\n                    }\n                } catch (Exception ex) {\n                    throw new RuntimeException(ex);\n                }\n            }\n        };\n    }\n\n    private String read(InputStream inputStream) throws Exception {\n        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n        byte[] buffer = new byte[256];\n        int len;\n        while ((len = inputStream.read(buffer)) != -1) {\n            outputStream.write(buffer, 0, len);\n        }\n        return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/platform/minimax/chat/MinimaxChatServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.platform.minimax.chat;\n\nimport io.github.lnyocly.ai4j.config.MinimaxConfig;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Protocol;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Collections;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class MinimaxChatServiceTest {\n\n    @Test\n    public void chatCompletionShouldReturnProviderToolCallsWhenPassThroughEnabled() throws Exception {\n        final AtomicInteger requestCount = new AtomicInteger();\n        final AtomicReference<Request> recordedRequest = new AtomicReference<Request>();\n        final String responseJson = \"{\"\n                + \"\\\"id\\\":\\\"resp_1\\\",\"\n                + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                + \"\\\"created\\\":1710000000,\"\n                + \"\\\"model\\\":\\\"MiniMax-M2.1\\\",\"\n                + \"\\\"choices\\\":[{\"\n                + \"\\\"index\\\":0,\"\n                + \"\\\"message\\\":{\"\n                + \"\\\"role\\\":\\\"assistant\\\",\"\n                + \"\\\"content\\\":\\\"\\\",\"\n                + \"\\\"tool_calls\\\":[{\"\n                + \"\\\"id\\\":\\\"call_1\\\",\"\n                + \"\\\"type\\\":\\\"function\\\",\"\n                + \"\\\"function\\\":{\"\n                + \"\\\"name\\\":\\\"read_file\\\",\"\n                + \"\\\"arguments\\\":\\\"{\\\\\\\"path\\\\\\\":\\\\\\\"README.md\\\\\\\"}\\\"\"\n                + \"}\"\n                + \"}]\"\n                + \"},\"\n                + \"\\\"finish_reason\\\":\\\"tool_calls\\\"\"\n                + \"}],\"\n                + \"\\\"usage\\\":{\"\n                + \"\\\"prompt_tokens\\\":11,\"\n                + \"\\\"completion_tokens\\\":7,\"\n                + \"\\\"total_tokens\\\":18\"\n                + \"}\"\n                + \"}\";\n\n        OkHttpClient okHttpClient = new OkHttpClient.Builder()\n                .addInterceptor(chain -> {\n                    requestCount.incrementAndGet();\n                    recordedRequest.set(chain.request());\n                    return new Response.Builder()\n                            .request(chain.request())\n                            .protocol(Protocol.HTTP_1_1)\n                            .code(200)\n                            .message(\"OK\")\n                            .body(ResponseBody.create(responseJson, MediaType.get(\"application/json\")))\n                            .build();\n                })\n                .build();\n\n        MinimaxConfig minimaxConfig = new MinimaxConfig();\n        minimaxConfig.setApiHost(\"https://unit.test/\");\n        minimaxConfig.setApiKey(\"config-api-key\");\n\n        Configuration configuration = new Configuration();\n        configuration.setMinimaxConfig(minimaxConfig);\n        configuration.setOkHttpClient(okHttpClient);\n\n        MinimaxChatService service = new MinimaxChatService(configuration);\n\n        ChatCompletion completion = ChatCompletion.builder()\n                .model(\"MiniMax-M2.1\")\n                .messages(Collections.singletonList(ChatMessage.withUser(\"Read README.md\")))\n                .tools(Collections.singletonList(tool(\"read_file\")))\n                .build();\n        completion.setPassThroughToolCalls(Boolean.TRUE);\n\n        ChatCompletionResponse response = service.chatCompletion(completion);\n\n        Assert.assertNotNull(response);\n        Assert.assertNotNull(response.getChoices());\n        Assert.assertEquals(1, response.getChoices().size());\n        Assert.assertEquals(1, requestCount.get());\n        Assert.assertEquals(\"tool_calls\", response.getChoices().get(0).getFinishReason());\n        Assert.assertNotNull(response.getChoices().get(0).getMessage());\n        Assert.assertNotNull(response.getChoices().get(0).getMessage().getToolCalls());\n        Assert.assertEquals(1, response.getChoices().get(0).getMessage().getToolCalls().size());\n        Assert.assertEquals(\"read_file\", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getName());\n        Assert.assertEquals(\"{\\\"path\\\":\\\"README.md\\\"}\", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getArguments());\n        Assert.assertEquals(\"Bearer config-api-key\", recordedRequest.get().header(\"Authorization\"));\n        Assert.assertEquals(\"https://unit.test/v1/text/chatcompletion_v2\", recordedRequest.get().url().toString());\n        Assert.assertEquals(18L, response.getUsage().getTotalTokens());\n    }\n\n    private Tool tool(String name) {\n        Tool.Function function = new Tool.Function();\n        function.setName(name);\n        function.setDescription(\"test tool\");\n        return new Tool(\"function\", function);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/platform/ollama/rerank/OllamaRerankServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.platform.ollama.rerank;\n\nimport com.sun.net.httpserver.HttpExchange;\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\nimport io.github.lnyocly.ai4j.config.OllamaConfig;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankDocument;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankRequest;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResponse;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.OkHttpClient;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Collections;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class OllamaRerankServiceTest {\n\n    @Test\n    public void shouldCallOllamaRerankEndpointWithoutAuthWhenApiKeyMissing() throws Exception {\n        AtomicReference<String> requestBody = new AtomicReference<String>();\n        AtomicReference<String> authorization = new AtomicReference<String>();\n\n        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);\n        server.createContext(\"/api/rerank\", jsonHandler(\n                \"{\\\"model\\\":\\\"bge-reranker-v2-m3\\\",\\\"results\\\":[{\\\"index\\\":0,\\\"relevance_score\\\":0.77,\\\"document\\\":\\\"document-a\\\"}]}\",\n                requestBody,\n                authorization));\n        server.start();\n        try {\n            Configuration configuration = new Configuration();\n            configuration.setOkHttpClient(new OkHttpClient());\n            configuration.setOllamaConfig(new OllamaConfig(\n                    \"http://127.0.0.1:\" + server.getAddress().getPort(),\n                    \"\",\n                    \"api/chat\",\n                    \"api/embed\",\n                    \"api/rerank\"\n            ));\n\n            OllamaRerankService service = new OllamaRerankService(configuration);\n            RerankResponse response = service.rerank(RerankRequest.builder()\n                    .model(\"bge-reranker-v2-m3\")\n                    .query(\"vacation policy\")\n                    .documents(Collections.singletonList(\n                            RerankDocument.builder().text(\"document-a\").content(\"document-a\").build()\n                    ))\n                    .topN(1)\n                    .build());\n\n            Assert.assertNull(authorization.get());\n            Assert.assertTrue(requestBody.get().contains(\"\\\"model\\\":\\\"bge-reranker-v2-m3\\\"\"));\n            Assert.assertEquals(1, response.getResults().size());\n            Assert.assertEquals(0.77d, response.getResults().get(0).getRelevanceScore(), 0.0001d);\n        } finally {\n            server.stop(0);\n        }\n    }\n\n    private HttpHandler jsonHandler(final String responseBody,\n                                    final AtomicReference<String> requestBody,\n                                    final AtomicReference<String> authorization) {\n        return new HttpHandler() {\n            @Override\n            public void handle(HttpExchange exchange) {\n                try {\n                    requestBody.set(read(exchange.getRequestBody()));\n                    authorization.set(exchange.getRequestHeaders().getFirst(\"Authorization\"));\n                    byte[] response = responseBody.getBytes(StandardCharsets.UTF_8);\n                    exchange.getResponseHeaders().add(\"Content-Type\", \"application/json\");\n                    exchange.sendResponseHeaders(200, response.length);\n                    OutputStream outputStream = exchange.getResponseBody();\n                    try {\n                        outputStream.write(response);\n                    } finally {\n                        outputStream.close();\n                    }\n                } catch (Exception ex) {\n                    throw new RuntimeException(ex);\n                }\n            }\n        };\n    }\n\n    private String read(InputStream inputStream) throws Exception {\n        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n        byte[] buffer = new byte[256];\n        int len;\n        while ((len = inputStream.read(buffer)) != -1) {\n            outputStream.write(buffer, 0, len);\n        }\n        return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/platform/openai/audio/OpenAiAudioServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.audio;\n\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.platform.openai.audio.entity.TextToSpeech;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Protocol;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class OpenAiAudioServiceTest {\n\n    @Test\n    public void test_text_to_speech_stream_remains_readable_after_method_returns() throws Exception {\n        final byte[] expectedAudio = \"fake-mp3-audio\".getBytes(StandardCharsets.UTF_8);\n        final AtomicReference<Request> recordedRequest = new AtomicReference<Request>();\n\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n        openAiConfig.setApiHost(\"https://unit.test/\");\n        openAiConfig.setApiKey(\"config-api-key\");\n\n        OkHttpClient okHttpClient = new OkHttpClient.Builder()\n                .addInterceptor(chain -> {\n                    recordedRequest.set(chain.request());\n                    return new Response.Builder()\n                            .request(chain.request())\n                            .protocol(Protocol.HTTP_1_1)\n                            .code(200)\n                            .message(\"OK\")\n                            .body(ResponseBody.create(expectedAudio, MediaType.get(\"audio/mpeg\")))\n                            .build();\n                })\n                .build();\n\n        Configuration configuration = new Configuration();\n        configuration.setOpenAiConfig(openAiConfig);\n        configuration.setOkHttpClient(okHttpClient);\n\n        OpenAiAudioService service = new OpenAiAudioService(configuration);\n\n        try (InputStream stream = service.textToSpeech(TextToSpeech.builder()\n                .input(\"hello\")\n                .build())) {\n            Assert.assertNotNull(stream);\n            Assert.assertArrayEquals(expectedAudio, readAll(stream));\n        }\n\n        Assert.assertNotNull(recordedRequest.get());\n        Assert.assertEquals(\"Bearer config-api-key\", recordedRequest.get().header(\"Authorization\"));\n        Assert.assertEquals(\"https://unit.test/v1/audio/speech\", recordedRequest.get().url().toString());\n    }\n\n    private static byte[] readAll(InputStream inputStream) throws Exception {\n        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n        byte[] buffer = new byte[256];\n        int read;\n        while ((read = inputStream.read(buffer)) != -1) {\n            outputStream.write(buffer, 0, read);\n        }\n        return outputStream.toByteArray();\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/platform/openai/chat/OpenAiChatServicePassThroughTest.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.chat;\n\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Protocol;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport okio.Buffer;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Collections;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class OpenAiChatServicePassThroughTest {\n\n    @Test\n    public void chatCompletionShouldReturnProviderToolCallsWhenPassThroughEnabled() throws Exception {\n        final AtomicInteger requestCount = new AtomicInteger();\n        final String responseJson = \"{\"\n                + \"\\\"id\\\":\\\"resp_1\\\",\"\n                + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                + \"\\\"created\\\":1710000000,\"\n                + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                + \"\\\"choices\\\":[{\"\n                + \"\\\"index\\\":0,\"\n                + \"\\\"message\\\":{\"\n                + \"\\\"role\\\":\\\"assistant\\\",\"\n                + \"\\\"content\\\":\\\"\\\",\"\n                + \"\\\"tool_calls\\\":[{\"\n                + \"\\\"id\\\":\\\"call_1\\\",\"\n                + \"\\\"type\\\":\\\"function\\\",\"\n                + \"\\\"function\\\":{\"\n                + \"\\\"name\\\":\\\"read_file\\\",\"\n                + \"\\\"arguments\\\":\\\"{\\\\\\\"path\\\\\\\":\\\\\\\"README.md\\\\\\\"}\\\"\"\n                + \"}\"\n                + \"}]\"\n                + \"},\"\n                + \"\\\"finish_reason\\\":\\\"tool_calls\\\"\"\n                + \"}],\"\n                + \"\\\"usage\\\":{\"\n                + \"\\\"prompt_tokens\\\":10,\"\n                + \"\\\"completion_tokens\\\":5,\"\n                + \"\\\"total_tokens\\\":15\"\n                + \"}\"\n                + \"}\";\n\n        OpenAiChatService service = new OpenAiChatService(configurationWithJsonResponse(responseJson, requestCount));\n        ChatCompletion completion = ChatCompletion.builder()\n                .model(\"gpt-test\")\n                .messages(Collections.singletonList(ChatMessage.withUser(\"Read README.md\")))\n                .tools(Collections.singletonList(tool(\"read_file\")))\n                .build();\n        completion.setPassThroughToolCalls(Boolean.TRUE);\n\n        ChatCompletionResponse response = service.chatCompletion(completion);\n\n        Assert.assertNotNull(response);\n        Assert.assertEquals(1, requestCount.get());\n        Assert.assertEquals(\"tool_calls\", response.getChoices().get(0).getFinishReason());\n        Assert.assertEquals(\"read_file\", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getName());\n        Assert.assertEquals(\"{\\\"path\\\":\\\"README.md\\\"}\", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getArguments());\n        Assert.assertEquals(15L, response.getUsage().getTotalTokens());\n    }\n\n    @Test\n    public void chatCompletionShouldKeepLegacyProviderToolLoopWhenPassThroughDisabled() throws Exception {\n        final String responseJson = \"{\"\n                + \"\\\"id\\\":\\\"resp_2\\\",\"\n                + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                + \"\\\"created\\\":1710000000,\"\n                + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                + \"\\\"choices\\\":[{\"\n                + \"\\\"index\\\":0,\"\n                + \"\\\"message\\\":{\"\n                + \"\\\"role\\\":\\\"assistant\\\",\"\n                + \"\\\"content\\\":\\\"\\\",\"\n                + \"\\\"tool_calls\\\":[{\"\n                + \"\\\"id\\\":\\\"call_2\\\",\"\n                + \"\\\"type\\\":\\\"function\\\",\"\n                + \"\\\"function\\\":{\"\n                + \"\\\"name\\\":\\\"unit_test_missing_tool\\\",\"\n                + \"\\\"arguments\\\":\\\"{}\\\"\"\n                + \"}\"\n                + \"}]\"\n                + \"},\"\n                + \"\\\"finish_reason\\\":\\\"tool_calls\\\"\"\n                + \"}],\"\n                + \"\\\"usage\\\":{\"\n                + \"\\\"prompt_tokens\\\":10,\"\n                + \"\\\"completion_tokens\\\":5,\"\n                + \"\\\"total_tokens\\\":15\"\n                + \"}\"\n                + \"}\";\n\n        OpenAiChatService service = new OpenAiChatService(configurationWithJsonResponse(responseJson, null));\n        ChatCompletion completion = ChatCompletion.builder()\n                .model(\"gpt-test\")\n                .messages(Collections.singletonList(ChatMessage.withUser(\"Call a tool\")))\n                .tools(Collections.singletonList(tool(\"unit_test_missing_tool\")))\n                .build();\n\n        try {\n            service.chatCompletion(completion);\n            Assert.fail(\"Expected legacy provider tool loop to invoke ToolUtil when pass-through is disabled\");\n        } catch (RuntimeException ex) {\n            Assert.assertTrue(String.valueOf(ex.getMessage()).contains(\"工具调用失败\"));\n        }\n    }\n\n    @Test\n    public void chatCompletionShouldKeepLegacyFunctionAutoLoopWorking() throws Exception {\n        final AtomicInteger requestCount = new AtomicInteger();\n        final AtomicReference<String> secondRequestBody = new AtomicReference<String>();\n        final String firstResponseJson = \"{\"\n                + \"\\\"id\\\":\\\"resp_legacy_tool_1\\\",\"\n                + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                + \"\\\"created\\\":1710000000,\"\n                + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                + \"\\\"choices\\\":[{\"\n                + \"\\\"index\\\":0,\"\n                + \"\\\"message\\\":{\"\n                + \"\\\"role\\\":\\\"assistant\\\",\"\n                + \"\\\"content\\\":\\\"\\\",\"\n                + \"\\\"tool_calls\\\":[{\"\n                + \"\\\"id\\\":\\\"call_weather_1\\\",\"\n                + \"\\\"type\\\":\\\"function\\\",\"\n                + \"\\\"function\\\":{\"\n                + \"\\\"name\\\":\\\"weather\\\",\"\n                + \"\\\"arguments\\\":\\\"{\\\\\\\"city\\\\\\\":\\\\\\\"Hangzhou\\\\\\\"}\\\"\"\n                + \"}\"\n                + \"}]\"\n                + \"},\"\n                + \"\\\"finish_reason\\\":\\\"tool_calls\\\"\"\n                + \"}],\"\n                + \"\\\"usage\\\":{\"\n                + \"\\\"prompt_tokens\\\":10,\"\n                + \"\\\"completion_tokens\\\":5,\"\n                + \"\\\"total_tokens\\\":15\"\n                + \"}\"\n                + \"}\";\n        final String secondResponseJson = \"{\"\n                + \"\\\"id\\\":\\\"resp_legacy_tool_2\\\",\"\n                + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                + \"\\\"created\\\":1710000001,\"\n                + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                + \"\\\"choices\\\":[{\"\n                + \"\\\"index\\\":0,\"\n                + \"\\\"message\\\":{\"\n                + \"\\\"role\\\":\\\"assistant\\\",\"\n                + \"\\\"content\\\":\\\"Hangzhou is sunny and 25C.\\\"\"\n                + \"},\"\n                + \"\\\"finish_reason\\\":\\\"stop\\\"\"\n                + \"}],\"\n                + \"\\\"usage\\\":{\"\n                + \"\\\"prompt_tokens\\\":12,\"\n                + \"\\\"completion_tokens\\\":7,\"\n                + \"\\\"total_tokens\\\":19\"\n                + \"}\"\n                + \"}\";\n\n        OpenAiChatService service = new OpenAiChatService(configurationWithJsonResponses(\n                requestCount,\n                secondRequestBody,\n                firstResponseJson,\n                secondResponseJson\n        ));\n        ChatCompletion completion = ChatCompletion.builder()\n                .model(\"gpt-test\")\n                .messages(Collections.singletonList(ChatMessage.withUser(\"What is the weather in Hangzhou?\")))\n                .functions(\"weather\")\n                .build();\n\n        ChatCompletionResponse response = service.chatCompletion(completion);\n\n        Assert.assertNotNull(response);\n        Assert.assertEquals(2, requestCount.get());\n        Assert.assertEquals(\"stop\", response.getChoices().get(0).getFinishReason());\n        Assert.assertEquals(\"Hangzhou is sunny and 25C.\", response.getChoices().get(0).getMessage().getContent().getText());\n        Assert.assertTrue(secondRequestBody.get().contains(\"\\\"tool_call_id\\\":\\\"call_weather_1\\\"\"));\n        Assert.assertTrue(secondRequestBody.get().contains(\"\\\"role\\\":\\\"tool\\\"\"));\n    }\n\n    private Configuration configurationWithJsonResponse(String responseJson, AtomicInteger requestCount) {\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n        openAiConfig.setApiHost(\"https://unit.test/\");\n        openAiConfig.setApiKey(\"config-api-key\");\n\n        OkHttpClient okHttpClient = new OkHttpClient.Builder()\n                .addInterceptor(chain -> {\n                    if (requestCount != null) {\n                        requestCount.incrementAndGet();\n                    }\n                    return new Response.Builder()\n                            .request(chain.request())\n                            .protocol(Protocol.HTTP_1_1)\n                            .code(200)\n                            .message(\"OK\")\n                            .body(ResponseBody.create(responseJson, MediaType.get(\"application/json\")))\n                            .build();\n                })\n                .build();\n\n        Configuration configuration = new Configuration();\n        configuration.setOpenAiConfig(openAiConfig);\n        configuration.setOkHttpClient(okHttpClient);\n        return configuration;\n    }\n\n    private Configuration configurationWithJsonResponses(AtomicInteger requestCount,\n                                                         AtomicReference<String> secondRequestBody,\n                                                         String firstResponseJson,\n                                                         String secondResponseJson) {\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n        openAiConfig.setApiHost(\"https://unit.test/\");\n        openAiConfig.setApiKey(\"config-api-key\");\n\n        OkHttpClient okHttpClient = new OkHttpClient.Builder()\n                .addInterceptor(chain -> {\n                    int current = requestCount.incrementAndGet();\n                    if (current == 2 && secondRequestBody != null) {\n                        Buffer buffer = new Buffer();\n                        if (chain.request().body() != null) {\n                            chain.request().body().writeTo(buffer);\n                        }\n                        secondRequestBody.set(buffer.readUtf8());\n                    }\n                    String responseJson = current == 1 ? firstResponseJson : secondResponseJson;\n                    return new Response.Builder()\n                            .request(chain.request())\n                            .protocol(Protocol.HTTP_1_1)\n                            .code(200)\n                            .message(\"OK\")\n                            .body(ResponseBody.create(responseJson, MediaType.get(\"application/json\")))\n                            .build();\n                })\n                .build();\n\n        Configuration configuration = new Configuration();\n        configuration.setOpenAiConfig(openAiConfig);\n        configuration.setOkHttpClient(okHttpClient);\n        return configuration;\n    }\n\n    private Tool tool(String name) {\n        Tool.Function function = new Tool.Function();\n        function.setName(name);\n        function.setDescription(\"test tool\");\n        return new Tool(\"function\", function);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/platform/openai/response/ResponseRequestToolResolverTest.java",
    "content": "package io.github.lnyocly.ai4j.platform.openai.response;\n\nimport io.github.lnyocly.ai4j.annotation.FunctionCall;\nimport io.github.lnyocly.ai4j.annotation.FunctionParameter;\nimport io.github.lnyocly.ai4j.annotation.FunctionRequest;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.tool.ResponseRequestToolResolver;\nimport org.junit.Test;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.function.Function;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class ResponseRequestToolResolverTest {\n\n    @Test\n    public void shouldResolveAnnotatedFunctionsIntoResponsesTools() {\n        ResponseRequest request = ResponseRequest.builder()\n                .model(\"test-model\")\n                .input(\"hello\")\n                .functions(\"responses_test_weather\")\n                .build();\n\n        ResponseRequest resolved = ResponseRequestToolResolver.resolve(request);\n\n        assertNotNull(resolved.getTools());\n        assertEquals(1, resolved.getTools().size());\n        Tool tool = (Tool) resolved.getTools().get(0);\n        assertEquals(\"function\", tool.getType());\n        assertEquals(\"responses_test_weather\", tool.getFunction().getName());\n    }\n\n    @Test\n    public void shouldMergeManualToolsWithResolvedFunctions() {\n        Map<String, Object> manualTool = new LinkedHashMap<String, Object>();\n        manualTool.put(\"type\", \"web_search_preview\");\n\n        ResponseRequest request = ResponseRequest.builder()\n                .model(\"test-model\")\n                .input(\"hello\")\n                .tools(Collections.<Object>singletonList(manualTool))\n                .functions(\"responses_test_weather\")\n                .build();\n\n        ResponseRequest resolved = ResponseRequestToolResolver.resolve(request);\n\n        assertNotNull(resolved.getTools());\n        assertEquals(2, resolved.getTools().size());\n        assertTrue(resolved.getTools().get(0) instanceof Map);\n        assertTrue(resolved.getTools().get(1) instanceof Tool);\n    }\n\n    @FunctionCall(name = \"responses_test_weather\", description = \"test weather function for responses\")\n    public static class ResponsesTestWeatherFunction implements Function<ResponsesTestWeatherFunction.Request, String> {\n\n        @Override\n        public String apply(Request request) {\n            return request.getLocation();\n        }\n\n        @FunctionRequest\n        public static class Request {\n            @FunctionParameter(description = \"query location\", required = true)\n            private String location;\n\n            public String getLocation() {\n                return location;\n            }\n\n            public void setLocation(String location) {\n                this.location = location;\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/rag/Bm25RetrieverTest.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class Bm25RetrieverTest {\n\n    @Test\n    public void shouldRankExactKeywordMatchFirst() throws Exception {\n        Bm25Retriever retriever = new Bm25Retriever(Arrays.asList(\n                RagHit.builder().id(\"1\").content(\"vacation policy for employees\").build(),\n                RagHit.builder().id(\"2\").content(\"insurance handbook and benefits\").build()\n        ));\n\n        List<RagHit> hits = retriever.retrieve(RagQuery.builder()\n                .query(\"vacation policy\")\n                .topK(2)\n                .build());\n\n        Assert.assertEquals(1, hits.size());\n        Assert.assertEquals(\"1\", hits.get(0).getId());\n        Assert.assertTrue(hits.get(0).getScore() > 0.0f);\n    }\n\n    @Test\n    public void shouldReturnEmptyWhenNoTermMatches() throws Exception {\n        Bm25Retriever retriever = new Bm25Retriever(Arrays.asList(\n                RagHit.builder().id(\"1\").content(\"vacation policy for employees\").build(),\n                RagHit.builder().id(\"2\").content(\"insurance handbook and benefits\").build()\n        ));\n\n        List<RagHit> hits = retriever.retrieve(RagQuery.builder()\n                .query(\"quarterly revenue guidance\")\n                .topK(3)\n                .build());\n\n        Assert.assertTrue(hits.isEmpty());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/rag/DefaultRagServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class DefaultRagServiceTest {\n\n    @Test\n    public void shouldAssembleContextAndTrimFinalHits() throws Exception {\n        Retriever retriever = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder()\n                                .id(\"1\")\n                                .content(\"policy one\")\n                                .sourceName(\"handbook.pdf\")\n                                .pageNumber(2)\n                                .sectionTitle(\"Leave\")\n                                .build(),\n                        RagHit.builder()\n                                .id(\"2\")\n                                .content(\"policy two\")\n                                .sourceName(\"benefits.pdf\")\n                                .pageNumber(5)\n                                .sectionTitle(\"Insurance\")\n                                .build()\n                );\n            }\n        };\n\n        DefaultRagService ragService = new DefaultRagService(retriever);\n        RagResult result = ragService.search(RagQuery.builder()\n                .query(\"benefits\")\n                .finalTopK(1)\n                .build());\n\n        Assert.assertEquals(1, result.getHits().size());\n        Assert.assertEquals(1, result.getCitations().size());\n        Assert.assertTrue(result.getContext().contains(\"[S1]\"));\n        Assert.assertTrue(result.getContext().contains(\"handbook.pdf\"));\n        Assert.assertTrue(result.getContext().contains(\"policy one\"));\n        Assert.assertNotNull(result.getTrace());\n        Assert.assertEquals(2, result.getTrace().getRetrievedHits().size());\n        Assert.assertEquals(2, result.getTrace().getRerankedHits().size());\n        Assert.assertEquals(Integer.valueOf(1), result.getHits().get(0).getRank());\n        Assert.assertEquals(\"retriever\", result.getHits().get(0).getRetrieverSource());\n    }\n\n    @Test\n    public void shouldExposeRerankScoresAndTrace() throws Exception {\n        Retriever retriever = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder().id(\"1\").content(\"alpha\").score(0.6f).build(),\n                        RagHit.builder().id(\"2\").content(\"beta\").score(0.5f).build()\n                );\n            }\n        };\n        Reranker reranker = new Reranker() {\n            @Override\n            public List<RagHit> rerank(String query, List<RagHit> hits) {\n                List<RagHit> reranked = new ArrayList<RagHit>(hits);\n                RagHit first = reranked.get(0);\n                RagHit second = reranked.get(1);\n                second.setScore(0.98f);\n                first.setScore(0.42f);\n                return Arrays.asList(second, first);\n            }\n        };\n\n        DefaultRagService ragService = new DefaultRagService(retriever, reranker, new DefaultRagContextAssembler());\n        RagResult result = ragService.search(RagQuery.builder()\n                .query(\"beta\")\n                .finalTopK(2)\n                .build());\n\n        Assert.assertEquals(2, result.getHits().size());\n        Assert.assertEquals(\"2\", result.getHits().get(0).getId());\n        Assert.assertEquals(Float.valueOf(0.5f), result.getHits().get(0).getRetrievalScore());\n        Assert.assertEquals(Float.valueOf(0.98f), result.getHits().get(0).getRerankScore());\n        Assert.assertEquals(Float.valueOf(0.98f), result.getHits().get(0).getScore());\n        Assert.assertEquals(Integer.valueOf(1), result.getHits().get(0).getRank());\n        Assert.assertNotNull(result.getTrace());\n        Assert.assertEquals(\"1\", result.getTrace().getRetrievedHits().get(0).getId());\n        Assert.assertEquals(\"2\", result.getTrace().getRerankedHits().get(0).getId());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/rag/DenseRetrieverTest.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class DenseRetrieverTest {\n\n    @Test\n    public void shouldConvertSearchResultsToRagHits() throws Exception {\n        CapturingVectorStore vectorStore = new CapturingVectorStore();\n        DenseRetriever retriever = new DenseRetriever(new FakeEmbeddingService(), vectorStore);\n\n        List<RagHit> hits = retriever.retrieve(RagQuery.builder()\n                .query(\"employee handbook\")\n                .embeddingModel(\"text-embedding-3-small\")\n                .dataset(\"kb_docs\")\n                .topK(4)\n                .filter(mapOf(\"tenant\", \"acme\"))\n                .build());\n\n        Assert.assertEquals(\"kb_docs\", vectorStore.lastRequest.getDataset());\n        Assert.assertEquals(Integer.valueOf(4), vectorStore.lastRequest.getTopK());\n        Assert.assertEquals(\"acme\", vectorStore.lastRequest.getFilter().get(\"tenant\"));\n        Assert.assertEquals(1, hits.size());\n        Assert.assertEquals(\"doc-1\", hits.get(0).getId());\n        Assert.assertEquals(\"Employee Handbook\", hits.get(0).getSourceName());\n        Assert.assertEquals(\"/docs/employee-handbook.pdf\", hits.get(0).getSourcePath());\n        Assert.assertEquals(Integer.valueOf(3), hits.get(0).getPageNumber());\n        Assert.assertEquals(\"Vacation Policy\", hits.get(0).getSectionTitle());\n        Assert.assertEquals(\"Paid leave policy\", hits.get(0).getContent());\n    }\n\n    private static class FakeEmbeddingService implements IEmbeddingService {\n        @Override\n        public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) {\n            return embedding(embeddingReq);\n        }\n\n        @Override\n        public EmbeddingResponse embedding(Embedding embeddingReq) {\n            return EmbeddingResponse.builder()\n                    .data(Collections.singletonList(EmbeddingObject.builder()\n                            .index(0)\n                            .embedding(Arrays.asList(0.1f, 0.2f, 0.3f))\n                            .object(\"embedding\")\n                            .build()))\n                    .model(embeddingReq.getModel())\n                    .object(\"list\")\n                    .build();\n        }\n    }\n\n    private static class CapturingVectorStore implements VectorStore {\n        private VectorSearchRequest lastRequest;\n\n        @Override\n        public int upsert(io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest request) {\n            return 0;\n        }\n\n        @Override\n        public List<VectorSearchResult> search(VectorSearchRequest request) {\n            this.lastRequest = request;\n            return Collections.singletonList(VectorSearchResult.builder()\n                    .id(\"doc-1\")\n                    .score(0.98f)\n                    .content(\"Paid leave policy\")\n                    .metadata(mapOf(\n                            RagMetadataKeys.SOURCE_NAME, \"Employee Handbook\",\n                            RagMetadataKeys.SOURCE_PATH, \"/docs/employee-handbook.pdf\",\n                            RagMetadataKeys.PAGE_NUMBER, \"3\",\n                            RagMetadataKeys.SECTION_TITLE, \"Vacation Policy\",\n                            RagMetadataKeys.CHUNK_INDEX, \"2\"\n                    ))\n                    .build());\n        }\n\n        @Override\n        public boolean delete(io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest request) {\n            return false;\n        }\n\n        @Override\n        public VectorStoreCapabilities capabilities() {\n            return VectorStoreCapabilities.builder().dataset(true).metadataFilter(true).build();\n        }\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/rag/HybridRetrieverTest.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class HybridRetrieverTest {\n\n    @Test\n    public void shouldMergeResultsFromMultipleRetrievers() throws Exception {\n        Retriever dense = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder().id(\"a\").content(\"dense-a\").build(),\n                        RagHit.builder().id(\"b\").content(\"dense-b\").build()\n                );\n            }\n        };\n        Retriever bm25 = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder().id(\"b\").content(\"bm25-b\").build(),\n                        RagHit.builder().id(\"c\").content(\"bm25-c\").build()\n                );\n            }\n        };\n\n        HybridRetriever retriever = new HybridRetriever(Arrays.asList(dense, bm25));\n        List<RagHit> hits = retriever.retrieve(RagQuery.builder().query(\"anything\").topK(3).build());\n\n        Assert.assertEquals(3, hits.size());\n        Assert.assertEquals(\"b\", hits.get(0).getId());\n        Assert.assertEquals(\"a\", hits.get(1).getId());\n        Assert.assertEquals(\"c\", hits.get(2).getId());\n    }\n\n    @Test\n    public void shouldDeduplicateSameChunkWithoutExplicitId() throws Exception {\n        Retriever dense = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder()\n                                .documentId(\"doc-1\")\n                                .chunkIndex(0)\n                                .content(\"same chunk\")\n                                .score(0.91f)\n                                .build()\n                );\n            }\n        };\n        Retriever bm25 = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder()\n                                .documentId(\"doc-1\")\n                                .chunkIndex(0)\n                                .content(\"same chunk\")\n                                .score(3.2f)\n                                .build()\n                );\n            }\n        };\n\n        HybridRetriever retriever = new HybridRetriever(Arrays.asList(dense, bm25));\n        List<RagHit> hits = retriever.retrieve(RagQuery.builder().query(\"same chunk\").topK(5).build());\n\n        Assert.assertEquals(1, hits.size());\n        Assert.assertEquals(\"doc-1\", hits.get(0).getDocumentId());\n        Assert.assertTrue(hits.get(0).getScore() > 0.0f);\n    }\n\n    @Test\n    public void shouldSupportRelativeScoreFusion() throws Exception {\n        Retriever dense = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder().id(\"a\").content(\"dense-a\").score(0.95f).build(),\n                        RagHit.builder().id(\"b\").content(\"dense-b\").score(0.90f).build(),\n                        RagHit.builder().id(\"c\").content(\"dense-c\").score(0.70f).build()\n                );\n            }\n        };\n        Retriever bm25 = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder().id(\"b\").content(\"bm25-b\").score(12.0f).build(),\n                        RagHit.builder().id(\"d\").content(\"bm25-d\").score(11.0f).build(),\n                        RagHit.builder().id(\"a\").content(\"bm25-a\").score(8.0f).build()\n                );\n            }\n        };\n\n        HybridRetriever retriever = new HybridRetriever(Arrays.asList(dense, bm25), new RsfFusionStrategy());\n        List<RagHit> hits = retriever.retrieve(RagQuery.builder().query(\"anything\").topK(4).build());\n\n        Assert.assertEquals(4, hits.size());\n        Assert.assertEquals(\"b\", hits.get(0).getId());\n        Assert.assertEquals(\"a\", hits.get(1).getId());\n        Assert.assertEquals(\"d\", hits.get(2).getId());\n        Assert.assertEquals(\"c\", hits.get(3).getId());\n    }\n\n    @Test\n    public void shouldSupportDistributionBasedScoreFusion() throws Exception {\n        Retriever dense = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder().id(\"a\").content(\"dense-a\").score(0.95f).build(),\n                        RagHit.builder().id(\"b\").content(\"dense-b\").score(0.90f).build(),\n                        RagHit.builder().id(\"c\").content(\"dense-c\").score(0.70f).build()\n                );\n            }\n        };\n        Retriever bm25 = new Retriever() {\n            @Override\n            public List<RagHit> retrieve(RagQuery query) {\n                return Arrays.asList(\n                        RagHit.builder().id(\"b\").content(\"bm25-b\").score(12.0f).build(),\n                        RagHit.builder().id(\"d\").content(\"bm25-d\").score(11.0f).build(),\n                        RagHit.builder().id(\"a\").content(\"bm25-a\").score(8.0f).build()\n                );\n            }\n        };\n\n        HybridRetriever retriever = new HybridRetriever(Arrays.asList(dense, bm25), new DbsfFusionStrategy());\n        List<RagHit> hits = retriever.retrieve(RagQuery.builder().query(\"anything\").topK(4).build());\n\n        Assert.assertEquals(4, hits.size());\n        Assert.assertEquals(\"b\", hits.get(0).getId());\n        Assert.assertEquals(\"a\", hits.get(1).getId());\n        Assert.assertEquals(\"d\", hits.get(2).getId());\n        Assert.assertEquals(\"c\", hits.get(3).getId());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/rag/IngestionPipelineTest.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline;\nimport io.github.lnyocly.ai4j.rag.ingestion.IngestionRequest;\nimport io.github.lnyocly.ai4j.rag.ingestion.IngestionResult;\nimport io.github.lnyocly.ai4j.rag.ingestion.IngestionSource;\nimport io.github.lnyocly.ai4j.rag.ingestion.LoadedDocument;\nimport io.github.lnyocly.ai4j.rag.ingestion.DocumentLoader;\nimport io.github.lnyocly.ai4j.rag.ingestion.MetadataEnricher;\nimport io.github.lnyocly.ai4j.rag.ingestion.OcrNoiseCleaningDocumentProcessor;\nimport io.github.lnyocly.ai4j.rag.ingestion.OcrTextExtractingDocumentProcessor;\nimport io.github.lnyocly.ai4j.rag.ingestion.OcrTextExtractor;\nimport io.github.lnyocly.ai4j.rag.ingestion.RecursiveTextChunker;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.File;\nimport java.io.FileWriter;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class IngestionPipelineTest {\n\n    @Test\n    public void shouldIngestInlineTextWithMetadataAndEmbeddings() throws Exception {\n        CapturingVectorStore vectorStore = new CapturingVectorStore();\n        IngestionPipeline pipeline = new IngestionPipeline(new FakeEmbeddingService(), vectorStore);\n\n        IngestionResult result = pipeline.ingest(IngestionRequest.builder()\n                .dataset(\"kb_docs\")\n                .embeddingModel(\"text-embedding-3-small\")\n                .document(RagDocument.builder()\n                        .sourceName(\"员工手册\")\n                        .sourcePath(\"/docs/handbook.md\")\n                        .tenant(\"acme\")\n                        .biz(\"hr\")\n                        .version(\"2026.03\")\n                        .build())\n                .source(IngestionSource.builder()\n                        .content(\"第一章 假期政策。\\n第二章 报销政策。\")\n                        .metadata(mapOf(\"department\", \"people-ops\"))\n                        .build())\n                .chunker(new RecursiveTextChunker(8, 2))\n                .batchSize(2)\n                .metadataEnrichers(Collections.<MetadataEnricher>singletonList(\n                        new MetadataEnricher() {\n                            @Override\n                            public void enrich(RagDocument document, RagChunk chunk, Map<String, Object> metadata) {\n                                metadata.put(\"customTag\", \"ingested\");\n                            }\n                        }\n                ))\n                .build());\n\n        Assert.assertNotNull(vectorStore.lastUpsertRequest);\n        Assert.assertEquals(\"kb_docs\", vectorStore.lastUpsertRequest.getDataset());\n        Assert.assertEquals(result.getChunks().size(), vectorStore.lastUpsertRequest.getRecords().size());\n        Assert.assertTrue(result.getChunks().size() >= 2);\n        Assert.assertEquals(result.getChunks().size(), result.getUpsertedCount());\n\n        VectorRecord first = vectorStore.lastUpsertRequest.getRecords().get(0);\n        Assert.assertEquals(first.getId(), result.getChunks().get(0).getChunkId());\n        Assert.assertEquals(\"ingested\", first.getMetadata().get(\"customTag\"));\n        Assert.assertEquals(\"people-ops\", first.getMetadata().get(\"department\"));\n        Assert.assertEquals(\"acme\", first.getMetadata().get(RagMetadataKeys.TENANT));\n        Assert.assertEquals(\"hr\", first.getMetadata().get(RagMetadataKeys.BIZ));\n        Assert.assertEquals(\"2026.03\", first.getMetadata().get(RagMetadataKeys.VERSION));\n        Assert.assertEquals(\"员工手册\", first.getMetadata().get(RagMetadataKeys.SOURCE_NAME));\n        Assert.assertEquals(\"/docs/handbook.md\", first.getMetadata().get(RagMetadataKeys.SOURCE_PATH));\n        Assert.assertEquals(first.getContent(), first.getMetadata().get(RagMetadataKeys.CONTENT));\n        Assert.assertEquals(Arrays.asList(1.0f, (float) first.getContent().length()), first.getVector());\n    }\n\n    @Test\n    public void shouldLoadLocalFileThroughTikaLoader() throws Exception {\n        File tempFile = File.createTempFile(\"ai4j-ingestion-\", \".txt\");\n        FileWriter writer = new FileWriter(tempFile);\n        try {\n            writer.write(\"alpha beta gamma\");\n        } finally {\n            writer.close();\n        }\n\n        CapturingVectorStore vectorStore = new CapturingVectorStore();\n        IngestionPipeline pipeline = new IngestionPipeline(new FakeEmbeddingService(), vectorStore);\n\n        IngestionResult result = pipeline.ingest(IngestionRequest.builder()\n                .dataset(\"kb_files\")\n                .embeddingModel(\"text-embedding-3-small\")\n                .source(IngestionSource.file(tempFile))\n                .upsert(Boolean.FALSE)\n                .build());\n\n        Assert.assertNotNull(result.getDocument());\n        Assert.assertEquals(tempFile.getName(), result.getDocument().getSourceName());\n        Assert.assertEquals(tempFile.getAbsolutePath(), result.getDocument().getSourcePath());\n        Assert.assertEquals(tempFile.toURI().toString(), result.getDocument().getSourceUri());\n        Assert.assertEquals(0, result.getUpsertedCount());\n        Assert.assertNull(vectorStore.lastUpsertRequest);\n        Assert.assertFalse(result.getRecords().isEmpty());\n        Assert.assertEquals(\"text/plain\", String.valueOf(result.getRecords().get(0).getMetadata().get(\"mimeType\")));\n\n        tempFile.delete();\n    }\n\n    @Test\n    public void shouldSupportOcrFallbackProcessor() throws Exception {\n        CapturingVectorStore vectorStore = new CapturingVectorStore();\n        IngestionPipeline pipeline = new IngestionPipeline(\n                new FakeEmbeddingService(),\n                vectorStore,\n                Collections.<DocumentLoader>singletonList(new BlankDocumentLoader()),\n                new RecursiveTextChunker(1000, 0),\n                Collections.singletonList(new OcrTextExtractingDocumentProcessor(new OcrTextExtractor() {\n                    @Override\n                    public boolean supports(IngestionSource source, LoadedDocument document) {\n                        return true;\n                    }\n\n                    @Override\n                    public String extractText(IngestionSource source, LoadedDocument document) {\n                        return \"scanned contract text\";\n                    }\n                })),\n                Collections.<MetadataEnricher>singletonList(new MetadataEnricher() {\n                    @Override\n                    public void enrich(RagDocument document, RagChunk chunk, Map<String, Object> metadata) {\n                        metadata.put(\"testCase\", \"ocr\");\n                    }\n                })\n        );\n\n        IngestionResult result = pipeline.ingest(IngestionRequest.builder()\n                .dataset(\"kb_scan\")\n                .embeddingModel(\"text-embedding-3-small\")\n                .source(IngestionSource.builder()\n                        .name(\"scan.pdf\")\n                        .build())\n                .upsert(Boolean.FALSE)\n                .build());\n\n        Assert.assertEquals(1, result.getChunks().size());\n        Assert.assertEquals(\"scanned contract text\", result.getChunks().get(0).getContent());\n        Assert.assertEquals(Boolean.TRUE, result.getRecords().get(0).getMetadata().get(\"ocrApplied\"));\n        Assert.assertEquals(\"ocr\", result.getRecords().get(0).getMetadata().get(\"testCase\"));\n    }\n\n    @Test\n    public void shouldCleanOcrNoiseBeforeChunking() throws Exception {\n        CapturingVectorStore vectorStore = new CapturingVectorStore();\n        IngestionPipeline pipeline = new IngestionPipeline(new FakeEmbeddingService(), vectorStore);\n\n        IngestionResult result = pipeline.ingest(IngestionRequest.builder()\n                .dataset(\"kb_clean\")\n                .embeddingModel(\"text-embedding-3-small\")\n                .source(IngestionSource.builder()\n                        .content(\"docu-\\nment\\n\\n\\nH e l l o\")\n                        .build())\n                .documentProcessors(Collections.singletonList(new OcrNoiseCleaningDocumentProcessor()))\n                .chunker(new RecursiveTextChunker(1000, 0))\n                .upsert(Boolean.FALSE)\n                .build());\n\n        Assert.assertEquals(1, result.getChunks().size());\n        Assert.assertEquals(\"document\\n\\nHello\", result.getChunks().get(0).getContent());\n        Assert.assertEquals(Boolean.TRUE, result.getRecords().get(0).getMetadata().get(\"whitespaceNormalized\"));\n        Assert.assertEquals(Boolean.TRUE, result.getRecords().get(0).getMetadata().get(\"ocrNoiseCleaned\"));\n    }\n\n    private static class FakeEmbeddingService implements IEmbeddingService {\n\n        @Override\n        public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) {\n            return embedding(embeddingReq);\n        }\n\n        @Override\n        public EmbeddingResponse embedding(Embedding embeddingReq) {\n            List<String> inputs = extractInputs(embeddingReq.getInput());\n            List<EmbeddingObject> data = new ArrayList<EmbeddingObject>(inputs.size());\n            for (int i = 0; i < inputs.size(); i++) {\n                data.add(EmbeddingObject.builder()\n                        .index(i)\n                        .object(\"embedding\")\n                        .embedding(Arrays.asList((float) (i + 1), (float) inputs.get(i).length()))\n                        .build());\n            }\n            return EmbeddingResponse.builder()\n                    .object(\"list\")\n                    .model(embeddingReq.getModel())\n                    .data(data)\n                    .build();\n        }\n\n        @SuppressWarnings(\"unchecked\")\n        private List<String> extractInputs(Object input) {\n            if (input == null) {\n                return Collections.emptyList();\n            }\n            if (input instanceof List) {\n                return (List<String>) input;\n            }\n            return Collections.singletonList(String.valueOf(input));\n        }\n    }\n\n    private static class CapturingVectorStore implements VectorStore {\n        private VectorUpsertRequest lastUpsertRequest;\n\n        @Override\n        public int upsert(VectorUpsertRequest request) {\n            this.lastUpsertRequest = request;\n            return request == null || request.getRecords() == null ? 0 : request.getRecords().size();\n        }\n\n        @Override\n        public List<VectorSearchResult> search(VectorSearchRequest request) {\n            return Collections.emptyList();\n        }\n\n        @Override\n        public boolean delete(VectorDeleteRequest request) {\n            return false;\n        }\n\n        @Override\n        public VectorStoreCapabilities capabilities() {\n            return VectorStoreCapabilities.builder()\n                    .dataset(true)\n                    .metadataFilter(true)\n                    .deleteByFilter(true)\n                    .returnStoredVector(true)\n                    .build();\n        }\n    }\n\n    private static class BlankDocumentLoader implements DocumentLoader {\n\n        @Override\n        public boolean supports(IngestionSource source) {\n            return true;\n        }\n\n        @Override\n        public LoadedDocument load(IngestionSource source) {\n            return LoadedDocument.builder()\n                    .content(\"\")\n                    .sourceName(source == null ? null : source.getName())\n                    .sourcePath(source == null ? null : source.getPath())\n                    .sourceUri(source == null ? null : source.getUri())\n                    .metadata(source == null ? Collections.<String, Object>emptyMap() : source.getMetadata())\n                    .build();\n        }\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/rag/ModelRerankerTest.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport io.github.lnyocly.ai4j.rerank.entity.RerankRequest;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResponse;\nimport io.github.lnyocly.ai4j.rerank.entity.RerankResult;\nimport io.github.lnyocly.ai4j.service.IRerankService;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class ModelRerankerTest {\n\n    @Test\n    public void shouldReorderHitsByModelScoresAndKeepTail() throws Exception {\n        ModelReranker reranker = new ModelReranker(new IRerankService() {\n            @Override\n            public RerankResponse rerank(String baseUrl, String apiKey, RerankRequest request) {\n                return rerank(request);\n            }\n\n            @Override\n            public RerankResponse rerank(RerankRequest request) {\n                return RerankResponse.builder()\n                        .model(request.getModel())\n                        .results(Arrays.asList(\n                                RerankResult.builder().index(1).relevanceScore(0.93f).build(),\n                                RerankResult.builder().index(0).relevanceScore(0.41f).build()\n                        ))\n                        .build();\n            }\n        }, \"jina-reranker-v2-base-multilingual\", 2, null, false, true);\n\n        List<RagHit> hits = reranker.rerank(\"vacation policy\", Arrays.asList(\n                RagHit.builder().id(\"a\").content(\"doc-a\").retrievalScore(0.55f).build(),\n                RagHit.builder().id(\"b\").content(\"doc-b\").retrievalScore(0.52f).build(),\n                RagHit.builder().id(\"c\").content(\"doc-c\").retrievalScore(0.40f).build()\n        ));\n\n        Assert.assertEquals(3, hits.size());\n        Assert.assertEquals(\"b\", hits.get(0).getId());\n        Assert.assertEquals(Float.valueOf(0.93f), hits.get(0).getRerankScore());\n        Assert.assertEquals(\"a\", hits.get(1).getId());\n        Assert.assertEquals(Float.valueOf(0.41f), hits.get(1).getRerankScore());\n        Assert.assertEquals(\"c\", hits.get(2).getId());\n        Assert.assertNull(hits.get(2).getRerankScore());\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/rag/RagEvaluatorTest.java",
    "content": "package io.github.lnyocly.ai4j.rag;\n\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class RagEvaluatorTest {\n\n    @Test\n    public void shouldComputeStandardRetrievalMetrics() {\n        List<RagHit> hits = Arrays.asList(\n                RagHit.builder().id(\"a\").content(\"A\").build(),\n                RagHit.builder().id(\"b\").content(\"B\").build(),\n                RagHit.builder().id(\"c\").content(\"C\").build(),\n                RagHit.builder().id(\"d\").content(\"D\").build()\n        );\n\n        RagEvaluation evaluation = new RagEvaluator().evaluate(hits, Arrays.asList(\"b\", \"d\"), 4);\n\n        Assert.assertEquals(Integer.valueOf(4), evaluation.getEvaluatedAtK());\n        Assert.assertEquals(Integer.valueOf(4), evaluation.getRetrievedCount());\n        Assert.assertEquals(Integer.valueOf(2), evaluation.getRelevantCount());\n        Assert.assertEquals(Integer.valueOf(2), evaluation.getTruePositiveCount());\n        Assert.assertEquals(0.5d, evaluation.getPrecisionAtK(), 0.0001d);\n        Assert.assertEquals(1.0d, evaluation.getRecallAtK(), 0.0001d);\n        Assert.assertEquals(0.6667d, evaluation.getF1AtK(), 0.001d);\n        Assert.assertEquals(0.5d, evaluation.getMrr(), 0.0001d);\n        Assert.assertEquals(0.6509d, evaluation.getNdcg(), 0.001d);\n    }\n\n    @Test\n    public void shouldHandleNoRelevantDocuments() {\n        List<RagHit> hits = Arrays.asList(\n                RagHit.builder().id(\"a\").content(\"A\").build(),\n                RagHit.builder().id(\"b\").content(\"B\").build()\n        );\n\n        RagEvaluation evaluation = new RagEvaluator().evaluate(hits, Arrays.asList(\"z\"), 2);\n\n        Assert.assertEquals(0.0d, evaluation.getPrecisionAtK(), 0.0001d);\n        Assert.assertEquals(0.0d, evaluation.getRecallAtK(), 0.0001d);\n        Assert.assertEquals(0.0d, evaluation.getF1AtK(), 0.0001d);\n        Assert.assertEquals(0.0d, evaluation.getMrr(), 0.0001d);\n        Assert.assertEquals(0.0d, evaluation.getNdcg(), 0.0001d);\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/skill/SkillsIChatServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.skill;\n\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.platform.openai.chat.OpenAiChatService;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tool.BuiltInTools;\nimport okhttp3.MediaType;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Protocol;\nimport okhttp3.RequestBody;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport okio.Buffer;\nimport org.junit.Assert;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.Arrays;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class SkillsIChatServiceTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldDiscoverSkillsAndAssemblePromptForBasicChatUsage() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"skills-basic-chat\").toPath();\n        Path skillFile = writeSkill(\n                workspaceRoot.resolve(\".ai4j\").resolve(\"skills\").resolve(\"planner\").resolve(\"SKILL.md\"),\n                \"---\\nname: planner\\ndescription: Plan implementation steps.\\n---\\n\"\n        );\n\n        Skills.DiscoveryResult discovery = Skills.discoverDefault(workspaceRoot);\n        Assert.assertEquals(1, discovery.getSkills().size());\n        Assert.assertEquals(\"planner\", discovery.getSkills().get(0).getName());\n        Assert.assertEquals(\"workspace\", discovery.getSkills().get(0).getSource());\n        Assert.assertTrue(discovery.getAllowedReadRoots().contains(skillFile.getParent().getParent().toString()));\n\n        String systemPrompt = Skills.appendAvailableSkillsPrompt(\"Base prompt.\", discovery.getSkills());\n        Assert.assertTrue(systemPrompt.contains(\"<available_skills>\"));\n        Assert.assertTrue(systemPrompt.contains(skillFile.toAbsolutePath().normalize().toString()));\n        Assert.assertEquals(4, BuiltInTools.codingTools().size());\n    }\n\n    @Test\n    public void shouldAllowBasicIChatServiceToMountSkillsAndBuiltInTools() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"skills-chat-request\").toPath();\n        Path skillFile = writeSkill(\n                workspaceRoot.resolve(\".ai4j\").resolve(\"skills\").resolve(\"reviewer\").resolve(\"SKILL.md\"),\n                \"# reviewer\\nReview code changes for risks.\\n\"\n        );\n        Skills.DiscoveryResult discovery = Skills.discoverDefault(workspaceRoot);\n        String systemPrompt = Skills.appendAvailableSkillsPrompt(\"You are a helpful assistant.\", discovery.getSkills());\n\n        AtomicReference<String> requestJson = new AtomicReference<String>();\n        IChatService chatService = new OpenAiChatService(configurationWithJsonResponse(\n                \"{\"\n                        + \"\\\"id\\\":\\\"resp_skill_1\\\",\"\n                        + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                        + \"\\\"created\\\":1710000000,\"\n                        + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                        + \"\\\"choices\\\":[{\"\n                        + \"\\\"index\\\":0,\"\n                        + \"\\\"message\\\":{\"\n                        + \"\\\"role\\\":\\\"assistant\\\",\"\n                        + \"\\\"content\\\":\\\"\\\",\"\n                        + \"\\\"tool_calls\\\":[{\"\n                        + \"\\\"id\\\":\\\"call_skill_1\\\",\"\n                        + \"\\\"type\\\":\\\"function\\\",\"\n                        + \"\\\"function\\\":{\"\n                        + \"\\\"name\\\":\\\"read_file\\\",\"\n                        + \"\\\"arguments\\\":\\\"{\\\\\\\"path\\\\\\\":\\\\\\\"\" + escapeJson(skillFile.toAbsolutePath().normalize().toString()) + \"\\\\\\\"}\\\"\"\n                        + \"}\"\n                        + \"}]\"\n                        + \"},\"\n                        + \"\\\"finish_reason\\\":\\\"tool_calls\\\"\"\n                        + \"}],\"\n                        + \"\\\"usage\\\":{\"\n                        + \"\\\"prompt_tokens\\\":10,\"\n                        + \"\\\"completion_tokens\\\":5,\"\n                        + \"\\\"total_tokens\\\":15\"\n                        + \"}\"\n                        + \"}\",\n                requestJson\n        ));\n\n        ChatCompletion completion = ChatCompletion.builder()\n                .model(\"gpt-test\")\n                .messages(Arrays.asList(\n                        ChatMessage.withSystem(systemPrompt),\n                        ChatMessage.withUser(\"Use the most relevant installed skill.\")\n                ))\n                .tools(BuiltInTools.codingTools())\n                .build();\n        completion.setPassThroughToolCalls(Boolean.TRUE);\n\n        ChatCompletionResponse response = chatService.chatCompletion(completion);\n\n        Assert.assertNotNull(response);\n        Assert.assertEquals(\"tool_calls\", response.getChoices().get(0).getFinishReason());\n        Assert.assertEquals(\"read_file\", response.getChoices().get(0).getMessage().getToolCalls().get(0).getFunction().getName());\n        Assert.assertTrue(requestJson.get().contains(\"<available_skills>\"));\n        Assert.assertTrue(requestJson.get().contains(\"reviewer\"));\n        Assert.assertTrue(requestJson.get().contains(escapeJson(skillFile.toAbsolutePath().normalize().toString())));\n        Assert.assertTrue(requestJson.get().contains(\"\\\"name\\\":\\\"read_file\\\"\"));\n    }\n\n    @Test\n    public void shouldAutoInvokeBuiltInReadFileToolWhenContextConfigured() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"skills-auto-loop\").toPath();\n        Path skillFile = writeSkill(\n                workspaceRoot.resolve(\".ai4j\").resolve(\"skills\").resolve(\"reviewer\").resolve(\"SKILL.md\"),\n                \"# reviewer\\nReview code changes for risks.\\n\"\n        );\n        Skills.DiscoveryResult discovery = Skills.discoverDefault(workspaceRoot);\n        String systemPrompt = Skills.appendAvailableSkillsPrompt(\"You are a helpful assistant.\", discovery.getSkills());\n\n        AtomicInteger requestCount = new AtomicInteger();\n        AtomicReference<String> secondRequestBody = new AtomicReference<String>();\n        String skillRelativePath = \".ai4j/skills/reviewer/SKILL.md\";\n        IChatService chatService = new OpenAiChatService(configurationWithJsonResponses(\n                requestCount,\n                secondRequestBody,\n                \"{\"\n                        + \"\\\"id\\\":\\\"resp_skill_auto_1\\\",\"\n                        + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                        + \"\\\"created\\\":1710000000,\"\n                        + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                        + \"\\\"choices\\\":[{\"\n                        + \"\\\"index\\\":0,\"\n                        + \"\\\"message\\\":{\"\n                        + \"\\\"role\\\":\\\"assistant\\\",\"\n                        + \"\\\"content\\\":\\\"\\\",\"\n                        + \"\\\"tool_calls\\\":[{\"\n                        + \"\\\"id\\\":\\\"call_skill_auto_1\\\",\"\n                        + \"\\\"type\\\":\\\"function\\\",\"\n                        + \"\\\"function\\\":{\"\n                        + \"\\\"name\\\":\\\"read_file\\\",\"\n                        + \"\\\"arguments\\\":\\\"{\\\\\\\"path\\\\\\\":\\\\\\\"\" + skillRelativePath + \"\\\\\\\"}\\\"\"\n                        + \"}\"\n                        + \"}]\"\n                        + \"},\"\n                        + \"\\\"finish_reason\\\":\\\"tool_calls\\\"\"\n                        + \"}],\"\n                        + \"\\\"usage\\\":{\"\n                        + \"\\\"prompt_tokens\\\":10,\"\n                        + \"\\\"completion_tokens\\\":5,\"\n                        + \"\\\"total_tokens\\\":15\"\n                        + \"}\"\n                        + \"}\",\n                \"{\"\n                        + \"\\\"id\\\":\\\"resp_skill_auto_2\\\",\"\n                        + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                        + \"\\\"created\\\":1710000001,\"\n                        + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                        + \"\\\"choices\\\":[{\"\n                        + \"\\\"index\\\":0,\"\n                        + \"\\\"message\\\":{\"\n                        + \"\\\"role\\\":\\\"assistant\\\",\"\n                        + \"\\\"content\\\":\\\"Skill loaded successfully.\\\"\"\n                        + \"},\"\n                        + \"\\\"finish_reason\\\":\\\"stop\\\"\"\n                        + \"}],\"\n                        + \"\\\"usage\\\":{\"\n                        + \"\\\"prompt_tokens\\\":12,\"\n                        + \"\\\"completion_tokens\\\":7,\"\n                        + \"\\\"total_tokens\\\":19\"\n                        + \"}\"\n                        + \"}\"\n        ));\n\n        ChatCompletion completion = ChatCompletion.builder()\n                .model(\"gpt-test\")\n                .messages(Arrays.asList(\n                        ChatMessage.withSystem(systemPrompt),\n                        ChatMessage.withUser(\"Use the most relevant installed skill.\")\n                ))\n                .tools(BuiltInTools.codingTools())\n                .builtInToolContext(Skills.createToolContext(workspaceRoot, discovery))\n                .build();\n\n        ChatCompletionResponse response = chatService.chatCompletion(completion);\n\n        Assert.assertNotNull(response);\n        Assert.assertEquals(2, requestCount.get());\n        Assert.assertEquals(\"stop\", response.getChoices().get(0).getFinishReason());\n        Assert.assertEquals(\"Skill loaded successfully.\", response.getChoices().get(0).getMessage().getContent().getText());\n        Assert.assertTrue(secondRequestBody.get().contains(\"\\\"role\\\":\\\"tool\\\"\"));\n        Assert.assertTrue(secondRequestBody.get().contains(\"Review code changes for risks.\"));\n    }\n\n    @Test\n    public void shouldAllowLegacyFunctionsApiToUseBuiltInReadFile() throws Exception {\n        Path repoRoot = Paths.get(\".\").toAbsolutePath().normalize();\n        Path workspaceRoot = repoRoot.resolve(\"target\").resolve(\"skills-functions-chat\");\n        Path skillFile = writeSkill(\n                workspaceRoot.resolve(\".ai4j\").resolve(\"skills\").resolve(\"reviewer\").resolve(\"SKILL.md\"),\n                \"# reviewer\\nReview code changes for risks.\\n\"\n        );\n        String skillRelativePath = repoRoot.relativize(skillFile.toAbsolutePath().normalize()).toString().replace('\\\\', '/');\n        Skills.DiscoveryResult discovery = Skills.discoverDefault(repoRoot, Arrays.asList(repoRoot.relativize(workspaceRoot.resolve(\".ai4j\").resolve(\"skills\")).toString()));\n        String systemPrompt = Skills.appendAvailableSkillsPrompt(\"You are a helpful assistant.\", discovery.getSkills());\n\n        AtomicInteger requestCount = new AtomicInteger();\n        AtomicReference<String> secondRequestBody = new AtomicReference<String>();\n        IChatService chatService = new OpenAiChatService(configurationWithJsonResponses(\n                requestCount,\n                secondRequestBody,\n                \"{\"\n                        + \"\\\"id\\\":\\\"resp_skill_fn_1\\\",\"\n                        + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                        + \"\\\"created\\\":1710000000,\"\n                        + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                        + \"\\\"choices\\\":[{\"\n                        + \"\\\"index\\\":0,\"\n                        + \"\\\"message\\\":{\"\n                        + \"\\\"role\\\":\\\"assistant\\\",\"\n                        + \"\\\"content\\\":\\\"\\\",\"\n                        + \"\\\"tool_calls\\\":[{\"\n                        + \"\\\"id\\\":\\\"call_skill_fn_1\\\",\"\n                        + \"\\\"type\\\":\\\"function\\\",\"\n                        + \"\\\"function\\\":{\"\n                        + \"\\\"name\\\":\\\"read_file\\\",\"\n                        + \"\\\"arguments\\\":\\\"{\\\\\\\"path\\\\\\\":\\\\\\\"\" + skillRelativePath + \"\\\\\\\"}\\\"\"\n                        + \"}\"\n                        + \"}]\"\n                        + \"},\"\n                        + \"\\\"finish_reason\\\":\\\"tool_calls\\\"\"\n                        + \"}],\"\n                        + \"\\\"usage\\\":{\"\n                        + \"\\\"prompt_tokens\\\":10,\"\n                        + \"\\\"completion_tokens\\\":5,\"\n                        + \"\\\"total_tokens\\\":15\"\n                        + \"}\"\n                        + \"}\",\n                \"{\"\n                        + \"\\\"id\\\":\\\"resp_skill_fn_2\\\",\"\n                        + \"\\\"object\\\":\\\"chat.completion\\\",\"\n                        + \"\\\"created\\\":1710000001,\"\n                        + \"\\\"model\\\":\\\"gpt-test\\\",\"\n                        + \"\\\"choices\\\":[{\"\n                        + \"\\\"index\\\":0,\"\n                        + \"\\\"message\\\":{\"\n                        + \"\\\"role\\\":\\\"assistant\\\",\"\n                        + \"\\\"content\\\":\\\"Legacy functions API still works.\\\"\"\n                        + \"},\"\n                        + \"\\\"finish_reason\\\":\\\"stop\\\"\"\n                        + \"}],\"\n                        + \"\\\"usage\\\":{\"\n                        + \"\\\"prompt_tokens\\\":12,\"\n                        + \"\\\"completion_tokens\\\":7,\"\n                        + \"\\\"total_tokens\\\":19\"\n                        + \"}\"\n                        + \"}\"\n        ));\n\n        ChatCompletion completion = ChatCompletion.builder()\n                .model(\"gpt-test\")\n                .messages(Arrays.asList(\n                        ChatMessage.withSystem(systemPrompt),\n                        ChatMessage.withUser(\"Use the most relevant installed skill.\")\n                ))\n                .functions(\"read_file\")\n                .build();\n\n        ChatCompletionResponse response = chatService.chatCompletion(completion);\n\n        Assert.assertNotNull(response);\n        Assert.assertEquals(2, requestCount.get());\n        Assert.assertEquals(\"stop\", response.getChoices().get(0).getFinishReason());\n        Assert.assertEquals(\"Legacy functions API still works.\", response.getChoices().get(0).getMessage().getContent().getText());\n        Assert.assertTrue(secondRequestBody.get().contains(\"\\\"tool_call_id\\\":\\\"call_skill_fn_1\\\"\"));\n        Assert.assertTrue(secondRequestBody.get().contains(\"Review code changes for risks.\"));\n    }\n\n    private static Path writeSkill(Path skillFile, String content) throws Exception {\n        Files.createDirectories(skillFile.getParent());\n        Files.write(skillFile, content.getBytes(StandardCharsets.UTF_8));\n        return skillFile;\n    }\n\n    private static String escapeJson(String value) {\n        return value.replace(\"\\\\\", \"\\\\\\\\\");\n    }\n\n    private static Configuration configurationWithJsonResponse(String responseJson, AtomicReference<String> requestJson) {\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n        openAiConfig.setApiHost(\"https://unit.test/\");\n        openAiConfig.setApiKey(\"config-api-key\");\n\n        OkHttpClient okHttpClient = new OkHttpClient.Builder()\n                .addInterceptor(chain -> {\n                    if (requestJson != null) {\n                        requestJson.set(readRequestBody(chain.request().body()));\n                    }\n                    return new Response.Builder()\n                            .request(chain.request())\n                            .protocol(Protocol.HTTP_1_1)\n                            .code(200)\n                            .message(\"OK\")\n                            .body(ResponseBody.create(responseJson, MediaType.get(\"application/json\")))\n                            .build();\n                })\n                .build();\n\n        Configuration configuration = new Configuration();\n        configuration.setOpenAiConfig(openAiConfig);\n        configuration.setOkHttpClient(okHttpClient);\n        return configuration;\n    }\n\n    private static Configuration configurationWithJsonResponses(AtomicInteger requestCount,\n                                                                AtomicReference<String> secondRequestBody,\n                                                                String firstResponseJson,\n                                                                String secondResponseJson) {\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n        openAiConfig.setApiHost(\"https://unit.test/\");\n        openAiConfig.setApiKey(\"config-api-key\");\n\n        OkHttpClient okHttpClient = new OkHttpClient.Builder()\n                .addInterceptor(chain -> {\n                    int current = requestCount.incrementAndGet();\n                    if (current == 2 && secondRequestBody != null) {\n                        secondRequestBody.set(readRequestBody(chain.request().body()));\n                    }\n                    String responseJson = current == 1 ? firstResponseJson : secondResponseJson;\n                    return new Response.Builder()\n                            .request(chain.request())\n                            .protocol(Protocol.HTTP_1_1)\n                            .code(200)\n                            .message(\"OK\")\n                            .body(ResponseBody.create(responseJson, MediaType.get(\"application/json\")))\n                            .build();\n                })\n                .build();\n\n        Configuration configuration = new Configuration();\n        configuration.setOpenAiConfig(openAiConfig);\n        configuration.setOkHttpClient(okHttpClient);\n        return configuration;\n    }\n\n    private static String readRequestBody(RequestBody body) {\n        if (body == null) {\n            return \"\";\n        }\n        try {\n            Buffer buffer = new Buffer();\n            body.writeTo(buffer);\n            return buffer.readUtf8();\n        } catch (Exception ex) {\n            throw new IllegalStateException(\"Failed to read request body\", ex);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/tool/BuiltInToolExecutorTest.java",
    "content": "package io.github.lnyocly.ai4j.tool;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport org.junit.Assert;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\npublic class BuiltInToolExecutorTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldExecuteReadWriteAndApplyPatchTools() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"built-in-tools\").toPath();\n        Files.write(workspaceRoot.resolve(\"notes.txt\"), \"hello\\nworld\\n\".getBytes(StandardCharsets.UTF_8));\n        BuiltInToolContext context = BuiltInToolContext.builder()\n                .workspaceRoot(workspaceRoot.toString())\n                .build();\n\n        JSONObject readResult = JSON.parseObject(ToolUtil.invoke(\n                BuiltInTools.READ_FILE,\n                \"{\\\"path\\\":\\\"notes.txt\\\"}\",\n                context\n        ));\n        Assert.assertEquals(\"notes.txt\", readResult.getString(\"path\"));\n        Assert.assertTrue(readResult.getString(\"content\").contains(\"hello\"));\n\n        JSONObject writeResult = JSON.parseObject(ToolUtil.invoke(\n                BuiltInTools.WRITE_FILE,\n                \"{\\\"path\\\":\\\"nested/output.txt\\\",\\\"content\\\":\\\"abc\\\",\\\"mode\\\":\\\"create\\\"}\",\n                context\n        ));\n        Assert.assertTrue(writeResult.getBooleanValue(\"created\"));\n        Assert.assertEquals(\"abc\", new String(Files.readAllBytes(workspaceRoot.resolve(\"nested\").resolve(\"output.txt\")), StandardCharsets.UTF_8));\n\n        String patch = \"*** Begin Patch\\n\"\n                + \"*** Update File: nested/output.txt\\n\"\n                + \"@@\\n\"\n                + \"-abc\\n\"\n                + \"+updated\\n\"\n                + \"*** End Patch\\n\";\n        JSONObject patchResult = JSON.parseObject(ToolUtil.invoke(\n                BuiltInTools.APPLY_PATCH,\n                JSON.toJSONString(new JSONObject() {{\n                    put(\"patch\", patch);\n                }}),\n                context\n        ));\n        Assert.assertEquals(1, patchResult.getIntValue(\"filesChanged\"));\n        Assert.assertEquals(\"updated\", new String(Files.readAllBytes(workspaceRoot.resolve(\"nested\").resolve(\"output.txt\")), StandardCharsets.UTF_8));\n        Assert.assertEquals(1, BuiltInTools.tools(BuiltInTools.READ_FILE).size());\n    }\n\n    @Test\n    public void shouldExecuteBashToolsWithinContext() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"built-in-bash\").toPath();\n        BuiltInToolContext context = BuiltInToolContext.builder()\n                .workspaceRoot(workspaceRoot.toString())\n                .build();\n\n        JSONObject execResult = JSON.parseObject(ToolUtil.invoke(\n                BuiltInTools.BASH,\n                \"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"echo hello\\\"}\",\n                context\n        ));\n        Assert.assertEquals(0, execResult.getIntValue(\"exitCode\"));\n        Assert.assertTrue(execResult.getString(\"stdout\").toLowerCase().contains(\"hello\"));\n\n        String command = isWindows()\n                ? \"ping 127.0.0.1 -n 6 >nul\"\n                : \"sleep 5\";\n        JSONObject startResult = JSON.parseObject(ToolUtil.invoke(\n                BuiltInTools.BASH,\n                JSON.toJSONString(new JSONObject() {{\n                    put(\"action\", \"start\");\n                    put(\"command\", command);\n                }}),\n                context\n        ));\n        String processId = startResult.getString(\"processId\");\n        Assert.assertNotNull(processId);\n\n        JSONObject listResult = JSON.parseObject(ToolUtil.invoke(\n                BuiltInTools.BASH,\n                \"{\\\"action\\\":\\\"list\\\"}\",\n                context\n        ));\n        Assert.assertFalse(listResult.getJSONArray(\"processes\").isEmpty());\n\n        JSONObject stopResult = JSON.parseObject(ToolUtil.invoke(\n                BuiltInTools.BASH,\n                JSON.toJSONString(new JSONObject() {{\n                    put(\"action\", \"stop\");\n                    put(\"processId\", processId);\n                }}),\n                context\n        ));\n        Assert.assertEquals(processId, stopResult.getString(\"processId\"));\n        Assert.assertEquals(\"STOPPED\", stopResult.getString(\"status\"));\n    }\n\n    private static boolean isWindows() {\n        return System.getProperty(\"os.name\", \"\").toLowerCase().contains(\"win\");\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/vector/store/milvus/MilvusVectorStoreTest.java",
    "content": "package io.github.lnyocly.ai4j.vector.store.milvus;\n\nimport com.sun.net.httpserver.HttpExchange;\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\nimport io.github.lnyocly.ai4j.config.MilvusConfig;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\nimport okhttp3.OkHttpClient;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class MilvusVectorStoreTest {\n\n    @Test\n    public void shouldUpsertAndSearchAgainstMilvusHttpApi() throws Exception {\n        AtomicReference<String> upsertBody = new AtomicReference<String>();\n        AtomicReference<String> searchBody = new AtomicReference<String>();\n\n        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);\n        server.createContext(\"/v2/vectordb/entities/upsert\", jsonHandler(\"{\\\"code\\\":0}\", upsertBody));\n        server.createContext(\"/v2/vectordb/entities/search\", jsonHandler(\n                \"{\\\"data\\\":[{\\\"id\\\":\\\"1\\\",\\\"distance\\\":0.08,\\\"entity\\\":{\\\"content\\\":\\\"snippet\\\",\\\"sourceName\\\":\\\"handbook.pdf\\\",\\\"pageNumber\\\":3}}]}\",\n                searchBody));\n        server.start();\n        try {\n            Configuration configuration = new Configuration();\n            configuration.setOkHttpClient(new OkHttpClient());\n            MilvusConfig milvusConfig = new MilvusConfig();\n            milvusConfig.setHost(\"http://127.0.0.1:\" + server.getAddress().getPort());\n            configuration.setMilvusConfig(milvusConfig);\n\n            MilvusVectorStore store = new MilvusVectorStore(configuration);\n            int inserted = store.upsert(VectorUpsertRequest.builder()\n                    .dataset(\"demo_collection\")\n                    .records(Collections.singletonList(VectorRecord.builder()\n                            .id(\"doc-1\")\n                            .vector(Arrays.asList(0.1f, 0.2f))\n                            .content(\"hello milvus\")\n                            .metadata(mapOf(\"sourceName\", \"handbook.pdf\"))\n                            .build()))\n                    .build());\n\n            List<VectorSearchResult> results = store.search(VectorSearchRequest.builder()\n                    .dataset(\"demo_collection\")\n                    .vector(Arrays.asList(0.2f, 0.3f))\n                    .topK(2)\n                    .filter(mapOf(\"tenant\", \"acme\"))\n                    .build());\n\n            Assert.assertEquals(1, inserted);\n            Assert.assertTrue(upsertBody.get().contains(\"\\\"collectionName\\\":\\\"demo_collection\\\"\"));\n            Assert.assertTrue(upsertBody.get().contains(\"\\\"sourceName\\\":\\\"handbook.pdf\\\"\"));\n            Assert.assertTrue(searchBody.get().contains(\"\\\"annsField\\\":\\\"vector\\\"\"));\n            Assert.assertTrue(searchBody.get().contains(\"\\\"tenant == \\\\\\\"acme\\\\\\\"\\\"\"));\n            Assert.assertEquals(1, results.size());\n            Assert.assertEquals(\"1\", results.get(0).getId());\n            Assert.assertEquals(\"snippet\", results.get(0).getContent());\n            Assert.assertTrue(results.get(0).getScore() > 0.9f);\n        } finally {\n            server.stop(0);\n        }\n    }\n\n    private HttpHandler jsonHandler(final String responseBody, final AtomicReference<String> requestBody) {\n        return new HttpHandler() {\n            @Override\n            public void handle(HttpExchange exchange) {\n                try {\n                    requestBody.set(read(exchange.getRequestBody()));\n                    byte[] response = responseBody.getBytes(StandardCharsets.UTF_8);\n                    exchange.getResponseHeaders().add(\"Content-Type\", \"application/json\");\n                    exchange.sendResponseHeaders(200, response.length);\n                    OutputStream outputStream = exchange.getResponseBody();\n                    try {\n                        outputStream.write(response);\n                    } finally {\n                        outputStream.close();\n                    }\n                } catch (Exception ex) {\n                    throw new RuntimeException(ex);\n                }\n            }\n        };\n    }\n\n    private String read(InputStream inputStream) throws Exception {\n        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n        byte[] buffer = new byte[256];\n        int len;\n        while ((len = inputStream.read(buffer)) != -1) {\n            outputStream.write(buffer, 0, len);\n        }\n        return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/vector/store/pinecone/PineconeVectorStoreTest.java",
    "content": "package io.github.lnyocly.ai4j.vector.store.pinecone;\n\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeDelete;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeInsert;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeInsertResponse;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeQuery;\nimport io.github.lnyocly.ai4j.vector.pinecone.PineconeQueryResponse;\nimport io.github.lnyocly.ai4j.vector.service.PineconeService;\nimport io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class PineconeVectorStoreTest {\n\n    @Test\n    public void shouldConvertUpsertRecordsToPineconeRequest() throws Exception {\n        CapturingPineconeService pineconeService = new CapturingPineconeService();\n        PineconeVectorStore store = new PineconeVectorStore(pineconeService);\n\n        int inserted = store.upsert(VectorUpsertRequest.builder()\n                .dataset(\"tenant_a\")\n                .records(Collections.singletonList(VectorRecord.builder()\n                        .id(\"doc-1\")\n                        .vector(Arrays.asList(0.1f, 0.2f))\n                        .content(\"hello rag\")\n                        .metadata(mapOf(\"sourceName\", \"manual.pdf\"))\n                        .build()))\n                .build());\n\n        Assert.assertEquals(1, inserted);\n        Assert.assertNotNull(pineconeService.lastInsert);\n        Assert.assertEquals(\"tenant_a\", pineconeService.lastInsert.getNamespace());\n        Assert.assertEquals(\"doc-1\", pineconeService.lastInsert.getVectors().get(0).getId());\n        Assert.assertEquals(\"hello rag\", pineconeService.lastInsert.getVectors().get(0).getMetadata().get(\"content\"));\n        Assert.assertEquals(\"manual.pdf\", pineconeService.lastInsert.getVectors().get(0).getMetadata().get(\"sourceName\"));\n    }\n\n    @Test\n    public void shouldConvertPineconeMatchesToVectorSearchResults() throws Exception {\n        CapturingPineconeService pineconeService = new CapturingPineconeService();\n        PineconeVectorStore store = new PineconeVectorStore(pineconeService);\n\n        List<VectorSearchResult> results = store.search(VectorSearchRequest.builder()\n                .dataset(\"tenant_b\")\n                .vector(Arrays.asList(0.3f, 0.4f))\n                .topK(3)\n                .filter(mapOf(\"tenant\", \"acme\"))\n                .build());\n\n        Assert.assertNotNull(pineconeService.lastQuery);\n        Assert.assertEquals(\"tenant_b\", pineconeService.lastQuery.getNamespace());\n        Assert.assertEquals(Integer.valueOf(3), pineconeService.lastQuery.getTopK());\n        Assert.assertEquals(\"acme\", pineconeService.lastQuery.getFilter().get(\"tenant\"));\n        Assert.assertEquals(1, results.size());\n        Assert.assertEquals(\"match-1\", results.get(0).getId());\n        Assert.assertEquals(\"retrieved snippet\", results.get(0).getContent());\n    }\n\n    @Test\n    public void shouldConvertDeleteRequestToPineconeDelete() throws Exception {\n        CapturingPineconeService pineconeService = new CapturingPineconeService();\n        PineconeVectorStore store = new PineconeVectorStore(pineconeService);\n\n        boolean deleted = store.delete(VectorDeleteRequest.builder()\n                .dataset(\"tenant_c\")\n                .ids(Collections.singletonList(\"doc-1\"))\n                .build());\n\n        Assert.assertTrue(deleted);\n        Assert.assertNotNull(pineconeService.lastDelete);\n        Assert.assertEquals(\"tenant_c\", pineconeService.lastDelete.getNamespace());\n        Assert.assertEquals(\"doc-1\", pineconeService.lastDelete.getIds().get(0));\n    }\n\n    private static class CapturingPineconeService extends PineconeService {\n        private PineconeInsert lastInsert;\n        private PineconeQuery lastQuery;\n        private PineconeDelete lastDelete;\n\n        private CapturingPineconeService() {\n            super(new Configuration());\n        }\n\n        @Override\n        public Integer insert(PineconeInsert pineconeInsertReq) {\n            this.lastInsert = pineconeInsertReq;\n            return 1;\n        }\n\n        @Override\n        public PineconeQueryResponse query(PineconeQuery pineconeQueryReq) {\n            this.lastQuery = pineconeQueryReq;\n            PineconeQueryResponse.Match match = new PineconeQueryResponse.Match();\n            match.setId(\"match-1\");\n            match.setScore(0.92f);\n            match.setMetadata(new LinkedHashMap<String, String>());\n            match.getMetadata().put(\"content\", \"retrieved snippet\");\n            match.getMetadata().put(\"sourceName\", \"manual.pdf\");\n\n            PineconeQueryResponse response = new PineconeQueryResponse();\n            response.setMatches(Collections.singletonList(match));\n            return response;\n        }\n\n        @Override\n        public Boolean delete(PineconeDelete pineconeDeleteReq) {\n            this.lastDelete = pineconeDeleteReq;\n            return true;\n        }\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/ai4j/vector/store/qdrant/QdrantVectorStoreTest.java",
    "content": "package io.github.lnyocly.ai4j.vector.store.qdrant;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport com.sun.net.httpserver.HttpExchange;\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\nimport io.github.lnyocly.ai4j.config.QdrantConfig;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.vector.store.VectorRecord;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\nimport okhttp3.OkHttpClient;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class QdrantVectorStoreTest {\n\n    @Test\n    public void shouldUpsertAndSearchAgainstQdrantHttpApi() throws Exception {\n        AtomicReference<String> upsertBody = new AtomicReference<String>();\n        AtomicReference<String> queryBody = new AtomicReference<String>();\n\n        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);\n        server.createContext(\"/collections/demo/points\", jsonHandler(\"{\\\"status\\\":\\\"ok\\\"}\", upsertBody));\n        server.createContext(\"/collections/demo/points/query\", jsonHandler(\n                \"{\\\"result\\\":{\\\"points\\\":[{\\\"id\\\":\\\"pt-1\\\",\\\"score\\\":0.87,\\\"payload\\\":{\\\"content\\\":\\\"snippet\\\",\\\"sourceName\\\":\\\"manual.pdf\\\"}}]}}\",\n                queryBody));\n        server.start();\n        try {\n            Configuration configuration = new Configuration();\n            configuration.setOkHttpClient(new OkHttpClient());\n            QdrantConfig qdrantConfig = new QdrantConfig();\n            qdrantConfig.setHost(\"http://127.0.0.1:\" + server.getAddress().getPort());\n            configuration.setQdrantConfig(qdrantConfig);\n\n            QdrantVectorStore store = new QdrantVectorStore(configuration);\n            int inserted = store.upsert(VectorUpsertRequest.builder()\n                    .dataset(\"demo\")\n                    .records(Collections.singletonList(VectorRecord.builder()\n                            .id(\"doc-1\")\n                            .vector(Arrays.asList(0.1f, 0.2f))\n                            .content(\"hello\")\n                            .metadata(mapOf(\"sourceName\", \"manual.pdf\"))\n                            .build()))\n                    .build());\n\n            List<VectorSearchResult> results = store.search(VectorSearchRequest.builder()\n                    .dataset(\"demo\")\n                    .vector(Arrays.asList(0.2f, 0.3f))\n                    .topK(3)\n                    .filter(mapOf(\"tenant\", \"acme\"))\n                    .build());\n\n            Assert.assertEquals(1, inserted);\n            Assert.assertTrue(upsertBody.get().contains(\"\\\"points\\\"\"));\n            Assert.assertTrue(upsertBody.get().contains(\"\\\"sourceName\\\":\\\"manual.pdf\\\"\"));\n            Assert.assertTrue(queryBody.get().contains(\"\\\"limit\\\":3\"));\n            Assert.assertTrue(queryBody.get().contains(\"\\\"tenant\\\"\"));\n            Assert.assertEquals(1, results.size());\n            Assert.assertEquals(\"pt-1\", results.get(0).getId());\n            Assert.assertEquals(\"snippet\", results.get(0).getContent());\n        } finally {\n            server.stop(0);\n        }\n    }\n\n    private HttpHandler jsonHandler(final String responseBody, final AtomicReference<String> requestBody) {\n        return new HttpHandler() {\n            @Override\n            public void handle(HttpExchange exchange) {\n                try {\n                    requestBody.set(read(exchange.getRequestBody()));\n                    byte[] response = responseBody.getBytes(StandardCharsets.UTF_8);\n                    exchange.getResponseHeaders().add(\"Content-Type\", \"application/json\");\n                    exchange.sendResponseHeaders(200, response.length);\n                    OutputStream outputStream = exchange.getResponseBody();\n                    try {\n                        outputStream.write(response);\n                    } finally {\n                        outputStream.close();\n                    }\n                } catch (Exception ex) {\n                    throw new RuntimeException(ex);\n                }\n            }\n        };\n    }\n\n    private String read(InputStream inputStream) throws Exception {\n        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n        byte[] buffer = new byte[256];\n        int len;\n        while ((len = inputStream.read(buffer)) != -1) {\n            outputStream.write(buffer, 0, len);\n        }\n        return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/interceptor/ErrorInterceptorTest.java",
    "content": "package io.github.lnyocly.interceptor;\n\nimport io.github.lnyocly.ai4j.exception.CommonException;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport okhttp3.Call;\nimport okhttp3.Connection;\nimport okhttp3.Interceptor;\nimport okhttp3.MediaType;\nimport okhttp3.Protocol;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.IOException;\nimport java.util.concurrent.TimeUnit;\n\npublic class ErrorInterceptorTest {\n\n    @Test\n    public void test_completed_response_with_text_error_keyword_should_not_throw() throws Exception {\n        ErrorInterceptor interceptor = new ErrorInterceptor();\n        Request request = new Request.Builder().url(\"https://example.com/test\").build();\n        String payload = \"{\\\"id\\\":\\\"resp_x\\\",\\\"status\\\":\\\"completed\\\",\\\"output\\\":[{\\\"content\\\":[{\\\"text\\\":\\\"Error Responses section\\\"}]}]}\";\n        Response response = buildResponse(request, 200, \"OK\", payload, \"application/json\");\n\n        Response intercepted = interceptor.intercept(new FixedResponseChain(request, response));\n        Assert.assertEquals(200, intercepted.code());\n        Assert.assertNotNull(intercepted.body());\n        Assert.assertTrue(intercepted.body().string().contains(\"Error Responses section\"));\n    }\n\n    @Test(expected = CommonException.class)\n    public void test_hunyuan_error_shape_in_success_response_should_throw() throws Exception {\n        ErrorInterceptor interceptor = new ErrorInterceptor();\n        Request request = new Request.Builder().url(\"https://example.com/test\").build();\n        String payload = \"{\\\"Response\\\":{\\\"Error\\\":{\\\"Code\\\":\\\"InvalidParameter\\\",\\\"Message\\\":\\\"bad request\\\"}}}\";\n        Response response = buildResponse(request, 200, \"OK\", payload, \"application/json\");\n\n        interceptor.intercept(new FixedResponseChain(request, response));\n    }\n\n    private Response buildResponse(Request request, int code, String message, String body, String contentType) {\n        return new Response.Builder()\n                .request(request)\n                .protocol(Protocol.HTTP_1_1)\n                .code(code)\n                .message(message)\n                .body(ResponseBody.create(MediaType.get(contentType), body))\n                .build();\n    }\n\n    private static class FixedResponseChain implements Interceptor.Chain {\n        private final Request request;\n        private final Response response;\n\n        private FixedResponseChain(Request request, Response response) {\n            this.request = request;\n            this.response = response;\n        }\n\n        @Override\n        public Request request() {\n            return request;\n        }\n\n        @Override\n        public Response proceed(Request request) throws IOException {\n            return response.newBuilder().request(request).build();\n        }\n\n        @Override\n        public Connection connection() {\n            return null;\n        }\n\n        @Override\n        public Call call() {\n            return null;\n        }\n\n        @Override\n        public int connectTimeoutMillis() {\n            return 0;\n        }\n\n        @Override\n        public Interceptor.Chain withConnectTimeout(int timeout, TimeUnit unit) {\n            return this;\n        }\n\n        @Override\n        public int readTimeoutMillis() {\n            return 0;\n        }\n\n        @Override\n        public Interceptor.Chain withReadTimeout(int timeout, TimeUnit unit) {\n            return this;\n        }\n\n        @Override\n        public int writeTimeoutMillis() {\n            return 0;\n        }\n\n        @Override\n        public Interceptor.Chain withWriteTimeout(int timeout, TimeUnit unit) {\n            return this;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/listener/SseListenerTest.java",
    "content": "package io.github.lnyocly.listener;\n\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.PrintStream;\nimport java.nio.charset.StandardCharsets;\n\npublic class SseListenerTest {\n\n    @Test\n    public void shouldIgnoreEmptyToolCallDeltaWhenFinishReasonIsToolCalls() {\n        RecordingSseListener listener = new RecordingSseListener();\n\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"\\\",\\\"tool_calls\\\":[]},\\\"finish_reason\\\":\\\"tool_calls\\\"}]}\");\n    }\n\n    @Test\n    public void shouldIgnoreToolCallDeltaWithoutFunctionPayload() {\n        RecordingSseListener listener = new RecordingSseListener();\n        listener.setShowToolArgs(true);\n\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"tool_calls\\\":[{}]},\\\"finish_reason\\\":null}]}\");\n    }\n\n    @Test\n    public void shouldFinalizeFragmentedToolCallArgumentsWhenFinishReasonArrivesWithoutDelta() {\n        RecordingSseListener listener = new RecordingSseListener();\n\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"tool_calls\\\":[{\\\"id\\\":\\\"call_1\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"bash\\\",\\\"arguments\\\":\\\"\\\"}}]},\\\"finish_reason\\\":null}]}\");\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"function\\\":{\\\"arguments\\\":\\\"{\\\\\\\"action\\\\\\\":\\\\\\\"exec\\\\\\\",\\\"}}]},\\\"finish_reason\\\":null}]}\");\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"function\\\":{\\\"arguments\\\":\\\"\\\\\\\"command\\\\\\\":\\\\\\\"date\\\\\\\"}\\\"}}]},\\\"finish_reason\\\":null}]}\");\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{},\\\"finish_reason\\\":\\\"tool_calls\\\"}]}\");\n\n        Assert.assertEquals(1, listener.getToolCalls().size());\n        ToolCall toolCall = listener.getToolCalls().get(0);\n        Assert.assertEquals(\"bash\", toolCall.getFunction().getName());\n        Assert.assertEquals(\"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"date\\\"}\", toolCall.getFunction().getArguments());\n    }\n\n    @Test\n    public void shouldAggregateMiniMaxStyleToolCallFragmentsIntoSingleCall() {\n        RecordingSseListener listener = new RecordingSseListener();\n\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"\\\",\\\"tool_calls\\\":[{\\\"id\\\":\\\"call_1\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"delegate_plan\\\",\\\"arguments\\\":\\\"{\\\\\\\"task\\\\\\\": \\\\\\\"Create a short implementation plan\\\"}}]},\\\"finish_reason\\\":null}]}\");\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"\\\",\\\"tool_calls\\\":[{\\\"function\\\":{\\\"arguments\\\":\\\" for adding a hello endpoint demo app in this empty workspace.\\\"}}]},\\\"finish_reason\\\":null}]}\");\n        listener.onEvent(null, null, null,\n                \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"\\\",\\\"tool_calls\\\":[{\\\"function\\\":{\\\"arguments\\\":\\\"\\\\\\\"}\\\"}}]},\\\"finish_reason\\\":\\\"tool_calls\\\"}]}\");\n\n        Assert.assertEquals(1, listener.getToolCalls().size());\n        ToolCall toolCall = listener.getToolCalls().get(0);\n        Assert.assertEquals(\"delegate_plan\", toolCall.getFunction().getName());\n        Assert.assertEquals(\"{\\\"task\\\": \\\"Create a short implementation plan for adding a hello endpoint demo app in this empty workspace.\\\"}\", toolCall.getFunction().getArguments());\n    }\n\n    @Test\n    public void shouldNotWriteBlankLinesToStdoutForEscapedNewlinePayloads() throws Exception {\n        RecordingSseListener listener = new RecordingSseListener();\n        ByteArrayOutputStream stdout = new ByteArrayOutputStream();\n        PrintStream originalOut = System.out;\n        System.setOut(new PrintStream(stdout, true, \"UTF-8\"));\n        try {\n            listener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"line1\\\\\\\\nline2\\\"},\\\"finish_reason\\\":\\\"stop\\\"}]}\");\n        } finally {\n            System.setOut(originalOut);\n        }\n\n        Assert.assertEquals(\"\", stdout.toString(StandardCharsets.UTF_8.name()));\n    }\n\n    private static final class RecordingSseListener extends SseListener {\n        @Override\n        protected void send() {\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/listener/StreamExecutionSupportTest.java",
    "content": "package io.github.lnyocly.listener;\n\nimport io.github.lnyocly.ai4j.listener.ManagedStreamListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionSupport;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class StreamExecutionSupportTest {\n\n    @Test\n    public void shouldRetryBeforeFirstEventAndStopAfterSuccess() throws Exception {\n        RecordingManagedStreamListener listener = new RecordingManagedStreamListener();\n        AtomicInteger attempts = new AtomicInteger();\n\n        StreamExecutionSupport.execute(\n                listener,\n                StreamExecutionOptions.builder().maxRetries(2).retryBackoffMs(0L).build(),\n                () -> {\n                    if (attempts.incrementAndGet() == 1) {\n                        listener.recordFailure(new RuntimeException(\"connect failed\"));\n                    } else {\n                        listener.clearFailure();\n                    }\n                }\n        );\n\n        Assert.assertEquals(2, attempts.get());\n        Assert.assertEquals(1, listener.retries.size());\n        Assert.assertEquals(\"connect failed|2/3\", listener.retries.get(0));\n        Assert.assertEquals(1, listener.prepareForRetryCalls);\n        Assert.assertNull(listener.getFailure());\n    }\n\n    @Test\n    public void shouldNotRetryAfterFirstEventArrives() throws Exception {\n        RecordingManagedStreamListener listener = new RecordingManagedStreamListener();\n        listener.receivedEvent = true;\n        AtomicInteger attempts = new AtomicInteger();\n\n        StreamExecutionSupport.execute(\n                listener,\n                StreamExecutionOptions.builder().maxRetries(3).retryBackoffMs(0L).build(),\n                () -> {\n                    attempts.incrementAndGet();\n                    listener.recordFailure(new RuntimeException(\"stream stalled\"));\n                }\n        );\n\n        Assert.assertEquals(1, attempts.get());\n        Assert.assertEquals(0, listener.retries.size());\n        Assert.assertEquals(0, listener.prepareForRetryCalls);\n        Assert.assertNotNull(listener.getFailure());\n    }\n\n    @Test\n    public void shouldApplySafeDefaultTimeoutsWhenOptionsAreMissing() throws Exception {\n        RecordingManagedStreamListener listener = new RecordingManagedStreamListener();\n\n        StreamExecutionSupport.execute(listener, null, new StreamExecutionSupport.StreamStarter() {\n            @Override\n            public void start() {\n            }\n        });\n\n        Assert.assertNotNull(listener.awaitedOptions);\n        Assert.assertEquals(StreamExecutionSupport.DEFAULT_FIRST_TOKEN_TIMEOUT_MS, listener.awaitedOptions.getFirstTokenTimeoutMs());\n        Assert.assertEquals(StreamExecutionSupport.DEFAULT_IDLE_TIMEOUT_MS, listener.awaitedOptions.getIdleTimeoutMs());\n        Assert.assertEquals(StreamExecutionSupport.DEFAULT_MAX_RETRIES, listener.awaitedOptions.getMaxRetries());\n        Assert.assertEquals(StreamExecutionSupport.DEFAULT_RETRY_BACKOFF_MS, listener.awaitedOptions.getRetryBackoffMs());\n    }\n\n    @Test\n    public void shouldHonorConfiguredDefaultTimeoutOverrides() throws Exception {\n        RecordingManagedStreamListener listener = new RecordingManagedStreamListener();\n        String firstTokenPrevious = System.getProperty(StreamExecutionSupport.DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY);\n        String idlePrevious = System.getProperty(StreamExecutionSupport.DEFAULT_IDLE_TIMEOUT_PROPERTY);\n        String retriesPrevious = System.getProperty(StreamExecutionSupport.DEFAULT_MAX_RETRIES_PROPERTY);\n        String backoffPrevious = System.getProperty(StreamExecutionSupport.DEFAULT_RETRY_BACKOFF_PROPERTY);\n        try {\n            System.setProperty(StreamExecutionSupport.DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY, \"1234\");\n            System.setProperty(StreamExecutionSupport.DEFAULT_IDLE_TIMEOUT_PROPERTY, \"5678\");\n            System.setProperty(StreamExecutionSupport.DEFAULT_MAX_RETRIES_PROPERTY, \"2\");\n            System.setProperty(StreamExecutionSupport.DEFAULT_RETRY_BACKOFF_PROPERTY, \"250\");\n\n            StreamExecutionSupport.execute(listener, null, new StreamExecutionSupport.StreamStarter() {\n                @Override\n                public void start() {\n                }\n            });\n\n            Assert.assertNotNull(listener.awaitedOptions);\n            Assert.assertEquals(1234L, listener.awaitedOptions.getFirstTokenTimeoutMs());\n            Assert.assertEquals(5678L, listener.awaitedOptions.getIdleTimeoutMs());\n            Assert.assertEquals(2, listener.awaitedOptions.getMaxRetries());\n            Assert.assertEquals(250L, listener.awaitedOptions.getRetryBackoffMs());\n        } finally {\n            restoreProperty(StreamExecutionSupport.DEFAULT_FIRST_TOKEN_TIMEOUT_PROPERTY, firstTokenPrevious);\n            restoreProperty(StreamExecutionSupport.DEFAULT_IDLE_TIMEOUT_PROPERTY, idlePrevious);\n            restoreProperty(StreamExecutionSupport.DEFAULT_MAX_RETRIES_PROPERTY, retriesPrevious);\n            restoreProperty(StreamExecutionSupport.DEFAULT_RETRY_BACKOFF_PROPERTY, backoffPrevious);\n        }\n    }\n\n    private static final class RecordingManagedStreamListener implements ManagedStreamListener {\n        private Throwable failure;\n        private boolean receivedEvent;\n        private boolean cancelRequested;\n        private int prepareForRetryCalls;\n        private final List<String> retries = new ArrayList<String>();\n        private StreamExecutionOptions awaitedOptions;\n\n        @Override\n        public void awaitCompletion(StreamExecutionOptions options) {\n            this.awaitedOptions = options;\n        }\n\n        @Override\n        public Throwable getFailure() {\n            return failure;\n        }\n\n        @Override\n        public void recordFailure(Throwable failure) {\n            this.failure = failure;\n        }\n\n        @Override\n        public void clearFailure() {\n            this.failure = null;\n        }\n\n        @Override\n        public void prepareForRetry() {\n            prepareForRetryCalls += 1;\n            clearFailure();\n            receivedEvent = false;\n        }\n\n        @Override\n        public boolean hasReceivedEvent() {\n            return receivedEvent;\n        }\n\n        @Override\n        public boolean isCancelRequested() {\n            return cancelRequested;\n        }\n\n        @Override\n        public void onRetrying(Throwable failure, int attempt, int maxAttempts) {\n            String message = failure == null ? \"unknown\" : failure.getMessage();\n            retries.add(message + \"|\" + attempt + \"/\" + maxAttempts);\n        }\n    }\n\n    private static void restoreProperty(String key, String value) {\n        if (value == null) {\n            System.clearProperty(key);\n        } else {\n            System.setProperty(key, value);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j/src/test/java/io/github/lnyocly/service/AiServiceRegistryTest.java",
    "content": "package io.github.lnyocly.service;\n\nimport io.github.lnyocly.ai4j.config.AiPlatform;\nimport io.github.lnyocly.ai4j.config.JinaConfig;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.platform.jina.rerank.JinaRerankService;\nimport io.github.lnyocly.ai4j.platform.openai.chat.OpenAiChatService;\nimport io.github.lnyocly.ai4j.rag.Reranker;\nimport io.github.lnyocly.ai4j.rag.ingestion.IngestionPipeline;\nimport io.github.lnyocly.ai4j.service.AiConfig;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceRegistration;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceRegistry;\nimport io.github.lnyocly.ai4j.service.factory.DefaultAiServiceRegistry;\nimport io.github.lnyocly.ai4j.service.factory.FreeAiService;\nimport io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities;\nimport okhttp3.OkHttpClient;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Collections;\n\npublic class AiServiceRegistryTest {\n\n    @Test\n    public void shouldBuildRegistryFromConfiguredPlatforms() {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(new OkHttpClient());\n\n        AiPlatform aiPlatform = new AiPlatform();\n        aiPlatform.setId(\"tenant-a-openai\");\n        aiPlatform.setPlatform(\"openai\");\n        aiPlatform.setApiHost(\"https://example-openai.local/\");\n        aiPlatform.setApiKey(\"sk-test\");\n\n        AiConfig aiConfig = new AiConfig();\n        aiConfig.setPlatforms(Collections.singletonList(aiPlatform));\n\n        AiServiceRegistry registry = DefaultAiServiceRegistry.from(configuration, aiConfig);\n        AiServiceRegistration registration = registry.get(\"tenant-a-openai\");\n        AiService aiService = registration.getAiService();\n\n        Assert.assertTrue(registry.contains(\"tenant-a-openai\"));\n        Assert.assertEquals(PlatformType.OPENAI, registration.getPlatformType());\n        Assert.assertTrue(registry.getChatService(\"tenant-a-openai\") instanceof OpenAiChatService);\n        Assert.assertNotNull(aiService);\n        Assert.assertNotNull(aiService.getConfiguration());\n        Assert.assertNotSame(configuration, aiService.getConfiguration());\n\n        OpenAiConfig scopedOpenAiConfig = aiService.getConfiguration().getOpenAiConfig();\n        Assert.assertEquals(\"https://example-openai.local/\", scopedOpenAiConfig.getApiHost());\n        Assert.assertEquals(\"sk-test\", scopedOpenAiConfig.getApiKey());\n    }\n\n    @Test\n    @SuppressWarnings(\"deprecation\")\n    public void shouldKeepFreeAiServiceAsCompatibilityShell() {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(new OkHttpClient());\n\n        AiPlatform aiPlatform = new AiPlatform();\n        aiPlatform.setId(\"tenant-a-openai\");\n        aiPlatform.setPlatform(\"openai\");\n        aiPlatform.setApiHost(\"https://example-openai.local/\");\n        aiPlatform.setApiKey(\"sk-test\");\n\n        AiConfig aiConfig = new AiConfig();\n        aiConfig.setPlatforms(Collections.singletonList(aiPlatform));\n\n        new FreeAiService(configuration, aiConfig);\n\n        Assert.assertTrue(FreeAiService.contains(\"tenant-a-openai\"));\n        Assert.assertTrue(FreeAiService.getChatService(\"tenant-a-openai\") instanceof OpenAiChatService);\n        Assert.assertNull(FreeAiService.getChatService(\"missing\"));\n    }\n\n    @Test\n    public void shouldFailFastWhenPlatformIsUnsupported() {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(new OkHttpClient());\n\n        AiPlatform aiPlatform = new AiPlatform();\n        aiPlatform.setId(\"tenant-a-unknown\");\n        aiPlatform.setPlatform(\"unknown-provider\");\n\n        AiConfig aiConfig = new AiConfig();\n        aiConfig.setPlatforms(Collections.singletonList(aiPlatform));\n\n        try {\n            DefaultAiServiceRegistry.from(configuration, aiConfig);\n            Assert.fail(\"Expected unsupported platform to fail fast\");\n        } catch (IllegalArgumentException e) {\n            Assert.assertEquals(\"Unsupported ai platform 'unknown-provider' for id 'tenant-a-unknown'\", e.getMessage());\n        }\n    }\n\n    @Test\n    public void shouldExposeJinaCompatibleRerankServiceFromRegistry() {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(new OkHttpClient());\n\n        AiPlatform aiPlatform = new AiPlatform();\n        aiPlatform.setId(\"tenant-a-rerank\");\n        aiPlatform.setPlatform(\"jina\");\n        aiPlatform.setApiHost(\"https://api.jina.ai/\");\n        aiPlatform.setApiKey(\"jina-key\");\n        aiPlatform.setRerankUrl(\"v1/rerank\");\n\n        AiConfig aiConfig = new AiConfig();\n        aiConfig.setPlatforms(Collections.singletonList(aiPlatform));\n\n        AiServiceRegistry registry = DefaultAiServiceRegistry.from(configuration, aiConfig);\n        AiServiceRegistration registration = registry.get(\"tenant-a-rerank\");\n\n        Assert.assertEquals(PlatformType.JINA, registration.getPlatformType());\n        Assert.assertTrue(registry.getRerankService(\"tenant-a-rerank\") instanceof JinaRerankService);\n        JinaConfig scopedJinaConfig = registration.getAiService().getConfiguration().getJinaConfig();\n        Assert.assertEquals(\"https://api.jina.ai/\", scopedJinaConfig.getApiHost());\n        Assert.assertEquals(\"jina-key\", scopedJinaConfig.getApiKey());\n        Assert.assertEquals(\"v1/rerank\", scopedJinaConfig.getRerankUrl());\n\n        Reranker reranker = registry.getModelReranker(\n                \"tenant-a-rerank\",\n                \"jina-reranker-v2-base-multilingual\",\n                5,\n                \"优先制度原文\"\n        );\n        Assert.assertNotNull(reranker);\n    }\n\n    @Test\n    public void shouldExposeIngestionPipelineFromRegistryAndCompatibilityShell() {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(new OkHttpClient());\n\n        AiPlatform aiPlatform = new AiPlatform();\n        aiPlatform.setId(\"tenant-a-openai\");\n        aiPlatform.setPlatform(\"openai\");\n        aiPlatform.setApiHost(\"https://example-openai.local/\");\n        aiPlatform.setApiKey(\"sk-test\");\n\n        AiConfig aiConfig = new AiConfig();\n        aiConfig.setPlatforms(Collections.singletonList(aiPlatform));\n\n        AiServiceRegistry registry = DefaultAiServiceRegistry.from(configuration, aiConfig);\n        VectorStore vectorStore = new NoopVectorStore();\n\n        IngestionPipeline pipeline = registry.getIngestionPipeline(\"tenant-a-openai\", vectorStore);\n        Assert.assertNotNull(pipeline);\n\n        new FreeAiService(registry);\n        Assert.assertNotNull(FreeAiService.getIngestionPipeline(\"tenant-a-openai\", vectorStore));\n    }\n\n    private static class NoopVectorStore implements VectorStore {\n        @Override\n        public int upsert(io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest request) {\n            return 0;\n        }\n\n        @Override\n        public java.util.List<VectorSearchResult> search(VectorSearchRequest request) {\n            return Collections.emptyList();\n        }\n\n        @Override\n        public boolean delete(VectorDeleteRequest request) {\n            return false;\n        }\n\n        @Override\n        public VectorStoreCapabilities capabilities() {\n            return VectorStoreCapabilities.builder().dataset(true).metadataFilter(true).build();\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-agent/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <parent>\n        <groupId>io.github.lnyo-cly</groupId>\n        <artifactId>ai4j-sdk</artifactId>\n        <version>2.3.0</version>\n    </parent>\n\n    <artifactId>ai4j-agent</artifactId>\n    <packaging>jar</packaging>\n\n    <name>ai4j-agent</name>\n    <description>ai4j 通用 Agent 运行时模块，支持 ReAct、subagent、team、memory 与 trace。 Agent runtime module for ReAct, subagents, teams, memory, and tracing.</description>\n\n    <licenses>\n        <license>\n            <name>The Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n        </license>\n    </licenses>\n\n    <issueManagement>\n        <system>GitHub</system>\n        <url>https://github.com/LnYo-Cly/ai4j/issues</url>\n    </issueManagement>\n    <url>https://github.com/LnYo-Cly/ai4j</url>\n\n    <developers>\n        <developer>\n            <id>LnYo-Cly</id>\n            <name>LnYo-Cly</name>\n            <email>lnyocly@gmail.com</email>\n            <url>https://github.com/LnYo-Cly/ai4j</url>\n            <timezone>+8</timezone>\n        </developer>\n    </developers>\n\n    <scm>\n        <url>https://github.com/LnYo-Cly/ai4j</url>\n        <connection>scm:git:https://github.com/LnYo-Cly/ai4j.git</connection>\n        <developerConnection>scm:git:https://github.com/LnYo-Cly/ai4j.git</developerConnection>\n    </scm>\n\n    <dependencies>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>com.alibaba.fastjson2</groupId>\n            <artifactId>fastjson2</artifactId>\n            <version>2.0.43</version>\n        </dependency>\n        <dependency>\n            <groupId>org.graalvm.sdk</groupId>\n            <artifactId>graal-sdk</artifactId>\n            <version>20.3.4</version>\n        </dependency>\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <version>1.18.30</version>\n        </dependency>\n        <dependency>\n            <groupId>org.slf4j</groupId>\n            <artifactId>slf4j-api</artifactId>\n            <version>1.7.30</version>\n        </dependency>\n        <dependency>\n            <groupId>io.opentelemetry</groupId>\n            <artifactId>opentelemetry-api</artifactId>\n            <version>1.39.0</version>\n        </dependency>\n        <dependency>\n            <groupId>junit</groupId>\n            <artifactId>junit</artifactId>\n            <version>4.13.2</version>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>com.h2database</groupId>\n            <artifactId>h2</artifactId>\n            <version>2.2.224</version>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n\n    <profiles>\n        <profile>\n            <id>release</id>\n            <build>\n                <plugins>\n                    <plugin>\n                        <groupId>org.codehaus.mojo</groupId>\n                        <artifactId>flatten-maven-plugin</artifactId>\n                        <version>${flatten-maven-plugin.version}</version>\n                        <configuration>\n                            <flattenMode>ossrh</flattenMode>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>flatten</id>\n                                <phase>process-resources</phase>\n                                <goals>\n                                    <goal>flatten</goal>\n                                </goals>\n                            </execution>\n                            <execution>\n                                <id>flatten-clean</id>\n                                <phase>clean</phase>\n                                <goals>\n                                    <goal>clean</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-source-plugin</artifactId>\n                        <version>3.3.1</version>\n                        <executions>\n                            <execution>\n                                <id>attach-sources</id>\n                                <goals>\n                                    <goal>jar-no-fork</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-javadoc-plugin</artifactId>\n                        <version>3.6.3</version>\n                        <executions>\n                            <execution>\n                                <id>attach-javadocs</id>\n                                <goals>\n                                    <goal>jar</goal>\n                                </goals>\n                                <configuration>\n                                    <doclint>none</doclint>\n                                    <failOnError>false</failOnError>\n                                    <tags>\n                                        <tag>\n                                            <name>Author</name>\n                                            <placement>a</placement>\n                                            <head>Author:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Description</name>\n                                            <placement>a</placement>\n                                            <head>Description:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Date</name>\n                                            <placement>a</placement>\n                                            <head>Date:</head>\n                                        </tag>\n                                    </tags>\n                                </configuration>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-gpg-plugin</artifactId>\n                        <version>1.6</version>\n                        <configuration>\n                            <executable>D:\\Develop\\DevelopEnv\\GnuPG\\bin\\gpg.exe</executable>\n                            <keyname>cly</keyname>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>sign-artifacts</id>\n                                <phase>verify</phase>\n                                <goals>\n                                    <goal>sign</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.sonatype.central</groupId>\n                        <artifactId>central-publishing-maven-plugin</artifactId>\n                        <version>0.4.0</version>\n                        <extensions>true</extensions>\n                        <configuration>\n                            <publishingServerId>LnYo-Cly</publishingServerId>\n                            <tokenAuth>true</tokenAuth>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n</project>\n\n\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/Agent.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.memory.AgentMemory;\n\nimport java.util.function.Supplier;\n\npublic class Agent {\n\n    private final AgentRuntime runtime;\n    private final AgentContext baseContext;\n    private final Supplier<AgentMemory> memorySupplier;\n\n    public Agent(AgentRuntime runtime, AgentContext baseContext, Supplier<AgentMemory> memorySupplier) {\n        this.runtime = runtime;\n        this.baseContext = baseContext;\n        this.memorySupplier = memorySupplier;\n    }\n\n    public AgentResult run(AgentRequest request) throws Exception {\n        return runtime.run(baseContext, request);\n    }\n\n    public void runStream(AgentRequest request, AgentListener listener) throws Exception {\n        runtime.runStream(baseContext, request, listener);\n    }\n\n    public AgentResult runStreamResult(AgentRequest request, AgentListener listener) throws Exception {\n        return runtime.runStreamResult(baseContext, request, listener);\n    }\n\n    public AgentSession newSession() {\n        AgentMemory memory = memorySupplier == null ? baseContext.getMemory() : memorySupplier.get();\n        AgentContext sessionContext = baseContext.toBuilder().memory(memory).build();\n        return new AgentSession(runtime, sessionContext);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentBuilder.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport io.github.lnyocly.ai4j.agent.codeact.CodeActOptions;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutor;\nimport io.github.lnyocly.ai4j.agent.codeact.GraalVmCodeExecutor;\nimport io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventPublisher;\nimport io.github.lnyocly.ai4j.agent.memory.AgentMemory;\nimport io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.runtime.ReActRuntime;\nimport io.github.lnyocly.ai4j.agent.subagent.StaticSubAgentRegistry;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentToolExecutor;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.CompositeToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.agent.trace.AgentTraceListener;\nimport io.github.lnyocly.ai4j.agent.trace.TraceConfig;\nimport io.github.lnyocly.ai4j.agent.trace.TraceExporter;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.lang.reflect.Constructor;\nimport java.util.ArrayList;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.function.Supplier;\n\npublic class AgentBuilder {\n\n    private AgentRuntime runtime;\n    private AgentModelClient modelClient;\n    private AgentToolRegistry toolRegistry;\n    private SubAgentRegistry subAgentRegistry;\n    private HandoffPolicy handoffPolicy;\n    private final List<SubAgentDefinition> subAgentDefinitions = new ArrayList<>();\n    private ToolExecutor toolExecutor;\n    private CodeExecutor codeExecutor;\n    private Supplier<AgentMemory> memorySupplier;\n    private AgentOptions options;\n    private CodeActOptions codeActOptions;\n    private TraceExporter traceExporter;\n    private TraceConfig traceConfig;\n    private AgentEventPublisher eventPublisher;\n    private String model;\n    private String instructions;\n    private String systemPrompt;\n    private Double temperature;\n    private Double topP;\n    private Integer maxOutputTokens;\n    private Object reasoning;\n    private Object toolChoice;\n    private Boolean parallelToolCalls;\n    private Boolean store;\n    private String user;\n    private Map<String, Object> extraBody;\n\n    public AgentBuilder runtime(AgentRuntime runtime) {\n        this.runtime = runtime;\n        return this;\n    }\n\n    public AgentBuilder modelClient(AgentModelClient modelClient) {\n        this.modelClient = modelClient;\n        return this;\n    }\n\n    public AgentBuilder toolRegistry(AgentToolRegistry toolRegistry) {\n        this.toolRegistry = toolRegistry;\n        return this;\n    }\n\n    public AgentBuilder toolRegistry(List<String> functions, List<String> mcpServices) {\n        this.toolRegistry = createToolUtilRegistry(functions, mcpServices);\n        return this;\n    }\n\n    public AgentBuilder subAgentRegistry(SubAgentRegistry subAgentRegistry) {\n        this.subAgentRegistry = subAgentRegistry;\n        return this;\n    }\n\n    public AgentBuilder handoffPolicy(HandoffPolicy handoffPolicy) {\n        this.handoffPolicy = handoffPolicy;\n        return this;\n    }\n\n    public AgentBuilder subAgent(SubAgentDefinition definition) {\n        if (definition != null) {\n            this.subAgentDefinitions.add(definition);\n        }\n        return this;\n    }\n\n    public AgentBuilder subAgents(List<SubAgentDefinition> definitions) {\n        if (definitions != null && !definitions.isEmpty()) {\n            this.subAgentDefinitions.addAll(definitions);\n        }\n        return this;\n    }\n\n    public AgentBuilder toolExecutor(ToolExecutor toolExecutor) {\n        this.toolExecutor = toolExecutor;\n        return this;\n    }\n\n    public AgentBuilder codeExecutor(CodeExecutor codeExecutor) {\n        this.codeExecutor = codeExecutor;\n        return this;\n    }\n\n    public AgentBuilder memorySupplier(Supplier<AgentMemory> memorySupplier) {\n        this.memorySupplier = memorySupplier;\n        return this;\n    }\n\n    public AgentBuilder options(AgentOptions options) {\n        this.options = options;\n        return this;\n    }\n\n    public AgentBuilder codeActOptions(CodeActOptions codeActOptions) {\n        this.codeActOptions = codeActOptions;\n        return this;\n    }\n\n    public AgentBuilder traceExporter(TraceExporter traceExporter) {\n        this.traceExporter = traceExporter;\n        return this;\n    }\n\n    public AgentBuilder traceConfig(TraceConfig traceConfig) {\n        this.traceConfig = traceConfig;\n        return this;\n    }\n\n    public AgentBuilder eventPublisher(AgentEventPublisher eventPublisher) {\n        this.eventPublisher = eventPublisher;\n        return this;\n    }\n\n    public AgentBuilder model(String model) {\n        this.model = model;\n        return this;\n    }\n\n    public AgentBuilder instructions(String instructions) {\n        this.instructions = instructions;\n        return this;\n    }\n\n    public AgentBuilder systemPrompt(String systemPrompt) {\n        this.systemPrompt = systemPrompt;\n        return this;\n    }\n\n    public AgentBuilder temperature(Double temperature) {\n        this.temperature = temperature;\n        return this;\n    }\n\n    public AgentBuilder topP(Double topP) {\n        this.topP = topP;\n        return this;\n    }\n\n    public AgentBuilder maxOutputTokens(Integer maxOutputTokens) {\n        this.maxOutputTokens = maxOutputTokens;\n        return this;\n    }\n\n    public AgentBuilder reasoning(Object reasoning) {\n        this.reasoning = reasoning;\n        return this;\n    }\n\n    public AgentBuilder toolChoice(Object toolChoice) {\n        this.toolChoice = toolChoice;\n        return this;\n    }\n\n    public AgentBuilder parallelToolCalls(Boolean parallelToolCalls) {\n        this.parallelToolCalls = parallelToolCalls;\n        return this;\n    }\n\n    public AgentBuilder store(Boolean store) {\n        this.store = store;\n        return this;\n    }\n\n    public AgentBuilder user(String user) {\n        this.user = user;\n        return this;\n    }\n\n    public AgentBuilder extraBody(Map<String, Object> extraBody) {\n        this.extraBody = extraBody;\n        return this;\n    }\n\n    public Agent build() {\n        AgentRuntime resolvedRuntime = runtime == null ? new ReActRuntime() : runtime;\n        Supplier<AgentMemory> resolvedMemorySupplier = memorySupplier == null ? InMemoryAgentMemory::new : memorySupplier;\n        AgentMemory memory = resolvedMemorySupplier.get();\n\n        AgentToolRegistry baseToolRegistry = toolRegistry == null ? StaticToolRegistry.empty() : toolRegistry;\n        SubAgentRegistry resolvedSubAgentRegistry = resolveSubAgentRegistry();\n        AgentToolRegistry resolvedToolRegistry = resolveToolRegistry(baseToolRegistry, resolvedSubAgentRegistry);\n\n        ToolExecutor resolvedToolExecutor = toolExecutor;\n        if (resolvedToolExecutor == null) {\n            Set<String> allowedToolNames = resolveToolNames(baseToolRegistry);\n            resolvedToolExecutor = createToolUtilExecutor(allowedToolNames);\n        }\n        if (resolvedSubAgentRegistry != null) {\n            HandoffPolicy resolvedHandoffPolicy = handoffPolicy == null ? HandoffPolicy.builder().build() : handoffPolicy;\n            resolvedToolExecutor = new SubAgentToolExecutor(resolvedSubAgentRegistry, resolvedToolExecutor, resolvedHandoffPolicy);\n        }\n\n        CodeExecutor resolvedCodeExecutor = codeExecutor == null ? createDefaultCodeExecutor() : codeExecutor;\n        AgentOptions resolvedOptions = options == null ? AgentOptions.builder().build() : options;\n        CodeActOptions resolvedCodeActOptions = codeActOptions == null ? CodeActOptions.builder().build() : codeActOptions;\n        AgentEventPublisher resolvedEventPublisher = eventPublisher == null ? new AgentEventPublisher() : eventPublisher;\n        if (traceExporter != null) {\n            resolvedEventPublisher.addListener(new AgentTraceListener(traceExporter, traceConfig));\n        }\n        if (modelClient == null) {\n            throw new IllegalStateException(\"modelClient is required\");\n        }\n\n        AgentContext context = AgentContext.builder()\n                .modelClient(modelClient)\n                .toolRegistry(resolvedToolRegistry)\n                .toolExecutor(resolvedToolExecutor)\n                .codeExecutor(resolvedCodeExecutor)\n                .memory(memory)\n                .options(resolvedOptions)\n                .codeActOptions(resolvedCodeActOptions)\n                .eventPublisher(resolvedEventPublisher)\n                .model(model)\n                .instructions(instructions)\n                .systemPrompt(systemPrompt)\n                .temperature(temperature)\n                .topP(topP)\n                .maxOutputTokens(maxOutputTokens)\n                .reasoning(reasoning)\n                .toolChoice(toolChoice)\n                .parallelToolCalls(parallelToolCalls)\n                .store(store)\n                .user(user)\n                .extraBody(extraBody)\n                .build();\n\n        return new Agent(resolvedRuntime, context, resolvedMemorySupplier);\n    }\n\n\n    private CodeExecutor createDefaultCodeExecutor() {\n        String javaSpecVersion = System.getProperty(\"java.specification.version\", \"\");\n        if (\"1.8\".equals(javaSpecVersion) || \"8\".equals(javaSpecVersion)) {\n            return new NashornCodeExecutor();\n        }\n        return new GraalVmCodeExecutor();\n    }\n    private SubAgentRegistry resolveSubAgentRegistry() {\n        if (subAgentRegistry != null) {\n            return subAgentRegistry;\n        }\n        if (!subAgentDefinitions.isEmpty()) {\n            return new StaticSubAgentRegistry(subAgentDefinitions);\n        }\n        return null;\n    }\n\n    private AgentToolRegistry resolveToolRegistry(AgentToolRegistry baseToolRegistry, SubAgentRegistry subRegistry) {\n        if (subRegistry == null) {\n            return baseToolRegistry;\n        }\n        return new CompositeToolRegistry(baseToolRegistry, new StaticToolRegistry(subRegistry.getTools()));\n    }\n\n    private Set<String> resolveToolNames(AgentToolRegistry registry) {\n        Set<String> names = new HashSet<>();\n        if (registry == null) {\n            return names;\n        }\n        List<Object> tools = registry.getTools();\n        if (tools == null) {\n            return names;\n        }\n        for (Object tool : tools) {\n            if (tool instanceof Tool) {\n                Tool.Function fn = ((Tool) tool).getFunction();\n                if (fn != null && fn.getName() != null) {\n                    names.add(fn.getName());\n                }\n            }\n        }\n        return names;\n    }\n\n    private AgentToolRegistry createToolUtilRegistry(List<String> functions, List<String> mcpServices) {\n        Object registry = instantiateClass(\n                \"io.github.lnyocly.ai4j.agent.tool.ToolUtilRegistry\",\n                new Class<?>[]{List.class, List.class},\n                new Object[]{functions, mcpServices}\n        );\n        if (!(registry instanceof AgentToolRegistry)) {\n            throw new IllegalStateException(\"ToolUtilRegistry is unavailable. Add the ai4j integration module or provide AgentToolRegistry manually.\");\n        }\n        return (AgentToolRegistry) registry;\n    }\n\n    private ToolExecutor createToolUtilExecutor(Set<String> allowedToolNames) {\n        Object executor = instantiateClass(\n                \"io.github.lnyocly.ai4j.agent.tool.ToolUtilExecutor\",\n                new Class<?>[]{Set.class},\n                new Object[]{allowedToolNames}\n        );\n        if (executor == null) {\n            if (allowedToolNames == null || allowedToolNames.isEmpty()) {\n                return null;\n            }\n            throw new IllegalStateException(\"ToolUtilExecutor is unavailable. Add the ai4j integration module or provide ToolExecutor manually.\");\n        }\n        if (!(executor instanceof ToolExecutor)) {\n            throw new IllegalStateException(\"ToolUtilExecutor does not implement ToolExecutor.\");\n        }\n        return (ToolExecutor) executor;\n    }\n\n    private Object instantiateClass(String className, Class<?>[] parameterTypes, Object[] args) {\n        try {\n            Class<?> clazz = Class.forName(className);\n            Constructor<?> constructor = clazz.getConstructor(parameterTypes);\n            return constructor.newInstance(args);\n        } catch (ClassNotFoundException e) {\n            return null;\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to initialize \" + className, e);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentContext.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEventPublisher;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutor;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeActOptions;\nimport io.github.lnyocly.ai4j.agent.memory.AgentMemory;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentContext {\n\n    private AgentModelClient modelClient;\n\n    private AgentToolRegistry toolRegistry;\n\n    private ToolExecutor toolExecutor;\n\n    private CodeExecutor codeExecutor;\n\n    private AgentMemory memory;\n\n    private AgentOptions options;\n\n    private CodeActOptions codeActOptions;\n\n    private AgentEventPublisher eventPublisher;\n\n    private String model;\n\n    private String instructions;\n\n    private String systemPrompt;\n\n    private Double temperature;\n\n    private Double topP;\n\n    private Integer maxOutputTokens;\n\n    private Object reasoning;\n\n    private Object toolChoice;\n\n    private Boolean parallelToolCalls;\n\n    private Boolean store;\n\n    private String user;\n\n    private Map<String, Object> extraBody;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentOptions.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentOptions {\n\n    @Builder.Default\n    private int maxSteps = 0;\n\n    @Builder.Default\n    private boolean stream = false;\n\n    @Builder.Default\n    private StreamExecutionOptions streamExecution = StreamExecutionOptions.builder().build();\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentRequest.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class AgentRequest {\n\n    private Object input;\n\n    private Map<String, Object> metadata;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentResult.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class AgentResult {\n\n    private String outputText;\n\n    private Object rawResponse;\n\n    private List<AgentToolCall> toolCalls;\n\n    private List<AgentToolResult> toolResults;\n\n    private Integer steps;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentRuntime.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\npublic interface AgentRuntime {\n\n    AgentResult run(AgentContext context, AgentRequest request) throws Exception;\n\n    void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception;\n\n    default AgentResult runStreamResult(AgentContext context, AgentRequest request, AgentListener listener) throws Exception {\n        runStream(context, request, listener);\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentSession.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\npublic class AgentSession {\n\n    private final AgentRuntime runtime;\n    private final AgentContext context;\n\n    public AgentSession(AgentRuntime runtime, AgentContext context) {\n        this.runtime = runtime;\n        this.context = context;\n    }\n\n    public AgentResult run(String input) throws Exception {\n        return runtime.run(context, AgentRequest.builder().input(input).build());\n    }\n\n    public AgentResult run(AgentRequest request) throws Exception {\n        return runtime.run(context, request);\n    }\n\n    public void runStream(AgentRequest request, AgentListener listener) throws Exception {\n        runtime.runStream(context, request, listener);\n    }\n\n    public AgentResult runStreamResult(AgentRequest request, AgentListener listener) throws Exception {\n        return runtime.runStreamResult(context, request, listener);\n    }\n\n    public AgentContext getContext() {\n        return context;\n    }\n\n    public AgentRuntime getRuntime() {\n        return runtime;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/Agents.java",
    "content": "package io.github.lnyocly.ai4j.agent;\n\nimport io.github.lnyocly.ai4j.agent.runtime.CodeActRuntime;\nimport io.github.lnyocly.ai4j.agent.runtime.DeepResearchRuntime;\nimport io.github.lnyocly.ai4j.agent.runtime.ReActRuntime;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamBuilder;\n\npublic final class Agents {\n\n    private Agents() {\n    }\n\n    public static AgentBuilder builder() {\n        return new AgentBuilder();\n    }\n\n    public static AgentBuilder react() {\n        return new AgentBuilder().runtime(new ReActRuntime());\n    }\n\n    public static AgentBuilder codeAct() {\n        return new AgentBuilder().runtime(new CodeActRuntime());\n    }\n\n    public static AgentBuilder deepResearch() {\n        return new AgentBuilder().runtime(new DeepResearchRuntime());\n    }\n\n    public static AgentTeamBuilder team() {\n        return AgentTeamBuilder.builder();\n    }\n\n    public static Agent teamAgent(AgentTeamBuilder builder) {\n        if (builder == null) {\n            throw new IllegalArgumentException(\"builder is required\");\n        }\n        return builder.buildAgent();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeActOptions.java",
    "content": "package io.github.lnyocly.ai4j.agent.codeact;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodeActOptions {\n\n    @Builder.Default\n    private boolean reAct = false;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionRequest.java",
    "content": "package io.github.lnyocly.ai4j.agent.codeact;\n\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\n@Builder\npublic class CodeExecutionRequest {\n\n    private String language;\n\n    private String code;\n\n    private List<String> toolNames;\n\n    private ToolExecutor toolExecutor;\n\n    private String user;\n\n    private Long timeoutMs;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionResult.java",
    "content": "package io.github.lnyocly.ai4j.agent.codeact;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder\npublic class CodeExecutionResult {\n\n    private String stdout;\n\n    private String result;\n\n    private String error;\n\n    public boolean isSuccess() {\n        return error == null || error.isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.agent.codeact;\n\npublic interface CodeExecutor {\n\n    CodeExecutionResult execute(CodeExecutionRequest request) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/GraalVmCodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.agent.codeact;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport org.graalvm.polyglot.Context;\nimport org.graalvm.polyglot.HostAccess;\nimport org.graalvm.polyglot.Value;\nimport org.graalvm.polyglot.proxy.ProxyExecutable;\nimport org.graalvm.polyglot.proxy.ProxyObject;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.PrintStream;\nimport java.nio.charset.StandardCharsets;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.Callable;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport java.util.regex.Pattern;\n\npublic class GraalVmCodeExecutor implements CodeExecutor {\n\n    private static final Logger log = LoggerFactory.getLogger(GraalVmCodeExecutor.class);\n    private static final Pattern IDENTIFIER = Pattern.compile(\"[A-Za-z_$][A-Za-z0-9_$]*\");\n    private static final long DEFAULT_TIMEOUT_MS = 8000L;\n\n    public GraalVmCodeExecutor() {\n    }\n\n    // Keep this constructor for compatibility with existing caller code.\n    public GraalVmCodeExecutor(String ignored) {\n    }\n\n    @Override\n    public CodeExecutionResult execute(CodeExecutionRequest request) {\n        if (request == null || request.getCode() == null) {\n            return CodeExecutionResult.builder().error(\"code is required\").build();\n        }\n\n        String language = normalizeLanguage(request.getLanguage());\n        if (!\"python\".equals(language)) {\n            return CodeExecutionResult.builder()\n                    .error(\"unsupported language: \" + request.getLanguage() + \", only python is enabled\")\n                    .build();\n        }\n\n        CodeExecutionResult pyResult = executePythonWithGraalPy(request);\n        if (pyResult == null) {\n            return CodeExecutionResult.builder()\n                    .error(\"Python engine not found (GraalPy). Ensure GraalPy runtime is available.\")\n                    .build();\n        }\n        return pyResult;\n    }\n\n    private String normalizeLanguage(String language) {\n        if (language == null || language.trim().isEmpty()) {\n            return \"python\";\n        }\n        String value = language.trim().toLowerCase();\n        if (\"py\".equals(value) || \"python3\".equals(value)) {\n            return \"python\";\n        }\n        return value;\n    }\n\n    private String buildPythonPrelude(List<String> toolNames) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"__codeact_result = None\\n\");\n        builder.append(\"def callTool(name, args=None, **kwargs):\\n\");\n        builder.append(\"    if args is None and kwargs:\\n\");\n        builder.append(\"        return tools.call(name, kwargs)\\n\");\n        builder.append(\"    if kwargs and isinstance(args, dict):\\n\");\n        builder.append(\"        merged = dict(args)\\n\");\n        builder.append(\"        merged.update(kwargs)\\n\");\n        builder.append(\"        return tools.call(name, merged)\\n\");\n        builder.append(\"    return tools.call(name, args)\\n\");\n        if (toolNames != null) {\n            for (String name : toolNames) {\n                if (name != null && IDENTIFIER.matcher(name).matches()) {\n                    builder.append(\"def \").append(name).append(\"(args=None, **kwargs):\\n\")\n                            .append(\"    if args is None and kwargs:\\n\")\n                            .append(\"        return tools.call(\\\"\")\n                            .append(escapePython(name)).append(\"\\\", kwargs)\\n\")\n                            .append(\"    if kwargs and isinstance(args, dict):\\n\")\n                            .append(\"        merged = dict(args)\\n\")\n                            .append(\"        merged.update(kwargs)\\n\")\n                            .append(\"        return tools.call(\\\"\")\n                            .append(escapePython(name)).append(\"\\\", merged)\\n\")\n                            .append(\"    return tools.call(\\\"\")\n                            .append(escapePython(name)).append(\"\\\", args)\\n\");\n                }\n            }\n        }\n        return builder.toString();\n    }\n\n    private String wrapPythonCode(String code) {\n        String[] lines = code.split(\"\\r?\\n\");\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"def __codeact_main():\\n\");\n        builder.append(\"    global __codeact_result\\n\");\n        if (lines.length == 0) {\n            builder.append(\"    pass\\n\");\n        } else {\n            for (String line : lines) {\n                builder.append(\"    \").append(line).append(\"\\n\");\n            }\n        }\n        builder.append(\"__codeact_tmp = __codeact_main()\\n\");\n        builder.append(\"if __codeact_tmp is not None:\\n\");\n        builder.append(\"    __codeact_result = __codeact_tmp\\n\");\n        return builder.toString();\n    }\n\n    private String escapePython(String text) {\n        return text.replace(\"\\\\\", \"\\\\\\\\\").replace(\"\\\"\", \"\\\\\\\"\");\n    }\n\n    private String trimError(String error) {\n        if (error == null || error.trim().isEmpty()) {\n            return null;\n        }\n        return error.trim();\n    }\n\n    private CodeExecutionResult executePythonWithGraalPy(CodeExecutionRequest request) {\n        ByteArrayOutputStream stdoutBytes = new ByteArrayOutputStream();\n        ByteArrayOutputStream stderrBytes = new ByteArrayOutputStream();\n        ToolExecutor toolExecutor = request.getToolExecutor();\n        ToolBridge toolBridge = new ToolBridge(toolExecutor, request.getUser());\n\n        ProxyExecutable callTool = new ProxyExecutable() {\n            @Override\n            public Object execute(Value... args) {\n                try {\n                    String name = args != null && args.length > 0 && args[0] != null ? args[0].asString() : null;\n                    Object payload = null;\n                    if (args != null && args.length > 1 && args[1] != null) {\n                        Value value = args[1];\n                        if (!value.isNull() && !isUndefined(value)) {\n                            if (value.isString()) {\n                                payload = value.asString();\n                            } else if (value.hasArrayElements()) {\n                                payload = value.as(List.class);\n                            } else if (value.hasMembers()) {\n                                payload = value.as(Map.class);\n                            } else if (value.isNumber()) {\n                                payload = value.as(Double.class);\n                            } else if (value.isBoolean()) {\n                                payload = value.asBoolean();\n                            } else {\n                                payload = value.toString();\n                            }\n                        }\n                    }\n                    return toolBridge.call(name, payload);\n                } catch (Exception e) {\n                    throw new RuntimeException(e);\n                }\n            }\n        };\n\n        Map<String, Object> toolMap = new HashMap<String, Object>();\n        toolMap.put(\"call\", callTool);\n        ProxyObject tools = ProxyObject.fromMap(toolMap);\n\n        Context context = null;\n        ExecutorService executor = Executors.newSingleThreadExecutor();\n        try {\n            HostAccess hostAccess = HostAccess.newBuilder()\n                    .allowAccessAnnotatedBy(HostAccess.Export.class)\n                    .build();\n            context = Context.newBuilder(\"python\")\n                    .allowHostAccess(hostAccess)\n                    .option(\"engine.WarnInterpreterOnly\", \"false\")\n                    .out(new PrintStream(stdoutBytes, true))\n                    .err(new PrintStream(stderrBytes, true))\n                    .build();\n            context.getBindings(\"python\").putMember(\"tools\", tools);\n\n            String prelude = buildPythonPrelude(request.getToolNames());\n            String wrapped = wrapPythonCode(request.getCode());\n            String script = prelude + \"\\n\" + wrapped;\n\n            Long timeoutMs = request.getTimeoutMs();\n            long timeout = timeoutMs == null ? DEFAULT_TIMEOUT_MS : timeoutMs;\n\n            final Context runContext = context;\n            final String runScript = script;\n            Future<Value> future = executor.submit(new Callable<Value>() {\n                @Override\n                public Value call() {\n                    return runContext.eval(\"python\", runScript);\n                }\n            });\n\n            Value value = future.get(timeout, TimeUnit.MILLISECONDS);\n            Value fallback = context.getBindings(\"python\").getMember(\"__codeact_result\");\n            String resolved = resolveValue(fallback);\n            if (resolved == null) {\n                resolved = resolveValue(value);\n            }\n\n            String stderrText = new String(stderrBytes.toByteArray(), StandardCharsets.UTF_8);\n            String filteredError = filterPolyglotWarnings(stderrText);\n            return CodeExecutionResult.builder()\n                    .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8))\n                    .result(resolved)\n                    .error(trimError(filteredError))\n                    .build();\n        } catch (IllegalArgumentException e) {\n            return null;\n        } catch (TimeoutException e) {\n            return CodeExecutionResult.builder()\n                    .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8))\n                    .error(\"code execution timeout\")\n                    .build();\n        } catch (ExecutionException e) {\n            Throwable cause = e.getCause() == null ? e : e.getCause();\n            log.warn(\"GraalPy execution failed\", cause);\n            return CodeExecutionResult.builder()\n                    .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8))\n                    .error(String.valueOf(cause.getMessage()))\n                    .build();\n        } catch (Throwable t) {\n            log.warn(\"GraalPy execution failed\", t);\n            return CodeExecutionResult.builder()\n                    .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8))\n                    .error(String.valueOf(t.getMessage()))\n                    .build();\n        } finally {\n            if (context != null) {\n                try {\n                    context.close(true);\n                } catch (Exception ignored) {\n                }\n            }\n            executor.shutdownNow();\n        }\n    }\n\n    private String resolveValue(Value value) {\n        if (value == null) {\n            return null;\n        }\n        if (value.isNull() || isUndefined(value)) {\n            return null;\n        }\n        if (value.isString()) {\n            return value.asString();\n        }\n        return value.toString();\n    }\n\n    private boolean isUndefined(Value value) {\n        if (value == null) {\n            return true;\n        }\n        try {\n            String text = value.toString();\n            return \"undefined\".equalsIgnoreCase(text) || \"null\".equalsIgnoreCase(text);\n        } catch (Exception e) {\n            return false;\n        }\n    }\n\n    private String filterPolyglotWarnings(String stderr) {\n        if (stderr == null || stderr.trim().isEmpty()) {\n            return stderr;\n        }\n        String[] lines = stderr.split(\"\\r?\\n\");\n        StringBuilder filtered = new StringBuilder();\n        for (String line : lines) {\n            if (line == null || line.trim().isEmpty()) {\n                continue;\n            }\n            String text = line.trim();\n            if (text.startsWith(\"[engine] WARNING:\")\n                    || text.contains(\"polyglot engine uses a fallback runtime\")\n                    || text.contains(\"JVMCI is not enabled\")\n                    || text.contains(\"WarnInterpreterOnly\")\n                    || text.startsWith(\"[To redirect Truffle log output\")\n                    || text.startsWith(\"* '--log.file=\")\n                    || text.startsWith(\"* '-Dpolyglot.log.file=\")\n                    || text.startsWith(\"* Configure logging using the polyglot embedding API\")\n                    || text.startsWith(\"Execution without runtime compilation will negatively impact\")\n                    || text.startsWith(\"For more information see:\")) {\n                continue;\n            }\n            if (filtered.length() > 0) {\n                filtered.append(\"\\n\");\n            }\n            filtered.append(text);\n        }\n        return filtered.length() == 0 ? null : filtered.toString();\n    }\n\n    private static class ToolBridge {\n        private final ToolExecutor toolExecutor;\n        private final String user;\n\n        private ToolBridge(ToolExecutor toolExecutor, String user) {\n            this.toolExecutor = toolExecutor;\n            this.user = user;\n        }\n\n        @HostAccess.Export\n        public String call(String name, Object args) throws Exception {\n            if (toolExecutor == null) {\n                throw new IllegalStateException(\"toolExecutor is required\");\n            }\n            String arguments;\n            if (args == null) {\n                arguments = \"{}\";\n            } else if (args instanceof String) {\n                arguments = (String) args;\n            } else {\n                arguments = JSON.toJSONString(args);\n            }\n            AgentToolCall call = AgentToolCall.builder()\n                    .name(resolveName(name))\n                    .arguments(arguments)\n                    .build();\n            return toolExecutor.execute(call);\n        }\n\n        private String resolveName(String name) {\n            if (user == null || user.trim().isEmpty()) {\n                return name;\n            }\n            return \"user_\" + user + \"_tool_\" + name;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/NashornCodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.agent.codeact;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.script.Bindings;\nimport javax.script.ScriptContext;\nimport javax.script.ScriptEngine;\nimport javax.script.ScriptEngineManager;\nimport javax.script.SimpleScriptContext;\nimport java.io.StringWriter;\nimport java.util.List;\nimport java.util.concurrent.Callable;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport java.util.regex.Pattern;\n\npublic class NashornCodeExecutor implements CodeExecutor {\n\n    private static final Logger log = LoggerFactory.getLogger(NashornCodeExecutor.class);\n    private static final Pattern IDENTIFIER = Pattern.compile(\"[A-Za-z_$][A-Za-z0-9_$]*\");\n    private static final long DEFAULT_TIMEOUT_MS = 8000L;\n\n    @Override\n    public CodeExecutionResult execute(CodeExecutionRequest request) {\n        if (request == null || request.getCode() == null) {\n            return CodeExecutionResult.builder().error(\"code is required\").build();\n        }\n\n        String language = normalizeLanguage(request.getLanguage());\n        if (!\"javascript\".equals(language)) {\n            return CodeExecutionResult.builder()\n                    .error(\"unsupported language: \" + request.getLanguage() + \", only javascript is enabled\")\n                    .build();\n        }\n\n        return executeJavaScript(request);\n    }\n\n    private String normalizeLanguage(String language) {\n        if (language == null || language.trim().isEmpty()) {\n            return \"javascript\";\n        }\n        String value = language.trim().toLowerCase();\n        if (\"js\".equals(value) || \"ecmascript\".equals(value)) {\n            return \"javascript\";\n        }\n        return value;\n    }\n\n    private CodeExecutionResult executeJavaScript(CodeExecutionRequest request) {\n        ScriptEngine engine = new ScriptEngineManager().getEngineByName(\"nashorn\");\n        if (engine == null) {\n            return CodeExecutionResult.builder()\n                    .error(\"Nashorn engine not found. Use JDK 8 or add nashorn engine dependency.\")\n                    .build();\n        }\n\n        StringWriter stdout = new StringWriter();\n        StringWriter stderr = new StringWriter();\n        ScriptContext context = new SimpleScriptContext();\n        context.setWriter(stdout);\n        context.setErrorWriter(stderr);\n        Bindings bindings = engine.createBindings();\n        bindings.put(\"__toolBridge\", new ToolBridge(request.getToolExecutor(), request.getUser()));\n        context.setBindings(bindings, ScriptContext.ENGINE_SCOPE);\n\n        String script = buildPrelude(request.getToolNames()) + \"\\n\" + wrapCode(request.getCode());\n        Long timeoutMs = request.getTimeoutMs();\n        long timeout = timeoutMs == null ? DEFAULT_TIMEOUT_MS : timeoutMs;\n\n        ExecutorService executor = Executors.newSingleThreadExecutor();\n        try {\n            Future<Object> future = executor.submit(new Callable<Object>() {\n                @Override\n                public Object call() throws Exception {\n                    return engine.eval(script, context);\n                }\n            });\n\n            Object value = future.get(timeout, TimeUnit.MILLISECONDS);\n            Object resultValue = bindings.get(\"__codeact_result\");\n            if (resultValue == null) {\n                resultValue = value;\n            }\n\n            String error = trimError(stderr.toString());\n            return CodeExecutionResult.builder()\n                    .stdout(stdout.toString())\n                    .result(resultValue == null ? null : String.valueOf(resultValue))\n                    .error(error)\n                    .build();\n        } catch (TimeoutException e) {\n            return CodeExecutionResult.builder()\n                    .stdout(stdout.toString())\n                    .error(\"code execution timeout\")\n                    .build();\n        } catch (ExecutionException e) {\n            Throwable cause = e.getCause() == null ? e : e.getCause();\n            log.warn(\"Nashorn execution failed\", cause);\n            return CodeExecutionResult.builder()\n                    .stdout(stdout.toString())\n                    .error(String.valueOf(cause.getMessage()))\n                    .build();\n        } catch (Throwable t) {\n            log.warn(\"Nashorn execution failed\", t);\n            return CodeExecutionResult.builder()\n                    .stdout(stdout.toString())\n                    .error(String.valueOf(t.getMessage()))\n                    .build();\n        } finally {\n            executor.shutdownNow();\n        }\n    }\n\n    private String buildPrelude(List<String> toolNames) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"var __codeact_result = null;\\n\");\n        builder.append(\"function __parseIfJson(value) {\\n\");\n        builder.append(\"  if (value == null) { return value; }\\n\");\n        builder.append(\"  var text = null;\\n\");\n        builder.append(\"  if (typeof value === 'string') { text = value; }\\n\");\n        builder.append(\"  else { try { text = String(value); } catch (e) { return value; } }\\n\");\n        builder.append(\"  text = text == null ? '' : text.trim();\\n\");\n        builder.append(\"  if (text.length < 2) { return value; }\\n\");\n        builder.append(\"  var quotedJson = text.charAt(0) === '\\\"' && text.charAt(text.length - 1) === '\\\"';\\n\");\n        builder.append(\"  var objJson = text.charAt(0) === '{' && text.charAt(text.length - 1) === '}';\\n\");\n        builder.append(\"  var arrJson = text.charAt(0) === '[' && text.charAt(text.length - 1) === ']';\\n\");\n        builder.append(\"  if (!quotedJson && !objJson && !arrJson) { return value; }\\n\");\n        builder.append(\"  try { return JSON.parse(text); } catch (e) { return value; }\\n\");\n        builder.append(\"}\\n\");\n        builder.append(\"function __normalizeToolResult(value) {\\n\");\n        builder.append(\"  var current = value;\\n\");\n        builder.append(\"  for (var i = 0; i < 3; i++) {\\n\");\n        builder.append(\"    var next = __parseIfJson(current);\\n\");\n        builder.append(\"    if (next === current) { break; }\\n\");\n        builder.append(\"    current = next;\\n\");\n        builder.append(\"  }\\n\");\n        builder.append(\"  return current;\\n\");\n        builder.append(\"}\\n\");\n        builder.append(\"function callTool(name, args) {\\n\");\n        builder.append(\"  var payload = args == null ? '{}': (typeof args === 'string' ? args : JSON.stringify(args));\\n\");\n        builder.append(\"  var raw = __toolBridge.call(String(name), payload);\\n\");\n        builder.append(\"  return __normalizeToolResult(raw);\\n\");\n        builder.append(\"}\\n\");\n\n        if (toolNames != null) {\n            for (String name : toolNames) {\n                if (name != null && IDENTIFIER.matcher(name).matches()) {\n                    builder.append(\"function \").append(name).append(\"(args) {\\n\")\n                            .append(\"  return callTool(\\\"\")\n                            .append(escapeJs(name)).append(\"\\\", args);\\n\")\n                            .append(\"}\\n\");\n                }\n            }\n        }\n        return builder.toString();\n    }\n\n    private String wrapCode(String code) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"var __codeact_return = (function __codeact_main__() {\\n\");\n        builder.append(code).append(\"\\n\");\n        builder.append(\"})();\\n\");\n        builder.append(\"if (__codeact_result == null && typeof __codeact_return !== 'undefined') {\\n\");\n        builder.append(\"  __codeact_result = __codeact_return;\\n\");\n        builder.append(\"}\\n\");\n        builder.append(\"__codeact_result;\\n\");\n        return builder.toString();\n    }\n\n    private String escapeJs(String text) {\n        return text.replace(\"\\\\\", \"\\\\\\\\\").replace(\"\\\"\", \"\\\\\\\"\");\n    }\n\n    private String trimError(String error) {\n        if (error == null || error.trim().isEmpty()) {\n            return null;\n        }\n        return error.trim();\n    }\n\n    public static class ToolBridge {\n        private final ToolExecutor toolExecutor;\n        private final String user;\n\n        private ToolBridge(ToolExecutor toolExecutor, String user) {\n            this.toolExecutor = toolExecutor;\n            this.user = user;\n        }\n\n        public String call(String name, String arguments) throws Exception {\n            if (toolExecutor == null) {\n                throw new IllegalStateException(\"toolExecutor is required\");\n            }\n            String payload = arguments == null || arguments.trim().isEmpty() ? \"{}\" : arguments;\n            AgentToolCall call = AgentToolCall.builder()\n                    .name(resolveName(name))\n                    .arguments(payload)\n                    .build();\n            return toolExecutor.execute(call);\n        }\n\n        private String resolveName(String name) {\n            if (user == null || user.trim().isEmpty()) {\n                return name;\n            }\n            return \"user_\" + user + \"_tool_\" + name;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEvent.java",
    "content": "package io.github.lnyocly.ai4j.agent.event;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class AgentEvent {\n\n    private AgentEventType type;\n\n    private Integer step;\n\n    private String message;\n\n    private Object payload;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEventPublisher.java",
    "content": "package io.github.lnyocly.ai4j.agent.event;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class AgentEventPublisher {\n\n    private final List<AgentListener> listeners = new ArrayList<>();\n\n    public AgentEventPublisher() {\n    }\n\n    public AgentEventPublisher(List<AgentListener> initial) {\n        if (initial != null) {\n            listeners.addAll(initial);\n        }\n    }\n\n    public void addListener(AgentListener listener) {\n        if (listener != null) {\n            listeners.add(listener);\n        }\n    }\n\n    public void publish(AgentEvent event) {\n        if (event == null) {\n            return;\n        }\n        for (AgentListener listener : listeners) {\n            try {\n                listener.onEvent(event);\n            } catch (Exception ignored) {\n                // Listener errors should not break agent execution.\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentEventType.java",
    "content": "package io.github.lnyocly.ai4j.agent.event;\n\npublic enum AgentEventType {\n    STEP_START,\n    STEP_END,\n    MODEL_REQUEST,\n    MODEL_RETRY,\n    MODEL_REASONING,\n    MODEL_RESPONSE,\n    TOOL_CALL,\n    TOOL_RESULT,\n    HANDOFF_START,\n    HANDOFF_END,\n    TEAM_TASK_CREATED,\n    TEAM_TASK_UPDATED,\n    TEAM_MESSAGE,\n    MEMORY_COMPRESS,\n    FINAL_OUTPUT,\n    ERROR\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/event/AgentListener.java",
    "content": "package io.github.lnyocly.ai4j.agent.event;\n\npublic interface AgentListener {\n\n    void onEvent(AgentEvent event);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/Ai4jFlowGramLlmNodeRunner.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentBuilder;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentRuntime;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.runtime.ReActRuntime;\nimport io.github.lnyocly.ai4j.agent.trace.TracePricing;\nimport io.github.lnyocly.ai4j.agent.trace.TracePricingResolver;\nimport java.util.ArrayList;\nimport java.lang.reflect.Field;\nimport java.lang.reflect.Method;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class Ai4jFlowGramLlmNodeRunner implements FlowGramLlmNodeRunner {\n\n    private final AgentModelClient defaultModelClient;\n    private final ModelClientResolver modelClientResolver;\n    private final AgentRuntime runtime;\n    private final AgentOptions agentOptions;\n    private final TracePricingResolver pricingResolver;\n\n    public Ai4jFlowGramLlmNodeRunner(AgentModelClient modelClient) {\n        this(modelClient, null, new ReActRuntime(), defaultOptions(), null);\n    }\n\n    public Ai4jFlowGramLlmNodeRunner(ModelClientResolver modelClientResolver) {\n        this(null, modelClientResolver, new ReActRuntime(), defaultOptions(), null);\n    }\n\n    public Ai4jFlowGramLlmNodeRunner(ModelClientResolver modelClientResolver,\n                                     TracePricingResolver pricingResolver) {\n        this(null, modelClientResolver, new ReActRuntime(), defaultOptions(), pricingResolver);\n    }\n\n    public Ai4jFlowGramLlmNodeRunner(AgentModelClient modelClient,\n                                     AgentRuntime runtime,\n                                     AgentOptions agentOptions) {\n        this(modelClient, null, runtime, agentOptions, null);\n    }\n\n    public Ai4jFlowGramLlmNodeRunner(AgentModelClient modelClient,\n                                     ModelClientResolver modelClientResolver,\n                                     AgentRuntime runtime,\n                                     AgentOptions agentOptions) {\n        this(modelClient, modelClientResolver, runtime, agentOptions, null);\n    }\n\n    public Ai4jFlowGramLlmNodeRunner(AgentModelClient modelClient,\n                                     ModelClientResolver modelClientResolver,\n                                     AgentRuntime runtime,\n                                     AgentOptions agentOptions,\n                                     TracePricingResolver pricingResolver) {\n        this.defaultModelClient = modelClient;\n        this.modelClientResolver = modelClientResolver;\n        this.runtime = runtime == null ? new ReActRuntime() : runtime;\n        this.agentOptions = agentOptions == null ? defaultOptions() : agentOptions;\n        this.pricingResolver = pricingResolver;\n    }\n\n    @Override\n    public Map<String, Object> run(FlowGramNodeSchema node, Map<String, Object> inputs) throws Exception {\n        AgentModelClient modelClient = resolveModelClient(node, inputs);\n        if (modelClient == null) {\n            throw new IllegalStateException(\"No AgentModelClient is available for FlowGram LLM node: \" + safeNodeId(node));\n        }\n\n        String model = firstNonBlank(\n                valueAsString(inputs == null ? null : inputs.get(\"modelName\")),\n                valueAsString(inputs == null ? null : inputs.get(\"model\")),\n                valueAsString(inputs == null ? null : inputs.get(\"modelId\"))\n        );\n        if (isBlank(model)) {\n            throw new IllegalArgumentException(\"FlowGram LLM node requires modelName/model input\");\n        }\n\n        String prompt = firstNonBlank(\n                valueAsString(inputs == null ? null : inputs.get(\"prompt\")),\n                valueAsString(inputs == null ? null : inputs.get(\"message\")),\n                valueAsString(inputs == null ? null : inputs.get(\"input\"))\n        );\n        if (isBlank(prompt)) {\n            throw new IllegalArgumentException(\"FlowGram LLM node requires prompt input\");\n        }\n\n        Agent agent = new AgentBuilder()\n                .runtime(runtime)\n                .modelClient(modelClient)\n                .model(model)\n                .systemPrompt(valueAsString(inputs == null ? null : inputs.get(\"systemPrompt\")))\n                .instructions(valueAsString(inputs == null ? null : inputs.get(\"instructions\")))\n                .temperature(valueAsDouble(inputs == null ? null : inputs.get(\"temperature\")))\n                .topP(valueAsDouble(inputs == null ? null : inputs.get(\"topP\")))\n                .maxOutputTokens(valueAsInteger(inputs == null ? null : inputs.get(\"maxOutputTokens\")))\n                .options(agentOptions)\n                .build();\n\n        long startedAt = System.currentTimeMillis();\n        AgentResult result = agent.run(AgentRequest.builder().input(prompt).build());\n        long durationMillis = Math.max(System.currentTimeMillis() - startedAt, 0L);\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        outputs.put(\"result\", result == null ? null : result.getOutputText());\n        outputs.put(\"outputText\", result == null ? null : result.getOutputText());\n        outputs.put(\"rawResponse\", result == null ? null : result.getRawResponse());\n        outputs.put(\"metrics\", buildMetrics(model, durationMillis, result == null ? null : result.getRawResponse()));\n        return outputs;\n    }\n\n    private Map<String, Object> buildMetrics(String model, long durationMillis, Object rawResponse) {\n        Map<String, Object> metrics = new LinkedHashMap<String, Object>();\n        metrics.put(\"durationMillis\", durationMillis);\n        if (!isBlank(model)) {\n            metrics.put(\"model\", model);\n        }\n        Object usage = propertyValue(normalizeTree(rawResponse), \"usage\");\n        if (usage == null) {\n            return metrics;\n        }\n        Long promptTokens = tokenValue(usage, \"promptTokens\", \"prompt_tokens\", \"input\");\n        Long completionTokens = tokenValue(usage, \"completionTokens\", \"completion_tokens\", \"output\");\n        Long totalTokens = tokenValue(usage, \"totalTokens\", \"total_tokens\", \"total\");\n        if (promptTokens != null) {\n            metrics.put(\"promptTokens\", promptTokens);\n        }\n        if (completionTokens != null) {\n            metrics.put(\"completionTokens\", completionTokens);\n        }\n        if (totalTokens != null) {\n            metrics.put(\"totalTokens\", totalTokens);\n        }\n        TracePricing pricing = pricingResolver == null || isBlank(model) ? null : pricingResolver.resolve(model);\n        if (pricing != null) {\n            Double inputCost = pricing.getInputCostPerMillionTokens() == null\n                    ? null\n                    : ((promptTokens == null ? 0L : promptTokens.longValue()) / 1000000D) * pricing.getInputCostPerMillionTokens();\n            Double outputCost = pricing.getOutputCostPerMillionTokens() == null\n                    ? null\n                    : ((completionTokens == null ? 0L : completionTokens.longValue()) / 1000000D) * pricing.getOutputCostPerMillionTokens();\n            Double totalCost = inputCost == null && outputCost == null\n                    ? null\n                    : (inputCost == null ? 0D : inputCost) + (outputCost == null ? 0D : outputCost);\n            if (inputCost != null) {\n                metrics.put(\"inputCost\", inputCost);\n            }\n            if (outputCost != null) {\n                metrics.put(\"outputCost\", outputCost);\n            }\n            if (totalCost != null) {\n                metrics.put(\"totalCost\", totalCost);\n            }\n            if (!isBlank(pricing.getCurrency())) {\n                metrics.put(\"currency\", pricing.getCurrency());\n            }\n        }\n        return metrics;\n    }\n\n    private Long tokenValue(Object usage, String camelName, String snakeName, String alias) {\n        return longObject(firstNonNull(\n                propertyValue(usage, camelName),\n                propertyValue(usage, snakeName),\n                propertyValue(usage, alias)\n        ));\n    }\n\n    private AgentModelClient resolveModelClient(FlowGramNodeSchema node, Map<String, Object> inputs) {\n        if (modelClientResolver != null) {\n            AgentModelClient resolved = modelClientResolver.resolve(node, inputs);\n            if (resolved != null) {\n                return resolved;\n            }\n        }\n        return defaultModelClient;\n    }\n\n    private static AgentOptions defaultOptions() {\n        return AgentOptions.builder()\n                .maxSteps(1)\n                .stream(false)\n                .build();\n    }\n\n    private String safeNodeId(FlowGramNodeSchema node) {\n        return node == null || isBlank(node.getId()) ? \"(unknown)\" : node.getId();\n    }\n\n    private static String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static Double valueAsDouble(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).doubleValue();\n        }\n        String text = String.valueOf(value).trim();\n        if (text.isEmpty()) {\n            return null;\n        }\n        try {\n            return Double.parseDouble(text);\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    private static Integer valueAsInteger(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).intValue();\n        }\n        String text = String.valueOf(value).trim();\n        if (text.isEmpty()) {\n            return null;\n        }\n        try {\n            return Integer.parseInt(text);\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Map<String, Object> mapValue(Object value) {\n        if (!(value instanceof Map)) {\n            return null;\n        }\n        Map<String, Object> copy = new LinkedHashMap<String, Object>();\n        Map<?, ?> source = (Map<?, ?>) value;\n        for (Map.Entry<?, ?> entry : source.entrySet()) {\n            copy.put(String.valueOf(entry.getKey()), entry.getValue());\n        }\n        return copy;\n    }\n\n    private static Object firstNonNull(Object... values) {\n        if (values == null) {\n            return null;\n        }\n        for (Object value : values) {\n            if (value != null) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Object propertyValue(Object source, String name) {\n        if (source == null || isBlank(name)) {\n            return null;\n        }\n        Object normalized = normalizeTree(source);\n        if (normalized instanceof Map) {\n            return ((Map<String, Object>) normalized).get(name);\n        }\n        Object value = invokeAccessor(normalized, \"get\" + Character.toUpperCase(name.charAt(0)) + name.substring(1));\n        if (value != null) {\n            return value;\n        }\n        value = invokeAccessor(normalized, \"is\" + Character.toUpperCase(name.charAt(0)) + name.substring(1));\n        if (value != null) {\n            return value;\n        }\n        return fieldValue(normalized, name);\n    }\n\n    private static Object normalizedSource(Object source) {\n        if (source == null || source instanceof Map || source instanceof List\n                || source instanceof String || source instanceof Number || source instanceof Boolean) {\n            return source;\n        }\n        try {\n            return JSON.parseObject(JSON.toJSONString(source));\n        } catch (RuntimeException ignored) {\n            return source;\n        }\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Object normalizeTree(Object source) {\n        Object normalized = normalizedSource(source);\n        if (normalized == null || normalized instanceof String\n                || normalized instanceof Number || normalized instanceof Boolean) {\n            return normalized;\n        }\n        if (normalized instanceof Map) {\n            Map<String, Object> copy = new LinkedHashMap<String, Object>();\n            Map<?, ?> sourceMap = (Map<?, ?>) normalized;\n            for (Map.Entry<?, ?> entry : sourceMap.entrySet()) {\n                copy.put(String.valueOf(entry.getKey()), normalizeTree(entry.getValue()));\n            }\n            return copy;\n        }\n        if (normalized instanceof List) {\n            List<Object> copy = new ArrayList<Object>();\n            for (Object item : (List<Object>) normalized) {\n                copy.add(normalizeTree(item));\n            }\n            return copy;\n        }\n        return normalized;\n    }\n\n    private static Object invokeAccessor(Object source, String methodName) {\n        if (source == null || isBlank(methodName)) {\n            return null;\n        }\n        try {\n            Method method = source.getClass().getMethod(methodName);\n            method.setAccessible(true);\n            return method.invoke(source);\n        } catch (Exception ignored) {\n            // fall through\n        }\n        Class<?> type = source.getClass();\n        while (type != null && type != Object.class) {\n            try {\n                Method method = type.getDeclaredMethod(methodName);\n                method.setAccessible(true);\n                return method.invoke(source);\n            } catch (Exception ignored) {\n                type = type.getSuperclass();\n            }\n        }\n        return null;\n    }\n\n    private static Object fieldValue(Object source, String name) {\n        if (source == null || isBlank(name)) {\n            return null;\n        }\n        Class<?> type = source.getClass();\n        while (type != null && type != Object.class) {\n            try {\n                Field field = type.getDeclaredField(name);\n                field.setAccessible(true);\n                return field.get(source);\n            } catch (Exception ignored) {\n                type = type.getSuperclass();\n            }\n        }\n        return null;\n    }\n\n    private static Long longObject(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return Long.valueOf(((Number) value).longValue());\n        }\n        try {\n            return Long.valueOf(Long.parseLong(String.valueOf(value)));\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    public interface ModelClientResolver {\n        AgentModelClient resolve(FlowGramNodeSchema node, Map<String, Object> inputs);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramLlmNodeRunner.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\n\nimport java.util.Map;\n\npublic interface FlowGramLlmNodeRunner {\n\n    Map<String, Object> run(FlowGramNodeSchema node, Map<String, Object> inputs) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramNodeExecutionContext.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramNodeExecutionContext {\n\n    private String taskId;\n    private FlowGramNodeSchema node;\n    private Map<String, Object> inputs;\n    private Map<String, Object> taskInputs;\n    private Map<String, Object> nodeOutputs;\n    private Map<String, Object> locals;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramNodeExecutionResult.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramNodeExecutionResult {\n\n    private Map<String, Object> outputs;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramNodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\npublic interface FlowGramNodeExecutor {\n\n    String getType();\n\n    FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramRuntimeEvent.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramRuntimeEvent {\n\n    private Type type;\n    private long timestamp;\n    private String taskId;\n    private String nodeId;\n    private String status;\n    private String error;\n\n    public enum Type {\n        TASK_STARTED,\n        TASK_FINISHED,\n        TASK_FAILED,\n        TASK_CANCELED,\n        NODE_STARTED,\n        NODE_FINISHED,\n        NODE_FAILED,\n        NODE_CANCELED\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramRuntimeListener.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\npublic interface FlowGramRuntimeListener {\n\n    void onEvent(FlowGramRuntimeEvent event);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramRuntimeService.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramEdgeSchema;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskCancelOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskResultOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunInput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskValidateOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramWorkflowSchema;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\nimport java.util.concurrent.CopyOnWriteArrayList;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.ThreadFactory;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class FlowGramRuntimeService implements AutoCloseable {\n\n    private static final String NODE_TYPE_START = \"START\";\n    private static final String NODE_TYPE_END = \"END\";\n    private static final String NODE_TYPE_LLM = \"LLM\";\n    private static final String NODE_TYPE_CONDITION = \"CONDITION\";\n    private static final String NODE_TYPE_LOOP = \"LOOP\";\n\n    private static final String STATUS_PENDING = \"pending\";\n    private static final String STATUS_PROCESSING = \"processing\";\n    private static final String STATUS_SUCCESS = \"success\";\n    private static final String STATUS_FAILED = \"failed\";\n    private static final String STATUS_CANCELED = \"canceled\";\n\n    private final FlowGramLlmNodeRunner llmNodeRunner;\n    private final ExecutorService executorService;\n    private final boolean ownsExecutor;\n    private final List<FlowGramRuntimeListener> listeners = new CopyOnWriteArrayList<FlowGramRuntimeListener>();\n    private final ConcurrentMap<String, TaskRecord> tasks = new ConcurrentHashMap<String, TaskRecord>();\n    private final ConcurrentMap<String, FlowGramNodeExecutor> customExecutors =\n            new ConcurrentHashMap<String, FlowGramNodeExecutor>();\n\n    public FlowGramRuntimeService(FlowGramLlmNodeRunner llmNodeRunner) {\n        this(llmNodeRunner, createExecutor(), true);\n    }\n\n    public FlowGramRuntimeService(FlowGramLlmNodeRunner llmNodeRunner, ExecutorService executorService) {\n        this(llmNodeRunner, executorService, false);\n    }\n\n    private FlowGramRuntimeService(FlowGramLlmNodeRunner llmNodeRunner,\n                                   ExecutorService executorService,\n                                   boolean ownsExecutor) {\n        this.llmNodeRunner = llmNodeRunner;\n        this.executorService = executorService == null ? createExecutor() : executorService;\n        this.ownsExecutor = executorService == null || ownsExecutor;\n    }\n\n    public FlowGramRuntimeService registerNodeExecutor(FlowGramNodeExecutor executor) {\n        if (executor == null || isBlank(executor.getType())) {\n            return this;\n        }\n        customExecutors.put(normalizeType(executor.getType()), executor);\n        return this;\n    }\n\n    public FlowGramRuntimeService registerListener(FlowGramRuntimeListener listener) {\n        if (listener == null) {\n            return this;\n        }\n        listeners.add(listener);\n        return this;\n    }\n\n    public FlowGramTaskRunOutput runTask(FlowGramTaskRunInput input) {\n        ParsedTask parsed = parseAndValidate(input);\n        String taskId = UUID.randomUUID().toString();\n        TaskRecord record = new TaskRecord(taskId, parsed.schema, parsed.nodeIndex, safeMap(input == null ? null : input.getInputs()));\n        tasks.put(taskId, record);\n        record.future = executorService.submit(new Runnable() {\n            @Override\n            public void run() {\n                executeTask(record, parsed.startNode);\n            }\n        });\n        return FlowGramTaskRunOutput.builder().taskID(taskId).build();\n    }\n\n    public FlowGramTaskValidateOutput validateTask(FlowGramTaskRunInput input) {\n        List<String> errors = validateInternal(input);\n        return FlowGramTaskValidateOutput.builder()\n                .valid(errors.isEmpty())\n                .errors(errors)\n                .build();\n    }\n\n    public FlowGramTaskReportOutput getTaskReport(String taskId) {\n        TaskRecord record = tasks.get(taskId);\n        return record == null ? null : record.toReport();\n    }\n\n    public FlowGramTaskResultOutput getTaskResult(String taskId) {\n        TaskRecord record = tasks.get(taskId);\n        return record == null ? null : record.toResult();\n    }\n\n    public FlowGramTaskCancelOutput cancelTask(String taskId) {\n        TaskRecord record = tasks.get(taskId);\n        if (record == null) {\n            return FlowGramTaskCancelOutput.builder().success(false).build();\n        }\n        record.cancelRequested.set(true);\n        Future<?> future = record.future;\n        if (future != null) {\n            future.cancel(true);\n        }\n        return FlowGramTaskCancelOutput.builder().success(true).build();\n    }\n\n    @Override\n    public void close() {\n        if (ownsExecutor) {\n            executorService.shutdownNow();\n        }\n    }\n\n    private void executeTask(TaskRecord record, FlowGramNodeSchema startNode) {\n        record.updateWorkflow(STATUS_PROCESSING, false, null);\n        publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_STARTED, STATUS_PROCESSING, null);\n        try {\n            GraphSegment graph = GraphSegment.root(record.schema);\n            executeFromNode(record, graph, startNode.getId(), Collections.<String, Object>emptyMap(), new LinkedHashSet<String>());\n            if (record.cancelRequested.get()) {\n                record.updateWorkflow(STATUS_CANCELED, true, null);\n                publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_CANCELED, STATUS_CANCELED, null);\n                return;\n            }\n            if (record.result == null) {\n                throw new IllegalStateException(\"FlowGram workflow finished without reaching an End node\");\n            }\n            record.updateWorkflow(STATUS_SUCCESS, true, null);\n            publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_FINISHED, STATUS_SUCCESS, null);\n        } catch (InterruptedException ex) {\n            Thread.currentThread().interrupt();\n            record.updateWorkflow(STATUS_CANCELED, true, null);\n            publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_CANCELED, STATUS_CANCELED, null);\n        } catch (Exception ex) {\n            String error = safeMessage(ex);\n            record.updateWorkflow(STATUS_FAILED, true, error);\n            publishTaskEvent(record, FlowGramRuntimeEvent.Type.TASK_FAILED, STATUS_FAILED, error);\n        }\n    }\n\n    private Map<String, Object> executeFromNode(TaskRecord record,\n                                                GraphSegment graph,\n                                                String nodeId,\n                                                Map<String, Object> locals,\n                                                Set<String> activePath) throws Exception {\n        checkCanceled(record);\n        if (isBlank(nodeId)) {\n            return Collections.emptyMap();\n        }\n        if (!activePath.add(nodeId)) {\n            throw new IllegalStateException(\"Cycle detected in FlowGram graph at node \" + nodeId);\n        }\n        try {\n            FlowGramNodeSchema node = graph.getNode(nodeId);\n            if (node == null) {\n                throw new IllegalArgumentException(\"Node not found in graph: \" + nodeId);\n            }\n            Map<String, Object> outputs = executeNode(record, graph, node, locals);\n            List<FlowGramEdgeSchema> nextEdges = selectNextEdges(record, graph, node, outputs);\n            Map<String, Object> last = outputs;\n            for (FlowGramEdgeSchema edge : nextEdges) {\n                last = executeFromNode(record, graph, edge.getTargetNodeID(), locals, new LinkedHashSet<String>(activePath));\n            }\n            return last;\n        } finally {\n            activePath.remove(nodeId);\n        }\n    }\n\n    private Map<String, Object> executeNode(TaskRecord record,\n                                            GraphSegment graph,\n                                            FlowGramNodeSchema node,\n                                            Map<String, Object> locals) throws Exception {\n        checkCanceled(record);\n        String nodeId = node.getId();\n        record.recordNodeOutputs(nodeId, Collections.<String, Object>emptyMap());\n        record.updateNode(nodeId, STATUS_PROCESSING, null, false);\n        publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_STARTED, STATUS_PROCESSING, null);\n        try {\n            Map<String, Object> outputs;\n            String type = normalizeType(node.getType());\n            if (NODE_TYPE_START.equals(type)) {\n                outputs = executeStartNode(record, node);\n            } else if (NODE_TYPE_END.equals(type)) {\n                outputs = executeEndNode(record, node, locals, graph.isTerminalResultGraph());\n            } else if (NODE_TYPE_LLM.equals(type)) {\n                outputs = executeLlmNode(record, node, locals);\n            } else if (NODE_TYPE_CONDITION.equals(type)) {\n                outputs = executeConditionNode(record, node, locals);\n            } else if (NODE_TYPE_LOOP.equals(type)) {\n                outputs = executeLoopNode(record, node, locals);\n            } else {\n                outputs = executeCustomNode(record, node, locals);\n            }\n            Map<String, Object> safeOutputs = safeMap(outputs);\n            record.recordNodeOutputs(nodeId, safeOutputs);\n            record.updateNode(nodeId, STATUS_SUCCESS, null, true);\n            publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_FINISHED, STATUS_SUCCESS, null);\n            return safeOutputs;\n        } catch (InterruptedException ex) {\n            record.recordNodeOutputs(nodeId, Collections.<String, Object>emptyMap());\n            record.updateNode(nodeId, STATUS_CANCELED, null, true);\n            publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_CANCELED, STATUS_CANCELED, null);\n            throw ex;\n        } catch (Exception ex) {\n            if (record.cancelRequested.get()) {\n                record.recordNodeOutputs(nodeId, Collections.<String, Object>emptyMap());\n                record.updateNode(nodeId, STATUS_CANCELED, null, true);\n                publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_CANCELED, STATUS_CANCELED, null);\n                throw new InterruptedException(\"FlowGram task canceled\");\n            }\n            record.recordNodeOutputs(nodeId, Collections.<String, Object>emptyMap());\n            String error = safeMessage(ex);\n            record.updateNode(nodeId, STATUS_FAILED, error, true);\n            publishNodeEvent(record, nodeId, FlowGramRuntimeEvent.Type.NODE_FAILED, STATUS_FAILED, error);\n            throw ex;\n        }\n    }\n\n    private void publishTaskEvent(TaskRecord record,\n                                  FlowGramRuntimeEvent.Type type,\n                                  String status,\n                                  String error) {\n        if (record == null || type == null) {\n            return;\n        }\n        publishEvent(FlowGramRuntimeEvent.builder()\n                .type(type)\n                .timestamp(System.currentTimeMillis())\n                .taskId(record.taskId)\n                .status(status)\n                .error(error)\n                .build());\n    }\n\n    private void publishNodeEvent(TaskRecord record,\n                                  String nodeId,\n                                  FlowGramRuntimeEvent.Type type,\n                                  String status,\n                                  String error) {\n        if (record == null || type == null || isBlank(nodeId)) {\n            return;\n        }\n        publishEvent(FlowGramRuntimeEvent.builder()\n                .type(type)\n                .timestamp(System.currentTimeMillis())\n                .taskId(record.taskId)\n                .nodeId(nodeId)\n                .status(status)\n                .error(error)\n                .build());\n    }\n\n    private void publishEvent(FlowGramRuntimeEvent event) {\n        if (event == null || listeners.isEmpty()) {\n            return;\n        }\n        for (FlowGramRuntimeListener listener : listeners) {\n            if (listener == null) {\n                continue;\n            }\n            try {\n                listener.onEvent(event);\n            } catch (RuntimeException ignored) {\n                // Listener failures must not break workflow execution.\n            }\n        }\n    }\n\n    private Map<String, Object> executeStartNode(TaskRecord record, FlowGramNodeSchema node) {\n        record.recordNodeInputs(node.getId(), record.taskInputs);\n        Map<String, Object> outputSchema = schemaMap(node, \"outputs\");\n        Map<String, Object> outputs = applyObjectDefaults(outputSchema, record.taskInputs);\n        validateObjectSchema(\"Start node \" + safeNodeId(node) + \" inputs\", outputSchema, outputs);\n        return outputs;\n    }\n\n    private Map<String, Object> executeEndNode(TaskRecord record,\n                                               FlowGramNodeSchema node,\n                                               Map<String, Object> locals,\n                                               boolean terminalResultGraph) {\n        Map<String, Object> inputs = resolveInputs(record, node, locals);\n        record.recordNodeInputs(node.getId(), inputs);\n        Map<String, Object> inputSchema = schemaMap(node, \"inputs\");\n        validateObjectSchema(\"End node \" + safeNodeId(node) + \" inputs\", inputSchema, inputs);\n        Map<String, Object> result = safeMap(inputs);\n        if (terminalResultGraph) {\n            record.result = result;\n        }\n        return result;\n    }\n\n    private Map<String, Object> executeLlmNode(TaskRecord record,\n                                               FlowGramNodeSchema node,\n                                               Map<String, Object> locals) throws Exception {\n        if (llmNodeRunner == null) {\n            throw new IllegalStateException(\"FlowGram LLM node runner is not configured\");\n        }\n        Map<String, Object> inputs = resolveInputs(record, node, locals);\n        record.recordNodeInputs(node.getId(), inputs);\n        Map<String, Object> inputSchema = schemaMap(node, \"inputs\");\n        validateObjectSchema(\"LLM node \" + safeNodeId(node) + \" inputs\", inputSchema, inputs);\n        Map<String, Object> outputs = llmNodeRunner.run(node, inputs);\n        Map<String, Object> outputSchema = schemaMap(node, \"outputs\");\n        validateObjectSchema(\"LLM node \" + safeNodeId(node) + \" outputs\", outputSchema, outputs);\n        return outputs;\n    }\n\n    private Map<String, Object> executeConditionNode(TaskRecord record,\n                                                     FlowGramNodeSchema node,\n                                                     Map<String, Object> locals) {\n        Map<String, Object> inputs = resolveInputs(record, node, locals);\n        record.recordNodeInputs(node.getId(), inputs);\n        Map<String, Object> inputSchema = schemaMap(node, \"inputs\");\n        validateObjectSchema(\"Condition node \" + safeNodeId(node) + \" inputs\", inputSchema, inputs);\n        String matchedBranch = resolveConditionBranch(record, node, locals, inputs);\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        outputs.put(\"branchKey\", matchedBranch);\n        outputs.putAll(inputs);\n        return outputs;\n    }\n\n    private Map<String, Object> executeLoopNode(TaskRecord record,\n                                                FlowGramNodeSchema node,\n                                                Map<String, Object> locals) throws Exception {\n        Map<String, Object> inputs = resolveInputs(record, node, locals);\n        record.recordNodeInputs(node.getId(), inputs);\n        Map<String, Object> inputSchema = schemaMap(node, \"inputs\");\n        validateObjectSchema(\"Loop node \" + safeNodeId(node) + \" inputs\", inputSchema, inputs);\n\n        Object loopFor = inputs.get(\"loopFor\");\n        if (!(loopFor instanceof List)) {\n            throw new IllegalArgumentException(\"Loop node \" + safeNodeId(node) + \" requires loopFor to be an array\");\n        }\n        List<?> items = (List<?>) loopFor;\n        Map<String, List<Object>> aggregates = new LinkedHashMap<String, List<Object>>();\n        Map<String, Object> loopOutputDefs = mapValue(firstNonNull(dataValue(node, \"loopOutputs\"), dataValue(node, \"outputsValues\")));\n        GraphSegment loopGraph = GraphSegment.loop(node);\n\n        for (int i = 0; i < items.size(); i++) {\n            checkCanceled(record);\n            Map<String, Object> iterationLocals = new LinkedHashMap<String, Object>(safeMap(locals));\n            Map<String, Object> loopLocals = new LinkedHashMap<String, Object>();\n            loopLocals.put(\"item\", copyValue(items.get(i)));\n            loopLocals.put(\"index\", i);\n            iterationLocals.put(node.getId() + \"_locals\", loopLocals);\n\n            if (!loopGraph.isEmpty()) {\n                List<String> entryNodeIds = loopGraph.entryNodeIds();\n                for (String entryNodeId : entryNodeIds) {\n                    executeFromNode(record, loopGraph, entryNodeId, iterationLocals, new LinkedHashSet<String>());\n                }\n            }\n\n            if (loopOutputDefs != null) {\n                for (Map.Entry<String, Object> entry : loopOutputDefs.entrySet()) {\n                    List<Object> values = aggregates.get(entry.getKey());\n                    if (values == null) {\n                        values = new ArrayList<Object>();\n                        aggregates.put(entry.getKey(), values);\n                    }\n                    values.add(copyValue(evaluateValue(entry.getValue(), record, iterationLocals)));\n                }\n            }\n        }\n\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        for (Map.Entry<String, List<Object>> entry : aggregates.entrySet()) {\n            outputs.put(entry.getKey(), entry.getValue());\n        }\n        Map<String, Object> outputSchema = schemaMap(node, \"outputs\");\n        validateObjectSchema(\"Loop node \" + safeNodeId(node) + \" outputs\", outputSchema, outputs);\n        return outputs;\n    }\n\n\n    private Map<String, Object> executeCustomNode(TaskRecord record,\n                                                  FlowGramNodeSchema node,\n                                                  Map<String, Object> locals) throws Exception {\n        FlowGramNodeExecutor executor = customExecutors.get(normalizeType(node.getType()));\n        if (executor == null) {\n            throw new IllegalArgumentException(\"Unsupported FlowGram node type: \" + node.getType());\n        }\n        Map<String, Object> inputs = resolveInputs(record, node, locals);\n        record.recordNodeInputs(node.getId(), inputs);\n        FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder()\n                .taskId(record.taskId)\n                .node(node)\n                .inputs(inputs)\n                .taskInputs(copyMap(record.taskInputs))\n                .nodeOutputs(copyMap(record.nodeOutputs))\n                .locals(copyMap(locals))\n                .build());\n        return result == null ? Collections.<String, Object>emptyMap() : safeMap(result.getOutputs());\n    }\n\n    private List<FlowGramEdgeSchema> selectNextEdges(TaskRecord record,\n                                                     GraphSegment graph,\n                                                     FlowGramNodeSchema node,\n                                                     Map<String, Object> outputs) {\n        if (NODE_TYPE_END.equals(normalizeType(node.getType()))) {\n            return Collections.emptyList();\n        }\n        List<FlowGramEdgeSchema> outgoing = graph.outgoing(node.getId());\n        if (outgoing.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (!NODE_TYPE_CONDITION.equals(normalizeType(node.getType()))) {\n            return outgoing;\n        }\n        String branchKey = valueAsString(outputs.get(\"branchKey\"));\n        if (isBlank(branchKey)) {\n            return Collections.emptyList();\n        }\n        List<FlowGramEdgeSchema> matched = new ArrayList<FlowGramEdgeSchema>();\n        for (FlowGramEdgeSchema edge : outgoing) {\n            String edgeKey = firstNonBlank(edge.getSourcePortID(), edge.getSourcePort());\n            if (branchKey.equals(edgeKey)) {\n                matched.add(edge);\n            }\n        }\n        return matched;\n    }\n\n    private String resolveConditionBranch(TaskRecord record,\n                                          FlowGramNodeSchema node,\n                                          Map<String, Object> locals,\n                                          Map<String, Object> inputs) {\n        Object conditionsObject = dataValue(node, \"conditions\");\n        if (conditionsObject instanceof List) {\n            @SuppressWarnings(\"unchecked\")\n            List<Object> rules = (List<Object>) conditionsObject;\n            for (Object ruleObject : rules) {\n                Map<String, Object> rule = mapValue(ruleObject);\n                if (rule == null || !conditionMatches(rule, record, locals, inputs)) {\n                    continue;\n                }\n                String branchKey = firstNonBlank(\n                        valueAsString(rule.get(\"branchKey\")),\n                        valueAsString(rule.get(\"key\")),\n                        valueAsString(rule.get(\"sourcePortID\")),\n                        valueAsString(rule.get(\"sourcePort\"))\n                );\n                if (!isBlank(branchKey)) {\n                    return branchKey;\n                }\n            }\n        }\n\n        Object explicit = firstNonNull(inputs.get(\"branchKey\"), inputs.get(\"sourcePort\"), inputs.get(\"sourcePortID\"));\n        if (explicit != null) {\n            return String.valueOf(explicit);\n        }\n        Object passed = firstNonNull(inputs.get(\"passed\"), inputs.get(\"matched\"), inputs.get(\"condition\"));\n        if (passed instanceof Boolean) {\n            return Boolean.TRUE.equals(passed) ? \"true\" : \"false\";\n        }\n        throw new IllegalArgumentException(\"Condition node \" + safeNodeId(node) + \" did not resolve any branch\");\n    }\n\n    private boolean conditionMatches(Map<String, Object> rule,\n                                     TaskRecord record,\n                                     Map<String, Object> locals,\n                                     Map<String, Object> inputs) {\n        String operator = normalizeOperator(valueAsString(rule.get(\"operator\")));\n        if (\"DEFAULT\".equals(operator)) {\n            return true;\n        }\n\n        Object left = firstNonNull(\n                evaluateConditionOperand(rule.get(\"left\"), record, locals, inputs),\n                evaluateConditionOperand(rule.get(\"leftValue\"), record, locals, inputs),\n                resolveInputValue(inputs, valueAsString(firstNonNull(rule.get(\"leftKey\"), rule.get(\"inputKey\"))))\n        );\n        Object right = firstNonNull(\n                evaluateConditionOperand(rule.get(\"right\"), record, locals, inputs),\n                evaluateConditionOperand(rule.get(\"rightValue\"), record, locals, inputs),\n                rule.get(\"value\")\n        );\n        return compareCondition(left, operator, right);\n    }\n\n    private Object evaluateConditionOperand(Object operand,\n                                            TaskRecord record,\n                                            Map<String, Object> locals,\n                                            Map<String, Object> inputs) {\n        if (operand == null) {\n            return null;\n        }\n        if (operand instanceof Map) {\n            return evaluateValue(operand, record, locals);\n        }\n        String key = valueAsString(operand);\n        if (!isBlank(key) && inputs.containsKey(key)) {\n            return inputs.get(key);\n        }\n        return operand;\n    }\n\n    private boolean compareCondition(Object left, String operator, Object right) {\n        if (operator == null) {\n            operator = \"EQ\";\n        }\n        if (\"TRUTHY\".equals(operator)) {\n            return truthy(left);\n        }\n        if (\"FALSY\".equals(operator)) {\n            return !truthy(left);\n        }\n        if (\"EQ\".equals(operator) || \"==\".equals(operator)) {\n            return valuesEqual(left, right);\n        }\n        if (\"NE\".equals(operator) || \"!=\".equals(operator)) {\n            return !valuesEqual(left, right);\n        }\n\n        Double leftNumber = valueAsDouble(left);\n        Double rightNumber = valueAsDouble(right);\n        if (leftNumber != null && rightNumber != null) {\n            if (\"GT\".equals(operator) || \">\".equals(operator)) {\n                return leftNumber > rightNumber;\n            }\n            if (\"GTE\".equals(operator) || \">=\".equals(operator)) {\n                return leftNumber >= rightNumber;\n            }\n            if (\"LT\".equals(operator) || \"<\".equals(operator)) {\n                return leftNumber < rightNumber;\n            }\n            if (\"LTE\".equals(operator) || \"<=\".equals(operator)) {\n                return leftNumber <= rightNumber;\n            }\n        }\n\n        String leftText = valueAsString(left);\n        String rightText = valueAsString(right);\n        if (\"CONTAINS\".equals(operator)) {\n            return leftText != null && rightText != null && leftText.contains(rightText);\n        }\n        if (\"STARTS_WITH\".equals(operator)) {\n            return leftText != null && rightText != null && leftText.startsWith(rightText);\n        }\n        if (\"ENDS_WITH\".equals(operator)) {\n            return leftText != null && rightText != null && leftText.endsWith(rightText);\n        }\n        return valuesEqual(left, right);\n    }\n\n    private Map<String, Object> resolveInputs(TaskRecord record,\n                                              FlowGramNodeSchema node,\n                                              Map<String, Object> locals) {\n        Map<String, Object> rawInputs = mapValue(dataValue(node, \"inputsValues\"));\n        Map<String, Object> resolved = new LinkedHashMap<String, Object>();\n        if (rawInputs != null) {\n            for (Map.Entry<String, Object> entry : rawInputs.entrySet()) {\n                resolved.put(entry.getKey(), copyValue(evaluateValue(entry.getValue(), record, locals)));\n            }\n        }\n        return applyObjectDefaults(schemaMap(node, \"inputs\"), resolved);\n    }\n\n    private Object evaluateValue(Object value, TaskRecord record, Map<String, Object> locals) {\n        if (!(value instanceof Map)) {\n            return copyValue(value);\n        }\n        Map<String, Object> valueMap = mapValue(value);\n        if (valueMap == null) {\n            return copyValue(value);\n        }\n        String type = normalizeType(valueAsString(valueMap.get(\"type\")));\n        if (\"REF\".equals(type)) {\n            return resolveReference(valueMap.get(\"content\"), record, locals);\n        }\n        if (\"CONSTANT\".equals(type)) {\n            return copyValue(valueMap.get(\"content\"));\n        }\n        if (\"TEMPLATE\".equals(type)) {\n            return renderTemplate(valueAsString(valueMap.get(\"content\")), record, locals);\n        }\n        if (\"EXPRESSION\".equals(type)) {\n            return evaluateExpression(valueAsString(valueMap.get(\"content\")), record, locals);\n        }\n        return copyValue(valueMap.get(\"content\"));\n    }\n\n    private Object resolveReference(Object content, TaskRecord record, Map<String, Object> locals) {\n        List<Object> path = objectList(content);\n        if (path.isEmpty()) {\n            return null;\n        }\n        Object current = resolveRootReference(path.get(0), record, locals);\n        for (int i = 1; i < path.size(); i++) {\n            current = descend(current, path.get(i));\n            if (current == null) {\n                return null;\n            }\n        }\n        return current;\n    }\n\n    private Object resolveRootReference(Object segment, TaskRecord record, Map<String, Object> locals) {\n        String key = valueAsString(segment);\n        if (isBlank(key)) {\n            return null;\n        }\n        if (locals != null && locals.containsKey(key)) {\n            return locals.get(key);\n        }\n        if (\"inputs\".equals(key) || \"taskInputs\".equals(key) || \"$inputs\".equals(key)) {\n            return record.taskInputs;\n        }\n        return record.nodeOutputs.get(key);\n    }\n\n    private Object evaluateExpression(String expression, TaskRecord record, Map<String, Object> locals) {\n        if (isBlank(expression)) {\n            return expression;\n        }\n        String trimmed = expression.trim();\n        if (trimmed.startsWith(\"${\") && trimmed.endsWith(\"}\")) {\n            return resolvePathExpression(trimmed.substring(2, trimmed.length() - 1), record, locals);\n        }\n        if (trimmed.startsWith(\"{{\") && trimmed.endsWith(\"}}\")) {\n            return resolvePathExpression(trimmed.substring(2, trimmed.length() - 2), record, locals);\n        }\n        if (\"true\".equalsIgnoreCase(trimmed) || \"false\".equalsIgnoreCase(trimmed)) {\n            return Boolean.parseBoolean(trimmed);\n        }\n        Double number = valueAsDouble(trimmed);\n        if (number != null) {\n            return trimmed.contains(\".\") ? number : Integer.valueOf(number.intValue());\n        }\n        return renderTemplate(trimmed, record, locals);\n    }\n\n    private String renderTemplate(String template, TaskRecord record, Map<String, Object> locals) {\n        if (template == null) {\n            return null;\n        }\n        String rendered = template;\n        rendered = replaceTemplatePattern(rendered, \"${\", \"}\", record, locals);\n        rendered = replaceTemplatePattern(rendered, \"{{\", \"}}\", record, locals);\n        return rendered;\n    }\n\n    private String replaceTemplatePattern(String template,\n                                          String prefix,\n                                          String suffix,\n                                          TaskRecord record,\n                                          Map<String, Object> locals) {\n        String rendered = template;\n        int start = rendered.indexOf(prefix);\n        while (start >= 0) {\n            int end = rendered.indexOf(suffix, start + prefix.length());\n            if (end < 0) {\n                break;\n            }\n            String expr = rendered.substring(start + prefix.length(), end).trim();\n            Object value = resolvePathExpression(expr, record, locals);\n            rendered = rendered.substring(0, start)\n                    + (value == null ? \"\" : String.valueOf(value))\n                    + rendered.substring(end + suffix.length());\n            start = rendered.indexOf(prefix, start);\n        }\n        return rendered;\n    }\n\n    private Object resolvePathExpression(String expression, TaskRecord record, Map<String, Object> locals) {\n        if (isBlank(expression)) {\n            return null;\n        }\n        List<Object> path = new ArrayList<Object>();\n        for (String segment : expression.split(\"\\\\.\")) {\n            String trimmed = segment.trim();\n            if (!trimmed.isEmpty()) {\n                path.add(trimmed);\n            }\n        }\n        return resolveReference(path, record, locals);\n    }\n\n    private Object descend(Object current, Object segment) {\n        if (current == null || segment == null) {\n            return null;\n        }\n        if (current instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> map = (Map<String, Object>) current;\n            return map.get(String.valueOf(segment));\n        }\n        if (current instanceof List) {\n            Integer index = valueAsInteger(segment);\n            if (index == null) {\n                return null;\n            }\n            List<?> list = (List<?>) current;\n            return index >= 0 && index < list.size() ? list.get(index) : null;\n        }\n        return null;\n    }\n\n    private ParsedTask parseAndValidate(FlowGramTaskRunInput input) {\n        List<String> errors = validateInternal(input);\n        if (!errors.isEmpty()) {\n            throw new IllegalArgumentException(errors.get(0));\n        }\n        FlowGramWorkflowSchema schema = parseSchema(input);\n        Map<String, FlowGramNodeSchema> index = new LinkedHashMap<String, FlowGramNodeSchema>();\n        collectNodes(schema.getNodes(), index, new ArrayList<String>());\n        return new ParsedTask(schema, index, findSingleStart(index.values()));\n    }\n\n    private List<String> validateInternal(FlowGramTaskRunInput input) {\n        List<String> errors = new ArrayList<String>();\n        FlowGramWorkflowSchema schema = parseSchema(input, errors);\n        if (schema == null) {\n            return errors;\n        }\n\n        Map<String, FlowGramNodeSchema> nodeIndex = new LinkedHashMap<String, FlowGramNodeSchema>();\n        collectNodes(schema.getNodes(), nodeIndex, errors);\n        validateGraph(\"workflow\", schema.getNodes(), schema.getEdges(), true, errors);\n        validateNodeDefinitions(schema.getNodes(), nodeIndex, errors);\n\n        FlowGramNodeSchema startNode = findSingleStart(nodeIndex.values());\n        if (startNode != null) {\n            Map<String, Object> startSchema = schemaMap(startNode, \"outputs\");\n            Map<String, Object> inputs = applyObjectDefaults(startSchema, safeMap(input == null ? null : input.getInputs()));\n            collectObjectSchemaErrors(\"workflow inputs\", startSchema, inputs, errors);\n        }\n\n        return errors;\n    }\n\n    private FlowGramWorkflowSchema parseSchema(FlowGramTaskRunInput input) {\n        List<String> errors = new ArrayList<String>();\n        FlowGramWorkflowSchema schema = parseSchema(input, errors);\n        if (!errors.isEmpty()) {\n            throw new IllegalArgumentException(errors.get(0));\n        }\n        return schema;\n    }\n\n    private FlowGramWorkflowSchema parseSchema(FlowGramTaskRunInput input, List<String> errors) {\n        if (input == null || isBlank(input.getSchema())) {\n            errors.add(\"FlowGram schema is required\");\n            return null;\n        }\n        try {\n            FlowGramWorkflowSchema schema = JSON.parseObject(input.getSchema(), FlowGramWorkflowSchema.class);\n            if (schema == null || schema.getNodes() == null || schema.getNodes().isEmpty()) {\n                errors.add(\"FlowGram schema must contain at least one node\");\n                return null;\n            }\n            return schema;\n        } catch (Exception ex) {\n            errors.add(\"Failed to parse FlowGram schema: \" + safeMessage(ex));\n            return null;\n        }\n    }\n\n    private void validateGraph(String graphName,\n                               List<FlowGramNodeSchema> nodes,\n                               List<FlowGramEdgeSchema> edges,\n                               boolean rootGraph,\n                               List<String> errors) {\n        List<FlowGramNodeSchema> safeNodes = nodes == null ? Collections.<FlowGramNodeSchema>emptyList() : nodes;\n        Map<String, FlowGramNodeSchema> localIndex = new LinkedHashMap<String, FlowGramNodeSchema>();\n        int startCount = 0;\n        int endCount = 0;\n        for (FlowGramNodeSchema node : safeNodes) {\n            if (node == null) {\n                continue;\n            }\n            localIndex.put(node.getId(), node);\n            String type = normalizeType(node.getType());\n            if (NODE_TYPE_START.equals(type)) {\n                startCount++;\n            }\n            if (NODE_TYPE_END.equals(type)) {\n                endCount++;\n            }\n            if (!isSupportedType(type)) {\n                errors.add(\"Unsupported FlowGram node type '\" + node.getType() + \"' at node \" + safeNodeId(node));\n            }\n            if (NODE_TYPE_LOOP.equals(type)) {\n                validateGraph(\"loop:\" + safeNodeId(node), node.getBlocks(), node.getEdges(), false, errors);\n            }\n        }\n\n        if (rootGraph) {\n            if (startCount != 1) {\n                errors.add(\"FlowGram workflow must contain exactly one Start node\");\n            }\n            if (endCount < 1) {\n                errors.add(\"FlowGram workflow must contain at least one End node\");\n            }\n        }\n\n        if (edges != null) {\n            for (FlowGramEdgeSchema edge : edges) {\n                if (edge == null) {\n                    continue;\n                }\n                if (!localIndex.containsKey(edge.getSourceNodeID())) {\n                    errors.add(\"Edge source node not found in \" + graphName + \": \" + edge.getSourceNodeID());\n                }\n                if (!localIndex.containsKey(edge.getTargetNodeID())) {\n                    errors.add(\"Edge target node not found in \" + graphName + \": \" + edge.getTargetNodeID());\n                }\n            }\n        }\n    }\n\n    private void validateNodeDefinitions(List<FlowGramNodeSchema> nodes,\n                                         Map<String, FlowGramNodeSchema> nodeIndex,\n                                         List<String> errors) {\n        if (nodes == null) {\n            return;\n        }\n        for (FlowGramNodeSchema node : nodes) {\n            if (node == null) {\n                continue;\n            }\n            validateRequiredInputBindings(node, errors);\n            validateOutputRefs(node, nodeIndex, errors);\n            validateNodeDefinitions(node.getBlocks(), nodeIndex, errors);\n        }\n    }\n\n    private void validateRequiredInputBindings(FlowGramNodeSchema node, List<String> errors) {\n        Map<String, Object> inputSchema = schemaMap(node, \"inputs\");\n        List<String> required = stringList(inputSchema == null ? null : inputSchema.get(\"required\"));\n        if (required.isEmpty()) {\n            return;\n        }\n        Map<String, Object> inputsValues = mapValue(dataValue(node, \"inputsValues\"));\n        for (String key : required) {\n            if (inputsValues == null || !inputsValues.containsKey(key)) {\n                Object defaultValue = propertyDefault(inputSchema, key);\n                if (defaultValue == null) {\n                    errors.add(\"Node \" + safeNodeId(node) + \" is missing required input binding: \" + key);\n                }\n            }\n        }\n    }\n\n    private void validateOutputRefs(FlowGramNodeSchema node,\n                                    Map<String, FlowGramNodeSchema> nodeIndex,\n                                    List<String> errors) {\n        Map<String, Object> loopOutputs = mapValue(firstNonNull(dataValue(node, \"loopOutputs\"), dataValue(node, \"outputsValues\")));\n        if (loopOutputs == null) {\n            return;\n        }\n        for (Object value : loopOutputs.values()) {\n            validateRefValue(node, value, nodeIndex, errors);\n        }\n    }\n\n    private void validateRefValue(FlowGramNodeSchema node,\n                                  Object rawValue,\n                                  Map<String, FlowGramNodeSchema> nodeIndex,\n                                  List<String> errors) {\n        Map<String, Object> value = mapValue(rawValue);\n        if (value == null || !\"REF\".equals(normalizeType(valueAsString(value.get(\"type\"))))) {\n            return;\n        }\n        List<Object> path = objectList(value.get(\"content\"));\n        if (path.isEmpty()) {\n            return;\n        }\n        String root = valueAsString(path.get(0));\n        if (isBlank(root) || root.endsWith(\"_locals\") || \"inputs\".equals(root) || \"taskInputs\".equals(root) || \"$inputs\".equals(root)) {\n            return;\n        }\n        if (!nodeIndex.containsKey(root)) {\n            errors.add(\"Node \" + safeNodeId(node) + \" references unknown node output: \" + root);\n        }\n    }\n\n    private void collectNodes(List<FlowGramNodeSchema> nodes,\n                              Map<String, FlowGramNodeSchema> nodeIndex,\n                              List<String> errors) {\n        if (nodes == null) {\n            return;\n        }\n        for (FlowGramNodeSchema node : nodes) {\n            if (node == null) {\n                continue;\n            }\n            if (isBlank(node.getId())) {\n                errors.add(\"FlowGram node id is required\");\n                continue;\n            }\n            if (nodeIndex.containsKey(node.getId())) {\n                errors.add(\"Duplicate FlowGram node id: \" + node.getId());\n                continue;\n            }\n            nodeIndex.put(node.getId(), node);\n            collectNodes(node.getBlocks(), nodeIndex, errors);\n        }\n    }\n\n    private FlowGramNodeSchema findSingleStart(Iterable<FlowGramNodeSchema> nodes) {\n        if (nodes == null) {\n            return null;\n        }\n        FlowGramNodeSchema start = null;\n        for (FlowGramNodeSchema node : nodes) {\n            if (!NODE_TYPE_START.equals(normalizeType(node.getType()))) {\n                continue;\n            }\n            if (start != null) {\n                return null;\n            }\n            start = node;\n        }\n        return start;\n    }\n\n    private boolean isSupportedType(String type) {\n        if (NODE_TYPE_START.equals(type)\n                || NODE_TYPE_END.equals(type)\n                || NODE_TYPE_LLM.equals(type)\n                || NODE_TYPE_CONDITION.equals(type)\n                || NODE_TYPE_LOOP.equals(type)) {\n            return true;\n        }\n        return type != null && customExecutors.containsKey(type);\n    }\n\n    private void checkCanceled(TaskRecord record) throws InterruptedException {\n        if (record.cancelRequested.get() || Thread.currentThread().isInterrupted()) {\n            throw new InterruptedException(\"FlowGram task canceled\");\n        }\n    }\n\n    private static ExecutorService createExecutor() {\n        AtomicInteger sequence = new AtomicInteger(1);\n        return Executors.newCachedThreadPool(new ThreadFactory() {\n            @Override\n            public Thread newThread(Runnable runnable) {\n                Thread thread = new Thread(runnable, \"ai4j-flowgram-\" + sequence.getAndIncrement());\n                thread.setDaemon(true);\n                return thread;\n            }\n        });\n    }\n\n    private Map<String, Object> applyObjectDefaults(Map<String, Object> schema, Map<String, Object> values) {\n        Map<String, Object> resolved = safeMap(values);\n        if (schema == null) {\n            return resolved;\n        }\n        Map<String, Object> properties = mapValue(schema.get(\"properties\"));\n        if (properties == null) {\n            return resolved;\n        }\n        for (Map.Entry<String, Object> entry : properties.entrySet()) {\n            Map<String, Object> propertySchema = mapValue(entry.getValue());\n            if (propertySchema == null) {\n                continue;\n            }\n            if (!resolved.containsKey(entry.getKey()) && propertySchema.containsKey(\"default\")) {\n                resolved.put(entry.getKey(), copyValue(propertySchema.get(\"default\")));\n            }\n        }\n        return resolved;\n    }\n\n    private void validateObjectSchema(String label, Map<String, Object> schema, Map<String, Object> value) {\n        List<String> errors = new ArrayList<String>();\n        collectObjectSchemaErrors(label, schema, value, errors);\n        if (!errors.isEmpty()) {\n            throw new IllegalArgumentException(errors.get(0));\n        }\n    }\n\n    private void collectObjectSchemaErrors(String label,\n                                           Map<String, Object> schema,\n                                           Map<String, Object> value,\n                                           List<String> errors) {\n        if (schema == null) {\n            return;\n        }\n        if (!\"object\".equalsIgnoreCase(valueAsString(schema.get(\"type\")))) {\n            return;\n        }\n        Map<String, Object> safeValue = value == null ? Collections.<String, Object>emptyMap() : value;\n        for (String required : stringList(schema.get(\"required\"))) {\n            if (!safeValue.containsKey(required) || safeValue.get(required) == null) {\n                errors.add(label + \" is missing required field '\" + required + \"'\");\n            }\n        }\n        Map<String, Object> properties = mapValue(schema.get(\"properties\"));\n        if (properties == null) {\n            return;\n        }\n        for (Map.Entry<String, Object> entry : properties.entrySet()) {\n            if (!safeValue.containsKey(entry.getKey())) {\n                continue;\n            }\n            collectPropertyErrors(label + \".\" + entry.getKey(), mapValue(entry.getValue()), safeValue.get(entry.getKey()), errors);\n        }\n    }\n\n    private void collectPropertyErrors(String label,\n                                       Map<String, Object> schema,\n                                       Object value,\n                                       List<String> errors) {\n        if (schema == null || value == null) {\n            return;\n        }\n        String type = valueAsString(schema.get(\"type\"));\n        if (!isBlank(type) && !matchesType(type, value)) {\n            errors.add(label + \" expected \" + type + \" but got \" + actualType(value));\n            return;\n        }\n        Object enumValue = schema.get(\"enum\");\n        if (enumValue instanceof List && !((List<?>) enumValue).contains(value)) {\n            errors.add(label + \" is not in enum \" + enumValue);\n        }\n        if (\"object\".equalsIgnoreCase(type) && value instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> child = (Map<String, Object>) value;\n            collectObjectSchemaErrors(label, schema, child, errors);\n            return;\n        }\n        if (\"array\".equalsIgnoreCase(type) && value instanceof List) {\n            Map<String, Object> itemSchema = mapValue(schema.get(\"items\"));\n            if (itemSchema == null) {\n                return;\n            }\n            int index = 0;\n            for (Object item : (List<?>) value) {\n                collectPropertyErrors(label + \"[\" + index + \"]\", itemSchema, item, errors);\n                index++;\n            }\n        }\n    }\n\n    private boolean matchesType(String type, Object value) {\n        String normalized = type.trim().toLowerCase(Locale.ROOT);\n        if (\"string\".equals(normalized)) {\n            return value instanceof String;\n        }\n        if (\"number\".equals(normalized)) {\n            return value instanceof Number;\n        }\n        if (\"integer\".equals(normalized)) {\n            return value instanceof Byte\n                    || value instanceof Short\n                    || value instanceof Integer\n                    || value instanceof Long;\n        }\n        if (\"boolean\".equals(normalized)) {\n            return value instanceof Boolean;\n        }\n        if (\"object\".equals(normalized)) {\n            return value instanceof Map;\n        }\n        if (\"array\".equals(normalized)) {\n            return value instanceof List;\n        }\n        return true;\n    }\n\n    private Object propertyDefault(Map<String, Object> objectSchema, String key) {\n        Map<String, Object> properties = mapValue(objectSchema == null ? null : objectSchema.get(\"properties\"));\n        Map<String, Object> propertySchema = mapValue(properties == null ? null : properties.get(key));\n        return propertySchema == null ? null : propertySchema.get(\"default\");\n    }\n\n    private Map<String, Object> schemaMap(FlowGramNodeSchema node, String key) {\n        return mapValue(dataValue(node, key));\n    }\n\n    private Object dataValue(FlowGramNodeSchema node, String key) {\n        return node == null || node.getData() == null ? null : node.getData().get(key);\n    }\n\n    private static Map<String, Object> safeMap(Map<String, Object> value) {\n        Map<String, Object> copy = new LinkedHashMap<String, Object>();\n        if (value == null) {\n            return copy;\n        }\n        for (Map.Entry<String, Object> entry : value.entrySet()) {\n            copy.put(entry.getKey(), copyValue(entry.getValue()));\n        }\n        return copy;\n    }\n\n    private static Map<String, Object> copyMap(Map<String, ?> value) {\n        Map<String, Object> copy = new LinkedHashMap<String, Object>();\n        if (value == null) {\n            return copy;\n        }\n        for (Map.Entry<String, ?> entry : value.entrySet()) {\n            copy.put(entry.getKey(), copyValue(entry.getValue()));\n        }\n        return copy;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Object copyValue(Object value) {\n        if (value instanceof Map) {\n            Map<String, Object> copy = new LinkedHashMap<String, Object>();\n            Map<?, ?> source = (Map<?, ?>) value;\n            for (Map.Entry<?, ?> entry : source.entrySet()) {\n                copy.put(String.valueOf(entry.getKey()), copyValue(entry.getValue()));\n            }\n            return copy;\n        }\n        if (value instanceof List) {\n            List<Object> copy = new ArrayList<Object>();\n            for (Object item : (List<Object>) value) {\n                copy.add(copyValue(item));\n            }\n            return copy;\n        }\n        return value;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Map<String, Object> mapValue(Object value) {\n        if (!(value instanceof Map)) {\n            return null;\n        }\n        Map<String, Object> copy = new LinkedHashMap<String, Object>();\n        Map<?, ?> source = (Map<?, ?>) value;\n        for (Map.Entry<?, ?> entry : source.entrySet()) {\n            copy.put(String.valueOf(entry.getKey()), entry.getValue());\n        }\n        return copy;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static List<Object> objectList(Object value) {\n        if (!(value instanceof List)) {\n            return Collections.emptyList();\n        }\n        return new ArrayList<Object>((List<Object>) value);\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static List<String> stringList(Object value) {\n        if (!(value instanceof List)) {\n            return Collections.emptyList();\n        }\n        List<String> result = new ArrayList<String>();\n        for (Object item : (List<Object>) value) {\n            if (item != null) {\n                result.add(String.valueOf(item));\n            }\n        }\n        return result;\n    }\n\n    private static String normalizeType(String value) {\n        return isBlank(value) ? null : value.trim().toUpperCase(Locale.ROOT);\n    }\n\n    private static String normalizeOperator(String value) {\n        if (isBlank(value)) {\n            return null;\n        }\n        String trimmed = value.trim();\n        if (\"==\".equals(trimmed) || \"!=\".equals(trimmed) || \">\".equals(trimmed) || \">=\".equals(trimmed)\n                || \"<\".equals(trimmed) || \"<=\".equals(trimmed)) {\n            return trimmed;\n        }\n        return trimmed.toUpperCase(Locale.ROOT).replace(' ', '_');\n    }\n\n    private static String safeNodeId(FlowGramNodeSchema node) {\n        return node == null || isBlank(node.getId()) ? \"(unknown)\" : node.getId();\n    }\n\n    private static String safeMessage(Throwable throwable) {\n        if (throwable == null || isBlank(throwable.getMessage())) {\n            return throwable == null ? null : throwable.getClass().getSimpleName();\n        }\n        return throwable.getMessage();\n    }\n\n    private static Object firstNonNull(Object... values) {\n        if (values == null) {\n            return null;\n        }\n        for (Object value : values) {\n            if (value != null) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private static boolean valuesEqual(Object left, Object right) {\n        return left == null ? right == null : left.equals(right);\n    }\n\n    private static boolean truthy(Object value) {\n        if (value == null) {\n            return false;\n        }\n        if (value instanceof Boolean) {\n            return (Boolean) value;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).doubleValue() != 0D;\n        }\n        if (value instanceof String) {\n            return !((String) value).trim().isEmpty();\n        }\n        if (value instanceof List) {\n            return !((List<?>) value).isEmpty();\n        }\n        if (value instanceof Map) {\n            return !((Map<?, ?>) value).isEmpty();\n        }\n        return true;\n    }\n\n    private static String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static Double valueAsDouble(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).doubleValue();\n        }\n        String text = String.valueOf(value).trim();\n        if (text.isEmpty()) {\n            return null;\n        }\n        try {\n            return Double.parseDouble(text);\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    private static Integer valueAsInteger(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).intValue();\n        }\n        String text = String.valueOf(value).trim();\n        if (text.isEmpty()) {\n            return null;\n        }\n        try {\n            return Integer.parseInt(text);\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static String actualType(Object value) {\n        if (value == null) {\n            return \"null\";\n        }\n        if (value instanceof Map) {\n            return \"object\";\n        }\n        if (value instanceof List) {\n            return \"array\";\n        }\n        if (value instanceof String) {\n            return \"string\";\n        }\n        if (value instanceof Boolean) {\n            return \"boolean\";\n        }\n        if (value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte) {\n            return \"integer\";\n        }\n        if (value instanceof Number) {\n            return \"number\";\n        }\n        return value.getClass().getSimpleName();\n    }\n\n    private static Object resolveInputValue(Map<String, Object> inputs, String key) {\n        return inputs == null || isBlank(key) ? null : inputs.get(key);\n    }\n\n    private static final class ParsedTask {\n        private final FlowGramWorkflowSchema schema;\n        private final Map<String, FlowGramNodeSchema> nodeIndex;\n        private final FlowGramNodeSchema startNode;\n\n        private ParsedTask(FlowGramWorkflowSchema schema,\n                           Map<String, FlowGramNodeSchema> nodeIndex,\n                           FlowGramNodeSchema startNode) {\n            this.schema = schema;\n            this.nodeIndex = nodeIndex;\n            this.startNode = startNode;\n        }\n    }\n\n    private static final class GraphSegment {\n        private final Map<String, FlowGramNodeSchema> nodes;\n        private final Map<String, List<FlowGramEdgeSchema>> outgoing;\n        private final boolean terminalResultGraph;\n\n        private GraphSegment(Map<String, FlowGramNodeSchema> nodes,\n                             Map<String, List<FlowGramEdgeSchema>> outgoing,\n                             boolean terminalResultGraph) {\n            this.nodes = nodes;\n            this.outgoing = outgoing;\n            this.terminalResultGraph = terminalResultGraph;\n        }\n\n        private static GraphSegment root(FlowGramWorkflowSchema schema) {\n            return new GraphSegment(indexNodes(schema == null ? null : schema.getNodes()), indexOutgoing(schema == null ? null : schema.getEdges()), true);\n        }\n\n        private static GraphSegment loop(FlowGramNodeSchema node) {\n            return new GraphSegment(indexNodes(node == null ? null : node.getBlocks()), indexOutgoing(node == null ? null : node.getEdges()), false);\n        }\n\n        private FlowGramNodeSchema getNode(String nodeId) {\n            return nodes.get(nodeId);\n        }\n\n        private List<FlowGramEdgeSchema> outgoing(String nodeId) {\n            List<FlowGramEdgeSchema> edges = outgoing.get(nodeId);\n            return edges == null ? Collections.<FlowGramEdgeSchema>emptyList() : edges;\n        }\n\n        private List<String> entryNodeIds() {\n            if (nodes.isEmpty()) {\n                return Collections.emptyList();\n            }\n            Set<String> incoming = new LinkedHashSet<String>();\n            for (List<FlowGramEdgeSchema> edges : outgoing.values()) {\n                if (edges == null) {\n                    continue;\n                }\n                for (FlowGramEdgeSchema edge : edges) {\n                    if (edge != null && !isBlank(edge.getTargetNodeID())) {\n                        incoming.add(edge.getTargetNodeID());\n                    }\n                }\n            }\n            List<String> roots = new ArrayList<String>();\n            for (String nodeId : nodes.keySet()) {\n                if (!incoming.contains(nodeId)) {\n                    roots.add(nodeId);\n                }\n            }\n            return roots.isEmpty() ? new ArrayList<String>(nodes.keySet()) : roots;\n        }\n\n        private boolean isEmpty() {\n            return nodes.isEmpty();\n        }\n\n        private boolean isTerminalResultGraph() {\n            return terminalResultGraph;\n        }\n\n        private static Map<String, FlowGramNodeSchema> indexNodes(List<FlowGramNodeSchema> nodes) {\n            Map<String, FlowGramNodeSchema> index = new LinkedHashMap<String, FlowGramNodeSchema>();\n            if (nodes == null) {\n                return index;\n            }\n            for (FlowGramNodeSchema node : nodes) {\n                if (node == null || isBlank(node.getId())) {\n                    continue;\n                }\n                index.put(node.getId(), node);\n            }\n            return index;\n        }\n\n        private static Map<String, List<FlowGramEdgeSchema>> indexOutgoing(List<FlowGramEdgeSchema> edges) {\n            Map<String, List<FlowGramEdgeSchema>> index = new LinkedHashMap<String, List<FlowGramEdgeSchema>>();\n            if (edges == null) {\n                return index;\n            }\n            for (FlowGramEdgeSchema edge : edges) {\n                if (edge == null || isBlank(edge.getSourceNodeID()) || isBlank(edge.getTargetNodeID())) {\n                    continue;\n                }\n                List<FlowGramEdgeSchema> outgoing = index.get(edge.getSourceNodeID());\n                if (outgoing == null) {\n                    outgoing = new ArrayList<FlowGramEdgeSchema>();\n                    index.put(edge.getSourceNodeID(), outgoing);\n                }\n                outgoing.add(edge);\n            }\n            return index;\n        }\n    }\n\n    private static final class TaskRecord {\n        private final String taskId;\n        private final FlowGramWorkflowSchema schema;\n        private final Map<String, FlowGramNodeSchema> nodeIndex;\n        private final Map<String, Object> taskInputs;\n        private final Map<String, Map<String, Object>> nodeInputs =\n                new ConcurrentHashMap<String, Map<String, Object>>();\n        private final Map<String, Map<String, Object>> nodeOutputs =\n                new ConcurrentHashMap<String, Map<String, Object>>();\n        private final ConcurrentMap<String, FlowGramTaskReportOutput.NodeStatus> nodeStatuses =\n                new ConcurrentHashMap<String, FlowGramTaskReportOutput.NodeStatus>();\n        private final AtomicBoolean cancelRequested = new AtomicBoolean(false);\n\n        private volatile String workflowStatus = STATUS_PENDING;\n        private volatile boolean terminated = false;\n        private volatile String workflowError;\n        private volatile Long workflowStartTime;\n        private volatile Long workflowEndTime;\n        private volatile Future<?> future;\n        private volatile Map<String, Object> result;\n\n        private TaskRecord(String taskId,\n                           FlowGramWorkflowSchema schema,\n                           Map<String, FlowGramNodeSchema> nodeIndex,\n                           Map<String, Object> taskInputs) {\n            this.taskId = taskId;\n            this.schema = schema;\n            this.nodeIndex = nodeIndex == null ? Collections.<String, FlowGramNodeSchema>emptyMap() : nodeIndex;\n            this.taskInputs = taskInputs == null ? Collections.<String, Object>emptyMap() : taskInputs;\n            for (String nodeId : this.nodeIndex.keySet()) {\n                nodeStatuses.put(nodeId, FlowGramTaskReportOutput.NodeStatus.builder()\n                        .status(STATUS_PENDING)\n                        .build());\n            }\n        }\n\n        private void updateNode(String nodeId, String status, String error, boolean finished) {\n            FlowGramTaskReportOutput.NodeStatus current = nodeStatuses.get(nodeId);\n            long now = System.currentTimeMillis();\n            FlowGramTaskReportOutput.NodeStatus.NodeStatusBuilder builder = current == null\n                    ? FlowGramTaskReportOutput.NodeStatus.builder()\n                    : current.toBuilder();\n            builder.status(status);\n            builder.terminated(finished || STATUS_SUCCESS.equals(status)\n                    || STATUS_FAILED.equals(status) || STATUS_CANCELED.equals(status));\n            if (STATUS_PROCESSING.equals(status) && (current == null || current.getStartTime() == null)) {\n                builder.startTime(now);\n            }\n            if (finished) {\n                if (current == null || current.getStartTime() == null) {\n                    builder.startTime(now);\n                }\n                builder.endTime(now);\n            }\n            builder.error(error);\n            nodeStatuses.put(nodeId, builder.build());\n        }\n\n        private void recordNodeInputs(String nodeId, Map<String, Object> inputs) {\n            nodeInputs.put(nodeId, copyMap(inputs));\n        }\n\n        private void recordNodeOutputs(String nodeId, Map<String, Object> outputs) {\n            nodeOutputs.put(nodeId, copyMap(outputs));\n        }\n\n        private void updateWorkflow(String status, boolean terminated, String error) {\n            long now = System.currentTimeMillis();\n            this.workflowStatus = status;\n            this.terminated = terminated;\n            this.workflowError = error;\n            if (STATUS_PROCESSING.equals(status) && workflowStartTime == null) {\n                workflowStartTime = now;\n            }\n            if (terminated) {\n                if (workflowStartTime == null) {\n                    workflowStartTime = now;\n                }\n                workflowEndTime = now;\n            }\n        }\n\n        private FlowGramTaskReportOutput toReport() {\n            Map<String, FlowGramTaskReportOutput.NodeStatus> snapshot =\n                    new LinkedHashMap<String, FlowGramTaskReportOutput.NodeStatus>();\n            for (Map.Entry<String, FlowGramTaskReportOutput.NodeStatus> entry : nodeStatuses.entrySet()) {\n                FlowGramTaskReportOutput.NodeStatus value = entry.getValue();\n                snapshot.put(entry.getKey(), value == null ? null : value.toBuilder()\n                        .inputs(copyMap(nodeInputs.get(entry.getKey())))\n                        .outputs(copyMap(nodeOutputs.get(entry.getKey())))\n                        .build());\n            }\n            return FlowGramTaskReportOutput.builder()\n                    .inputs(copyMap(taskInputs))\n                    .outputs(copyMap(result))\n                    .workflow(FlowGramTaskReportOutput.WorkflowStatus.builder()\n                            .status(workflowStatus)\n                            .terminated(terminated)\n                            .startTime(workflowStartTime)\n                            .endTime(workflowEndTime)\n                            .error(workflowError)\n                            .build())\n                    .nodes(snapshot)\n                    .build();\n        }\n\n        private FlowGramTaskResultOutput toResult() {\n            return FlowGramTaskResultOutput.builder()\n                    .status(workflowStatus)\n                    .terminated(terminated)\n                    .error(workflowError)\n                    .result(result == null ? null : copyMap(result))\n                    .build();\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramEdgeSchema.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramEdgeSchema {\n\n    private String sourceNodeID;\n    private String sourcePort;\n    private String sourcePortID;\n    private String targetNodeID;\n    private String targetPort;\n    private String targetPortID;\n\n    public String sourcePortKey() {\n        return firstNonBlank(sourcePortID, sourcePort);\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramNodeSchema.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramNodeSchema {\n\n    private String id;\n    private String type;\n    private String name;\n    private Map<String, Object> meta;\n    private Map<String, Object> data;\n    private List<FlowGramNodeSchema> blocks;\n    private List<FlowGramEdgeSchema> edges;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskCancelOutput.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskCancelOutput {\n\n    private boolean success;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskReportOutput.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskReportOutput {\n\n    private Map<String, Object> inputs;\n    private Map<String, Object> outputs;\n    private WorkflowStatus workflow;\n    private Map<String, NodeStatus> nodes;\n\n    @Data\n    @Builder(toBuilder = true)\n    @NoArgsConstructor\n    @AllArgsConstructor\n    public static class WorkflowStatus {\n        private String status;\n        private boolean terminated;\n        private Long startTime;\n        private Long endTime;\n        private String error;\n    }\n\n    @Data\n    @Builder(toBuilder = true)\n    @NoArgsConstructor\n    @AllArgsConstructor\n    public static class NodeStatus {\n        private String status;\n        private boolean terminated;\n        private Long startTime;\n        private Long endTime;\n        private String error;\n        private Map<String, Object> inputs;\n        private Map<String, Object> outputs;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskResultOutput.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskResultOutput {\n\n    private String status;\n    private boolean terminated;\n    private String error;\n    private Map<String, Object> result;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskRunInput.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskRunInput {\n\n    private String schema;\n    private Map<String, Object> inputs;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskRunOutput.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskRunOutput {\n\n    private String taskID;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramTaskValidateOutput.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskValidateOutput {\n\n    private boolean valid;\n    private List<String> errors;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/flowgram/model/FlowGramWorkflowSchema.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram.model;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramWorkflowSchema {\n\n    private List<FlowGramNodeSchema> nodes;\n    private List<FlowGramEdgeSchema> edges;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/AgentMemory.java",
    "content": "package io.github.lnyocly.ai4j.agent.memory;\n\nimport java.util.List;\n\npublic interface AgentMemory {\n\n    void addUserInput(Object input);\n\n    void addOutputItems(List<Object> items);\n\n    void addToolOutput(String callId, String output);\n\n    List<Object> getItems();\n\n    String getSummary();\n\n    void clear();\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/InMemoryAgentMemory.java",
    "content": "package io.github.lnyocly.ai4j.agent.memory;\n\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class InMemoryAgentMemory implements AgentMemory {\n\n    private final List<Object> items = new ArrayList<>();\n    private String summary;\n    private MemoryCompressor compressor;\n\n    public InMemoryAgentMemory() {\n    }\n\n    public InMemoryAgentMemory(MemoryCompressor compressor) {\n        this.compressor = compressor;\n    }\n\n    public void setCompressor(MemoryCompressor compressor) {\n        this.compressor = compressor;\n    }\n\n    @Override\n    public void addUserInput(Object input) {\n        if (input == null) {\n            return;\n        }\n        if (input instanceof String) {\n            items.add(AgentInputItem.userMessage((String) input));\n        } else {\n            items.add(input);\n        }\n        maybeCompress();\n    }\n\n    @Override\n    public void addOutputItems(List<Object> outputItems) {\n        if (outputItems == null || outputItems.isEmpty()) {\n            return;\n        }\n        items.addAll(outputItems);\n        maybeCompress();\n    }\n\n    @Override\n    public void addToolOutput(String callId, String output) {\n        if (callId == null) {\n            return;\n        }\n        items.add(AgentInputItem.functionCallOutput(callId, output));\n        maybeCompress();\n    }\n\n    @Override\n    public List<Object> getItems() {\n        if (summary == null || summary.trim().isEmpty()) {\n            return new ArrayList<>(items);\n        }\n        List<Object> merged = new ArrayList<>();\n        merged.add(AgentInputItem.systemMessage(summary));\n        merged.addAll(items);\n        return merged;\n    }\n\n    @Override\n    public String getSummary() {\n        return summary;\n    }\n\n    public void setSummary(String summary) {\n        this.summary = summary;\n    }\n\n    public MemorySnapshot snapshot() {\n        return MemorySnapshot.from(items, summary);\n    }\n\n    public void restore(MemorySnapshot snapshot) {\n        items.clear();\n        if (snapshot != null && snapshot.getItems() != null) {\n            items.addAll(snapshot.getItems());\n        }\n        summary = snapshot == null ? null : snapshot.getSummary();\n    }\n\n    @Override\n    public void clear() {\n        items.clear();\n        summary = null;\n    }\n\n    private void maybeCompress() {\n        if (compressor == null) {\n            return;\n        }\n        MemorySnapshot snapshot = compressor.compress(MemorySnapshot.from(items, summary));\n        items.clear();\n        if (snapshot.getItems() != null) {\n            items.addAll(snapshot.getItems());\n        }\n        summary = snapshot.getSummary();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemory.java",
    "content": "package io.github.lnyocly.ai4j.agent.memory;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\n\nimport javax.sql.DataSource;\nimport java.sql.Connection;\nimport java.sql.DriverManager;\nimport java.sql.PreparedStatement;\nimport java.sql.ResultSet;\nimport java.sql.Statement;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class JdbcAgentMemory implements AgentMemory {\n\n    private static final String ENTRY_TYPE_ITEM = \"item\";\n    private static final String ENTRY_TYPE_SUMMARY = \"summary\";\n\n    private final DataSource dataSource;\n    private final String jdbcUrl;\n    private final String username;\n    private final String password;\n    private final String sessionId;\n    private final String tableName;\n\n    private MemoryCompressor compressor;\n\n    public JdbcAgentMemory(JdbcAgentMemoryConfig config) {\n        if (config == null) {\n            throw new IllegalArgumentException(\"config is required\");\n        }\n        this.dataSource = config.getDataSource();\n        this.jdbcUrl = trimToNull(config.getJdbcUrl());\n        this.username = trimToNull(config.getUsername());\n        this.password = config.getPassword();\n        this.sessionId = requiredText(config.getSessionId(), \"sessionId\");\n        this.tableName = validIdentifier(config.getTableName());\n        this.compressor = config.getCompressor();\n        if (this.dataSource == null && this.jdbcUrl == null) {\n            throw new IllegalArgumentException(\"dataSource or jdbcUrl is required\");\n        }\n        if (config.isInitializeSchema()) {\n            initializeSchema();\n        }\n    }\n\n    public JdbcAgentMemory(String jdbcUrl, String sessionId) {\n        this(JdbcAgentMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .sessionId(sessionId)\n                .build());\n    }\n\n    public JdbcAgentMemory(String jdbcUrl, String username, String password, String sessionId) {\n        this(JdbcAgentMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .username(username)\n                .password(password)\n                .sessionId(sessionId)\n                .build());\n    }\n\n    public JdbcAgentMemory(DataSource dataSource, String sessionId) {\n        this(JdbcAgentMemoryConfig.builder()\n                .dataSource(dataSource)\n                .sessionId(sessionId)\n                .build());\n    }\n\n    public void setCompressor(MemoryCompressor compressor) {\n        this.compressor = compressor;\n        synchronized (this) {\n            replaceSnapshot(applyCompressor(loadSnapshot()));\n        }\n    }\n\n    public synchronized void setSummary(String summary) {\n        MemorySnapshot snapshot = loadSnapshot();\n        snapshot.setSummary(summary);\n        replaceSnapshot(applyCompressor(snapshot));\n    }\n\n    public synchronized MemorySnapshot snapshot() {\n        MemorySnapshot snapshot = loadSnapshot();\n        return MemorySnapshot.from(snapshot.getItems(), snapshot.getSummary());\n    }\n\n    public synchronized void restore(MemorySnapshot snapshot) {\n        MemorySnapshot target = snapshot == null\n                ? MemorySnapshot.from(Collections.<Object>emptyList(), null)\n                : MemorySnapshot.from(snapshot.getItems(), snapshot.getSummary());\n        replaceSnapshot(applyCompressor(target));\n    }\n\n    @Override\n    public synchronized void addUserInput(Object input) {\n        if (input == null) {\n            return;\n        }\n        MemorySnapshot snapshot = loadSnapshot();\n        List<Object> items = copyItems(snapshot.getItems());\n        if (input instanceof String) {\n            items.add(AgentInputItem.userMessage((String) input));\n        } else {\n            items.add(input);\n        }\n        replaceSnapshot(applyCompressor(MemorySnapshot.from(items, snapshot.getSummary())));\n    }\n\n    @Override\n    public synchronized void addOutputItems(List<Object> outputItems) {\n        if (outputItems == null || outputItems.isEmpty()) {\n            return;\n        }\n        MemorySnapshot snapshot = loadSnapshot();\n        List<Object> items = copyItems(snapshot.getItems());\n        items.addAll(outputItems);\n        replaceSnapshot(applyCompressor(MemorySnapshot.from(items, snapshot.getSummary())));\n    }\n\n    @Override\n    public synchronized void addToolOutput(String callId, String output) {\n        if (callId == null) {\n            return;\n        }\n        MemorySnapshot snapshot = loadSnapshot();\n        List<Object> items = copyItems(snapshot.getItems());\n        items.add(AgentInputItem.functionCallOutput(callId, output));\n        replaceSnapshot(applyCompressor(MemorySnapshot.from(items, snapshot.getSummary())));\n    }\n\n    @Override\n    public synchronized List<Object> getItems() {\n        MemorySnapshot snapshot = loadSnapshot();\n        List<Object> items = copyItems(snapshot.getItems());\n        if (snapshot.getSummary() == null || snapshot.getSummary().trim().isEmpty()) {\n            return items;\n        }\n        List<Object> merged = new ArrayList<Object>(items.size() + 1);\n        merged.add(AgentInputItem.systemMessage(snapshot.getSummary()));\n        merged.addAll(items);\n        return merged;\n    }\n\n    @Override\n    public synchronized String getSummary() {\n        return loadSnapshot().getSummary();\n    }\n\n    @Override\n    public synchronized void clear() {\n        replaceSnapshot(MemorySnapshot.from(Collections.<Object>emptyList(), null));\n    }\n\n    private void initializeSchema() {\n        String sql = \"create table if not exists \" + tableName + \" (\" +\n                \"session_id varchar(191) not null, \" +\n                \"entry_type varchar(16) not null, \" +\n                \"entry_index integer not null, \" +\n                \"entry_json text, \" +\n                \"updated_at bigint not null, \" +\n                \"primary key (session_id, entry_type, entry_index)\" +\n                \")\";\n        try (Connection connection = openConnection();\n             Statement statement = connection.createStatement()) {\n            statement.executeUpdate(sql);\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to initialize agent memory schema\", e);\n        }\n    }\n\n    private MemorySnapshot loadSnapshot() {\n        String summarySql = \"select entry_json from \" + tableName +\n                \" where session_id = ? and entry_type = ? and entry_index = 0\";\n        String itemsSql = \"select entry_json from \" + tableName +\n                \" where session_id = ? and entry_type = ? order by entry_index asc\";\n        try (Connection connection = openConnection()) {\n            String summary = null;\n            try (PreparedStatement summaryStatement = connection.prepareStatement(summarySql)) {\n                summaryStatement.setString(1, sessionId);\n                summaryStatement.setString(2, ENTRY_TYPE_SUMMARY);\n                try (ResultSet resultSet = summaryStatement.executeQuery()) {\n                    if (resultSet.next()) {\n                        Object value = JSON.parse(resultSet.getString(\"entry_json\"));\n                        summary = value == null ? null : String.valueOf(value);\n                    }\n                }\n            }\n\n            List<Object> items = new ArrayList<Object>();\n            try (PreparedStatement itemsStatement = connection.prepareStatement(itemsSql)) {\n                itemsStatement.setString(1, sessionId);\n                itemsStatement.setString(2, ENTRY_TYPE_ITEM);\n                try (ResultSet resultSet = itemsStatement.executeQuery()) {\n                    while (resultSet.next()) {\n                        items.add(JSON.parse(resultSet.getString(\"entry_json\")));\n                    }\n                }\n            }\n            return MemorySnapshot.from(items, summary);\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to load agent memory\", e);\n        }\n    }\n\n    private void replaceSnapshot(MemorySnapshot snapshot) {\n        String deleteSql = \"delete from \" + tableName + \" where session_id = ?\";\n        String insertSql = \"insert into \" + tableName +\n                \" (session_id, entry_type, entry_index, entry_json, updated_at) values (?, ?, ?, ?, ?)\";\n        try (Connection connection = openConnection()) {\n            boolean autoCommit = connection.getAutoCommit();\n            connection.setAutoCommit(false);\n            try {\n                try (PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) {\n                    deleteStatement.setString(1, sessionId);\n                    deleteStatement.executeUpdate();\n                }\n                long now = System.currentTimeMillis();\n                if (snapshot != null && snapshot.getSummary() != null) {\n                    try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) {\n                        insertStatement.setString(1, sessionId);\n                        insertStatement.setString(2, ENTRY_TYPE_SUMMARY);\n                        insertStatement.setInt(3, 0);\n                        insertStatement.setString(4, JSON.toJSONString(snapshot.getSummary()));\n                        insertStatement.setLong(5, now);\n                        insertStatement.executeUpdate();\n                    }\n                }\n                if (snapshot != null && snapshot.getItems() != null && !snapshot.getItems().isEmpty()) {\n                    try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) {\n                        for (int i = 0; i < snapshot.getItems().size(); i++) {\n                            insertStatement.setString(1, sessionId);\n                            insertStatement.setString(2, ENTRY_TYPE_ITEM);\n                            insertStatement.setInt(3, i);\n                            insertStatement.setString(4, JSON.toJSONString(snapshot.getItems().get(i)));\n                            insertStatement.setLong(5, now);\n                            insertStatement.addBatch();\n                        }\n                        insertStatement.executeBatch();\n                    }\n                }\n                connection.commit();\n            } catch (Exception e) {\n                connection.rollback();\n                throw e;\n            } finally {\n                connection.setAutoCommit(autoCommit);\n            }\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to persist agent memory\", e);\n        }\n    }\n\n    private MemorySnapshot applyCompressor(MemorySnapshot snapshot) {\n        if (compressor == null) {\n            return MemorySnapshot.from(snapshot == null ? null : snapshot.getItems(), snapshot == null ? null : snapshot.getSummary());\n        }\n        return compressor.compress(snapshot == null\n                ? MemorySnapshot.from(Collections.<Object>emptyList(), null)\n                : MemorySnapshot.from(snapshot.getItems(), snapshot.getSummary()));\n    }\n\n    private List<Object> copyItems(List<Object> items) {\n        return items == null ? new ArrayList<Object>() : new ArrayList<Object>(items);\n    }\n\n    private Connection openConnection() throws Exception {\n        if (dataSource != null) {\n            return dataSource.getConnection();\n        }\n        if (username == null) {\n            return DriverManager.getConnection(jdbcUrl);\n        }\n        return DriverManager.getConnection(jdbcUrl, username, password);\n    }\n\n    private String validIdentifier(String value) {\n        String identifier = requiredText(value, \"tableName\");\n        if (!identifier.matches(\"[A-Za-z_][A-Za-z0-9_]*(\\\\.[A-Za-z_][A-Za-z0-9_]*)?\")) {\n            throw new IllegalArgumentException(\"Invalid sql identifier: \" + value);\n        }\n        return identifier;\n    }\n\n    private String requiredText(String value, String fieldName) {\n        String text = trimToNull(value);\n        if (text == null) {\n            throw new IllegalArgumentException(fieldName + \" is required\");\n        }\n        return text;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemoryConfig.java",
    "content": "package io.github.lnyocly.ai4j.agent.memory;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport javax.sql.DataSource;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class JdbcAgentMemoryConfig {\n\n    private DataSource dataSource;\n\n    private String jdbcUrl;\n\n    private String username;\n\n    private String password;\n\n    private String sessionId;\n\n    @Builder.Default\n    private String tableName = \"ai4j_agent_memory\";\n\n    @Builder.Default\n    private boolean initializeSchema = true;\n\n    private MemoryCompressor compressor;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/MemoryCompressor.java",
    "content": "package io.github.lnyocly.ai4j.agent.memory;\n\npublic interface MemoryCompressor {\n\n    MemorySnapshot compress(MemorySnapshot snapshot);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/MemorySnapshot.java",
    "content": "package io.github.lnyocly.ai4j.agent.memory;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class MemorySnapshot {\n\n    private List<Object> items;\n\n    private String summary;\n\n    public static MemorySnapshot from(List<Object> items, String summary) {\n        return MemorySnapshot.builder()\n                .items(items == null ? new ArrayList<Object>() : new ArrayList<Object>(items))\n                .summary(summary)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/WindowedMemoryCompressor.java",
    "content": "package io.github.lnyocly.ai4j.agent.memory;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class WindowedMemoryCompressor implements MemoryCompressor {\n\n    private final int maxItems;\n\n    public WindowedMemoryCompressor(int maxItems) {\n        if (maxItems <= 0) {\n            throw new IllegalArgumentException(\"maxItems must be greater than 0\");\n        }\n        this.maxItems = maxItems;\n    }\n\n    @Override\n    public MemorySnapshot compress(MemorySnapshot snapshot) {\n        if (snapshot == null) {\n            return MemorySnapshot.from(Collections.emptyList(), null);\n        }\n        List<Object> items = snapshot.getItems();\n        if (items == null || items.size() <= maxItems) {\n            return snapshot;\n        }\n        List<Object> trimmed = new ArrayList<>(items.subList(items.size() - maxItems, items.size()));\n        return MemorySnapshot.from(trimmed, snapshot.getSummary());\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/AgentModelClient.java",
    "content": "package io.github.lnyocly.ai4j.agent.model;\n\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\n\npublic interface AgentModelClient {\n\n    AgentModelResult create(AgentPrompt prompt) throws Exception;\n\n    AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/AgentModelResult.java",
    "content": "package io.github.lnyocly.ai4j.agent.model;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class AgentModelResult {\n\n    private String reasoningText;\n\n    private String outputText;\n\n    private List<AgentToolCall> toolCalls;\n\n    private List<Object> memoryItems;\n\n    private Object rawResponse;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/AgentModelStreamListener.java",
    "content": "package io.github.lnyocly.ai4j.agent.model;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\n\npublic interface AgentModelStreamListener {\n\n    default void onReasoningDelta(String delta) {\n    }\n\n    default void onDeltaText(String delta) {\n    }\n\n    default void onToolCall(AgentToolCall call) {\n    }\n\n    default void onEvent(Object event) {\n    }\n\n    default void onComplete(AgentModelResult result) {\n    }\n\n    default void onError(Throwable t) {\n    }\n\n    default void onRetry(String message, int attempt, int maxAttempts, Throwable cause) {\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/AgentPrompt.java",
    "content": "package io.github.lnyocly.ai4j.agent.model;\n\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class AgentPrompt {\n\n    private String model;\n\n    private List<Object> items;\n\n    private String systemPrompt;\n\n    private String instructions;\n\n    private List<Object> tools;\n\n    private Object toolChoice;\n\n    private Boolean parallelToolCalls;\n\n    private Double temperature;\n\n    private Double topP;\n\n    private Integer maxOutputTokens;\n\n    private Object reasoning;\n\n    private Boolean store;\n\n    private Boolean stream;\n\n    private String user;\n\n    private Map<String, Object> extraBody;\n\n    private StreamExecutionOptions streamExecution;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/ChatModelClient.java",
    "content": "package io.github.lnyocly.ai4j.agent.model;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Content;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.service.IChatService;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\n\npublic class ChatModelClient implements AgentModelClient {\n\n    private static final ConcurrentMap<Thread, SseListener> ACTIVE_STREAMS = new ConcurrentHashMap<Thread, SseListener>();\n\n    private final IChatService chatService;\n    private final String baseUrl;\n    private final String apiKey;\n\n    public ChatModelClient(IChatService chatService) {\n        this(chatService, null, null);\n    }\n\n    public ChatModelClient(IChatService chatService, String baseUrl, String apiKey) {\n        this.chatService = chatService;\n        this.baseUrl = baseUrl;\n        this.apiKey = apiKey;\n    }\n\n    @Override\n    public AgentModelResult create(AgentPrompt prompt) throws Exception {\n        ChatCompletion completion = toChatCompletion(prompt, false);\n        ChatCompletionResponse response = chatService.chatCompletion(baseUrl, apiKey, completion);\n        return toModelResult(response);\n    }\n\n    @Override\n    public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception {\n        ChatCompletion completion = toChatCompletion(prompt, true);\n        completion.setPassThroughToolCalls(Boolean.TRUE);\n        StreamingSseListener sseListener = new StreamingSseListener(listener);\n        Thread currentThread = Thread.currentThread();\n        ACTIVE_STREAMS.put(currentThread, sseListener);\n        try {\n            chatService.chatCompletionStream(baseUrl, apiKey, completion, sseListener);\n            throwIfInterrupted(sseListener);\n            sseListener.dispatchFailure();\n            AgentModelResult result = sseListener.toResult();\n            throwIfInterrupted(sseListener);\n            if (listener != null) {\n                listener.onComplete(result);\n            }\n            return result;\n        } catch (InterruptedException ex) {\n            sseListener.cancelStream();\n            Thread.currentThread().interrupt();\n            throw ex;\n        } catch (Exception ex) {\n            if (currentThread.isInterrupted()) {\n                sseListener.cancelStream();\n            }\n            throw ex;\n        } finally {\n            ACTIVE_STREAMS.remove(currentThread, sseListener);\n        }\n    }\n\n    public static void cancelActiveStream(Thread thread) {\n        if (thread == null) {\n            return;\n        }\n        SseListener listener = ACTIVE_STREAMS.get(thread);\n        if (listener != null) {\n            listener.cancelStream();\n        }\n    }\n\n    private void throwIfInterrupted(SseListener sseListener) throws InterruptedException {\n        if (!Thread.currentThread().isInterrupted()) {\n            return;\n        }\n        sseListener.cancelStream();\n        throw new InterruptedException(\"Model stream interrupted\");\n    }\n\n    private ChatCompletion toChatCompletion(AgentPrompt prompt, boolean stream) {\n        if (prompt == null) {\n            throw new IllegalArgumentException(\"prompt is required\");\n        }\n        List<ChatMessage> messages = new ArrayList<>();\n        if (prompt.getSystemPrompt() != null && !prompt.getSystemPrompt().trim().isEmpty()) {\n            messages.add(ChatMessage.withSystem(prompt.getSystemPrompt()));\n        }\n        if (prompt.getInstructions() != null && !prompt.getInstructions().trim().isEmpty()) {\n            messages.add(ChatMessage.withSystem(prompt.getInstructions()));\n        }\n\n        if (prompt.getItems() != null) {\n            for (Object item : prompt.getItems()) {\n                ChatMessage message = convertToMessage(item);\n                if (message != null) {\n                    messages.add(message);\n                }\n            }\n        }\n\n        ChatCompletion.ChatCompletionBuilder builder = ChatCompletion.builder()\n                .model(prompt.getModel())\n                .messages(messages)\n                .stream(stream)\n                .streamExecution(prompt.getStreamExecution())\n                .temperature(prompt.getTemperature() == null ? null : prompt.getTemperature().floatValue())\n                .topP(prompt.getTopP() == null ? null : prompt.getTopP().floatValue())\n                .maxCompletionTokens(prompt.getMaxOutputTokens())\n                .user(prompt.getUser());\n\n        if (prompt.getParallelToolCalls() != null) {\n            builder.parallelToolCalls(prompt.getParallelToolCalls());\n        }\n\n        if (prompt.getToolChoice() instanceof String) {\n            builder.toolChoice((String) prompt.getToolChoice());\n        }\n\n        List<Tool> tools = convertTools(prompt.getTools());\n        if (!tools.isEmpty()) {\n            builder.tools(tools);\n            builder.passThroughToolCalls(Boolean.TRUE);\n        }\n\n        Map<String, Object> extraBody = prompt.getExtraBody();\n        if (extraBody != null) {\n            builder.extraBody(extraBody);\n        }\n\n        return builder.build();\n    }\n\n    private List<Tool> convertTools(List<Object> tools) {\n        List<Tool> converted = new ArrayList<>();\n        if (tools == null) {\n            return converted;\n        }\n        for (Object tool : tools) {\n            if (tool instanceof Tool) {\n                converted.add((Tool) tool);\n            }\n        }\n        return converted;\n    }\n\n    private ChatMessage convertToMessage(Object item) {\n        if (item == null) {\n            return null;\n        }\n        if (item instanceof ChatMessage) {\n            return (ChatMessage) item;\n        }\n        if (item instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> map = (Map<String, Object>) item;\n            Object type = map.get(\"type\");\n            if (\"message\".equals(type)) {\n                String role = valueAsString(map.get(\"role\"));\n                List<ToolCall> toolCalls = convertMessageToolCalls(map.get(\"tool_calls\"));\n                ChatMessage message = buildMessageFromContent(role, map.get(\"content\"));\n                if (!toolCalls.isEmpty() && \"assistant\".equals(role)) {\n                    if (message == null) {\n                        return ChatMessage.withAssistant(toolCalls);\n                    }\n                    return ChatMessage.builder()\n                            .role(message.getRole())\n                            .content(message.getContent())\n                            .name(message.getName())\n                            .reasoningContent(message.getReasoningContent())\n                            .toolCalls(toolCalls)\n                            .build();\n                }\n                if (message != null) {\n                    return message;\n                }\n            }\n            if (\"function_call_output\".equals(type)) {\n                String callId = valueAsString(map.get(\"call_id\"));\n                String output = valueAsString(map.get(\"output\"));\n                if (callId != null) {\n                    return ChatMessage.withTool(output, callId);\n                }\n            }\n        }\n        return null;\n    }\n\n    private List<ToolCall> convertMessageToolCalls(Object value) {\n        List<ToolCall> toolCalls = new ArrayList<ToolCall>();\n        if (!(value instanceof List)) {\n            return toolCalls;\n        }\n        @SuppressWarnings(\"unchecked\")\n        List<Object> rawCalls = (List<Object>) value;\n        for (Object rawCall : rawCalls) {\n            if (rawCall instanceof ToolCall) {\n                toolCalls.add((ToolCall) rawCall);\n                continue;\n            }\n            if (!(rawCall instanceof Map)) {\n                continue;\n            }\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> rawMap = (Map<String, Object>) rawCall;\n            Object functionValue = rawMap.get(\"function\");\n            if (!(functionValue instanceof Map)) {\n                continue;\n            }\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> functionMap = (Map<String, Object>) functionValue;\n            toolCalls.add(new ToolCall(\n                    valueAsString(rawMap.get(\"id\")),\n                    valueAsString(rawMap.get(\"type\")),\n                    new ToolCall.Function(\n                            valueAsString(functionMap.get(\"name\")),\n                            valueAsString(functionMap.get(\"arguments\"))\n                    )\n            ));\n        }\n        return toolCalls;\n    }\n\n    private ChatMessage buildMessageFromContent(String role, Object content) {\n        if (role == null || content == null) {\n            return null;\n        }\n        if (content instanceof String) {\n            String text = (String) content;\n            return text.isEmpty() ? null : new ChatMessage(role, text);\n        }\n        if (content instanceof List) {\n            @SuppressWarnings(\"unchecked\")\n            List<Object> parts = (List<Object>) content;\n            List<Content.MultiModal> multiModals = new ArrayList<>();\n            StringBuilder textBuilder = new StringBuilder();\n            boolean hasImage = false;\n\n            for (Object part : parts) {\n                if (!(part instanceof Map)) {\n                    continue;\n                }\n                @SuppressWarnings(\"unchecked\")\n                Map<String, Object> map = (Map<String, Object>) part;\n                Object type = map.get(\"type\");\n                if (\"input_text\".equals(type)) {\n                    String text = valueAsString(map.get(\"text\"));\n                    if (text != null && !text.isEmpty()) {\n                        textBuilder.append(text);\n                        multiModals.add(new Content.MultiModal(Content.MultiModal.Type.TEXT.getType(), text, null));\n                    }\n                }\n                if (\"input_image\".equals(type)) {\n                    String url = extractImageUrl(map.get(\"image_url\"));\n                    if (url != null && !url.isEmpty()) {\n                        hasImage = true;\n                        multiModals.add(new Content.MultiModal(Content.MultiModal.Type.IMAGE_URL.getType(), null, new Content.MultiModal.ImageUrl(url)));\n                    }\n                }\n            }\n\n            if (hasImage) {\n                if (multiModals.isEmpty()) {\n                    return null;\n                }\n                return ChatMessage.builder()\n                        .role(role)\n                        .content(Content.ofMultiModals(multiModals))\n                        .build();\n            }\n\n            if (textBuilder.length() > 0) {\n                return new ChatMessage(role, textBuilder.toString());\n            }\n        }\n        return null;\n    }\n\n    private String extractImageUrl(Object imageUrl) {\n        if (imageUrl == null) {\n            return null;\n        }\n        if (imageUrl instanceof String) {\n            return (String) imageUrl;\n        }\n        if (imageUrl instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> map = (Map<String, Object>) imageUrl;\n            return valueAsString(map.get(\"url\"));\n        }\n        return null;\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private AgentModelResult toModelResult(ChatCompletionResponse response) {\n        if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) {\n            return AgentModelResult.builder().rawResponse(response).build();\n        }\n        ChatMessage message = response.getChoices().get(0).getMessage();\n        String outputText = null;\n        String reasoningText = null;\n        List<AgentToolCall> toolCalls = new ArrayList<>();\n        if (message != null) {\n            if (message.getContent() != null) {\n                outputText = message.getContent().getText();\n            }\n            reasoningText = message.getReasoningContent();\n            if (message.getToolCalls() != null) {\n                for (ToolCall toolCall : message.getToolCalls()) {\n                    if (toolCall == null || toolCall.getFunction() == null) {\n                        continue;\n                    }\n                    toolCalls.add(AgentToolCall.builder()\n                            .callId(toolCall.getId())\n                            .name(toolCall.getFunction().getName())\n                            .arguments(toolCall.getFunction().getArguments())\n                            .type(toolCall.getType())\n                            .build());\n                }\n            }\n        }\n\n        List<Object> memoryItems = buildAssistantMemoryItems(outputText, toolCalls);\n\n        return AgentModelResult.builder()\n                .reasoningText(reasoningText == null ? \"\" : reasoningText)\n                .outputText(outputText == null ? \"\" : outputText)\n                .toolCalls(toolCalls)\n                .memoryItems(memoryItems)\n                .rawResponse(response)\n                .build();\n    }\n\n    private final class StreamingSseListener extends SseListener {\n\n        private final AgentModelStreamListener listener;\n\n        private StreamingSseListener(AgentModelStreamListener listener) {\n            this.listener = listener;\n        }\n\n        @Override\n        protected void error(Throwable t, okhttp3.Response response) {\n            if (listener != null) {\n                listener.onError(t);\n            }\n        }\n\n        @Override\n        protected void send() {\n            if (listener == null) {\n                return;\n            }\n            String delta = getCurrStr();\n            if (delta == null || delta.isEmpty()) {\n                return;\n            }\n            if (isReasoning()) {\n                listener.onReasoningDelta(delta);\n                return;\n            }\n            listener.onDeltaText(delta);\n        }\n\n        @Override\n        protected void retry(Throwable t, int attempt, int maxAttempts) {\n            if (listener == null) {\n                return;\n            }\n            String message = t == null || t.getMessage() == null || t.getMessage().trim().isEmpty()\n                    ? \"Retrying model stream\"\n                    : t.getMessage().trim();\n            listener.onRetry(message, attempt, maxAttempts, t);\n        }\n\n        private AgentModelResult toResult() {\n            String outputText = getOutput().toString();\n            String reasoningText = getReasoningOutput().toString();\n            List<AgentToolCall> calls = convertToolCalls(getToolCalls());\n            List<Object> memoryItems = buildAssistantMemoryItems(outputText, calls);\n\n            Map<String, Object> rawResponse = new LinkedHashMap<>();\n            rawResponse.put(\"mode\", \"chat.stream\");\n            rawResponse.put(\"outputText\", outputText);\n            rawResponse.put(\"reasoningText\", reasoningText);\n            rawResponse.put(\"finishReason\", getFinishReason());\n            rawResponse.put(\"toolCalls\", getToolCalls());\n            rawResponse.put(\"usage\", getUsage());\n\n            return AgentModelResult.builder()\n                    .reasoningText(reasoningText)\n                    .outputText(outputText)\n                    .toolCalls(calls)\n                    .memoryItems(memoryItems)\n                    .rawResponse(rawResponse)\n                    .build();\n        }\n    }\n\n    private List<Object> buildAssistantMemoryItems(String outputText, List<AgentToolCall> toolCalls) {\n        List<Object> memoryItems = new ArrayList<Object>();\n        if (toolCalls != null && !toolCalls.isEmpty()) {\n            memoryItems.add(io.github.lnyocly.ai4j.agent.util.AgentInputItem.assistantToolCallsMessage(\n                    outputText == null ? \"\" : outputText,\n                    toolCalls\n            ));\n            return memoryItems;\n        }\n        if (outputText != null && !outputText.isEmpty()) {\n            memoryItems.add(io.github.lnyocly.ai4j.agent.util.AgentInputItem.message(\"assistant\", outputText));\n        }\n        return memoryItems;\n    }\n\n    private List<AgentToolCall> convertToolCalls(List<ToolCall> toolCalls) {\n        List<AgentToolCall> calls = new ArrayList<>();\n        if (toolCalls == null) {\n            return calls;\n        }\n        for (ToolCall toolCall : toolCalls) {\n            if (toolCall == null || toolCall.getFunction() == null) {\n                continue;\n            }\n            calls.add(AgentToolCall.builder()\n                    .callId(toolCall.getId())\n                    .name(toolCall.getFunction().getName())\n                    .arguments(toolCall.getFunction().getArguments())\n                    .type(toolCall.getType())\n                    .build());\n        }\n        return calls;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/ResponsesModelClient.java",
    "content": "package io.github.lnyocly.ai4j.agent.model;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.util.ResponseUtil;\nimport io.github.lnyocly.ai4j.listener.ResponseSseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\n\npublic class ResponsesModelClient implements AgentModelClient {\n\n    private static final ConcurrentMap<Thread, ResponseSseListener> ACTIVE_STREAMS =\n            new ConcurrentHashMap<Thread, ResponseSseListener>();\n\n    private final IResponsesService responsesService;\n    private final String baseUrl;\n    private final String apiKey;\n\n    public ResponsesModelClient(IResponsesService responsesService) {\n        this(responsesService, null, null);\n    }\n\n    public ResponsesModelClient(IResponsesService responsesService, String baseUrl, String apiKey) {\n        this.responsesService = responsesService;\n        this.baseUrl = baseUrl;\n        this.apiKey = apiKey;\n    }\n\n    @Override\n    public AgentModelResult create(AgentPrompt prompt) throws Exception {\n        ResponseRequest request = toResponseRequest(prompt, false);\n        Response response = responsesService.create(baseUrl, apiKey, request);\n        return toModelResult(response);\n    }\n\n    @Override\n    public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception {\n        ResponseRequest request = toResponseRequest(prompt, true);\n        ResponseSseListener sseListener = new ResponseSseListener() {\n            @Override\n            protected void onEvent() {\n                if (listener != null && !getCurrText().isEmpty()) {\n                    listener.onDeltaText(getCurrText());\n                }\n            }\n\n            @Override\n            protected void error(Throwable t, okhttp3.Response response) {\n                if (listener != null) {\n                    listener.onError(t);\n                }\n            }\n\n            @Override\n            protected void retry(Throwable t, int attempt, int maxAttempts) {\n                if (listener == null) {\n                    return;\n                }\n                String message = t == null || t.getMessage() == null || t.getMessage().trim().isEmpty()\n                        ? \"Retrying model stream\"\n                        : t.getMessage().trim();\n                listener.onRetry(message, attempt, maxAttempts, t);\n            }\n        };\n        Thread currentThread = Thread.currentThread();\n        ACTIVE_STREAMS.put(currentThread, sseListener);\n        try {\n            responsesService.createStream(baseUrl, apiKey, request, sseListener);\n            throwIfInterrupted(sseListener);\n            sseListener.dispatchFailure();\n            Response response = sseListener.getResponse();\n            AgentModelResult result = toModelResult(response);\n            throwIfInterrupted(sseListener);\n            if (listener != null) {\n                listener.onComplete(result);\n            }\n            return result;\n        } catch (InterruptedException ex) {\n            sseListener.cancelStream();\n            Thread.currentThread().interrupt();\n            throw ex;\n        } catch (Exception ex) {\n            if (currentThread.isInterrupted()) {\n                sseListener.cancelStream();\n            }\n            throw ex;\n        } finally {\n            ACTIVE_STREAMS.remove(currentThread, sseListener);\n        }\n    }\n\n    public static void cancelActiveStream(Thread thread) {\n        if (thread == null) {\n            return;\n        }\n        ResponseSseListener listener = ACTIVE_STREAMS.get(thread);\n        if (listener != null) {\n            listener.cancelStream();\n        }\n    }\n\n    private void throwIfInterrupted(ResponseSseListener sseListener) throws InterruptedException {\n        if (!Thread.currentThread().isInterrupted()) {\n            return;\n        }\n        sseListener.cancelStream();\n        throw new InterruptedException(\"Model stream interrupted\");\n    }\n\n    private ResponseRequest toResponseRequest(AgentPrompt prompt, boolean forceStream) {\n        if (prompt == null) {\n            throw new IllegalArgumentException(\"prompt is required\");\n        }\n        ResponseRequest.ResponseRequestBuilder builder = ResponseRequest.builder();\n        builder.model(prompt.getModel());\n        builder.input(buildItems(prompt));\n        builder.tools(prompt.getTools());\n        builder.toolChoice(prompt.getToolChoice());\n        builder.parallelToolCalls(prompt.getParallelToolCalls());\n        builder.temperature(prompt.getTemperature());\n        builder.topP(prompt.getTopP());\n        builder.maxOutputTokens(prompt.getMaxOutputTokens());\n        builder.reasoning(prompt.getReasoning());\n        builder.store(prompt.getStore());\n        builder.user(prompt.getUser());\n\n        Boolean stream = prompt.getStream();\n        if (stream == null) {\n            stream = forceStream;\n        }\n        builder.stream(stream);\n        builder.streamExecution(prompt.getStreamExecution());\n\n        if (prompt.getSystemPrompt() != null && !prompt.getSystemPrompt().trim().isEmpty()) {\n            builder.instructions(prompt.getSystemPrompt());\n        }\n\n        Map<String, Object> extraBody = prompt.getExtraBody();\n        if (extraBody != null) {\n            builder.extraBody(extraBody);\n        }\n        return builder.build();\n    }\n\n    private AgentModelResult toModelResult(Response response) {\n        List<AgentToolCall> calls = ResponseUtil.extractToolCalls(response);\n        List<Object> memoryItems = new ArrayList<>();\n        if (response != null && response.getOutput() != null) {\n            memoryItems.addAll(response.getOutput());\n        }\n        return AgentModelResult.builder()\n                .outputText(ResponseUtil.extractOutputText(response))\n                .toolCalls(calls)\n                .memoryItems(memoryItems)\n                .rawResponse(response)\n                .build();\n    }\n\n    private List<Object> buildItems(AgentPrompt prompt) {\n        List<Object> items = new ArrayList<>();\n        if (prompt.getItems() != null) {\n            items.addAll(prompt.getItems());\n        }\n        if (prompt.getInstructions() != null && !prompt.getInstructions().trim().isEmpty()) {\n            items.add(0, io.github.lnyocly.ai4j.agent.util.AgentInputItem.systemMessage(prompt.getInstructions()));\n        }\n        return items;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/AgentToolExecutionScope.java",
    "content": "package io.github.lnyocly.ai4j.agent.runtime;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\n\npublic final class AgentToolExecutionScope {\n\n    public interface EventEmitter {\n\n        void emit(AgentEventType type, String message, Object payload);\n    }\n\n    public interface ScopeCallable<T> {\n\n        T call() throws Exception;\n    }\n\n    private static final ThreadLocal<EventEmitter> CURRENT = new ThreadLocal<EventEmitter>();\n\n    private AgentToolExecutionScope() {\n    }\n\n    public static <T> T runWithEmitter(EventEmitter emitter, ScopeCallable<T> callable) throws Exception {\n        if (callable == null) {\n            return null;\n        }\n        EventEmitter previous = CURRENT.get();\n        if (emitter == null) {\n            CURRENT.remove();\n        } else {\n            CURRENT.set(emitter);\n        }\n        try {\n            return callable.call();\n        } finally {\n            if (previous == null) {\n                CURRENT.remove();\n            } else {\n                CURRENT.set(previous);\n            }\n        }\n    }\n\n    public static void emit(AgentEventType type, String message, Object payload) {\n        EventEmitter emitter = CURRENT.get();\n        if (emitter != null && type != null) {\n            emitter.emit(type, message, payload);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/BaseAgentRuntime.java",
    "content": "package io.github.lnyocly.ai4j.agent.runtime;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventPublisher;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.memory.AgentMemory;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCallSanitizer;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\n\npublic abstract class BaseAgentRuntime implements io.github.lnyocly.ai4j.agent.AgentRuntime {\n\n    protected String runtimeName() {\n        return \"base\";\n    }\n\n    protected String runtimeInstructions() {\n        return null;\n    }\n\n    @Override\n    public AgentResult run(AgentContext context, AgentRequest request) throws Exception {\n        return runInternal(context, request, null);\n    }\n\n    @Override\n    public void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception {\n        runInternal(context, request, listener);\n    }\n\n    @Override\n    public AgentResult runStreamResult(AgentContext context, AgentRequest request, AgentListener listener) throws Exception {\n        return runInternal(context, request, listener);\n    }\n\n    protected AgentResult runInternal(AgentContext context, AgentRequest request, AgentListener listener) throws Exception {\n        AgentOptions options = context.getOptions();\n        int maxSteps = options == null ? 0 : options.getMaxSteps();\n        boolean stream = listener != null && options != null && options.isStream();\n\n        AgentMemory memory = context.getMemory();\n        if (memory == null) {\n            throw new IllegalStateException(\"memory is required\");\n        }\n\n        if (request != null && request.getInput() != null) {\n            memory.addUserInput(request.getInput());\n        }\n\n        List<AgentToolCall> toolCalls = new ArrayList<>();\n        List<AgentToolResult> toolResults = new ArrayList<>();\n        int step = 0;\n        AgentModelResult lastResult = null;\n        boolean stepLimited = maxSteps > 0;\n\n        while (!stepLimited || step < maxSteps) {\n            throwIfInterrupted();\n            publish(context, listener, AgentEventType.STEP_START, step, runtimeName(), null);\n\n            AgentPrompt prompt = buildPrompt(context, memory, stream);\n            AgentModelResult modelResult = executeModel(context, prompt, listener, step, stream);\n            throwIfInterrupted();\n            lastResult = modelResult;\n\n            if (modelResult != null && modelResult.getMemoryItems() != null) {\n                memory.addOutputItems(modelResult.getMemoryItems());\n            }\n\n            List<AgentToolCall> calls = normalizeToolCalls(modelResult == null ? null : modelResult.getToolCalls(), step);\n            if (calls == null || calls.isEmpty()) {\n                String outputText = modelResult == null ? \"\" : modelResult.getOutputText();\n                publish(context, listener, AgentEventType.FINAL_OUTPUT, step, outputText, modelResult == null ? null : modelResult.getRawResponse());\n                publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null);\n                return AgentResult.builder()\n                        .outputText(outputText)\n                        .rawResponse(modelResult == null ? null : modelResult.getRawResponse())\n                        .toolCalls(toolCalls)\n                        .toolResults(toolResults)\n                        .steps(step + 1)\n                        .build();\n            }\n\n            List<AgentToolCall> validatedCalls = new ArrayList<AgentToolCall>();\n            for (AgentToolCall call : calls) {\n                toolCalls.add(call);\n                publish(context, listener, AgentEventType.TOOL_CALL, step, call.getName(), call);\n                String validationError = AgentToolCallSanitizer.validationError(call);\n                if (validationError == null) {\n                    validatedCalls.add(call);\n                    continue;\n                }\n                String output = buildToolValidationErrorOutput(call, validationError);\n                AgentToolResult toolResult = AgentToolResult.builder()\n                        .name(call.getName())\n                        .callId(call.getCallId())\n                        .output(output)\n                        .build();\n                toolResults.add(toolResult);\n                memory.addToolOutput(call.getCallId(), output);\n                publish(context, listener, AgentEventType.TOOL_RESULT, step, output, toolResult);\n            }\n\n            boolean parallelExecution = Boolean.TRUE.equals(context.getParallelToolCalls()) && validatedCalls.size() > 1;\n            List<String> outputs = parallelExecution\n                    ? executeToolCallsInParallel(context, validatedCalls, step, listener)\n                    : executeToolCallsSequential(context, validatedCalls, step, listener);\n            throwIfInterrupted();\n\n            for (int i = 0; i < validatedCalls.size(); i++) {\n                AgentToolCall call = validatedCalls.get(i);\n                String output = outputs.get(i);\n\n                AgentToolResult toolResult = AgentToolResult.builder()\n                        .name(call.getName())\n                        .callId(call.getCallId())\n                        .output(output)\n                        .build();\n                toolResults.add(toolResult);\n                memory.addToolOutput(call.getCallId(), output);\n                publish(context, listener, AgentEventType.TOOL_RESULT, step, output, toolResult);\n            }\n\n            publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null);\n            step += 1;\n        }\n\n        String outputText = lastResult == null ? \"\" : lastResult.getOutputText();\n        return AgentResult.builder()\n                .outputText(outputText)\n                .rawResponse(lastResult == null ? null : lastResult.getRawResponse())\n                .toolCalls(toolCalls)\n                .toolResults(toolResults)\n                .steps(step)\n                .build();\n    }\n\n    private void throwIfInterrupted() throws InterruptedException {\n        if (Thread.currentThread().isInterrupted()) {\n            throw new InterruptedException(\"Agent run interrupted\");\n        }\n    }\n\n    private List<AgentToolCall> normalizeToolCalls(List<AgentToolCall> calls, int step) {\n        List<AgentToolCall> normalized = new ArrayList<AgentToolCall>();\n        if (calls == null || calls.isEmpty()) {\n            return normalized;\n        }\n        int index = 0;\n        for (AgentToolCall call : calls) {\n            if (call == null) {\n                index++;\n                continue;\n            }\n            String callId = trimToNull(call.getCallId());\n            if (callId == null) {\n                callId = \"tool_step_\" + step + \"_\" + index;\n            }\n            normalized.add(AgentToolCall.builder()\n                    .callId(callId)\n                    .name(trimToNull(call.getName()) == null ? \"tool\" : call.getName().trim())\n                    .arguments(call.getArguments())\n                    .type(call.getType())\n                    .build());\n            index++;\n        }\n        return normalized;\n    }\n\n    protected AgentPrompt buildPrompt(AgentContext context, AgentMemory memory, boolean stream) {\n        if (context.getModel() == null || context.getModel().trim().isEmpty()) {\n            throw new IllegalStateException(\"model is required\");\n        }\n        AgentOptions options = context.getOptions();\n        String systemPrompt = mergeText(context.getSystemPrompt(), runtimeInstructions());\n\n        List<Object> tools = context.getToolRegistry() == null ? null : context.getToolRegistry().getTools();\n        AgentPrompt.AgentPromptBuilder builder = AgentPrompt.builder()\n                .model(context.getModel())\n                .items(memory.getItems())\n                .systemPrompt(systemPrompt)\n                .instructions(context.getInstructions())\n                .tools(tools)\n                .toolChoice(context.getToolChoice())\n                .parallelToolCalls(context.getParallelToolCalls())\n                .temperature(context.getTemperature())\n                .topP(context.getTopP())\n                .maxOutputTokens(context.getMaxOutputTokens())\n                .reasoning(context.getReasoning())\n                .store(context.getStore())\n                .stream(stream)\n                .user(context.getUser())\n                .extraBody(context.getExtraBody())\n                .streamExecution(options == null ? null : options.getStreamExecution());\n\n        return builder.build();\n    }\n\n    protected AgentModelResult executeModel(AgentContext context, AgentPrompt prompt, AgentListener listener, int step, boolean stream) throws Exception {\n        publish(context, listener, AgentEventType.MODEL_REQUEST, step, null, prompt);\n        AgentModelResult result;\n        if (stream) {\n            final boolean[] streamedReasoning = new boolean[]{false};\n            final boolean[] streamedText = new boolean[]{false};\n            AgentModelStreamListener streamListener = new AgentModelStreamListener() {\n                @Override\n                public void onReasoningDelta(String delta) {\n                    if (delta != null && !delta.isEmpty()) {\n                        streamedReasoning[0] = true;\n                        publish(context, listener, AgentEventType.MODEL_REASONING, step, delta, null);\n                    }\n                }\n\n                @Override\n                public void onDeltaText(String delta) {\n                    if (delta != null && !delta.isEmpty()) {\n                        streamedText[0] = true;\n                        publish(context, listener, AgentEventType.MODEL_RESPONSE, step, delta, null);\n                    }\n                }\n\n                @Override\n                public void onToolCall(AgentToolCall call) {\n                    if (call != null) {\n                        publish(context, listener, AgentEventType.TOOL_CALL, step, call.getName(), call);\n                    }\n                }\n\n                @Override\n                public void onEvent(Object event) {\n                    publish(context, listener, AgentEventType.MODEL_RESPONSE, step, null, event);\n                }\n\n                @Override\n                public void onError(Throwable t) {\n                    publish(context, listener, AgentEventType.ERROR, step, t == null ? null : t.getMessage(), t);\n                }\n\n                @Override\n                public void onRetry(String message, int attempt, int maxAttempts, Throwable cause) {\n                    publish(context, listener, AgentEventType.MODEL_RETRY, step, message, retryPayload(attempt, maxAttempts, cause));\n                }\n            };\n            result = context.getModelClient().createStream(prompt, streamListener);\n            if (!streamedReasoning[0] && result != null && result.getReasoningText() != null && !result.getReasoningText().isEmpty()) {\n                publish(context, listener, AgentEventType.MODEL_REASONING, step, result.getReasoningText(), null);\n            }\n            if (!streamedText[0] && result != null && result.getOutputText() != null && !result.getOutputText().isEmpty()) {\n                publish(context, listener, AgentEventType.MODEL_RESPONSE, step, result.getOutputText(), null);\n            }\n            publish(context, listener, AgentEventType.MODEL_RESPONSE, step, null, result == null ? null : result.getRawResponse());\n        } else {\n            result = context.getModelClient().create(prompt);\n            if (result != null && result.getReasoningText() != null && !result.getReasoningText().isEmpty()) {\n                publish(context, listener, AgentEventType.MODEL_REASONING, step, result.getReasoningText(), null);\n            }\n            if (result != null && result.getOutputText() != null && !result.getOutputText().isEmpty()) {\n                publish(context, listener, AgentEventType.MODEL_RESPONSE, step, result.getOutputText(), null);\n            }\n            publish(context, listener, AgentEventType.MODEL_RESPONSE, step, null, result == null ? null : result.getRawResponse());\n        }\n        return result;\n    }\n\n    private Map<String, Object> retryPayload(int attempt, int maxAttempts, Throwable cause) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"attempt\", attempt);\n        payload.put(\"maxAttempts\", maxAttempts);\n        if (cause != null && cause.getMessage() != null && !cause.getMessage().trim().isEmpty()) {\n            payload.put(\"reason\", cause.getMessage().trim());\n        }\n        return payload;\n    }\n\n    protected String executeTool(AgentContext context, AgentToolCall call) throws Exception {\n        return executeTool(context, call, null, null);\n    }\n\n    protected String executeTool(AgentContext context,\n                                 AgentToolCall call,\n                                 Integer step,\n                                 AgentListener listener) throws Exception {\n        ToolExecutor executor = context.getToolExecutor();\n        if (executor == null) {\n            throw new IllegalStateException(\"toolExecutor is required\");\n        }\n        try {\n            return AgentToolExecutionScope.runWithEmitter(new AgentToolExecutionScope.EventEmitter() {\n                @Override\n                public void emit(AgentEventType type, String message, Object payload) {\n                    publish(context, listener, type, step == null ? 0 : step, message, payload);\n                }\n            }, new AgentToolExecutionScope.ScopeCallable<String>() {\n                @Override\n                public String call() throws Exception {\n                    return executor.execute(call);\n                }\n            });\n        } catch (InterruptedException interruptedException) {\n            Thread.currentThread().interrupt();\n            throw interruptedException;\n        } catch (Exception ex) {\n            return buildToolErrorOutput(call, ex);\n        }\n    }\n\n    protected String buildToolErrorOutput(AgentToolCall call, Exception error) {\n        JSONObject payload = new JSONObject();\n        payload.put(\"error\", safeToolErrorMessage(error));\n        if (call != null) {\n            payload.put(\"tool\", call.getName());\n            if (call.getCallId() != null) {\n                payload.put(\"callId\", call.getCallId());\n            }\n        }\n        return \"TOOL_ERROR: \" + JSON.toJSONString(payload);\n    }\n\n    protected String buildToolValidationErrorOutput(AgentToolCall call, String validationError) {\n        return buildToolErrorOutput(call, new IllegalArgumentException(validationError));\n    }\n\n    private String safeToolErrorMessage(Exception error) {\n        if (error == null || error.getMessage() == null || error.getMessage().trim().isEmpty()) {\n            return error == null ? \"unknown tool failure\" : error.getClass().getSimpleName();\n        }\n        return error.getMessage().trim();\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private List<String> executeToolCallsSequential(AgentContext context,\n                                                    List<AgentToolCall> calls,\n                                                    Integer step,\n                                                    AgentListener listener) throws Exception {\n        List<String> outputs = new ArrayList<>();\n        for (AgentToolCall call : calls) {\n            outputs.add(executeTool(context, call, step, listener));\n        }\n        return outputs;\n    }\n\n    private List<String> executeToolCallsInParallel(AgentContext context,\n                                                    List<AgentToolCall> calls,\n                                                    Integer step,\n                                                    AgentListener listener) throws Exception {\n        ExecutorService executor = Executors.newFixedThreadPool(calls.size());\n        try {\n            List<Future<String>> futures = new ArrayList<>();\n            for (AgentToolCall call : calls) {\n                futures.add(executor.submit(() -> executeTool(context, call, step, listener)));\n            }\n            List<String> outputs = new ArrayList<>();\n            for (Future<String> future : futures) {\n                outputs.add(waitForFuture(future));\n            }\n            return outputs;\n        } finally {\n            executor.shutdownNow();\n        }\n    }\n\n    private String waitForFuture(Future<String> future) throws Exception {\n        try {\n            return future.get();\n        } catch (InterruptedException e) {\n            Thread.currentThread().interrupt();\n            throw e;\n        } catch (ExecutionException e) {\n            Throwable cause = e.getCause();\n            if (cause instanceof Exception) {\n                throw (Exception) cause;\n            }\n            throw new RuntimeException(cause);\n        }\n    }\n\n    protected void publish(AgentContext context, AgentListener listener, AgentEventType type, int step, String message, Object payload) {\n        AgentEvent event = AgentEvent.builder()\n                .type(type)\n                .step(step)\n                .message(message)\n                .payload(payload)\n                .build();\n        AgentEventPublisher publisher = context.getEventPublisher();\n        if (publisher != null) {\n            publisher.publish(event);\n        }\n        if (listener != null) {\n            listener.onEvent(event);\n        }\n    }\n\n    private String mergeText(String base, String extra) {\n        if (base == null || base.trim().isEmpty()) {\n            return extra;\n        }\n        if (extra == null || extra.trim().isEmpty()) {\n            return base;\n        }\n        return base + \"\\n\" + extra;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/CodeActRuntime.java",
    "content": "package io.github.lnyocly.ai4j.agent.runtime;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeActOptions;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionRequest;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutor;\nimport io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.memory.AgentMemory;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class CodeActRuntime extends BaseAgentRuntime {\n\n    private static final String CODE_CALL_ID = \"code_execution\";\n\n    @Override\n    protected String runtimeName() {\n        return \"codeact\";\n    }\n\n    @Override\n    public AgentResult run(AgentContext context, AgentRequest request) throws Exception {\n        return runInternal(context, request, null);\n    }\n\n    @Override\n    public void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception {\n        runInternal(context, request, listener);\n    }\n\n    protected AgentResult runInternal(AgentContext context, AgentRequest request, AgentListener listener) throws Exception {\n        AgentOptions options = context.getOptions();\n        int maxSteps = options == null ? 0 : options.getMaxSteps();\n        CodeActOptions codeActOptions = context.getCodeActOptions();\n        boolean reAct = codeActOptions != null && codeActOptions.isReAct();\n\n        AgentMemory memory = context.getMemory();\n        if (memory == null) {\n            throw new IllegalStateException(\"memory is required\");\n        }\n        if (request != null && request.getInput() != null) {\n            memory.addUserInput(request.getInput());\n        }\n\n        CodeExecutor codeExecutor = context.getCodeExecutor();\n        if (codeExecutor == null) {\n            throw new IllegalStateException(\"codeExecutor is required\");\n        }\n\n        List<AgentToolCall> toolCalls = new ArrayList<>();\n        List<AgentToolResult> toolResults = new ArrayList<>();\n        AgentModelResult lastResult = null;\n        boolean finalizeRequested = false;\n\n        int step = 0;\n        boolean stepLimited = maxSteps > 0;\n        while (!stepLimited || step < maxSteps) {\n            publish(context, listener, AgentEventType.STEP_START, step, runtimeName(), null);\n\n            AgentPrompt prompt = buildPrompt(context, memory, false);\n            AgentModelResult modelResult = executeModel(context, prompt, listener, step, false);\n            lastResult = modelResult;\n\n            if (modelResult != null && modelResult.getMemoryItems() != null) {\n                memory.addOutputItems(modelResult.getMemoryItems());\n            }\n\n            String output = modelResult == null ? null : modelResult.getOutputText();\n            CodeActMessage message = parseMessage(output);\n            if (reAct && finalizeRequested && message != null && \"code\".equals(message.type)) {\n                memory.addOutputItems(java.util.Collections.singletonList(\n                        AgentInputItem.systemMessage(\"FINALIZE_MODE: Do not output code. Use the latest CODE_RESULT to respond with {\\\"type\\\":\\\"final\\\",\\\"output\\\":\\\"...\\\"}.\")\n                ));\n                publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null);\n                step += 1;\n                continue;\n            }\n            if (message == null || \"final\".equals(message.type)) {\n                String answer = message == null ? output : message.output;\n                publish(context, listener, AgentEventType.FINAL_OUTPUT, step, answer, modelResult == null ? null : modelResult.getRawResponse());\n                publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null);\n                return AgentResult.builder()\n                        .outputText(answer == null ? \"\" : answer)\n                        .rawResponse(modelResult == null ? null : modelResult.getRawResponse())\n                        .toolCalls(toolCalls)\n                        .toolResults(toolResults)\n                        .steps(step + 1)\n                        .build();\n            }\n\n            if (!\"code\".equals(message.type) || message.code == null) {\n                publish(context, listener, AgentEventType.FINAL_OUTPUT, step, output, modelResult == null ? null : modelResult.getRawResponse());\n                publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null);\n                return AgentResult.builder()\n                        .outputText(output == null ? \"\" : output)\n                        .rawResponse(modelResult == null ? null : modelResult.getRawResponse())\n                        .toolCalls(toolCalls)\n                        .toolResults(toolResults)\n                        .steps(step + 1)\n                        .build();\n            }\n\n            AgentToolCall toolCall = AgentToolCall.builder()\n                    .name(\"code\")\n                    .arguments(message.code)\n                    .callId(CODE_CALL_ID + \"_\" + step)\n                    .build();\n            toolCalls.add(toolCall);\n            publish(context, listener, AgentEventType.TOOL_CALL, step, toolCall.getName(), toolCall);\n\n            CodeExecutionResult execResult = codeExecutor.execute(CodeExecutionRequest.builder()\n                    .language(message.language)\n                    .code(message.code)\n                    .toolNames(extractToolNames(context.getToolRegistry() == null ? null : context.getToolRegistry().getTools()))\n                    .toolExecutor(context.getToolExecutor())\n                    .user(context.getUser())\n                    .build());\n\n            String toolOutput = buildToolOutput(execResult);\n            toolResults.add(AgentToolResult.builder()\n                    .name(\"code\")\n                    .callId(toolCall.getCallId())\n                    .output(toolOutput)\n                    .build());\n            String toolMessage = (execResult != null && execResult.isSuccess())\n                    ? \"CODE_RESULT: \" + toolOutput\n                    : \"CODE_ERROR: \" + toolOutput;\n            memory.addOutputItems(java.util.Collections.singletonList(AgentInputItem.systemMessage(toolMessage)));\n            publish(context, listener, AgentEventType.TOOL_RESULT, step, toolOutput, execResult);\n            if (reAct) {\n                finalizeRequested = execResult != null && execResult.isSuccess();\n            }\n\n            String directOutput = resolveDirectOutput(execResult);\n            String fallbackOutput = resolveFallbackOutput(execResult, toolOutput);\n            String finalOutput = directOutput == null ? fallbackOutput : directOutput;\n            if (!reAct && finalOutput != null) {\n                publish(context, listener, AgentEventType.FINAL_OUTPUT, step, finalOutput, modelResult == null ? null : modelResult.getRawResponse());\n                publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null);\n                return AgentResult.builder()\n                        .outputText(finalOutput)\n                        .rawResponse(modelResult == null ? null : modelResult.getRawResponse())\n                        .toolCalls(toolCalls)\n                        .toolResults(toolResults)\n                        .steps(step + 1)\n                        .build();\n            }\n\n            publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null);\n            step += 1;\n        }\n\n        String outputText = lastResult == null ? \"\" : lastResult.getOutputText();\n        return AgentResult.builder()\n                .outputText(outputText == null ? \"\" : outputText)\n                .rawResponse(lastResult == null ? null : lastResult.getRawResponse())\n                .toolCalls(toolCalls)\n                .toolResults(toolResults)\n                .steps(step)\n                .build();\n    }\n\n    @Override\n    protected AgentPrompt buildPrompt(AgentContext context, AgentMemory memory, boolean stream) {\n        String systemPrompt = mergeText(context.getSystemPrompt(), runtimeInstructions(context));\n        AgentPrompt.AgentPromptBuilder builder = AgentPrompt.builder()\n                .model(context.getModel())\n                .items(memory.getItems())\n                .systemPrompt(systemPrompt)\n                .instructions(context.getInstructions())\n                .temperature(context.getTemperature())\n                .topP(context.getTopP())\n                .maxOutputTokens(context.getMaxOutputTokens())\n                .reasoning(context.getReasoning())\n                .store(context.getStore())\n                .stream(false)\n                .user(context.getUser())\n                .extraBody(context.getExtraBody());\n        return builder.build();\n    }\n\n    private String runtimeInstructions(AgentContext context) {\n        StringBuilder builder = new StringBuilder();\n        if (context.getCodeExecutor() instanceof NashornCodeExecutor) {\n            builder.append(\"You are a CodeAct agent. Use JavaScript code to call tools when needed. \")\n                    .append(\"Respond with a single JSON object only. \")\n                    .append(\"If you need to run code, respond with {\\\"type\\\":\\\"code\\\",\\\"language\\\":\\\"js\\\",\\\"code\\\":\\\"...\\\"}. \")\n                    .append(\"When you have the final answer, respond with {\\\"type\\\":\\\"final\\\",\\\"output\\\":\\\"...\\\"}. \")\n                    .append(\"Do not include any extra text outside the JSON. \")\n                    .append(\"In code, use JavaScript syntax compatible with Nashorn (ES5). \")\n                    .append(\"Do not use Promise, async/await, template literals, let/const, or arrow functions. \")\n                    .append(\"Always return a string or assign the final answer to __codeact_result before code ends. \")\n                    .append(\"If your code returns a value, it will be used as the final answer. \")\n                    .append(\"If you cannot use return, assign the final answer to __codeact_result. \")\n                    .append(\"In code, you may call tools by name (e.g. queryWeather({\\\"location\\\":\\\"Beijing\\\"})) \")\n                    .append(\"or use callTool(\\\"toolName\\\", args). \")\n                    .append(\"If you see a CODE_RESULT message, use it to respond with type=final unless more tools are required. \");\n        } else {\n            builder.append(\"You are a CodeAct agent. Use Python code to call tools when needed. \")\n                    .append(\"Respond with a single JSON object only. \")\n                    .append(\"If you need to run code, respond with {\\\"type\\\":\\\"code\\\",\\\"language\\\":\\\"python\\\",\\\"code\\\":\\\"...\\\"}. \")\n                    .append(\"When you have the final answer, respond with {\\\"type\\\":\\\"final\\\",\\\"output\\\":\\\"...\\\"}. \")\n                    .append(\"Do not include any extra text outside the JSON. \")\n                    .append(\"In code, use Python syntax only. \")\n                    .append(\"If your code returns a value, it will be used as the final answer. \")\n                    .append(\"If you cannot use return, assign the final answer to __codeact_result. \")\n                    .append(\"In code, you may call tools by name (e.g. queryWeather({\\\"location\\\":\\\"Beijing\\\"})) \")\n                    .append(\"or use callTool(\\\"toolName\\\", args). \")\n                    .append(\"If you see a CODE_RESULT message, use it to respond with type=final unless more tools are required. \");\n        }\n        List<Object> tools = context.getToolRegistry() == null ? null : context.getToolRegistry().getTools();\n        String toolGuide = buildToolGuide(tools);\n        if (!toolGuide.isEmpty()) {\n            builder.append(\"Available tools: \").append(toolGuide);\n        }\n        if (hasTool(tools, \"queryWeather\")) {\n            if (context.getCodeExecutor() instanceof NashornCodeExecutor) {\n                builder.append(\" queryWeather returns Seniverse JSON (already parsed when possible).\")\n                        .append(\" Use results[0].daily[0] fields: text_day, high, low, date.\")\n                        .append(\" Example: var day = data.results[0].daily[0];\");\n            } else {\n                builder.append(\" queryWeather returns Seniverse JSON string.\")\n                        .append(\" Parse first, then read data['results'][0]['daily'][0] fields text_day/high/low/date.\");\n            }\n        }\n        return builder.toString();\n    }\n\n\n    private boolean hasTool(List<Object> tools, String name) {\n        if (tools == null || name == null) {\n            return false;\n        }\n        for (Object tool : tools) {\n            if (tool instanceof Tool) {\n                Tool.Function fn = ((Tool) tool).getFunction();\n                if (fn != null && name.equals(fn.getName())) {\n                    return true;\n                }\n            }\n        }\n        return false;\n    }\n    private String buildToolGuide(List<Object> tools) {\n        if (tools == null || tools.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (Object tool : tools) {\n            if (tool instanceof Tool) {\n                Tool.Function fn = ((Tool) tool).getFunction();\n                if (fn != null && fn.getName() != null) {\n                    if (builder.length() > 0) {\n                        builder.append(\"; \");\n                    }\n                    builder.append(fn.getName());\n                    if (fn.getDescription() != null) {\n                        builder.append(\" - \").append(fn.getDescription());\n                    }\n                }\n            }\n        }\n        return builder.toString();\n    }\n\n    private List<String> extractToolNames(List<Object> tools) {\n        List<String> names = new ArrayList<>();\n        if (tools == null) {\n            return names;\n        }\n        for (Object tool : tools) {\n            if (tool instanceof Tool) {\n                Tool.Function fn = ((Tool) tool).getFunction();\n                if (fn != null && fn.getName() != null) {\n                    names.add(fn.getName());\n                }\n            }\n        }\n        return names;\n    }\n\n    private CodeActMessage parseMessage(String output) {\n        if (output == null || output.trim().isEmpty()) {\n            return null;\n        }\n        String json = extractJson(output);\n        if (json == null) {\n            return null;\n        }\n        try {\n            JSONObject obj = JSON.parseObject(json);\n            CodeActMessage message = new CodeActMessage();\n            message.type = valueAsString(obj.get(\"type\"));\n            message.language = valueAsString(obj.get(\"language\"));\n            message.code = valueAsString(obj.get(\"code\"));\n            message.output = valueAsString(obj.get(\"output\"));\n            return message;\n        } catch (Exception e) {\n            return null;\n        }\n    }\n\n    private String extractJson(String text) {\n        int start = -1;\n        int depth = 0;\n        boolean inString = false;\n        boolean escape = false;\n        for (int i = 0; i < text.length(); i++) {\n            char c = text.charAt(i);\n            if (inString) {\n                if (escape) {\n                    escape = false;\n                } else if (c == '\\\\') {\n                    escape = true;\n                } else if (c == '\"') {\n                    inString = false;\n                }\n                continue;\n            }\n            if (c == '\"') {\n                inString = true;\n                continue;\n            }\n            if (c == '{') {\n                if (start == -1) {\n                    start = i;\n                }\n                depth += 1;\n            } else if (c == '}') {\n                depth -= 1;\n                if (depth == 0 && start != -1) {\n                    return text.substring(start, i + 1);\n                }\n            }\n        }\n        return null;\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private String buildToolOutput(CodeExecutionResult result) {\n        if (result == null) {\n            return \"\";\n        }\n        JSONObject obj = new JSONObject();\n        if (result.getResult() != null) {\n            obj.put(\"result\", result.getResult());\n        }\n        if (result.getStdout() != null && !result.getStdout().isEmpty()) {\n            obj.put(\"stdout\", result.getStdout());\n        }\n        if (result.getError() != null && !result.getError().isEmpty()) {\n            obj.put(\"error\", result.getError());\n        }\n        return obj.toJSONString();\n    }\n\n    private String resolveDirectOutput(CodeExecutionResult result) {\n        if (result == null || !result.isSuccess()) {\n            return null;\n        }\n        String value = result.getResult();\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        if (trimmed.isEmpty()) {\n            return null;\n        }\n        if (\"undefined\".equalsIgnoreCase(trimmed) || \"null\".equalsIgnoreCase(trimmed)) {\n            return null;\n        }\n        return trimmed;\n    }\n\n    private String resolveFallbackOutput(CodeExecutionResult result, String toolOutput) {\n        if (result == null) {\n            return toolOutput == null || toolOutput.isEmpty() ? null : toolOutput;\n        }\n        if (result.getError() != null && !result.getError().isEmpty()) {\n            return \"CODE_ERROR: \" + result.getError();\n        }\n        if (result.getStdout() != null && !result.getStdout().isEmpty()) {\n            return result.getStdout().trim();\n        }\n        if (toolOutput != null && !toolOutput.isEmpty()) {\n            return toolOutput;\n        }\n        return null;\n    }\n\n    private String mergeText(String base, String extra) {\n        if (base == null || base.trim().isEmpty()) {\n            return extra;\n        }\n        if (extra == null || extra.trim().isEmpty()) {\n            return base;\n        }\n        return base + \"\\n\" + extra;\n    }\n\n    private static class CodeActMessage {\n        private String type;\n        private String language;\n        private String code;\n        private String output;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/DeepResearchRuntime.java",
    "content": "package io.github.lnyocly.ai4j.agent.runtime;\n\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\n\nimport java.util.List;\n\npublic class DeepResearchRuntime extends BaseAgentRuntime {\n\n    private final Planner planner;\n\n    public DeepResearchRuntime() {\n        this(Planner.simple());\n    }\n\n    public DeepResearchRuntime(Planner planner) {\n        this.planner = planner;\n    }\n\n    @Override\n    protected String runtimeName() {\n        return \"deepresearch\";\n    }\n\n    @Override\n    protected String runtimeInstructions() {\n        return \"Break down tasks into steps and call tools for evidence. Provide a structured final summary.\";\n    }\n\n    @Override\n    public io.github.lnyocly.ai4j.agent.AgentResult run(AgentContext context, AgentRequest request) throws Exception {\n        preparePlan(context, request);\n        return super.run(context, request);\n    }\n\n    @Override\n    public void runStream(AgentContext context, AgentRequest request, io.github.lnyocly.ai4j.agent.event.AgentListener listener) throws Exception {\n        preparePlan(context, request);\n        super.runStream(context, request, listener);\n    }\n\n    private void preparePlan(AgentContext context, AgentRequest request) {\n        if (planner == null || request == null || request.getInput() == null) {\n            return;\n        }\n        if (!(request.getInput() instanceof String)) {\n            return;\n        }\n        String goal = (String) request.getInput();\n        List<String> steps = planner.plan(goal);\n        if (steps == null || steps.isEmpty()) {\n            return;\n        }\n        StringBuilder planText = new StringBuilder(\"Plan:\\n\");\n        for (int i = 0; i < steps.size(); i++) {\n            planText.append(i + 1).append(\". \").append(steps.get(i)).append(\"\\n\");\n        }\n        context.getMemory().addUserInput(AgentInputItem.systemMessage(planText.toString()));\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/Planner.java",
    "content": "package io.github.lnyocly.ai4j.agent.runtime;\n\nimport java.util.Collections;\nimport java.util.List;\n\npublic interface Planner {\n\n    List<String> plan(String goal);\n\n    static Planner simple() {\n        return new SimplePlanner();\n    }\n\n    class SimplePlanner implements Planner {\n        @Override\n        public List<String> plan(String goal) {\n            if (goal == null) {\n                return Collections.emptyList();\n            }\n            return Collections.singletonList(goal);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/ReActRuntime.java",
    "content": "package io.github.lnyocly.ai4j.agent.runtime;\n\npublic class ReActRuntime extends BaseAgentRuntime {\n\n    @Override\n    protected String runtimeName() {\n        return \"react\";\n    }\n\n    @Override\n    protected String runtimeInstructions() {\n        return \"Use tools when necessary. Return concise final answers.\";\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/HandoffContext.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\npublic final class HandoffContext {\n\n    private static final ThreadLocal<Integer> DEPTH = new ThreadLocal<>();\n\n    private HandoffContext() {\n    }\n\n    public static int currentDepth() {\n        Integer depth = DEPTH.get();\n        return depth == null ? 0 : depth;\n    }\n\n    public static <T> T runWithDepth(int depth, HandoffCallable<T> callable) throws Exception {\n        Integer previous = DEPTH.get();\n        DEPTH.set(depth);\n        try {\n            return callable.call();\n        } finally {\n            if (previous == null) {\n                DEPTH.remove();\n            } else {\n                DEPTH.set(previous);\n            }\n        }\n    }\n\n    public interface HandoffCallable<T> {\n        T call() throws Exception;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/HandoffFailureAction.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\npublic enum HandoffFailureAction {\n    FAIL,\n    FALLBACK_TO_PRIMARY\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/HandoffInputFilter.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\n\npublic interface HandoffInputFilter {\n\n    AgentToolCall filter(AgentToolCall call) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/HandoffPolicy.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.Set;\n\n@Data\n@Builder(toBuilder = true)\npublic class HandoffPolicy {\n\n    @Builder.Default\n    private boolean enabled = true;\n\n    /**\n     * Maximum nested handoff depth. 1 means lead -> subagent only.\n     */\n    @Builder.Default\n    private int maxDepth = 1;\n\n    /**\n     * Retry count after the first failed attempt.\n     */\n    @Builder.Default\n    private int maxRetries = 0;\n\n    /**\n     * Timeout for one handoff attempt in milliseconds, 0 disables timeout.\n     */\n    @Builder.Default\n    private long timeoutMillis = 0L;\n\n    /**\n     * Optional allow-list by subagent tool name. Empty means allow all.\n     */\n    private Set<String> allowedTools;\n\n    /**\n     * Optional deny-list by subagent tool name.\n     */\n    private Set<String> deniedTools;\n\n    @Builder.Default\n    private HandoffFailureAction onDenied = HandoffFailureAction.FAIL;\n\n    @Builder.Default\n    private HandoffFailureAction onError = HandoffFailureAction.FAIL;\n\n    private HandoffInputFilter inputFilter;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/StaticSubAgentRegistry.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class StaticSubAgentRegistry implements SubAgentRegistry {\n\n    private final Map<String, RuntimeSubAgent> subAgents;\n\n    public StaticSubAgentRegistry(List<SubAgentDefinition> definitions) {\n        if (definitions == null || definitions.isEmpty()) {\n            this.subAgents = Collections.emptyMap();\n            return;\n        }\n        Map<String, RuntimeSubAgent> map = new LinkedHashMap<>();\n        for (SubAgentDefinition definition : definitions) {\n            RuntimeSubAgent runtimeSubAgent = RuntimeSubAgent.from(definition);\n            if (map.containsKey(runtimeSubAgent.toolName)) {\n                throw new IllegalArgumentException(\"duplicate subagent tool name: \" + runtimeSubAgent.toolName);\n            }\n            map.put(runtimeSubAgent.toolName, runtimeSubAgent);\n        }\n        this.subAgents = map;\n    }\n\n    @Override\n    public List<Object> getTools() {\n        if (subAgents.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Object> tools = new ArrayList<>();\n        for (RuntimeSubAgent subAgent : subAgents.values()) {\n            tools.add(subAgent.tool);\n        }\n        return tools;\n    }\n\n    @Override\n    public boolean supports(String toolName) {\n        return toolName != null && subAgents.containsKey(toolName);\n    }\n\n    @Override\n    public SubAgentDefinition getDefinition(String toolName) {\n        RuntimeSubAgent subAgent = toolName == null ? null : subAgents.get(toolName);\n        return subAgent == null ? null : subAgent.toDefinition();\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        if (call == null || call.getName() == null) {\n            throw new IllegalArgumentException(\"subagent tool call is invalid\");\n        }\n        RuntimeSubAgent subAgent = subAgents.get(call.getName());\n        if (subAgent == null) {\n            throw new IllegalArgumentException(\"unknown subagent tool: \" + call.getName());\n        }\n        String input = resolveInput(call.getArguments());\n        AgentResult result = subAgent.invoke(input);\n        JSONObject payload = new JSONObject();\n        payload.put(\"subagent\", subAgent.name);\n        payload.put(\"toolName\", subAgent.toolName);\n        payload.put(\"output\", result == null ? null : result.getOutputText());\n        payload.put(\"steps\", result == null ? 0 : result.getSteps());\n        return payload.toJSONString();\n    }\n\n    private String resolveInput(String arguments) {\n        if (arguments == null || arguments.trim().isEmpty()) {\n            return \"\";\n        }\n        try {\n            JSONObject obj = JSON.parseObject(arguments);\n            String task = trimToNull(obj.getString(\"task\"));\n            if (task == null) {\n                task = trimToNull(obj.getString(\"input\"));\n            }\n            String context = trimToNull(obj.getString(\"context\"));\n            if (task == null) {\n                return arguments;\n            }\n            if (context == null) {\n                return task;\n            }\n            return task + \"\\n\\nContext:\\n\" + context;\n        } catch (Exception e) {\n            return arguments;\n        }\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private static class RuntimeSubAgent {\n\n        private final String name;\n        private final String toolName;\n        private final Agent agent;\n        private final SubAgentSessionMode sessionMode;\n        private final Tool tool;\n        private final Map<String, AgentSession> sessions = new ConcurrentHashMap<>();\n\n        private RuntimeSubAgent(String name, String toolName, Agent agent, SubAgentSessionMode sessionMode, Tool tool) {\n            this.name = name;\n            this.toolName = toolName;\n            this.agent = agent;\n            this.sessionMode = sessionMode;\n            this.tool = tool;\n        }\n\n        private AgentResult invoke(String input) throws Exception {\n            if (sessionMode == SubAgentSessionMode.REUSE_SESSION) {\n                AgentSession session = sessions.computeIfAbsent(toolName, key -> agent.newSession());\n                synchronized (session) {\n                    return session.run(AgentRequest.builder().input(input).build());\n                }\n            }\n            AgentSession session = agent.newSession();\n            return session.run(AgentRequest.builder().input(input).build());\n        }\n\n        private static RuntimeSubAgent from(SubAgentDefinition definition) {\n            if (definition == null) {\n                throw new IllegalArgumentException(\"subagent definition cannot be null\");\n            }\n            if (definition.getAgent() == null) {\n                throw new IllegalArgumentException(\"subagent agent is required\");\n            }\n            String name = trimToNull(definition.getName());\n            if (name == null) {\n                throw new IllegalArgumentException(\"subagent name is required\");\n            }\n            String description = trimToNull(definition.getDescription());\n            if (description == null) {\n                description = \"Delegate a specialized task to subagent \" + name;\n            }\n            String toolName = trimToNull(definition.getToolName());\n            if (toolName == null) {\n                toolName = \"subagent_\" + normalizeToolName(name);\n            }\n            Tool tool = createTool(toolName, description);\n            SubAgentSessionMode sessionMode = definition.getSessionMode() == null\n                    ? SubAgentSessionMode.NEW_SESSION\n                    : definition.getSessionMode();\n            return new RuntimeSubAgent(name, toolName, definition.getAgent(), sessionMode, tool);\n        }\n\n        private SubAgentDefinition toDefinition() {\n            return SubAgentDefinition.builder()\n                    .name(name)\n                    .description(tool == null || tool.getFunction() == null ? null : tool.getFunction().getDescription())\n                    .toolName(toolName)\n                    .agent(agent)\n                    .sessionMode(sessionMode)\n                    .build();\n        }\n\n        private static Tool createTool(String toolName, String description) {\n            Tool.Function.Property taskProperty = new Tool.Function.Property();\n            taskProperty.setType(\"string\");\n            taskProperty.setDescription(\"Task to delegate to this subagent\");\n\n            Tool.Function.Property contextProperty = new Tool.Function.Property();\n            contextProperty.setType(\"string\");\n            contextProperty.setDescription(\"Optional extra context for the task\");\n\n            Map<String, Tool.Function.Property> properties = new LinkedHashMap<>();\n            properties.put(\"task\", taskProperty);\n            properties.put(\"context\", contextProperty);\n\n            Tool.Function.Parameter parameter = new Tool.Function.Parameter(\"object\", properties, Arrays.asList(\"task\"));\n            Tool.Function function = new Tool.Function(toolName, description, parameter);\n\n            Tool tool = new Tool();\n            tool.setType(\"function\");\n            tool.setFunction(function);\n            return tool;\n        }\n\n        private static String normalizeToolName(String raw) {\n            String normalized = raw.toLowerCase().replaceAll(\"[^a-z0-9_]\", \"_\");\n            normalized = normalized.replaceAll(\"_+\", \"_\");\n            normalized = normalized.replaceAll(\"^_+\", \"\");\n            normalized = normalized.replaceAll(\"_+$\", \"\");\n            if (normalized.isEmpty()) {\n                normalized = \"agent\";\n            }\n            if (Character.isDigit(normalized.charAt(0))) {\n                normalized = \"agent_\" + normalized;\n            }\n            return normalized;\n        }\n\n        private static String trimToNull(String value) {\n            if (value == null) {\n                return null;\n            }\n            String trimmed = value.trim();\n            return trimmed.isEmpty() ? null : trimmed;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentDefinition.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class SubAgentDefinition {\n\n    private String name;\n\n    private String description;\n\n    private String toolName;\n\n    private Agent agent;\n\n    @Builder.Default\n    private SubAgentSessionMode sessionMode = SubAgentSessionMode.NEW_SESSION;\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentRegistry.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\n\nimport java.util.List;\n\npublic interface SubAgentRegistry {\n\n    List<Object> getTools();\n\n    boolean supports(String toolName);\n\n    default SubAgentDefinition getDefinition(String toolName) {\n        return null;\n    }\n\n    String execute(AgentToolCall call) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentSessionMode.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\npublic enum SubAgentSessionMode {\n    NEW_SESSION,\n    REUSE_SESSION\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.agent.subagent;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.runtime.AgentToolExecutionScope;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\n\nimport java.util.LinkedHashMap;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.ThreadFactory;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class SubAgentToolExecutor implements ToolExecutor {\n\n    private static final AtomicInteger HANDOFF_THREAD_INDEX = new AtomicInteger(1);\n    private static final ExecutorService HANDOFF_EXECUTOR = Executors.newCachedThreadPool(new ThreadFactory() {\n        @Override\n        public Thread newThread(Runnable runnable) {\n            Thread thread = new Thread(runnable, \"ai4j-handoff-\" + HANDOFF_THREAD_INDEX.getAndIncrement());\n            thread.setDaemon(true);\n            return thread;\n        }\n    });\n\n    private final SubAgentRegistry subAgentRegistry;\n    private final ToolExecutor delegate;\n    private final HandoffPolicy policy;\n    private final Set<String> allowedTools;\n    private final Set<String> deniedTools;\n\n    public SubAgentToolExecutor(SubAgentRegistry subAgentRegistry, ToolExecutor delegate) {\n        this(subAgentRegistry, delegate, HandoffPolicy.builder().build());\n    }\n\n    public SubAgentToolExecutor(SubAgentRegistry subAgentRegistry, ToolExecutor delegate, HandoffPolicy policy) {\n        this.subAgentRegistry = subAgentRegistry;\n        this.delegate = delegate;\n        this.policy = policy == null ? HandoffPolicy.builder().build() : policy;\n        this.allowedTools = toSafeSet(this.policy.getAllowedTools());\n        this.deniedTools = toSafeSet(this.policy.getDeniedTools());\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        if (call == null) {\n            return null;\n        }\n        String toolName = call.getName();\n        if (subAgentRegistry != null && subAgentRegistry.supports(toolName)) {\n            return executeSubAgent(call, toolName);\n        }\n        if (delegate == null) {\n            throw new IllegalStateException(\"toolExecutor is required for non-subagent tool: \" + toolName);\n        }\n        return delegate.execute(call);\n    }\n\n    private String executeSubAgent(AgentToolCall call, String toolName) throws Exception {\n        if (!policy.isEnabled()) {\n            return executeWithoutPolicy(call, toolName);\n        }\n\n        SubAgentDefinition definition = subAgentRegistry == null ? null : subAgentRegistry.getDefinition(toolName);\n        int nextDepth = HandoffContext.currentDepth() + 1;\n        String handoffId = resolveHandoffId(call);\n        long startedAt = System.currentTimeMillis();\n        emitHandoffEvent(AgentEventType.HANDOFF_START, buildHandoffPayload(\n                handoffId,\n                call,\n                definition,\n                toolName,\n                nextDepth,\n                \"starting\",\n                \"Delegating to subagent \" + resolveSubAgentName(definition, toolName) + \".\",\n                null,\n                null,\n                null,\n                0L\n        ));\n        String deniedReason = denyReason(toolName, nextDepth);\n        if (deniedReason != null) {\n            return onDenied(call, definition, toolName, handoffId, nextDepth, startedAt, deniedReason);\n        }\n\n        AgentToolCall filteredCall = applyInputFilter(call);\n\n        Exception lastError = null;\n        int attempts = Math.max(1, policy.getMaxRetries() + 1);\n        for (int attempt = 0; attempt < attempts; attempt++) {\n            try {\n                String output = executeOnce(filteredCall, nextDepth, toolName);\n                emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                        handoffId,\n                        call,\n                        definition,\n                        toolName,\n                        nextDepth,\n                        \"completed\",\n                        \"Subagent completed.\",\n                        extractResultOutput(output),\n                        null,\n                        Integer.valueOf(attempt + 1),\n                        System.currentTimeMillis() - startedAt\n                ));\n                return output;\n            } catch (Exception ex) {\n                lastError = ex;\n            }\n        }\n\n        return onError(call, definition, toolName, handoffId, nextDepth, startedAt, lastError, attempts);\n    }\n\n    private String executeWithoutPolicy(AgentToolCall call, String toolName) throws Exception {\n        SubAgentDefinition definition = subAgentRegistry == null ? null : subAgentRegistry.getDefinition(toolName);\n        int depth = HandoffContext.currentDepth() + 1;\n        String handoffId = resolveHandoffId(call);\n        long startedAt = System.currentTimeMillis();\n        emitHandoffEvent(AgentEventType.HANDOFF_START, buildHandoffPayload(\n                handoffId,\n                call,\n                definition,\n                toolName,\n                depth,\n                \"starting\",\n                \"Delegating to subagent \" + resolveSubAgentName(definition, toolName) + \".\",\n                null,\n                null,\n                null,\n                0L\n        ));\n        try {\n            String output = executeOnce(call, depth, toolName);\n            emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                    handoffId,\n                    call,\n                    definition,\n                    toolName,\n                    depth,\n                    \"completed\",\n                    \"Subagent completed.\",\n                    extractResultOutput(output),\n                    null,\n                    Integer.valueOf(1),\n                    System.currentTimeMillis() - startedAt\n            ));\n            return output;\n        } catch (Exception ex) {\n            emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                    handoffId,\n                    call,\n                    definition,\n                    toolName,\n                    depth,\n                    \"failed\",\n                    safeMessage(ex),\n                    null,\n                    safeMessage(ex),\n                    Integer.valueOf(1),\n                    System.currentTimeMillis() - startedAt\n            ));\n            throw ex;\n        }\n    }\n\n    private AgentToolCall applyInputFilter(AgentToolCall call) throws Exception {\n        HandoffInputFilter inputFilter = policy.getInputFilter();\n        if (inputFilter == null) {\n            return call;\n        }\n        AgentToolCall filtered = inputFilter.filter(call);\n        return filtered == null ? call : filtered;\n    }\n\n    private String executeOnce(AgentToolCall call, int depth, String toolName) throws Exception {\n        long timeoutMillis = policy.getTimeoutMillis();\n        if (timeoutMillis <= 0L) {\n            return HandoffContext.runWithDepth(depth, () -> subAgentRegistry.execute(call));\n        }\n        Future<String> future = HANDOFF_EXECUTOR.submit(() -> HandoffContext.runWithDepth(depth, () -> subAgentRegistry.execute(call)));\n        try {\n            return future.get(timeoutMillis, TimeUnit.MILLISECONDS);\n        } catch (TimeoutException timeoutException) {\n            future.cancel(true);\n            throw new RuntimeException(\"Handoff timeout for subagent tool \" + toolName + \" after \" + timeoutMillis + \" ms\", timeoutException);\n        } catch (ExecutionException executionException) {\n            Throwable cause = executionException.getCause();\n            if (cause instanceof Exception) {\n                throw (Exception) cause;\n            }\n            throw new RuntimeException(cause);\n        } catch (InterruptedException interruptedException) {\n            Thread.currentThread().interrupt();\n            throw interruptedException;\n        }\n    }\n\n    private String onDenied(AgentToolCall call,\n                            SubAgentDefinition definition,\n                            String toolName,\n                            String handoffId,\n                            int depth,\n                            long startedAt,\n                            String deniedReason) throws Exception {\n        if (policy.getOnDenied() == HandoffFailureAction.FALLBACK_TO_PRIMARY && delegate != null) {\n            try {\n                String output = delegate.execute(call);\n                emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                        handoffId,\n                        call,\n                        definition,\n                        toolName,\n                        depth,\n                        \"fallback\",\n                        \"Handoff denied. Fell back to primary tool executor.\",\n                        extractResultOutput(output),\n                        deniedReason,\n                        Integer.valueOf(0),\n                        System.currentTimeMillis() - startedAt\n                ));\n                return output;\n            } catch (Exception ex) {\n                emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                        handoffId,\n                        call,\n                        definition,\n                        toolName,\n                        depth,\n                        \"failed\",\n                        safeMessage(ex),\n                        null,\n                        safeMessage(ex),\n                        Integer.valueOf(0),\n                        System.currentTimeMillis() - startedAt\n                ));\n                throw ex;\n            }\n        }\n        emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                handoffId,\n                call,\n                definition,\n                toolName,\n                depth,\n                \"failed\",\n                deniedReason,\n                null,\n                deniedReason,\n                Integer.valueOf(0),\n                System.currentTimeMillis() - startedAt\n        ));\n        throw new IllegalStateException(\"Handoff denied for subagent tool \" + toolName + \": \" + deniedReason);\n    }\n\n    private String onError(AgentToolCall call,\n                           SubAgentDefinition definition,\n                           String toolName,\n                           String handoffId,\n                           int depth,\n                           long startedAt,\n                           Exception error,\n                           int attempts) throws Exception {\n        if (policy.getOnError() == HandoffFailureAction.FALLBACK_TO_PRIMARY && delegate != null) {\n            try {\n                String output = delegate.execute(call);\n                emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                        handoffId,\n                        call,\n                        definition,\n                        toolName,\n                        depth,\n                        \"fallback\",\n                        \"Subagent failed. Fell back to primary tool executor.\",\n                        extractResultOutput(output),\n                        safeMessage(error),\n                        Integer.valueOf(attempts),\n                        System.currentTimeMillis() - startedAt\n                ));\n                return output;\n            } catch (Exception ex) {\n                emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                        handoffId,\n                        call,\n                        definition,\n                        toolName,\n                        depth,\n                        \"failed\",\n                        safeMessage(ex),\n                        null,\n                        safeMessage(ex),\n                        Integer.valueOf(attempts),\n                        System.currentTimeMillis() - startedAt\n                ));\n                throw ex;\n            }\n        }\n        emitHandoffEvent(AgentEventType.HANDOFF_END, buildHandoffPayload(\n                handoffId,\n                call,\n                definition,\n                toolName,\n                depth,\n                \"failed\",\n                safeMessage(error),\n                null,\n                safeMessage(error),\n                Integer.valueOf(attempts),\n                System.currentTimeMillis() - startedAt\n        ));\n        if (error == null) {\n            throw new RuntimeException(\"Handoff failed for subagent tool \" + toolName);\n        }\n        throw error;\n    }\n\n    private String denyReason(String toolName, int nextDepth) {\n        if (!allowedTools.isEmpty() && !allowedTools.contains(toolName)) {\n            return \"tool is not in allowedTools\";\n        }\n        if (!deniedTools.isEmpty() && deniedTools.contains(toolName)) {\n            return \"tool is in deniedTools\";\n        }\n        if (policy.getMaxDepth() > 0 && nextDepth > policy.getMaxDepth()) {\n            return \"handoff depth \" + nextDepth + \" exceeds maxDepth \" + policy.getMaxDepth();\n        }\n        return null;\n    }\n\n    private Set<String> toSafeSet(Set<String> source) {\n        if (source == null || source.isEmpty()) {\n            return Collections.emptySet();\n        }\n        return Collections.unmodifiableSet(new HashSet<>(source));\n    }\n\n    private void emitHandoffEvent(AgentEventType type, Map<String, Object> payload) {\n        AgentToolExecutionScope.emit(\n                type,\n                payload == null ? null : String.valueOf(payload.get(\"title\")),\n                payload\n        );\n    }\n\n    private Map<String, Object> buildHandoffPayload(String handoffId,\n                                                    AgentToolCall call,\n                                                    SubAgentDefinition definition,\n                                                    String toolName,\n                                                    int depth,\n                                                    String status,\n                                                    String detail,\n                                                    String output,\n                                                    String error,\n                                                    Integer attempts,\n                                                    long durationMillis) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        String subAgentName = resolveSubAgentName(definition, toolName);\n        payload.put(\"handoffId\", handoffId);\n        payload.put(\"callId\", call == null ? null : call.getCallId());\n        payload.put(\"tool\", toolName);\n        payload.put(\"subagent\", subAgentName);\n        payload.put(\"title\", \"Subagent \" + subAgentName);\n        payload.put(\"detail\", detail);\n        payload.put(\"status\", status == null ? null : status.toLowerCase(Locale.ROOT));\n        payload.put(\"depth\", Integer.valueOf(depth));\n        payload.put(\"sessionMode\", definition == null || definition.getSessionMode() == null\n                ? null\n                : definition.getSessionMode().name().toLowerCase(Locale.ROOT));\n        payload.put(\"attempts\", attempts);\n        payload.put(\"durationMillis\", Long.valueOf(durationMillis));\n        payload.put(\"output\", output);\n        payload.put(\"error\", error);\n        return payload;\n    }\n\n    private String resolveSubAgentName(SubAgentDefinition definition, String toolName) {\n        String name = definition == null ? null : trimToNull(definition.getName());\n        return name == null ? firstNonBlank(trimToNull(toolName), \"subagent\") : name;\n    }\n\n    private String resolveHandoffId(AgentToolCall call) {\n        String callId = call == null ? null : trimToNull(call.getCallId());\n        return \"handoff:\" + (callId == null ? UUID.randomUUID().toString() : callId);\n    }\n\n    private String extractResultOutput(String raw) {\n        String value = trimToNull(raw);\n        if (value == null) {\n            return null;\n        }\n        try {\n            JSONObject object = JSON.parseObject(value);\n            String output = trimToNull(object == null ? null : object.getString(\"output\"));\n            return output == null ? value : output;\n        } catch (Exception ignored) {\n            return value;\n        }\n    }\n\n    private String safeMessage(Throwable throwable) {\n        if (throwable == null) {\n            return null;\n        }\n        Throwable current = throwable;\n        String message = null;\n        while (current != null) {\n            if (!isBlank(current.getMessage())) {\n                message = current.getMessage().trim();\n            }\n            current = current.getCause();\n        }\n        return message == null ? throwable.getClass().getSimpleName() : message;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            String normalized = trimToNull(value);\n            if (normalized != null) {\n                return normalized;\n            }\n        }\n        return null;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeam.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.team.tool.AgentTeamToolExecutor;\nimport io.github.lnyocly.ai4j.agent.team.tool.AgentTeamToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.CompositeToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.Callable;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\n\npublic class AgentTeam implements AgentTeamControl {\n\n    private static final String SYSTEM_MEMBER = \"system\";\n    private static final String LEAD_MEMBER = \"lead\";\n\n    private final AgentTeamPlanner planner;\n    private final AgentTeamSynthesizer synthesizer;\n    private final AgentTeamOptions options;\n    private final String teamId;\n    private final List<RuntimeMember> orderedMembers;\n    private final Map<String, RuntimeMember> membersById;\n    private final AgentTeamMessageBus messageBus;\n    private final AgentTeamStateStore stateStore;\n    private final AgentTeamPlanApproval planApproval;\n    private final List<AgentTeamHook> hooks;\n    private final AgentTeamToolRegistry teamToolRegistry;\n\n    private final Object memberLock = new Object();\n    private final Object runtimeLock = new Object();\n\n    private volatile AgentTeamTaskBoard activeBoard;\n    private volatile List<AgentTeamTaskState> lastTaskStates = Collections.emptyList();\n    private volatile String activeObjective;\n    private volatile String lastOutput;\n    private volatile int lastRounds;\n    private volatile long lastRunStartedAt;\n    private volatile long lastRunCompletedAt;\n\n    AgentTeam(AgentTeamBuilder builder) {\n        if (builder == null) {\n            throw new IllegalArgumentException(\"builder is required\");\n        }\n        this.options = builder.getOptions() == null ? AgentTeamOptions.builder().build() : builder.getOptions();\n        this.teamId = firstNonBlank(builder.getTeamId(), UUID.randomUUID().toString());\n        this.messageBus = resolveMessageBus(builder);\n        this.stateStore = resolveStateStore(builder);\n        this.planApproval = builder.getPlanApproval();\n        this.hooks = builder.getHooks() == null ? Collections.<AgentTeamHook>emptyList() : new ArrayList<>(builder.getHooks());\n        this.teamToolRegistry = new AgentTeamToolRegistry();\n\n        List<AgentTeamMember> rawMembers = builder.getMembers();\n        if (rawMembers == null || rawMembers.isEmpty()) {\n            throw new IllegalStateException(\"at least one team member is required\");\n        }\n        this.orderedMembers = new ArrayList<>();\n        this.membersById = new LinkedHashMap<>();\n        for (AgentTeamMember member : rawMembers) {\n            RuntimeMember runtimeMember = RuntimeMember.from(member);\n            if (membersById.containsKey(runtimeMember.id)) {\n                throw new IllegalStateException(\"duplicate team member id: \" + runtimeMember.id);\n            }\n            orderedMembers.add(runtimeMember);\n            membersById.put(runtimeMember.id, runtimeMember);\n        }\n\n        Agent leadAgent = builder.getLeadAgent();\n\n        AgentTeamPlanner plannerOverride = builder.getPlanner();\n        if (plannerOverride != null) {\n            this.planner = plannerOverride;\n        } else {\n            Agent plannerAgent = builder.getPlannerAgent();\n            if (plannerAgent == null) {\n                plannerAgent = leadAgent;\n            }\n            if (plannerAgent == null) {\n                throw new IllegalStateException(\"planner or plannerAgent or leadAgent is required\");\n            }\n            this.planner = new LlmAgentTeamPlanner(plannerAgent);\n        }\n\n        AgentTeamSynthesizer synthesizerOverride = builder.getSynthesizer();\n        if (synthesizerOverride != null) {\n            this.synthesizer = synthesizerOverride;\n        } else {\n            Agent synthesizerAgent = builder.getSynthesizerAgent();\n            if (synthesizerAgent == null) {\n                synthesizerAgent = leadAgent;\n            }\n            if (synthesizerAgent == null && builder.getPlannerAgent() != null) {\n                synthesizerAgent = builder.getPlannerAgent();\n            }\n            if (synthesizerAgent == null) {\n                throw new IllegalStateException(\"synthesizer or synthesizerAgent or leadAgent is required\");\n            }\n            this.synthesizer = new LlmAgentTeamSynthesizer(synthesizerAgent);\n        }\n    }\n\n    public static AgentTeamBuilder builder() {\n        return AgentTeamBuilder.builder();\n    }\n\n    public String getTeamId() {\n        return teamId;\n    }\n\n    public AgentTeamState snapshotState() {\n        return AgentTeamState.builder()\n                .teamId(teamId)\n                .objective(currentObjective())\n                .members(snapshotMemberViews())\n                .taskStates(copyTaskStates(listTaskStates()))\n                .messages(options.isEnableMessageBus() ? copyMessages(messageBus.snapshot()) : Collections.<AgentTeamMessage>emptyList())\n                .lastOutput(lastOutput)\n                .lastRounds(lastRounds)\n                .lastRunStartedAt(lastRunStartedAt)\n                .lastRunCompletedAt(lastRunCompletedAt)\n                .updatedAt(System.currentTimeMillis())\n                .runActive(currentBoard() != null)\n                .build();\n    }\n\n    public AgentTeamState loadPersistedState() {\n        if (stateStore == null) {\n            return null;\n        }\n        AgentTeamState state = stateStore.load(teamId);\n        restoreState(state);\n        return state;\n    }\n\n    public void restoreState(AgentTeamState state) {\n        if (state == null) {\n            return;\n        }\n        if (!isSameTeam(state)) {\n            throw new IllegalArgumentException(\"team state does not belong to teamId=\" + teamId);\n        }\n        if (options.isEnableMessageBus()) {\n            messageBus.restore(copyMessages(state.getMessages()));\n        }\n        synchronized (runtimeLock) {\n            activeObjective = state.getObjective();\n            activeBoard = null;\n            lastTaskStates = copyTaskStates(state.getTaskStates());\n        }\n        lastOutput = state.getLastOutput();\n        lastRounds = state.getLastRounds();\n        lastRunStartedAt = state.getLastRunStartedAt();\n        lastRunCompletedAt = state.getLastRunCompletedAt();\n    }\n\n    public boolean clearPersistedState() {\n        if (options.isEnableMessageBus()) {\n            messageBus.clear();\n        }\n        synchronized (runtimeLock) {\n            activeBoard = null;\n            activeObjective = null;\n            lastTaskStates = Collections.emptyList();\n        }\n        lastOutput = null;\n        lastRounds = 0;\n        lastRunStartedAt = 0L;\n        lastRunCompletedAt = 0L;\n        if (stateStore == null) {\n            return false;\n        }\n        return stateStore.delete(teamId);\n    }\n\n    @Override\n    public void registerMember(AgentTeamMember member) {\n        if (!options.isAllowDynamicMemberRegistration()) {\n            throw new IllegalStateException(\"dynamic member registration is disabled\");\n        }\n        RuntimeMember runtimeMember = RuntimeMember.from(member);\n        synchronized (memberLock) {\n            if (membersById.containsKey(runtimeMember.id)) {\n                throw new IllegalStateException(\"duplicate team member id: \" + runtimeMember.id);\n            }\n            orderedMembers.add(runtimeMember);\n            membersById.put(runtimeMember.id, runtimeMember);\n        }\n        persistState();\n    }\n\n    @Override\n    public boolean unregisterMember(String memberId) {\n        if (!options.isAllowDynamicMemberRegistration()) {\n            throw new IllegalStateException(\"dynamic member registration is disabled\");\n        }\n        String normalized = normalize(memberId);\n        if (normalized == null) {\n            return false;\n        }\n        synchronized (memberLock) {\n            if (!membersById.containsKey(normalized)) {\n                return false;\n            }\n            if (orderedMembers.size() <= 1) {\n                return false;\n            }\n            RuntimeMember removed = membersById.remove(normalized);\n            if (removed != null) {\n                orderedMembers.remove(removed);\n                persistState();\n                return true;\n            }\n            return false;\n        }\n    }\n\n    @Override\n    public List<AgentTeamMember> listMembers() {\n        return snapshotMembers();\n    }\n\n    @Override\n    public List<AgentTeamMessage> listMessages() {\n        if (!options.isEnableMessageBus()) {\n            return Collections.emptyList();\n        }\n        return messageBus.snapshot();\n    }\n\n    @Override\n    public List<AgentTeamMessage> listMessagesFor(String memberId, int limit) {\n        if (!options.isEnableMessageBus()) {\n            return Collections.emptyList();\n        }\n        String normalizedMemberId = normalize(memberId);\n        if (normalizedMemberId != null && !\"*\".equals(normalizedMemberId)) {\n            validateKnownMemberId(normalizedMemberId, true, \"memberId\");\n        }\n        return messageBus.historyFor(normalizedMemberId, limit);\n    }\n\n    @Override\n    public void publishMessage(AgentTeamMessage message) {\n        publishMessageInternal(message);\n    }\n\n    @Override\n    public void sendMessage(String fromMemberId, String toMemberId, String type, String taskId, String content) {\n        String from = normalize(fromMemberId);\n        String to = normalize(toMemberId);\n        validateKnownMemberId(from, true, \"fromMemberId\");\n        validateKnownMemberId(to, false, \"toMemberId\");\n        publishMessage(from, to, type, taskId, content);\n    }\n\n    @Override\n    public void broadcastMessage(String fromMemberId, String type, String taskId, String content) {\n        String from = normalize(fromMemberId);\n        validateKnownMemberId(from, true, \"fromMemberId\");\n        publishMessage(from, \"*\", type, taskId, content);\n    }\n\n    @Override\n    public List<AgentTeamTaskState> listTaskStates() {\n        AgentTeamTaskBoard board = currentBoard();\n        if (board != null) {\n            return board.snapshot();\n        }\n        synchronized (runtimeLock) {\n            if (lastTaskStates == null || lastTaskStates.isEmpty()) {\n                return Collections.emptyList();\n            }\n            return new ArrayList<>(lastTaskStates);\n        }\n    }\n\n    @Override\n    public boolean claimTask(String taskId, String memberId) {\n        AgentTeamTaskBoard board = currentBoard();\n        if (board == null) {\n            return false;\n        }\n        String normalizedMemberId = normalize(memberId);\n        validateKnownMemberId(normalizedMemberId, false, \"memberId\");\n        boolean claimed = board.claimTask(taskId, normalizedMemberId);\n        if (claimed) {\n            fireTaskStateChanged(board.getTaskState(taskId), resolveMemberView(normalizedMemberId),\n                    \"Claimed by \" + normalizedMemberId + \".\");\n        }\n        return claimed;\n    }\n\n    @Override\n    public boolean releaseTask(String taskId, String memberId, String reason) {\n        AgentTeamTaskBoard board = currentBoard();\n        if (board == null) {\n            return false;\n        }\n        String normalizedMemberId = normalize(memberId);\n        validateKnownMemberId(normalizedMemberId, false, \"memberId\");\n        boolean released = board.releaseTask(taskId, normalizedMemberId, reason);\n        if (released) {\n            fireTaskStateChanged(board.getTaskState(taskId), resolveMemberView(normalizedMemberId),\n                    firstNonBlank(reason, \"Released back to queue.\"));\n        }\n        return released;\n    }\n\n    @Override\n    public boolean reassignTask(String taskId, String fromMemberId, String toMemberId) {\n        AgentTeamTaskBoard board = currentBoard();\n        if (board == null) {\n            return false;\n        }\n        String from = normalize(fromMemberId);\n        String to = normalize(toMemberId);\n        validateKnownMemberId(from, false, \"fromMemberId\");\n        validateKnownMemberId(to, false, \"toMemberId\");\n        boolean reassigned = board.reassignTask(taskId, from, to);\n        if (reassigned) {\n            fireTaskStateChanged(board.getTaskState(taskId), resolveMemberView(to),\n                    \"Reassigned from \" + from + \" to \" + to + \".\");\n        }\n        return reassigned;\n    }\n\n    @Override\n    public boolean heartbeatTask(String taskId, String memberId) {\n        AgentTeamTaskBoard board = currentBoard();\n        if (board == null) {\n            return false;\n        }\n        String normalizedMemberId = normalize(memberId);\n        validateKnownMemberId(normalizedMemberId, false, \"memberId\");\n        boolean heartbeated = board.heartbeatTask(taskId, normalizedMemberId);\n        if (heartbeated) {\n            fireTaskStateChanged(board.getTaskState(taskId), resolveMemberView(normalizedMemberId),\n                    \"Heartbeat from \" + normalizedMemberId + \".\");\n        }\n        return heartbeated;\n    }\n\n    public AgentTeamResult run(String objective) throws Exception {\n        return run(AgentRequest.builder().input(objective).build());\n    }\n\n    public AgentTeamResult run(AgentRequest request) throws Exception {\n        long start = System.currentTimeMillis();\n        String objective = request == null ? \"\" : toText(request.getInput());\n        lastRunStartedAt = start;\n        lastRunCompletedAt = 0L;\n\n        if (options.isEnableMessageBus()) {\n            messageBus.clear();\n        }\n\n        List<AgentTeamMember> members = snapshotMembers();\n        fireBeforePlan(objective, members);\n\n        AgentTeamPlan plan = planner.plan(objective, members, options);\n        if (plan == null) {\n            plan = AgentTeamPlan.builder().tasks(Collections.<AgentTeamTask>emptyList()).build();\n        }\n        AgentTeamTaskBoard board = new AgentTeamTaskBoard(plan.getTasks());\n        plan = plan.toBuilder().tasks(board.normalizedTasks()).build();\n\n        synchronized (runtimeLock) {\n            activeBoard = board;\n            lastTaskStates = Collections.emptyList();\n            activeObjective = objective;\n        }\n        persistState();\n\n        try {\n            fireAfterPlan(objective, plan);\n            ensurePlanApproved(objective, plan, members);\n\n            DispatchOutcome dispatch = dispatchTasks(objective, board);\n\n            AgentResult synthesis = synthesizer.synthesize(objective, plan, dispatch.results, options);\n            fireAfterSynthesis(objective, synthesis);\n\n            List<AgentTeamTaskState> taskStates = board.snapshot();\n            rememberTaskStates(taskStates);\n            lastRounds = dispatch.rounds;\n            lastOutput = synthesis == null ? \"\" : synthesis.getOutputText();\n            lastRunCompletedAt = System.currentTimeMillis();\n            persistState();\n\n            return AgentTeamResult.builder()\n                    .teamId(teamId)\n                    .objective(objective)\n                    .plan(plan)\n                    .memberResults(dispatch.results)\n                    .taskStates(taskStates)\n                    .messages(options.isEnableMessageBus() ? messageBus.snapshot() : Collections.<AgentTeamMessage>emptyList())\n                    .rounds(dispatch.rounds)\n                    .synthesisResult(synthesis)\n                    .output(synthesis == null ? \"\" : synthesis.getOutputText())\n                    .totalDurationMillis(System.currentTimeMillis() - start)\n                    .build();\n        } finally {\n            rememberTaskStates(board.snapshot());\n            lastRunCompletedAt = System.currentTimeMillis();\n            persistState();\n            synchronized (runtimeLock) {\n                activeBoard = null;\n                activeObjective = null;\n            }\n        }\n    }\n\n    private void ensurePlanApproved(String objective, AgentTeamPlan plan, List<AgentTeamMember> members) {\n        if (planApproval == null) {\n            if (options.isRequirePlanApproval()) {\n                throw new IllegalStateException(\"plan approval is required but no planApproval callback provided\");\n            }\n            return;\n        }\n        boolean approved = planApproval.approve(objective, plan, members, options);\n        if (!approved) {\n            throw new IllegalStateException(\"plan rejected by planApproval callback\");\n        }\n    }\n\n    private DispatchOutcome dispatchTasks(String objective, AgentTeamTaskBoard board) throws Exception {\n        if (board == null || board.size() == 0) {\n            return new DispatchOutcome(Collections.<AgentTeamMemberResult>emptyList(), 0);\n        }\n\n        List<AgentTeamMemberResult> results = new ArrayList<>();\n        int rounds = 0;\n        int maxRounds = options.getMaxRounds() <= 0 ? 64 : options.getMaxRounds();\n\n        while (board.hasWorkRemaining()) {\n            if (options.getTaskClaimTimeoutMillis() > 0L) {\n                board.recoverTimedOutClaims(options.getTaskClaimTimeoutMillis(), \"task claim timeout\");\n            }\n            if (rounds >= maxRounds) {\n                board.markStalledAsBlocked(\"max rounds exceeded: \" + maxRounds);\n                break;\n            }\n\n            int batchSize = options.isParallelDispatch() ? Math.max(1, options.getMaxConcurrency()) : 1;\n            List<AgentTeamTaskState> readyTasks = board.nextReadyTasks(batchSize);\n            if (readyTasks.isEmpty()) {\n                board.markStalledAsBlocked(\"unresolved dependencies or cyclic plan\");\n                break;\n            }\n\n            rounds++;\n            List<PreparedDispatch> prepared = new ArrayList<>();\n            for (AgentTeamTaskState state : readyTasks) {\n                RuntimeMember member;\n                try {\n                    member = resolveMember(state.getTask() == null ? null : state.getTask().getMemberId());\n                } catch (Exception e) {\n                    board.markFailed(state.getTaskId(), e.getMessage(), 0L);\n                    AgentTeamMemberResult failure = AgentTeamMemberResult.builder()\n                            .taskId(state.getTaskId())\n                            .task(state.getTask())\n                            .taskStatus(AgentTeamTaskStatus.FAILED)\n                            .memberId(state.getTask() == null ? null : state.getTask().getMemberId())\n                            .error(e.getMessage())\n                            .durationMillis(0L)\n                            .build();\n                    results.add(failure);\n                    fireAfterTask(objective, failure);\n                    if (!options.isContinueOnMemberError()) {\n                        throw new IllegalStateException(\"team member failed: \" + failure.getMemberId() + \" -> \" + failure.getError());\n                    }\n                    continue;\n                }\n\n                if (!board.claimTask(state.getTaskId(), member.id)) {\n                    continue;\n                }\n                prepared.add(new PreparedDispatch(state.getTaskId(), state.getTask(), member));\n            }\n\n            if (prepared.isEmpty()) {\n                continue;\n            }\n\n            List<AgentTeamMemberResult> roundResults = executeRound(objective, prepared, board);\n            for (AgentTeamMemberResult result : roundResults) {\n                results.add(result);\n                if (!result.isSuccess() && !options.isContinueOnMemberError()) {\n                    throw new IllegalStateException(\"team member failed: \" + result.getMemberId() + \" -> \" + result.getError());\n                }\n            }\n        }\n\n        return new DispatchOutcome(results, rounds);\n    }\n\n    private List<AgentTeamMemberResult> executeRound(String objective,\n                                                     List<PreparedDispatch> dispatches,\n                                                     AgentTeamTaskBoard board) throws Exception {\n        if (dispatches.size() <= 1 || !options.isParallelDispatch()) {\n            List<AgentTeamMemberResult> results = new ArrayList<>();\n            for (PreparedDispatch dispatch : dispatches) {\n                results.add(executePreparedTask(objective, dispatch, board));\n            }\n            return results;\n        }\n\n        int configured = options.getMaxConcurrency() <= 0 ? dispatches.size() : options.getMaxConcurrency();\n        int threads = Math.max(1, Math.min(configured, dispatches.size()));\n        ExecutorService executor = Executors.newFixedThreadPool(threads);\n        try {\n            List<Future<AgentTeamMemberResult>> futures = new ArrayList<>();\n            for (PreparedDispatch dispatch : dispatches) {\n                futures.add(executor.submit(new Callable<AgentTeamMemberResult>() {\n                    @Override\n                    public AgentTeamMemberResult call() throws Exception {\n                        return executePreparedTask(objective, dispatch, board);\n                    }\n                }));\n            }\n\n            List<AgentTeamMemberResult> results = new ArrayList<>();\n            for (Future<AgentTeamMemberResult> future : futures) {\n                results.add(waitForFuture(future));\n            }\n            return results;\n        } finally {\n            executor.shutdownNow();\n        }\n    }\n\n    private AgentTeamMemberResult waitForFuture(Future<AgentTeamMemberResult> future) throws Exception {\n        try {\n            return future.get();\n        } catch (InterruptedException e) {\n            Thread.currentThread().interrupt();\n            throw e;\n        } catch (ExecutionException e) {\n            Throwable cause = e.getCause();\n            if (cause instanceof Exception) {\n                throw (Exception) cause;\n            }\n            throw new RuntimeException(cause);\n        }\n    }\n\n    private AgentTeamMemberResult executePreparedTask(String objective,\n                                                      PreparedDispatch dispatch,\n                                                      AgentTeamTaskBoard board) {\n        long start = System.currentTimeMillis();\n        AgentTeamMember memberView = dispatch.member.toPublicMember();\n\n        fireBeforeTask(objective, dispatch.task, memberView);\n        publishMessage(SYSTEM_MEMBER, dispatch.member.id, \"task.assigned\", dispatch.taskId,\n                safe(dispatch.task == null ? null : dispatch.task.getTask()));\n\n        try {\n            String input = buildDispatchInput(objective, dispatch.member, dispatch.task);\n            AgentResult result = runMemberTask(dispatch, input);\n            String output = result == null ? \"\" : result.getOutputText();\n            long duration = System.currentTimeMillis() - start;\n\n            board.markCompleted(dispatch.taskId, output, duration);\n            publishMessage(dispatch.member.id, LEAD_MEMBER, \"task.result\", dispatch.taskId, safeShort(output));\n\n            AgentTeamMemberResult memberResult = AgentTeamMemberResult.builder()\n                    .taskId(dispatch.taskId)\n                    .memberId(dispatch.member.id)\n                    .memberName(dispatch.member.name)\n                    .task(dispatch.task)\n                    .taskStatus(AgentTeamTaskStatus.COMPLETED)\n                    .output(output)\n                    .rawResult(result)\n                    .durationMillis(duration)\n                    .build();\n            fireAfterTask(objective, memberResult);\n            return memberResult;\n        } catch (Exception e) {\n            long duration = System.currentTimeMillis() - start;\n            board.markFailed(dispatch.taskId, e.getMessage(), duration);\n            publishMessage(dispatch.member.id, LEAD_MEMBER, \"task.error\", dispatch.taskId, safe(e.getMessage()));\n\n            AgentTeamMemberResult memberResult = AgentTeamMemberResult.builder()\n                    .taskId(dispatch.taskId)\n                    .memberId(dispatch.member.id)\n                    .memberName(dispatch.member.name)\n                    .task(dispatch.task)\n                    .taskStatus(AgentTeamTaskStatus.FAILED)\n                    .error(e.getMessage())\n                    .durationMillis(duration)\n                    .build();\n            fireAfterTask(objective, memberResult);\n            return memberResult;\n        }\n    }\n\n    private AgentResult runMemberTask(PreparedDispatch dispatch, String input) throws Exception {\n        AgentSession session = dispatch.member.agent.newSession();\n        if (session == null) {\n            throw new IllegalStateException(\"failed to create team member session\");\n        }\n\n        AgentContext sessionContext = session.getContext();\n        if (sessionContext != null && options.isEnableMemberTeamTools()) {\n            AgentToolRegistry originalRegistry = sessionContext.getToolRegistry();\n            ToolExecutor originalExecutor = sessionContext.getToolExecutor();\n\n            AgentToolRegistry mergedRegistry;\n            if (originalRegistry == null) {\n                mergedRegistry = teamToolRegistry;\n            } else {\n                mergedRegistry = new CompositeToolRegistry(originalRegistry, teamToolRegistry);\n            }\n\n            sessionContext.setToolRegistry(mergedRegistry);\n            sessionContext.setToolExecutor(new AgentTeamToolExecutor(this, dispatch.member.id, dispatch.taskId, originalExecutor));\n        }\n\n        return session.run(AgentRequest.builder().input(input).build());\n    }\n\n    private String buildDispatchInput(String objective, RuntimeMember member, AgentTeamTask task) {\n        StringBuilder sb = new StringBuilder();\n        sb.append(\"Team member role: \").append(member.name).append(\"\\n\");\n        if (member.description != null && !member.description.trim().isEmpty()) {\n            sb.append(\"Expertise: \").append(member.description).append(\"\\n\");\n        }\n        if (task != null && task.getId() != null) {\n            sb.append(\"Task ID: \").append(task.getId()).append(\"\\n\");\n        }\n        if (task != null && task.getDependsOn() != null && !task.getDependsOn().isEmpty()) {\n            sb.append(\"Dependencies: \").append(task.getDependsOn()).append(\"\\n\");\n        }\n        if (options.isIncludeOriginalObjectiveInDispatch()) {\n            sb.append(\"Objective:\\n\").append(objective == null ? \"\" : objective).append(\"\\n\\n\");\n        }\n        sb.append(\"Assigned task:\\n\").append(task == null ? \"\" : safe(task.getTask())).append(\"\\n\");\n        if (options.isIncludeTaskContextInDispatch()) {\n            String context = task == null ? null : safe(task.getContext());\n            if (context != null && !context.trim().isEmpty()) {\n                sb.append(\"Additional context:\\n\").append(context).append(\"\\n\");\n            }\n        }\n\n        if (options.isEnableMessageBus() && options.isIncludeMessageHistoryInDispatch()) {\n            List<AgentTeamMessage> history = messageBus.historyFor(member.id, options.getMessageHistoryLimit());\n            if (history != null && !history.isEmpty()) {\n                sb.append(\"Recent team messages:\\n\");\n                for (AgentTeamMessage message : history) {\n                    sb.append(\"- [\").append(safe(message.getType())).append(\"] \")\n                            .append(safe(message.getFromMemberId())).append(\" -> \")\n                            .append(safe(message.getToMemberId())).append(\": \")\n                            .append(safeShort(message.getContent())).append(\"\\n\");\n                }\n            }\n        }\n\n        sb.append(\"Return concise, high-signal output for the team lead.\");\n        return sb.toString();\n    }\n\n    private void publishMessage(String from,\n                                String to,\n                                String type,\n                                String taskId,\n                                String content) {\n        if (!options.isEnableMessageBus()) {\n            return;\n        }\n        AgentTeamMessage message = AgentTeamMessage.builder()\n                .id(UUID.randomUUID().toString())\n                .fromMemberId(from)\n                .toMemberId(to)\n                .type(type)\n                .taskId(taskId)\n                .content(content)\n                .createdAt(System.currentTimeMillis())\n                .build();\n        publishMessageInternal(message);\n    }\n\n    private void publishMessageInternal(AgentTeamMessage message) {\n        if (!options.isEnableMessageBus() || message == null) {\n            return;\n        }\n\n        AgentTeamMessage safeMessage = message;\n        if (safeMessage.getId() == null || safeMessage.getId().trim().isEmpty() || safeMessage.getCreatedAt() <= 0L) {\n            safeMessage = safeMessage.toBuilder()\n                    .id(safeMessage.getId() == null || safeMessage.getId().trim().isEmpty() ? UUID.randomUUID().toString() : safeMessage.getId())\n                    .createdAt(safeMessage.getCreatedAt() <= 0L ? System.currentTimeMillis() : safeMessage.getCreatedAt())\n                    .build();\n        }\n\n        messageBus.publish(safeMessage);\n        persistState();\n        for (AgentTeamHook hook : hooks) {\n            try {\n                hook.onMessage(safeMessage);\n            } catch (Exception ignored) {\n                // hook failures must not break dispatch\n            }\n        }\n    }\n\n    private RuntimeMember resolveMember(String requestedId) {\n        synchronized (memberLock) {\n            if (orderedMembers.isEmpty()) {\n                throw new IllegalStateException(\"no team member available\");\n            }\n            if (requestedId == null || requestedId.trim().isEmpty()) {\n                return orderedMembers.get(0);\n            }\n            String normalized = normalize(requestedId);\n            RuntimeMember member = membersById.get(normalized);\n            if (member != null) {\n                return member;\n            }\n            if (options.isFailOnUnknownMember()) {\n                throw new IllegalStateException(\"unknown team member: \" + requestedId);\n            }\n            return orderedMembers.get(0);\n        }\n    }\n\n    private List<AgentTeamMember> snapshotMembers() {\n        synchronized (memberLock) {\n            List<AgentTeamMember> snapshot = new ArrayList<>();\n            for (RuntimeMember member : orderedMembers) {\n                snapshot.add(member.toPublicMember());\n            }\n            return snapshot;\n        }\n    }\n\n    private AgentTeamTaskBoard currentBoard() {\n        synchronized (runtimeLock) {\n            return activeBoard;\n        }\n    }\n\n    private List<AgentTeamMemberSnapshot> snapshotMemberViews() {\n        synchronized (memberLock) {\n            List<AgentTeamMemberSnapshot> snapshot = new ArrayList<AgentTeamMemberSnapshot>();\n            for (RuntimeMember member : orderedMembers) {\n                snapshot.add(AgentTeamMemberSnapshot.from(member.toPublicMember()));\n            }\n            return snapshot;\n        }\n    }\n\n    private void rememberTaskStates(List<AgentTeamTaskState> taskStates) {\n        synchronized (runtimeLock) {\n            if (taskStates == null || taskStates.isEmpty()) {\n                lastTaskStates = Collections.emptyList();\n            } else {\n                lastTaskStates = new ArrayList<>(taskStates);\n            }\n        }\n        persistState();\n    }\n\n    private String currentObjective() {\n        synchronized (runtimeLock) {\n            return activeObjective;\n        }\n    }\n\n    private AgentTeamMessageBus resolveMessageBus(AgentTeamBuilder builder) {\n        if (builder.getMessageBus() != null) {\n            return builder.getMessageBus();\n        }\n        if (builder.getStorageDirectory() != null) {\n            return new FileAgentTeamMessageBus(\n                    builder.getStorageDirectory()\n                            .resolve(\"mailbox\")\n                            .resolve(teamId + \".jsonl\")\n            );\n        }\n        return new InMemoryAgentTeamMessageBus();\n    }\n\n    private AgentTeamStateStore resolveStateStore(AgentTeamBuilder builder) {\n        if (builder.getStateStore() != null) {\n            return builder.getStateStore();\n        }\n        if (builder.getStorageDirectory() != null) {\n            return new FileAgentTeamStateStore(builder.getStorageDirectory().resolve(\"state\"));\n        }\n        return null;\n    }\n\n    private void persistState() {\n        if (stateStore == null) {\n            return;\n        }\n        stateStore.save(snapshotState());\n    }\n\n    private boolean isSameTeam(AgentTeamState state) {\n        return state != null && state.getTeamId() != null && state.getTeamId().equals(teamId);\n    }\n\n    private List<AgentTeamTaskState> copyTaskStates(List<AgentTeamTaskState> taskStates) {\n        if (taskStates == null || taskStates.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamTaskState> copy = new ArrayList<AgentTeamTaskState>(taskStates.size());\n        for (AgentTeamTaskState state : taskStates) {\n            copy.add(state == null ? null : state.toBuilder().build());\n        }\n        return copy;\n    }\n\n    private List<AgentTeamMessage> copyMessages(List<AgentTeamMessage> messages) {\n        if (messages == null || messages.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamMessage> copy = new ArrayList<AgentTeamMessage>(messages.size());\n        for (AgentTeamMessage message : messages) {\n            copy.add(message == null ? null : message.toBuilder().build());\n        }\n        return copy;\n    }\n\n    private AgentTeamMember resolveMemberView(String memberId) {\n        if (memberId == null || memberId.trim().isEmpty()) {\n            return null;\n        }\n        synchronized (memberLock) {\n            RuntimeMember member = membersById.get(memberId);\n            return member == null ? null : member.toPublicMember();\n        }\n    }\n\n    private void fireTaskStateChanged(AgentTeamTaskState state, AgentTeamMember member, String detail) {\n        for (AgentTeamHook hook : hooks) {\n            try {\n                hook.onTaskStateChanged(currentObjective(), state, member, detail);\n            } catch (Exception ignored) {\n                // hook failures must not break dispatch\n            }\n        }\n    }\n\n    private void validateKnownMemberId(String memberId, boolean allowReserved, String fieldName) {\n        if (memberId == null || memberId.trim().isEmpty()) {\n            throw new IllegalArgumentException(fieldName + \" is required\");\n        }\n        if (allowReserved && isReservedMember(memberId)) {\n            return;\n        }\n        synchronized (memberLock) {\n            if (!membersById.containsKey(memberId)) {\n                throw new IllegalArgumentException(\"unknown team member: \" + memberId);\n            }\n        }\n    }\n\n    private boolean isReservedMember(String memberId) {\n        return SYSTEM_MEMBER.equals(memberId) || LEAD_MEMBER.equals(memberId);\n    }\n\n\n    private void fireBeforePlan(String objective, List<AgentTeamMember> members) {\n        for (AgentTeamHook hook : hooks) {\n            try {\n                hook.beforePlan(objective, members, options);\n            } catch (Exception ignored) {\n                // hook failures must not break dispatch\n            }\n        }\n    }\n\n    private void fireAfterPlan(String objective, AgentTeamPlan plan) {\n        for (AgentTeamHook hook : hooks) {\n            try {\n                hook.afterPlan(objective, plan);\n            } catch (Exception ignored) {\n                // hook failures must not break dispatch\n            }\n        }\n    }\n\n    private void fireBeforeTask(String objective, AgentTeamTask task, AgentTeamMember member) {\n        for (AgentTeamHook hook : hooks) {\n            try {\n                hook.beforeTask(objective, task, member);\n            } catch (Exception ignored) {\n                // hook failures must not break dispatch\n            }\n        }\n    }\n\n    private void fireAfterTask(String objective, AgentTeamMemberResult result) {\n        for (AgentTeamHook hook : hooks) {\n            try {\n                hook.afterTask(objective, result);\n            } catch (Exception ignored) {\n                // hook failures must not break dispatch\n            }\n        }\n    }\n\n    private void fireAfterSynthesis(String objective, AgentResult synthesis) {\n        for (AgentTeamHook hook : hooks) {\n            try {\n                hook.afterSynthesis(objective, synthesis);\n            } catch (Exception ignored) {\n                // hook failures must not break dispatch\n            }\n        }\n        publishMessage(SYSTEM_MEMBER, LEAD_MEMBER, \"run.complete\", null,\n                synthesis == null ? \"\" : safeShort(synthesis.getOutputText()));\n    }\n\n    private String normalize(String raw) {\n        if (raw == null) {\n            return null;\n        }\n        String normalized = raw.trim().toLowerCase();\n        if (normalized.isEmpty()) {\n            return null;\n        }\n        normalized = normalized.replaceAll(\"[^a-z0-9_\\\\-]\", \"_\");\n        normalized = normalized.replaceAll(\"_+\", \"_\");\n        normalized = normalized.replaceAll(\"^_+\", \"\");\n        normalized = normalized.replaceAll(\"_+$\", \"\");\n        return normalized;\n    }\n\n    private String safe(String value) {\n        return value == null ? \"\" : value;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private String safeShort(String value) {\n        if (value == null) {\n            return \"\";\n        }\n        String text = value.trim();\n        if (text.length() <= 240) {\n            return text;\n        }\n        return text.substring(0, 240) + \"...\";\n    }\n\n    private String toText(Object value) {\n        if (value == null) {\n            return \"\";\n        }\n        if (value instanceof String) {\n            return (String) value;\n        }\n        return String.valueOf(value);\n    }\n\n    private static class RuntimeMember {\n        private final String id;\n        private final String name;\n        private final String description;\n        private final Agent agent;\n\n        private RuntimeMember(String id, String name, String description, Agent agent) {\n            this.id = id;\n            this.name = name;\n            this.description = description;\n            this.agent = agent;\n        }\n\n        private static RuntimeMember from(AgentTeamMember member) {\n            if (member == null) {\n                throw new IllegalArgumentException(\"team member cannot be null\");\n            }\n            if (member.getAgent() == null) {\n                throw new IllegalArgumentException(\"team member agent is required\");\n            }\n            String id = member.resolveId();\n            String name = member.getName();\n            if (name == null || name.trim().isEmpty()) {\n                name = id;\n            }\n            return new RuntimeMember(id, name, member.getDescription(), member.getAgent());\n        }\n\n        private AgentTeamMember toPublicMember() {\n            return AgentTeamMember.builder()\n                    .id(id)\n                    .name(name)\n                    .description(description)\n                    .agent(agent)\n                    .build();\n        }\n    }\n\n    private static class PreparedDispatch {\n        private final String taskId;\n        private final AgentTeamTask task;\n        private final RuntimeMember member;\n\n        private PreparedDispatch(String taskId, AgentTeamTask task, RuntimeMember member) {\n            this.taskId = taskId;\n            this.task = task;\n            this.member = member;\n        }\n    }\n\n    private static class DispatchOutcome {\n        private final List<AgentTeamMemberResult> results;\n        private final int rounds;\n\n        private DispatchOutcome(List<AgentTeamMemberResult> results, int rounds) {\n            this.results = results;\n            this.rounds = rounds;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamAgentRuntime.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentRuntime;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class AgentTeamAgentRuntime implements AgentRuntime {\n\n    private final AgentTeamBuilder template;\n\n    public AgentTeamAgentRuntime(AgentTeamBuilder template) {\n        if (template == null) {\n            throw new IllegalArgumentException(\"template is required\");\n        }\n        this.template = template;\n    }\n\n    @Override\n    public AgentResult run(AgentContext context, AgentRequest request) throws Exception {\n        AgentTeam team = prepareTeam(null);\n        AgentTeamResult result = team.run(request);\n        return toAgentResult(result);\n    }\n\n    @Override\n    public void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception {\n        try {\n            AgentTeam team = prepareTeam(listener);\n            AgentTeamResult result = team.run(request);\n            if (listener != null) {\n                listener.onEvent(AgentEvent.builder()\n                        .type(AgentEventType.FINAL_OUTPUT)\n                        .message(result == null ? null : result.getOutput())\n                        .payload(result)\n                        .build());\n            }\n        } catch (Exception ex) {\n            if (listener != null) {\n                listener.onEvent(AgentEvent.builder()\n                        .type(AgentEventType.ERROR)\n                        .message(ex.getMessage())\n                        .payload(ex)\n                        .build());\n            }\n            throw ex;\n        }\n    }\n\n    private AgentTeam prepareTeam(AgentListener listener) {\n        AgentTeamBuilder builder = copyBuilder(template);\n        builder.hook(new AgentTeamEventHook(listener));\n        return builder.build();\n    }\n\n    private AgentTeamBuilder copyBuilder(AgentTeamBuilder source) {\n        AgentTeamBuilder copy = AgentTeam.builder();\n        copy.leadAgent(source.getLeadAgent());\n        copy.plannerAgent(source.getPlannerAgent());\n        copy.synthesizerAgent(source.getSynthesizerAgent());\n        copy.planner(source.getPlanner());\n        copy.synthesizer(source.getSynthesizer());\n        if (source.getMembers() != null) {\n            copy.members(new ArrayList<AgentTeamMember>(source.getMembers()));\n        }\n        copy.options(source.getOptions());\n        copy.messageBus(source.getMessageBus());\n        copy.stateStore(source.getStateStore());\n        copy.teamId(source.getTeamId());\n        copy.storageDirectory(source.getStorageDirectory());\n        copy.planApproval(source.getPlanApproval());\n        if (source.getHooks() != null) {\n            copy.hooks(new ArrayList<AgentTeamHook>(source.getHooks()));\n        }\n        return copy;\n    }\n\n    private AgentResult toAgentResult(AgentTeamResult result) {\n        return AgentResult.builder()\n                .outputText(result == null ? null : result.getOutput())\n                .rawResponse(result)\n                .steps(result == null ? null : Integer.valueOf(result.getRounds()))\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamBuilder.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.memory.AgentMemory;\nimport io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory;\nimport java.util.function.Supplier;\n\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class AgentTeamBuilder {\n\n    private Agent leadAgent;\n    private Agent plannerAgent;\n    private Agent synthesizerAgent;\n    private AgentTeamPlanner planner;\n    private AgentTeamSynthesizer synthesizer;\n    private final List<AgentTeamMember> members = new ArrayList<>();\n    private AgentTeamOptions options;\n    private AgentTeamMessageBus messageBus;\n    private AgentTeamStateStore stateStore;\n    private String teamId;\n    private Path storageDirectory;\n    private AgentTeamPlanApproval planApproval;\n    private final List<AgentTeamHook> hooks = new ArrayList<>();\n\n    public static AgentTeamBuilder builder() {\n        return new AgentTeamBuilder();\n    }\n\n    public Agent getLeadAgent() {\n        return leadAgent;\n    }\n\n    public Agent getPlannerAgent() {\n        return plannerAgent;\n    }\n\n    public Agent getSynthesizerAgent() {\n        return synthesizerAgent;\n    }\n\n    public AgentTeamPlanner getPlanner() {\n        return planner;\n    }\n\n    public AgentTeamSynthesizer getSynthesizer() {\n        return synthesizer;\n    }\n\n    public List<AgentTeamMember> getMembers() {\n        return members;\n    }\n\n    public AgentTeamOptions getOptions() {\n        return options;\n    }\n\n    public AgentTeamMessageBus getMessageBus() {\n        return messageBus;\n    }\n\n    public AgentTeamStateStore getStateStore() {\n        return stateStore;\n    }\n\n    public String getTeamId() {\n        return teamId;\n    }\n\n    public Path getStorageDirectory() {\n        return storageDirectory;\n    }\n\n    public AgentTeamPlanApproval getPlanApproval() {\n        return planApproval;\n    }\n\n    public List<AgentTeamHook> getHooks() {\n        return hooks;\n    }\n\n    public AgentTeamBuilder leadAgent(Agent leadAgent) {\n        this.leadAgent = leadAgent;\n        return this;\n    }\n\n    public AgentTeamBuilder plannerAgent(Agent plannerAgent) {\n        this.plannerAgent = plannerAgent;\n        return this;\n    }\n\n    public AgentTeamBuilder synthesizerAgent(Agent synthesizerAgent) {\n        this.synthesizerAgent = synthesizerAgent;\n        return this;\n    }\n\n    public AgentTeamBuilder planner(AgentTeamPlanner planner) {\n        this.planner = planner;\n        return this;\n    }\n\n    public AgentTeamBuilder synthesizer(AgentTeamSynthesizer synthesizer) {\n        this.synthesizer = synthesizer;\n        return this;\n    }\n\n    public AgentTeamBuilder member(AgentTeamMember member) {\n        if (member != null) {\n            this.members.add(member);\n        }\n        return this;\n    }\n\n    public AgentTeamBuilder members(List<AgentTeamMember> members) {\n        if (members != null && !members.isEmpty()) {\n            this.members.addAll(members);\n        }\n        return this;\n    }\n\n    public AgentTeamBuilder options(AgentTeamOptions options) {\n        this.options = options;\n        return this;\n    }\n\n    public AgentTeamBuilder messageBus(AgentTeamMessageBus messageBus) {\n        this.messageBus = messageBus;\n        return this;\n    }\n\n    public AgentTeamBuilder stateStore(AgentTeamStateStore stateStore) {\n        this.stateStore = stateStore;\n        return this;\n    }\n\n    public AgentTeamBuilder teamId(String teamId) {\n        this.teamId = teamId;\n        return this;\n    }\n\n    public AgentTeamBuilder storageDirectory(Path storageDirectory) {\n        this.storageDirectory = storageDirectory;\n        return this;\n    }\n\n    public AgentTeamBuilder planApproval(AgentTeamPlanApproval planApproval) {\n        this.planApproval = planApproval;\n        return this;\n    }\n\n    public AgentTeamBuilder hook(AgentTeamHook hook) {\n        if (hook != null) {\n            this.hooks.add(hook);\n        }\n        return this;\n    }\n\n    public AgentTeamBuilder hooks(List<AgentTeamHook> hooks) {\n        if (hooks != null && !hooks.isEmpty()) {\n            this.hooks.addAll(hooks);\n        }\n        return this;\n    }\n\n    public AgentTeam build() {\n        return new AgentTeam(this);\n    }\n\n    public Agent buildAgent() {\n        return new Agent(\n                new AgentTeamAgentRuntime(this),\n                AgentContext.builder()\n                        .memory(new InMemoryAgentMemory())\n                        .build(),\n                new Supplier<AgentMemory>() {\n                    @Override\n                    public AgentMemory get() {\n                        return new InMemoryAgentMemory();\n                    }\n                }\n        );\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamControl.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport java.util.List;\n\npublic interface AgentTeamControl {\n\n    void registerMember(AgentTeamMember member);\n\n    boolean unregisterMember(String memberId);\n\n    List<AgentTeamMember> listMembers();\n\n    List<AgentTeamMessage> listMessages();\n\n    List<AgentTeamMessage> listMessagesFor(String memberId, int limit);\n\n    void publishMessage(AgentTeamMessage message);\n\n    void sendMessage(String fromMemberId, String toMemberId, String type, String taskId, String content);\n\n    void broadcastMessage(String fromMemberId, String type, String taskId, String content);\n\n    List<AgentTeamTaskState> listTaskStates();\n\n    boolean claimTask(String taskId, String memberId);\n\n    boolean releaseTask(String taskId, String memberId, String reason);\n\n    boolean reassignTask(String taskId, String fromMemberId, String toMemberId);\n\n    boolean heartbeatTask(String taskId, String memberId);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamEventHook.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.runtime.AgentToolExecutionScope;\n\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class AgentTeamEventHook implements AgentTeamHook {\n\n    private final AgentListener listener;\n\n    public AgentTeamEventHook() {\n        this(null);\n    }\n\n    public AgentTeamEventHook(AgentListener listener) {\n        this.listener = listener;\n    }\n\n    @Override\n    public void afterPlan(String objective, AgentTeamPlan plan) {\n        if (plan == null || plan.getTasks() == null) {\n            return;\n        }\n        for (AgentTeamTask task : plan.getTasks()) {\n            if (task == null || isBlank(task.getId())) {\n                continue;\n            }\n            emit(AgentEventType.TEAM_TASK_CREATED, buildTaskSummary(task, \"planned\"), buildTaskPayload(\n                    task,\n                    null,\n                    \"planned\",\n                    firstNonBlank(task.getTask(), \"Team task planned.\"),\n                    null,\n                    null,\n                    0L,\n                    null\n            ));\n        }\n    }\n\n    @Override\n    public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) {\n        if (task == null || isBlank(task.getId())) {\n            return;\n        }\n        emit(AgentEventType.TEAM_TASK_UPDATED, buildTaskSummary(task, \"running\"), buildTaskPayload(\n                task,\n                member,\n                \"running\",\n                \"Assigned to \" + firstNonBlank(member == null ? null : member.getName(), member == null ? null : member.resolveId(), \"member\") + \".\",\n                null,\n                null,\n                0L,\n                null\n        ));\n    }\n\n    @Override\n    public void afterTask(String objective, AgentTeamMemberResult result) {\n        if (result == null || result.getTask() == null || isBlank(result.getTaskId())) {\n            return;\n        }\n        AgentTeamTask task = result.getTask();\n        AgentTeamMember member = AgentTeamMember.builder()\n                .id(result.getMemberId())\n                .name(result.getMemberName())\n                .build();\n        String status = result.getTaskStatus() == AgentTeamTaskStatus.FAILED ? \"failed\" : \"completed\";\n        emit(AgentEventType.TEAM_TASK_UPDATED, buildTaskSummary(task, status), buildTaskPayload(\n                task,\n                member,\n                status,\n                firstNonBlank(result.getError(), result.getOutput(), status),\n                result.getOutput(),\n                result.getError(),\n                result.getDurationMillis(),\n                null\n        ));\n    }\n\n    @Override\n    public void onTaskStateChanged(String objective,\n                                   AgentTeamTaskState state,\n                                   AgentTeamMember member,\n                                   String detail) {\n        if (state == null || state.getTask() == null || isBlank(state.getTaskId())) {\n            return;\n        }\n        AgentTeamTask task = state.getTask();\n        String status = normalizeStatus(state.getStatus());\n        emit(AgentEventType.TEAM_TASK_UPDATED, buildTaskSummary(task, status), buildTaskPayload(\n                task,\n                member,\n                status,\n                firstNonBlank(detail, state.getDetail(), state.getError(), state.getOutput(), status),\n                state.getOutput(),\n                state.getError(),\n                state.getDurationMillis(),\n                state\n        ));\n    }\n\n    @Override\n    public void onMessage(AgentTeamMessage message) {\n        if (message == null) {\n            return;\n        }\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"messageId\", message.getId());\n        payload.put(\"fromMemberId\", message.getFromMemberId());\n        payload.put(\"toMemberId\", message.getToMemberId());\n        payload.put(\"taskId\", message.getTaskId());\n        payload.put(\"type\", message.getType());\n        payload.put(\"content\", message.getContent());\n        payload.put(\"createdAt\", Long.valueOf(message.getCreatedAt()));\n        payload.put(\"title\", \"Team message\");\n        payload.put(\"detail\", firstNonBlank(message.getContent(), message.getType(), \"Team message\"));\n        emit(AgentEventType.TEAM_MESSAGE, firstNonBlank(message.getContent(), message.getType(), \"Team message\"), payload);\n    }\n\n    private Map<String, Object> buildTaskPayload(AgentTeamTask task,\n                                                 AgentTeamMember member,\n                                                 String status,\n                                                 String detail,\n                                                 String output,\n                                                 String error,\n                                                 long durationMillis,\n                                                 AgentTeamTaskState state) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        String taskId = task == null ? null : task.getId();\n        payload.put(\"taskId\", taskId);\n        payload.put(\"callId\", taskId == null ? null : \"team-task:\" + taskId);\n        payload.put(\"title\", \"Team task \" + firstNonBlank(task == null ? null : task.getId(), \"task\"));\n        payload.put(\"status\", status);\n        payload.put(\"detail\", detail);\n        payload.put(\"phase\", firstNonBlank(state == null ? null : state.getPhase(), status));\n        payload.put(\"percent\", Integer.valueOf(resolvePercent(status, state == null ? null : state.getPercent())));\n        payload.put(\"updatedAtEpochMs\", Long.valueOf(state == null ? System.currentTimeMillis() : state.getUpdatedAtEpochMs()));\n        payload.put(\"heartbeatCount\", Integer.valueOf(state == null ? 0 : state.getHeartbeatCount()));\n        payload.put(\"startTime\", Long.valueOf(state == null ? 0L : state.getStartTime()));\n        payload.put(\"endTime\", Long.valueOf(state == null ? 0L : state.getEndTime()));\n        payload.put(\"lastHeartbeatTime\", Long.valueOf(state == null ? 0L : state.getLastHeartbeatTime()));\n        payload.put(\"memberId\", firstNonBlank(\n                member == null ? null : member.resolveId(),\n                state == null ? null : state.getClaimedBy(),\n                task == null ? null : task.getMemberId()));\n        payload.put(\"memberName\", firstNonBlank(member == null ? null : member.getName(),\n                state == null ? null : state.getClaimedBy(),\n                member == null ? null : member.resolveId(),\n                task == null ? null : task.getMemberId()));\n        payload.put(\"task\", task == null ? null : task.getTask());\n        payload.put(\"context\", task == null ? null : task.getContext());\n        payload.put(\"dependsOn\", task == null ? null : task.getDependsOn());\n        payload.put(\"output\", output);\n        payload.put(\"error\", error);\n        payload.put(\"durationMillis\", Long.valueOf(durationMillis));\n        return payload;\n    }\n\n    private String buildTaskSummary(AgentTeamTask task, String status) {\n        return \"Team task \" + firstNonBlank(task == null ? null : task.getId(), \"task\") + \" [\" + firstNonBlank(status, \"unknown\") + \"]\";\n    }\n\n    private String normalizeStatus(AgentTeamTaskStatus status) {\n        return status == null ? null : status.name().toLowerCase();\n    }\n\n    private int resolvePercent(String status, Integer statePercent) {\n        if (statePercent != null) {\n            return Math.max(0, Math.min(100, statePercent.intValue()));\n        }\n        if (isBlank(status)) {\n            return 0;\n        }\n        String normalized = status.trim().toLowerCase();\n        if (\"completed\".equals(normalized) || \"failed\".equals(normalized) || \"blocked\".equals(normalized)) {\n            return 100;\n        }\n        if (\"running\".equals(normalized) || \"in_progress\".equals(normalized) || \"in-progress\".equals(normalized)) {\n            return 15;\n        }\n        if (\"ready\".equals(normalized)) {\n            return 5;\n        }\n        return 0;\n    }\n\n    private void emit(AgentEventType type, String message, Map<String, Object> payload) {\n        AgentToolExecutionScope.emit(type, message, payload);\n        if (listener != null && type != null) {\n            listener.onEvent(AgentEvent.builder()\n                    .type(type)\n                    .message(message)\n                    .payload(payload)\n                    .build());\n        }\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamHook.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.AgentResult;\n\nimport java.util.List;\n\npublic interface AgentTeamHook {\n\n    default void beforePlan(String objective, List<AgentTeamMember> members, AgentTeamOptions options) {\n    }\n\n    default void afterPlan(String objective, AgentTeamPlan plan) {\n    }\n\n    default void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) {\n    }\n\n    default void afterTask(String objective, AgentTeamMemberResult result) {\n    }\n\n    default void onTaskStateChanged(String objective,\n                                    AgentTeamTaskState state,\n                                    AgentTeamMember member,\n                                    String detail) {\n    }\n\n    default void afterSynthesis(String objective, AgentResult result) {\n    }\n\n    default void onMessage(AgentTeamMessage message) {\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMember.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamMember {\n\n    private String id;\n\n    private String name;\n\n    private String description;\n\n    private Agent agent;\n\n    public String resolveId() {\n        String candidate = normalize(id);\n        if (candidate != null) {\n            return candidate;\n        }\n        candidate = normalize(name);\n        if (candidate != null) {\n            return candidate;\n        }\n        throw new IllegalArgumentException(\"team member id or name is required\");\n    }\n\n    private String normalize(String raw) {\n        if (raw == null) {\n            return null;\n        }\n        String normalized = raw.trim().toLowerCase();\n        if (normalized.isEmpty()) {\n            return null;\n        }\n        normalized = normalized.replaceAll(\"[^a-z0-9_\\\\-]\", \"_\");\n        normalized = normalized.replaceAll(\"_+\", \"_\");\n        normalized = normalized.replaceAll(\"^_+\", \"\");\n        normalized = normalized.replaceAll(\"_+$\", \"\");\n        return normalized.isEmpty() ? null : normalized;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMemberResult.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamMemberResult {\n\n    private String taskId;\n\n    private String memberId;\n\n    private String memberName;\n\n    private AgentTeamTask task;\n\n    private AgentTeamTaskStatus taskStatus;\n\n    private String output;\n\n    private String error;\n\n    private AgentResult rawResult;\n\n    private long durationMillis;\n\n    public boolean isSuccess() {\n        return error == null;\n    }\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMemberSnapshot.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamMemberSnapshot {\n\n    private String id;\n\n    private String name;\n\n    private String description;\n\n    public static AgentTeamMemberSnapshot from(AgentTeamMember member) {\n        if (member == null) {\n            return null;\n        }\n        return AgentTeamMemberSnapshot.builder()\n                .id(member.getId())\n                .name(member.getName())\n                .description(member.getDescription())\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMessage.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamMessage {\n\n    private String id;\n\n    private String fromMemberId;\n\n    private String toMemberId;\n\n    private String type;\n\n    private String taskId;\n\n    private String content;\n\n    private long createdAt;\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamMessageBus.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport java.util.List;\n\npublic interface AgentTeamMessageBus {\n\n    void publish(AgentTeamMessage message);\n\n    List<AgentTeamMessage> snapshot();\n\n    List<AgentTeamMessage> historyFor(String memberId, int limit);\n\n    void clear();\n\n    void restore(List<AgentTeamMessage> messages);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamOptions.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamOptions {\n\n    @Builder.Default\n    private boolean parallelDispatch = true;\n\n    @Builder.Default\n    private int maxConcurrency = 4;\n\n    @Builder.Default\n    private boolean continueOnMemberError = true;\n\n    @Builder.Default\n    private boolean broadcastOnPlannerFailure = true;\n\n    @Builder.Default\n    private boolean failOnUnknownMember = false;\n\n    @Builder.Default\n    private boolean includeOriginalObjectiveInDispatch = true;\n\n    @Builder.Default\n    private boolean includeTaskContextInDispatch = true;\n\n    @Builder.Default\n    private boolean includeMessageHistoryInDispatch = true;\n\n    @Builder.Default\n    private int messageHistoryLimit = 20;\n\n    @Builder.Default\n    private boolean enableMessageBus = true;\n\n    @Builder.Default\n    private boolean allowDynamicMemberRegistration = true;\n\n    @Builder.Default\n    private boolean requirePlanApproval = false;\n\n    @Builder.Default\n    private int maxRounds = 64;\n\n    @Builder.Default\n    private long taskClaimTimeoutMillis = 0L;\n\n    @Builder.Default\n    private boolean enableMemberTeamTools = true;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamPlan.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamPlan {\n\n    private List<AgentTeamTask> tasks;\n\n    private String rawPlanText;\n\n    @Builder.Default\n    private boolean fallback = false;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamPlanApproval.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport java.util.List;\n\npublic interface AgentTeamPlanApproval {\n\n    boolean approve(String objective,\n                    AgentTeamPlan plan,\n                    List<AgentTeamMember> members,\n                    AgentTeamOptions options);\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamPlanParser.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\nfinal class AgentTeamPlanParser {\n\n    private AgentTeamPlanParser() {\n    }\n\n    static List<AgentTeamTask> parseTasks(String rawText) {\n        if (rawText == null || rawText.trim().isEmpty()) {\n            return Collections.emptyList();\n        }\n\n        List<AgentTeamTask> tasks = parseFromJson(rawText);\n        if (!tasks.isEmpty()) {\n            return tasks;\n        }\n\n        String jsonPart = extractJson(rawText);\n        if (jsonPart == null) {\n            return Collections.emptyList();\n        }\n        return parseFromJson(jsonPart);\n    }\n\n    private static List<AgentTeamTask> parseFromJson(String text) {\n        if (text == null) {\n            return Collections.emptyList();\n        }\n        String trimmed = text.trim();\n        if (trimmed.isEmpty()) {\n            return Collections.emptyList();\n        }\n\n        try {\n            if (trimmed.startsWith(\"[\")) {\n                return parseArray(JSON.parseArray(trimmed));\n            }\n            if (trimmed.startsWith(\"{\")) {\n                JSONObject obj = JSON.parseObject(trimmed);\n                JSONArray tasksArray = firstArray(obj, \"tasks\", \"plan\", \"delegations\", \"assignments\");\n                if (tasksArray != null) {\n                    return parseArray(tasksArray);\n                }\n                AgentTeamTask singleTask = parseTask(obj);\n                if (singleTask == null) {\n                    return Collections.emptyList();\n                }\n                List<AgentTeamTask> tasks = new ArrayList<>();\n                tasks.add(singleTask);\n                return tasks;\n            }\n            return Collections.emptyList();\n        } catch (Exception ignored) {\n            return Collections.emptyList();\n        }\n    }\n\n    private static JSONArray firstArray(JSONObject obj, String... keys) {\n        if (obj == null || keys == null) {\n            return null;\n        }\n        for (String key : keys) {\n            Object value = obj.get(key);\n            if (value instanceof JSONArray) {\n                return (JSONArray) value;\n            }\n        }\n        return null;\n    }\n\n    private static List<AgentTeamTask> parseArray(JSONArray array) {\n        if (array == null || array.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamTask> tasks = new ArrayList<>();\n        for (Object item : array) {\n            if (!(item instanceof JSONObject)) {\n                continue;\n            }\n            AgentTeamTask task = parseTask((JSONObject) item);\n            if (task != null) {\n                tasks.add(task);\n            }\n        }\n        return tasks;\n    }\n\n    private static AgentTeamTask parseTask(JSONObject obj) {\n        if (obj == null) {\n            return null;\n        }\n        String taskId = firstString(obj, \"id\", \"taskId\", \"task_id\", \"name\");\n        String memberId = firstString(obj, \"memberId\", \"member\", \"agent\", \"assignee\");\n        String task = firstString(obj, \"task\", \"instruction\", \"goal\", \"work\", \"input\");\n        String context = firstString(obj, \"context\", \"notes\", \"memo\", \"details\");\n        List<String> dependsOn = parseDependencies(firstValue(obj, \"dependsOn\", \"depends_on\", \"deps\", \"after\"));\n        if (task == null || task.trim().isEmpty()) {\n            return null;\n        }\n        return AgentTeamTask.builder()\n                .id(taskId)\n                .memberId(memberId)\n                .task(task.trim())\n                .context(context == null ? null : context.trim())\n                .dependsOn(dependsOn)\n                .build();\n    }\n\n    private static List<String> parseDependencies(Object raw) {\n        if (raw == null) {\n            return Collections.emptyList();\n        }\n        List<String> dependencies = new ArrayList<>();\n        if (raw instanceof JSONArray) {\n            JSONArray array = (JSONArray) raw;\n            for (Object item : array) {\n                if (item instanceof String) {\n                    String value = ((String) item).trim();\n                    if (!value.isEmpty()) {\n                        dependencies.add(value);\n                    }\n                }\n            }\n            return dependencies;\n        }\n        if (raw instanceof String) {\n            String[] parts = ((String) raw).split(\",\");\n            for (String part : parts) {\n                String value = part.trim();\n                if (!value.isEmpty()) {\n                    dependencies.add(value);\n                }\n            }\n            return dependencies;\n        }\n        return Collections.emptyList();\n    }\n\n    private static Object firstValue(JSONObject obj, String... keys) {\n        if (obj == null || keys == null) {\n            return null;\n        }\n        for (String key : keys) {\n            if (obj.containsKey(key)) {\n                return obj.get(key);\n            }\n        }\n        return null;\n    }\n\n    private static String firstString(JSONObject obj, String... keys) {\n        Object value = firstValue(obj, keys);\n        if (value instanceof String) {\n            String text = ((String) value).trim();\n            if (!text.isEmpty()) {\n                return text;\n            }\n        }\n        return null;\n    }\n\n    private static String extractJson(String text) {\n        int objectStart = text.indexOf('{');\n        int arrayStart = text.indexOf('[');\n        int start;\n        char open;\n        char close;\n\n        if (objectStart < 0 && arrayStart < 0) {\n            return null;\n        }\n        if (arrayStart >= 0 && (objectStart < 0 || arrayStart < objectStart)) {\n            start = arrayStart;\n            open = '[';\n            close = ']';\n        } else {\n            start = objectStart;\n            open = '{';\n            close = '}';\n        }\n\n        int depth = 0;\n        boolean inString = false;\n        boolean escaped = false;\n        for (int i = start; i < text.length(); i++) {\n            char c = text.charAt(i);\n            if (inString) {\n                if (escaped) {\n                    escaped = false;\n                    continue;\n                }\n                if (c == '\\\\') {\n                    escaped = true;\n                    continue;\n                }\n                if (c == '\"') {\n                    inString = false;\n                }\n                continue;\n            }\n\n            if (c == '\"') {\n                inString = true;\n                continue;\n            }\n\n            if (c == open) {\n                depth++;\n                continue;\n            }\n            if (c == close) {\n                depth--;\n                if (depth == 0) {\n                    return text.substring(start, i + 1);\n                }\n            }\n        }\n        return null;\n    }\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamPlanner.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport java.util.List;\n\npublic interface AgentTeamPlanner {\n\n    AgentTeamPlan plan(String objective, List<AgentTeamMember> members, AgentTeamOptions options) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamResult.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamResult {\n\n    private String teamId;\n\n    private String objective;\n\n    private AgentTeamPlan plan;\n\n    private List<AgentTeamMemberResult> memberResults;\n\n    private List<AgentTeamTaskState> taskStates;\n\n    private List<AgentTeamMessage> messages;\n\n    private int rounds;\n\n    private String output;\n\n    private AgentResult synthesisResult;\n\n    private long totalDurationMillis;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamState.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamState {\n\n    private String teamId;\n\n    private String objective;\n\n    private List<AgentTeamMemberSnapshot> members;\n\n    private List<AgentTeamTaskState> taskStates;\n\n    private List<AgentTeamMessage> messages;\n\n    private String lastOutput;\n\n    private int lastRounds;\n\n    private long lastRunStartedAt;\n\n    private long lastRunCompletedAt;\n\n    private long updatedAt;\n\n    private boolean runActive;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamStateStore.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport java.util.List;\n\npublic interface AgentTeamStateStore {\n\n    void save(AgentTeamState state);\n\n    AgentTeamState load(String teamId);\n\n    List<AgentTeamState> list();\n\n    boolean delete(String teamId);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamSynthesizer.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.AgentResult;\n\nimport java.util.List;\n\npublic interface AgentTeamSynthesizer {\n\n    AgentResult synthesize(String objective,\n                           AgentTeamPlan plan,\n                           List<AgentTeamMemberResult> memberResults,\n                           AgentTeamOptions options) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamTask.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamTask {\n\n    private String id;\n\n    private String memberId;\n\n    private String task;\n\n    private String context;\n\n    private List<String> dependsOn;\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamTaskBoard.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class AgentTeamTaskBoard {\n\n    private final LinkedHashMap<String, AgentTeamTaskState> states = new LinkedHashMap<>();\n    private static final int PERCENT_PLANNED = 0;\n    private static final int PERCENT_READY = 5;\n    private static final int PERCENT_IN_PROGRESS = 15;\n    private static final int PERCENT_TERMINAL = 100;\n\n    public AgentTeamTaskBoard(List<AgentTeamTask> tasks) {\n        initialize(tasks);\n    }\n\n    private void initialize(List<AgentTeamTask> tasks) {\n        if (tasks == null || tasks.isEmpty()) {\n            return;\n        }\n        Set<String> usedIds = new HashSet<>();\n        int seq = 1;\n        for (AgentTeamTask task : tasks) {\n            AgentTeamTask safeTask = task == null ? AgentTeamTask.builder().build() : task;\n            String id = normalizeId(safeTask.getId());\n            if (id == null) {\n                id = \"task_\" + seq;\n                seq++;\n            }\n            while (usedIds.contains(id)) {\n                id = id + \"_\" + seq;\n                seq++;\n            }\n            usedIds.add(id);\n\n            List<String> dependencies = normalizeDependencies(safeTask.getDependsOn());\n            AgentTeamTask normalized = safeTask.toBuilder()\n                    .id(id)\n                    .dependsOn(dependencies)\n                    .build();\n\n            states.put(id, AgentTeamTaskState.builder()\n                    .taskId(id)\n                    .task(normalized)\n                    .status(AgentTeamTaskStatus.PENDING)\n                    .phase(\"planned\")\n                    .percent(Integer.valueOf(PERCENT_PLANNED))\n                    .updatedAtEpochMs(System.currentTimeMillis())\n                    .build());\n        }\n        refreshStatuses();\n    }\n\n    public synchronized List<AgentTeamTask> normalizedTasks() {\n        if (states.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamTask> tasks = new ArrayList<>();\n        for (AgentTeamTaskState state : states.values()) {\n            tasks.add(state.getTask());\n        }\n        return tasks;\n    }\n\n    public synchronized List<AgentTeamTaskState> nextReadyTasks(int maxCount) {\n        refreshStatuses();\n        if (states.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int safeCount = maxCount <= 0 ? 1 : maxCount;\n        List<AgentTeamTaskState> ready = new ArrayList<>();\n        for (AgentTeamTaskState state : states.values()) {\n            if (state.getStatus() == AgentTeamTaskStatus.READY) {\n                ready.add(copy(state));\n                if (ready.size() >= safeCount) {\n                    break;\n                }\n            }\n        }\n        return ready;\n    }\n\n    public synchronized AgentTeamTaskState getTaskState(String taskId) {\n        String key = resolveTaskKey(taskId);\n        if (key == null) {\n            return null;\n        }\n        return copy(states.get(key));\n    }\n\n    public synchronized boolean claimTask(String taskId, String memberId) {\n        refreshStatuses();\n        String key = resolveTaskKey(taskId);\n        if (key == null) {\n            return false;\n        }\n        AgentTeamTaskState state = states.get(key);\n        if (state == null || state.getStatus() != AgentTeamTaskStatus.READY) {\n            return false;\n        }\n        long now = System.currentTimeMillis();\n        states.put(key, state.toBuilder()\n                .status(AgentTeamTaskStatus.IN_PROGRESS)\n                .claimedBy(normalizeMemberId(memberId))\n                .startTime(now)\n                .lastHeartbeatTime(now)\n                .endTime(0L)\n                .durationMillis(0L)\n                .phase(\"running\")\n                .detail(\"Claimed by \" + firstNonBlank(normalizeMemberId(memberId), \"member\") + \".\")\n                .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_IN_PROGRESS)))\n                .updatedAtEpochMs(now)\n                .heartbeatCount(0)\n                .output(null)\n                .error(null)\n                .build());\n        return true;\n    }\n\n    public synchronized boolean releaseTask(String taskId, String memberId, String reason) {\n        String key = resolveTaskKey(taskId);\n        if (key == null) {\n            return false;\n        }\n        AgentTeamTaskState state = states.get(key);\n        if (state == null || state.getStatus() != AgentTeamTaskStatus.IN_PROGRESS) {\n            return false;\n        }\n        if (!isSameMember(state.getClaimedBy(), memberId)) {\n            return false;\n        }\n        states.put(key, state.toBuilder()\n                .status(AgentTeamTaskStatus.PENDING)\n                .claimedBy(null)\n                .startTime(0L)\n                .lastHeartbeatTime(0L)\n                .endTime(0L)\n                .durationMillis(0L)\n                .phase(\"released\")\n                .detail(firstNonBlank(trimToNull(reason), \"Released back to queue.\"))\n                .updatedAtEpochMs(System.currentTimeMillis())\n                .output(null)\n                .error(reason)\n                .build());\n        refreshStatuses();\n        return true;\n    }\n\n    public synchronized boolean reassignTask(String taskId, String fromMemberId, String toMemberId) {\n        String key = resolveTaskKey(taskId);\n        if (key == null) {\n            return false;\n        }\n        AgentTeamTaskState state = states.get(key);\n        if (state == null || state.getStatus() != AgentTeamTaskStatus.IN_PROGRESS) {\n            return false;\n        }\n        if (!isSameMember(state.getClaimedBy(), fromMemberId)) {\n            return false;\n        }\n        long now = System.currentTimeMillis();\n        states.put(key, state.toBuilder()\n                .claimedBy(normalizeMemberId(toMemberId))\n                .lastHeartbeatTime(now)\n                .phase(\"reassigned\")\n                .detail(\"Reassigned from \"\n                        + firstNonBlank(normalizeMemberId(fromMemberId), \"member\")\n                        + \" to \"\n                        + firstNonBlank(normalizeMemberId(toMemberId), \"member\")\n                        + \".\")\n                .updatedAtEpochMs(now)\n                .build());\n        return true;\n    }\n\n    public synchronized boolean heartbeatTask(String taskId, String memberId) {\n        String key = resolveTaskKey(taskId);\n        if (key == null) {\n            return false;\n        }\n        AgentTeamTaskState state = states.get(key);\n        if (state == null || state.getStatus() != AgentTeamTaskStatus.IN_PROGRESS) {\n            return false;\n        }\n        if (!isSameMember(state.getClaimedBy(), memberId)) {\n            return false;\n        }\n        long now = System.currentTimeMillis();\n        states.put(key, state.toBuilder()\n                .lastHeartbeatTime(now)\n                .phase(\"heartbeat\")\n                .detail(\"Heartbeat from \" + firstNonBlank(normalizeMemberId(memberId), \"member\") + \".\")\n                .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_IN_PROGRESS)))\n                .updatedAtEpochMs(now)\n                .heartbeatCount(state.getHeartbeatCount() + 1)\n                .build());\n        return true;\n    }\n\n    public synchronized int recoverTimedOutClaims(long timeoutMillis, String reason) {\n        if (timeoutMillis <= 0 || states.isEmpty()) {\n            return 0;\n        }\n        long now = System.currentTimeMillis();\n        int recovered = 0;\n        for (Map.Entry<String, AgentTeamTaskState> entry : states.entrySet()) {\n            AgentTeamTaskState state = entry.getValue();\n            if (state.getStatus() != AgentTeamTaskStatus.IN_PROGRESS) {\n                continue;\n            }\n            long lastBeat = state.getLastHeartbeatTime() > 0 ? state.getLastHeartbeatTime() : state.getStartTime();\n            if (lastBeat <= 0 || now - lastBeat < timeoutMillis) {\n                continue;\n            }\n            entry.setValue(state.toBuilder()\n                    .status(AgentTeamTaskStatus.PENDING)\n                    .claimedBy(null)\n                    .startTime(0L)\n                    .lastHeartbeatTime(0L)\n                    .endTime(0L)\n                    .durationMillis(0L)\n                    .phase(\"timeout_recovered\")\n                    .detail(reason == null ? \"Claim timed out.\" : reason)\n                    .updatedAtEpochMs(now)\n                    .output(null)\n                    .error(reason == null ? \"claim timeout\" : reason)\n                    .build());\n            recovered++;\n        }\n        if (recovered > 0) {\n            refreshStatuses();\n        }\n        return recovered;\n    }\n\n    public synchronized void markInProgress(String taskId, String claimedBy) {\n        claimTask(taskId, claimedBy);\n    }\n\n    public synchronized void markCompleted(String taskId, String output, long durationMillis) {\n        String key = resolveTaskKey(taskId);\n        if (key == null) {\n            return;\n        }\n        AgentTeamTaskState state = states.get(key);\n        if (state == null) {\n            return;\n        }\n        long now = System.currentTimeMillis();\n        states.put(key, state.toBuilder()\n                .status(AgentTeamTaskStatus.COMPLETED)\n                .phase(\"completed\")\n                .detail(firstNonBlank(trimToNull(output), \"Task completed.\"))\n                .percent(Integer.valueOf(PERCENT_TERMINAL))\n                .updatedAtEpochMs(now)\n                .output(output)\n                .durationMillis(durationMillis)\n                .endTime(now)\n                .lastHeartbeatTime(now)\n                .error(null)\n                .build());\n        refreshStatuses();\n    }\n\n    public synchronized void markFailed(String taskId, String error, long durationMillis) {\n        String key = resolveTaskKey(taskId);\n        if (key == null) {\n            return;\n        }\n        AgentTeamTaskState state = states.get(key);\n        if (state == null) {\n            return;\n        }\n        long now = System.currentTimeMillis();\n        states.put(key, state.toBuilder()\n                .status(AgentTeamTaskStatus.FAILED)\n                .phase(\"failed\")\n                .detail(firstNonBlank(trimToNull(error), \"Task failed.\"))\n                .percent(Integer.valueOf(PERCENT_TERMINAL))\n                .updatedAtEpochMs(now)\n                .error(error)\n                .durationMillis(durationMillis)\n                .endTime(now)\n                .lastHeartbeatTime(now)\n                .build());\n        refreshStatuses();\n    }\n\n    public synchronized void markStalledAsBlocked(String reason) {\n        for (Map.Entry<String, AgentTeamTaskState> entry : states.entrySet()) {\n            AgentTeamTaskState state = entry.getValue();\n            if (state.getStatus() == AgentTeamTaskStatus.PENDING || state.getStatus() == AgentTeamTaskStatus.READY) {\n                entry.setValue(state.toBuilder()\n                        .status(AgentTeamTaskStatus.BLOCKED)\n                        .phase(\"blocked\")\n                        .detail(reason)\n                        .percent(Integer.valueOf(PERCENT_TERMINAL))\n                        .updatedAtEpochMs(System.currentTimeMillis())\n                        .error(reason)\n                        .endTime(System.currentTimeMillis())\n                        .build());\n            }\n        }\n    }\n\n    public synchronized boolean hasWorkRemaining() {\n        for (AgentTeamTaskState state : states.values()) {\n            if (state.getStatus() == AgentTeamTaskStatus.PENDING\n                    || state.getStatus() == AgentTeamTaskStatus.READY\n                    || state.getStatus() == AgentTeamTaskStatus.IN_PROGRESS) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    public synchronized boolean hasFailed() {\n        for (AgentTeamTaskState state : states.values()) {\n            if (state.getStatus() == AgentTeamTaskStatus.FAILED) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    public synchronized List<AgentTeamTaskState> snapshot() {\n        if (states.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamTaskState> all = new ArrayList<>();\n        for (AgentTeamTaskState state : states.values()) {\n            all.add(copy(state));\n        }\n        return all;\n    }\n\n    public synchronized int size() {\n        return states.size();\n    }\n\n    private void refreshStatuses() {\n        if (states.isEmpty()) {\n            return;\n        }\n        for (Map.Entry<String, AgentTeamTaskState> entry : states.entrySet()) {\n            AgentTeamTaskState state = entry.getValue();\n            if (state.getStatus() == AgentTeamTaskStatus.IN_PROGRESS || state.isTerminal()) {\n                continue;\n            }\n\n            AgentTeamTask task = state.getTask();\n            List<String> dependencies = task == null ? null : task.getDependsOn();\n            if (dependencies == null || dependencies.isEmpty()) {\n                entry.setValue(state.toBuilder()\n                        .status(AgentTeamTaskStatus.READY)\n                        .phase(\"ready\")\n                        .detail(\"Ready for dispatch.\")\n                        .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_READY)))\n                        .updatedAtEpochMs(selectUpdatedAt(state))\n                        .error(null)\n                        .build());\n                continue;\n            }\n\n            boolean missing = false;\n            boolean blocked = false;\n            boolean allCompleted = true;\n            for (String dependencyId : dependencies) {\n                AgentTeamTaskState dependency = states.get(dependencyId);\n                if (dependency == null) {\n                    missing = true;\n                    allCompleted = false;\n                    break;\n                }\n                if (dependency.getStatus() == AgentTeamTaskStatus.FAILED\n                        || dependency.getStatus() == AgentTeamTaskStatus.BLOCKED) {\n                    blocked = true;\n                    allCompleted = false;\n                    break;\n                }\n                if (dependency.getStatus() != AgentTeamTaskStatus.COMPLETED) {\n                    allCompleted = false;\n                }\n            }\n\n            if (missing) {\n                entry.setValue(state.toBuilder()\n                        .status(AgentTeamTaskStatus.BLOCKED)\n                        .phase(\"blocked\")\n                        .detail(\"Blocked: missing dependency.\")\n                        .percent(Integer.valueOf(PERCENT_TERMINAL))\n                        .updatedAtEpochMs(selectUpdatedAt(state))\n                        .error(\"missing dependency\")\n                        .build());\n            } else if (blocked) {\n                entry.setValue(state.toBuilder()\n                        .status(AgentTeamTaskStatus.BLOCKED)\n                        .phase(\"blocked\")\n                        .detail(\"Blocked: dependency failed.\")\n                        .percent(Integer.valueOf(PERCENT_TERMINAL))\n                        .updatedAtEpochMs(selectUpdatedAt(state))\n                        .error(\"dependency failed\")\n                        .build());\n            } else if (allCompleted) {\n                entry.setValue(state.toBuilder()\n                        .status(AgentTeamTaskStatus.READY)\n                        .phase(\"ready\")\n                        .detail(\"Dependencies satisfied.\")\n                        .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_READY)))\n                        .updatedAtEpochMs(selectUpdatedAt(state))\n                        .error(null)\n                        .build());\n            } else {\n                entry.setValue(state.toBuilder()\n                        .status(AgentTeamTaskStatus.PENDING)\n                        .phase(\"pending\")\n                        .detail(\"Waiting for dependencies.\")\n                        .percent(Integer.valueOf(Math.max(percentOf(state), PERCENT_PLANNED)))\n                        .updatedAtEpochMs(selectUpdatedAt(state))\n                        .error(null)\n                        .build());\n            }\n        }\n    }\n\n    private String resolveTaskKey(String taskId) {\n        if (taskId == null) {\n            return null;\n        }\n        String trimmed = taskId.trim();\n        if (trimmed.isEmpty()) {\n            return null;\n        }\n        if (states.containsKey(trimmed)) {\n            return trimmed;\n        }\n        String normalized = normalizeId(trimmed);\n        if (normalized != null && states.containsKey(normalized)) {\n            return normalized;\n        }\n        return null;\n    }\n\n    private List<String> normalizeDependencies(List<String> dependencies) {\n        if (dependencies == null || dependencies.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> normalized = new ArrayList<>();\n        for (String dependency : dependencies) {\n            String id = normalizeId(dependency);\n            if (id != null && !normalized.contains(id)) {\n                normalized.add(id);\n            }\n        }\n        return normalized;\n    }\n\n    private String normalizeId(String raw) {\n        if (raw == null) {\n            return null;\n        }\n        String normalized = raw.trim().toLowerCase();\n        if (normalized.isEmpty()) {\n            return null;\n        }\n        normalized = normalized.replaceAll(\"[^a-z0-9_\\\\-]\", \"_\");\n        normalized = normalized.replaceAll(\"_+\", \"_\");\n        normalized = normalized.replaceAll(\"^_+\", \"\");\n        normalized = normalized.replaceAll(\"_+$\", \"\");\n        return normalized.isEmpty() ? null : normalized;\n    }\n\n    private String normalizeMemberId(String memberId) {\n        if (memberId == null) {\n            return null;\n        }\n        String normalized = memberId.trim();\n        return normalized.isEmpty() ? null : normalized;\n    }\n\n    private boolean isSameMember(String currentClaimedBy, String expectedMemberId) {\n        String current = normalizeMemberId(currentClaimedBy);\n        String expected = normalizeMemberId(expectedMemberId);\n        if (expected == null) {\n            return true;\n        }\n        return expected.equals(current);\n    }\n\n    private AgentTeamTaskState copy(AgentTeamTaskState state) {\n        if (state == null) {\n            return null;\n        }\n        return state.toBuilder().build();\n    }\n\n    private int percentOf(AgentTeamTaskState state) {\n        Integer percent = state == null ? null : state.getPercent();\n        return percent == null ? 0 : Math.max(0, Math.min(100, percent.intValue()));\n    }\n\n    private long selectUpdatedAt(AgentTeamTaskState state) {\n        if (state == null) {\n            return System.currentTimeMillis();\n        }\n        return state.getUpdatedAtEpochMs() > 0L ? state.getUpdatedAtEpochMs() : System.currentTimeMillis();\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (trimToNull(value) != null) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamTaskState.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class AgentTeamTaskState {\n\n    private String taskId;\n\n    private AgentTeamTask task;\n\n    private AgentTeamTaskStatus status;\n\n    private String claimedBy;\n\n    private long startTime;\n\n    private long endTime;\n\n    private long durationMillis;\n\n    private long lastHeartbeatTime;\n\n    private String phase;\n\n    private String detail;\n\n    private Integer percent;\n\n    private long updatedAtEpochMs;\n\n    private int heartbeatCount;\n\n    private String output;\n\n    private String error;\n\n    public boolean isTerminal() {\n        return status == AgentTeamTaskStatus.COMPLETED\n                || status == AgentTeamTaskStatus.FAILED\n                || status == AgentTeamTaskStatus.BLOCKED;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/AgentTeamTaskStatus.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\npublic enum AgentTeamTaskStatus {\n    PENDING,\n    READY,\n    IN_PROGRESS,\n    COMPLETED,\n    FAILED,\n    BLOCKED\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/FileAgentTeamMessageBus.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport com.alibaba.fastjson2.JSON;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class FileAgentTeamMessageBus implements AgentTeamMessageBus {\n\n    private final Path file;\n    private final List<AgentTeamMessage> messages = new ArrayList<AgentTeamMessage>();\n\n    public FileAgentTeamMessageBus(Path file) {\n        if (file == null) {\n            throw new IllegalArgumentException(\"file is required\");\n        }\n        this.file = file;\n        loadExistingMessages();\n    }\n\n    @Override\n    public synchronized void publish(AgentTeamMessage message) {\n        if (message == null) {\n            return;\n        }\n        messages.add(message);\n        append(message);\n    }\n\n    @Override\n    public synchronized List<AgentTeamMessage> snapshot() {\n        if (messages.isEmpty()) {\n            return Collections.emptyList();\n        }\n        return copyMessages(messages);\n    }\n\n    @Override\n    public synchronized List<AgentTeamMessage> historyFor(String memberId, int limit) {\n        if (messages.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int safeLimit = limit <= 0 ? messages.size() : limit;\n        List<AgentTeamMessage> filtered = new ArrayList<AgentTeamMessage>();\n        for (AgentTeamMessage message : messages) {\n            if (memberId == null || memberId.trim().isEmpty()) {\n                filtered.add(message);\n                continue;\n            }\n            if (memberId.equals(message.getToMemberId()) || message.getToMemberId() == null || \"*\".equals(message.getToMemberId())) {\n                filtered.add(message);\n            }\n        }\n        if (filtered.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int from = Math.max(0, filtered.size() - safeLimit);\n        return copyMessages(filtered.subList(from, filtered.size()));\n    }\n\n    @Override\n    public synchronized void clear() {\n        messages.clear();\n        rewriteAll();\n    }\n\n    @Override\n    public synchronized void restore(List<AgentTeamMessage> restoredMessages) {\n        messages.clear();\n        if (restoredMessages != null && !restoredMessages.isEmpty()) {\n            messages.addAll(copyMessages(restoredMessages));\n        }\n        rewriteAll();\n    }\n\n    private void loadExistingMessages() {\n        if (!Files.exists(file)) {\n            return;\n        }\n        try {\n            List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);\n            for (String line : lines) {\n                if (line == null || line.trim().isEmpty()) {\n                    continue;\n                }\n                AgentTeamMessage message = JSON.parseObject(line, AgentTeamMessage.class);\n                if (message != null) {\n                    messages.add(message);\n                }\n            }\n        } catch (IOException e) {\n            throw new IllegalStateException(\"failed to load team mailbox from \" + file, e);\n        }\n    }\n\n    private void append(AgentTeamMessage message) {\n        try {\n            ensureParent();\n            Files.write(file,\n                    Collections.singletonList(JSON.toJSONString(message)),\n                    StandardCharsets.UTF_8,\n                    StandardOpenOption.CREATE,\n                    StandardOpenOption.APPEND);\n        } catch (IOException e) {\n            throw new IllegalStateException(\"failed to append team message to \" + file, e);\n        }\n    }\n\n    private void rewriteAll() {\n        try {\n            ensureParent();\n            List<String> lines = new ArrayList<String>();\n            for (AgentTeamMessage message : messages) {\n                lines.add(JSON.toJSONString(message));\n            }\n            Files.write(file,\n                    lines,\n                    StandardCharsets.UTF_8,\n                    StandardOpenOption.CREATE,\n                    StandardOpenOption.TRUNCATE_EXISTING,\n                    StandardOpenOption.WRITE);\n        } catch (IOException e) {\n            throw new IllegalStateException(\"failed to rewrite team mailbox \" + file, e);\n        }\n    }\n\n    private void ensureParent() throws IOException {\n        Path parent = file.getParent();\n        if (parent != null) {\n            Files.createDirectories(parent);\n        }\n    }\n\n    private List<AgentTeamMessage> copyMessages(List<AgentTeamMessage> source) {\n        if (source == null || source.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamMessage> copy = new ArrayList<AgentTeamMessage>(source.size());\n        for (AgentTeamMessage message : source) {\n            copy.add(message == null ? null : message.toBuilder().build());\n        }\n        return copy;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/FileAgentTeamStateStore.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport com.alibaba.fastjson2.JSON;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.List;\n\npublic class FileAgentTeamStateStore implements AgentTeamStateStore {\n\n    private final Path directory;\n\n    public FileAgentTeamStateStore(Path directory) {\n        if (directory == null) {\n            throw new IllegalArgumentException(\"directory is required\");\n        }\n        this.directory = directory;\n    }\n\n    @Override\n    public synchronized void save(AgentTeamState state) {\n        if (state == null || isBlank(state.getTeamId())) {\n            return;\n        }\n        try {\n            ensureDirectory();\n            Files.write(fileOf(state.getTeamId()),\n                    JSON.toJSONString(state).getBytes(StandardCharsets.UTF_8),\n                    StandardOpenOption.CREATE,\n                    StandardOpenOption.TRUNCATE_EXISTING,\n                    StandardOpenOption.WRITE);\n        } catch (IOException e) {\n            throw new IllegalStateException(\"failed to save team state: \" + state.getTeamId(), e);\n        }\n    }\n\n    @Override\n    public synchronized AgentTeamState load(String teamId) {\n        if (isBlank(teamId)) {\n            return null;\n        }\n        Path file = fileOf(teamId);\n        if (!Files.exists(file)) {\n            return null;\n        }\n        try {\n            byte[] bytes = Files.readAllBytes(file);\n            if (bytes.length == 0) {\n                return null;\n            }\n            return JSON.parseObject(new String(bytes, StandardCharsets.UTF_8), AgentTeamState.class);\n        } catch (IOException e) {\n            throw new IllegalStateException(\"failed to load team state: \" + teamId, e);\n        }\n    }\n\n    @Override\n    public synchronized List<AgentTeamState> list() {\n        if (!Files.exists(directory)) {\n            return Collections.emptyList();\n        }\n        try {\n            List<AgentTeamState> states = new ArrayList<AgentTeamState>();\n            Files.list(directory)\n                    .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(\".json\"))\n                    .forEach(path -> {\n                        try {\n                            byte[] bytes = Files.readAllBytes(path);\n                            if (bytes.length == 0) {\n                                return;\n                            }\n                            AgentTeamState state = JSON.parseObject(new String(bytes, StandardCharsets.UTF_8), AgentTeamState.class);\n                            if (state != null) {\n                                states.add(state);\n                            }\n                        } catch (IOException ignored) {\n                        }\n                    });\n            Collections.sort(states, new Comparator<AgentTeamState>() {\n                @Override\n                public int compare(AgentTeamState left, AgentTeamState right) {\n                    long l = left == null ? 0L : left.getUpdatedAt();\n                    long r = right == null ? 0L : right.getUpdatedAt();\n                    return l == r ? 0 : (l < r ? 1 : -1);\n                }\n            });\n            return states;\n        } catch (IOException e) {\n            throw new IllegalStateException(\"failed to list team states in \" + directory, e);\n        }\n    }\n\n    @Override\n    public synchronized boolean delete(String teamId) {\n        if (isBlank(teamId)) {\n            return false;\n        }\n        try {\n            return Files.deleteIfExists(fileOf(teamId));\n        } catch (IOException e) {\n            throw new IllegalStateException(\"failed to delete team state: \" + teamId, e);\n        }\n    }\n\n    private void ensureDirectory() throws IOException {\n        Files.createDirectories(directory);\n    }\n\n    private Path fileOf(String teamId) {\n        return directory.resolve(teamId + \".json\");\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/InMemoryAgentTeamMessageBus.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class InMemoryAgentTeamMessageBus implements AgentTeamMessageBus {\n\n    private final List<AgentTeamMessage> messages = new ArrayList<>();\n\n    @Override\n    public synchronized void publish(AgentTeamMessage message) {\n        if (message == null) {\n            return;\n        }\n        messages.add(message);\n    }\n\n    @Override\n    public synchronized List<AgentTeamMessage> snapshot() {\n        if (messages.isEmpty()) {\n            return Collections.emptyList();\n        }\n        return new ArrayList<>(messages);\n    }\n\n    @Override\n    public synchronized List<AgentTeamMessage> historyFor(String memberId, int limit) {\n        if (messages.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int safeLimit = limit <= 0 ? messages.size() : limit;\n        List<AgentTeamMessage> filtered = new ArrayList<>();\n        for (AgentTeamMessage message : messages) {\n            if (memberId == null || memberId.trim().isEmpty()) {\n                filtered.add(message);\n                continue;\n            }\n            if (memberId.equals(message.getToMemberId()) || message.getToMemberId() == null || \"*\".equals(message.getToMemberId())) {\n                filtered.add(message);\n            }\n        }\n        if (filtered.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int from = Math.max(0, filtered.size() - safeLimit);\n        return new ArrayList<>(filtered.subList(from, filtered.size()));\n    }\n\n    @Override\n    public synchronized void clear() {\n        messages.clear();\n    }\n\n    @Override\n    public synchronized void restore(List<AgentTeamMessage> restoredMessages) {\n        messages.clear();\n        if (restoredMessages != null && !restoredMessages.isEmpty()) {\n            messages.addAll(restoredMessages);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/InMemoryAgentTeamStateStore.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class InMemoryAgentTeamStateStore implements AgentTeamStateStore {\n\n    private final Map<String, AgentTeamState> states = new LinkedHashMap<String, AgentTeamState>();\n\n    @Override\n    public synchronized void save(AgentTeamState state) {\n        if (state == null || isBlank(state.getTeamId())) {\n            return;\n        }\n        states.put(state.getTeamId(), copy(state));\n    }\n\n    @Override\n    public synchronized AgentTeamState load(String teamId) {\n        AgentTeamState state = states.get(teamId);\n        return state == null ? null : copy(state);\n    }\n\n    @Override\n    public synchronized List<AgentTeamState> list() {\n        if (states.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamState> list = new ArrayList<AgentTeamState>();\n        for (AgentTeamState state : states.values()) {\n            list.add(copy(state));\n        }\n        Collections.sort(list, new Comparator<AgentTeamState>() {\n            @Override\n            public int compare(AgentTeamState left, AgentTeamState right) {\n                long l = left == null ? 0L : left.getUpdatedAt();\n                long r = right == null ? 0L : right.getUpdatedAt();\n                return l == r ? 0 : (l < r ? 1 : -1);\n            }\n        });\n        return list;\n    }\n\n    @Override\n    public synchronized boolean delete(String teamId) {\n        return !isBlank(teamId) && states.remove(teamId) != null;\n    }\n\n    private AgentTeamState copy(AgentTeamState state) {\n        if (state == null) {\n            return null;\n        }\n        return state.toBuilder()\n                .members(copyMembers(state.getMembers()))\n                .taskStates(copyTaskStates(state.getTaskStates()))\n                .messages(copyMessages(state.getMessages()))\n                .build();\n    }\n\n    private List<AgentTeamMemberSnapshot> copyMembers(List<AgentTeamMemberSnapshot> members) {\n        if (members == null || members.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamMemberSnapshot> copy = new ArrayList<AgentTeamMemberSnapshot>(members.size());\n        for (AgentTeamMemberSnapshot member : members) {\n            copy.add(member == null ? null : member.toBuilder().build());\n        }\n        return copy;\n    }\n\n    private List<AgentTeamTaskState> copyTaskStates(List<AgentTeamTaskState> taskStates) {\n        if (taskStates == null || taskStates.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamTaskState> copy = new ArrayList<AgentTeamTaskState>(taskStates.size());\n        for (AgentTeamTaskState taskState : taskStates) {\n            copy.add(taskState == null ? null : taskState.toBuilder().build());\n        }\n        return copy;\n    }\n\n    private List<AgentTeamMessage> copyMessages(List<AgentTeamMessage> messages) {\n        if (messages == null || messages.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamMessage> copy = new ArrayList<AgentTeamMessage>(messages.size());\n        for (AgentTeamMessage message : messages) {\n            copy.add(message == null ? null : message.toBuilder().build());\n        }\n        return copy;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/LlmAgentTeamPlanner.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class LlmAgentTeamPlanner implements AgentTeamPlanner {\n\n    private final Agent plannerAgent;\n\n    public LlmAgentTeamPlanner(Agent plannerAgent) {\n        if (plannerAgent == null) {\n            throw new IllegalArgumentException(\"plannerAgent is required\");\n        }\n        this.plannerAgent = plannerAgent;\n    }\n\n    @Override\n    public AgentTeamPlan plan(String objective, List<AgentTeamMember> members, AgentTeamOptions options) throws Exception {\n        List<AgentTeamMember> safeMembers = members == null ? Collections.<AgentTeamMember>emptyList() : members;\n        String prompt = buildPlannerPrompt(objective, safeMembers);\n        AgentResult plannerResult = plannerAgent.newSession().run(AgentRequest.builder().input(prompt).build());\n        String planText = plannerResult == null ? null : plannerResult.getOutputText();\n\n        List<AgentTeamTask> parsedTasks = AgentTeamPlanParser.parseTasks(planText);\n        boolean fallback = false;\n        if (parsedTasks.isEmpty() && options != null && options.isBroadcastOnPlannerFailure()) {\n            parsedTasks = fallbackTasks(objective, safeMembers);\n            fallback = true;\n        }\n\n        return AgentTeamPlan.builder()\n                .tasks(parsedTasks)\n                .rawPlanText(planText)\n                .fallback(fallback)\n                .build();\n    }\n\n    private String buildPlannerPrompt(String objective, List<AgentTeamMember> members) {\n        StringBuilder sb = new StringBuilder();\n        sb.append(\"You are a team planner.\\n\");\n        sb.append(\"Break the user objective into executable tasks and assign each task to one member.\\n\");\n        sb.append(\"Prefer short tasks that can run independently.\\n\");\n        sb.append(\"Output JSON only. No markdown, no prose.\\n\");\n        sb.append(\"JSON schema:\\n\");\n        sb.append(\"{\\\"tasks\\\":[{\\\"id\\\":\\\"t1\\\",\\\"memberId\\\":\\\"<member id>\\\",\\\"task\\\":\\\"<task>\\\",\\\"context\\\":\\\"<optional context>\\\",\\\"dependsOn\\\":[\\\"<optional task id>\\\"]}]}\\n\\n\");\n        sb.append(\"Available members:\\n\");\n        for (AgentTeamMember member : members) {\n            sb.append(\"- id=\").append(member.resolveId());\n            if (member.getName() != null) {\n                sb.append(\", name=\").append(member.getName());\n            }\n            if (member.getDescription() != null) {\n                sb.append(\", expertise=\").append(member.getDescription());\n            }\n            sb.append(\"\\n\");\n        }\n        sb.append(\"\\nObjective:\\n\");\n        sb.append(objective == null ? \"\" : objective);\n        return sb.toString();\n    }\n\n    private List<AgentTeamTask> fallbackTasks(String objective, List<AgentTeamMember> members) {\n        if (members == null || members.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AgentTeamTask> tasks = new ArrayList<>();\n        int index = 1;\n        for (AgentTeamMember member : members) {\n            String description = member.getDescription();\n            String taskText;\n            if (description == null || description.trim().isEmpty()) {\n                taskText = objective;\n            } else {\n                taskText = \"Focus on \" + description + \". Objective: \" + objective;\n            }\n            tasks.add(AgentTeamTask.builder()\n                    .id(\"fallback_\" + index)\n                    .memberId(member.resolveId())\n                    .task(taskText)\n                    .context(\"planner_fallback\")\n                    .build());\n            index++;\n        }\n        return tasks;\n    }\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/LlmAgentTeamSynthesizer.java",
    "content": "package io.github.lnyocly.ai4j.agent.team;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\n\nimport java.util.Collections;\nimport java.util.List;\n\npublic class LlmAgentTeamSynthesizer implements AgentTeamSynthesizer {\n\n    private final Agent synthesizerAgent;\n\n    public LlmAgentTeamSynthesizer(Agent synthesizerAgent) {\n        if (synthesizerAgent == null) {\n            throw new IllegalArgumentException(\"synthesizerAgent is required\");\n        }\n        this.synthesizerAgent = synthesizerAgent;\n    }\n\n    @Override\n    public AgentResult synthesize(String objective,\n                                  AgentTeamPlan plan,\n                                  List<AgentTeamMemberResult> memberResults,\n                                  AgentTeamOptions options) throws Exception {\n        String prompt = buildSynthesisPrompt(objective, plan, memberResults);\n        return synthesizerAgent.newSession().run(AgentRequest.builder().input(prompt).build());\n    }\n\n    private String buildSynthesisPrompt(String objective,\n                                        AgentTeamPlan plan,\n                                        List<AgentTeamMemberResult> memberResults) {\n        List<AgentTeamMemberResult> safeResults = memberResults == null ? Collections.emptyList() : memberResults;\n\n        StringBuilder sb = new StringBuilder();\n        sb.append(\"You are the team lead. Merge member outputs into a final answer.\\n\");\n        sb.append(\"Rules:\\n\");\n        sb.append(\"1) Keep the final answer directly useful for the user objective.\\n\");\n        sb.append(\"2) Resolve conflicts by preferring higher-confidence or concrete evidence.\\n\");\n        sb.append(\"3) If a member failed, continue and mention missing evidence briefly.\\n\\n\");\n        sb.append(\"Objective:\\n\").append(objective == null ? \"\" : objective).append(\"\\n\\n\");\n        if (plan != null) {\n            sb.append(\"Plan JSON:\\n\");\n            sb.append(JSON.toJSONString(plan.getTasks())).append(\"\\n\\n\");\n        }\n        sb.append(\"Member outputs:\\n\");\n        for (AgentTeamMemberResult result : safeResults) {\n            sb.append(\"- memberId=\").append(result.getMemberId());\n            if (result.getMemberName() != null) {\n                sb.append(\", name=\").append(result.getMemberName());\n            }\n            if (result.isSuccess()) {\n                sb.append(\"\\n  output: \").append(result.getOutput() == null ? \"\" : result.getOutput());\n            } else {\n                sb.append(\"\\n  error: \").append(result.getError());\n            }\n            sb.append(\"\\n\");\n        }\n        return sb.toString();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/tool/AgentTeamToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.agent.team.tool;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamControl;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class AgentTeamToolExecutor implements ToolExecutor {\n\n    private final AgentTeamControl control;\n    private final String memberId;\n    private final String defaultTaskId;\n    private final ToolExecutor delegate;\n\n    public AgentTeamToolExecutor(AgentTeamControl control,\n                                 String memberId,\n                                 String defaultTaskId,\n                                 ToolExecutor delegate) {\n        if (control == null) {\n            throw new IllegalArgumentException(\"control is required\");\n        }\n        if (memberId == null || memberId.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"memberId is required\");\n        }\n        this.control = control;\n        this.memberId = memberId.trim();\n        this.defaultTaskId = defaultTaskId;\n        this.delegate = delegate;\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        if (call == null) {\n            return null;\n        }\n        String toolName = call.getName();\n        if (!AgentTeamToolRegistry.supports(toolName)) {\n            if (delegate == null) {\n                throw new IllegalStateException(\"toolExecutor is required for non-team tool: \" + toolName);\n            }\n            return delegate.execute(call);\n        }\n\n        JSONObject args = parseArguments(call.getArguments());\n        if (AgentTeamToolRegistry.TOOL_SEND_MESSAGE.equals(toolName)) {\n            return handleSendMessage(args);\n        }\n        if (AgentTeamToolRegistry.TOOL_BROADCAST.equals(toolName)) {\n            return handleBroadcast(args);\n        }\n        if (AgentTeamToolRegistry.TOOL_LIST_TASKS.equals(toolName)) {\n            return handleListTasks();\n        }\n        if (AgentTeamToolRegistry.TOOL_CLAIM_TASK.equals(toolName)) {\n            return handleClaimTask(args);\n        }\n        if (AgentTeamToolRegistry.TOOL_RELEASE_TASK.equals(toolName)) {\n            return handleReleaseTask(args);\n        }\n        if (AgentTeamToolRegistry.TOOL_REASSIGN_TASK.equals(toolName)) {\n            return handleReassignTask(args);\n        }\n        if (AgentTeamToolRegistry.TOOL_HEARTBEAT_TASK.equals(toolName)) {\n            return handleHeartbeatTask(args);\n        }\n        throw new IllegalStateException(\"unsupported team tool: \" + toolName);\n    }\n\n    private String handleSendMessage(JSONObject args) {\n        String toMemberId = firstString(args, \"toMemberId\", \"to\", \"memberId\");\n        String content = firstString(args, \"content\", \"message\", \"text\");\n        String type = firstString(args, \"type\");\n        String taskId = resolveTaskId(args, false);\n\n        if (toMemberId == null) {\n            throw new IllegalArgumentException(\"toMemberId is required\");\n        }\n        if (type == null) {\n            type = \"peer.message\";\n        }\n        if (content == null) {\n            content = \"\";\n        }\n\n        control.sendMessage(memberId, toMemberId, type, taskId, content);\n        JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_SEND_MESSAGE, true);\n        result.put(\"toMemberId\", toMemberId);\n        result.put(\"taskId\", taskId);\n        return result.toJSONString();\n    }\n\n    private String handleBroadcast(JSONObject args) {\n        String content = firstString(args, \"content\", \"message\", \"text\");\n        String type = firstString(args, \"type\");\n        String taskId = resolveTaskId(args, false);\n\n        if (type == null) {\n            type = \"peer.broadcast\";\n        }\n        if (content == null) {\n            content = \"\";\n        }\n\n        control.broadcastMessage(memberId, type, taskId, content);\n        JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_BROADCAST, true);\n        result.put(\"taskId\", taskId);\n        return result.toJSONString();\n    }\n\n    private String handleListTasks() {\n        List<AgentTeamTaskState> tasks = control.listTaskStates();\n        JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_LIST_TASKS, true);\n        result.put(\"tasks\", tasks == null ? new ArrayList<AgentTeamTaskState>() : tasks);\n        return result.toJSONString();\n    }\n\n    private String handleClaimTask(JSONObject args) {\n        String taskId = resolveTaskId(args, true);\n        boolean ok = control.claimTask(taskId, memberId);\n        JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_CLAIM_TASK, ok);\n        result.put(\"taskId\", taskId);\n        result.put(\"taskState\", findTaskState(taskId));\n        return result.toJSONString();\n    }\n\n    private String handleReleaseTask(JSONObject args) {\n        String taskId = resolveTaskId(args, true);\n        String reason = firstString(args, \"reason\", \"message\");\n        boolean ok = control.releaseTask(taskId, memberId, reason);\n        JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_RELEASE_TASK, ok);\n        result.put(\"taskId\", taskId);\n        result.put(\"taskState\", findTaskState(taskId));\n        return result.toJSONString();\n    }\n\n    private String handleReassignTask(JSONObject args) {\n        String taskId = resolveTaskId(args, true);\n        String toMemberId = firstString(args, \"toMemberId\", \"to\", \"memberId\");\n        if (toMemberId == null) {\n            throw new IllegalArgumentException(\"toMemberId is required\");\n        }\n        boolean ok = control.reassignTask(taskId, memberId, toMemberId);\n        JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_REASSIGN_TASK, ok);\n        result.put(\"taskId\", taskId);\n        result.put(\"toMemberId\", toMemberId);\n        result.put(\"taskState\", findTaskState(taskId));\n        return result.toJSONString();\n    }\n\n    private String handleHeartbeatTask(JSONObject args) {\n        String taskId = resolveTaskId(args, true);\n        boolean ok = control.heartbeatTask(taskId, memberId);\n        JSONObject result = baseResult(AgentTeamToolRegistry.TOOL_HEARTBEAT_TASK, ok);\n        result.put(\"taskId\", taskId);\n        result.put(\"taskState\", findTaskState(taskId));\n        return result.toJSONString();\n    }\n\n    private JSONObject parseArguments(String raw) {\n        if (raw == null || raw.trim().isEmpty()) {\n            return new JSONObject();\n        }\n        try {\n            JSONObject object = JSON.parseObject(raw);\n            return object == null ? new JSONObject() : object;\n        } catch (Exception e) {\n            throw new IllegalArgumentException(\"invalid team tool arguments: \" + raw);\n        }\n    }\n\n    private String resolveTaskId(JSONObject args, boolean required) {\n        String taskId = firstString(args, \"taskId\", \"id\");\n        if (taskId == null || taskId.trim().isEmpty()) {\n            taskId = defaultTaskId;\n        }\n        if (required && (taskId == null || taskId.trim().isEmpty())) {\n            throw new IllegalArgumentException(\"taskId is required\");\n        }\n        return taskId;\n    }\n\n    private AgentTeamTaskState findTaskState(String taskId) {\n        if (taskId == null || taskId.trim().isEmpty()) {\n            return null;\n        }\n        List<AgentTeamTaskState> states = control.listTaskStates();\n        if (states == null || states.isEmpty()) {\n            return null;\n        }\n        for (AgentTeamTaskState state : states) {\n            if (state == null || state.getTaskId() == null) {\n                continue;\n            }\n            if (taskId.equals(state.getTaskId())) {\n                return state;\n            }\n        }\n        return null;\n    }\n\n    private String firstString(JSONObject args, String... keys) {\n        if (args == null || keys == null) {\n            return null;\n        }\n        for (String key : keys) {\n            String value = args.getString(key);\n            if (value != null) {\n                String trimmed = value.trim();\n                if (!trimmed.isEmpty()) {\n                    return trimmed;\n                }\n            }\n        }\n        return null;\n    }\n\n    private JSONObject baseResult(String action, boolean ok) {\n        JSONObject object = new JSONObject();\n        object.put(\"action\", action);\n        object.put(\"ok\", ok);\n        object.put(\"memberId\", memberId);\n        return object;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/tool/AgentTeamToolRegistry.java",
    "content": "package io.github.lnyocly.ai4j.agent.team.tool;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class AgentTeamToolRegistry implements AgentToolRegistry {\n\n    public static final String TOOL_SEND_MESSAGE = \"team_send_message\";\n    public static final String TOOL_BROADCAST = \"team_broadcast\";\n    public static final String TOOL_LIST_TASKS = \"team_list_tasks\";\n    public static final String TOOL_CLAIM_TASK = \"team_claim_task\";\n    public static final String TOOL_RELEASE_TASK = \"team_release_task\";\n    public static final String TOOL_REASSIGN_TASK = \"team_reassign_task\";\n    public static final String TOOL_HEARTBEAT_TASK = \"team_heartbeat_task\";\n\n    private static final Set<String> TOOL_NAMES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(\n            TOOL_SEND_MESSAGE,\n            TOOL_BROADCAST,\n            TOOL_LIST_TASKS,\n            TOOL_CLAIM_TASK,\n            TOOL_RELEASE_TASK,\n            TOOL_REASSIGN_TASK,\n            TOOL_HEARTBEAT_TASK\n    )));\n\n    private final List<Object> tools;\n\n    public AgentTeamToolRegistry() {\n        this.tools = buildTools();\n    }\n\n    @Override\n    public List<Object> getTools() {\n        return new ArrayList<>(tools);\n    }\n\n    public static boolean supports(String toolName) {\n        return toolName != null && TOOL_NAMES.contains(toolName);\n    }\n\n    private List<Object> buildTools() {\n        List<Object> list = new ArrayList<>();\n        list.add(createSendMessageTool());\n        list.add(createBroadcastTool());\n        list.add(createListTasksTool());\n        list.add(createClaimTaskTool());\n        list.add(createReleaseTaskTool());\n        list.add(createReassignTaskTool());\n        list.add(createHeartbeatTaskTool());\n        return list;\n    }\n\n    private Tool createSendMessageTool() {\n        Map<String, Tool.Function.Property> props = new LinkedHashMap<>();\n        props.put(\"toMemberId\", stringProperty(\"Receiver member id\"));\n        props.put(\"content\", stringProperty(\"Message content\"));\n        props.put(\"type\", stringProperty(\"Optional message type, default peer.message\"));\n        props.put(\"taskId\", stringProperty(\"Optional task id for message threading\"));\n        return createTool(TOOL_SEND_MESSAGE,\n                \"Send a direct message to a teammate.\",\n                props,\n                Arrays.asList(\"toMemberId\", \"content\"));\n    }\n\n    private Tool createBroadcastTool() {\n        Map<String, Tool.Function.Property> props = new LinkedHashMap<>();\n        props.put(\"content\", stringProperty(\"Broadcast message content\"));\n        props.put(\"type\", stringProperty(\"Optional message type, default peer.broadcast\"));\n        props.put(\"taskId\", stringProperty(\"Optional task id for message threading\"));\n        return createTool(TOOL_BROADCAST,\n                \"Broadcast a message to the whole team.\",\n                props,\n                Arrays.asList(\"content\"));\n    }\n\n    private Tool createListTasksTool() {\n        Map<String, Tool.Function.Property> props = new LinkedHashMap<>();\n        return createTool(TOOL_LIST_TASKS,\n                \"List current shared task states.\",\n                props,\n                Collections.<String>emptyList());\n    }\n\n    private Tool createClaimTaskTool() {\n        Map<String, Tool.Function.Property> props = new LinkedHashMap<>();\n        props.put(\"taskId\", stringProperty(\"Task id to claim\"));\n        return createTool(TOOL_CLAIM_TASK,\n                \"Claim a ready task for execution.\",\n                props,\n                Arrays.asList(\"taskId\"));\n    }\n\n    private Tool createReleaseTaskTool() {\n        Map<String, Tool.Function.Property> props = new LinkedHashMap<>();\n        props.put(\"taskId\", stringProperty(\"Task id to release\"));\n        props.put(\"reason\", stringProperty(\"Optional release reason\"));\n        return createTool(TOOL_RELEASE_TASK,\n                \"Release a claimed task back to the queue.\",\n                props,\n                Arrays.asList(\"taskId\"));\n    }\n\n    private Tool createReassignTaskTool() {\n        Map<String, Tool.Function.Property> props = new LinkedHashMap<>();\n        props.put(\"taskId\", stringProperty(\"Task id to reassign\"));\n        props.put(\"toMemberId\", stringProperty(\"Target teammate id\"));\n        return createTool(TOOL_REASSIGN_TASK,\n                \"Reassign your claimed task to another teammate.\",\n                props,\n                Arrays.asList(\"taskId\", \"toMemberId\"));\n    }\n\n    private Tool createHeartbeatTaskTool() {\n        Map<String, Tool.Function.Property> props = new LinkedHashMap<>();\n        props.put(\"taskId\", stringProperty(\"Task id to heartbeat\"));\n        return createTool(TOOL_HEARTBEAT_TASK,\n                \"Heartbeat for an in-progress task to avoid timeout recovery.\",\n                props,\n                Arrays.asList(\"taskId\"));\n    }\n\n    private Tool createTool(String name,\n                            String description,\n                            Map<String, Tool.Function.Property> properties,\n                            List<String> required) {\n        Tool.Function.Parameter parameter = new Tool.Function.Parameter();\n        parameter.setType(\"object\");\n        parameter.setProperties(properties);\n        parameter.setRequired(required);\n\n        Tool.Function function = new Tool.Function();\n        function.setName(name);\n        function.setDescription(description);\n        function.setParameters(parameter);\n\n        Tool tool = new Tool();\n        tool.setType(\"function\");\n        tool.setFunction(function);\n        return tool;\n    }\n\n    private Tool.Function.Property stringProperty(String description) {\n        Tool.Function.Property property = new Tool.Function.Property();\n        property.setType(\"string\");\n        property.setDescription(description);\n        return property;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolCall.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class AgentToolCall {\n\n    private String name;\n\n    private String arguments;\n\n    private String callId;\n\n    private String type;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolCallSanitizer.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic final class AgentToolCallSanitizer {\n\n    private AgentToolCallSanitizer() {\n    }\n\n    public static List<AgentToolCall> retainExecutableCalls(List<AgentToolCall> calls) {\n        List<AgentToolCall> valid = new ArrayList<AgentToolCall>();\n        if (calls == null || calls.isEmpty()) {\n            return valid;\n        }\n        for (AgentToolCall call : calls) {\n            if (isExecutable(call)) {\n                valid.add(call);\n            }\n        }\n        return valid;\n    }\n\n    public static String validationError(AgentToolCall call) {\n        if (call == null) {\n            return \"tool call payload is missing\";\n        }\n        if (isBlank(call.getName())) {\n            return \"tool name is required\";\n        }\n        if (isBlank(call.getArguments())) {\n            return call.getName().trim() + \" arguments are required\";\n        }\n        String toolName = call.getName().trim();\n        JSONObject arguments = parseObject(call.getArguments());\n        if (arguments == null) {\n            return toolName + \" arguments must be a JSON object\";\n        }\n        if (\"bash\".equals(toolName)) {\n            return bashValidationError(arguments);\n        }\n        if (\"read_file\".equals(toolName) && isBlank(arguments.getString(\"path\"))) {\n            return \"read_file requires a non-empty path\";\n        }\n        if (\"apply_patch\".equals(toolName) && isBlank(arguments.getString(\"patch\"))) {\n            return \"apply_patch requires a non-empty patch\";\n        }\n        return null;\n    }\n\n    public static boolean isExecutable(AgentToolCall call) {\n        return validationError(call) == null;\n    }\n\n    private static boolean isExecutableBashCall(JSONObject arguments) {\n        if (arguments == null) {\n            return false;\n        }\n        String action = firstNonBlank(arguments.getString(\"action\"), \"exec\");\n        if (\"exec\".equals(action) || \"start\".equals(action)) {\n            return !isBlank(arguments.getString(\"command\"));\n        }\n        if (\"status\".equals(action) || \"logs\".equals(action) || \"stop\".equals(action) || \"write\".equals(action)) {\n            return !isBlank(arguments.getString(\"processId\"));\n        }\n        if (\"list\".equals(action)) {\n            return true;\n        }\n        return false;\n    }\n\n    private static String bashValidationError(JSONObject arguments) {\n        if (arguments == null) {\n            return \"bash arguments must be a JSON object\";\n        }\n        String action = firstNonBlank(arguments.getString(\"action\"), \"exec\");\n        if (\"exec\".equals(action) || \"start\".equals(action)) {\n            return isBlank(arguments.getString(\"command\")) ? \"bash \" + action + \" requires a non-empty command\" : null;\n        }\n        if (\"status\".equals(action) || \"logs\".equals(action) || \"stop\".equals(action) || \"write\".equals(action)) {\n            return isBlank(arguments.getString(\"processId\")) ? \"bash \" + action + \" requires a processId\" : null;\n        }\n        if (\"list\".equals(action)) {\n            return null;\n        }\n        return \"unsupported bash action: \" + action;\n    }\n\n    private static JSONObject parseObject(String value) {\n        if (isBlank(value)) {\n            return null;\n        }\n        try {\n            return JSON.parseObject(value);\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolRegistry.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\nimport java.util.List;\n\npublic interface AgentToolRegistry {\n\n    List<Object> getTools();\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolResult.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class AgentToolResult {\n\n    private String name;\n\n    private String callId;\n\n    private String output;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/CompositeToolRegistry.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class CompositeToolRegistry implements AgentToolRegistry {\n\n    private final List<AgentToolRegistry> registries;\n\n    public CompositeToolRegistry(List<AgentToolRegistry> registries) {\n        if (registries == null) {\n            this.registries = Collections.emptyList();\n        } else {\n            this.registries = new ArrayList<>(registries);\n        }\n    }\n\n    public CompositeToolRegistry(AgentToolRegistry first, AgentToolRegistry second) {\n        List<AgentToolRegistry> list = new ArrayList<>();\n        if (first != null) {\n            list.add(first);\n        }\n        if (second != null) {\n            list.add(second);\n        }\n        this.registries = list;\n    }\n\n    @Override\n    public List<Object> getTools() {\n        List<Object> tools = new ArrayList<>();\n        for (AgentToolRegistry registry : registries) {\n            if (registry == null) {\n                continue;\n            }\n            List<Object> items = registry.getTools();\n            if (items != null && !items.isEmpty()) {\n                tools.addAll(items);\n            }\n        }\n        return tools;\n    }\n}"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/StaticToolRegistry.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class StaticToolRegistry implements AgentToolRegistry {\n\n    private final List<Object> tools;\n\n    public StaticToolRegistry(List<Object> tools) {\n        if (tools == null) {\n            this.tools = Collections.emptyList();\n        } else {\n            this.tools = new ArrayList<>(tools);\n        }\n    }\n\n    @Override\n    public List<Object> getTools() {\n        return new ArrayList<>(tools);\n    }\n\n    public static StaticToolRegistry empty() {\n        return new StaticToolRegistry(Collections.emptyList());\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\npublic interface ToolExecutor {\n\n    String execute(AgentToolCall call) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolUtilExecutor.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\n\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.Set;\n\npublic class ToolUtilExecutor implements ToolExecutor {\n\n    private final Set<String> allowedToolNames;\n\n    public ToolUtilExecutor() {\n        this(null);\n    }\n\n    public ToolUtilExecutor(Set<String> allowedToolNames) {\n        if (allowedToolNames == null) {\n            this.allowedToolNames = null;\n        } else {\n            this.allowedToolNames = Collections.unmodifiableSet(new HashSet<>(allowedToolNames));\n        }\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        if (call == null) {\n            return null;\n        }\n        if (allowedToolNames != null) {\n            String toolName = call.getName();\n            if (toolName == null || !allowedToolNames.contains(toolName)) {\n                throw new IllegalArgumentException(\"Tool not allowed: \" + toolName);\n            }\n        }\n        return ToolUtil.invoke(call.getName(), call.getArguments());\n    }\n}\n\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolUtilRegistry.java",
    "content": "package io.github.lnyocly.ai4j.agent.tool;\n\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.tool.ToolUtil;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class ToolUtilRegistry implements AgentToolRegistry {\n\n    private final List<String> functionList;\n    private final List<String> mcpServerIds;\n\n    public ToolUtilRegistry(List<String> functionList, List<String> mcpServerIds) {\n        this.functionList = functionList;\n        this.mcpServerIds = mcpServerIds;\n    }\n\n    @Override\n    public List<Object> getTools() {\n        List<Tool> tools = ToolUtil.getAllTools(functionList, mcpServerIds);\n        return new ArrayList<Object>(tools);\n    }\n}\n\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AbstractOpenTelemetryTraceExporter.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport io.opentelemetry.api.OpenTelemetry;\nimport io.opentelemetry.api.trace.Span;\nimport io.opentelemetry.api.trace.SpanBuilder;\nimport io.opentelemetry.api.trace.Tracer;\nimport io.opentelemetry.context.Context;\n\nimport java.util.Iterator;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\n\nabstract class AbstractOpenTelemetryTraceExporter implements TraceExporter {\n\n    private final Tracer tracer;\n    private final Map<String, TraceSpan> pendingSpans = new LinkedHashMap<String, TraceSpan>();\n    private final Map<String, Span> exportedSpans = new LinkedHashMap<String, Span>();\n    private final Map<String, String> spanTraceIds = new LinkedHashMap<String, String>();\n    private final Map<String, Boolean> completedTraces = new LinkedHashMap<String, Boolean>();\n\n    protected AbstractOpenTelemetryTraceExporter(OpenTelemetry openTelemetry, String instrumentationName) {\n        this(openTelemetry == null ? null : openTelemetry.getTracer(instrumentationName));\n    }\n\n    protected AbstractOpenTelemetryTraceExporter(Tracer tracer) {\n        if (tracer == null) {\n            throw new IllegalArgumentException(\"tracer is required\");\n        }\n        this.tracer = tracer;\n    }\n\n    @Override\n    public synchronized void export(TraceSpan traceSpan) {\n        if (traceSpan == null || traceSpan.getSpanId() == null) {\n            return;\n        }\n        pendingSpans.put(traceSpan.getSpanId(), traceSpan);\n        if (traceSpan.getParentSpanId() == null) {\n            completedTraces.put(traceSpan.getTraceId(), Boolean.TRUE);\n        }\n        flushReadySpans(traceSpan.getTraceId());\n        cleanupIfTraceComplete(traceSpan.getTraceId());\n    }\n\n    protected void customizeSpan(Span span, TraceSpan traceSpan) {\n    }\n\n    private void flushReadySpans(String traceId) {\n        boolean emitted;\n        do {\n            emitted = false;\n            Iterator<Map.Entry<String, TraceSpan>> iterator = pendingSpans.entrySet().iterator();\n            while (iterator.hasNext()) {\n                Map.Entry<String, TraceSpan> entry = iterator.next();\n                TraceSpan traceSpan = entry.getValue();\n                if (!sameTrace(traceId, traceSpan.getTraceId())) {\n                    continue;\n                }\n                String parentSpanId = traceSpan.getParentSpanId();\n                if (parentSpanId != null && !exportedSpans.containsKey(parentSpanId)) {\n                    continue;\n                }\n                Span parentSpan = parentSpanId == null ? null : exportedSpans.get(parentSpanId);\n                emitSpan(traceSpan, parentSpan);\n                iterator.remove();\n                emitted = true;\n            }\n        } while (emitted);\n    }\n\n    private void emitSpan(TraceSpan traceSpan, Span parentSpan) {\n        SpanBuilder builder = tracer.spanBuilder(traceSpan.getName() == null ? \"ai4j.trace\" : traceSpan.getName())\n                .setStartTimestamp(traceSpan.getStartTime(), TimeUnit.MILLISECONDS);\n        if (parentSpan != null) {\n            builder.setParent(Context.current().with(parentSpan));\n        }\n        Span span = builder.startSpan();\n        try {\n            OpenTelemetryTraceSupport.applyCommonAttributes(span, traceSpan);\n            customizeSpan(span, traceSpan);\n        } finally {\n            span.end(traceSpan.getEndTime() > 0 ? traceSpan.getEndTime() : System.currentTimeMillis(), TimeUnit.MILLISECONDS);\n        }\n        exportedSpans.put(traceSpan.getSpanId(), span);\n        spanTraceIds.put(traceSpan.getSpanId(), traceSpan.getTraceId());\n    }\n\n    private void cleanupIfTraceComplete(String traceId) {\n        if (traceId == null || !Boolean.TRUE.equals(completedTraces.get(traceId)) || hasPendingTrace(traceId)) {\n            return;\n        }\n        Iterator<Map.Entry<String, String>> iterator = spanTraceIds.entrySet().iterator();\n        while (iterator.hasNext()) {\n            Map.Entry<String, String> entry = iterator.next();\n            if (sameTrace(traceId, entry.getValue())) {\n                exportedSpans.remove(entry.getKey());\n                iterator.remove();\n            }\n        }\n        completedTraces.remove(traceId);\n    }\n\n    private boolean hasPendingTrace(String traceId) {\n        for (TraceSpan traceSpan : pendingSpans.values()) {\n            if (sameTrace(traceId, traceSpan.getTraceId())) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private boolean sameTrace(String left, String right) {\n        if (left == null) {\n            return right == null;\n        }\n        return left.equals(right);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AgentFlowTraceBridge.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceListener;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowEvent;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowRequest;\nimport io.github.lnyocly.ai4j.agentflow.workflow.AgentFlowWorkflowResponse;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic class AgentFlowTraceBridge implements AgentFlowTraceListener {\n\n    private final TraceExporter exporter;\n    private final TraceConfig config;\n    private final Map<String, TraceSpan> activeSpans = new LinkedHashMap<String, TraceSpan>();\n\n    public AgentFlowTraceBridge(TraceExporter exporter) {\n        this(exporter, null);\n    }\n\n    public AgentFlowTraceBridge(TraceExporter exporter, TraceConfig config) {\n        if (exporter == null) {\n            throw new IllegalArgumentException(\"exporter is required\");\n        }\n        this.exporter = exporter;\n        this.config = config == null ? TraceConfig.builder().build() : config;\n    }\n\n    @Override\n    public synchronized void onStart(AgentFlowTraceContext context) {\n        if (context == null || isBlank(context.getExecutionId())) {\n            return;\n        }\n        TraceSpan span = TraceSpan.builder()\n                .traceId(UUID.randomUUID().toString())\n                .spanId(UUID.randomUUID().toString())\n                .name(spanName(context))\n                .type(TraceSpanType.AGENT_FLOW)\n                .startTime(context.getStartedAt() > 0L ? context.getStartedAt() : System.currentTimeMillis())\n                .attributes(startAttributes(context))\n                .events(new ArrayList<TraceSpanEvent>())\n                .build();\n        activeSpans.put(context.getExecutionId(), span);\n    }\n\n    @Override\n    public synchronized void onEvent(AgentFlowTraceContext context, Object event) {\n        TraceSpan span = span(context);\n        if (span == null || event == null) {\n            return;\n        }\n        if (event instanceof AgentFlowChatEvent) {\n            applyChatEvent(span, (AgentFlowChatEvent) event);\n            return;\n        }\n        if (event instanceof AgentFlowWorkflowEvent) {\n            applyWorkflowEvent(span, (AgentFlowWorkflowEvent) event);\n            return;\n        }\n        addSpanEvent(span, \"agentflow.event\", singletonAttribute(\"value\", safeValue(event)));\n    }\n\n    @Override\n    public synchronized void onComplete(AgentFlowTraceContext context, Object response) {\n        TraceSpan span = removeSpan(context);\n        if (span == null) {\n            return;\n        }\n        if (response instanceof AgentFlowChatResponse) {\n            applyChatResponse(span, (AgentFlowChatResponse) response);\n        } else if (response instanceof AgentFlowWorkflowResponse) {\n            applyWorkflowResponse(span, (AgentFlowWorkflowResponse) response);\n        } else if (response != null && config.isRecordModelOutput()) {\n            putAttribute(span, \"output\", safeValue(response));\n        }\n        finishSpan(span, TraceSpanStatus.OK, null);\n    }\n\n    @Override\n    public synchronized void onError(AgentFlowTraceContext context, Throwable throwable) {\n        TraceSpan span = removeSpan(context);\n        if (span == null) {\n            return;\n        }\n        finishSpan(span, TraceSpanStatus.ERROR, throwable == null ? null : safeText(throwable.getMessage()));\n    }\n\n    private TraceSpan span(AgentFlowTraceContext context) {\n        if (context == null || isBlank(context.getExecutionId())) {\n            return null;\n        }\n        return activeSpans.get(context.getExecutionId());\n    }\n\n    private TraceSpan removeSpan(AgentFlowTraceContext context) {\n        if (context == null || isBlank(context.getExecutionId())) {\n            return null;\n        }\n        return activeSpans.remove(context.getExecutionId());\n    }\n\n    private String spanName(AgentFlowTraceContext context) {\n        String operation = context == null ? null : context.getOperation();\n        return isBlank(operation) ? \"agentflow.run\" : \"agentflow.\" + operation;\n    }\n\n    private Map<String, Object> startAttributes(AgentFlowTraceContext context) {\n        Map<String, Object> attributes = new LinkedHashMap<String, Object>();\n        if (context == null) {\n            return attributes;\n        }\n        putIfPresent(attributes, \"providerType\", context.getType() == null ? null : context.getType().name());\n        putIfPresent(attributes, \"operation\", context.getOperation());\n        attributes.put(\"streaming\", Boolean.valueOf(context.isStreaming()));\n        putIfPresent(attributes, \"baseUrl\", safeText(context.getBaseUrl()));\n        putIfPresent(attributes, \"webhookUrl\", safeText(context.getWebhookUrl()));\n        putIfPresent(attributes, \"botId\", safeText(context.getBotId()));\n        putIfPresent(attributes, \"configuredWorkflowId\", safeText(context.getWorkflowId()));\n        putIfPresent(attributes, \"appId\", safeText(context.getAppId()));\n        putIfPresent(attributes, \"configuredUserId\", safeText(context.getConfiguredUserId()));\n        putIfPresent(attributes, \"configuredConversationId\", safeText(context.getConfiguredConversationId()));\n        applyRequest(attributes, context.getRequest());\n        return attributes;\n    }\n\n    private void applyRequest(Map<String, Object> attributes, Object request) {\n        if (attributes == null || request == null || !config.isRecordModelInput()) {\n            return;\n        }\n        if (request instanceof AgentFlowChatRequest) {\n            AgentFlowChatRequest chatRequest = (AgentFlowChatRequest) request;\n            putIfPresent(attributes, \"message\", safeText(chatRequest.getPrompt()));\n            putIfPresent(attributes, \"arguments\", safeValue(chatRequest.getInputs()));\n            putIfPresent(attributes, \"requestUserId\", safeText(chatRequest.getUserId()));\n            putIfPresent(attributes, \"requestConversationId\", safeText(chatRequest.getConversationId()));\n            putIfPresent(attributes, \"requestMetadata\", safeValue(chatRequest.getMetadata()));\n            putIfPresent(attributes, \"requestExtraBody\", safeValue(chatRequest.getExtraBody()));\n            return;\n        }\n        if (request instanceof AgentFlowWorkflowRequest) {\n            AgentFlowWorkflowRequest workflowRequest = (AgentFlowWorkflowRequest) request;\n            putIfPresent(attributes, \"arguments\", safeValue(workflowRequest.getInputs()));\n            putIfPresent(attributes, \"requestUserId\", safeText(workflowRequest.getUserId()));\n            putIfPresent(attributes, \"requestWorkflowId\", safeText(workflowRequest.getWorkflowId()));\n            putIfPresent(attributes, \"requestMetadata\", safeValue(workflowRequest.getMetadata()));\n            putIfPresent(attributes, \"requestExtraBody\", safeValue(workflowRequest.getExtraBody()));\n            return;\n        }\n        putIfPresent(attributes, \"arguments\", safeValue(request));\n    }\n\n    private void applyChatEvent(TraceSpan span, AgentFlowChatEvent event) {\n        if (span == null || event == null) {\n            return;\n        }\n        putIfPresent(span.getAttributes(), \"conversationId\", safeText(event.getConversationId()));\n        putIfPresent(span.getAttributes(), \"messageId\", safeText(event.getMessageId()));\n        putIfPresent(span.getAttributes(), \"taskId\", safeText(event.getTaskId()));\n        applyUsage(span, event.getUsage());\n\n        Map<String, Object> attributes = new HashMap<String, Object>();\n        putIfPresent(attributes, \"type\", safeText(event.getType()));\n        if (config.isRecordModelOutput()) {\n            putIfPresent(attributes, \"delta\", safeText(event.getContentDelta()));\n        }\n        if (event.isDone()) {\n            attributes.put(\"done\", Boolean.TRUE);\n        }\n        putIfPresent(attributes, \"conversationId\", safeText(event.getConversationId()));\n        putIfPresent(attributes, \"messageId\", safeText(event.getMessageId()));\n        putIfPresent(attributes, \"taskId\", safeText(event.getTaskId()));\n        addSpanEvent(span, \"agentflow.chat.event\", attributes);\n    }\n\n    private void applyWorkflowEvent(TraceSpan span, AgentFlowWorkflowEvent event) {\n        if (span == null || event == null) {\n            return;\n        }\n        putIfPresent(span.getAttributes(), \"status\", safeText(event.getStatus()));\n        putIfPresent(span.getAttributes(), \"taskId\", safeText(event.getTaskId()));\n        putIfPresent(span.getAttributes(), \"workflowRunId\", safeText(event.getWorkflowRunId()));\n        applyUsage(span, event.getUsage());\n\n        Map<String, Object> attributes = new HashMap<String, Object>();\n        putIfPresent(attributes, \"type\", safeText(event.getType()));\n        putIfPresent(attributes, \"status\", safeText(event.getStatus()));\n        if (config.isRecordModelOutput()) {\n            putIfPresent(attributes, \"outputText\", safeText(event.getOutputText()));\n        }\n        if (event.isDone()) {\n            attributes.put(\"done\", Boolean.TRUE);\n        }\n        putIfPresent(attributes, \"taskId\", safeText(event.getTaskId()));\n        putIfPresent(attributes, \"workflowRunId\", safeText(event.getWorkflowRunId()));\n        addSpanEvent(span, \"agentflow.workflow.event\", attributes);\n    }\n\n    private void applyChatResponse(TraceSpan span, AgentFlowChatResponse response) {\n        if (span == null || response == null) {\n            return;\n        }\n        putIfPresent(span.getAttributes(), \"conversationId\", safeText(response.getConversationId()));\n        putIfPresent(span.getAttributes(), \"messageId\", safeText(response.getMessageId()));\n        putIfPresent(span.getAttributes(), \"taskId\", safeText(response.getTaskId()));\n        if (config.isRecordModelOutput()) {\n            putIfPresent(span.getAttributes(), \"output\", safeText(response.getContent()));\n            putIfPresent(span.getAttributes(), \"rawResponse\", safeValue(response.getRaw()));\n        }\n        applyUsage(span, response.getUsage());\n    }\n\n    private void applyWorkflowResponse(TraceSpan span, AgentFlowWorkflowResponse response) {\n        if (span == null || response == null) {\n            return;\n        }\n        putIfPresent(span.getAttributes(), \"status\", safeText(response.getStatus()));\n        putIfPresent(span.getAttributes(), \"taskId\", safeText(response.getTaskId()));\n        putIfPresent(span.getAttributes(), \"workflowRunId\", safeText(response.getWorkflowRunId()));\n        if (config.isRecordModelOutput()) {\n            putIfPresent(span.getAttributes(), \"output\", safeText(response.getOutputText()));\n            putIfPresent(span.getAttributes(), \"outputs\", safeValue(response.getOutputs()));\n            putIfPresent(span.getAttributes(), \"rawResponse\", safeValue(response.getRaw()));\n        }\n        applyUsage(span, response.getUsage());\n    }\n\n    private void applyUsage(TraceSpan span, AgentFlowUsage usage) {\n        if (span == null || usage == null || !config.isRecordMetrics()) {\n            return;\n        }\n        TraceMetrics metrics = span.getMetrics();\n        if (metrics == null) {\n            metrics = new TraceMetrics();\n            span.setMetrics(metrics);\n        }\n        if (usage.getInputTokens() != null) {\n            metrics.setPromptTokens(Long.valueOf(usage.getInputTokens().longValue()));\n        }\n        if (usage.getOutputTokens() != null) {\n            metrics.setCompletionTokens(Long.valueOf(usage.getOutputTokens().longValue()));\n        }\n        if (usage.getTotalTokens() != null) {\n            metrics.setTotalTokens(Long.valueOf(usage.getTotalTokens().longValue()));\n        }\n    }\n\n    private void finishSpan(TraceSpan span, TraceSpanStatus status, String error) {\n        span.setStatus(status == null ? TraceSpanStatus.OK : status);\n        span.setEndTime(System.currentTimeMillis());\n        span.setError(error);\n        if (config.isRecordMetrics()) {\n            TraceMetrics metrics = span.getMetrics();\n            if (metrics == null) {\n                metrics = new TraceMetrics();\n                span.setMetrics(metrics);\n            }\n            long durationMillis = Math.max(span.getEndTime() - span.getStartTime(), 0L);\n            metrics.setDurationMillis(Long.valueOf(durationMillis));\n        }\n        exporter.export(span);\n    }\n\n    private void addSpanEvent(TraceSpan span, String name, Map<String, Object> attributes) {\n        if (span == null || isBlank(name)) {\n            return;\n        }\n        List<TraceSpanEvent> events = span.getEvents();\n        if (events == null) {\n            events = new ArrayList<TraceSpanEvent>();\n            span.setEvents(events);\n        }\n        events.add(TraceSpanEvent.builder()\n                .timestamp(System.currentTimeMillis())\n                .name(name)\n                .attributes(attributes == null ? new HashMap<String, Object>() : attributes)\n                .build());\n    }\n\n    private void putAttribute(TraceSpan span, String key, Object value) {\n        if (span == null || isBlank(key) || value == null) {\n            return;\n        }\n        Map<String, Object> attributes = span.getAttributes();\n        if (attributes == null) {\n            attributes = new LinkedHashMap<String, Object>();\n            span.setAttributes(attributes);\n        }\n        attributes.put(key, value);\n    }\n\n    private void putIfPresent(Map<String, Object> target, String key, Object value) {\n        if (target != null && !isBlank(key) && value != null) {\n            target.put(key, value);\n        }\n    }\n\n    private Map<String, Object> singletonAttribute(String key, Object value) {\n        Map<String, Object> attributes = new HashMap<String, Object>();\n        if (!isBlank(key) && value != null) {\n            attributes.put(key, value);\n        }\n        return attributes;\n    }\n\n    private Object safeValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        String text = value instanceof String ? (String) value : JSON.toJSONString(value);\n        if (config.getMasker() != null) {\n            text = config.getMasker().mask(text);\n        }\n        int maxLength = config.getMaxFieldLength();\n        if (maxLength > 0 && text.length() > maxLength) {\n            text = text.substring(0, maxLength) + \"...\";\n        }\n        return text;\n    }\n\n    private String safeText(String value) {\n        Object safe = safeValue(value);\n        return safe == null ? null : String.valueOf(safe);\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/AgentTraceListener.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic class AgentTraceListener implements AgentListener {\n\n    private final TraceExporter exporter;\n    private final TraceConfig config;\n    private String traceId;\n    private TraceSpan rootSpan;\n    private final Map<Integer, TraceSpan> stepSpans = new HashMap<>();\n    private final Map<Integer, TraceSpan> modelSpans = new HashMap<>();\n    private final Map<String, TraceSpan> toolSpans = new HashMap<>();\n    private final Map<Integer, TraceSpan> toolSpansByStep = new HashMap<>();\n    private final Map<String, TraceSpan> handoffSpans = new HashMap<>();\n    private final Map<String, TraceSpan> teamTaskSpans = new HashMap<>();\n\n    public AgentTraceListener(TraceExporter exporter) {\n        this(exporter, null);\n    }\n\n    public AgentTraceListener(TraceExporter exporter, TraceConfig config) {\n        this.exporter = exporter;\n        this.config = config == null ? TraceConfig.builder().build() : config;\n    }\n\n    @Override\n    public synchronized void onEvent(AgentEvent event) {\n        if (event == null) {\n            return;\n        }\n        AgentEventType type = event.getType();\n        if (type == null) {\n            return;\n        }\n        switch (type) {\n            case STEP_START:\n                onStepStart(event);\n                break;\n            case STEP_END:\n                onStepEnd(event);\n                break;\n            case MODEL_REQUEST:\n                onModelRequest(event);\n                break;\n            case MODEL_RESPONSE:\n                onModelResponse(event);\n                break;\n            case MODEL_RETRY:\n                onModelRetry(event);\n                break;\n            case MODEL_REASONING:\n                onModelReasoning(event);\n                break;\n            case TOOL_CALL:\n                onToolCall(event);\n                break;\n            case TOOL_RESULT:\n                onToolResult(event);\n                break;\n            case HANDOFF_START:\n                onHandoffStart(event);\n                break;\n            case HANDOFF_END:\n                onHandoffEnd(event);\n                break;\n            case TEAM_TASK_CREATED:\n                onTeamTaskCreated(event);\n                break;\n            case TEAM_TASK_UPDATED:\n                onTeamTaskUpdated(event);\n                break;\n            case TEAM_MESSAGE:\n                onTeamMessage(event);\n                break;\n            case MEMORY_COMPRESS:\n                onMemoryCompress(event);\n                break;\n            case FINAL_OUTPUT:\n                onFinalOutput(event);\n                break;\n            case ERROR:\n                onError(event);\n                break;\n            default:\n                break;\n        }\n    }\n\n    private void onStepStart(AgentEvent event) {\n        if (traceId == null) {\n            traceId = UUID.randomUUID().toString();\n            rootSpan = startSpan(\"agent.run\", TraceSpanType.RUN, null, null);\n        }\n        Integer step = event.getStep();\n        if (step == null) {\n            return;\n        }\n        TraceSpan stepSpan = startSpan(\"step:\" + step, TraceSpanType.STEP, rootSpan == null ? null : rootSpan.getSpanId(), null);\n        stepSpans.put(step, stepSpan);\n    }\n\n    private void onStepEnd(AgentEvent event) {\n        Integer step = event.getStep();\n        if (step == null) {\n            return;\n        }\n        TraceSpan span = stepSpans.remove(step);\n        finishSpan(span, TraceSpanStatus.OK, null);\n        toolSpansByStep.remove(step);\n        if (rootSpan != null && rootSpan.getEndTime() > 0 && stepSpans.isEmpty()) {\n            reset();\n        }\n    }\n\n    private void onModelRequest(AgentEvent event) {\n        Integer step = event.getStep();\n        TraceSpan parent = step == null ? rootSpan : stepSpans.get(step);\n        Map<String, Object> attributes = new HashMap<>();\n        Object payload = event.getPayload();\n        if (payload instanceof AgentPrompt) {\n            AgentPrompt prompt = (AgentPrompt) payload;\n            if (prompt.getModel() != null) {\n                attributes.put(\"model\", prompt.getModel());\n            }\n            if (config.isRecordModelInput()) {\n                attributes.put(\"systemPrompt\", safeValue(prompt.getSystemPrompt()));\n                attributes.put(\"instructions\", safeValue(prompt.getInstructions()));\n                attributes.put(\"items\", safeValue(prompt.getItems()));\n                attributes.put(\"tools\", safeValue(prompt.getTools()));\n                attributes.put(\"toolChoice\", safeValue(prompt.getToolChoice()));\n                attributes.put(\"parallelToolCalls\", safeValue(prompt.getParallelToolCalls()));\n                attributes.put(\"temperature\", safeValue(prompt.getTemperature()));\n                attributes.put(\"topP\", safeValue(prompt.getTopP()));\n                attributes.put(\"maxOutputTokens\", safeValue(prompt.getMaxOutputTokens()));\n                attributes.put(\"reasoning\", safeValue(prompt.getReasoning()));\n                attributes.put(\"store\", safeValue(prompt.getStore()));\n                attributes.put(\"stream\", safeValue(prompt.getStream()));\n                attributes.put(\"user\", safeValue(prompt.getUser()));\n                attributes.put(\"extraBody\", safeValue(prompt.getExtraBody()));\n            }\n        }\n        TraceSpan span = startSpan(\"model.request\", TraceSpanType.MODEL,\n                parent == null ? null : parent.getSpanId(), attributes);\n        if (step != null) {\n            modelSpans.put(step, span);\n        }\n    }\n\n    private void onModelResponse(AgentEvent event) {\n        Integer step = event.getStep();\n        TraceSpan span = resolveModelSpan(step);\n        if (span == null) {\n            return;\n        }\n        if (event.getPayload() != null) {\n            enrichModelSpan(span, event.getPayload());\n        }\n        if (config.isRecordModelOutput()) {\n            if (event.getPayload() != null) {\n                putAttribute(span, \"output\", safeValue(event.getPayload()));\n            } else if (event.getMessage() != null && !event.getMessage().isEmpty()) {\n                addSpanEvent(span, \"model.response.delta\", singletonAttribute(\"delta\", safeValue(event.getMessage())));\n            }\n        }\n        if (event.getPayload() != null) {\n            accumulateModelMetrics(step, span);\n            if (step != null) {\n                modelSpans.remove(step);\n            }\n            finishSpan(span, TraceSpanStatus.OK, null);\n        }\n    }\n\n    private void onModelRetry(AgentEvent event) {\n        TraceSpan span = resolveModelSpan(event.getStep());\n        addSpanEvent(span, \"model.retry\", attributesFromEvent(event));\n    }\n\n    private void onModelReasoning(AgentEvent event) {\n        TraceSpan span = resolveModelSpan(event.getStep());\n        Map<String, Object> attributes = attributesFromEvent(event);\n        if (event.getMessage() != null && !event.getMessage().isEmpty()) {\n            attributes.put(\"text\", safeValue(event.getMessage()));\n        }\n        addSpanEvent(span, \"model.reasoning\", attributes);\n    }\n\n    private void onToolCall(AgentEvent event) {\n        Object payload = event.getPayload();\n        String callId = null;\n        String toolName = event.getMessage();\n        if (payload instanceof AgentToolCall) {\n            AgentToolCall call = (AgentToolCall) payload;\n            callId = call.getCallId();\n            toolName = call.getName();\n            if (config.isRecordToolArgs()) {\n                Map<String, Object> attributes = new HashMap<>();\n                attributes.put(\"tool\", toolName);\n                attributes.put(\"callId\", callId);\n                attributes.put(\"arguments\", safeValue(call.getArguments()));\n                TraceSpan parent = event.getStep() == null ? rootSpan : stepSpans.get(event.getStep());\n                TraceSpan span = startSpan(\"tool:\" + (toolName == null ? \"unknown\" : toolName), TraceSpanType.TOOL,\n                        parent == null ? null : parent.getSpanId(), attributes);\n                toolSpans.put(callId == null ? keyForStep(event.getStep(), toolName) : callId, span);\n                if (event.getStep() != null) {\n                    toolSpansByStep.put(event.getStep(), span);\n                }\n                return;\n            }\n        }\n        String key = callId == null ? keyForStep(event.getStep(), toolName) : callId;\n        TraceSpan parent = event.getStep() == null ? rootSpan : stepSpans.get(event.getStep());\n        Map<String, Object> attributes = new HashMap<>();\n        if (toolName != null) {\n            attributes.put(\"tool\", toolName);\n        }\n        TraceSpan span = startSpan(\"tool:\" + (toolName == null ? \"unknown\" : toolName), TraceSpanType.TOOL,\n                parent == null ? null : parent.getSpanId(), attributes);\n        toolSpans.put(key, span);\n        if (event.getStep() != null) {\n            toolSpansByStep.put(event.getStep(), span);\n        }\n    }\n\n    private void onToolResult(AgentEvent event) {\n        Object payload = event.getPayload();\n        String callId = null;\n        if (payload instanceof AgentToolResult) {\n            callId = ((AgentToolResult) payload).getCallId();\n        }\n        String key = callId == null ? keyForStep(event.getStep(), event.getMessage()) : callId;\n        TraceSpan span = toolSpans.remove(key);\n        if (span == null && event.getStep() != null) {\n            span = toolSpansByStep.remove(event.getStep());\n        }\n        TraceSpanStatus status = TraceSpanStatus.OK;\n        String error = null;\n        if (payload instanceof CodeExecutionResult) {\n            CodeExecutionResult result = (CodeExecutionResult) payload;\n            if (!result.isSuccess()) {\n                status = TraceSpanStatus.ERROR;\n                error = result.getError();\n            }\n            if (config.isRecordToolOutput()) {\n                putAttribute(span, \"result\", safeValue(result.getResult()));\n                putAttribute(span, \"stdout\", safeValue(result.getStdout()));\n                putAttribute(span, \"error\", safeValue(result.getError()));\n            }\n        } else if (payload instanceof AgentToolResult) {\n            AgentToolResult result = (AgentToolResult) payload;\n            if (config.isRecordToolOutput()) {\n                putAttribute(span, \"output\", safeValue(result.getOutput()));\n            }\n        } else if (config.isRecordToolOutput()) {\n            putAttribute(span, \"output\", safeValue(event.getMessage()));\n        }\n        finishSpan(span, status, error);\n    }\n\n    private void onFinalOutput(AgentEvent event) {\n        if (config.isRecordModelOutput()) {\n            putAttribute(rootSpan, \"finalOutput\", safeValue(event.getMessage()));\n        }\n        finishSpan(rootSpan, TraceSpanStatus.OK, null);\n    }\n\n    private void onError(AgentEvent event) {\n        finishSpan(rootSpan, TraceSpanStatus.ERROR, event.getMessage());\n        reset();\n    }\n\n    private void onHandoffStart(AgentEvent event) {\n        Map<String, Object> payload = payloadMap(event.getPayload());\n        String handoffId = firstNonBlank(stringValue(payload, \"handoffId\"), event.getMessage(), UUID.randomUUID().toString());\n        TraceSpan parent = resolveHandoffParent(event, payload);\n        TraceSpan span = startSpan(\"handoff:\" + firstNonBlank(stringValue(payload, \"subagent\"), stringValue(payload, \"tool\"), \"subagent\"),\n                TraceSpanType.HANDOFF,\n                parent == null ? null : parent.getSpanId(),\n                safeAttributes(payload));\n        handoffSpans.put(handoffId, span);\n    }\n\n    private void onHandoffEnd(AgentEvent event) {\n        Map<String, Object> payload = payloadMap(event.getPayload());\n        String handoffId = firstNonBlank(stringValue(payload, \"handoffId\"), event.getMessage());\n        TraceSpan span = handoffId == null ? null : handoffSpans.remove(handoffId);\n        if (span == null) {\n            TraceSpan parent = resolveHandoffParent(event, payload);\n            span = startSpan(\"handoff:\" + firstNonBlank(stringValue(payload, \"subagent\"), stringValue(payload, \"tool\"), \"subagent\"),\n                    TraceSpanType.HANDOFF,\n                    parent == null ? null : parent.getSpanId(),\n                    safeAttributes(payload));\n        } else {\n            mergeAttributes(span, safeAttributes(payload));\n        }\n        addSpanEvent(span, \"handoff.end\", attributesFromEvent(event));\n        finishSpan(span, resolveStatus(payload, event.getMessage()), firstNonBlank(stringValue(payload, \"error\"), event.getMessage()));\n    }\n\n    private void onTeamTaskCreated(AgentEvent event) {\n        Map<String, Object> payload = payloadMap(event.getPayload());\n        String taskId = firstNonBlank(stringValue(payload, \"taskId\"), event.getMessage(), UUID.randomUUID().toString());\n        TraceSpan span = startSpan(\"team.task:\" + taskId,\n                TraceSpanType.TEAM_TASK,\n                rootSpan == null ? null : rootSpan.getSpanId(),\n                safeAttributes(payload));\n        teamTaskSpans.put(taskId, span);\n        addSpanEvent(span, \"team.task.created\", attributesFromEvent(event));\n    }\n\n    private void onTeamTaskUpdated(AgentEvent event) {\n        Map<String, Object> payload = payloadMap(event.getPayload());\n        String taskId = firstNonBlank(stringValue(payload, \"taskId\"), event.getMessage());\n        if (taskId == null) {\n            return;\n        }\n        TraceSpan span = teamTaskSpans.get(taskId);\n        if (span == null) {\n            span = startSpan(\"team.task:\" + taskId,\n                    TraceSpanType.TEAM_TASK,\n                    rootSpan == null ? null : rootSpan.getSpanId(),\n                    safeAttributes(payload));\n            teamTaskSpans.put(taskId, span);\n        } else {\n            mergeAttributes(span, safeAttributes(payload));\n        }\n        addSpanEvent(span, \"team.task.updated\", attributesFromEvent(event));\n        if (isTerminalStatus(stringValue(payload, \"status\")) || stringValue(payload, \"error\") != null) {\n            teamTaskSpans.remove(taskId);\n            finishSpan(span, resolveStatus(payload, event.getMessage()), stringValue(payload, \"error\"));\n        }\n    }\n\n    private void onTeamMessage(AgentEvent event) {\n        Map<String, Object> payload = payloadMap(event.getPayload());\n        String taskId = stringValue(payload, \"taskId\");\n        TraceSpan span = taskId == null ? rootSpan : teamTaskSpans.get(taskId);\n        addSpanEvent(span == null ? rootSpan : span, \"team.message\", attributesFromEvent(event));\n    }\n\n    private void onMemoryCompress(AgentEvent event) {\n        TraceSpan parent = event.getStep() == null ? rootSpan : stepSpans.get(event.getStep());\n        TraceSpan span = startSpan(\"memory.compress\", TraceSpanType.MEMORY, parent == null ? null : parent.getSpanId(), attributesFromEvent(event));\n        finishSpan(span, TraceSpanStatus.OK, null);\n    }\n\n    private TraceSpan startSpan(String name, TraceSpanType type, String parentId, Map<String, Object> attributes) {\n        TraceSpan span = TraceSpan.builder()\n                .traceId(traceId)\n                .spanId(UUID.randomUUID().toString())\n                .parentSpanId(parentId)\n                .name(name)\n                .type(type)\n                .status(TraceSpanStatus.OK)\n                .startTime(System.currentTimeMillis())\n                .attributes(attributes == null ? new HashMap<>() : attributes)\n                .events(new ArrayList<TraceSpanEvent>())\n                .metrics(new TraceMetrics())\n                .build();\n        return span;\n    }\n\n    private TraceSpan resolveModelSpan(Integer step) {\n        if (step == null) {\n            return null;\n        }\n        return modelSpans.get(step);\n    }\n\n    private TraceSpan resolveHandoffParent(AgentEvent event, Map<String, Object> payload) {\n        String callId = stringValue(payload, \"callId\");\n        if (callId != null) {\n            TraceSpan span = toolSpans.get(callId);\n            if (span != null) {\n                return span;\n            }\n        }\n        if (event != null && event.getStep() != null) {\n            TraceSpan span = toolSpansByStep.get(event.getStep());\n            if (span != null) {\n                return span;\n            }\n            span = stepSpans.get(event.getStep());\n            if (span != null) {\n                return span;\n            }\n        }\n        return rootSpan;\n    }\n\n    private void enrichModelSpan(TraceSpan span, Object payload) {\n        if (span == null || payload == null) {\n            return;\n        }\n        String responseId = null;\n        String responseModel = null;\n        String finishReason = null;\n        Usage usage = null;\n        if (payload instanceof ChatCompletionResponse) {\n            ChatCompletionResponse response = (ChatCompletionResponse) payload;\n            responseId = response.getId();\n            responseModel = response.getModel();\n            usage = response.getUsage();\n            finishReason = firstChoiceFinishReason(response.getChoices());\n            putAttribute(span, \"systemFingerprint\", safeValue(response.getSystemFingerprint()));\n        } else if (payload instanceof Map) {\n            Map<String, Object> response = payloadMap(payload);\n            responseId = stringValue(response, \"id\");\n            responseModel = stringValue(response, \"model\");\n            finishReason = stringValue(response, \"finishReason\");\n            usage = mapToUsage(response.get(\"usage\"));\n        }\n        if (responseId != null) {\n            putAttribute(span, \"responseId\", responseId);\n        }\n        if (responseModel != null) {\n            putAttribute(span, \"responseModel\", responseModel);\n        }\n        if (finishReason != null) {\n            putAttribute(span, \"finishReason\", finishReason);\n        }\n        mergeMetrics(span, metricsFromUsage(usage, resolveModelPricing(span, responseModel)));\n    }\n\n    private void accumulateModelMetrics(Integer step, TraceSpan modelSpan) {\n        if (modelSpan == null || modelSpan.getMetrics() == null) {\n            return;\n        }\n        mergeUsageMetrics(rootSpan, modelSpan.getMetrics());\n        if (step != null) {\n            mergeUsageMetrics(stepSpans.get(step), modelSpan.getMetrics());\n        }\n    }\n\n    private void mergeMetrics(TraceSpan span, TraceMetrics metrics) {\n        if (span == null || metrics == null) {\n            return;\n        }\n        TraceMetrics target = span.getMetrics();\n        if (target == null) {\n            target = new TraceMetrics();\n            span.setMetrics(target);\n        }\n        if (target.getDurationMillis() == null) {\n            target.setDurationMillis(metrics.getDurationMillis());\n        }\n        target.setPromptTokens(sum(target.getPromptTokens(), metrics.getPromptTokens()));\n        target.setCompletionTokens(sum(target.getCompletionTokens(), metrics.getCompletionTokens()));\n        target.setTotalTokens(sum(target.getTotalTokens(), metrics.getTotalTokens()));\n        target.setInputCost(sum(target.getInputCost(), metrics.getInputCost()));\n        target.setOutputCost(sum(target.getOutputCost(), metrics.getOutputCost()));\n        target.setTotalCost(sum(target.getTotalCost(), metrics.getTotalCost()));\n        if (target.getCurrency() == null) {\n            target.setCurrency(metrics.getCurrency());\n        }\n    }\n\n    private void mergeUsageMetrics(TraceSpan span, TraceMetrics source) {\n        if (span == null || source == null) {\n            return;\n        }\n        TraceMetrics usageOnly = TraceMetrics.builder()\n                .promptTokens(source.getPromptTokens())\n                .completionTokens(source.getCompletionTokens())\n                .totalTokens(source.getTotalTokens())\n                .inputCost(source.getInputCost())\n                .outputCost(source.getOutputCost())\n                .totalCost(source.getTotalCost())\n                .currency(source.getCurrency())\n                .build();\n        mergeMetrics(span, usageOnly);\n    }\n\n    private TraceMetrics metricsFromUsage(Usage usage, TracePricing pricing) {\n        if (!config.isRecordMetrics() || usage == null) {\n            return null;\n        }\n        long promptTokens = usage.getPromptTokens();\n        long completionTokens = usage.getCompletionTokens();\n        long totalTokens = usage.getTotalTokens();\n        Double inputCost = null;\n        Double outputCost = null;\n        Double totalCost = null;\n        String currency = null;\n        if (pricing != null) {\n            if (pricing.getInputCostPerMillionTokens() != null) {\n                inputCost = (promptTokens / 1000000D) * pricing.getInputCostPerMillionTokens();\n            }\n            if (pricing.getOutputCostPerMillionTokens() != null) {\n                outputCost = (completionTokens / 1000000D) * pricing.getOutputCostPerMillionTokens();\n            }\n            if (inputCost != null || outputCost != null) {\n                totalCost = (inputCost == null ? 0D : inputCost) + (outputCost == null ? 0D : outputCost);\n            }\n            currency = pricing.getCurrency();\n        }\n        return TraceMetrics.builder()\n                .promptTokens(promptTokens)\n                .completionTokens(completionTokens)\n                .totalTokens(totalTokens)\n                .inputCost(inputCost)\n                .outputCost(outputCost)\n                .totalCost(totalCost)\n                .currency(currency)\n                .build();\n    }\n\n    private TracePricing resolveModelPricing(TraceSpan span, String responseModel) {\n        TracePricingResolver resolver = config.getPricingResolver();\n        if (resolver == null) {\n            return null;\n        }\n        String model = responseModel;\n        if (model == null && span != null && span.getAttributes() != null) {\n            Object value = span.getAttributes().get(\"model\");\n            if (value != null) {\n                model = String.valueOf(value);\n            }\n        }\n        return model == null ? null : resolver.resolve(model);\n    }\n\n    private Usage mapToUsage(Object value) {\n        if (value instanceof Usage) {\n            return (Usage) value;\n        }\n        if (!(value instanceof Map)) {\n            return null;\n        }\n        Map<String, Object> usageMap = payloadMap(value);\n        Usage usage = new Usage();\n        usage.setPromptTokens(longValue(firstNonNull(usageMap.get(\"prompt_tokens\"), usageMap.get(\"promptTokens\"), usageMap.get(\"input\"))));\n        usage.setCompletionTokens(longValue(firstNonNull(usageMap.get(\"completion_tokens\"), usageMap.get(\"completionTokens\"), usageMap.get(\"output\"))));\n        usage.setTotalTokens(longValue(firstNonNull(usageMap.get(\"total_tokens\"), usageMap.get(\"totalTokens\"), usageMap.get(\"total\"))));\n        return usage;\n    }\n\n    private String firstChoiceFinishReason(List<Choice> choices) {\n        if (choices == null || choices.isEmpty() || choices.get(0) == null) {\n            return null;\n        }\n        return choices.get(0).getFinishReason();\n    }\n\n    private void putAttribute(TraceSpan span, String key, Object value) {\n        if (span == null || key == null) {\n            return;\n        }\n        Map<String, Object> attributes = span.getAttributes();\n        if (attributes == null) {\n            attributes = new HashMap<>();\n            span.setAttributes(attributes);\n        }\n        if (value == null) {\n            return;\n        }\n        attributes.put(key, value);\n    }\n\n    private void mergeAttributes(TraceSpan span, Map<String, Object> attributes) {\n        if (span == null || attributes == null || attributes.isEmpty()) {\n            return;\n        }\n        Map<String, Object> target = span.getAttributes();\n        if (target == null) {\n            target = new HashMap<String, Object>();\n            span.setAttributes(target);\n        }\n        target.putAll(attributes);\n    }\n\n    private void addSpanEvent(TraceSpan span, String name, Map<String, Object> attributes) {\n        if (span == null || name == null) {\n            return;\n        }\n        List<TraceSpanEvent> events = span.getEvents();\n        if (events == null) {\n            events = new ArrayList<TraceSpanEvent>();\n            span.setEvents(events);\n        }\n        events.add(TraceSpanEvent.builder()\n                .timestamp(System.currentTimeMillis())\n                .name(name)\n                .attributes(attributes == null ? new HashMap<String, Object>() : attributes)\n                .build());\n    }\n\n    private Object safeValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        String text;\n        if (value instanceof String) {\n            text = (String) value;\n        } else {\n            text = JSON.toJSONString(value);\n        }\n        if (config.getMasker() != null) {\n            text = config.getMasker().mask(text);\n        }\n        int maxLength = config.getMaxFieldLength();\n        if (maxLength > 0 && text.length() > maxLength) {\n            text = text.substring(0, maxLength) + \"...\";\n        }\n        return text;\n    }\n\n    private Map<String, Object> attributesFromEvent(AgentEvent event) {\n        Map<String, Object> attributes = safeAttributes(payloadMap(event == null ? null : event.getPayload()));\n        if (event != null && event.getMessage() != null && !event.getMessage().isEmpty()) {\n            attributes.put(\"message\", safeValue(event.getMessage()));\n        }\n        if (event != null && event.getStep() != null) {\n            attributes.put(\"step\", event.getStep());\n        }\n        return attributes;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private Map<String, Object> payloadMap(Object payload) {\n        if (payload instanceof Map) {\n            return (Map<String, Object>) payload;\n        }\n        return new HashMap<String, Object>();\n    }\n\n    private Map<String, Object> safeAttributes(Map<String, Object> source) {\n        Map<String, Object> target = new HashMap<String, Object>();\n        if (source == null || source.isEmpty()) {\n            return target;\n        }\n        for (Map.Entry<String, Object> entry : source.entrySet()) {\n            target.put(entry.getKey(), safeValue(entry.getValue()));\n        }\n        return target;\n    }\n\n    private Map<String, Object> singletonAttribute(String key, Object value) {\n        Map<String, Object> attributes = new HashMap<String, Object>();\n        if (key != null && value != null) {\n            attributes.put(key, value);\n        }\n        return attributes;\n    }\n\n    private String stringValue(Map<String, Object> payload, String key) {\n        if (payload == null || key == null) {\n            return null;\n        }\n        Object value = payload.get(key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private TraceSpanStatus resolveStatus(Map<String, Object> payload, String fallbackMessage) {\n        String status = stringValue(payload, \"status\");\n        if (stringValue(payload, \"error\") != null || (fallbackMessage != null && fallbackMessage.toLowerCase().contains(\"fail\"))) {\n            return TraceSpanStatus.ERROR;\n        }\n        if (status == null) {\n            return TraceSpanStatus.OK;\n        }\n        String normalized = status.trim().toLowerCase();\n        if (\"canceled\".equals(normalized) || \"cancelled\".equals(normalized)) {\n            return TraceSpanStatus.CANCELED;\n        }\n        if (\"failed\".equals(normalized) || \"error\".equals(normalized)) {\n            return TraceSpanStatus.ERROR;\n        }\n        return TraceSpanStatus.OK;\n    }\n\n    private boolean isTerminalStatus(String status) {\n        if (status == null) {\n            return false;\n        }\n        String normalized = status.trim().toLowerCase();\n        return \"completed\".equals(normalized)\n                || \"failed\".equals(normalized)\n                || \"blocked\".equals(normalized)\n                || \"canceled\".equals(normalized)\n                || \"cancelled\".equals(normalized);\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private Object firstNonNull(Object... values) {\n        if (values == null) {\n            return null;\n        }\n        for (Object value : values) {\n            if (value != null) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private Long sum(Long left, Long right) {\n        if (left == null) {\n            return right;\n        }\n        if (right == null) {\n            return left;\n        }\n        return left + right;\n    }\n\n    private Double sum(Double left, Double right) {\n        if (left == null) {\n            return right;\n        }\n        if (right == null) {\n            return left;\n        }\n        return left + right;\n    }\n\n    private long longValue(Object value) {\n        if (value == null) {\n            return 0L;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).longValue();\n        }\n        try {\n            return Long.parseLong(String.valueOf(value));\n        } catch (NumberFormatException ex) {\n            return 0L;\n        }\n    }\n\n    private void finishSpan(TraceSpan span, TraceSpanStatus status, String error) {\n        if (span == null) {\n            return;\n        }\n        span.setStatus(status == null ? TraceSpanStatus.OK : status);\n        span.setEndTime(System.currentTimeMillis());\n        span.setError(error);\n        if (config.isRecordMetrics()) {\n            TraceMetrics metrics = span.getMetrics();\n            if (metrics == null) {\n                metrics = new TraceMetrics();\n                span.setMetrics(metrics);\n            }\n            long durationMillis = span.getEndTime() - span.getStartTime();\n            metrics.setDurationMillis(durationMillis < 0 ? 0L : durationMillis);\n        }\n        if (exporter != null) {\n            exporter.export(span);\n        }\n    }\n\n    private String keyForStep(Integer step, String toolName) {\n        if (step == null) {\n            return toolName == null ? \"tool\" : toolName;\n        }\n        return step + \":\" + (toolName == null ? \"tool\" : toolName);\n    }\n\n    private void reset() {\n        traceId = null;\n        rootSpan = null;\n        stepSpans.clear();\n        modelSpans.clear();\n        toolSpans.clear();\n        toolSpansByStep.clear();\n        handoffSpans.clear();\n        teamTaskSpans.clear();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/CompositeTraceExporter.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class CompositeTraceExporter implements TraceExporter {\n\n    private final List<TraceExporter> exporters;\n\n    public CompositeTraceExporter(TraceExporter... exporters) {\n        this(exporters == null ? null : Arrays.asList(exporters));\n    }\n\n    public CompositeTraceExporter(List<TraceExporter> exporters) {\n        this.exporters = new ArrayList<TraceExporter>();\n        if (exporters == null) {\n            return;\n        }\n        for (TraceExporter exporter : exporters) {\n            if (exporter != null) {\n                this.exporters.add(exporter);\n            }\n        }\n    }\n\n    @Override\n    public void export(TraceSpan span) {\n        for (TraceExporter exporter : exporters) {\n            exporter.export(span);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/ConsoleTraceExporter.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport com.alibaba.fastjson2.JSON;\n\npublic class ConsoleTraceExporter implements TraceExporter {\n\n    @Override\n    public void export(TraceSpan span) {\n        if (span == null) {\n            return;\n        }\n        System.out.println(\"TRACE \" + JSON.toJSONString(span));\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/InMemoryTraceExporter.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class InMemoryTraceExporter implements TraceExporter {\n\n    private final List<TraceSpan> spans = new ArrayList<>();\n\n    @Override\n    public synchronized void export(TraceSpan span) {\n        if (span != null) {\n            spans.add(span);\n        }\n    }\n\n    public synchronized List<TraceSpan> getSpans() {\n        return Collections.unmodifiableList(new ArrayList<>(spans));\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/JsonlTraceExporter.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport com.alibaba.fastjson2.JSON;\n\nimport java.io.BufferedWriter;\nimport java.io.File;\nimport java.io.FileOutputStream;\nimport java.io.IOException;\nimport java.io.OutputStreamWriter;\nimport java.io.Writer;\nimport java.nio.charset.StandardCharsets;\n\npublic class JsonlTraceExporter implements TraceExporter, AutoCloseable {\n\n    private final Writer writer;\n    private final boolean ownsWriter;\n\n    public JsonlTraceExporter(String path) {\n        this(path == null ? null : new File(path));\n    }\n\n    public JsonlTraceExporter(File file) {\n        this(createWriter(file), true);\n    }\n\n    public JsonlTraceExporter(Writer writer) {\n        this(writer, false);\n    }\n\n    private JsonlTraceExporter(Writer writer, boolean ownsWriter) {\n        if (writer == null) {\n            throw new IllegalArgumentException(\"writer is required\");\n        }\n        this.writer = writer;\n        this.ownsWriter = ownsWriter;\n    }\n\n    @Override\n    public synchronized void export(TraceSpan span) {\n        if (span == null) {\n            return;\n        }\n        try {\n            writer.write(JSON.toJSONString(span));\n            writer.write(System.lineSeparator());\n            writer.flush();\n        } catch (IOException ex) {\n            throw new IllegalStateException(\"Failed to export trace span as JSONL\", ex);\n        }\n    }\n\n    @Override\n    public synchronized void close() throws Exception {\n        if (ownsWriter) {\n            writer.close();\n        } else {\n            writer.flush();\n        }\n    }\n\n    private static Writer createWriter(File file) {\n        if (file == null) {\n            throw new IllegalArgumentException(\"file is required\");\n        }\n        File parent = file.getParentFile();\n        if (parent != null && !parent.exists() && !parent.mkdirs() && !parent.exists()) {\n            throw new IllegalStateException(\"Failed to create trace export directory: \" + parent.getAbsolutePath());\n        }\n        try {\n            return new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file, true), StandardCharsets.UTF_8));\n        } catch (IOException ex) {\n            throw new IllegalStateException(\"Failed to open trace export file: \" + file.getAbsolutePath(), ex);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/LangfuseTraceExporter.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.opentelemetry.api.OpenTelemetry;\nimport io.opentelemetry.api.trace.Span;\nimport io.opentelemetry.api.trace.Tracer;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class LangfuseTraceExporter extends AbstractOpenTelemetryTraceExporter {\n\n    private final String environment;\n    private final String release;\n\n    public LangfuseTraceExporter(OpenTelemetry openTelemetry) {\n        this(openTelemetry, null, null);\n    }\n\n    public LangfuseTraceExporter(OpenTelemetry openTelemetry, String environment, String release) {\n        super(openTelemetry, \"io.github.lnyocly.ai4j.agent.trace.langfuse\");\n        this.environment = environment;\n        this.release = release;\n    }\n\n    public LangfuseTraceExporter(Tracer tracer) {\n        this(tracer, null, null);\n    }\n\n    public LangfuseTraceExporter(Tracer tracer, String environment, String release) {\n        super(tracer);\n        this.environment = environment;\n        this.release = release;\n    }\n\n    @Override\n    protected void customizeSpan(Span span, TraceSpan traceSpan) {\n        Map<String, Object> attributes = LangfuseSpanAttributes.project(traceSpan, environment, release);\n        for (Map.Entry<String, Object> entry : attributes.entrySet()) {\n            OpenTelemetryTraceSupport.setAttribute(span, entry.getKey(), entry.getValue());\n        }\n    }\n\n    static final class LangfuseSpanAttributes {\n\n        private LangfuseSpanAttributes() {\n        }\n\n        static Map<String, Object> project(TraceSpan traceSpan, String environment, String release) {\n            Map<String, Object> projected = new LinkedHashMap<String, Object>();\n            if (traceSpan == null) {\n                return projected;\n            }\n            if (environment != null && !environment.trim().isEmpty()) {\n                projected.put(\"langfuse.environment\", environment.trim());\n            }\n            if (release != null && !release.trim().isEmpty()) {\n                projected.put(\"langfuse.release\", release.trim());\n            }\n\n            projected.put(\"langfuse.observation.type\", observationType(traceSpan.getType()));\n            projected.put(\"langfuse.observation.level\", observationLevel(traceSpan.getStatus()));\n            if (traceSpan.getError() != null && !traceSpan.getError().trim().isEmpty()) {\n                projected.put(\"langfuse.observation.status_message\", traceSpan.getError().trim());\n            }\n            if (traceSpan.getParentSpanId() == null && traceSpan.getName() != null) {\n                projected.put(\"langfuse.trace.name\", traceSpan.getName());\n            }\n            if (traceSpan.getType() == TraceSpanType.MODEL) {\n                applyModelAttributes(projected, traceSpan);\n            } else {\n                applyGenericObservation(projected, traceSpan);\n            }\n            return projected;\n        }\n\n        private static void applyModelAttributes(Map<String, Object> projected, TraceSpan traceSpan) {\n            Map<String, Object> attributes = traceSpan.getAttributes();\n            String model = stringValue(attributes, \"responseModel\");\n            if (model == null) {\n                model = stringValue(attributes, \"model\");\n            }\n            if (model != null) {\n                projected.put(\"langfuse.observation.model\", model);\n            }\n\n            Map<String, Object> input = pick(attributes,\n                    \"systemPrompt\", \"instructions\", \"items\", \"tools\", \"toolChoice\",\n                    \"parallelToolCalls\", \"temperature\", \"topP\", \"maxOutputTokens\", \"reasoning\",\n                    \"store\", \"stream\", \"user\", \"extraBody\");\n            if (!input.isEmpty()) {\n                projected.put(\"langfuse.observation.input\", JSON.toJSONString(input));\n            }\n\n            Object output = attributes == null ? null : firstNonNull(\n                    attributes.get(\"output\"),\n                    attributes.get(\"finalOutput\"));\n            if (output != null) {\n                projected.put(\"langfuse.observation.output\", String.valueOf(output));\n            }\n\n            Map<String, Object> modelParameters = pick(attributes,\n                    \"temperature\", \"topP\", \"maxOutputTokens\", \"toolChoice\",\n                    \"parallelToolCalls\", \"reasoning\", \"store\", \"stream\");\n            if (!modelParameters.isEmpty()) {\n                projected.put(\"langfuse.observation.model_parameters\", JSON.toJSONString(modelParameters));\n            }\n\n            TraceMetrics metrics = traceSpan.getMetrics();\n            if (metrics != null) {\n                Map<String, Object> usageDetails = new LinkedHashMap<String, Object>();\n                putIfPresent(usageDetails, \"input\", metrics.getPromptTokens());\n                putIfPresent(usageDetails, \"output\", metrics.getCompletionTokens());\n                putIfPresent(usageDetails, \"total\", metrics.getTotalTokens());\n                putIfPresent(usageDetails, \"prompt_tokens\", metrics.getPromptTokens());\n                putIfPresent(usageDetails, \"completion_tokens\", metrics.getCompletionTokens());\n                putIfPresent(usageDetails, \"total_tokens\", metrics.getTotalTokens());\n                if (!usageDetails.isEmpty()) {\n                    projected.put(\"langfuse.observation.usage_details\", JSON.toJSONString(usageDetails));\n                }\n                Map<String, Object> costDetails = new LinkedHashMap<String, Object>();\n                putIfPresent(costDetails, \"input\", metrics.getInputCost());\n                putIfPresent(costDetails, \"output\", metrics.getOutputCost());\n                putIfPresent(costDetails, \"total\", metrics.getTotalCost());\n                if (!costDetails.isEmpty()) {\n                    projected.put(\"langfuse.observation.cost_details\", JSON.toJSONString(costDetails));\n                }\n            }\n\n            Map<String, Object> metadata = metadataAttributes(attributes,\n                    \"systemPrompt\", \"instructions\", \"items\", \"tools\", \"toolChoice\",\n                    \"parallelToolCalls\", \"temperature\", \"topP\", \"maxOutputTokens\", \"reasoning\",\n                    \"store\", \"stream\", \"user\", \"extraBody\", \"output\", \"finalOutput\", \"model\", \"responseModel\");\n            if (!metadata.isEmpty()) {\n                projected.put(\"langfuse.observation.metadata\", JSON.toJSONString(metadata));\n            }\n        }\n\n        private static void applyGenericObservation(Map<String, Object> projected, TraceSpan traceSpan) {\n            Map<String, Object> attributes = traceSpan.getAttributes();\n            Object input = attributes == null ? null : firstNonNull(attributes.get(\"arguments\"), attributes.get(\"message\"));\n            Object output = attributes == null ? null : firstNonNull(\n                    attributes.get(\"output\"),\n                    attributes.get(\"result\"),\n                    attributes.get(\"finalOutput\"));\n            if (input != null) {\n                projected.put(\"langfuse.observation.input\", String.valueOf(input));\n            }\n            if (output != null) {\n                projected.put(\"langfuse.observation.output\", String.valueOf(output));\n            }\n            if (traceSpan.getParentSpanId() == null && attributes != null && attributes.get(\"finalOutput\") != null) {\n                projected.put(\"langfuse.trace.output\", String.valueOf(attributes.get(\"finalOutput\")));\n            }\n            if (attributes != null && !attributes.isEmpty()) {\n                projected.put(\"langfuse.observation.metadata\", JSON.toJSONString(attributes));\n                if (traceSpan.getParentSpanId() == null) {\n                    projected.put(\"langfuse.trace.metadata\", JSON.toJSONString(attributes));\n                }\n            }\n        }\n\n        private static Map<String, Object> metadataAttributes(Map<String, Object> attributes, String... excludedKeys) {\n            Map<String, Object> metadata = new LinkedHashMap<String, Object>();\n            if (attributes == null || attributes.isEmpty()) {\n                return metadata;\n            }\n            for (Map.Entry<String, Object> entry : attributes.entrySet()) {\n                if (isExcluded(entry.getKey(), excludedKeys)) {\n                    continue;\n                }\n                metadata.put(entry.getKey(), entry.getValue());\n            }\n            return metadata;\n        }\n\n        private static boolean isExcluded(String key, String... excludedKeys) {\n            if (key == null || excludedKeys == null) {\n                return false;\n            }\n            for (String excludedKey : excludedKeys) {\n                if (key.equals(excludedKey)) {\n                    return true;\n                }\n            }\n            return false;\n        }\n\n        private static Map<String, Object> pick(Map<String, Object> attributes, String... keys) {\n            Map<String, Object> selected = new LinkedHashMap<String, Object>();\n            if (attributes == null || keys == null) {\n                return selected;\n            }\n            for (String key : keys) {\n                Object value = attributes.get(key);\n                if (value != null) {\n                    selected.put(key, value);\n                }\n            }\n            return selected;\n        }\n\n        private static Object firstNonNull(Object... values) {\n            if (values == null) {\n                return null;\n            }\n            for (Object value : values) {\n                if (value != null) {\n                    return value;\n                }\n            }\n            return null;\n        }\n\n        private static String stringValue(Map<String, Object> attributes, String key) {\n            if (attributes == null || key == null) {\n                return null;\n            }\n            Object value = attributes.get(key);\n            return value == null ? null : String.valueOf(value);\n        }\n\n        private static void putIfPresent(Map<String, Object> target, String key, Object value) {\n            if (target != null && key != null && value != null) {\n                target.put(key, value);\n            }\n        }\n\n        private static String observationType(TraceSpanType type) {\n            if (type == null) {\n                return \"span\";\n            }\n            switch (type) {\n                case RUN:\n                case HANDOFF:\n                case TEAM_TASK:\n                    return \"agent\";\n                case MODEL:\n                    return \"generation\";\n                case TOOL:\n                    return \"tool\";\n                case AGENT_FLOW:\n                case FLOWGRAM_TASK:\n                    return \"chain\";\n                default:\n                    return \"span\";\n            }\n        }\n\n        private static String observationLevel(TraceSpanStatus status) {\n            if (status == null) {\n                return \"DEFAULT\";\n            }\n            switch (status) {\n                case ERROR:\n                    return \"ERROR\";\n                case CANCELED:\n                    return \"WARNING\";\n                default:\n                    return \"DEFAULT\";\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/OpenTelemetryTraceExporter.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport io.opentelemetry.api.OpenTelemetry;\nimport io.opentelemetry.api.trace.Tracer;\n\npublic class OpenTelemetryTraceExporter extends AbstractOpenTelemetryTraceExporter {\n\n    public OpenTelemetryTraceExporter(OpenTelemetry openTelemetry) {\n        super(openTelemetry, \"io.github.lnyocly.ai4j.agent.trace\");\n    }\n\n    public OpenTelemetryTraceExporter(Tracer tracer) {\n        super(tracer);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/OpenTelemetryTraceSupport.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.opentelemetry.api.common.AttributeKey;\nimport io.opentelemetry.api.trace.Span;\nimport io.opentelemetry.api.trace.StatusCode;\n\nimport java.util.Map;\n\nfinal class OpenTelemetryTraceSupport {\n\n    private OpenTelemetryTraceSupport() {\n    }\n\n    static void applyCommonAttributes(Span span, TraceSpan traceSpan) {\n        if (span == null || traceSpan == null) {\n            return;\n        }\n        setAttribute(span, \"ai4j.trace_id\", traceSpan.getTraceId());\n        setAttribute(span, \"ai4j.span_id\", traceSpan.getSpanId());\n        setAttribute(span, \"ai4j.parent_span_id\", traceSpan.getParentSpanId());\n        setAttribute(span, \"ai4j.span_type\", traceSpan.getType() == null ? null : traceSpan.getType().name());\n        setAttribute(span, \"ai4j.span_status\", traceSpan.getStatus() == null ? null : traceSpan.getStatus().name());\n        setAttribute(span, \"ai4j.error\", traceSpan.getError());\n        if (traceSpan.getAttributes() != null) {\n            for (Map.Entry<String, Object> entry : traceSpan.getAttributes().entrySet()) {\n                setAttribute(span, \"ai4j.attr.\" + entry.getKey(), entry.getValue());\n            }\n        }\n        TraceMetrics metrics = traceSpan.getMetrics();\n        if (metrics != null) {\n            setAttribute(span, \"ai4j.metrics.duration_ms\", metrics.getDurationMillis());\n            setAttribute(span, \"ai4j.metrics.prompt_tokens\", metrics.getPromptTokens());\n            setAttribute(span, \"ai4j.metrics.completion_tokens\", metrics.getCompletionTokens());\n            setAttribute(span, \"ai4j.metrics.total_tokens\", metrics.getTotalTokens());\n            setAttribute(span, \"ai4j.metrics.input_cost\", metrics.getInputCost());\n            setAttribute(span, \"ai4j.metrics.output_cost\", metrics.getOutputCost());\n            setAttribute(span, \"ai4j.metrics.total_cost\", metrics.getTotalCost());\n            setAttribute(span, \"ai4j.metrics.currency\", metrics.getCurrency());\n\n            setAttribute(span, \"gen_ai.usage.input_tokens\", metrics.getPromptTokens());\n            setAttribute(span, \"gen_ai.usage.output_tokens\", metrics.getCompletionTokens());\n        }\n        if (traceSpan.getEvents() != null) {\n            for (TraceSpanEvent event : traceSpan.getEvents()) {\n                if (event == null) {\n                    continue;\n                }\n                span.addEvent(event.getName() == null ? \"ai4j.event\" : event.getName());\n                if (event.getAttributes() != null) {\n                    for (Map.Entry<String, Object> entry : event.getAttributes().entrySet()) {\n                        setAttribute(span, \"ai4j.event.\" + safeSegment(event.getName()) + \".\" + entry.getKey(), entry.getValue());\n                    }\n                }\n            }\n        }\n        if (traceSpan.getStatus() == TraceSpanStatus.ERROR) {\n            span.setStatus(StatusCode.ERROR, traceSpan.getError() == null ? \"ai4j trace error\" : traceSpan.getError());\n        }\n    }\n\n    static void setAttribute(Span span, String key, Object value) {\n        if (span == null || key == null || value == null) {\n            return;\n        }\n        if (value instanceof Boolean) {\n            span.setAttribute(AttributeKey.booleanKey(key), (Boolean) value);\n            return;\n        }\n        if (value instanceof Long) {\n            span.setAttribute(AttributeKey.longKey(key), (Long) value);\n            return;\n        }\n        if (value instanceof Integer) {\n            span.setAttribute(AttributeKey.longKey(key), ((Integer) value).longValue());\n            return;\n        }\n        if (value instanceof Double) {\n            span.setAttribute(AttributeKey.doubleKey(key), (Double) value);\n            return;\n        }\n        if (value instanceof Float) {\n            span.setAttribute(AttributeKey.doubleKey(key), ((Float) value).doubleValue());\n            return;\n        }\n        span.setAttribute(AttributeKey.stringKey(key), value instanceof String ? (String) value : JSON.toJSONString(value));\n    }\n\n    static String safeSegment(String value) {\n        if (value == null || value.trim().isEmpty()) {\n            return \"event\";\n        }\n        return value.trim().replace(' ', '_').toLowerCase();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceConfig.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class TraceConfig {\n\n    @Builder.Default\n    private boolean recordModelInput = true;\n\n    @Builder.Default\n    private boolean recordModelOutput = true;\n\n    @Builder.Default\n    private boolean recordToolArgs = true;\n\n    @Builder.Default\n    private boolean recordToolOutput = true;\n\n    @Builder.Default\n    private boolean recordMetrics = true;\n\n    @Builder.Default\n    private int maxFieldLength = 0;\n\n    private TraceMasker masker;\n\n    private TracePricingResolver pricingResolver;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceExporter.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\npublic interface TraceExporter {\n\n    void export(TraceSpan span);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceMasker.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\npublic interface TraceMasker {\n\n    String mask(String value);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceMetrics.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class TraceMetrics {\n\n    private Long durationMillis;\n    private Long promptTokens;\n    private Long completionTokens;\n    private Long totalTokens;\n    private Double inputCost;\n    private Double outputCost;\n    private Double totalCost;\n    private String currency;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TracePricing.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class TracePricing {\n\n    private Double inputCostPerMillionTokens;\n    private Double outputCostPerMillionTokens;\n    private String currency;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TracePricingResolver.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\npublic interface TracePricingResolver {\n\n    TracePricing resolve(String model);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceSpan.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\npublic class TraceSpan {\n\n    private String traceId;\n    private String spanId;\n    private String parentSpanId;\n    private String name;\n    private TraceSpanType type;\n    private TraceSpanStatus status;\n    private long startTime;\n    private long endTime;\n    private String error;\n    private Map<String, Object> attributes;\n    private List<TraceSpanEvent> events;\n    private TraceMetrics metrics;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceSpanEvent.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class TraceSpanEvent {\n\n    private long timestamp;\n    private String name;\n    private Map<String, Object> attributes;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceSpanStatus.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\npublic enum TraceSpanStatus {\n    OK,\n    ERROR,\n    CANCELED\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/trace/TraceSpanType.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\npublic enum TraceSpanType {\n    RUN,\n    STEP,\n    MODEL,\n    TOOL,\n    HANDOFF,\n    TEAM_TASK,\n    MEMORY,\n    AGENT_FLOW,\n    FLOWGRAM_TASK,\n    FLOWGRAM_NODE\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/util/AgentInputItem.java",
    "content": "package io.github.lnyocly.ai4j.agent.util;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic final class AgentInputItem {\n\n    private AgentInputItem() {\n    }\n\n    public static Map<String, Object> inputText(String text) {\n        Map<String, Object> item = new LinkedHashMap<>();\n        item.put(\"type\", \"input_text\");\n        item.put(\"text\", text);\n        return item;\n    }\n\n    public static Map<String, Object> inputImageUrl(String url) {\n        Map<String, Object> item = new LinkedHashMap<>();\n        item.put(\"type\", \"input_image\");\n        Map<String, Object> imageUrl = new LinkedHashMap<>();\n        imageUrl.put(\"url\", url);\n        item.put(\"image_url\", imageUrl);\n        return item;\n    }\n\n    public static Map<String, Object> userMessage(String text) {\n        return message(\"user\", text);\n    }\n\n    public static Map<String, Object> userMessage(String text, String... imageUrls) {\n        return message(\"user\", text, imageUrls);\n    }\n\n    public static Map<String, Object> systemMessage(String text) {\n        return message(\"system\", text);\n    }\n\n    public static Map<String, Object> message(String role, String text) {\n        Map<String, Object> item = new LinkedHashMap<>();\n        item.put(\"type\", \"message\");\n        item.put(\"role\", role);\n        List<Map<String, Object>> content = new ArrayList<>();\n        content.add(inputText(text));\n        item.put(\"content\", content);\n        return item;\n    }\n\n    public static Map<String, Object> message(String role, String text, String... imageUrls) {\n        Map<String, Object> item = new LinkedHashMap<>();\n        item.put(\"type\", \"message\");\n        item.put(\"role\", role);\n        List<Map<String, Object>> content = new ArrayList<>();\n        if (text != null) {\n            content.add(inputText(text));\n        }\n        if (imageUrls != null) {\n            for (String url : imageUrls) {\n                if (url != null && !url.trim().isEmpty()) {\n                    content.add(inputImageUrl(url));\n                }\n            }\n        }\n        item.put(\"content\", content);\n        return item;\n    }\n\n    public static Map<String, Object> functionCallOutput(String callId, String output) {\n        Map<String, Object> item = new LinkedHashMap<>();\n        item.put(\"type\", \"function_call_output\");\n        item.put(\"call_id\", callId);\n        item.put(\"output\", output);\n        return item;\n    }\n\n    public static Map<String, Object> assistantToolCallsMessage(String text, List<AgentToolCall> toolCalls) {\n        Map<String, Object> item = new LinkedHashMap<>();\n        item.put(\"type\", \"message\");\n        item.put(\"role\", \"assistant\");\n\n        List<Map<String, Object>> content = new ArrayList<>();\n        if (text != null && !text.isEmpty()) {\n            content.add(inputText(text));\n        }\n        if (!content.isEmpty()) {\n            item.put(\"content\", content);\n        }\n\n        List<Map<String, Object>> serializedCalls = new ArrayList<>();\n        if (toolCalls != null) {\n            for (AgentToolCall toolCall : toolCalls) {\n                if (toolCall == null) {\n                    continue;\n                }\n                Map<String, Object> function = new LinkedHashMap<>();\n                function.put(\"name\", toolCall.getName());\n                function.put(\"arguments\", toolCall.getArguments());\n\n                Map<String, Object> serializedCall = new LinkedHashMap<>();\n                serializedCall.put(\"id\", toolCall.getCallId());\n                serializedCall.put(\"type\", toolCall.getType() == null || toolCall.getType().trim().isEmpty()\n                        ? \"function\"\n                        : toolCall.getType());\n                serializedCall.put(\"function\", function);\n                serializedCalls.add(serializedCall);\n            }\n        }\n        if (!serializedCalls.isEmpty()) {\n            item.put(\"tool_calls\", serializedCalls);\n        }\n        return item;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/util/ResponseUtil.java",
    "content": "package io.github.lnyocly.ai4j.agent.util;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseContentPart;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseItem;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic final class ResponseUtil {\n\n    private ResponseUtil() {\n    }\n\n    public static String extractOutputText(Response response) {\n        if (response == null || response.getOutput() == null) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (ResponseItem item : response.getOutput()) {\n            if (item.getContent() == null) {\n                continue;\n            }\n            for (ResponseContentPart part : item.getContent()) {\n                if (part == null) {\n                    continue;\n                }\n                if (\"output_text\".equals(part.getType()) && part.getText() != null) {\n                    builder.append(part.getText());\n                }\n            }\n        }\n        return builder.toString();\n    }\n\n    public static List<AgentToolCall> extractToolCalls(Response response) {\n        List<AgentToolCall> calls = new ArrayList<>();\n        if (response == null || response.getOutput() == null) {\n            return calls;\n        }\n        for (ResponseItem item : response.getOutput()) {\n            if (item == null) {\n                continue;\n            }\n            if (item.getCallId() != null && item.getName() != null && item.getArguments() != null) {\n                calls.add(AgentToolCall.builder()\n                        .callId(item.getCallId())\n                        .name(item.getName())\n                        .arguments(item.getArguments())\n                        .type(item.getType())\n                        .build());\n            }\n        }\n        return calls;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/AgentNode.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\npublic interface AgentNode {\n\n    AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception;\n\n    default void executeStream(WorkflowContext context, AgentRequest request, AgentListener listener) throws Exception {\n        AgentResult result = execute(context, request);\n        if (listener != null && result != null) {\n            listener.onEvent(context.createResultEvent(result));\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/AgentWorkflow.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\npublic interface AgentWorkflow {\n\n    AgentResult run(AgentSession session, AgentRequest request) throws Exception;\n\n    void runStream(AgentSession session, AgentRequest request, AgentListener listener) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/RuntimeAgentNode.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\npublic class RuntimeAgentNode implements AgentNode, WorkflowResultAware {\n\n    private final AgentSession session;\n    private AgentResult lastResult;\n\n    public RuntimeAgentNode(AgentSession session) {\n        this.session = session;\n    }\n\n    @Override\n    public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception {\n        lastResult = session.run(request);\n        return lastResult;\n    }\n\n    @Override\n    public void executeStream(WorkflowContext context, AgentRequest request, AgentListener listener) throws Exception {\n        session.runStream(request, listener);\n    }\n\n    @Override\n    public AgentResult getLastResult() {\n        return lastResult;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/SequentialWorkflow.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class SequentialWorkflow implements AgentWorkflow {\n\n    private final List<AgentNode> nodes = new ArrayList<>();\n\n    public SequentialWorkflow addNode(AgentNode node) {\n        if (node != null) {\n            nodes.add(node);\n        }\n        return this;\n    }\n\n    public List<AgentNode> getNodes() {\n        return new ArrayList<>(nodes);\n    }\n\n    @Override\n    public AgentResult run(AgentSession session, AgentRequest request) throws Exception {\n        WorkflowContext context = WorkflowContext.builder().session(session).build();\n        return executeNodes(context, request, null);\n    }\n\n    @Override\n    public void runStream(AgentSession session, AgentRequest request, AgentListener listener) throws Exception {\n        WorkflowContext context = WorkflowContext.builder().session(session).build();\n        executeNodes(context, request, listener);\n    }\n\n    private AgentResult executeNodes(WorkflowContext context, AgentRequest request, AgentListener listener) throws Exception {\n        AgentRequest current = request;\n        AgentResult lastResult = null;\n        for (AgentNode node : nodes) {\n            if (listener == null) {\n                lastResult = node.execute(context, current);\n            } else {\n                node.executeStream(context, current, listener);\n                if (node instanceof WorkflowResultAware) {\n                    lastResult = ((WorkflowResultAware) node).getLastResult();\n                }\n            }\n            if (lastResult != null && lastResult.getOutputText() != null) {\n                current = AgentRequest.builder().input(lastResult.getOutputText()).build();\n            }\n        }\n        return lastResult;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/StateCondition.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\n\npublic interface StateCondition {\n\n    boolean matches(WorkflowContext context, AgentRequest request, AgentResult result);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/StateGraphWorkflow.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class StateGraphWorkflow implements AgentWorkflow {\n\n    private final Map<String, AgentNode> nodes = new LinkedHashMap<>();\n    private final List<StateTransition> transitions = new ArrayList<>();\n    private final List<ConditionalEdges> conditionalEdges = new ArrayList<>();\n    private String startNodeId;\n    private int maxSteps = 32;\n\n    public StateGraphWorkflow addNode(String nodeId, AgentNode node) {\n        if (nodeId == null || nodeId.trim().isEmpty() || node == null) {\n            return this;\n        }\n        nodes.put(nodeId, node);\n        return this;\n    }\n\n    public StateGraphWorkflow start(String nodeId) {\n        this.startNodeId = nodeId;\n        return this;\n    }\n\n    public StateGraphWorkflow maxSteps(int maxSteps) {\n        if (maxSteps > 0) {\n            this.maxSteps = maxSteps;\n        }\n        return this;\n    }\n\n    public StateGraphWorkflow addTransition(String from, String to) {\n        return addTransition(from, to, null);\n    }\n\n    public StateGraphWorkflow addTransition(String from, String to, StateCondition condition) {\n        transitions.add(StateTransition.builder()\n                .from(from)\n                .to(to)\n                .condition(condition)\n                .build());\n        return this;\n    }\n\n    public StateGraphWorkflow addEdge(String from, String to) {\n        return addTransition(from, to, null);\n    }\n\n    public StateGraphWorkflow addConditionalEdges(String from, StateRouter router) {\n        return addConditionalEdges(from, router, null);\n    }\n\n    public StateGraphWorkflow addConditionalEdges(String from, StateRouter router, Map<String, String> routeMap) {\n        if (from == null || from.trim().isEmpty() || router == null) {\n            return this;\n        }\n        ConditionalEdges edges = new ConditionalEdges();\n        edges.from = from;\n        edges.router = router;\n        edges.routeMap = routeMap == null ? null : new LinkedHashMap<>(routeMap);\n        conditionalEdges.add(edges);\n        return this;\n    }\n\n    @Override\n    public AgentResult run(AgentSession session, AgentRequest request) throws Exception {\n        WorkflowContext context = WorkflowContext.builder().session(session).build();\n        return executeGraph(context, request, null);\n    }\n\n    @Override\n    public void runStream(AgentSession session, AgentRequest request, AgentListener listener) throws Exception {\n        WorkflowContext context = WorkflowContext.builder().session(session).build();\n        executeGraph(context, request, listener);\n    }\n\n    private AgentResult executeGraph(WorkflowContext context, AgentRequest request, AgentListener listener) throws Exception {\n        if (startNodeId == null || startNodeId.trim().isEmpty()) {\n            throw new IllegalStateException(\"start node is required\");\n        }\n\n        String currentNodeId = startNodeId;\n        AgentRequest currentRequest = request;\n        AgentResult lastResult = null;\n        int steps = 0;\n\n        while (currentNodeId != null && steps < maxSteps) {\n            AgentNode node = nodes.get(currentNodeId);\n            if (node == null) {\n                throw new IllegalStateException(\"node not found: \" + currentNodeId);\n            }\n\n            context.put(\"currentNodeId\", currentNodeId);\n            context.put(\"currentRequest\", currentRequest);\n\n            if (listener == null) {\n                lastResult = node.execute(context, currentRequest);\n            } else {\n                node.executeStream(context, currentRequest, listener);\n                if (node instanceof WorkflowResultAware) {\n                    lastResult = ((WorkflowResultAware) node).getLastResult();\n                }\n            }\n\n            context.put(\"lastResult\", lastResult);\n            context.put(\"lastNodeId\", currentNodeId);\n\n            if (lastResult != null && lastResult.getOutputText() != null) {\n                currentRequest = AgentRequest.builder().input(lastResult.getOutputText()).build();\n            }\n\n            currentNodeId = resolveNext(currentNodeId, context, currentRequest, lastResult);\n            steps += 1;\n        }\n\n        return lastResult;\n    }\n\n    private String resolveNext(String currentNodeId, WorkflowContext context, AgentRequest request, AgentResult result) {\n        for (ConditionalEdges edges : conditionalEdges) {\n            if (!currentNodeId.equals(edges.from)) {\n                continue;\n            }\n            String route = edges.router == null ? null : edges.router.route(context, request, result);\n            if (route == null) {\n                continue;\n            }\n            if (edges.routeMap != null) {\n                route = edges.routeMap.get(route);\n            }\n            if (route != null) {\n                return route;\n            }\n        }\n        for (StateTransition transition : transitions) {\n            if (!currentNodeId.equals(transition.getFrom())) {\n                continue;\n            }\n            StateCondition condition = transition.getCondition();\n            if (condition == null || condition.matches(context, request, result)) {\n                return transition.getTo();\n            }\n        }\n        return null;\n    }\n\n    private static class ConditionalEdges {\n        private String from;\n        private StateRouter router;\n        private Map<String, String> routeMap;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/StateRouter.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\n\npublic interface StateRouter {\n\n    String route(WorkflowContext context, AgentRequest request, AgentResult result);\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/StateTransition.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class StateTransition {\n\n    private String from;\n\n    private String to;\n\n    private StateCondition condition;\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/WorkflowAgent.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\n\npublic class WorkflowAgent {\n\n    private final AgentWorkflow workflow;\n    private final AgentSession session;\n\n    public WorkflowAgent(AgentWorkflow workflow, AgentSession session) {\n        this.workflow = workflow;\n        this.session = session;\n    }\n\n    public AgentResult run(AgentRequest request) throws Exception {\n        return workflow.run(session, request);\n    }\n\n    public void runStream(AgentRequest request, AgentListener listener) throws Exception {\n        workflow.runStream(session, request, listener);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/WorkflowContext.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventPublisher;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\n@Data\n@Builder\npublic class WorkflowContext {\n\n    private AgentSession session;\n\n    @Builder.Default\n    private Map<String, Object> state = new HashMap<>();\n\n    private AgentEventPublisher eventPublisher;\n\n    public void put(String key, Object value) {\n        state.put(key, value);\n    }\n\n    public Object get(String key) {\n        return state.get(key);\n    }\n\n    public AgentEvent createResultEvent(AgentResult result) {\n        return AgentEvent.builder()\n                .type(AgentEventType.FINAL_OUTPUT)\n                .message(result == null ? null : result.getOutputText())\n                .payload(result)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/workflow/WorkflowResultAware.java",
    "content": "package io.github.lnyocly.ai4j.agent.workflow;\n\nimport io.github.lnyocly.ai4j.agent.AgentResult;\n\npublic interface WorkflowResultAware {\n\n    AgentResult getLastResult();\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentFlowTraceBridgeTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.trace.AgentFlowTraceBridge;\nimport io.github.lnyocly.ai4j.agent.trace.InMemoryTraceExporter;\nimport io.github.lnyocly.ai4j.agent.trace.TraceConfig;\nimport io.github.lnyocly.ai4j.agent.trace.TraceSpan;\nimport io.github.lnyocly.ai4j.agent.trace.TraceSpanStatus;\nimport io.github.lnyocly.ai4j.agent.trace.TraceSpanType;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowType;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowUsage;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatEvent;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatRequest;\nimport io.github.lnyocly.ai4j.agentflow.chat.AgentFlowChatResponse;\nimport io.github.lnyocly.ai4j.agentflow.trace.AgentFlowTraceContext;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.List;\n\npublic class AgentFlowTraceBridgeTest {\n\n    @Test\n    public void test_bridge_exports_agentflow_chat_span() {\n        InMemoryTraceExporter exporter = new InMemoryTraceExporter();\n        AgentFlowTraceBridge bridge = new AgentFlowTraceBridge(exporter, TraceConfig.builder().build());\n\n        AgentFlowTraceContext context = AgentFlowTraceContext.builder()\n                .executionId(\"exec-1\")\n                .type(AgentFlowType.DIFY)\n                .operation(\"chat\")\n                .streaming(true)\n                .startedAt(System.currentTimeMillis())\n                .baseUrl(\"https://api.dify.example\")\n                .request(AgentFlowChatRequest.builder()\n                        .prompt(\"plan my trip\")\n                        .build())\n                .build();\n\n        bridge.onStart(context);\n        bridge.onEvent(context, AgentFlowChatEvent.builder()\n                .type(\"message\")\n                .contentDelta(\"hello\")\n                .conversationId(\"conv-1\")\n                .messageId(\"msg-1\")\n                .taskId(\"task-1\")\n                .usage(AgentFlowUsage.builder()\n                        .inputTokens(Integer.valueOf(11))\n                        .outputTokens(Integer.valueOf(7))\n                        .totalTokens(Integer.valueOf(18))\n                        .build())\n                .build());\n        bridge.onComplete(context, AgentFlowChatResponse.builder()\n                .content(\"hello world\")\n                .conversationId(\"conv-1\")\n                .messageId(\"msg-1\")\n                .taskId(\"task-1\")\n                .usage(AgentFlowUsage.builder()\n                        .inputTokens(Integer.valueOf(11))\n                        .outputTokens(Integer.valueOf(7))\n                        .totalTokens(Integer.valueOf(18))\n                        .build())\n                .build());\n\n        List<TraceSpan> spans = exporter.getSpans();\n        Assert.assertEquals(Integer.valueOf(1), Integer.valueOf(spans.size()));\n        TraceSpan span = spans.get(0);\n        Assert.assertEquals(TraceSpanType.AGENT_FLOW, span.getType());\n        Assert.assertEquals(TraceSpanStatus.OK, span.getStatus());\n        Assert.assertEquals(\"agentflow.chat\", span.getName());\n        Assert.assertEquals(\"DIFY\", span.getAttributes().get(\"providerType\"));\n        Assert.assertEquals(\"plan my trip\", span.getAttributes().get(\"message\"));\n        Assert.assertEquals(\"hello world\", span.getAttributes().get(\"output\"));\n        Assert.assertEquals(\"task-1\", span.getAttributes().get(\"taskId\"));\n        Assert.assertNotNull(span.getMetrics());\n        Assert.assertEquals(Long.valueOf(18L), span.getMetrics().getTotalTokens());\n        Assert.assertTrue(span.getEvents().stream().anyMatch(event -> \"agentflow.chat.event\".equals(event.getName())));\n    }\n\n    @Test\n    public void test_bridge_exports_error_span() {\n        InMemoryTraceExporter exporter = new InMemoryTraceExporter();\n        AgentFlowTraceBridge bridge = new AgentFlowTraceBridge(exporter);\n\n        AgentFlowTraceContext context = AgentFlowTraceContext.builder()\n                .executionId(\"exec-2\")\n                .type(AgentFlowType.N8N)\n                .operation(\"workflow\")\n                .streaming(false)\n                .startedAt(System.currentTimeMillis())\n                .webhookUrl(\"https://n8n.example/webhook/demo\")\n                .build();\n\n        bridge.onStart(context);\n        bridge.onError(context, new IllegalStateException(\"workflow failed\"));\n\n        TraceSpan span = exporter.getSpans().get(0);\n        Assert.assertEquals(TraceSpanStatus.ERROR, span.getStatus());\n        Assert.assertEquals(\"workflow failed\", span.getError());\n        Assert.assertEquals(\"workflow\", span.getAttributes().get(\"operation\"));\n        Assert.assertEquals(\"https://n8n.example/webhook/demo\", span.getAttributes().get(\"webhookUrl\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentMemoryTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory;\nimport io.github.lnyocly.ai4j.agent.memory.WindowedMemoryCompressor;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.List;\n\npublic class AgentMemoryTest {\n\n    @Test\n    public void test_windowed_compression() {\n        InMemoryAgentMemory memory = new InMemoryAgentMemory(new WindowedMemoryCompressor(2));\n        memory.addUserInput(\"one\");\n        memory.addUserInput(\"two\");\n        memory.addUserInput(\"three\");\n\n        List<Object> items = memory.getItems();\n        Assert.assertEquals(2, items.size());\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentRuntimeTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.runtime.ReActRuntime;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Deque;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class AgentRuntimeTest {\n\n    @Test\n    public void test_tool_loop_and_output() throws Exception {\n        Deque<AgentModelResult> queue = new ArrayDeque<>();\n        queue.add(resultWithToolCall(\"call_1\", \"echo\", \"{}\"));\n        queue.add(resultWithText(\"done\"));\n\n        AgentModelClient modelClient = new QueueModelClient(queue);\n        CountingToolExecutor toolExecutor = new CountingToolExecutor();\n\n        AgentContext context = AgentContext.builder()\n                .modelClient(modelClient)\n                .toolExecutor(toolExecutor)\n                .memory(new InMemoryAgentMemory())\n                .options(AgentOptions.builder().maxSteps(4).build())\n                .model(\"test-model\")\n                .build();\n\n        AgentResult result = new ReActRuntime().run(context, AgentRequest.builder().input(\"hi\").build());\n\n        Assert.assertEquals(\"done\", result.getOutputText());\n        Assert.assertEquals(1, toolExecutor.count);\n        Assert.assertEquals(1, result.getToolCalls().size());\n        AgentToolCall call = result.getToolCalls().get(0);\n        Assert.assertEquals(\"echo\", call.getName());\n        Assert.assertEquals(\"call_1\", call.getCallId());\n    }\n\n    @Test\n    public void test_stream_run_publishes_reasoning_and_text_before_tool_call() throws Exception {\n        AgentModelClient modelClient = new AgentModelClient() {\n            private int invocation;\n\n            @Override\n            public AgentModelResult create(AgentPrompt prompt) {\n                throw new UnsupportedOperationException(\"stream path only\");\n            }\n\n            @Override\n            public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n                invocation++;\n                if (invocation == 1) {\n                    return AgentModelResult.builder()\n                            .reasoningText(\"Need to inspect the tool output first.\")\n                            .outputText(\"I will run the tool now.\")\n                            .toolCalls(Arrays.asList(AgentToolCall.builder()\n                                    .callId(\"call_1\")\n                                    .name(\"echo\")\n                                    .arguments(\"{}\")\n                                    .type(\"function_call\")\n                                    .build()))\n                            .memoryItems(new ArrayList<Object>())\n                            .build();\n                }\n                return resultWithText(\"done\");\n            }\n        };\n\n        AgentContext context = AgentContext.builder()\n                .modelClient(modelClient)\n                .toolExecutor(new CountingToolExecutor())\n                .memory(new InMemoryAgentMemory())\n                .options(AgentOptions.builder().stream(true).maxSteps(4).build())\n                .model(\"test-model\")\n                .build();\n\n        List<AgentEventType> eventTypes = new ArrayList<>();\n        List<String> eventMessages = new ArrayList<>();\n        AgentListener listener = new AgentListener() {\n            @Override\n            public void onEvent(AgentEvent event) {\n                if (event == null || event.getType() == null) {\n                    return;\n                }\n                eventTypes.add(event.getType());\n                eventMessages.add(event.getMessage());\n            }\n        };\n\n        new ReActRuntime().runStream(context, AgentRequest.builder().input(\"hi\").build(), listener);\n\n        int reasoningIndex = eventTypes.indexOf(AgentEventType.MODEL_REASONING);\n        int responseIndex = eventTypes.indexOf(AgentEventType.MODEL_RESPONSE);\n        int toolCallIndex = eventTypes.indexOf(AgentEventType.TOOL_CALL);\n        Assert.assertTrue(\"missing reasoning event: \" + eventTypes, reasoningIndex >= 0);\n        Assert.assertTrue(\"missing response event: \" + eventTypes, responseIndex >= 0);\n        Assert.assertTrue(\"missing tool call event: \" + eventTypes, toolCallIndex >= 0);\n        Assert.assertTrue(\"reasoning should arrive before tool call: \" + eventTypes, reasoningIndex < toolCallIndex);\n        Assert.assertTrue(\"text should arrive before tool call: \" + eventTypes, responseIndex < toolCallIndex);\n        Assert.assertTrue(eventMessages.stream().filter(msg -> msg != null).collect(Collectors.toList())\n                .contains(\"Need to inspect the tool output first.\"));\n        Assert.assertTrue(eventMessages.stream().filter(msg -> msg != null).collect(Collectors.toList())\n                .contains(\"I will run the tool now.\"));\n    }\n\n    @Test\n    public void test_invalid_tool_call_is_reported_without_execution() throws Exception {\n        Deque<AgentModelResult> queue = new ArrayDeque<>();\n        queue.add(AgentModelResult.builder()\n                .reasoningText(\"Need to inspect the shell call.\")\n                .toolCalls(Arrays.asList(AgentToolCall.builder()\n                        .name(\"bash\")\n                        .arguments(\"{\\\"action\\\":\\\"exec\\\"}\")\n                        .build()))\n                .memoryItems(new ArrayList<Object>())\n                .build());\n        queue.add(resultWithText(\"done\"));\n\n        CountingToolExecutor toolExecutor = new CountingToolExecutor();\n        AgentContext context = AgentContext.builder()\n                .modelClient(new QueueModelClient(queue))\n                .toolExecutor(toolExecutor)\n                .memory(new InMemoryAgentMemory())\n                .options(AgentOptions.builder().stream(true).maxSteps(4).build())\n                .model(\"test-model\")\n                .build();\n\n        List<AgentEvent> events = new ArrayList<AgentEvent>();\n        AgentListener listener = new AgentListener() {\n            @Override\n            public void onEvent(AgentEvent event) {\n                if (event != null) {\n                    events.add(event);\n                }\n            }\n        };\n\n        new ReActRuntime().runStream(context, AgentRequest.builder().input(\"hi\").build(), listener);\n\n        Assert.assertEquals(0, toolExecutor.count);\n        Assert.assertTrue(events.stream().anyMatch(event -> event.getType() == AgentEventType.TOOL_CALL));\n        Assert.assertTrue(events.stream().anyMatch(event -> {\n            if (event.getType() != AgentEventType.TOOL_RESULT || !(event.getPayload() instanceof io.github.lnyocly.ai4j.agent.tool.AgentToolResult)) {\n                return false;\n            }\n            io.github.lnyocly.ai4j.agent.tool.AgentToolResult result =\n                    (io.github.lnyocly.ai4j.agent.tool.AgentToolResult) event.getPayload();\n            return result.getOutput() != null && result.getOutput().contains(\"bash exec requires a non-empty command\");\n        }));\n    }\n\n    @Test\n    public void test_stream_run_publishes_model_retry_event() throws Exception {\n        AgentModelClient modelClient = new AgentModelClient() {\n            @Override\n            public AgentModelResult create(AgentPrompt prompt) {\n                throw new UnsupportedOperationException(\"stream path only\");\n            }\n\n            @Override\n            public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n                listener.onRetry(\"Timed out waiting for first model stream event after 30000 ms\", 2, 3,\n                        new RuntimeException(\"Timed out waiting for first model stream event after 30000 ms\"));\n                return resultWithText(\"done\");\n            }\n        };\n\n        AgentContext context = AgentContext.builder()\n                .modelClient(modelClient)\n                .toolExecutor(new CountingToolExecutor())\n                .memory(new InMemoryAgentMemory())\n                .options(AgentOptions.builder().stream(true).maxSteps(4).build())\n                .model(\"test-model\")\n                .build();\n\n        List<AgentEvent> events = new ArrayList<AgentEvent>();\n        AgentListener listener = new AgentListener() {\n            @Override\n            public void onEvent(AgentEvent event) {\n                if (event != null) {\n                    events.add(event);\n                }\n            }\n        };\n\n        new ReActRuntime().runStream(context, AgentRequest.builder().input(\"hi\").build(), listener);\n\n        Assert.assertTrue(events.stream().anyMatch(event -> event.getType() == AgentEventType.MODEL_RETRY));\n        Assert.assertTrue(events.stream().anyMatch(event ->\n                event.getType() == AgentEventType.MODEL_RETRY\n                        && event.getMessage() != null\n                        && event.getMessage().contains(\"Timed out waiting for first model stream event\")));\n    }\n\n    @Test\n    public void test_zero_max_steps_means_unlimited() throws Exception {\n        Deque<AgentModelResult> queue = new ArrayDeque<>();\n        queue.add(resultWithToolCall(\"call_1\", \"echo\", \"{}\"));\n        queue.add(resultWithToolCall(\"call_2\", \"echo\", \"{}\"));\n        queue.add(resultWithText(\"done\"));\n\n        CountingToolExecutor toolExecutor = new CountingToolExecutor();\n        AgentContext context = AgentContext.builder()\n                .modelClient(new QueueModelClient(queue))\n                .toolExecutor(toolExecutor)\n                .memory(new InMemoryAgentMemory())\n                .options(AgentOptions.builder().maxSteps(0).build())\n                .model(\"test-model\")\n                .build();\n\n        AgentResult result = new ReActRuntime().run(context, AgentRequest.builder().input(\"hi\").build());\n\n        Assert.assertEquals(\"done\", result.getOutputText());\n        Assert.assertEquals(2, toolExecutor.count);\n        Assert.assertEquals(2, result.getToolCalls().size());\n    }\n\n    private AgentModelResult resultWithText(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .memoryItems(new ArrayList<Object>())\n                .toolCalls(new ArrayList<AgentToolCall>())\n                .build();\n    }\n\n    private AgentModelResult resultWithToolCall(String callId, String name, String arguments) {\n        List<AgentToolCall> calls = Arrays.asList(AgentToolCall.builder()\n                .callId(callId)\n                .name(name)\n                .arguments(arguments)\n                .type(\"function_call\")\n                .build());\n        return AgentModelResult.builder()\n                .toolCalls(calls)\n                .memoryItems(new ArrayList<Object>())\n                .build();\n    }\n\n    private static class QueueModelClient implements AgentModelClient {\n        private final Deque<AgentModelResult> queue;\n\n        private QueueModelClient(Deque<AgentModelResult> queue) {\n            this.queue = queue;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll();\n        }\n    }\n\n    private static class CountingToolExecutor implements ToolExecutor {\n        private int count = 0;\n\n        @Override\n        public String execute(AgentToolCall call) {\n            count += 1;\n            return \"{\\\"ok\\\":true}\";\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamAgentAdapterTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Deque;\nimport java.util.List;\n\npublic class AgentTeamAgentAdapterTest {\n\n    @Test\n    public void shouldBuildTeamAsStandardAgentAndRun() throws Exception {\n        ScriptedModelClient memberClient = new ScriptedModelClient();\n        memberClient.enqueue(textResult(\"requirements-collected\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"team-final-answer\"));\n\n        Agent teamAgent = Agents.teamAgent(Agents.team()\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"collect\")\n                                        .memberId(\"researcher\")\n                                        .task(\"Collect requirements\")\n                                        .build()\n                        ))\n                        .build())\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder()\n                        .id(\"researcher\")\n                        .name(\"Researcher\")\n                        .agent(newAgent(\"member\", memberClient))\n                        .build()));\n\n        AgentResult result = teamAgent.run(AgentRequest.builder().input(\"prepare plan\").build());\n\n        Assert.assertEquals(\"team-final-answer\", result.getOutputText());\n        Assert.assertTrue(result.getRawResponse() instanceof io.github.lnyocly.ai4j.agent.team.AgentTeamResult);\n    }\n\n    @Test\n    public void shouldEmitTeamTaskEventsWhenRunningStream() throws Exception {\n        ScriptedModelClient memberClient = new ScriptedModelClient();\n        memberClient.enqueue(textResult(\"collected\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"synthesized\"));\n\n        Agent teamAgent = Agents.team()\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"collect\")\n                                        .memberId(\"researcher\")\n                                        .task(\"Collect requirements\")\n                                        .build()\n                        ))\n                        .build())\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder()\n                        .id(\"researcher\")\n                        .name(\"Researcher\")\n                        .agent(newAgent(\"member\", memberClient))\n                        .build())\n                .buildAgent();\n\n        final List<AgentEvent> events = new ArrayList<AgentEvent>();\n        teamAgent.runStream(AgentRequest.builder().input(\"run team\").build(), new AgentListener() {\n            @Override\n            public void onEvent(AgentEvent event) {\n                events.add(event);\n            }\n        });\n\n        Assert.assertNotNull(firstEvent(events, AgentEventType.TEAM_TASK_CREATED));\n        Assert.assertNotNull(firstEvent(events, AgentEventType.TEAM_TASK_UPDATED));\n        AgentEvent finalEvent = firstEvent(events, AgentEventType.FINAL_OUTPUT);\n        Assert.assertNotNull(finalEvent);\n        Assert.assertEquals(\"synthesized\", finalEvent.getMessage());\n    }\n\n    @Test\n    public void shouldPreserveTeamPersistenceSettingsWhenBuildingStandardAgent() throws Exception {\n        Path storageRoot = Files.createTempDirectory(\"agent-team-build-agent-persistence\");\n\n        ScriptedModelClient memberClient = new ScriptedModelClient();\n        memberClient.enqueue(textResult(\"delivery-complete\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"persisted-summary\"));\n\n        Agent teamAgent = Agents.team()\n                .teamId(\"delivery-team\")\n                .storageDirectory(storageRoot)\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"deliver\")\n                                        .memberId(\"builder\")\n                                        .task(\"Deliver travel package\")\n                                        .build()\n                        ))\n                        .build())\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder()\n                        .id(\"builder\")\n                        .name(\"Builder\")\n                        .agent(newAgent(\"member\", memberClient))\n                        .build())\n                .buildAgent();\n\n        AgentResult result = teamAgent.run(AgentRequest.builder().input(\"run delivery\").build());\n\n        Assert.assertEquals(\"persisted-summary\", result.getOutputText());\n\n        AgentTeamState restored = Agents.team()\n                .teamId(\"delivery-team\")\n                .storageDirectory(storageRoot)\n                .planner((objective, members, options) -> AgentTeamPlan.builder().tasks(new ArrayList<AgentTeamTask>()).build())\n                .synthesizerAgent(newAgent(\"noop-synth\", new ScriptedModelClient()))\n                .member(AgentTeamMember.builder()\n                        .id(\"builder\")\n                        .name(\"Builder\")\n                        .agent(newAgent(\"noop-member\", new ScriptedModelClient()))\n                        .build())\n                .build()\n                .loadPersistedState();\n\n        Assert.assertNotNull(restored);\n        Assert.assertEquals(\"delivery-team\", restored.getTeamId());\n        Assert.assertEquals(\"persisted-summary\", restored.getLastOutput());\n        Assert.assertEquals(1, restored.getTaskStates().size());\n    }\n\n    private static AgentEvent firstEvent(List<AgentEvent> events, AgentEventType type) {\n        if (events == null || type == null) {\n            return null;\n        }\n        for (AgentEvent event : events) {\n            if (event != null && type == event.getType()) {\n                return event;\n            }\n        }\n        return null;\n    }\n\n    private static Agent newAgent(String model, AgentModelClient client) {\n        return Agents.react()\n                .modelClient(client)\n                .model(model)\n                .build();\n    }\n\n    private static AgentModelResult textResult(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .memoryItems(new ArrayList<Object>())\n                .toolCalls(new ArrayList<io.github.lnyocly.ai4j.agent.tool.AgentToolCall>())\n                .build();\n    }\n\n    private static class ScriptedModelClient implements AgentModelClient {\n        private final Deque<AgentModelResult> queue = new ArrayDeque<AgentModelResult>();\n\n        private void enqueue(AgentModelResult result) {\n            queue.add(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamPersistenceTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport org.junit.Assert;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Deque;\nimport java.util.List;\n\npublic class AgentTeamPersistenceTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldPersistAndRestoreTeamStateFromStorageDirectory() throws Exception {\n        Path root = temporaryFolder.newFolder(\"agent-team-storage\").toPath();\n\n        AgentTeam firstTeam = buildTeam(root, \"travel-team\");\n        AgentTeamResult result = firstTeam.run(\"prepare travel delivery package\");\n\n        Assert.assertEquals(\"team synthesis\", result.getOutput());\n        Assert.assertEquals(\"travel-team\", result.getTeamId());\n        Assert.assertEquals(1, result.getTaskStates().size());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(0).getStatus());\n\n        AgentTeamState snapshot = firstTeam.snapshotState();\n        Assert.assertNotNull(snapshot);\n        Assert.assertEquals(\"travel-team\", snapshot.getTeamId());\n        Assert.assertTrue(snapshot.getMessages().size() >= 2);\n\n        AgentTeam restoredTeam = buildTeam(root, \"travel-team\");\n        AgentTeamState restored = restoredTeam.loadPersistedState();\n\n        Assert.assertNotNull(restored);\n        Assert.assertEquals(\"travel-team\", restored.getTeamId());\n        Assert.assertEquals(1, restoredTeam.listTaskStates().size());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, restoredTeam.listTaskStates().get(0).getStatus());\n        Assert.assertTrue(restoredTeam.listMessages().size() >= 2);\n        Assert.assertEquals(\"team synthesis\", restored.getLastOutput());\n\n        Assert.assertTrue(restoredTeam.clearPersistedState());\n        Assert.assertTrue(restoredTeam.listMessages().isEmpty());\n        Assert.assertTrue(restoredTeam.listTaskStates().isEmpty());\n\n        AgentTeam afterClear = buildTeam(root, \"travel-team\");\n        Assert.assertNull(afterClear.loadPersistedState());\n    }\n\n    private AgentTeam buildTeam(Path root, String teamId) {\n        ScriptedModelClient memberClient = new ScriptedModelClient();\n        memberClient.enqueue(textResult(\"backend and frontend delivery ready\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"team synthesis\"));\n\n        return Agents.team()\n                .teamId(teamId)\n                .storageDirectory(root)\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .rawPlanText(\"fixed\")\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"delivery\")\n                                        .memberId(\"builder\")\n                                        .task(\"Produce delivery package\")\n                                        .build()\n                        ))\n                        .build())\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder()\n                        .id(\"builder\")\n                        .name(\"Builder\")\n                        .description(\"Produces delivery output\")\n                        .agent(newAgent(\"member\", memberClient))\n                        .build())\n                .build();\n    }\n\n    private Agent newAgent(String model, AgentModelClient client) {\n        return Agents.react()\n                .modelClient(client)\n                .model(model)\n                .build();\n    }\n\n    private AgentModelResult textResult(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .memoryItems(new ArrayList<Object>())\n                .toolCalls(new ArrayList<io.github.lnyocly.ai4j.agent.tool.AgentToolCall>())\n                .build();\n    }\n\n    private static class ScriptedModelClient implements AgentModelClient {\n        private final Deque<AgentModelResult> queue = new ArrayDeque<AgentModelResult>();\n\n        private void enqueue(AgentModelResult result) {\n            queue.add(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamProjectDeliveryExampleTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamOptions;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlanner;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamSynthesizer;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Deque;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * End-to-end AgentTeam example for a software delivery team.\n *\n * Team roles: architect, backend, frontend, QA, ops.\n */\npublic class AgentTeamProjectDeliveryExampleTest {\n\n    @Test\n    public void test_project_delivery_team_example() throws Exception {\n        ScriptedModelClient architectClient = new ScriptedModelClient();\n        architectClient.enqueue(textResult(\n                \"Architecture: modular monolith + REST API + MySQL + Redis + JWT + CI/CD checkpoints.\"));\n\n        ScriptedModelClient backendClient = new ScriptedModelClient();\n        backendClient.enqueue(toolCallResult(Arrays.asList(\n                toolCall(\"team_send_message\",\n                        \"{\\\"toMemberId\\\":\\\"frontend\\\",\\\"type\\\":\\\"api.contract\\\",\\\"content\\\":\\\"Draft API: GET/POST /api/tasks, GET /api/tasks/{id}, PATCH /api/tasks/{id}\\\"}\")\n        )));\n        backendClient.enqueue(textResult(\n                \"Backend: Spring Boot modules, DTO validation, OpenAPI spec, and migration scripts are ready.\"));\n\n        ScriptedModelClient frontendClient = new ScriptedModelClient();\n        frontendClient.enqueue(toolCallResult(Arrays.asList(\n                toolCall(\"team_send_message\",\n                        \"{\\\"toMemberId\\\":\\\"backend\\\",\\\"type\\\":\\\"ui.question\\\",\\\"content\\\":\\\"Please confirm pagination params page,size and sort format.\\\"}\")\n        )));\n        frontendClient.enqueue(textResult(\n                \"Frontend: React pages for task list/detail/edit, API client integration, and error-state UI done.\"));\n\n        ScriptedModelClient qaClient = new ScriptedModelClient();\n        qaClient.enqueue(toolCallResult(Arrays.asList(\n                toolCall(\"team_list_tasks\", \"{}\"),\n                toolCall(\"team_broadcast\",\n                        \"{\\\"type\\\":\\\"qa.sync\\\",\\\"content\\\":\\\"Integration testing started. Please freeze API changes.\\\"}\")\n        )));\n        qaClient.enqueue(textResult(\n                \"QA: smoke/regression cases pass; one minor UI edge case documented with workaround.\"));\n\n        ScriptedModelClient opsClient = new ScriptedModelClient();\n        opsClient.enqueue(textResult(\n                \"Ops: Docker image, staging deployment, health checks, dashboards, and rollback playbook prepared.\"));\n\n        AgentTeam team = Agents.team()\n                .planner(projectPlanner())\n                .synthesizer(projectSynthesizer())\n                .member(member(\"architect\", \"Architect\", \"system design and API boundaries\", architectClient))\n                .member(member(\"backend\", \"Backend\", \"service implementation and persistence\", backendClient))\n                .member(member(\"frontend\", \"Frontend\", \"UI implementation and API integration\", frontendClient))\n                .member(member(\"qa\", \"QA\", \"test strategy and release quality gate\", qaClient))\n                .member(member(\"ops\", \"Ops\", \"deployment, observability, and production readiness\", opsClient))\n                .options(AgentTeamOptions.builder()\n                        .parallelDispatch(true)\n                        .maxConcurrency(3)\n                        .enableMessageBus(true)\n                        .includeMessageHistoryInDispatch(true)\n                        .enableMemberTeamTools(true)\n                        .build())\n                .build();\n\n        AgentTeamResult result = team.run(AgentRequest.builder()\n                .input(\"Deliver a production-ready task management web app in one sprint.\")\n                .build());\n\n        printResult(result);\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getPlan());\n        Assert.assertEquals(5, result.getPlan().getTasks().size());\n        Assert.assertEquals(5, result.getMemberResults().size());\n        Assert.assertTrue(result.getOutput().contains(\"Project Delivery Summary\"));\n        Assert.assertTrue(result.getOutput().contains(\"[Architect]\"));\n        Assert.assertTrue(result.getOutput().contains(\"[Backend]\"));\n        Assert.assertTrue(result.getOutput().contains(\"[Frontend]\"));\n        Assert.assertTrue(result.getOutput().contains(\"[QA]\"));\n        Assert.assertTrue(result.getOutput().contains(\"[Ops]\"));\n\n        for (AgentTeamTaskState state : result.getTaskStates()) {\n            Assert.assertEquals(\"task must complete: \" + state.getTaskId(),\n                    AgentTeamTaskStatus.COMPLETED,\n                    state.getStatus());\n        }\n\n        Assert.assertTrue(hasMessage(result.getMessages(), \"backend\", \"frontend\", \"api.contract\"));\n        Assert.assertTrue(hasMessage(result.getMessages(), \"frontend\", \"backend\", \"ui.question\"));\n        Assert.assertTrue(hasMessage(result.getMessages(), \"qa\", \"*\", \"qa.sync\"));\n    }\n\n    private static AgentTeamPlanner projectPlanner() {\n        return (objective, members, options) -> AgentTeamPlan.builder()\n                .rawPlanText(\"fixed-project-delivery-plan\")\n                .tasks(Arrays.asList(\n                        AgentTeamTask.builder()\n                                .id(\"architecture\")\n                                .memberId(\"architect\")\n                                .task(\"Design the target architecture and define API boundaries.\")\n                                .context(\"Output architecture notes + API contracts\")\n                                .build(),\n                        AgentTeamTask.builder()\n                                .id(\"backend_impl\")\n                                .memberId(\"backend\")\n                                .task(\"Implement backend services and persistence schema.\")\n                                .dependsOn(Arrays.asList(\"architecture\"))\n                                .build(),\n                        AgentTeamTask.builder()\n                                .id(\"frontend_impl\")\n                                .memberId(\"frontend\")\n                                .task(\"Implement frontend pages and integrate backend APIs.\")\n                                .dependsOn(Arrays.asList(\"architecture\"))\n                                .build(),\n                        AgentTeamTask.builder()\n                                .id(\"qa_validation\")\n                                .memberId(\"qa\")\n                                .task(\"Run integration/regression tests and publish quality report.\")\n                                .dependsOn(Arrays.asList(\"backend_impl\", \"frontend_impl\"))\n                                .build(),\n                        AgentTeamTask.builder()\n                                .id(\"ops_release\")\n                                .memberId(\"ops\")\n                                .task(\"Prepare release pipeline, monitoring, and rollback plan.\")\n                                .dependsOn(Arrays.asList(\"backend_impl\", \"frontend_impl\"))\n                                .build()\n                ))\n                .build();\n    }\n\n    private static AgentTeamSynthesizer projectSynthesizer() {\n        return (objective, plan, memberResults, options) -> {\n            Map<String, String> byMember = new LinkedHashMap<>();\n            if (memberResults != null) {\n                for (AgentTeamMemberResult item : memberResults) {\n                    if (item == null || item.getMemberId() == null) {\n                        continue;\n                    }\n                    byMember.put(item.getMemberId(), item.isSuccess() ? safe(item.getOutput()) : \"FAILED: \" + safe(item.getError()));\n                }\n            }\n\n            StringBuilder sb = new StringBuilder();\n            sb.append(\"Project Delivery Summary\\n\");\n            sb.append(\"Objective: \").append(safe(objective)).append(\"\\n\\n\");\n            sb.append(\"[Architect]\\n\").append(safe(byMember.get(\"architect\"))).append(\"\\n\\n\");\n            sb.append(\"[Backend]\\n\").append(safe(byMember.get(\"backend\"))).append(\"\\n\\n\");\n            sb.append(\"[Frontend]\\n\").append(safe(byMember.get(\"frontend\"))).append(\"\\n\\n\");\n            sb.append(\"[QA]\\n\").append(safe(byMember.get(\"qa\"))).append(\"\\n\\n\");\n            sb.append(\"[Ops]\\n\").append(safe(byMember.get(\"ops\"))).append(\"\\n\");\n\n            return AgentResult.builder().outputText(sb.toString()).build();\n        };\n    }\n\n    private static AgentTeamMember member(String id,\n                                          String name,\n                                          String description,\n                                          AgentModelClient modelClient) {\n        return AgentTeamMember.builder()\n                .id(id)\n                .name(name)\n                .description(description)\n                .agent(Agents.react().model(modelClient.getClass().getSimpleName()).modelClient(modelClient).build())\n                .build();\n    }\n\n    private static boolean hasMessage(List<AgentTeamMessage> messages,\n                                      String from,\n                                      String to,\n                                      String type) {\n        if (messages == null || messages.isEmpty()) {\n            return false;\n        }\n        for (AgentTeamMessage msg : messages) {\n            if (msg == null) {\n                continue;\n            }\n            if (equals(msg.getFromMemberId(), from)\n                    && equals(msg.getToMemberId(), to)\n                    && equals(msg.getType(), type)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private static boolean equals(String left, String right) {\n        if (left == null) {\n            return right == null;\n        }\n        return left.equals(right);\n    }\n\n    private static String safe(String value) {\n        return value == null ? \"\" : value;\n    }\n\n    private static AgentModelResult textResult(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .toolCalls(new ArrayList<AgentToolCall>())\n                .memoryItems(new ArrayList<Object>())\n                .build();\n    }\n\n    private static AgentModelResult toolCallResult(List<AgentToolCall> calls) {\n        return AgentModelResult.builder()\n                .outputText(\"\")\n                .toolCalls(calls)\n                .memoryItems(new ArrayList<Object>())\n                .build();\n    }\n\n    private static AgentToolCall toolCall(String name, String arguments) {\n        return AgentToolCall.builder()\n                .name(name)\n                .arguments(arguments)\n                .type(\"function\")\n                .callId(name + \"_\" + System.nanoTime())\n                .build();\n    }\n\n    private static void printResult(AgentTeamResult result) {\n        System.out.println(\"==== TEAM PLAN ====\");\n        if (result != null && result.getPlan() != null && result.getPlan().getTasks() != null) {\n            for (AgentTeamTask task : result.getPlan().getTasks()) {\n                System.out.println(task.getId() + \" -> \" + task.getMemberId() + \" | dependsOn=\" + task.getDependsOn());\n            }\n        }\n\n        System.out.println(\"==== TASK STATES ====\");\n        if (result != null && result.getTaskStates() != null) {\n            for (AgentTeamTaskState state : result.getTaskStates()) {\n                System.out.println(state.getTaskId() + \" => \" + state.getStatus() + \" by \" + state.getClaimedBy());\n            }\n        }\n\n        System.out.println(\"==== TEAM MESSAGES ====\");\n        if (result != null && result.getMessages() != null) {\n            for (AgentTeamMessage message : result.getMessages()) {\n                System.out.println(\"[\" + message.getType() + \"] \"\n                        + message.getFromMemberId() + \" -> \" + message.getToMemberId()\n                        + \" (task=\" + message.getTaskId() + \"): \"\n                        + message.getContent());\n            }\n        }\n\n        System.out.println(\"==== FINAL OUTPUT ====\");\n        System.out.println(result == null ? \"\" : result.getOutput());\n    }\n\n    private static class ScriptedModelClient implements AgentModelClient {\n        private final Deque<AgentModelResult> queue = new ArrayDeque<>();\n\n        void enqueue(AgentModelResult result) {\n            queue.add(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in this test\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamTaskBoardTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskBoard;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class AgentTeamTaskBoardTest {\n\n    @Test\n    public void test_claim_release_reassign_and_dependency_flow() {\n        List<AgentTeamTask> tasks = new ArrayList<>();\n        tasks.add(AgentTeamTask.builder().id(\"collect\").memberId(\"m1\").task(\"collect facts\").build());\n        tasks.add(AgentTeamTask.builder().id(\"format\").memberId(\"m2\").task(\"format final answer\").dependsOn(Arrays.asList(\"collect\")).build());\n\n        AgentTeamTaskBoard board = new AgentTeamTaskBoard(tasks);\n\n        List<AgentTeamTaskState> ready = board.nextReadyTasks(10);\n        Assert.assertEquals(1, ready.size());\n        Assert.assertEquals(\"collect\", ready.get(0).getTaskId());\n\n        Assert.assertTrue(board.claimTask(\"collect\", \"m1\"));\n        Assert.assertEquals(AgentTeamTaskStatus.IN_PROGRESS, board.getTaskState(\"collect\").getStatus());\n        Assert.assertEquals(\"m1\", board.getTaskState(\"collect\").getClaimedBy());\n        Assert.assertEquals(\"running\", board.getTaskState(\"collect\").getPhase());\n        Assert.assertEquals(Integer.valueOf(15), board.getTaskState(\"collect\").getPercent());\n\n        Assert.assertTrue(board.reassignTask(\"collect\", \"m1\", \"m2\"));\n        Assert.assertEquals(\"m2\", board.getTaskState(\"collect\").getClaimedBy());\n        Assert.assertEquals(\"reassigned\", board.getTaskState(\"collect\").getPhase());\n\n        Assert.assertTrue(board.releaseTask(\"collect\", \"m2\", \"handoff\"));\n        Assert.assertEquals(AgentTeamTaskStatus.READY, board.getTaskState(\"collect\").getStatus());\n        Assert.assertEquals(\"ready\", board.getTaskState(\"collect\").getPhase());\n\n        Assert.assertTrue(board.claimTask(\"collect\", \"m2\"));\n        Assert.assertTrue(board.heartbeatTask(\"collect\", \"m2\"));\n        Assert.assertEquals(\"heartbeat\", board.getTaskState(\"collect\").getPhase());\n        Assert.assertEquals(1, board.getTaskState(\"collect\").getHeartbeatCount());\n        board.markCompleted(\"collect\", \"facts\", 12L);\n\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, board.getTaskState(\"collect\").getStatus());\n        Assert.assertEquals(Integer.valueOf(100), board.getTaskState(\"collect\").getPercent());\n        Assert.assertEquals(AgentTeamTaskStatus.READY, board.getTaskState(\"format\").getStatus());\n\n        Assert.assertTrue(board.claimTask(\"format\", \"m2\"));\n        board.markCompleted(\"format\", \"final\", 8L);\n\n        Assert.assertFalse(board.hasWorkRemaining());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, board.getTaskState(\"format\").getStatus());\n    }\n\n    @Test\n    public void test_recover_timed_out_claims() throws Exception {\n        List<AgentTeamTask> tasks = new ArrayList<>();\n        tasks.add(AgentTeamTask.builder().id(\"t1\").memberId(\"m1\").task(\"run\").build());\n        AgentTeamTaskBoard board = new AgentTeamTaskBoard(tasks);\n\n        Assert.assertTrue(board.claimTask(\"t1\", \"m1\"));\n        Thread.sleep(5L);\n\n        int recovered = board.recoverTimedOutClaims(1L, \"timeout\");\n        Assert.assertEquals(1, recovered);\n        Assert.assertEquals(AgentTeamTaskStatus.READY, board.getTaskState(\"t1\").getStatus());\n        Assert.assertNull(board.getTaskState(\"t1\").getClaimedBy());\n        Assert.assertEquals(\"ready\", board.getTaskState(\"t1\").getPhase());\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamHook;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamOptions;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Deque;\nimport java.util.List;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class AgentTeamTest {\n\n    @Test\n    public void test_team_plan_delegate_synthesize() throws Exception {\n        ScriptedModelClient plannerClient = new ScriptedModelClient();\n        plannerClient.enqueue(textResult(\"{\\\"tasks\\\":[{\\\"memberId\\\":\\\"researcher\\\",\\\"task\\\":\\\"Collect weather facts for Beijing\\\"},{\\\"memberId\\\":\\\"formatter\\\",\\\"task\\\":\\\"Format output as concise summary\\\"}]}\"));\n\n        ScriptedModelClient researcherClient = new ScriptedModelClient();\n        researcherClient.enqueue(textResult(\"Beijing weather: cloudy, 9C, light wind.\"));\n\n        ScriptedModelClient formatterClient = new ScriptedModelClient();\n        formatterClient.enqueue(textResult(\"Summary formatted.\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"Final answer from team.\"));\n\n        Agent planner = newAgent(\"planner-model\", plannerClient);\n        Agent researcher = newAgent(\"researcher-model\", researcherClient);\n        Agent formatter = newAgent(\"formatter-model\", formatterClient);\n        Agent synthesizer = newAgent(\"synth-model\", synthClient);\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(planner)\n                .synthesizerAgent(synthesizer)\n                .member(AgentTeamMember.builder().id(\"researcher\").name(\"Researcher\").description(\"collect factual weather details\").agent(researcher).build())\n                .member(AgentTeamMember.builder().id(\"formatter\").name(\"Formatter\").description(\"format and polish final text\").agent(formatter).build())\n                .options(AgentTeamOptions.builder().parallelDispatch(true).maxConcurrency(2).build())\n                .build();\n\n        AgentTeamResult result = team.run(\"Give me a weather summary for Beijing.\");\n\n        Assert.assertEquals(\"Final answer from team.\", result.getOutput());\n        Assert.assertNotNull(result.getPlan());\n        Assert.assertEquals(2, result.getPlan().getTasks().size());\n        Assert.assertEquals(2, result.getMemberResults().size());\n        Assert.assertTrue(result.getMemberResults().get(0).isSuccess());\n        Assert.assertTrue(result.getMemberResults().get(1).isSuccess());\n\n        Assert.assertTrue(researcherClient.prompts.get(0).getItems().toString().contains(\"Collect weather facts\"));\n        Assert.assertTrue(formatterClient.prompts.get(0).getItems().toString().contains(\"Format output\"));\n    }\n\n    @Test\n    public void test_team_parallel_dispatch() throws Exception {\n        ScriptedModelClient plannerClient = new ScriptedModelClient();\n        plannerClient.enqueue(textResult(\"{\\\"tasks\\\":[{\\\"memberId\\\":\\\"m1\\\",\\\"task\\\":\\\"task one\\\"},{\\\"memberId\\\":\\\"m2\\\",\\\"task\\\":\\\"task two\\\"}]}\"));\n\n        ConcurrentModelClient sharedMemberClient = new ConcurrentModelClient(220L, \"member-done\");\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"parallel merged\"));\n\n        Agent planner = newAgent(\"planner\", plannerClient);\n        Agent m1 = newAgent(\"m1\", sharedMemberClient);\n        Agent m2 = newAgent(\"m2\", sharedMemberClient);\n        Agent synth = newAgent(\"synth\", synthClient);\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(planner)\n                .synthesizerAgent(synth)\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"M1\").agent(m1).build())\n                .member(AgentTeamMember.builder().id(\"m2\").name(\"M2\").agent(m2).build())\n                .options(AgentTeamOptions.builder().parallelDispatch(true).maxConcurrency(2).build())\n                .build();\n\n        AgentTeamResult result = team.run(\"Run tasks in parallel\");\n\n        Assert.assertEquals(\"parallel merged\", result.getOutput());\n        Assert.assertTrue(\"expected parallel member execution\", sharedMemberClient.maxConcurrent.get() >= 2);\n    }\n\n    @Test\n    public void test_team_planner_fallback_broadcast() throws Exception {\n        ScriptedModelClient plannerClient = new ScriptedModelClient();\n        plannerClient.enqueue(textResult(\"I will delegate tasks shortly.\"));\n\n        ScriptedModelClient m1Client = new ScriptedModelClient();\n        m1Client.enqueue(textResult(\"m1 handled objective.\"));\n\n        ScriptedModelClient m2Client = new ScriptedModelClient();\n        m2Client.enqueue(textResult(\"m2 handled objective.\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"fallback merged\"));\n\n        Agent planner = newAgent(\"planner\", plannerClient);\n        Agent m1 = newAgent(\"m1\", m1Client);\n        Agent m2 = newAgent(\"m2\", m2Client);\n        Agent synth = newAgent(\"synth\", synthClient);\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(planner)\n                .synthesizerAgent(synth)\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"M1\").description(\"first domain\").agent(m1).build())\n                .member(AgentTeamMember.builder().id(\"m2\").name(\"M2\").description(\"second domain\").agent(m2).build())\n                .options(AgentTeamOptions.builder().broadcastOnPlannerFailure(true).parallelDispatch(false).build())\n                .build();\n\n        AgentTeamResult result = team.run(\"Prepare combined analysis\");\n\n        Assert.assertNotNull(result.getPlan());\n        Assert.assertTrue(result.getPlan().isFallback());\n        Assert.assertEquals(2, result.getMemberResults().size());\n        Assert.assertEquals(\"fallback merged\", result.getOutput());\n    }\n\n    @Test\n    public void test_team_dependency_and_message_bus() throws Exception {\n        ScriptedModelClient plannerClient = new ScriptedModelClient();\n        plannerClient.enqueue(textResult(\"{\\\"tasks\\\":[{\\\"id\\\":\\\"collect\\\",\\\"memberId\\\":\\\"m1\\\",\\\"task\\\":\\\"collect weather facts\\\"},{\\\"id\\\":\\\"format\\\",\\\"memberId\\\":\\\"m2\\\",\\\"task\\\":\\\"format summary\\\",\\\"dependsOn\\\":[\\\"collect\\\"]}]}\"));\n\n        ScriptedModelClient m1Client = new ScriptedModelClient();\n        m1Client.enqueue(textResult(\"facts ready\"));\n\n        ScriptedModelClient m2Client = new ScriptedModelClient();\n        m2Client.enqueue(textResult(\"formatted from facts\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"dependency merged\"));\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(newAgent(\"planner\", plannerClient))\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"collector\").agent(newAgent(\"m1\", m1Client)).build())\n                .member(AgentTeamMember.builder().id(\"m2\").name(\"formatter\").agent(newAgent(\"m2\", m2Client)).build())\n                .options(AgentTeamOptions.builder().parallelDispatch(true).maxConcurrency(2).build())\n                .build();\n\n        AgentTeamResult result = team.run(\"build a weather brief\");\n\n        Assert.assertEquals(\"dependency merged\", result.getOutput());\n        Assert.assertEquals(2, result.getRounds());\n        Assert.assertEquals(2, result.getTaskStates().size());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(0).getStatus());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(1).getStatus());\n        Assert.assertTrue(result.getMessages().size() >= 4);\n    }\n\n    @Test\n    public void test_team_plan_approval_rejected() throws Exception {\n        ScriptedModelClient plannerClient = new ScriptedModelClient();\n        plannerClient.enqueue(textResult(\"{\\\"tasks\\\":[{\\\"memberId\\\":\\\"m1\\\",\\\"task\\\":\\\"do task\\\"}]}\"));\n\n        ScriptedModelClient m1Client = new ScriptedModelClient();\n        m1Client.enqueue(textResult(\"m1 output\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"unused\"));\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(newAgent(\"planner\", plannerClient))\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"m1\").agent(newAgent(\"m1\", m1Client)).build())\n                .options(AgentTeamOptions.builder().requirePlanApproval(true).build())\n                .planApproval((objective, plan, members, options) -> false)\n                .build();\n\n        try {\n            team.run(\"task\");\n            Assert.fail(\"expected plan approval rejection\");\n        } catch (IllegalStateException expected) {\n            Assert.assertTrue(expected.getMessage().contains(\"plan rejected\"));\n        }\n    }\n\n    @Test\n    public void test_team_dynamic_member_registration_and_hooks() throws Exception {\n        ScriptedModelClient plannerClient = new ScriptedModelClient();\n        plannerClient.enqueue(textResult(\"{\\\"tasks\\\":[{\\\"memberId\\\":\\\"m2\\\",\\\"task\\\":\\\"take over task\\\"}]}\"));\n\n        ScriptedModelClient m1Client = new ScriptedModelClient();\n        m1Client.enqueue(textResult(\"m1 idle\"));\n\n        ScriptedModelClient m2Client = new ScriptedModelClient();\n        m2Client.enqueue(textResult(\"m2 handled\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"dynamic merged\"));\n\n        AtomicInteger beforeTaskCount = new AtomicInteger();\n        AtomicInteger afterTaskCount = new AtomicInteger();\n        AtomicInteger messageCount = new AtomicInteger();\n\n        AgentTeamHook hook = new AgentTeamHook() {\n            @Override\n            public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) {\n                beforeTaskCount.incrementAndGet();\n            }\n\n            @Override\n            public void afterTask(String objective, AgentTeamMemberResult result) {\n                afterTaskCount.incrementAndGet();\n            }\n\n            @Override\n            public void onMessage(AgentTeamMessage message) {\n                messageCount.incrementAndGet();\n            }\n        };\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(newAgent(\"planner\", plannerClient))\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"m1\").agent(newAgent(\"m1\", m1Client)).build())\n                .hook(hook)\n                .build();\n\n        team.registerMember(AgentTeamMember.builder().id(\"m2\").name(\"m2\").agent(newAgent(\"m2\", m2Client)).build());\n        Assert.assertEquals(2, team.listMembers().size());\n\n        AgentTeamResult result = team.run(\"delegate to m2\");\n\n        Assert.assertEquals(\"dynamic merged\", result.getOutput());\n        Assert.assertEquals(\"m2\", result.getMemberResults().get(0).getMemberId());\n        Assert.assertTrue(beforeTaskCount.get() >= 1);\n        Assert.assertTrue(afterTaskCount.get() >= 1);\n        Assert.assertTrue(messageCount.get() >= 1);\n\n        Assert.assertTrue(team.unregisterMember(\"m2\"));\n        Assert.assertEquals(1, team.listMembers().size());\n    }\n\n\n    @Test\n    public void test_team_message_controls_direct_and_broadcast() {\n        ScriptedModelClient sharedClient = new ScriptedModelClient();\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(newAgent(\"planner\", sharedClient))\n                .synthesizerAgent(newAgent(\"synth\", sharedClient))\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"m1\").agent(newAgent(\"m1\", sharedClient)).build())\n                .member(AgentTeamMember.builder().id(\"m2\").name(\"m2\").agent(newAgent(\"m2\", sharedClient)).build())\n                .build();\n\n        team.sendMessage(\"m1\", \"m2\", \"peer.ask\", \"task-1\", \"need your evidence\");\n        team.broadcastMessage(\"m2\", \"peer.broadcast\", null, \"shared update\");\n\n        Assert.assertEquals(2, team.listMessages().size());\n        Assert.assertEquals(2, team.listMessagesFor(\"m2\", 10).size());\n        Assert.assertEquals(1, team.listMessagesFor(\"m1\", 10).size());\n    }\n\n    @Test\n    public void test_team_list_task_states_after_run() throws Exception {\n        ScriptedModelClient plannerClient = new ScriptedModelClient();\n        plannerClient.enqueue(textResult(\"{\\\"tasks\\\":[{\\\"id\\\":\\\"collect\\\",\\\"memberId\\\":\\\"m1\\\",\\\"task\\\":\\\"collect\\\"}]}\"));\n\n        ScriptedModelClient memberClient = new ScriptedModelClient();\n        memberClient.enqueue(textResult(\"collected\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"done\"));\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(newAgent(\"planner\", plannerClient))\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"m1\").agent(newAgent(\"m1\", memberClient)).build())\n                .build();\n\n        AgentTeamResult result = team.run(\"run one task\");\n\n        Assert.assertEquals(\"done\", result.getOutput());\n        List<io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState> states = team.listTaskStates();\n        Assert.assertEquals(1, states.size());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, states.get(0).getStatus());\n    }\n\n    @Test\n    public void test_team_member_tools_can_message_and_heartbeat() throws Exception {\n        ScriptedModelClient plannerClient = new ScriptedModelClient();\n        plannerClient.enqueue(textResult(\"{\\\"tasks\\\":[{\\\"id\\\":\\\"task_1\\\",\\\"memberId\\\":\\\"m1\\\",\\\"task\\\":\\\"collect and notify\\\"}]}\"));\n\n        ScriptedModelClient m1Client = new ScriptedModelClient();\n        m1Client.enqueue(toolCallResult(Arrays.asList(\n                toolCall(\"team_send_message\", \"{\\\"toMemberId\\\":\\\"m2\\\",\\\"type\\\":\\\"peer.ask\\\",\\\"taskId\\\":\\\"task_1\\\",\\\"content\\\":\\\"Please share your notes\\\"}\"),\n                toolCall(\"team_broadcast\", \"{\\\"type\\\":\\\"peer.broadcast\\\",\\\"content\\\":\\\"task_1 is running\\\"}\"),\n                toolCall(\"team_heartbeat_task\", \"{\\\"taskId\\\":\\\"task_1\\\"}\"),\n                toolCall(\"team_list_tasks\", \"{}\")\n        )));\n        m1Client.enqueue(textResult(\"m1 finished\"));\n\n        ScriptedModelClient m2Client = new ScriptedModelClient();\n        m2Client.enqueue(textResult(\"m2 standby\"));\n\n        ScriptedModelClient synthClient = new ScriptedModelClient();\n        synthClient.enqueue(textResult(\"team final\"));\n\n        AgentTeam team = Agents.team()\n                .plannerAgent(newAgent(\"planner\", plannerClient))\n                .synthesizerAgent(newAgent(\"synth\", synthClient))\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"member-1\").agent(newAgent(\"m1\", m1Client)).build())\n                .member(AgentTeamMember.builder().id(\"m2\").name(\"member-2\").agent(newAgent(\"m2\", m2Client)).build())\n                .build();\n\n        AgentTeamResult result = team.run(\"execute team tools\");\n\n        Assert.assertEquals(\"team final\", result.getOutput());\n        Assert.assertTrue(hasToolNamed(m1Client.prompts.get(0).getTools(), \"team_send_message\"));\n        Assert.assertTrue(hasToolNamed(m1Client.prompts.get(0).getTools(), \"team_broadcast\"));\n        Assert.assertTrue(hasToolNamed(m1Client.prompts.get(0).getTools(), \"team_heartbeat_task\"));\n\n        boolean hasDirect = false;\n        boolean hasBroadcast = false;\n        for (AgentTeamMessage message : result.getMessages()) {\n            if (\"peer.ask\".equals(message.getType())\n                    && \"m1\".equals(message.getFromMemberId())\n                    && \"m2\".equals(message.getToMemberId())) {\n                hasDirect = true;\n            }\n            if (\"peer.broadcast\".equals(message.getType())\n                    && \"m1\".equals(message.getFromMemberId())\n                    && \"*\".equals(message.getToMemberId())) {\n                hasBroadcast = true;\n            }\n        }\n        Assert.assertTrue(hasDirect);\n        Assert.assertTrue(hasBroadcast);\n    }\n\n    @Test\n    public void test_team_single_lead_agent_defaults_planner_and_synthesizer() throws Exception {\n        ScriptedModelClient leadClient = new ScriptedModelClient();\n        leadClient.enqueue(textResult(\"{\\\"tasks\\\":[{\\\"id\\\":\\\"collect\\\",\\\"memberId\\\":\\\"m1\\\",\\\"task\\\":\\\"collect weather facts\\\"}]}\"));\n        leadClient.enqueue(textResult(\"lead merged output\"));\n\n        ScriptedModelClient memberClient = new ScriptedModelClient();\n        memberClient.enqueue(textResult(\"facts done\"));\n\n        AgentTeam team = Agents.team()\n                .leadAgent(newAgent(\"lead\", leadClient))\n                .member(AgentTeamMember.builder().id(\"m1\").name(\"m1\").agent(newAgent(\"m1\", memberClient)).build())\n                .build();\n\n        AgentTeamResult result = team.run(\"build summary with lead agent\");\n\n        Assert.assertEquals(\"lead merged output\", result.getOutput());\n        Assert.assertEquals(2, leadClient.prompts.size());\n        Assert.assertTrue(leadClient.prompts.get(0).getItems().toString().contains(\"You are a team planner.\"));\n        Assert.assertTrue(leadClient.prompts.get(1).getItems().toString().contains(\"You are the team lead.\"));\n    }\n\n    private static Agent newAgent(String model, AgentModelClient client) {\n        return Agents.react()\n                .modelClient(client)\n                .model(model)\n                .build();\n    }\n\n    private static AgentModelResult textResult(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .memoryItems(new ArrayList<Object>())\n                .toolCalls(new ArrayList<io.github.lnyocly.ai4j.agent.tool.AgentToolCall>())\n                .build();\n    }\n\n    private static AgentModelResult toolCallResult(List<AgentToolCall> calls) {\n        return AgentModelResult.builder()\n                .outputText(\"\")\n                .memoryItems(new ArrayList<Object>())\n                .toolCalls(calls)\n                .build();\n    }\n\n    private static AgentToolCall toolCall(String name, String arguments) {\n        return AgentToolCall.builder()\n                .name(name)\n                .arguments(arguments)\n                .callId(name + \"_\" + System.nanoTime())\n                .type(\"function\")\n                .build();\n    }\n\n    private static boolean hasToolNamed(List<Object> tools, String name) {\n        if (tools == null || tools.isEmpty() || name == null) {\n            return false;\n        }\n        for (Object toolObj : tools) {\n            if (!(toolObj instanceof Tool)) {\n                continue;\n            }\n            Tool tool = (Tool) toolObj;\n            if (tool.getFunction() != null && name.equals(tool.getFunction().getName())) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private static class ScriptedModelClient implements AgentModelClient {\n        private final Deque<AgentModelResult> queue = new ArrayDeque<>();\n        private final List<AgentPrompt> prompts = new ArrayList<>();\n\n        private void enqueue(AgentModelResult result) {\n            queue.add(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            prompts.add(prompt);\n            return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n\n    private static class ConcurrentModelClient implements AgentModelClient {\n        private final long sleepMs;\n        private final String output;\n        private final AtomicInteger active = new AtomicInteger();\n        private final AtomicInteger maxConcurrent = new AtomicInteger();\n\n        private ConcurrentModelClient(long sleepMs, String output) {\n            this.sleepMs = sleepMs;\n            this.output = output;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            int concurrent = active.incrementAndGet();\n            maxConcurrent.accumulateAndGet(concurrent, Math::max);\n            try {\n                Thread.sleep(sleepMs);\n            } catch (InterruptedException e) {\n                Thread.currentThread().interrupt();\n            } finally {\n                active.decrementAndGet();\n            }\n            return textResult(output);\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTeamUsageTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.agent.support.ZhipuAgentTestSupport;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamHook;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamOptions;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class AgentTeamUsageTest extends ZhipuAgentTestSupport {\n\n    @Test\n    public void test_team_with_deterministic_plan_and_real_members() throws Exception {\n        Agent analyst = createRoleAgent(\n                \"你是需求分析师\",\n                \"输出需求拆解、风险点、验收口径，控制在5条以内。\"\n        );\n\n        Agent backend = createRoleAgent(\n                \"你是后端工程师\",\n                \"输出接口与数据模型建议，强调可上线性。\"\n        );\n\n        Agent lead = createRoleAgent(\n                \"你是技术负责人\",\n                \"把成员结果合并成最终执行计划，按模块分段输出。\"\n        );\n\n        AtomicInteger beforeTaskCount = new AtomicInteger();\n        AtomicInteger afterTaskCount = new AtomicInteger();\n\n        AgentTeam team = Agents.team()\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .rawPlanText(\"fixed-plan\")\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder().id(\"t1\").memberId(\"analyst\").task(\"拆解目标与风险\").build(),\n                                AgentTeamTask.builder().id(\"t2\").memberId(\"backend\").task(\"给出后端实施方案\").dependsOn(Arrays.asList(\"t1\")).build()\n                        ))\n                        .build())\n                .synthesizerAgent(lead)\n                .member(AgentTeamMember.builder().id(\"analyst\").name(\"Analyst\").description(\"需求分析\").agent(analyst).build())\n                .member(AgentTeamMember.builder().id(\"backend\").name(\"Backend\").description(\"后端设计\").agent(backend).build())\n                .options(AgentTeamOptions.builder()\n                        .parallelDispatch(true)\n                        .maxConcurrency(2)\n                        .enableMessageBus(true)\n                        .enableMemberTeamTools(false)\n                        .includeMessageHistoryInDispatch(true)\n                        .requirePlanApproval(true)\n                        .maxRounds(8)\n                        .build())\n                .planApproval((objective, plan, members, options) -> plan != null && plan.getTasks() != null && !plan.getTasks().isEmpty())\n                .hook(new AgentTeamHook() {\n                    @Override\n                    public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) {\n                        beforeTaskCount.incrementAndGet();\n                    }\n\n                    @Override\n                    public void afterTask(String objective, AgentTeamMemberResult result) {\n                        afterTaskCount.incrementAndGet();\n                    }\n                })\n                .build();\n\n        AgentTeamResult result = runTeamWithRetry(team, \"给一个中型 Java 项目的一周迭代交付方案\", 3);\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutput());\n        Assert.assertTrue(result.getOutput().trim().length() > 0);\n        Assert.assertEquals(2, result.getTaskStates().size());\n        Assert.assertTrue(\"任务未全部完成: \" + describeTaskStates(result.getTaskStates()), allTasksCompleted(result.getTaskStates()));\n        Assert.assertTrue(beforeTaskCount.get() >= 2);\n        Assert.assertTrue(afterTaskCount.get() >= 2);\n    }\n\n    @Test\n    public void test_team_dynamic_member_and_message_bus() throws Exception {\n        Agent lead = createRoleAgent(\n                \"你是团队负责人\",\n                \"先规划任务再总结输出，内容简洁。\"\n        );\n\n        Agent writer = createRoleAgent(\n                \"你是文档工程师\",\n                \"产出发布说明草稿。\"\n        );\n\n        Agent reviewer = createRoleAgent(\n                \"你是评审工程师\",\n                \"产出评审意见。\"\n        );\n\n        AgentTeam team = Agents.team()\n                .leadAgent(lead)\n                .member(AgentTeamMember.builder().id(\"writer\").name(\"Writer\").agent(writer).build())\n                .options(AgentTeamOptions.builder()\n                        .allowDynamicMemberRegistration(true)\n                        .enableMessageBus(true)\n                        .enableMemberTeamTools(false)\n                        .maxRounds(8)\n                        .build())\n                .build();\n\n        team.registerMember(AgentTeamMember.builder().id(\"reviewer\").name(\"Reviewer\").agent(reviewer).build());\n\n        AgentTeamResult result = callWithProviderGuard(() -> team.run(\"整理本周发布说明并补充评审意见\"));\n        team.sendMessage(\"writer\", \"reviewer\", \"peer.ask\", \"task-seed\", \"请重点关注回滚策略\");\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutput());\n        Assert.assertTrue(result.getOutput().trim().length() > 0);\n        Assert.assertTrue(result.getMemberResults().size() > 0);\n        Assert.assertTrue(team.listMembers().size() >= 2);\n        Assert.assertTrue(result.getMessages().size() > 0);\n\n        boolean hasPeerAsk = false;\n        for (AgentTeamMessage message : team.listMessages()) {\n            if (\"peer.ask\".equals(message.getType())) {\n                hasPeerAsk = true;\n                break;\n            }\n        }\n        Assert.assertTrue(hasPeerAsk);\n\n        Assert.assertTrue(team.unregisterMember(\"reviewer\"));\n    }\n\n    private Agent createRoleAgent(String systemPrompt, String instructions) {\n        return Agents.builder()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(systemPrompt)\n                .instructions(instructions)\n                .options(AgentOptions.builder().maxSteps(3).build())\n                .build();\n    }\n\n    private AgentTeamResult runTeamWithRetry(AgentTeam team, String objective, int maxAttempts) throws Exception {\n        AgentTeamResult lastResult = null;\n        for (int i = 0; i < maxAttempts; i++) {\n            lastResult = callWithProviderGuard(() -> team.run(objective));\n            if (lastResult != null && allTasksCompleted(lastResult.getTaskStates())) {\n                return lastResult;\n            }\n        }\n        return lastResult;\n    }\n\n    private boolean allTasksCompleted(List<AgentTeamTaskState> states) {\n        if (states == null || states.isEmpty()) {\n            return false;\n        }\n        for (AgentTeamTaskState state : states) {\n            if (state == null || state.getStatus() != AgentTeamTaskStatus.COMPLETED) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n    private String describeTaskStates(List<AgentTeamTaskState> states) {\n        if (states == null || states.isEmpty()) {\n            return \"[]\";\n        }\n        StringBuilder sb = new StringBuilder(\"[\");\n        for (int i = 0; i < states.size(); i++) {\n            AgentTeamTaskState state = states.get(i);\n            if (i > 0) {\n                sb.append(\", \");\n            }\n            if (state == null) {\n                sb.append(\"null\");\n                continue;\n            }\n            sb.append(state.getTaskId()).append(\":\").append(state.getStatus());\n            if (state.getError() != null && !state.getError().isEmpty()) {\n                sb.append(\"(error=\").append(state.getError()).append(\")\");\n            }\n        }\n        sb.append(\"]\");\n        return sb.toString();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTraceListenerTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.agent.trace.AgentTraceListener;\nimport io.github.lnyocly.ai4j.agent.trace.InMemoryTraceExporter;\nimport io.github.lnyocly.ai4j.agent.trace.TraceConfig;\nimport io.github.lnyocly.ai4j.agent.trace.TracePricing;\nimport io.github.lnyocly.ai4j.agent.trace.TraceSpan;\nimport io.github.lnyocly.ai4j.agent.trace.TraceSpanType;\nimport io.github.lnyocly.ai4j.agent.trace.TraceSpanStatus;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class AgentTraceListenerTest {\n\n    @Test\n    public void test_trace_listener_collects_spans() {\n        InMemoryTraceExporter exporter = new InMemoryTraceExporter();\n        AgentTraceListener listener = new AgentTraceListener(exporter);\n\n        listener.onEvent(event(AgentEventType.STEP_START, 0, null, null));\n        listener.onEvent(event(AgentEventType.MODEL_REQUEST, 0, null, AgentPrompt.builder().model(\"test-model\").build()));\n        listener.onEvent(event(AgentEventType.MODEL_RESPONSE, 0, null, new Object()));\n\n        AgentToolCall call = AgentToolCall.builder()\n                .name(\"mockTool\")\n                .callId(\"tool_1\")\n                .arguments(\"{}\")\n                .build();\n        listener.onEvent(event(AgentEventType.TOOL_CALL, 0, call.getName(), call));\n        AgentToolResult result = AgentToolResult.builder()\n                .name(\"mockTool\")\n                .callId(\"tool_1\")\n                .output(\"ok\")\n                .build();\n        listener.onEvent(event(AgentEventType.TOOL_RESULT, 0, \"ok\", result));\n\n        listener.onEvent(event(AgentEventType.FINAL_OUTPUT, 0, \"done\", null));\n        listener.onEvent(event(AgentEventType.STEP_END, 0, null, null));\n\n        List<TraceSpan> spans = exporter.getSpans();\n        Assert.assertFalse(spans.isEmpty());\n        Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.RUN));\n        Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.STEP));\n        Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.MODEL));\n        Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.TOOL));\n    }\n\n    @Test\n    public void test_trace_listener_captures_reasoning_handoff_team_and_memory_events() {\n        InMemoryTraceExporter exporter = new InMemoryTraceExporter();\n        AgentTraceListener listener = new AgentTraceListener(exporter);\n\n        listener.onEvent(event(AgentEventType.STEP_START, 0, null, null));\n        listener.onEvent(event(AgentEventType.MODEL_REQUEST, 0, null, AgentPrompt.builder().model(\"test-model\").build()));\n        listener.onEvent(event(AgentEventType.MODEL_REASONING, 0, \"thinking\", null));\n        listener.onEvent(event(AgentEventType.MODEL_RETRY, 0, \"retry once\", retryPayload()));\n        listener.onEvent(event(AgentEventType.MODEL_RESPONSE, 0, null, \"raw-response\"));\n\n        AgentToolCall call = AgentToolCall.builder()\n                .name(\"delegate\")\n                .callId(\"tool_1\")\n                .arguments(\"{}\")\n                .build();\n        listener.onEvent(event(AgentEventType.TOOL_CALL, 0, call.getName(), call));\n        listener.onEvent(event(AgentEventType.HANDOFF_START, 0, \"handoff\", handoffPayload(\"starting\", null)));\n        listener.onEvent(event(AgentEventType.HANDOFF_END, 0, \"handoff\", handoffPayload(\"completed\", null)));\n        listener.onEvent(event(AgentEventType.TOOL_RESULT, 0, \"ok\", AgentToolResult.builder()\n                .name(\"delegate\")\n                .callId(\"tool_1\")\n                .output(\"ok\")\n                .build()));\n\n        listener.onEvent(event(AgentEventType.TEAM_TASK_CREATED, 0, \"team-created\", teamPayload(\"planned\", null)));\n        listener.onEvent(event(AgentEventType.TEAM_MESSAGE, 0, \"team-message\", messagePayload()));\n        listener.onEvent(event(AgentEventType.TEAM_TASK_UPDATED, 0, \"team-updated\", teamPayload(\"completed\", null)));\n        listener.onEvent(event(AgentEventType.MEMORY_COMPRESS, 0, \"compact\", compactPayload()));\n        listener.onEvent(event(AgentEventType.FINAL_OUTPUT, 0, \"done\", null));\n        listener.onEvent(event(AgentEventType.STEP_END, 0, null, null));\n\n        List<TraceSpan> spans = exporter.getSpans();\n        TraceSpan modelSpan = findSpan(spans, TraceSpanType.MODEL);\n        TraceSpan handoffSpan = findSpan(spans, TraceSpanType.HANDOFF);\n        TraceSpan teamSpan = findSpan(spans, TraceSpanType.TEAM_TASK);\n        TraceSpan memorySpan = findSpan(spans, TraceSpanType.MEMORY);\n\n        Assert.assertNotNull(modelSpan);\n        Assert.assertNotNull(handoffSpan);\n        Assert.assertNotNull(teamSpan);\n        Assert.assertNotNull(memorySpan);\n        Assert.assertEquals(TraceSpanStatus.OK, handoffSpan.getStatus());\n        Assert.assertTrue(modelSpan.getEvents().stream().anyMatch(event -> \"model.reasoning\".equals(event.getName())));\n        Assert.assertTrue(modelSpan.getEvents().stream().anyMatch(event -> \"model.retry\".equals(event.getName())));\n        Assert.assertTrue(teamSpan.getEvents().stream().anyMatch(event -> \"team.message\".equals(event.getName())));\n        Assert.assertEquals(\"summary-1\", memorySpan.getAttributes().get(\"summaryId\"));\n    }\n\n    @Test\n    public void test_trace_listener_captures_model_usage_cost_and_duration_metrics() {\n        InMemoryTraceExporter exporter = new InMemoryTraceExporter();\n        TraceConfig config = TraceConfig.builder()\n                .pricingResolver(model -> TracePricing.builder()\n                        .inputCostPerMillionTokens(2.0D)\n                        .outputCostPerMillionTokens(8.0D)\n                        .currency(\"USD\")\n                        .build())\n                .build();\n        AgentTraceListener listener = new AgentTraceListener(exporter, config);\n\n        listener.onEvent(event(AgentEventType.STEP_START, 0, null, null));\n        listener.onEvent(event(AgentEventType.MODEL_REQUEST, 0, null, AgentPrompt.builder()\n                .model(\"glm-4.7\")\n                .temperature(0.3D)\n                .build()));\n        listener.onEvent(event(AgentEventType.MODEL_RESPONSE, 0, null, modelResponsePayload()));\n        listener.onEvent(event(AgentEventType.FINAL_OUTPUT, 0, \"done\", null));\n        listener.onEvent(event(AgentEventType.STEP_END, 0, null, null));\n\n        TraceSpan modelSpan = findSpan(exporter.getSpans(), TraceSpanType.MODEL);\n        TraceSpan runSpan = findSpan(exporter.getSpans(), TraceSpanType.RUN);\n\n        Assert.assertNotNull(modelSpan);\n        Assert.assertNotNull(modelSpan.getMetrics());\n        Assert.assertEquals(Long.valueOf(120L), modelSpan.getMetrics().getPromptTokens());\n        Assert.assertEquals(Long.valueOf(45L), modelSpan.getMetrics().getCompletionTokens());\n        Assert.assertEquals(Long.valueOf(165L), modelSpan.getMetrics().getTotalTokens());\n        Assert.assertEquals(\"USD\", modelSpan.getMetrics().getCurrency());\n        Assert.assertEquals(\"glm-4.7\", modelSpan.getAttributes().get(\"responseModel\"));\n        Assert.assertEquals(\"stop\", modelSpan.getAttributes().get(\"finishReason\"));\n        Assert.assertNotNull(modelSpan.getMetrics().getDurationMillis());\n        Assert.assertTrue(modelSpan.getMetrics().getDurationMillis() >= 0L);\n        Assert.assertNotNull(modelSpan.getMetrics().getTotalCost());\n        Assert.assertEquals(0.00024D, modelSpan.getMetrics().getInputCost(), 0.0000001D);\n        Assert.assertEquals(0.00036D, modelSpan.getMetrics().getOutputCost(), 0.0000001D);\n        Assert.assertEquals(0.0006D, modelSpan.getMetrics().getTotalCost(), 0.0000001D);\n\n        Assert.assertNotNull(runSpan);\n        Assert.assertNotNull(runSpan.getMetrics());\n        Assert.assertEquals(Long.valueOf(165L), runSpan.getMetrics().getTotalTokens());\n        Assert.assertEquals(0.0006D, runSpan.getMetrics().getTotalCost(), 0.0000001D);\n    }\n\n    private TraceSpan findSpan(List<TraceSpan> spans, TraceSpanType type) {\n        for (TraceSpan span : spans) {\n            if (span != null && span.getType() == type) {\n                return span;\n            }\n        }\n        return null;\n    }\n\n    private Map<String, Object> retryPayload() {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"attempt\", Integer.valueOf(2));\n        payload.put(\"maxAttempts\", Integer.valueOf(3));\n        payload.put(\"reason\", \"network\");\n        return payload;\n    }\n\n    private Map<String, Object> handoffPayload(String status, String error) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"handoffId\", \"handoff:tool_1\");\n        payload.put(\"callId\", \"tool_1\");\n        payload.put(\"tool\", \"delegate\");\n        payload.put(\"subagent\", \"coder\");\n        payload.put(\"status\", status);\n        payload.put(\"error\", error);\n        return payload;\n    }\n\n    private Map<String, Object> teamPayload(String status, String error) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"taskId\", \"task-1\");\n        payload.put(\"status\", status);\n        payload.put(\"detail\", \"working\");\n        payload.put(\"error\", error);\n        return payload;\n    }\n\n    private Map<String, Object> messagePayload() {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"taskId\", \"task-1\");\n        payload.put(\"content\", \"sync\");\n        return payload;\n    }\n\n    private Map<String, Object> compactPayload() {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"summaryId\", \"summary-1\");\n        payload.put(\"reason\", \"token-limit\");\n        return payload;\n    }\n\n    private Map<String, Object> modelResponsePayload() {\n        Map<String, Object> usage = new LinkedHashMap<String, Object>();\n        usage.put(\"prompt_tokens\", 120L);\n        usage.put(\"completion_tokens\", 45L);\n        usage.put(\"total_tokens\", 165L);\n\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"id\", \"resp_1\");\n        payload.put(\"model\", \"glm-4.7\");\n        payload.put(\"finishReason\", \"stop\");\n        payload.put(\"usage\", usage);\n        payload.put(\"outputText\", \"hello\");\n        return payload;\n    }\n\n    private AgentEvent event(AgentEventType type, Integer step, String message, Object payload) {\n        return AgentEvent.builder()\n                .type(type)\n                .step(step)\n                .message(message)\n                .payload(payload)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentTraceUsageTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.agent.support.ZhipuAgentTestSupport;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.trace.InMemoryTraceExporter;\nimport io.github.lnyocly.ai4j.agent.trace.TraceConfig;\nimport io.github.lnyocly.ai4j.agent.trace.TraceSpan;\nimport io.github.lnyocly.ai4j.agent.trace.TraceSpanType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.List;\n\npublic class AgentTraceUsageTest extends ZhipuAgentTestSupport {\n\n    @Test\n    public void test_trace_for_react_runtime_with_real_model() throws Exception {\n        // Trace 基础能力：RUN/STEP/MODEL span 能被正确记录\n        InMemoryTraceExporter exporter = new InMemoryTraceExporter();\n\n        Agent agent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是技术总结助手，输出一句简洁结论。\")\n                .traceExporter(exporter)\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        callWithProviderGuard(() -> {\n            agent.run(AgentRequest.builder().input(\"说明可观测性对线上稳定性的价值\").build());\n            return null;\n        });\n\n        List<TraceSpan> spans = exporter.getSpans();\n        Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.RUN));\n        Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.STEP));\n        Assert.assertTrue(spans.stream().anyMatch(span -> span.getType() == TraceSpanType.MODEL));\n    }\n\n    @Test\n    public void test_trace_mask_and_truncate_config() throws Exception {\n        // Trace 高级配置：字段脱敏 + 长字段截断\n        InMemoryTraceExporter exporter = new InMemoryTraceExporter();\n        TraceConfig config = TraceConfig.builder()\n                .maxFieldLength(24)\n                .masker(text -> text.replace(\"SECRET\", \"***\"))\n                .build();\n\n        Agent agent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"SECRET-token-should-be-masked-and-truncated\")\n                .traceExporter(exporter)\n                .traceConfig(config)\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        callWithProviderGuard(() -> {\n            agent.run(AgentRequest.builder().input(\"输出一句话说明可观测性价值\").build());\n            return null;\n        });\n\n        TraceSpan modelSpan = exporter.getSpans().stream()\n                .filter(span -> span.getType() == TraceSpanType.MODEL)\n                .findFirst()\n                .orElse(null);\n\n        Assert.assertNotNull(modelSpan);\n        Object systemPrompt = modelSpan.getAttributes().get(\"systemPrompt\");\n        Assert.assertNotNull(systemPrompt);\n        String text = String.valueOf(systemPrompt);\n        Assert.assertTrue(text.contains(\"***\"));\n        Assert.assertTrue(text.endsWith(\"...\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentWorkflowTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.workflow.AgentNode;\nimport io.github.lnyocly.ai4j.agent.workflow.SequentialWorkflow;\nimport io.github.lnyocly.ai4j.agent.workflow.WorkflowContext;\nimport org.junit.Assert;\nimport org.junit.Test;\n\npublic class AgentWorkflowTest {\n\n    @Test\n    public void test_sequential_workflow_passes_output() throws Exception {\n        SequentialWorkflow workflow = new SequentialWorkflow()\n                .addNode(new StaticNode(\"step1\"))\n                .addNode(new EchoNode());\n\n        AgentResult result = workflow.run(new AgentSession(null, null), AgentRequest.builder().input(\"start\").build());\n        Assert.assertEquals(\"echo:step1\", result.getOutputText());\n    }\n\n    private static class StaticNode implements AgentNode {\n        private final String output;\n\n        private StaticNode(String output) {\n            this.output = output;\n        }\n\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) {\n            return AgentResult.builder().outputText(output).build();\n        }\n    }\n\n    private static class EchoNode implements AgentNode {\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) {\n            return AgentResult.builder().outputText(\"echo:\" + request.getInput()).build();\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentWorkflowUsageTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.agent.support.ZhipuAgentTestSupport;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.workflow.RuntimeAgentNode;\nimport io.github.lnyocly.ai4j.agent.workflow.SequentialWorkflow;\nimport io.github.lnyocly.ai4j.agent.workflow.StateGraphWorkflow;\nimport io.github.lnyocly.ai4j.agent.workflow.WorkflowAgent;\nimport org.junit.Assert;\nimport org.junit.Test;\n\npublic class AgentWorkflowUsageTest extends ZhipuAgentTestSupport {\n\n    @Test\n    public void test_sequential_workflow_with_real_agents() throws Exception {\n        // 顺序编排：第一个节点产出草稿，第二个节点做结构化整理\n        Agent draftAgent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是方案起草助手。给出3条关键执行步骤。\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        Agent formatAgent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是格式化助手。把输入整理成编号清单。\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        SequentialWorkflow workflow = new SequentialWorkflow()\n                .addNode(new RuntimeAgentNode(draftAgent.newSession()))\n                .addNode(new RuntimeAgentNode(formatAgent.newSession()));\n\n        AgentResult result = callWithProviderGuard(() -> workflow.run(new AgentSession(null, null), AgentRequest.builder().input(\"生成一次数据库迁移发布流程\").build()));\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n    }\n\n    @Test\n    public void test_state_graph_workflow_with_router() throws Exception {\n        // 状态图编排：先路由，再进入不同节点，最后统一收敛\n        Agent routerAgent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是路由器。只输出 ROUTE_INCIDENT 或 ROUTE_GENERAL。\")\n                .instructions(\"输入含有故障、报错、事故时输出 ROUTE_INCIDENT，否则输出 ROUTE_GENERAL。\")\n                .options(AgentOptions.builder().maxSteps(1).build())\n                .build();\n\n        Agent incidentAgent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是应急响应助手。给出故障处理建议。\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        Agent generalAgent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是通用助手。给出普通建议。\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        Agent finalAgent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是总结助手。输出最终建议，保持简洁。\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        StateGraphWorkflow workflow = new StateGraphWorkflow()\n                .addNode(\"route\", new RuntimeAgentNode(routerAgent.newSession()))\n                .addNode(\"incident\", new RuntimeAgentNode(incidentAgent.newSession()))\n                .addNode(\"general\", new RuntimeAgentNode(generalAgent.newSession()))\n                .addNode(\"final\", new RuntimeAgentNode(finalAgent.newSession()))\n                .start(\"route\")\n                .addConditionalEdges(\"route\", (context, request, result) -> {\n                    String output = result == null ? \"\" : result.getOutputText();\n                    if (output != null && output.contains(\"ROUTE_INCIDENT\")) {\n                        return \"incident\";\n                    }\n                    return \"general\";\n                })\n                .addEdge(\"incident\", \"final\")\n                .addEdge(\"general\", \"final\");\n\n        WorkflowAgent workflowAgent = new WorkflowAgent(workflow, new AgentSession(null, null));\n        AgentResult result = callWithProviderGuard(() -> workflowAgent.run(AgentRequest.builder().input(\"线上发生接口报错，用户大量失败\").build()));\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/ChatModelClientTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatMessage;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.Choice;\nimport io.github.lnyocly.ai4j.platform.openai.tool.ToolCall;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport okhttp3.MediaType;\nimport okhttp3.Protocol;\nimport okhttp3.Request;\nimport okhttp3.Response;\nimport okhttp3.ResponseBody;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\n\npublic class ChatModelClientTest {\n\n    @Test\n    public void test_stream_preserves_reasoning_text_and_tool_calls() throws Exception {\n        FakeChatService chatService = new FakeChatService();\n        ChatModelClient client = new ChatModelClient(chatService);\n\n        List<String> reasoningDeltas = new ArrayList<>();\n        List<String> textDeltas = new ArrayList<>();\n\n        AgentModelResult result = client.createStream(AgentPrompt.builder()\n                        .model(\"glm-4.7\")\n                        .build(),\n                new AgentModelStreamListener() {\n                    @Override\n                    public void onReasoningDelta(String delta) {\n                        reasoningDeltas.add(delta);\n                    }\n\n                    @Override\n                    public void onDeltaText(String delta) {\n                        textDeltas.add(delta);\n                    }\n                });\n\n        Assert.assertEquals(Arrays.asList(\"Need a tool first.\"), reasoningDeltas);\n        Assert.assertEquals(Arrays.asList(\"I will get the current time.\"), textDeltas);\n        Assert.assertEquals(\"Need a tool first.\", result.getReasoningText());\n        Assert.assertEquals(\"I will get the current time.\", result.getOutputText());\n        Assert.assertEquals(1, result.getToolCalls().size());\n        Assert.assertEquals(\"get_current_time\", result.getToolCalls().get(0).getName());\n        Assert.assertEquals(\"{}\", result.getToolCalls().get(0).getArguments());\n        Assert.assertTrue(chatService.lastStreamRequest != null && Boolean.TRUE.equals(chatService.lastStreamRequest.getPassThroughToolCalls()));\n    }\n\n    @Test\n    public void test_stream_preserves_invalid_coding_tool_calls_for_runtime_validation() throws Exception {\n        FakeInvalidBashChatService chatService = new FakeInvalidBashChatService();\n        ChatModelClient client = new ChatModelClient(chatService);\n\n        AgentModelResult result = client.createStream(AgentPrompt.builder()\n                        .model(\"glm-4.7\")\n                        .build(),\n                new AgentModelStreamListener() {\n                });\n\n        Assert.assertEquals(\"I should inspect the local time.\", result.getOutputText());\n        Assert.assertEquals(1, result.getToolCalls().size());\n        Assert.assertEquals(\"bash\", result.getToolCalls().get(0).getName());\n    }\n\n    @Test\n    public void test_stream_aggregates_minimax_style_fragmented_tool_calls() throws Exception {\n        FakeFragmentedMiniMaxChatService chatService = new FakeFragmentedMiniMaxChatService();\n        ChatModelClient client = new ChatModelClient(chatService);\n\n        AgentModelResult result = client.createStream(AgentPrompt.builder()\n                        .model(\"MiniMax-M2.7\")\n                        .build(),\n                new AgentModelStreamListener() {\n                });\n\n        Assert.assertEquals(1, result.getToolCalls().size());\n        Assert.assertEquals(\"delegate_plan\", result.getToolCalls().get(0).getName());\n        Assert.assertEquals(\n                \"{\\\"task\\\": \\\"Create a short implementation plan for adding a hello endpoint demo app in this empty workspace.\\\"}\",\n                result.getToolCalls().get(0).getArguments()\n        );\n    }\n\n    @Test\n    public void test_stream_propagates_stream_execution_options() throws Exception {\n        FakeChatService chatService = new FakeChatService();\n        ChatModelClient client = new ChatModelClient(chatService);\n\n        client.createStream(AgentPrompt.builder()\n                        .model(\"glm-4.7\")\n                        .streamExecution(StreamExecutionOptions.builder()\n                                .firstTokenTimeoutMs(1234L)\n                                .idleTimeoutMs(5678L)\n                                .maxRetries(2)\n                                .retryBackoffMs(90L)\n                                .build())\n                        .build(),\n                new AgentModelStreamListener() {\n                });\n\n        Assert.assertNotNull(chatService.lastStreamRequest);\n        Assert.assertNotNull(chatService.lastStreamRequest.getStreamExecution());\n        Assert.assertEquals(1234L, chatService.lastStreamRequest.getStreamExecution().getFirstTokenTimeoutMs());\n        Assert.assertEquals(5678L, chatService.lastStreamRequest.getStreamExecution().getIdleTimeoutMs());\n        Assert.assertEquals(2, chatService.lastStreamRequest.getStreamExecution().getMaxRetries());\n        Assert.assertEquals(90L, chatService.lastStreamRequest.getStreamExecution().getRetryBackoffMs());\n    }\n\n    @Test\n    public void test_stream_surfaces_http_error_payload_when_sse_failure_has_no_throwable() throws Exception {\n        FakeStreamingErrorChatService chatService = new FakeStreamingErrorChatService();\n        ChatModelClient client = new ChatModelClient(chatService);\n        final List<String> errors = new ArrayList<String>();\n\n        AgentModelResult result = client.createStream(AgentPrompt.builder()\n                        .model(\"glm-4.7\")\n                        .build(),\n                new AgentModelStreamListener() {\n                    @Override\n                    public void onError(Throwable t) {\n                        errors.add(t == null ? null : t.getMessage());\n                    }\n                });\n\n        Assert.assertEquals(\"\", result.getOutputText());\n        Assert.assertEquals(1, errors.size());\n        Assert.assertEquals(\"Invalid API key provided.\", errors.get(0));\n    }\n\n    @Test\n    public void test_create_stores_assistant_tool_calls_in_memory_items() throws Exception {\n        CapturingChatService chatService = new CapturingChatService();\n        chatService.responseMessage = ChatMessage.withAssistant(\n                \"I should inspect the workspace.\",\n                Collections.singletonList(new ToolCall(\n                        \"call_1\",\n                        \"function\",\n                        new ToolCall.Function(\"bash\", \"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"dir\\\"}\")\n                ))\n        );\n        ChatModelClient client = new ChatModelClient(chatService);\n\n        AgentModelResult result = client.create(AgentPrompt.builder()\n                .model(\"glm-4.7\")\n                .build());\n\n        Assert.assertEquals(1, result.getMemoryItems().size());\n        Assert.assertTrue(result.getMemoryItems().get(0) instanceof Map);\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> item = (Map<String, Object>) result.getMemoryItems().get(0);\n        Assert.assertEquals(\"message\", item.get(\"type\"));\n        Assert.assertEquals(\"assistant\", item.get(\"role\"));\n        Assert.assertTrue(item.containsKey(\"tool_calls\"));\n    }\n\n    @Test\n    public void test_create_rehydrates_assistant_tool_calls_before_tool_output() throws Exception {\n        CapturingChatService chatService = new CapturingChatService();\n        ChatModelClient client = new ChatModelClient(chatService);\n        List<AgentToolCall> toolCalls = Collections.singletonList(AgentToolCall.builder()\n                .callId(\"call_1\")\n                .name(\"bash\")\n                .type(\"function\")\n                .arguments(\"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"dir\\\"}\")\n                .build());\n\n        client.create(AgentPrompt.builder()\n                .model(\"glm-4.7\")\n                .items(Arrays.<Object>asList(\n                        AgentInputItem.userMessage(\"inspect the workspace\"),\n                        AgentInputItem.assistantToolCallsMessage(\"I will inspect the workspace.\", toolCalls),\n                        AgentInputItem.functionCallOutput(\"call_1\", \"{\\\"exitCode\\\":0}\")\n                ))\n                .build());\n\n        Assert.assertNotNull(chatService.lastRequest);\n        Assert.assertNotNull(chatService.lastRequest.getMessages());\n        Assert.assertEquals(3, chatService.lastRequest.getMessages().size());\n\n        ChatMessage assistantMessage = chatService.lastRequest.getMessages().get(1);\n        Assert.assertEquals(\"assistant\", assistantMessage.getRole());\n        Assert.assertEquals(\"I will inspect the workspace.\", assistantMessage.getContent().getText());\n        Assert.assertNotNull(assistantMessage.getToolCalls());\n        Assert.assertEquals(1, assistantMessage.getToolCalls().size());\n        Assert.assertEquals(\"bash\", assistantMessage.getToolCalls().get(0).getFunction().getName());\n\n        ChatMessage toolMessage = chatService.lastRequest.getMessages().get(2);\n        Assert.assertEquals(\"tool\", toolMessage.getRole());\n        Assert.assertEquals(\"call_1\", toolMessage.getToolCallId());\n    }\n\n    private static class FakeChatService implements IChatService {\n        private ChatCompletion lastStreamRequest;\n\n        @Override\n        public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            lastStreamRequest = chatCompletion;\n            eventSourceListener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"reasoning_content\\\":\\\"Need a tool first.\\\"},\\\"finish_reason\\\":null}]}\");\n            eventSourceListener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"I will get the current time.\\\"},\\\"finish_reason\\\":null}]}\");\n            eventSourceListener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"id\\\":\\\"call_1\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"get_current_time\\\",\\\"arguments\\\":\\\"{}\\\"}}]},\\\"finish_reason\\\":\\\"tool_calls\\\"}]}\");\n            eventSourceListener.onEvent(null, null, null, \"[DONE]\");\n            eventSourceListener.onClosed(null);\n        }\n\n        @Override\n        public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n    }\n\n    private static class FakeInvalidBashChatService implements IChatService {\n        @Override\n        public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            eventSourceListener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"I should inspect the local time.\\\"},\\\"finish_reason\\\":null}]}\");\n            eventSourceListener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"tool_calls\\\":[{\\\"id\\\":\\\"call_1\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"bash\\\",\\\"arguments\\\":\\\"{\\\\\\\"action\\\\\\\":\\\\\\\"exec\\\\\\\"}\\\"}}]},\\\"finish_reason\\\":\\\"tool_calls\\\"}]}\");\n            eventSourceListener.onEvent(null, null, null, \"[DONE]\");\n            eventSourceListener.onClosed(null);\n        }\n\n        @Override\n        public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n    }\n\n    private static class FakeStreamingErrorChatService implements IChatService {\n        @Override\n        public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            Request request = new Request.Builder().url(\"https://example.com/chat\").build();\n            Response response = new Response.Builder()\n                    .request(request)\n                    .protocol(Protocol.HTTP_1_1)\n                    .code(401)\n                    .message(\"Unauthorized\")\n                    .body(ResponseBody.create(\n                            MediaType.get(\"application/json\"),\n                            \"{\\\"error\\\":{\\\"message\\\":\\\"Invalid API key provided.\\\"}}\"\n                    ))\n                    .build();\n            eventSourceListener.onFailure(null, null, response);\n        }\n\n        @Override\n        public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n    }\n\n    private static class CapturingChatService implements IChatService {\n        private ChatCompletion lastRequest;\n        private ChatMessage responseMessage;\n\n        @Override\n        public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) {\n            lastRequest = chatCompletion;\n            return response();\n        }\n\n        @Override\n        public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) {\n            lastRequest = chatCompletion;\n            return response();\n        }\n\n        @Override\n        public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        private ChatCompletionResponse response() {\n            if (responseMessage == null) {\n                return new ChatCompletionResponse();\n            }\n            Choice choice = new Choice();\n            choice.setMessage(responseMessage);\n            ChatCompletionResponse response = new ChatCompletionResponse();\n            response.setChoices(Collections.singletonList(choice));\n            return response;\n        }\n    }\n\n    private static class FakeFragmentedMiniMaxChatService implements IChatService {\n        @Override\n        public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            eventSourceListener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"\\\",\\\"tool_calls\\\":[{\\\"id\\\":\\\"call_1\\\",\\\"type\\\":\\\"function\\\",\\\"function\\\":{\\\"name\\\":\\\"delegate_plan\\\",\\\"arguments\\\":\\\"{\\\\\\\"task\\\\\\\": \\\\\\\"Create a short implementation plan\\\"}}]},\\\"finish_reason\\\":null}]}\");\n            eventSourceListener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"\\\",\\\"tool_calls\\\":[{\\\"function\\\":{\\\"arguments\\\":\\\" for adding a hello endpoint demo app in this empty workspace.\\\"}}]},\\\"finish_reason\\\":null}]}\");\n            eventSourceListener.onEvent(null, null, null,\n                    \"{\\\"choices\\\":[{\\\"delta\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"\\\",\\\"tool_calls\\\":[{\\\"function\\\":{\\\"arguments\\\":\\\"\\\\\\\"}\\\"}}]},\\\"finish_reason\\\":\\\"tool_calls\\\"}]}\");\n            eventSourceListener.onEvent(null, null, null, \"[DONE]\");\n            eventSourceListener.onClosed(null);\n        }\n\n        @Override\n        public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/CodeActAgentUsageTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.agent.support.ZhipuAgentTestSupport;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeActOptions;\nimport io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Test;\n\nimport java.net.SocketTimeoutException;\n\nimport javax.script.ScriptEngine;\nimport javax.script.ScriptEngineManager;\n\npublic class CodeActAgentUsageTest extends ZhipuAgentTestSupport {\n\n    @Test\n    public void test_codeact_basic_js_execution() throws Exception {\n        // CodeAct 在 JDK8 场景优先使用 Nashorn，保证 JavaScript 执行链路可用\n        Assume.assumeTrue(isNashornAvailable());\n\n        Agent agent = Agents.codeAct()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .maxOutputTokens(512)\n                .codeExecutor(new NashornCodeExecutor())\n                .systemPrompt(\"你是代码执行助手。只允许输出 JSON。优先使用 JavaScript 计算并返回最终结果。\")\n                .codeActOptions(CodeActOptions.builder().reAct(false).build())\n                .options(AgentOptions.builder().maxSteps(4).build())\n                .build();\n\n        AgentResult result = runWithRetry(agent, AgentRequest.builder().input(\"请用JavaScript计算 17*3+5 ，只返回最终数字\").build(), 2);\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n        Assert.assertFalse(result.getOutputText().contains(\"CODE_ERROR\"));\n    }\n\n    @Test\n    public void test_codeact_react_finalize_flow_without_external_tool() throws Exception {\n        // CodeAct ReAct 模式：先输出代码块，再进入 final 输出\n        Assume.assumeTrue(isNashornAvailable());\n\n        Agent agent = Agents.codeAct()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .maxOutputTokens(512)\n                .codeExecutor(new NashornCodeExecutor())\n                .systemPrompt(\"你是代码执行助手。先输出可执行 JavaScript，再输出最终结果。保持简短。\")\n                .instructions(\"用 JavaScript 计算 88 除以 11。\")\n                .codeActOptions(CodeActOptions.builder().reAct(true).build())\n                .options(AgentOptions.builder().maxSteps(4).build())\n                .build();\n\n        AgentResult result = runWithRetry(agent, AgentRequest.builder().input(\"请先写代码再返回最终结果，不要解释\").build(), 2);\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n        Assert.assertFalse(result.getOutputText().contains(\"CODE_ERROR\"));\n    }\n\n    private boolean isNashornAvailable() {\n        ScriptEngine engine = new ScriptEngineManager().getEngineByName(\"nashorn\");\n        return engine != null;\n    }\n\n    private AgentResult runWithRetry(Agent agent, AgentRequest request, int maxAttempts) throws Exception {\n        Exception last = null;\n        for (int i = 0; i < maxAttempts; i++) {\n            try {\n                return callWithProviderGuard(() -> agent.run(request));\n            } catch (Exception ex) {\n                last = ex;\n                if (i + 1 >= maxAttempts || !isRetryable(ex)) {\n                    throw ex;\n                }\n            }\n        }\n        throw last == null ? new RuntimeException(\"CodeAct run failed without exception detail\") : last;\n    }\n\n    private boolean isRetryable(Throwable throwable) {\n        Throwable current = throwable;\n        while (current != null) {\n            if (current instanceof SocketTimeoutException) {\n                return true;\n            }\n            String message = current.getMessage();\n            if (message != null && message.toLowerCase().contains(\"timeout\")) {\n                return true;\n            }\n            current = current.getCause();\n        }\n        return false;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/CodeActPythonExecutorTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionRequest;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.codeact.GraalVmCodeExecutor;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport org.graalvm.polyglot.Context;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Test;\n\npublic class CodeActPythonExecutorTest {\n\n    @Test\n    public void test_python_executor_with_tool() throws Exception {\n        Assume.assumeTrue(\"GraalPy is not available\", isGraalPyAvailable());\n\n        GraalVmCodeExecutor executor = new GraalVmCodeExecutor();\n        CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder()\n                .language(\"python\")\n                .code(\"result = callTool(\\\"echo\\\", {\\\"text\\\": \\\"hi\\\"})\\n__codeact_result = \\\"ok:\\\" + result\")\n                .toolExecutor(new ToolExecutor() {\n                    @Override\n                    public String execute(AgentToolCall call) {\n                        return call.getName() + \":\" + call.getArguments();\n                    }\n                })\n                .build());\n\n        System.out.println(\"PY RESULT: \" + result);\n        System.out.println(\"PY ERROR: \" + result.getError());\n        Assert.assertTrue(result.isSuccess());\n        Assert.assertNotNull(result.getResult());\n        Assert.assertTrue(result.getResult().contains(\"ok:\"));\n    }\n\n    private boolean isGraalPyAvailable() {\n        try (Context context = Context.newBuilder(\"python\").build()) {\n            context.eval(\"python\", \"1+1\");\n            return true;\n        } catch (Throwable t) {\n            return false;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/CodeActRuntimeTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeActOptions;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventPublisher;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.Arrays;\nimport java.util.concurrent.TimeUnit;\n\npublic class CodeActRuntimeTest {\n\n    private AiService aiService;\n\n    @Before\n    public void init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ARK_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getenv(\"DOUBAO_API_KEY\");\n        }\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getProperty(\"doubao.api.key\");\n        }\n        Assume.assumeTrue(apiKey != null && !apiKey.isEmpty());\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        doubaoConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setDoubaoConfig(doubaoConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n\n        configuration.setOkHttpClient(okHttpClient);\n        aiService = new AiService(configuration);\n    }\n\n    @Test\n    public void test_codeact_with_tool() throws Exception {\n        Agent agent = Agents.codeAct()\n                .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .systemPrompt(\"You are a weather assistant. Use Python only. Always call queryWeather with args {location, type, days}. Use type \\\"daily\\\" and days 1. Return a final answer string. If you do not return, set __codeact_result.\")\n                .toolRegistry(Arrays.asList(\"queryWeather\"), null)\n                .options(AgentOptions.builder().maxSteps(4).build())\n                .codeActOptions(CodeActOptions.builder().reAct(true).build())\n                .eventPublisher(buildEventPublisher())\n                .build();\n\n        AgentResult result = agent.run(AgentRequest.builder()\n                .input(\"Use Python to query weather for Beijing, Shanghai, and Shenzhen. Call queryWeather with type \\\"daily\\\" and days 1 for each city, then return a single summary string.\")\n                .build());\n\n        System.out.println(\"CODEACT OUTPUT: \" + result.getOutputText());\n        if (result.getToolCalls() != null && !result.getToolCalls().isEmpty()) {\n            System.out.println(\"CODEACT CODE: \" + result.getToolCalls().get(0).getArguments());\n        }\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().length() > 0);\n        Assert.assertFalse(result.getOutputText().contains(\"CODE_ERROR\"));\n        Assert.assertTrue(result.getToolResults() != null && !result.getToolResults().isEmpty());\n    }\n\n    private AgentEventPublisher buildEventPublisher() {\n        AgentEventPublisher publisher = new AgentEventPublisher();\n        publisher.addListener(event -> logToolCall(event));\n        return publisher;\n    }\n\n    private void logToolCall(AgentEvent event) {\n        if (event == null || event.getType() != AgentEventType.TOOL_CALL) {\n            return;\n        }\n        Object payload = event.getPayload();\n        if (!(payload instanceof AgentToolCall)) {\n            return;\n        }\n        AgentToolCall call = (AgentToolCall) payload;\n        if (!\"code\".equals(call.getName())) {\n            return;\n        }\n        System.out.println(\"CODEACT CODE (pre-exec): \" + call.getArguments());\n    }\n}\n\n\n\n\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/CodeActRuntimeWithTraceTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeActOptions;\nimport io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.agent.trace.ConsoleTraceExporter;\nimport io.github.lnyocly.ai4j.agent.trace.TraceConfig;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport javax.script.ScriptEngineManager;\nimport java.util.Arrays;\nimport java.util.concurrent.TimeUnit;\n\npublic class CodeActRuntimeWithTraceTest {\n\n    private AiService aiService;\n\n    @Before\n    public void init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ARK_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getenv(\"DOUBAO_API_KEY\");\n        }\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getProperty(\"doubao.api.key\");\n        }\n        apiKey = \"67f0a8ec-10fd-4949-87d1-1e3bf1f77d91\";\n        Assume.assumeTrue(apiKey != null && !apiKey.isEmpty());\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        doubaoConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setDoubaoConfig(doubaoConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n\n        configuration.setOkHttpClient(okHttpClient);\n        aiService = new AiService(configuration);\n    }\n\n    @Test\n    public void test_codeact_with_trace() throws Exception {\n        Assume.assumeTrue(\"Nashorn runtime is not available\", isNashornAvailable());\n        Agent agent = Agents.codeAct()\n                .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .systemPrompt(\"You are a weather assistant. Use JavaScript only. Always call queryWeather with args {location, type, days}. Use type \\\"daily\\\" and days 1. Return a final answer string. If you do not return, set __codeact_result.\")\n                .toolRegistry(Arrays.asList(\"queryWeather\"), null)\n                .options(AgentOptions.builder().maxSteps(4).build())\n            .codeExecutor(new NashornCodeExecutor())\n                .codeActOptions(CodeActOptions.builder().reAct(true).build())\n                .traceConfig(TraceConfig.builder().build())\n                .traceExporter(new ConsoleTraceExporter())\n                .build();\n\n        AgentResult result = agent.run(AgentRequest.builder()\n                .input(\"Use JavaScript to query weather for Beijing, Shanghai, and Shenzhen. Call queryWeather with type \\\"daily\\\" and days 1 for each city, then return a single summary string.\")\n                .build());\n\n        System.out.println(\"CODEACT OUTPUT: \" + result.getOutputText());\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().length() > 0);\n        Assert.assertFalse(result.getOutputText().contains(\"CODE_ERROR\"));\n        Assert.assertTrue(result.getToolResults() != null && !result.getToolResults().isEmpty());\n    }\n\n    private boolean isNashornAvailable() {\n        return new ScriptEngineManager().getEngineByName(\"nashorn\") != null;\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/DoubaoAgentTeamBestPracticeTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamHook;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamOptions;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.config.ZhipuConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.Set;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicInteger;\n\n/**\n * Best-practice Agent Teams demo using Doubao:\n * - deterministic dependency plan to make orchestration stable in tests\n * - Doubao-powered teammates + synthesizer\n * - message bus + hooks + plan approval enabled\n */\npublic class DoubaoAgentTeamBestPracticeTest {\n\n    private static final String MODEL = (System.getenv(\"ZHIPU_MODEL\") == null || System.getenv(\"ZHIPU_MODEL\").isEmpty())\n            ? \"GLM-4.5-Flash\"\n            : System.getenv(\"ZHIPU_MODEL\");\n\n    private AiService aiService;\n\n    @Before\n    public void init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ZHIPU_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = \"1cbd1960cdc7e9144ded698a9763569b.seHlVxdOq3eTnY9m\";\n        }\n        Assume.assumeTrue(apiKey != null && !apiKey.isEmpty());\n\n        ZhipuConfig zhipuConfig = new ZhipuConfig();\n        zhipuConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setZhipuConfig(zhipuConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n\n        configuration.setOkHttpClient(okHttpClient);\n        aiService = new AiService(configuration);\n    }\n\n    @Test\n    public void test_doubao_agent_team_best_practice() throws Exception {\n        Agent northWeatherAgent = Agents.react()\n                .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU)))\n                .model(MODEL)\n                .temperature(0.7)\n                .systemPrompt(\"You are a weather data specialist. You must call queryWeather before answering.\")\n                .instructions(\"Use queryWeather(location, type=daily, days=1). Return concise JSON only.\")\n                .toolRegistry(Collections.singletonList(\"queryWeather\"), null)\n                .options(AgentOptions.builder().maxSteps(3).build())\n                .build();\n\n        Agent southWeatherAgent = Agents.react()\n                .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU)))\n                .model(MODEL)\n                .temperature(0.7)\n                .systemPrompt(\"You are a weather data specialist. You must call queryWeather before answering.\")\n                .instructions(\"Use queryWeather(location, type=daily, days=1). Return concise JSON only.\")\n                .toolRegistry(Collections.singletonList(\"queryWeather\"), null)\n                .options(AgentOptions.builder().maxSteps(3).build())\n                .build();\n\n        Agent analystAgent = Agents.react()\n                .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU)))\n                .model(MODEL)\n                .temperature(0.7)\n                .systemPrompt(\"You are a weather risk analyst. Compare city weather and provide travel guidance.\")\n                .instructions(\"Use teammate outputs. Produce compact bullet points.\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        Agent synthesizerAgent = Agents.react()\n                .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU)))\n                .model(MODEL)\n                .temperature(0.7)\n                .systemPrompt(\"You are the team lead. Merge teammate results into final structured JSON.\")\n                .instructions(\"Output JSON with fields: summary, cityComparisons, travelAdvice.\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        final AtomicInteger beforeTaskCount = new AtomicInteger();\n        final AtomicInteger afterTaskCount = new AtomicInteger();\n        final AtomicInteger messageCount = new AtomicInteger();\n\n        AgentTeam team = Agents.team()\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .rawPlanText(\"deterministic_best_practice_plan\")\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"north_weather\")\n                                        .memberId(\"north_weather\")\n                                        .task(\"Get a 1-day daily weather forecast for Beijing and return compact JSON.\")\n                                        .context(\"city=Beijing\")\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"south_weather\")\n                                        .memberId(\"south_weather\")\n                                        .task(\"Get a 1-day daily weather forecast for Shenzhen and return compact JSON.\")\n                                        .context(\"city=Shenzhen\")\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"risk_analysis\")\n                                        .memberId(\"analyst\")\n                                        .task(\"Compare both weather reports and provide practical travel suggestions.\")\n                                        .dependsOn(Arrays.asList(\"north_weather\", \"south_weather\"))\n                                        .build()\n                        ))\n                        .build())\n                .synthesizerAgent(synthesizerAgent)\n                .member(AgentTeamMember.builder()\n                        .id(\"north_weather\")\n                        .name(\"North Weather\")\n                        .description(\"Fetches weather for northern city\")\n                        .agent(northWeatherAgent)\n                        .build())\n                .member(AgentTeamMember.builder()\n                        .id(\"south_weather\")\n                        .name(\"South Weather\")\n                        .description(\"Fetches weather for southern city\")\n                        .agent(southWeatherAgent)\n                        .build())\n                .member(AgentTeamMember.builder()\n                        .id(\"analyst\")\n                        .name(\"Analyst\")\n                        .description(\"Compares reports and produces advice\")\n                        .agent(analystAgent)\n                        .build())\n                .options(AgentTeamOptions.builder()\n                        .parallelDispatch(true)\n                        .maxConcurrency(3)\n                        .enableMessageBus(true)\n                        .includeMessageHistoryInDispatch(true)\n                        .messageHistoryLimit(50)\n                        .requirePlanApproval(true)\n                        .maxRounds(12)\n                        .build())\n                .planApproval((objective, plan, members, options) -> {\n                    if (plan == null || plan.getTasks() == null || plan.getTasks().isEmpty()) {\n                        return false;\n                    }\n                    Set<String> validMembers = new HashSet<String>();\n                    for (AgentTeamMember member : members) {\n                        validMembers.add(member.resolveId());\n                    }\n                    for (AgentTeamTask task : plan.getTasks()) {\n                        if (!validMembers.contains(task.getMemberId())) {\n                            return false;\n                        }\n                    }\n                    return true;\n                })\n                .hook(new AgentTeamHook() {\n                    @Override\n                    public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) {\n                        beforeTaskCount.incrementAndGet();\n                        System.out.println(\"TEAM TASK START: \" + task.getId() + \" -> \" + member.resolveId());\n                    }\n\n                    @Override\n                    public void afterTask(String objective, AgentTeamMemberResult result) {\n                        afterTaskCount.incrementAndGet();\n                        System.out.println(\"TEAM TASK END: \" + result.getTaskId() + \" status=\" + result.getTaskStatus());\n                    }\n\n                    @Override\n                    public void onMessage(AgentTeamMessage message) {\n                        messageCount.incrementAndGet();\n                        System.out.println(\"TEAM MSG: [\" + message.getType() + \"] \"\n                                + message.getFromMemberId() + \" -> \" + message.getToMemberId()\n                                + \" | \" + message.getContent());\n                    }\n                })\n                .build();\n\n        team.publishMessage(AgentTeamMessage.builder()\n                .id(\"seed-message\")\n                .fromMemberId(\"lead\")\n                .toMemberId(\"*\")\n                .type(\"run.context\")\n                .content(\"Use factual weather data. Keep output concise and actionable.\")\n                .createdAt(System.currentTimeMillis())\n                .build());\n\n        AgentTeamResult result = team.run(\"Create a two-city weather briefing for Beijing and Shenzhen.\");\n\n        System.out.println(\"TEAM ROUNDS: \" + result.getRounds());\n        System.out.println(\"TEAM TASK STATES: \" + result.getTaskStates());\n        System.out.println(\"TEAM OUTPUT: \" + result.getOutput());\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutput());\n        Assert.assertTrue(result.getOutput().length() > 0);\n        Assert.assertTrue(result.getRounds() >= 2);\n        Assert.assertNotNull(result.getTaskStates());\n        Assert.assertEquals(3, result.getTaskStates().size());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(0).getStatus());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(1).getStatus());\n        Assert.assertEquals(AgentTeamTaskStatus.COMPLETED, result.getTaskStates().get(2).getStatus());\n        Assert.assertTrue(beforeTaskCount.get() >= 3);\n        Assert.assertTrue(afterTaskCount.get() >= 3);\n        Assert.assertTrue(messageCount.get() > 0);\n        Assert.assertNotNull(result.getMessages());\n        Assert.assertTrue(result.getMessages().size() > 0);\n    }\n}\n\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/DoubaoAgentWorkflowTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.agent.workflow.RuntimeAgentNode;\nimport io.github.lnyocly.ai4j.agent.workflow.SequentialWorkflow;\nimport io.github.lnyocly.ai4j.agent.workflow.WorkflowAgent;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\npublic class DoubaoAgentWorkflowTest {\n\n    private AiService aiService;\n\n    @Before\n    public void init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ARK_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getenv(\"DOUBAO_API_KEY\");\n        }\n        Assume.assumeTrue(apiKey != null && !apiKey.isEmpty());\n\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        doubaoConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setDoubaoConfig(doubaoConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n\n        configuration.setOkHttpClient(okHttpClient);\n        aiService = new AiService(configuration);\n    }\n\n    @Test\n    public void test_doubao_agent_workflow() throws Exception {\n        Agent agent = Agents.react()\n                .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        SequentialWorkflow workflow = new SequentialWorkflow()\n                .addNode(new RuntimeAgentNode(agent.newSession()));\n\n        WorkflowAgent runner = new WorkflowAgent(workflow, agent.newSession());\n\n        AgentResult result = runner.run(AgentRequest.builder()\n                .input(\"Explain the Responses API in one sentence\")\n                .build());\n\n        System.out.println(\"agent output: \" + result.getOutputText());\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().length() > 0);\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/DoubaoProjectTeamAgentTeamsTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamHook;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamOptions;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.config.ZhipuConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * 使用豆包真实大模型的 Agent Teams 集成测试。\n * 场景：模拟完整研发小组（架构/后端/前端/测试/运维）协作完成项目交付规划。\n */\npublic class DoubaoProjectTeamAgentTeamsTest {\n\n    private static final String DEFAULT_API_KEY = \"1cbd1960cdc7e9144ded698a9763569b.seHlVxdOq3eTnY9m\";\n    private static final String MODEL = (System.getenv(\"ZHIPU_MODEL\") == null || System.getenv(\"ZHIPU_MODEL\").isEmpty())\n            ? \"GLM-4.5-Flash\"\n            : System.getenv(\"ZHIPU_MODEL\");\n\n    private AiService aiService;\n\n    @Before\n    public void init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ZHIPU_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = DEFAULT_API_KEY;\n        }\n\n        ZhipuConfig zhipuConfig = new ZhipuConfig();\n        zhipuConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setZhipuConfig(zhipuConfig);\n\n        HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();\n        loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        // ????????????????????CI/???????????????\n        OkHttpClient okHttpClient = new OkHttpClient.Builder()\n                .addInterceptor(loggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n\n        configuration.setOkHttpClient(okHttpClient);\n        aiService = new AiService(configuration);\n    }\n\n    @Test\n    public void test_project_delivery_team_with_doubao() throws Exception {\n        // 1) 构建不同职责的 Agent（同一模型，不同 systemPrompt/instructions）\n        Agent architect = createRoleAgent(\n                \"You are the software architect.\",\n                \"Define architecture boundaries, API contract shape, and key risks in concise bullet points.\");\n\n        Agent backend = createRoleAgent(\n                \"You are the backend engineer.\",\n                \"Create backend implementation plan with modules, API endpoints, and schema changes. \"\n                        + \"Use team_send_message to notify frontend about API contract changes when needed.\");\n\n        Agent frontend = createRoleAgent(\n                \"You are the frontend engineer.\",\n                \"Create frontend implementation plan with pages/components/state/API integration. \"\n                        + \"Use team_send_message to ask backend for contract clarification when needed.\");\n\n        Agent qa = createRoleAgent(\n                \"You are the QA engineer.\",\n                \"Build a concise quality plan: smoke/regression/e2e, acceptance criteria, and release gate. \"\n                        + \"Use team_list_tasks if needed.\");\n\n        Agent ops = createRoleAgent(\n                \"You are the DevOps engineer.\",\n                \"Provide deployment, monitoring, alerting, rollback strategy, and production readiness checklist.\");\n\n        Agent lead = createRoleAgent(\n                \"You are the team lead.\",\n                \"You are responsible for both planning and final synthesis. \"\n                        + \"First break objective into executable tasks and assign to member ids: architect, backend, frontend, qa, ops. \"\n                        + \"Then merge member outputs into one sprint delivery plan with sections: architecture, backend, frontend, qa, ops, milestones, risks.\");\n\n        // 2) 构建 Team：固定规划任务 DAG，成员并发执行，最后由 lead 汇总\n        AgentTeam team = Agents.team()\n                .leadAgent(lead)\n                .member(AgentTeamMember.builder().id(\"architect\").name(\"Architect\").description(\"architecture\").agent(architect).build())\n                .member(AgentTeamMember.builder().id(\"backend\").name(\"Backend\").description(\"backend\").agent(backend).build())\n                .member(AgentTeamMember.builder().id(\"frontend\").name(\"Frontend\").description(\"frontend\").agent(frontend).build())\n                .member(AgentTeamMember.builder().id(\"qa\").name(\"QA\").description(\"quality\").agent(qa).build())\n                .member(AgentTeamMember.builder().id(\"ops\").name(\"Ops\").description(\"operations\").agent(ops).build())\n                .options(AgentTeamOptions.builder()\n                        .parallelDispatch(true)\n                        .maxConcurrency(3)\n                        .enableMessageBus(true)\n                        .includeMessageHistoryInDispatch(true)\n                        .messageHistoryLimit(50)\n                        .enableMemberTeamTools(false)\n                        .maxRounds(16)\n                        .build())\n                .hook(new AgentTeamHook() {\n                    @Override\n                    public void beforeTask(String objective, AgentTeamTask task, AgentTeamMember member) {\n                        System.out.println(\"TEAM TASK START: \" + task.getId() + \" -> \" + member.resolveId());\n                    }\n\n                    @Override\n                    public void afterTask(String objective, AgentTeamMemberResult result) {\n                        System.out.println(\"TEAM TASK END: \" + result.getTaskId() + \" | \" + result.getTaskStatus());\n                    }\n\n                    @Override\n                    public void onMessage(AgentTeamMessage message) {\n                        System.out.println(\"TEAM MSG: [\" + message.getType() + \"] \"\n                                + message.getFromMemberId() + \" -> \" + message.getToMemberId()\n                                + \" | \" + message.getContent());\n                    }\n                })\n                .build();\n\n        // 3) 执行团队协作任务\n        AgentTeamResult result = team.run(\"Build a production-ready task management web application in one sprint.\");\n\n        System.out.println(\"TEAM ROUNDS: \" + result.getRounds());\n        System.out.println(\"TEAM OUTPUT:\\n\" + result.getOutput());\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutput());\n        Assert.assertTrue(result.getOutput().length() > 120);\n        Assert.assertNotNull(result.getTaskStates());\n        Assert.assertTrue(result.getTaskStates().size() > 0);\n        Assert.assertNotNull(result.getMemberResults());\n        Assert.assertTrue(result.getMemberResults().size() > 0);\n        Assert.assertTrue(result.getRounds() >= 1);\n\n        int completedCount = 0;\n        for (AgentTeamTaskState state : result.getTaskStates()) {\n            if (state != null && state.getStatus() == AgentTeamTaskStatus.COMPLETED) {\n                completedCount++;\n            }\n        }\n        Assert.assertTrue(\"expected at least one completed task\", completedCount > 0);\n\n        Assert.assertNotNull(result.getMessages());\n        Assert.assertTrue(result.getMessages().size() > 0);\n    }\n\n    // 统一创建 ReAct Agent，便于按角色切换 systemPrompt 与 instructions\n    private Agent createRoleAgent(String systemPrompt, String instructions) {\n        return Agents.react()\n                .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU)))\n                .model(MODEL)\n                .temperature(0.7)\n                .systemPrompt(systemPrompt)\n                .instructions(instructions)\n                .options(AgentOptions.builder().maxSteps(4).build())\n                .build();\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/FileAgentTeamStateStoreTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamState;\nimport io.github.lnyocly.ai4j.agent.team.FileAgentTeamMessageBus;\nimport io.github.lnyocly.ai4j.agent.team.FileAgentTeamStateStore;\nimport org.junit.Assert;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class FileAgentTeamStateStoreTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldSaveLoadListAndDeleteState() throws Exception {\n        Path root = temporaryFolder.newFolder(\"team-state-store\").toPath();\n        FileAgentTeamStateStore store = new FileAgentTeamStateStore(root);\n\n        AgentTeamState state = AgentTeamState.builder()\n                .teamId(\"travel-team\")\n                .objective(\"ship travel demo\")\n                .lastOutput(\"done\")\n                .lastRounds(3)\n                .updatedAt(123L)\n                .build();\n\n        store.save(state);\n\n        AgentTeamState loaded = store.load(\"travel-team\");\n        Assert.assertNotNull(loaded);\n        Assert.assertEquals(\"ship travel demo\", loaded.getObjective());\n        Assert.assertEquals(\"done\", loaded.getLastOutput());\n\n        List<AgentTeamState> listed = store.list();\n        Assert.assertEquals(1, listed.size());\n        Assert.assertEquals(\"travel-team\", listed.get(0).getTeamId());\n\n        Assert.assertTrue(store.delete(\"travel-team\"));\n        Assert.assertNull(store.load(\"travel-team\"));\n    }\n\n    @Test\n    public void shouldPersistMailboxAcrossInstances() throws Exception {\n        Path file = temporaryFolder.newFile(\"team-mailbox.jsonl\").toPath();\n        FileAgentTeamMessageBus first = new FileAgentTeamMessageBus(file);\n        first.publish(AgentTeamMessage.builder()\n                .id(\"m1\")\n                .fromMemberId(\"architect\")\n                .toMemberId(\"backend\")\n                .type(\"peer.message\")\n                .taskId(\"architecture\")\n                .content(\"API schema ready\")\n                .createdAt(1L)\n                .build());\n        first.publish(AgentTeamMessage.builder()\n                .id(\"m2\")\n                .fromMemberId(\"backend\")\n                .toMemberId(\"*\")\n                .type(\"peer.broadcast\")\n                .taskId(\"backend\")\n                .content(\"openapi updated\")\n                .createdAt(2L)\n                .build());\n\n        FileAgentTeamMessageBus second = new FileAgentTeamMessageBus(file);\n        Assert.assertEquals(2, second.snapshot().size());\n        Assert.assertEquals(2, second.historyFor(\"backend\", 10).size());\n\n        second.restore(Arrays.asList(\n                AgentTeamMessage.builder()\n                        .id(\"m3\")\n                        .fromMemberId(\"qa\")\n                        .toMemberId(\"lead\")\n                        .type(\"task.result\")\n                        .taskId(\"qa\")\n                        .content(\"qa plan ready\")\n                        .createdAt(3L)\n                        .build()\n        ));\n\n        FileAgentTeamMessageBus third = new FileAgentTeamMessageBus(file);\n        Assert.assertEquals(1, third.snapshot().size());\n        Assert.assertEquals(\"m3\", third.snapshot().get(0).getId());\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/HandoffPolicyTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffFailureAction;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentToolExecutor;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class HandoffPolicyTest {\n\n    @Test\n    public void testAllowedToolsPolicyDeniesUnexpectedSubagent() throws Exception {\n        SubAgentDefinition definition = SubAgentDefinition.builder()\n                .name(\"writer\")\n                .toolName(\"delegate_writer\")\n                .description(\"Write output\")\n                .agent(staticOutputAgent(\"writer-ok\"))\n                .build();\n\n        Agent manager = io.github.lnyocly.ai4j.agent.Agents.react()\n                .modelClient(queueModelClient(\n                        toolCallResult(\"call_1\", \"delegate_writer\", \"{\\\"task\\\":\\\"x\\\"}\")\n                ))\n                .model(\"manager\")\n                .subAgent(definition)\n                .handoffPolicy(HandoffPolicy.builder()\n                        .allowedTools(Collections.singleton(\"delegate_other\"))\n                        .build())\n                .build();\n\n        try {\n            manager.run(io.github.lnyocly.ai4j.agent.AgentRequest.builder().input(\"go\").build());\n            Assert.fail(\"Expected handoff denied\");\n        } catch (IllegalStateException ex) {\n            Assert.assertTrue(ex.getMessage().contains(\"allowedTools\"));\n        }\n    }\n\n    @Test\n    public void testDenyCanFallbackToPrimaryExecutor() throws Exception {\n        SubAgentRegistry registry = new NoopSubAgentRegistry(\"delegate_writer\");\n        ToolExecutor fallback = call -> \"fallback-ok\";\n\n        SubAgentToolExecutor executor = new SubAgentToolExecutor(\n                registry,\n                fallback,\n                HandoffPolicy.builder()\n                        .allowedTools(Collections.singleton(\"delegate_other\"))\n                        .onDenied(HandoffFailureAction.FALLBACK_TO_PRIMARY)\n                        .build()\n        );\n\n        String output = executor.execute(AgentToolCall.builder().name(\"delegate_writer\").arguments(\"{}\").build());\n        Assert.assertEquals(\"fallback-ok\", output);\n    }\n\n    @Test\n    public void testRetryRecoversTransientSubagentFailure() throws Exception {\n        AtomicInteger attempts = new AtomicInteger();\n        SubAgentRegistry flakyRegistry = new SubAgentRegistry() {\n            @Override\n            public List<Object> getTools() {\n                return Collections.emptyList();\n            }\n\n            @Override\n            public boolean supports(String toolName) {\n                return \"delegate_flaky\".equals(toolName);\n            }\n\n            @Override\n            public String execute(AgentToolCall call) {\n                int current = attempts.incrementAndGet();\n                if (current == 1) {\n                    throw new RuntimeException(\"temporary error\");\n                }\n                return \"ok-after-retry\";\n            }\n        };\n\n        SubAgentToolExecutor executor = new SubAgentToolExecutor(\n                flakyRegistry,\n                null,\n                HandoffPolicy.builder().maxRetries(1).build()\n        );\n\n        String output = executor.execute(AgentToolCall.builder().name(\"delegate_flaky\").arguments(\"{}\").build());\n        Assert.assertEquals(\"ok-after-retry\", output);\n        Assert.assertEquals(2, attempts.get());\n    }\n\n    @Test\n    public void testTimeoutCanFallbackToPrimaryExecutor() throws Exception {\n        SubAgentRegistry slowRegistry = new SubAgentRegistry() {\n            @Override\n            public List<Object> getTools() {\n                return Collections.emptyList();\n            }\n\n            @Override\n            public boolean supports(String toolName) {\n                return \"delegate_slow\".equals(toolName);\n            }\n\n            @Override\n            public String execute(AgentToolCall call) {\n                try {\n                    Thread.sleep(80L);\n                } catch (InterruptedException e) {\n                    Thread.currentThread().interrupt();\n                    throw new RuntimeException(e);\n                }\n                return \"slow-output\";\n            }\n        };\n\n        ToolExecutor fallback = call -> \"timeout-fallback\";\n\n        SubAgentToolExecutor executor = new SubAgentToolExecutor(\n                slowRegistry,\n                fallback,\n                HandoffPolicy.builder()\n                        .timeoutMillis(10L)\n                        .onError(HandoffFailureAction.FALLBACK_TO_PRIMARY)\n                        .build()\n        );\n\n        String output = executor.execute(AgentToolCall.builder().name(\"delegate_slow\").arguments(\"{}\").build());\n        Assert.assertEquals(\"timeout-fallback\", output);\n    }\n\n    @Test\n    public void testInputFilterCanRewriteDelegatedArguments() throws Exception {\n        AtomicReference<String> capturedArgs = new AtomicReference<>();\n\n        SubAgentRegistry registry = new SubAgentRegistry() {\n            @Override\n            public List<Object> getTools() {\n                return Collections.emptyList();\n            }\n\n            @Override\n            public boolean supports(String toolName) {\n                return \"delegate_filtered\".equals(toolName);\n            }\n\n            @Override\n            public String execute(AgentToolCall call) {\n                capturedArgs.set(call.getArguments());\n                return \"filtered-ok\";\n            }\n        };\n\n        SubAgentToolExecutor executor = new SubAgentToolExecutor(\n                registry,\n                null,\n                HandoffPolicy.builder()\n                        .inputFilter(call -> AgentToolCall.builder()\n                                .name(call.getName())\n                                .callId(call.getCallId())\n                                .arguments(\"{\\\"task\\\":\\\"filtered\\\"}\")\n                                .build())\n                        .build()\n        );\n\n        String output = executor.execute(AgentToolCall.builder()\n                .name(\"delegate_filtered\")\n                .callId(\"f1\")\n                .arguments(\"{\\\"task\\\":\\\"original\\\"}\")\n                .build());\n\n        Assert.assertEquals(\"filtered-ok\", output);\n        Assert.assertEquals(\"{\\\"task\\\":\\\"filtered\\\"}\", capturedArgs.get());\n    }\n\n    @Test\n    public void testNestedHandoffBlockedByMaxDepth() throws Exception {\n        SubAgentDefinition leaf = SubAgentDefinition.builder()\n                .name(\"leaf\")\n                .toolName(\"delegate_leaf\")\n                .description(\"Leaf\")\n                .agent(staticOutputAgent(\"leaf-done\"))\n                .build();\n\n        Agent child = io.github.lnyocly.ai4j.agent.Agents.react()\n                .modelClient(queueModelClient(\n                        toolCallResult(\"child_1\", \"delegate_leaf\", \"{\\\"task\\\":\\\"nested\\\"}\"),\n                        textResult(\"child-final\")\n                ))\n                .model(\"child\")\n                .subAgent(leaf)\n                .handoffPolicy(HandoffPolicy.builder().maxDepth(1).build())\n                .build();\n\n        SubAgentDefinition childDefinition = SubAgentDefinition.builder()\n                .name(\"child\")\n                .toolName(\"delegate_child\")\n                .description(\"Child\")\n                .agent(child)\n                .build();\n\n        Agent parent = io.github.lnyocly.ai4j.agent.Agents.react()\n                .modelClient(queueModelClient(\n                        toolCallResult(\"parent_1\", \"delegate_child\", \"{\\\"task\\\":\\\"outer\\\"}\")\n                ))\n                .model(\"parent\")\n                .subAgent(childDefinition)\n                .handoffPolicy(HandoffPolicy.builder().maxDepth(1).build())\n                .build();\n\n        try {\n            parent.run(io.github.lnyocly.ai4j.agent.AgentRequest.builder().input(\"start\").build());\n            Assert.fail(\"Expected max depth violation\");\n        } catch (IllegalStateException ex) {\n            Assert.assertTrue(ex.getMessage().contains(\"maxDepth\"));\n        }\n    }\n\n    private Agent staticOutputAgent(String output) {\n        return io.github.lnyocly.ai4j.agent.Agents.react()\n                .modelClient(queueModelClient(textResult(output)))\n                .model(\"static-model\")\n                .build();\n    }\n\n    private QueueModelClient queueModelClient(io.github.lnyocly.ai4j.agent.model.AgentModelResult... results) {\n        return new QueueModelClient(Arrays.asList(results));\n    }\n\n    private io.github.lnyocly.ai4j.agent.model.AgentModelResult textResult(String text) {\n        return io.github.lnyocly.ai4j.agent.model.AgentModelResult.builder()\n                .outputText(text)\n                .toolCalls(new ArrayList<>())\n                .memoryItems(new ArrayList<>())\n                .build();\n    }\n\n    private io.github.lnyocly.ai4j.agent.model.AgentModelResult toolCallResult(String callId, String toolName, String args) {\n        return io.github.lnyocly.ai4j.agent.model.AgentModelResult.builder()\n                .toolCalls(Arrays.asList(io.github.lnyocly.ai4j.agent.tool.AgentToolCall.builder()\n                        .callId(callId)\n                        .name(toolName)\n                        .arguments(args)\n                        .type(\"function_call\")\n                        .build()))\n                .memoryItems(new ArrayList<>())\n                .build();\n    }\n\n    private static class QueueModelClient implements io.github.lnyocly.ai4j.agent.model.AgentModelClient {\n        private final java.util.Deque<io.github.lnyocly.ai4j.agent.model.AgentModelResult> queue;\n\n        private QueueModelClient(List<io.github.lnyocly.ai4j.agent.model.AgentModelResult> results) {\n            this.queue = new java.util.ArrayDeque<>(results);\n        }\n\n        @Override\n        public io.github.lnyocly.ai4j.agent.model.AgentModelResult create(io.github.lnyocly.ai4j.agent.model.AgentPrompt prompt) {\n            return queue.isEmpty() ? io.github.lnyocly.ai4j.agent.model.AgentModelResult.builder().build() : queue.poll();\n        }\n\n        @Override\n        public io.github.lnyocly.ai4j.agent.model.AgentModelResult createStream(io.github.lnyocly.ai4j.agent.model.AgentPrompt prompt,\n                                                                                 io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n\n    private static class NoopSubAgentRegistry implements SubAgentRegistry {\n        private final String toolName;\n\n        private NoopSubAgentRegistry(String toolName) {\n            this.toolName = toolName;\n        }\n\n        @Override\n        public List<Object> getTools() {\n            return Collections.emptyList();\n        }\n\n        @Override\n        public boolean supports(String toolName) {\n            return this.toolName.equals(toolName);\n        }\n\n        @Override\n        public String execute(AgentToolCall call) {\n            return \"subagent-output\";\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/MinimaxAgentTeamTravelUsageTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamOptions;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.config.MinimaxConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\n\npublic class MinimaxAgentTeamTravelUsageTest {\n\n    private static final String DEFAULT_MODEL = \"MiniMax-M2.7\";\n\n    private ChatModelClient modelClient;\n    private String model;\n\n    @Before\n    public void setupMinimaxClient() {\n        String apiKey = readValue(\"MINIMAX_API_KEY\", \"minimax.api.key\");\n        Assume.assumeTrue(\"Skip because MiniMax API key is not configured\", !isBlank(apiKey));\n\n        model = readValue(\"MINIMAX_MODEL\", \"minimax.model\");\n        if (isBlank(model)) {\n            model = DEFAULT_MODEL;\n        }\n\n        Configuration configuration = new Configuration();\n        MinimaxConfig minimaxConfig = new MinimaxConfig();\n        minimaxConfig.setApiKey(apiKey);\n        configuration.setMinimaxConfig(minimaxConfig);\n        configuration.setOkHttpClient(createHttpClient());\n\n        AiService aiService = new AiService(configuration);\n        modelClient = new ChatModelClient(aiService.getChatService(PlatformType.MINIMAX));\n    }\n\n    @Test\n    public void test_travel_demo_delivery_team_with_minimax() throws Exception {\n        AgentTeam team = Agents.team()\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .rawPlanText(\"travel-demo-fixed-plan\")\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"product\")\n                                        .memberId(\"product\")\n                                        .task(\"Define the MVP for a travel planning demo app, target users, user stories, and acceptance criteria.\")\n                                        .context(\"Output should include scope, features, non-goals, and release criteria. Objective: \"\n                                                + safe(objective))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"architecture\")\n                                        .memberId(\"architect\")\n                                        .task(\"Design the system architecture, backend/frontend boundaries, and delivery sequence for the travel demo app.\")\n                                        .context(\"Base the design on the product scope. Produce module boundaries, deployment shape, and API contract outline.\")\n                                        .dependsOn(Arrays.asList(\"product\"))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"backend\")\n                                        .memberId(\"backend\")\n                                        .task(\"Design backend APIs, data model, and implementation plan for itinerary planning, destination search, and booking summary.\")\n                                        .context(\"Produce concrete REST endpoints, request/response examples, and persistence model.\")\n                                        .dependsOn(Arrays.asList(\"product\", \"architecture\"))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"frontend\")\n                                        .memberId(\"frontend\")\n                                        .task(\"Design frontend pages, key components, and interaction flow for the travel demo app.\")\n                                        .context(\"Cover the landing page, destination discovery, itinerary builder, and booking summary flow.\")\n                                        .dependsOn(Arrays.asList(\"product\", \"architecture\"))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"qa\")\n                                        .memberId(\"qa\")\n                                        .task(\"Create the QA strategy, core test matrix, and release gate for the travel demo app.\")\n                                        .context(\"Base validation on the PRD, architecture, backend API plan, and frontend flow.\")\n                                        .dependsOn(Arrays.asList(\"product\", \"backend\", \"frontend\"))\n                                        .build()\n                        ))\n                        .build())\n                .synthesizer((objective, plan, memberResults, options) -> AgentResult.builder()\n                        .outputText(renderSummary(objective, memberResults))\n                        .build())\n                .member(member(\n                        \"product\",\n                        \"Product Manager\",\n                        \"Owns product scope, user value, and acceptance criteria.\",\n                        \"You are the product manager for a travel demo application.\\n\"\n                                + \"Deliver a concise but concrete PRD-style output with: target users, user stories, MVP scope, non-goals, and release acceptance criteria.\\n\"\n                                + \"Keep the result structured and implementation-ready.\"\n                ))\n                .member(member(\n                        \"architect\",\n                        \"Architecture Analyst\",\n                        \"Owns system boundaries, technical design, and delivery sequencing.\",\n                        \"You are the architecture analyst for a travel demo application.\\n\"\n                                + \"Deliver the technical architecture, core modules, backend/frontend boundaries, data flow, and implementation milestones.\\n\"\n                                + \"Reference the product scope and write for engineers.\"\n                ))\n                .member(member(\n                        \"backend\",\n                        \"Backend Engineer\",\n                        \"Owns APIs, domain model, and backend delivery plan.\",\n                        \"You are the backend engineer for a travel demo application.\\n\"\n                                + \"Produce concrete REST APIs, data models, service modules, and implementation notes for itinerary planning, destination search, and booking summary.\\n\"\n                                + \"Prefer production-like API shape and clear request/response examples.\"\n                ))\n                .member(member(\n                        \"frontend\",\n                        \"Frontend Engineer\",\n                        \"Owns user flows, pages, and component structure.\",\n                        \"You are the frontend engineer for a travel demo application.\\n\"\n                                + \"Produce the page map, component breakdown, UI states, and API integration points for the main flows.\\n\"\n                                + \"Focus on a realistic MVP that a React web app could implement.\"\n                ))\n                .member(member(\n                        \"qa\",\n                        \"QA Engineer\",\n                        \"Owns verification strategy and release quality gate.\",\n                        \"You are the QA engineer for a travel demo application.\\n\"\n                                + \"Produce the test strategy, scenario matrix, risk-based priorities, and release criteria.\\n\"\n                                + \"Cover happy path, validation, API failure, and regression scope.\"\n                ))\n                .options(AgentTeamOptions.builder()\n                        .parallelDispatch(true)\n                        .maxConcurrency(3)\n                        .enableMessageBus(true)\n                        .includeMessageHistoryInDispatch(true)\n                        .enableMemberTeamTools(true)\n                        .maxRounds(12)\n                        .build())\n                .build();\n\n        AgentTeamResult result = callWithProviderGuard(new ThrowingSupplier<AgentTeamResult>() {\n            @Override\n            public AgentTeamResult get() throws Exception {\n                return team.run(AgentRequest.builder()\n                        .input(\"Create a concrete delivery package for a travel planning demo app that helps users discover destinations, build itineraries, and review booking summaries.\")\n                        .build());\n            }\n        });\n\n        printResult(result);\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getPlan());\n        Assert.assertEquals(5, result.getPlan().getTasks().size());\n        Assert.assertEquals(5, result.getTaskStates().size());\n        Assert.assertTrue(\"Team tasks did not all complete: \" + describeTaskStates(result.getTaskStates()),\n                allTasksCompleted(result.getTaskStates()));\n        Assert.assertTrue(result.getMemberResults().size() >= 5);\n        Assert.assertNotNull(result.getOutput());\n        Assert.assertTrue(result.getOutput().contains(\"[Product]\"));\n        Assert.assertTrue(result.getOutput().contains(\"[Architect]\"));\n        Assert.assertTrue(result.getOutput().contains(\"[Backend]\"));\n        Assert.assertTrue(result.getOutput().contains(\"[Frontend]\"));\n        Assert.assertTrue(result.getOutput().contains(\"[QA]\"));\n\n        for (AgentTeamMemberResult memberResult : result.getMemberResults()) {\n            Assert.assertNotNull(memberResult);\n            Assert.assertTrue(\"member result should succeed: \" + memberResult.getMemberId(), memberResult.isSuccess());\n            Assert.assertTrue(\"member output should not be blank: \" + memberResult.getMemberId(),\n                    !isBlank(memberResult.getOutput()));\n        }\n    }\n\n    private AgentTeamMember member(String id,\n                                   String name,\n                                   String description,\n                                   String systemPrompt) {\n        return AgentTeamMember.builder()\n                .id(id)\n                .name(name)\n                .description(description)\n                .agent(Agents.builder()\n                        .modelClient(modelClient)\n                        .model(model)\n                        .temperature(0.4)\n                        .systemPrompt(systemPrompt)\n                        .options(AgentOptions.builder()\n                                .maxSteps(4)\n                                .stream(false)\n                                .build())\n                        .build())\n                .build();\n    }\n\n    private OkHttpClient createHttpClient() {\n        HttpLoggingInterceptor logging = new HttpLoggingInterceptor();\n        logging.setLevel(HttpLoggingInterceptor.Level.BASIC);\n        return new OkHttpClient.Builder()\n                .addInterceptor(logging)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .build();\n    }\n\n    private String renderSummary(String objective, List<AgentTeamMemberResult> memberResults) {\n        Map<String, String> outputs = new LinkedHashMap<String, String>();\n        if (memberResults != null) {\n            for (AgentTeamMemberResult item : memberResults) {\n                if (item == null || isBlank(item.getMemberId())) {\n                    continue;\n                }\n                outputs.put(item.getMemberId(), item.isSuccess() ? safe(item.getOutput()) : \"FAILED: \" + safe(item.getError()));\n            }\n        }\n\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"Travel Demo Team Summary\\n\");\n        builder.append(\"Objective: \").append(safe(objective)).append(\"\\n\\n\");\n        appendSection(builder, \"Product\", outputs.get(\"product\"));\n        appendSection(builder, \"Architect\", outputs.get(\"architect\"));\n        appendSection(builder, \"Backend\", outputs.get(\"backend\"));\n        appendSection(builder, \"Frontend\", outputs.get(\"frontend\"));\n        appendSection(builder, \"QA\", outputs.get(\"qa\"));\n        return builder.toString().trim();\n    }\n\n    private void appendSection(StringBuilder builder, String title, String output) {\n        builder.append('[').append(title).append(\"]\\n\");\n        builder.append(safe(output)).append(\"\\n\\n\");\n    }\n\n    private boolean allTasksCompleted(List<AgentTeamTaskState> states) {\n        if (states == null || states.isEmpty()) {\n            return false;\n        }\n        for (AgentTeamTaskState state : states) {\n            if (state == null || state.getStatus() != AgentTeamTaskStatus.COMPLETED) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n    private String describeTaskStates(List<AgentTeamTaskState> states) {\n        if (states == null || states.isEmpty()) {\n            return \"[]\";\n        }\n        StringBuilder sb = new StringBuilder(\"[\");\n        for (int i = 0; i < states.size(); i++) {\n            AgentTeamTaskState state = states.get(i);\n            if (i > 0) {\n                sb.append(\", \");\n            }\n            if (state == null) {\n                sb.append(\"null\");\n                continue;\n            }\n            sb.append(state.getTaskId()).append(\":\").append(state.getStatus());\n            if (!isBlank(state.getClaimedBy())) {\n                sb.append(\"@\").append(state.getClaimedBy());\n            }\n            if (!isBlank(state.getError())) {\n                sb.append(\"(error=\").append(state.getError()).append(\")\");\n            }\n        }\n        sb.append(\"]\");\n        return sb.toString();\n    }\n\n    private void printResult(AgentTeamResult result) {\n        System.out.println(\"==== TEAM PLAN ====\");\n        if (result != null && result.getPlan() != null && result.getPlan().getTasks() != null) {\n            for (AgentTeamTask task : result.getPlan().getTasks()) {\n                System.out.println(task.getId() + \" -> \" + task.getMemberId() + \" | dependsOn=\" + task.getDependsOn());\n            }\n        }\n\n        System.out.println(\"==== TASK STATES ====\");\n        if (result != null && result.getTaskStates() != null) {\n            for (AgentTeamTaskState state : result.getTaskStates()) {\n                System.out.println(state.getTaskId() + \" => \" + state.getStatus() + \" by \" + state.getClaimedBy());\n            }\n        }\n\n        System.out.println(\"==== TEAM MESSAGES ====\");\n        if (result != null && result.getMessages() != null) {\n            for (int i = 0; i < result.getMessages().size(); i++) {\n                System.out.println(result.getMessages().get(i));\n            }\n        }\n\n        System.out.println(\"==== MEMBER OUTPUTS ====\");\n        if (result != null && result.getMemberResults() != null) {\n            for (AgentTeamMemberResult memberResult : result.getMemberResults()) {\n                System.out.println(\"[\" + memberResult.getMemberId() + \"] success=\" + memberResult.isSuccess());\n                System.out.println(safe(memberResult.getOutput()));\n                System.out.println();\n            }\n        }\n\n        System.out.println(\"==== FINAL OUTPUT ====\");\n        System.out.println(result == null ? \"\" : result.getOutput());\n    }\n\n    private <T> T callWithProviderGuard(ThrowingSupplier<T> supplier) throws Exception {\n        try {\n            return supplier.get();\n        } catch (Exception ex) {\n            skipIfProviderUnavailable(ex);\n            throw ex;\n        }\n    }\n\n    private void skipIfProviderUnavailable(Throwable throwable) {\n        if (isProviderUnavailable(throwable)) {\n            Assume.assumeTrue(\"Skip due provider limit/unavailable: \" + extractRootMessage(throwable), false);\n        }\n    }\n\n    private boolean isProviderUnavailable(Throwable throwable) {\n        Throwable current = throwable;\n        while (current != null) {\n            String message = current.getMessage();\n            if (!isBlank(message)) {\n                String lower = message.toLowerCase();\n                if (lower.contains(\"timeout\")\n                        || lower.contains(\"rate limit\")\n                        || lower.contains(\"too many requests\")\n                        || lower.contains(\"quota\")\n                        || lower.contains(\"tool arguments must be a json object\")\n                        || message.contains(\"频次\")\n                        || message.contains(\"限流\")\n                        || message.contains(\"额度\")\n                        || message.contains(\"配额\")\n                        || message.contains(\"账户已达到\")) {\n                    return true;\n                }\n            }\n            current = current.getCause();\n        }\n        return false;\n    }\n\n    private String extractRootMessage(Throwable throwable) {\n        Throwable current = throwable;\n        Throwable last = throwable;\n        while (current != null) {\n            last = current;\n            current = current.getCause();\n        }\n        return last == null || isBlank(last.getMessage()) ? \"unknown error\" : last.getMessage();\n    }\n\n    private String readValue(String envKey, String propertyKey) {\n        String value = envKey == null ? null : System.getenv(envKey);\n        if (isBlank(value) && propertyKey != null) {\n            value = System.getProperty(propertyKey);\n        }\n        return value;\n    }\n\n    private String safe(String value) {\n        return isBlank(value) ? \"\" : value.trim();\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    @FunctionalInterface\n    private interface ThrowingSupplier<T> {\n        T get() throws Exception;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/NashornCodeExecutorTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionRequest;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Test;\n\nimport javax.script.ScriptEngineManager;\nimport java.util.Arrays;\n\npublic class NashornCodeExecutorTest {\n\n    @Test\n    public void test_nashorn_executor_with_tool_alias() throws Exception {\n        Assume.assumeTrue(\"Nashorn is not available\", isNashornAvailable());\n\n        NashornCodeExecutor executor = new NashornCodeExecutor();\n        CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder()\n                .language(\"js\")\n                .toolNames(Arrays.asList(\"echo\"))\n                .code(\"var value = echo({text: 'hi'}); __codeact_result = 'ok:' + value;\")\n                .toolExecutor(new ToolExecutor() {\n                    @Override\n                    public String execute(AgentToolCall call) {\n                        return call.getName() + \":\" + call.getArguments();\n                    }\n                })\n                .build());\n\n        Assert.assertTrue(result.isSuccess());\n        Assert.assertNotNull(result.getResult());\n        Assert.assertTrue(result.getResult().contains(\"ok:echo:\"));\n    }\n\n    @Test\n    public void test_nashorn_parses_json_tool_result() throws Exception {\n        Assume.assumeTrue(\"Nashorn is not available\", isNashornAvailable());\n\n        NashornCodeExecutor executor = new NashornCodeExecutor();\n        CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder()\n                .language(\"js\")\n                .toolNames(Arrays.asList(\"queryWeather\"))\n                .code(\"var data = queryWeather({location:'Beijing',type:'daily',days:1}); __codeact_result = data.results[0].daily[0].text_day;\")\n                .toolExecutor(new ToolExecutor() {\n                    @Override\n                    public String execute(AgentToolCall call) {\n                        return \"\\\"{\\\\\\\"results\\\\\\\":[{\\\\\\\"daily\\\\\\\":[{\\\\\\\"text_day\\\\\\\":\\\\\\\"Sunny\\\\\\\"}]}]}\\\"\";\n                    }\n                })\n                .build());\n\n        Assert.assertTrue(result.isSuccess());\n        Assert.assertEquals(\"Sunny\", result.getResult());\n    }\n\n    @Test\n    public void test_nashorn_uses_return_value_when_codeact_result_not_set() throws Exception {\n        Assume.assumeTrue(\"Nashorn is not available\", isNashornAvailable());\n\n        NashornCodeExecutor executor = new NashornCodeExecutor();\n        CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder()\n                .language(\"js\")\n                .code(\"return 'done';\")\n                .build());\n\n        Assert.assertTrue(result.isSuccess());\n        Assert.assertEquals(\"done\", result.getResult());\n    }\n\n    @Test\n    public void test_nashorn_rejects_python_language() throws Exception {\n        NashornCodeExecutor executor = new NashornCodeExecutor();\n        CodeExecutionResult result = executor.execute(CodeExecutionRequest.builder()\n                .language(\"python\")\n                .code(\"print('hello')\")\n                .build());\n\n        Assert.assertFalse(result.isSuccess());\n        Assert.assertTrue(result.getError().contains(\"only javascript is enabled\"));\n    }\n\n    private boolean isNashornAvailable() {\n        return new ScriptEngineManager().getEngineByName(\"nashorn\") != null;\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/ReActAgentUsageTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.agent.support.ZhipuAgentTestSupport;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventPublisher;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class ReActAgentUsageTest extends ZhipuAgentTestSupport {\n\n    @Test\n    public void test_react_agent_basic_run_with_real_model() throws Exception {\n        // ReAct 基础能力：真实模型完成多步推理并返回文本\n        Agent agent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是架构顾问。先给结论，再给3条执行建议。\")\n                .options(AgentOptions.builder().maxSteps(3).build())\n                .build();\n\n        AgentResult result = callWithProviderGuard(() -> agent.run(AgentRequest.builder().input(\"我们要把单体应用拆到微服务，先从哪里开始？\").build()));\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n    }\n\n    @Test\n    public void test_react_agent_parallel_tool_calls_and_event_lifecycle() throws Exception {\n        // 监听运行事件，验证 ReAct 生命周期事件可观测\n        List<AgentEventType> events = new ArrayList<AgentEventType>();\n        AgentEventPublisher publisher = new AgentEventPublisher();\n        publisher.addListener(event -> events.add(event.getType()));\n\n        Agent agent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .parallelToolCalls(true)\n                .systemPrompt(\"你是技术评审助手，输出精炼结论。\")\n                .instructions(\"围绕可测试性和可维护性给建议。\")\n                .eventPublisher(publisher)\n                .options(AgentOptions.builder().maxSteps(3).build())\n                .build();\n\n        AgentResult result = callWithProviderGuard(() -> agent.run(AgentRequest.builder().input(\"请点评当前项目的测试策略并给改进建议\").build()));\n\n        Assert.assertNotNull(result);\n        Assert.assertTrue(events.contains(AgentEventType.STEP_START));\n        Assert.assertTrue(events.contains(AgentEventType.MODEL_REQUEST));\n        Assert.assertTrue(events.contains(AgentEventType.MODEL_RESPONSE));\n        Assert.assertTrue(events.contains(AgentEventType.FINAL_OUTPUT));\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/ResponsesModelClientTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.listener.ResponseSseListener;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.Response;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseDeleteResponse;\nimport io.github.lnyocly.ai4j.platform.openai.response.entity.ResponseRequest;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\nimport org.junit.Assert;\nimport org.junit.Test;\n\npublic class ResponsesModelClientTest {\n\n    @Test\n    public void test_stream_propagates_stream_execution_options() throws Exception {\n        CapturingResponsesService responsesService = new CapturingResponsesService();\n        ResponsesModelClient client = new ResponsesModelClient(responsesService);\n\n        client.createStream(AgentPrompt.builder()\n                        .model(\"gpt-5-mini\")\n                        .streamExecution(StreamExecutionOptions.builder()\n                                .firstTokenTimeoutMs(4321L)\n                                .idleTimeoutMs(8765L)\n                                .maxRetries(3)\n                                .retryBackoffMs(120L)\n                                .build())\n                        .build(),\n                new AgentModelStreamListener() {\n                });\n\n        Assert.assertNotNull(responsesService.lastRequest);\n        Assert.assertNotNull(responsesService.lastRequest.getStreamExecution());\n        Assert.assertEquals(4321L, responsesService.lastRequest.getStreamExecution().getFirstTokenTimeoutMs());\n        Assert.assertEquals(8765L, responsesService.lastRequest.getStreamExecution().getIdleTimeoutMs());\n        Assert.assertEquals(3, responsesService.lastRequest.getStreamExecution().getMaxRetries());\n        Assert.assertEquals(120L, responsesService.lastRequest.getStreamExecution().getRetryBackoffMs());\n    }\n\n    private static final class CapturingResponsesService implements IResponsesService {\n        private ResponseRequest lastRequest;\n\n        @Override\n        public Response create(String baseUrl, String apiKey, ResponseRequest request) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public Response create(ResponseRequest request) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public void createStream(String baseUrl, String apiKey, ResponseRequest request, ResponseSseListener listener) {\n            lastRequest = request;\n        }\n\n        @Override\n        public void createStream(ResponseRequest request, ResponseSseListener listener) {\n            lastRequest = request;\n        }\n\n        @Override\n        public Response retrieve(String baseUrl, String apiKey, String responseId) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public Response retrieve(String responseId) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public ResponseDeleteResponse delete(String baseUrl, String apiKey, String responseId) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public ResponseDeleteResponse delete(String responseId) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/StateGraphWorkflowTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.agent.workflow.AgentNode;\nimport io.github.lnyocly.ai4j.agent.workflow.StateGraphWorkflow;\nimport io.github.lnyocly.ai4j.agent.workflow.WorkflowContext;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.Arrays;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.concurrent.TimeUnit;\n\npublic class StateGraphWorkflowTest {\n\n    private AiService aiService;\n\n    @Before\n    public void init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ARK_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getenv(\"DOUBAO_API_KEY\");\n        }\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getProperty(\"doubao.api.key\");\n        }\n        Assume.assumeTrue(apiKey != null && !apiKey.isEmpty());\n\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        doubaoConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setDoubaoConfig(doubaoConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n\n        configuration.setOkHttpClient(okHttpClient);\n        aiService = new AiService(configuration);\n    }\n\n    @Test\n    public void test_branching_route() throws Exception {\n        StateGraphWorkflow workflow = new StateGraphWorkflow()\n                .addNode(\"start\", new StaticNode(\"cold\"))\n                .addNode(\"cold\", new StaticNode(\"take_coat\"))\n                .addNode(\"warm\", new StaticNode(\"t_shirt\"))\n                .start(\"start\")\n                .addConditionalEdges(\"start\", (context, request, result) -> {\n                    return \"cold\".equals(result.getOutputText()) ? \"cold\" : \"warm\";\n                });\n\n        AgentResult result = workflow.run(new AgentSession(null, null), AgentRequest.builder().input(\"go\").build());\n        Assert.assertEquals(\"take_coat\", result.getOutputText());\n    }\n\n    @Test\n    public void test_loop_route() throws Exception {\n        AtomicInteger counter = new AtomicInteger(0);\n\n        StateGraphWorkflow workflow = new StateGraphWorkflow()\n                .addNode(\"loop\", new CounterNode(counter))\n                .addNode(\"done\", new StaticNode(\"done\"))\n                .start(\"loop\")\n                .maxSteps(10)\n                .addConditionalEdges(\"loop\", (context, request, result) -> {\n                    Object value = context.get(\"count\");\n                    if (value instanceof Integer && ((Integer) value) < 3) {\n                        return \"loop\";\n                    }\n                    return \"done\";\n                });\n\n        AgentResult result = workflow.run(new AgentSession(null, null), AgentRequest.builder().input(\"start\").build());\n        Assert.assertEquals(\"done\", result.getOutputText());\n        Assert.assertEquals(3, counter.get());\n    }\n\n    @Test\n    public void test_state_graph_with_agents() throws Exception {\n        Agent routerAgent = Agents.react()\n                .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .systemPrompt(\"You are a router. Output only ROUTE_WEATHER or ROUTE_GENERIC.\")\n                .instructions(\"If the user asks about weather or temperature, output ROUTE_WEATHER. Otherwise output ROUTE_GENERIC.\")\n                .options(AgentOptions.builder().maxSteps(1).build())\n                .build();\n\n        Agent weatherAgent = Agents.react()\n                .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .systemPrompt(\"You are a weather assistant. Always call queryWeather before answering.\")\n                .instructions(\"Use queryWeather with the user's location, type=now, days=1.\")\n                .toolRegistry(Arrays.asList(\"queryWeather\"), null)\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        Agent genericAgent = Agents.react()\n                .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .systemPrompt(\"You answer general questions in one short sentence.\")\n                .options(AgentOptions.builder().maxSteps(1).build())\n                .build();\n\n        Agent formatAgent = Agents.react()\n                .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .systemPrompt(\"You format responses into JSON.\")\n                .instructions(\"Return JSON with fields: route, answer.\")\n                .options(AgentOptions.builder().maxSteps(1).build())\n                .build();\n\n        StateGraphWorkflow workflow = new StateGraphWorkflow()\n                .addNode(\"decide\", new NamedNode(\"Decide\", new RoutingAgentNode(routerAgent.newSession())))\n                .addNode(\"weather\", new NamedNode(\"Weather\", new RuntimeAgentNode(weatherAgent.newSession())))\n                .addNode(\"generic\", new NamedNode(\"Generic\", new RuntimeAgentNode(genericAgent.newSession())))\n                .addNode(\"format\", new NamedNode(\"Format\", new FormatAgentNode(formatAgent.newSession())))\n                .start(\"decide\")\n                .addConditionalEdges(\"decide\", (context, request, result) -> String.valueOf(context.get(\"route\")))\n                .addEdge(\"weather\", \"format\")\n                .addEdge(\"generic\", \"format\");\n\n        AgentResult result = workflow.run(new AgentSession(null, null), AgentRequest.builder()\n                .input(\"What is the weather in Beijing today?\")\n                .build());\n\n        System.out.println(\"FINAL OUTPUT: \" + result.getOutputText());\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().contains(\"\\\"route\\\"\"));\n    }\n\n    private static class StaticNode implements AgentNode {\n        private final String output;\n\n        private StaticNode(String output) {\n            this.output = output;\n        }\n\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) {\n            return AgentResult.builder().outputText(output).build();\n        }\n    }\n\n    private static class CounterNode implements AgentNode {\n        private final AtomicInteger counter;\n\n        private CounterNode(AtomicInteger counter) {\n            this.counter = counter;\n        }\n\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) {\n            int current = counter.incrementAndGet();\n            context.put(\"count\", current);\n            return AgentResult.builder().outputText(\"count=\" + current).build();\n        }\n    }\n\n    private static class NamedNode implements AgentNode {\n        private final String name;\n        private final AgentNode delegate;\n\n        private NamedNode(String name, AgentNode delegate) {\n            this.name = name;\n            this.delegate = delegate;\n        }\n\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception {\n            System.out.println(\"NODE START: \" + name);\n            try {\n                AgentResult result = delegate.execute(context, request);\n                System.out.println(\"NODE END: \" + name + \" | status=OK\");\n                return result;\n            } catch (Exception e) {\n                System.out.println(\"NODE END: \" + name + \" | status=ERROR\");\n                throw e;\n            }\n        }\n    }\n\n    private static class RoutingAgentNode implements AgentNode {\n        private final AgentSession session;\n\n        private RoutingAgentNode(AgentSession session) {\n            this.session = session;\n        }\n\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception {\n            AgentResult result = session.run(request);\n            String output = result == null ? null : result.getOutputText();\n            if (output != null && output.contains(\"ROUTE_WEATHER\")) {\n                context.put(\"route\", \"weather\");\n            } else {\n                context.put(\"route\", \"generic\");\n            }\n            return AgentResult.builder()\n                    .outputText(request == null || request.getInput() == null ? null : String.valueOf(request.getInput()))\n                    .rawResponse(result == null ? null : result.getRawResponse())\n                    .build();\n        }\n    }\n\n    private static class RuntimeAgentNode implements AgentNode {\n        private final AgentSession session;\n\n        private RuntimeAgentNode(AgentSession session) {\n            this.session = session;\n        }\n\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception {\n            return session.run(request);\n        }\n    }\n\n    private static class FormatAgentNode implements AgentNode {\n        private final AgentSession session;\n\n        private FormatAgentNode(AgentSession session) {\n            this.session = session;\n        }\n\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception {\n            String route = context.get(\"route\") == null ? \"unknown\" : String.valueOf(context.get(\"route\"));\n            String input = request == null || request.getInput() == null ? \"\" : String.valueOf(request.getInput());\n            AgentRequest formatted = AgentRequest.builder()\n                    .input(\"route=\" + route + \"\\nanswer=\" + input)\n                    .build();\n            return session.run(formatted);\n        }\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/SubAgentParallelFallbackTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffFailureAction;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Deque;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class SubAgentParallelFallbackTest {\n\n    @Test\n    public void test_parallel_subagents_with_fallback_policy() throws Exception {\n        ConcurrencyTracker tracker = new ConcurrencyTracker();\n\n        Agent weatherSubAgent = Agents.react()\n                .modelClient(new TimedSubAgentModelClient(tracker, 180L, \"weather-ok\", false))\n                .model(\"weather-model\")\n                .build();\n\n        Agent formatSubAgent = Agents.react()\n                .modelClient(new TimedSubAgentModelClient(tracker, 180L, null, true))\n                .model(\"format-model\")\n                .build();\n\n        SubAgentDefinition weatherDefinition = SubAgentDefinition.builder()\n                .name(\"weather-agent\")\n                .toolName(\"delegate_weather\")\n                .description(\"Collect weather data\")\n                .agent(weatherSubAgent)\n                .build();\n\n        SubAgentDefinition formatDefinition = SubAgentDefinition.builder()\n                .name(\"format-agent\")\n                .toolName(\"delegate_format\")\n                .description(\"Format weather result\")\n                .agent(formatSubAgent)\n                .build();\n\n        ScriptedModelClient managerClient = new ScriptedModelClient();\n        managerClient.enqueue(toolCallsResult(Arrays.asList(\n                AgentToolCall.builder().callId(\"c1\").name(\"delegate_weather\").arguments(\"{\\\"task\\\":\\\"Beijing\\\"}\").type(\"function_call\").build(),\n                AgentToolCall.builder().callId(\"c2\").name(\"delegate_format\").arguments(\"{\\\"task\\\":\\\"format\\\"}\").type(\"function_call\").build()\n        )));\n        managerClient.enqueue(textResult(\"manager-complete\"));\n\n        ToolExecutor fallbackExecutor = call -> \"{\\\"fallback\\\":true,\\\"tool\\\":\\\"\" + call.getName() + \"\\\"}\";\n\n        Agent manager = Agents.react()\n                .modelClient(managerClient)\n                .model(\"manager-model\")\n                .parallelToolCalls(true)\n                .subAgents(Arrays.asList(weatherDefinition, formatDefinition))\n                .handoffPolicy(HandoffPolicy.builder()\n                        .onError(HandoffFailureAction.FALLBACK_TO_PRIMARY)\n                        .maxRetries(0)\n                        .build())\n                .toolExecutor(fallbackExecutor)\n                .build();\n\n        AgentResult result = manager.run(AgentRequest.builder().input(\"run\").build());\n\n        Assert.assertEquals(\"manager-complete\", result.getOutputText());\n        Assert.assertEquals(2, result.getToolCalls().size());\n        Assert.assertEquals(2, result.getToolResults().size());\n\n        Map<String, String> resultByCallId = toResultMap(result.getToolResults());\n        Assert.assertTrue(resultByCallId.get(\"c1\").contains(\"weather-ok\"));\n        Assert.assertTrue(resultByCallId.get(\"c2\").contains(\"\\\"fallback\\\":true\"));\n\n        Assert.assertTrue(\"expected parallel subagent execution\", tracker.maxConcurrent.get() >= 2);\n    }\n\n    private static Map<String, String> toResultMap(List<AgentToolResult> toolResults) {\n        Map<String, String> map = new HashMap<>();\n        if (toolResults == null) {\n            return map;\n        }\n        for (AgentToolResult result : toolResults) {\n            map.put(result.getCallId(), result.getOutput());\n        }\n        return map;\n    }\n\n    private static AgentModelResult textResult(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .toolCalls(new ArrayList<>())\n                .memoryItems(new ArrayList<>())\n                .build();\n    }\n\n    private static AgentModelResult toolCallsResult(List<AgentToolCall> calls) {\n        return AgentModelResult.builder()\n                .toolCalls(calls)\n                .memoryItems(new ArrayList<>())\n                .build();\n    }\n\n    private static class ScriptedModelClient implements AgentModelClient {\n        private final Deque<AgentModelResult> queue = new ArrayDeque<>();\n\n        private void enqueue(AgentModelResult result) {\n            queue.add(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n\n    private static class TimedSubAgentModelClient implements AgentModelClient {\n        private final ConcurrencyTracker tracker;\n        private final long sleepMs;\n        private final String output;\n        private final boolean shouldFail;\n\n        private TimedSubAgentModelClient(ConcurrencyTracker tracker, long sleepMs, String output, boolean shouldFail) {\n            this.tracker = tracker;\n            this.sleepMs = sleepMs;\n            this.output = output;\n            this.shouldFail = shouldFail;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            int active = tracker.active.incrementAndGet();\n            tracker.maxConcurrent.accumulateAndGet(active, Math::max);\n            try {\n                Thread.sleep(sleepMs);\n                if (shouldFail) {\n                    throw new RuntimeException(\"subagent-failure\");\n                }\n                return textResult(output);\n            } catch (InterruptedException e) {\n                Thread.currentThread().interrupt();\n                throw new RuntimeException(e);\n            } finally {\n                tracker.active.decrementAndGet();\n            }\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n\n    private static class ConcurrencyTracker {\n        private final AtomicInteger active = new AtomicInteger();\n        private final AtomicInteger maxConcurrent = new AtomicInteger();\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/SubAgentRuntimeTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventPublisher;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Deque;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class SubAgentRuntimeTest {\n\n    @Test\n    public void testSubagentToolDelegationAndExposure() throws Exception {\n        ScriptedModelClient reviewerClient = new ScriptedModelClient();\n        reviewerClient.enqueue(resultWithText(\"subagent-review-ready\"));\n\n        Agent reviewer = Agents.react()\n                .modelClient(reviewerClient)\n                .model(\"reviewer-model\")\n                .build();\n\n        String subToolName = \"delegate_code_review\";\n        SubAgentDefinition reviewerSubAgent = SubAgentDefinition.builder()\n                .name(\"code-reviewer\")\n                .description(\"Review code quality and risks\")\n                .toolName(subToolName)\n                .agent(reviewer)\n                .build();\n\n        ScriptedModelClient parentClient = new ScriptedModelClient();\n        parentClient.enqueue(resultWithToolCall(\"call_1\", subToolName, \"{\\\"task\\\":\\\"Review the auth flow\\\"}\"));\n        parentClient.enqueue(resultWithText(\"main-final-answer\"));\n\n        Agent parent = Agents.react()\n                .modelClient(parentClient)\n                .model(\"manager-model\")\n                .subAgent(reviewerSubAgent)\n                .build();\n\n        AgentResult result = parent.run(AgentRequest.builder().input(\"analyze\").build());\n\n        Assert.assertEquals(\"main-final-answer\", result.getOutputText());\n        Assert.assertEquals(1, result.getToolResults().size());\n        Assert.assertTrue(result.getToolResults().get(0).getOutput().contains(\"subagent-review-ready\"));\n\n        AgentPrompt firstPrompt = parentClient.prompts.get(0);\n        Assert.assertTrue(hasTool(firstPrompt.getTools(), subToolName));\n    }\n\n    @Test\n    public void testParallelSubagentExecutionForCodexStyleDelegation() throws Exception {\n        ConcurrentModelClient sharedSubClient = new ConcurrentModelClient(250L, \"subagent-output\");\n\n        Agent weatherSubAgent = Agents.react()\n                .modelClient(sharedSubClient)\n                .model(\"sub-model\")\n                .build();\n\n        Agent formatSubAgent = Agents.react()\n                .modelClient(sharedSubClient)\n                .model(\"sub-model\")\n                .build();\n\n        SubAgentDefinition weather = SubAgentDefinition.builder()\n                .name(\"weather\")\n                .toolName(\"delegate_weather\")\n                .description(\"Collect weather details\")\n                .agent(weatherSubAgent)\n                .build();\n\n        SubAgentDefinition format = SubAgentDefinition.builder()\n                .name(\"format\")\n                .toolName(\"delegate_format\")\n                .description(\"Format weather report\")\n                .agent(formatSubAgent)\n                .build();\n\n        ScriptedModelClient parentClient = new ScriptedModelClient();\n        parentClient.enqueue(resultWithToolCalls(Arrays.asList(\n                AgentToolCall.builder().callId(\"c_weather\").name(\"delegate_weather\").arguments(\"{\\\"task\\\":\\\"Beijing\\\"}\").build(),\n                AgentToolCall.builder().callId(\"c_format\").name(\"delegate_format\").arguments(\"{\\\"task\\\":\\\"Shanghai\\\"}\").build()\n        )));\n        parentClient.enqueue(resultWithText(\"done\"));\n\n        Agent parent = Agents.react()\n                .modelClient(parentClient)\n                .model(\"manager-model\")\n                .parallelToolCalls(true)\n                .subAgents(Arrays.asList(weather, format))\n                .build();\n\n        AgentResult result = parent.run(AgentRequest.builder().input(\"parallel\").build());\n\n        Assert.assertEquals(\"done\", result.getOutputText());\n        Assert.assertTrue(\"expected parallel subagent execution\", sharedSubClient.maxConcurrent.get() >= 2);\n    }\n\n    @Test\n    public void testSubagentHandoffEventsArePublished() throws Exception {\n        ScriptedModelClient reviewerClient = new ScriptedModelClient();\n        reviewerClient.enqueue(resultWithText(\"subagent-review-ready\"));\n\n        Agent reviewer = Agents.react()\n                .modelClient(reviewerClient)\n                .model(\"reviewer-model\")\n                .build();\n\n        SubAgentDefinition reviewerSubAgent = SubAgentDefinition.builder()\n                .name(\"code-reviewer\")\n                .description(\"Review code quality and risks\")\n                .toolName(\"delegate_code_review\")\n                .agent(reviewer)\n                .build();\n\n        ScriptedModelClient parentClient = new ScriptedModelClient();\n        parentClient.enqueue(resultWithToolCall(\"call_1\", \"delegate_code_review\", \"{\\\"task\\\":\\\"Review the auth flow\\\"}\"));\n        parentClient.enqueue(resultWithText(\"main-final-answer\"));\n\n        final List<AgentEvent> events = new ArrayList<AgentEvent>();\n        AgentEventPublisher publisher = new AgentEventPublisher();\n        publisher.addListener(new AgentListener() {\n            @Override\n            public void onEvent(AgentEvent event) {\n                events.add(event);\n            }\n        });\n\n        Agent parent = Agents.react()\n                .modelClient(parentClient)\n                .model(\"manager-model\")\n                .subAgent(reviewerSubAgent)\n                .eventPublisher(publisher)\n                .build();\n\n        AgentResult result = parent.run(AgentRequest.builder().input(\"analyze\").build());\n\n        Assert.assertEquals(\"main-final-answer\", result.getOutputText());\n\n        AgentEvent startEvent = firstEvent(events, AgentEventType.HANDOFF_START);\n        AgentEvent endEvent = firstEvent(events, AgentEventType.HANDOFF_END);\n        Assert.assertNotNull(startEvent);\n        Assert.assertNotNull(endEvent);\n\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> startPayload = (Map<String, Object>) startEvent.getPayload();\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> endPayload = (Map<String, Object>) endEvent.getPayload();\n        Assert.assertEquals(\"code-reviewer\", startPayload.get(\"subagent\"));\n        Assert.assertEquals(\"delegate_code_review\", startPayload.get(\"tool\"));\n        Assert.assertEquals(\"starting\", startPayload.get(\"status\"));\n        Assert.assertEquals(\"completed\", endPayload.get(\"status\"));\n        Assert.assertEquals(\"subagent-review-ready\", endPayload.get(\"output\"));\n    }\n\n    @Test\n    public void testTeamSubagentPublishesTeamTaskEventsToParent() throws Exception {\n        ScriptedModelClient teamMemberClient = new ScriptedModelClient();\n        teamMemberClient.enqueue(resultWithText(\"team-member-ready\"));\n\n        ScriptedModelClient teamSynthClient = new ScriptedModelClient();\n        teamSynthClient.enqueue(resultWithText(\"team-subagent-final\"));\n\n        Agent teamSubagent = Agents.team()\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"review-task\")\n                                        .memberId(\"reviewer\")\n                                        .task(\"Review the patch\")\n                                        .build()\n                        ))\n                        .build())\n                .synthesizerAgent(Agents.react()\n                        .modelClient(teamSynthClient)\n                        .model(\"team-synth\")\n                        .build())\n                .member(AgentTeamMember.builder()\n                        .id(\"reviewer\")\n                        .name(\"Reviewer\")\n                        .agent(Agents.react()\n                                .modelClient(teamMemberClient)\n                                .model(\"team-member\")\n                                .build())\n                        .build())\n                .buildAgent();\n\n        SubAgentDefinition reviewerSubAgent = SubAgentDefinition.builder()\n                .name(\"team-reviewer\")\n                .description(\"Review code quality with a small team\")\n                .toolName(\"delegate_team_review\")\n                .agent(teamSubagent)\n                .build();\n\n        ScriptedModelClient parentClient = new ScriptedModelClient();\n        parentClient.enqueue(resultWithToolCall(\"call_team\", \"delegate_team_review\", \"{\\\"task\\\":\\\"Review the auth flow\\\"}\"));\n        parentClient.enqueue(resultWithText(\"main-final-answer\"));\n\n        final List<AgentEvent> events = new ArrayList<AgentEvent>();\n        AgentEventPublisher publisher = new AgentEventPublisher();\n        publisher.addListener(new AgentListener() {\n            @Override\n            public void onEvent(AgentEvent event) {\n                events.add(event);\n            }\n        });\n\n        Agent parent = Agents.react()\n                .modelClient(parentClient)\n                .model(\"manager-model\")\n                .subAgent(reviewerSubAgent)\n                .eventPublisher(publisher)\n                .build();\n\n        AgentResult result = parent.run(AgentRequest.builder().input(\"analyze\").build());\n\n        Assert.assertEquals(\"main-final-answer\", result.getOutputText());\n        Assert.assertNotNull(firstEvent(events, AgentEventType.TEAM_TASK_CREATED));\n        AgentEvent updated = firstEventByStatus(events, AgentEventType.TEAM_TASK_UPDATED, \"completed\");\n        Assert.assertNotNull(updated);\n\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> updatedPayload = (Map<String, Object>) updated.getPayload();\n        Assert.assertEquals(\"completed\", updatedPayload.get(\"status\"));\n        Assert.assertEquals(\"team-member-ready\", updatedPayload.get(\"output\"));\n    }\n\n    private static AgentEvent firstEvent(List<AgentEvent> events, AgentEventType type) {\n        if (events == null || type == null) {\n            return null;\n        }\n        for (AgentEvent event : events) {\n            if (event != null && type == event.getType()) {\n                return event;\n            }\n        }\n        return null;\n    }\n\n    private static AgentEvent firstEventByStatus(List<AgentEvent> events, AgentEventType type, String status) {\n        if (events == null || type == null) {\n            return null;\n        }\n        for (AgentEvent event : events) {\n            if (event == null || type != event.getType() || !(event.getPayload() instanceof Map)) {\n                continue;\n            }\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> payload = (Map<String, Object>) event.getPayload();\n            Object currentStatus = payload.get(\"status\");\n            if (status == null ? currentStatus == null : status.equals(String.valueOf(currentStatus))) {\n                return event;\n            }\n        }\n        return null;\n    }\n\n    private static boolean hasTool(List<Object> tools, String name) {\n        if (tools == null) {\n            return false;\n        }\n        for (Object tool : tools) {\n            if (tool instanceof Tool) {\n                Tool.Function fn = ((Tool) tool).getFunction();\n                if (fn != null && name.equals(fn.getName())) {\n                    return true;\n                }\n            }\n        }\n        return false;\n    }\n\n    private static AgentModelResult resultWithText(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .memoryItems(new ArrayList<Object>())\n                .toolCalls(new ArrayList<AgentToolCall>())\n                .build();\n    }\n\n    private static AgentModelResult resultWithToolCall(String callId, String toolName, String args) {\n        return resultWithToolCalls(Arrays.asList(\n                AgentToolCall.builder()\n                        .callId(callId)\n                        .name(toolName)\n                        .arguments(args)\n                        .type(\"function_call\")\n                        .build()\n        ));\n    }\n\n    private static AgentModelResult resultWithToolCalls(List<AgentToolCall> calls) {\n        return AgentModelResult.builder()\n                .toolCalls(calls)\n                .memoryItems(new ArrayList<Object>())\n                .build();\n    }\n\n    private static class ScriptedModelClient implements AgentModelClient {\n        private final Deque<AgentModelResult> queue = new ArrayDeque<>();\n        private final List<AgentPrompt> prompts = new ArrayList<>();\n\n        private void enqueue(AgentModelResult result) {\n            queue.add(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            prompts.add(prompt);\n            return queue.isEmpty() ? AgentModelResult.builder().build() : queue.poll();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n\n    private static class ConcurrentModelClient implements AgentModelClient {\n        private final long sleepMs;\n        private final String output;\n        private final AtomicInteger active = new AtomicInteger();\n        private final AtomicInteger maxConcurrent = new AtomicInteger();\n\n        private ConcurrentModelClient(long sleepMs, String output) {\n            this.sleepMs = sleepMs;\n            this.output = output;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            int concurrent = active.incrementAndGet();\n            maxConcurrent.accumulateAndGet(concurrent, Math::max);\n            try {\n                Thread.sleep(sleepMs);\n            } catch (InterruptedException e) {\n                Thread.currentThread().interrupt();\n            } finally {\n                active.decrementAndGet();\n            }\n            return resultWithText(output);\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            throw new UnsupportedOperationException(\"stream not used in test\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/SubAgentUsageTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.agent.support.ZhipuAgentTestSupport;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffFailureAction;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Collections;\n\npublic class SubAgentUsageTest extends ZhipuAgentTestSupport {\n\n    @Test\n    public void test_subagent_delegation_with_real_llm() throws Exception {\n        // 子代理配置示例：在 Chat 模式下先验证“可挂载 + 可运行”的主流程\n        Agent reviewer = Agents.builder()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是代码审查专家。输出3条高风险问题。\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        SubAgentDefinition reviewerSubAgent = SubAgentDefinition.builder()\n                .name(\"reviewer\")\n                .description(\"负责代码风险审查\")\n                .toolName(\"delegate_code_review\")\n                .agent(reviewer)\n                .build();\n\n        Agent lead = Agents.builder()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .toolChoice(\"none\")\n                .systemPrompt(\"你是团队负责人。直接输出审查结论，不要调用任何工具。\")\n                .subAgent(reviewerSubAgent)\n                .options(AgentOptions.builder().maxSteps(3).build())\n                .build();\n\n        AgentResult result = callWithProviderGuard(() -> lead.run(AgentRequest.builder().input(\"审查一个登录模块，重点关注安全风险\").build()));\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n    }\n\n    @Test\n    public void test_handoff_policy_denied_with_fallback_executor() throws Exception {\n        // HandoffPolicy 配置示例：展示 allow/deny 与 fallback 的配置方式\n        Agent subAgent = Agents.builder()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是子代理\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        SubAgentDefinition definition = SubAgentDefinition.builder()\n                .name(\"planner\")\n                .description(\"负责规划\")\n                .toolName(\"delegate_planning\")\n                .agent(subAgent)\n                .build();\n\n        Agent parent = Agents.builder()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .toolChoice(\"none\")\n                .toolExecutor(call -> \"{\\\"fallback\\\":true,\\\"tool\\\":\\\"\" + call.getName() + \"\\\"}\")\n                .subAgent(definition)\n                .handoffPolicy(HandoffPolicy.builder()\n                        .allowedTools(Collections.singleton(\"delegate_other\"))\n                        .onDenied(HandoffFailureAction.FALLBACK_TO_PRIMARY)\n                        .build())\n                .systemPrompt(\"你是主代理。直接输出简短发布建议，不要调用任何工具。\")\n                .options(AgentOptions.builder().maxSteps(3).build())\n                .build();\n\n        AgentResult result = callWithProviderGuard(() -> parent.run(AgentRequest.builder().input(\"给一个两天发布窗口的变更建议\").build()));\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/ToolUtilExecutorRestrictionTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolUtilExecutor;\nimport org.junit.Test;\n\nimport java.util.Collections;\n\npublic class ToolUtilExecutorRestrictionTest {\n\n    @Test(expected = IllegalArgumentException.class)\n    public void testRejectsUnknownTool() throws Exception {\n        ToolUtilExecutor executor = new ToolUtilExecutor(Collections.singleton(\"allowed_tool\"));\n        AgentToolCall call = AgentToolCall.builder()\n                .name(\"not_allowed\")\n                .arguments(\"{}\")\n                .build();\n        executor.execute(call);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/UniversalAgentUsageTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.agent.support.ZhipuAgentTestSupport;\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class UniversalAgentUsageTest extends ZhipuAgentTestSupport {\n\n    @Test\n    public void test_basic_agent_build_and_run() throws Exception {\n        // 基础搭建：最小可用 Agent\n        Agent agent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .systemPrompt(\"你是一个简洁助手，只输出一行结论，不要废话。\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        AgentResult result = callWithProviderGuard(() -> agent.run(AgentRequest.builder().input(\"请用一句话说明为什么代码评审重要\").build()));\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n    }\n\n    @Test\n    public void test_advanced_parameters_and_session_memory() throws Exception {\n        // 高级参数：采样参数、额外请求体、用户标识\n        Map<String, Object> reasoning = new HashMap<>();\n        reasoning.put(\"effort\", \"low\");\n\n        Map<String, Object> extraBody = new HashMap<>();\n        extraBody.put(\"metadata\", \"agent-usage-test\");\n\n        Agent agent = Agents.builder()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.3)\n                .topP(0.9)\n                .maxOutputTokens(512)\n                .reasoning(reasoning)\n                .parallelToolCalls(false)\n                .store(false)\n                .user(\"agent-demo-user\")\n                .extraBody(extraBody)\n                .instructions(\"回答时尽量精炼。\")\n                .options(AgentOptions.builder().maxSteps(3).build())\n                .build();\n\n        AgentSession session = agent.newSession();\n\n        callWithProviderGuard(() -> {\n            session.run(AgentRequest.builder().input(\"记住口令：ALPHA-2026-XYZ。只回复：收到\").build());\n            return null;\n        });\n        AgentResult secondTurn = callWithProviderGuard(() -> session.run(AgentRequest.builder().input(\"请只回复上一个口令，不要解释\").build()));\n\n        Assert.assertNotNull(secondTurn);\n        Assert.assertNotNull(secondTurn.getOutputText());\n        Assert.assertTrue(secondTurn.getOutputText().contains(\"ALPHA-2026-XYZ\"));\n    }\n\n    @Test\n    public void test_stream_mode_with_real_chat_model() throws Exception {\n        // stream=true 会走 createStream 分支，ChatModelClient 内部会安全降级到非流式请求\n        Agent agent = Agents.react()\n                .modelClient(chatModelClient())\n                .model(model)\n                .temperature(0.7)\n                .options(AgentOptions.builder().stream(true).maxSteps(2).build())\n                .build();\n\n        AgentResult result = callWithProviderGuard(() -> agent.run(AgentRequest.builder().input(\"给我一个 Java 代码重构建议\").build()));\n\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().trim().length() > 0);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/WeatherAgentWorkflowTest.java",
    "content": "package io.github.lnyocly.agent;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.agent.workflow.AgentNode;\nimport io.github.lnyocly.ai4j.agent.workflow.RuntimeAgentNode;\nimport io.github.lnyocly.ai4j.agent.workflow.SequentialWorkflow;\nimport io.github.lnyocly.ai4j.agent.workflow.WorkflowAgent;\nimport io.github.lnyocly.ai4j.agent.workflow.WorkflowContext;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Test;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.Arrays;\nimport java.util.concurrent.TimeUnit;\n\npublic class WeatherAgentWorkflowTest {\n\n    private AiService aiService;\n\n    @Before\n    public void init() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ARK_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getenv(\"DOUBAO_API_KEY\");\n        }\n        Assume.assumeTrue(apiKey != null && !apiKey.isEmpty());\n\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        doubaoConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setDoubaoConfig(doubaoConfig);\n\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n\n        configuration.setOkHttpClient(okHttpClient);\n        aiService = new AiService(configuration);\n    }\n\n    @Test\n    public void test_weather_workflow() throws Exception {\n        Agent weatherAgent = Agents.react()\n                .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .systemPrompt(\"You are a weather analyst. Always call queryWeather before answering.\")\n                .instructions(\"Use queryWeather with the user's location, type=now, days=1.\")\n                .toolRegistry(Arrays.asList(\"queryWeather\"), null)\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        Agent formatAgent = Agents.react()\n                .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n                .model(\"doubao-seed-1-8-251228\")\n                .systemPrompt(\"You format weather analysis into strict JSON.\")\n                .instructions(\"Return JSON with fields: city, summary, advice.\")\n                .options(AgentOptions.builder().maxSteps(2).build())\n                .build();\n\n        SequentialWorkflow workflow = new SequentialWorkflow()\n                .addNode(new NamedNode(\"WeatherAnalysis\", new RuntimeAgentNode(weatherAgent.newSession())))\n                .addNode(new NamedNode(\"FormatOutput\", new RuntimeAgentNode(formatAgent.newSession())));\n\n        WorkflowAgent runner = new WorkflowAgent(workflow, weatherAgent.newSession());\n        AgentResult result = runner.run(AgentRequest.builder()\n                .input(\"Get the current weather in Beijing and provide advice.\")\n                .build());\n\n        System.out.println(\"FINAL OUTPUT: \" + result.getOutputText());\n        Assert.assertNotNull(result);\n        Assert.assertNotNull(result.getOutputText());\n        Assert.assertTrue(result.getOutputText().length() > 0);\n    }\n\n    private static class NamedNode implements AgentNode {\n        private final String name;\n        private final AgentNode delegate;\n\n        private NamedNode(String name, AgentNode delegate) {\n            this.name = name;\n            this.delegate = delegate;\n        }\n\n        @Override\n        public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception {\n            System.out.println(\"NODE START: \" + name);\n            try {\n                AgentResult result = delegate.execute(context, request);\n                System.out.println(\"NODE END: \" + name + \" | status=OK\");\n                return result;\n            } catch (Exception e) {\n                System.out.println(\"NODE END: \" + name + \" | status=ERROR\");\n                throw e;\n            }\n        }\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/agent/support/ZhipuAgentTestSupport.java",
    "content": "package io.github.lnyocly.agent.support;\n\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.config.ZhipuConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assume;\nimport org.junit.Before;\n\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.concurrent.TimeUnit;\n\npublic abstract class ZhipuAgentTestSupport {\n\n    protected static final String DEFAULT_API_KEY = \"1cbd1960cdc7e9144ded698a9763569b.seHlVxdOq3eTnY9m\";\n    protected static final String DEFAULT_MODEL = \"GLM-4.5-Flash\";\n\n    protected AiService aiService;\n    protected String model;\n\n    @Before\n    public void setupZhipuAiService() throws NoSuchAlgorithmException, KeyManagementException {\n        String apiKey = System.getenv(\"ZHIPU_API_KEY\");\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = System.getProperty(\"zhipu.api.key\");\n        }\n        if (apiKey == null || apiKey.isEmpty()) {\n            apiKey = DEFAULT_API_KEY;\n        }\n\n        model = System.getenv(\"ZHIPU_MODEL\");\n        if (model == null || model.isEmpty()) {\n            model = System.getProperty(\"zhipu.model\");\n        }\n        if (model == null || model.isEmpty()) {\n            model = DEFAULT_MODEL;\n        }\n\n        ZhipuConfig zhipuConfig = new ZhipuConfig();\n        zhipuConfig.setApiKey(apiKey);\n\n        Configuration configuration = new Configuration();\n        configuration.setZhipuConfig(zhipuConfig);\n\n        HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();\n        loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BASIC);\n\n        OkHttpClient okHttpClient = new OkHttpClient.Builder()\n                .addInterceptor(loggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier())\n                .build();\n\n        configuration.setOkHttpClient(okHttpClient);\n        aiService = new AiService(configuration);\n    }\n\n    protected ChatModelClient chatModelClient() {\n        return new ChatModelClient(aiService.getChatService(PlatformType.ZHIPU));\n    }\n\n    protected <T> T callWithProviderGuard(ThrowingSupplier<T> supplier) throws Exception {\n        try {\n            return supplier.get();\n        } catch (Exception ex) {\n            skipIfProviderUnavailable(ex);\n            throw ex;\n        }\n    }\n\n    protected void skipIfProviderUnavailable(Throwable throwable) {\n        if (isProviderUnavailable(throwable)) {\n            String reason = extractRootMessage(throwable);\n            Assume.assumeTrue(\"Skip due provider limit/unavailable: \" + reason, false);\n        }\n    }\n\n    private boolean isProviderUnavailable(Throwable throwable) {\n        Throwable current = throwable;\n        while (current != null) {\n            String message = current.getMessage();\n            if (message != null) {\n                String lower = message.toLowerCase();\n                if (lower.contains(\"timeout\")\n                        || lower.contains(\"rate limit\")\n                        || lower.contains(\"too many requests\")\n                        || lower.contains(\"quota\")\n                        || lower.contains(\"inference limit\")\n                        || message.contains(\"频次\")\n                        || message.contains(\"限流\")\n                        || message.contains(\"额度\")\n                        || message.contains(\"配额\")\n                        || message.contains(\"模型服务已暂停\")\n                        || message.contains(\"账户已达到\")) {\n                    return true;\n                }\n            }\n            current = current.getCause();\n        }\n        return false;\n    }\n\n    private String extractRootMessage(Throwable throwable) {\n        Throwable current = throwable;\n        Throwable last = throwable;\n        while (current != null) {\n            last = current;\n            current = current.getCause();\n        }\n        String message = last == null ? null : last.getMessage();\n        return message == null || message.trim().isEmpty() ? \"unknown error\" : message;\n    }\n\n    @FunctionalInterface\n    protected interface ThrowingSupplier<T> {\n        T get() throws Exception;\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/ai4j/agent/flowgram/FlowGramRuntimeServiceTest.java",
    "content": "package io.github.lnyocly.ai4j.agent.flowgram;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramEdgeSchema;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskResultOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunInput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskValidateOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramWorkflowSchema;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\n\npublic class FlowGramRuntimeServiceTest {\n\n    @Test\n    public void shouldValidateWorkflowShape() {\n        FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner());\n        try {\n            FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder()\n                    .nodes(Collections.singletonList(node(\"llm_0\", \"LLM\", llmData(ref(\"start_0\", \"prompt\")))))\n                    .edges(Collections.<FlowGramEdgeSchema>emptyList())\n                    .build();\n\n            FlowGramTaskValidateOutput result = service.validateTask(FlowGramTaskRunInput.builder()\n                    .schema(JSON.toJSONString(schema))\n                    .inputs(mapOf(\"prompt\", \"hello\"))\n                    .build());\n\n            assertFalse(result.isValid());\n            assertTrue(result.getErrors().contains(\"FlowGram workflow must contain exactly one Start node\"));\n            assertTrue(result.getErrors().contains(\"FlowGram workflow must contain at least one End node\"));\n        } finally {\n            service.close();\n        }\n    }\n\n    @Test\n    public void shouldRunSimpleStartLlmEndWorkflow() throws Exception {\n        FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner());\n        try {\n            FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder()\n                    .nodes(Arrays.asList(\n                            node(\"start_0\", \"Start\", startData()),\n                            node(\"llm_0\", \"LLM\", llmData(ref(\"start_0\", \"prompt\"))),\n                            node(\"end_0\", \"End\", endData(ref(\"llm_0\", \"result\")))\n                    ))\n                    .edges(Arrays.asList(\n                            edge(\"start_0\", \"llm_0\"),\n                            edge(\"llm_0\", \"end_0\")\n                    ))\n                    .build();\n\n            FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder()\n                    .schema(JSON.toJSONString(schema))\n                    .inputs(mapOf(\"prompt\", \"hello-flowgram\"))\n                    .build());\n\n            FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID());\n            FlowGramTaskReportOutput report = service.getTaskReport(runOutput.getTaskID());\n            assertEquals(\"success\", result.getStatus());\n            assertEquals(\"echo:hello-flowgram\", result.getResult().get(\"result\"));\n            assertEquals(\"hello-flowgram\", report.getInputs().get(\"prompt\"));\n            assertEquals(\"echo:hello-flowgram\", report.getOutputs().get(\"result\"));\n            assertEquals(\"hello-flowgram\", report.getNodes().get(\"start_0\").getInputs().get(\"prompt\"));\n            assertEquals(\"echo:hello-flowgram\", report.getNodes().get(\"llm_0\").getOutputs().get(\"result\"));\n            assertTrue(report.getNodes().get(\"end_0\").isTerminated());\n        } finally {\n            service.close();\n        }\n    }\n\n    @Test\n    public void shouldRouteConditionBranch() throws Exception {\n        FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner());\n        try {\n            FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder()\n                    .nodes(Arrays.asList(\n                            node(\"start_0\", \"Start\", startNumberData()),\n                            node(\"condition_0\", \"Condition\", conditionData()),\n                            node(\"end_pass\", \"End\", endData(constant(\"passed\"))),\n                            node(\"end_fail\", \"End\", endData(constant(\"failed\")))\n                    ))\n                    .edges(Arrays.asList(\n                            edge(\"start_0\", \"condition_0\"),\n                            edge(\"condition_0\", \"end_pass\", \"pass\"),\n                            edge(\"condition_0\", \"end_fail\", \"fail\")\n                    ))\n                    .build();\n\n            FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder()\n                    .schema(JSON.toJSONString(schema))\n                    .inputs(mapOf(\"score\", 88))\n                    .build());\n\n            FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID());\n            assertEquals(\"passed\", result.getResult().get(\"result\"));\n        } finally {\n            service.close();\n        }\n    }\n\n    @Test\n    public void shouldAggregateLoopOutputs() throws Exception {\n        FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner());\n        try {\n            FlowGramNodeSchema loopNode = node(\"loop_0\", \"Loop\", loopData());\n            loopNode.setBlocks(Collections.singletonList(\n                    node(\"llm_1\", \"LLM\", llmData(ref(\"loop_0_locals\", \"item\")))\n            ));\n\n            FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder()\n                    .nodes(Arrays.asList(\n                            node(\"start_0\", \"Start\", startCitiesData()),\n                            loopNode,\n                            node(\"end_0\", \"End\", endArrayData(ref(\"loop_0\", \"suggestions\")))\n                    ))\n                    .edges(Arrays.asList(\n                            edge(\"start_0\", \"loop_0\"),\n                            edge(\"loop_0\", \"end_0\")\n                    ))\n                    .build();\n\n            FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder()\n                    .schema(JSON.toJSONString(schema))\n                    .inputs(mapOf(\"cities\", Arrays.asList(\"beijing\", \"shanghai\")))\n                    .build());\n\n            FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID());\n            @SuppressWarnings(\"unchecked\")\n            List<String> suggestions = (List<String>) result.getResult().get(\"result\");\n            assertEquals(Arrays.asList(\"echo:beijing\", \"echo:shanghai\"), suggestions);\n        } finally {\n            service.close();\n        }\n    }\n\n    @Test\n    public void shouldCancelTask() throws Exception {\n        FlowGramRuntimeService service = new FlowGramRuntimeService(new SlowRunner());\n        try {\n            FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder()\n                    .nodes(Arrays.asList(\n                            node(\"start_0\", \"Start\", startData()),\n                            node(\"llm_0\", \"LLM\", llmData(ref(\"start_0\", \"prompt\"))),\n                            node(\"end_0\", \"End\", endData(ref(\"llm_0\", \"result\")))\n                    ))\n                    .edges(Arrays.asList(\n                            edge(\"start_0\", \"llm_0\"),\n                            edge(\"llm_0\", \"end_0\")\n                    ))\n                    .build();\n\n            FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder()\n                    .schema(JSON.toJSONString(schema))\n                    .inputs(mapOf(\"prompt\", \"cancel-me\"))\n                    .build());\n\n            waitForProcessing(service, runOutput.getTaskID(), \"llm_0\");\n            assertTrue(service.cancelTask(runOutput.getTaskID()).isSuccess());\n\n            FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID());\n            assertEquals(\"canceled\", result.getStatus());\n        } finally {\n            service.close();\n        }\n    }\n\n    @Test\n    public void shouldExposeWorkflowFailureReasonInResultAndReport() throws Exception {\n        FlowGramRuntimeService service = new FlowGramRuntimeService(new FailingRunner());\n        try {\n            FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder()\n                    .nodes(Arrays.asList(\n                            node(\"start_0\", \"Start\", startData()),\n                            node(\"llm_0\", \"LLM\", llmData(ref(\"start_0\", \"prompt\"))),\n                            node(\"end_0\", \"End\", endData(ref(\"llm_0\", \"result\")))\n                    ))\n                    .edges(Arrays.asList(\n                            edge(\"start_0\", \"llm_0\"),\n                            edge(\"llm_0\", \"end_0\")\n                    ))\n                    .build();\n\n            FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder()\n                    .schema(JSON.toJSONString(schema))\n                    .inputs(mapOf(\"prompt\", \"boom\"))\n                    .build());\n\n            FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID());\n            FlowGramTaskReportOutput report = service.getTaskReport(runOutput.getTaskID());\n\n            assertEquals(\"failed\", result.getStatus());\n            assertEquals(\"runner-boom\", result.getError());\n            assertEquals(\"failed\", report.getWorkflow().getStatus());\n            assertEquals(\"runner-boom\", report.getWorkflow().getError());\n            assertEquals(\"runner-boom\", report.getNodes().get(\"llm_0\").getError());\n            assertTrue(report.getWorkflow().getStartTime() != null);\n            assertTrue(report.getWorkflow().getEndTime() != null);\n        } finally {\n            service.close();\n        }\n    }\n\n    @Test\n    public void shouldPublishRuntimeListenerEventsForSuccessfulTask() throws Exception {\n        final List<FlowGramRuntimeEvent> events = Collections.synchronizedList(new ArrayList<FlowGramRuntimeEvent>());\n        FlowGramRuntimeService service = new FlowGramRuntimeService(new EchoRunner())\n                .registerListener(new FlowGramRuntimeListener() {\n                    @Override\n                    public void onEvent(FlowGramRuntimeEvent event) {\n                        events.add(event);\n                    }\n                });\n        try {\n            FlowGramWorkflowSchema schema = FlowGramWorkflowSchema.builder()\n                    .nodes(Arrays.asList(\n                            node(\"start_0\", \"Start\", startData()),\n                            node(\"llm_0\", \"LLM\", llmData(ref(\"start_0\", \"prompt\"))),\n                            node(\"end_0\", \"End\", endData(ref(\"llm_0\", \"result\")))\n                    ))\n                    .edges(Arrays.asList(\n                            edge(\"start_0\", \"llm_0\"),\n                            edge(\"llm_0\", \"end_0\")\n                    ))\n                    .build();\n\n            FlowGramTaskRunOutput runOutput = service.runTask(FlowGramTaskRunInput.builder()\n                    .schema(JSON.toJSONString(schema))\n                    .inputs(mapOf(\"prompt\", \"listener-test\"))\n                    .build());\n\n            FlowGramTaskResultOutput result = awaitResult(service, runOutput.getTaskID());\n            awaitEvent(events, FlowGramRuntimeEvent.Type.TASK_FINISHED);\n\n            assertEquals(\"success\", result.getStatus());\n\n            List<FlowGramRuntimeEvent.Type> eventTypes = new ArrayList<FlowGramRuntimeEvent.Type>();\n            for (FlowGramRuntimeEvent event : snapshot(events)) {\n                eventTypes.add(event.getType());\n            }\n\n            assertEquals(Arrays.asList(\n                    FlowGramRuntimeEvent.Type.TASK_STARTED,\n                    FlowGramRuntimeEvent.Type.NODE_STARTED,\n                    FlowGramRuntimeEvent.Type.NODE_FINISHED,\n                    FlowGramRuntimeEvent.Type.NODE_STARTED,\n                    FlowGramRuntimeEvent.Type.NODE_FINISHED,\n                    FlowGramRuntimeEvent.Type.NODE_STARTED,\n                    FlowGramRuntimeEvent.Type.NODE_FINISHED,\n                    FlowGramRuntimeEvent.Type.TASK_FINISHED\n            ), eventTypes);\n        } finally {\n            service.close();\n        }\n    }\n\n    @Test\n    public void shouldRunAi4jBackedLlmNodeRunner() throws Exception {\n        Ai4jFlowGramLlmNodeRunner runner = new Ai4jFlowGramLlmNodeRunner(new AgentModelClient() {\n            @Override\n            public AgentModelResult create(AgentPrompt prompt) {\n                return AgentModelResult.builder()\n                        .outputText(\"ai4j-ok\")\n                        .rawResponse(new MinimaxChatCompletionResponse(\n                                \"resp-1\",\n                                \"chat.completion\",\n                                1L,\n                                \"test-model\",\n                                null,\n                                new Usage(12L, 8L, 20L)))\n                        .build();\n            }\n\n            @Override\n            public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n                return create(prompt);\n            }\n        });\n\n        Map<String, Object> result = runner.run(node(\"llm_0\", \"LLM\", llmData(ref(\"start_0\", \"prompt\"))),\n                mapOf(\"modelName\", \"test-model\", \"prompt\", \"hello\"));\n\n        assertEquals(\"ai4j-ok\", result.get(\"result\"));\n        assertEquals(\"ai4j-ok\", result.get(\"outputText\"));\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> metrics = (Map<String, Object>) result.get(\"metrics\");\n        assertEquals(12L, metrics.get(\"promptTokens\"));\n        assertEquals(8L, metrics.get(\"completionTokens\"));\n        assertEquals(20L, metrics.get(\"totalTokens\"));\n    }\n\n    private static FlowGramTaskResultOutput awaitResult(FlowGramRuntimeService service, String taskId) throws Exception {\n        long deadline = System.currentTimeMillis() + 5000L;\n        while (System.currentTimeMillis() < deadline) {\n            FlowGramTaskResultOutput result = service.getTaskResult(taskId);\n            if (result != null && result.isTerminated()) {\n                return result;\n            }\n            Thread.sleep(20L);\n        }\n        throw new AssertionError(\"Timed out waiting for task result\");\n    }\n\n    private static void waitForProcessing(FlowGramRuntimeService service, String taskId, String nodeId) throws Exception {\n        long deadline = System.currentTimeMillis() + 5000L;\n        while (System.currentTimeMillis() < deadline) {\n            if (\"processing\".equals(service.getTaskReport(taskId).getNodes().get(nodeId).getStatus())) {\n                return;\n            }\n            Thread.sleep(10L);\n        }\n        throw new AssertionError(\"Timed out waiting for node to enter processing state\");\n    }\n\n    private static void awaitEvent(List<FlowGramRuntimeEvent> events, FlowGramRuntimeEvent.Type type) throws Exception {\n        long deadline = System.currentTimeMillis() + 5000L;\n        while (System.currentTimeMillis() < deadline) {\n            for (FlowGramRuntimeEvent event : snapshot(events)) {\n                if (event != null && type == event.getType()) {\n                    return;\n                }\n            }\n            Thread.sleep(10L);\n        }\n        throw new AssertionError(\"Timed out waiting for event \" + type);\n    }\n\n    private static List<FlowGramRuntimeEvent> snapshot(List<FlowGramRuntimeEvent> events) {\n        synchronized (events) {\n            return new ArrayList<FlowGramRuntimeEvent>(events);\n        }\n    }\n\n    private static FlowGramNodeSchema node(String id, String type, Map<String, Object> data) {\n        return FlowGramNodeSchema.builder()\n                .id(id)\n                .type(type)\n                .name(id)\n                .data(data)\n                .build();\n    }\n\n    private static FlowGramEdgeSchema edge(String source, String target) {\n        return FlowGramEdgeSchema.builder()\n                .sourceNodeID(source)\n                .targetNodeID(target)\n                .build();\n    }\n\n    private static FlowGramEdgeSchema edge(String source, String target, String sourcePortId) {\n        return FlowGramEdgeSchema.builder()\n                .sourceNodeID(source)\n                .targetNodeID(target)\n                .sourcePortID(sourcePortId)\n                .build();\n    }\n\n    private static Map<String, Object> startData() {\n        return mapOf(\"outputs\", objectSchema(required(\"prompt\"), property(\"prompt\", stringSchema())));\n    }\n\n    private static Map<String, Object> startNumberData() {\n        return mapOf(\"outputs\", objectSchema(required(\"score\"), property(\"score\", numberSchema())));\n    }\n\n    private static Map<String, Object> startCitiesData() {\n        return mapOf(\"outputs\", objectSchema(required(\"cities\"), property(\"cities\", mapOf(\"type\", \"array\"))));\n    }\n\n    private static Map<String, Object> llmData(Map<String, Object> promptValue) {\n        return mapOf(\n                \"inputs\", objectSchema(\n                        required(\"modelName\", \"prompt\"),\n                        property(\"modelName\", mapOf(\"type\", \"string\", \"default\", \"demo-model\")),\n                        property(\"prompt\", stringSchema())\n                ),\n                \"outputs\", objectSchema(required(\"result\"), property(\"result\", stringSchema())),\n                \"inputsValues\", mapOf(\n                        \"modelName\", constant(\"demo-model\"),\n                        \"prompt\", promptValue\n                )\n        );\n    }\n\n    private static Map<String, Object> endData(Map<String, Object> resultValue) {\n        return mapOf(\n                \"inputs\", objectSchema(required(\"result\"), property(\"result\", stringSchema())),\n                \"inputsValues\", mapOf(\"result\", resultValue)\n        );\n    }\n\n    private static Map<String, Object> endArrayData(Map<String, Object> resultValue) {\n        return mapOf(\n                \"inputs\", objectSchema(required(\"result\"), property(\"result\", mapOf(\"type\", \"array\"))),\n                \"inputsValues\", mapOf(\"result\", resultValue)\n        );\n    }\n\n    private static Map<String, Object> conditionData() {\n        List<Object> conditions = new ArrayList<Object>();\n        conditions.add(mapOf(\"key\", \"pass\", \"leftKey\", \"score\", \"operator\", \">=\", \"value\", 60));\n        conditions.add(mapOf(\"key\", \"fail\", \"operator\", \"default\"));\n        return mapOf(\n                \"inputs\", objectSchema(required(\"score\"), property(\"score\", numberSchema())),\n                \"inputsValues\", mapOf(\"score\", ref(\"start_0\", \"score\")),\n                \"conditions\", conditions\n        );\n    }\n\n    private static Map<String, Object> loopData() {\n        return mapOf(\n                \"inputs\", objectSchema(required(\"loopFor\"), property(\"loopFor\", mapOf(\"type\", \"array\"))),\n                \"outputs\", objectSchema(required(\"suggestions\"), property(\"suggestions\", mapOf(\"type\", \"array\"))),\n                \"inputsValues\", mapOf(\"loopFor\", ref(\"start_0\", \"cities\")),\n                \"loopOutputs\", mapOf(\"suggestions\", ref(\"llm_1\", \"result\"))\n        );\n    }\n\n    private static Map<String, Object> stringSchema() {\n        return mapOf(\"type\", \"string\");\n    }\n\n    private static Map<String, Object> numberSchema() {\n        return mapOf(\"type\", \"number\");\n    }\n\n    private static Map<String, Object> objectSchema(List<String> required, Map<String, Object>... properties) {\n        Map<String, Object> object = new LinkedHashMap<String, Object>();\n        object.put(\"type\", \"object\");\n        object.put(\"required\", required);\n        Map<String, Object> props = new LinkedHashMap<String, Object>();\n        if (properties != null) {\n            for (Map<String, Object> property : properties) {\n                props.putAll(property);\n            }\n        }\n        object.put(\"properties\", props);\n        return object;\n    }\n\n    private static Map<String, Object> property(String name, Map<String, Object> schema) {\n        return mapOf(name, schema);\n    }\n\n    private static List<String> required(String... names) {\n        return Arrays.asList(names);\n    }\n\n    private static Map<String, Object> ref(String... path) {\n        return mapOf(\"type\", \"ref\", \"content\", Arrays.asList(path));\n    }\n\n    private static Map<String, Object> constant(Object content) {\n        return mapOf(\"type\", \"constant\", \"content\", content);\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n\n    private static final class EchoRunner implements FlowGramLlmNodeRunner {\n        @Override\n        public Map<String, Object> run(FlowGramNodeSchema node, Map<String, Object> inputs) {\n            return mapOf(\"result\", \"echo:\" + inputs.get(\"prompt\"));\n        }\n    }\n\n    private static final class SlowRunner implements FlowGramLlmNodeRunner {\n        @Override\n        public Map<String, Object> run(FlowGramNodeSchema node, Map<String, Object> inputs) throws Exception {\n            for (int i = 0; i < 100; i++) {\n                Thread.sleep(20L);\n            }\n            return mapOf(\"result\", \"slow:\" + inputs.get(\"prompt\"));\n        }\n    }\n\n    private static final class FailingRunner implements FlowGramLlmNodeRunner {\n        @Override\n        public Map<String, Object> run(FlowGramNodeSchema node, Map<String, Object> inputs) {\n            throw new IllegalStateException(\"runner-boom\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemoryTest.java",
    "content": "package io.github.lnyocly.ai4j.agent.memory;\n\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class JdbcAgentMemoryTest {\n\n    @Test\n    public void shouldPersistAgentMemoryAcrossInstances() {\n        String jdbcUrl = jdbcUrl(\"persist\");\n\n        JdbcAgentMemory first = new JdbcAgentMemory(JdbcAgentMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .sessionId(\"agent-1\")\n                .build());\n        first.addUserInput(\"hello\");\n        first.addOutputItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", \"hi\")));\n        first.addToolOutput(\"call-1\", \"{\\\"ok\\\":true}\");\n\n        JdbcAgentMemory second = new JdbcAgentMemory(JdbcAgentMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl)\n                .sessionId(\"agent-1\")\n                .build());\n\n        List<Object> items = second.getItems();\n        assertEquals(3, items.size());\n        assertEquals(\"message\", ((Map<?, ?>) items.get(0)).get(\"type\"));\n        assertEquals(\"function_call_output\", ((Map<?, ?>) items.get(2)).get(\"type\"));\n    }\n\n    @Test\n    public void shouldIncludeSummaryInMergedItems() {\n        JdbcAgentMemory memory = new JdbcAgentMemory(JdbcAgentMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl(\"summary\"))\n                .sessionId(\"agent-2\")\n                .build());\n\n        memory.addUserInput(\"hello\");\n        memory.setSummary(\"previous summary\");\n\n        List<Object> items = memory.getItems();\n        assertEquals(2, items.size());\n        assertEquals(\"system\", ((Map<?, ?>) items.get(0)).get(\"role\"));\n        assertEquals(\"previous summary\",\n                ((Map<?, ?>) ((List<?>) ((Map<?, ?>) items.get(0)).get(\"content\")).get(0)).get(\"text\"));\n    }\n\n    @Test\n    public void shouldSupportSnapshotRestoreAndClear() {\n        JdbcAgentMemory memory = new JdbcAgentMemory(JdbcAgentMemoryConfig.builder()\n                .jdbcUrl(jdbcUrl(\"snapshot\"))\n                .sessionId(\"agent-3\")\n                .build());\n\n        memory.addUserInput(\"hello\");\n        memory.addOutputItems(Arrays.<Object>asList(\n                AgentInputItem.message(\"assistant\", \"hi\"),\n                AgentInputItem.functionCallOutput(\"call-1\", \"{\\\"ok\\\":true}\")\n        ));\n\n        MemorySnapshot snapshot = memory.snapshot();\n        memory.clear();\n        assertTrue(memory.getItems().isEmpty());\n        assertNull(memory.getSummary());\n\n        memory.restore(snapshot);\n        assertEquals(3, memory.getItems().size());\n    }\n\n    private String jdbcUrl(String suffix) {\n        return \"jdbc:h2:mem:ai4j_agent_memory_\" + suffix + \";MODE=MYSQL;DB_CLOSE_DELAY=-1\";\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/ai4j/agent/model/ChatModelClientTest.java",
    "content": "package io.github.lnyocly.ai4j.agent.model;\n\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Collections;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class ChatModelClientTest {\n\n    @Test\n    public void createShouldEnableToolCallPassThroughForAgentTools() throws Exception {\n        final AtomicReference<ChatCompletion> captured = new AtomicReference<ChatCompletion>();\n        IChatService chatService = new IChatService() {\n            @Override\n            public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) {\n                captured.set(chatCompletion);\n                return new ChatCompletionResponse();\n            }\n\n            @Override\n            public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) {\n                captured.set(chatCompletion);\n                return new ChatCompletionResponse();\n            }\n\n            @Override\n            public void chatCompletionStream(String baseUrl, String apiKey, ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            }\n\n            @Override\n            public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) {\n            }\n        };\n\n        ChatModelClient client = new ChatModelClient(chatService);\n        client.create(AgentPrompt.builder()\n                .model(\"MiniMax-M2.1\")\n                .tools(Collections.<Object>singletonList(testTool(\"read_file\")))\n                .build());\n\n        Assert.assertNotNull(captured.get());\n        Assert.assertEquals(Boolean.TRUE, captured.get().getPassThroughToolCalls());\n    }\n\n    private Tool testTool(String name) {\n        Tool.Function function = new Tool.Function();\n        function.setName(name);\n        function.setDescription(\"test tool\");\n        return new Tool(\"function\", function);\n    }\n}\n"
  },
  {
    "path": "ai4j-agent/src/test/java/io/github/lnyocly/ai4j/agent/trace/LangfuseTraceExporterTest.java",
    "content": "package io.github.lnyocly.ai4j.agent.trace;\n\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class LangfuseTraceExporterTest {\n\n    @Test\n    public void test_langfuse_projection_for_model_span() {\n        Map<String, Object> attributes = new LinkedHashMap<String, Object>();\n        attributes.put(\"model\", \"glm-4.7\");\n        attributes.put(\"systemPrompt\", \"system\");\n        attributes.put(\"items\", \"[{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"hi\\\"}]\");\n        attributes.put(\"output\", \"hello\");\n        attributes.put(\"temperature\", 0.2D);\n        attributes.put(\"finishReason\", \"stop\");\n\n        TraceSpan span = TraceSpan.builder()\n                .traceId(\"trace_1\")\n                .spanId(\"span_1\")\n                .name(\"model.request\")\n                .type(TraceSpanType.MODEL)\n                .status(TraceSpanStatus.OK)\n                .startTime(System.currentTimeMillis())\n                .endTime(System.currentTimeMillis() + 30L)\n                .attributes(attributes)\n                .metrics(TraceMetrics.builder()\n                        .durationMillis(30L)\n                        .promptTokens(100L)\n                        .completionTokens(25L)\n                        .totalTokens(125L)\n                        .inputCost(0.0002D)\n                        .outputCost(0.0005D)\n                        .totalCost(0.0007D)\n                        .currency(\"USD\")\n                        .build())\n                .build();\n\n        Map<String, Object> projected = LangfuseTraceExporter.LangfuseSpanAttributes.project(span, \"prod\", \"1.0.0\");\n\n        Assert.assertEquals(\"prod\", projected.get(\"langfuse.environment\"));\n        Assert.assertEquals(\"1.0.0\", projected.get(\"langfuse.release\"));\n        Assert.assertEquals(\"generation\", projected.get(\"langfuse.observation.type\"));\n        Assert.assertEquals(\"DEFAULT\", projected.get(\"langfuse.observation.level\"));\n        Assert.assertEquals(\"glm-4.7\", projected.get(\"langfuse.observation.model\"));\n        Assert.assertTrue(String.valueOf(projected.get(\"langfuse.observation.input\")).contains(\"systemPrompt\"));\n        Assert.assertTrue(String.valueOf(projected.get(\"langfuse.observation.output\")).contains(\"hello\"));\n        Assert.assertTrue(String.valueOf(projected.get(\"langfuse.observation.model_parameters\")).contains(\"temperature\"));\n        Assert.assertTrue(String.valueOf(projected.get(\"langfuse.observation.usage_details\")).contains(\"prompt_tokens\"));\n        Assert.assertTrue(String.valueOf(projected.get(\"langfuse.observation.cost_details\")).contains(\"\\\"total\\\"\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-bom/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <parent>\n        <groupId>io.github.lnyo-cly</groupId>\n        <artifactId>ai4j-sdk</artifactId>\n        <version>2.3.0</version>\n    </parent>\n\n    <artifactId>ai4j-bom</artifactId>\n    <packaging>pom</packaging>\n\n    <name>ai4j-bom</name>\n    <description>ai4j 多模块版本对齐 BOM。 Bill of materials for aligning ai4j module versions.</description>\n\n    <licenses>\n        <license>\n            <name>The Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n        </license>\n    </licenses>\n\n    <issueManagement>\n        <system>GitHub</system>\n        <url>https://github.com/LnYo-Cly/ai4j/issues</url>\n    </issueManagement>\n    <url>https://github.com/LnYo-Cly/ai4j</url>\n\n    <developers>\n        <developer>\n            <id>LnYo-Cly</id>\n            <name>LnYo-Cly</name>\n            <email>lnyocly@gmail.com</email>\n            <url>https://github.com/LnYo-Cly/ai4j</url>\n            <timezone>+8</timezone>\n        </developer>\n    </developers>\n\n    <scm>\n        <url>https://github.com/LnYo-Cly/ai4j</url>\n        <connection>scm:git:https://github.com/LnYo-Cly/ai4j.git</connection>\n        <developerConnection>scm:git:https://github.com/LnYo-Cly/ai4j.git</developerConnection>\n    </scm>\n\n    <dependencyManagement>\n        <dependencies>\n            <dependency>\n                <groupId>io.github.lnyo-cly</groupId>\n                <artifactId>ai4j-agent</artifactId>\n                <version>${project.version}</version>\n            </dependency>\n            <dependency>\n                <groupId>io.github.lnyo-cly</groupId>\n                <artifactId>ai4j</artifactId>\n                <version>${project.version}</version>\n            </dependency>\n            <dependency>\n                <groupId>io.github.lnyo-cly</groupId>\n                <artifactId>ai4j-coding</artifactId>\n                <version>${project.version}</version>\n            </dependency>\n            <dependency>\n                <groupId>io.github.lnyo-cly</groupId>\n                <artifactId>ai4j-spring-boot-starter</artifactId>\n                <version>${project.version}</version>\n            </dependency>\n            <dependency>\n                <groupId>io.github.lnyo-cly</groupId>\n                <artifactId>ai4j-flowgram-spring-boot-starter</artifactId>\n                <version>${project.version}</version>\n            </dependency>\n            <dependency>\n                <groupId>io.github.lnyo-cly</groupId>\n                <artifactId>ai4j-cli</artifactId>\n                <version>${project.version}</version>\n            </dependency>\n        </dependencies>\n    </dependencyManagement>\n\n    <profiles>\n        <profile>\n            <id>release</id>\n            <build>\n                <plugins>\n                    <plugin>\n                        <groupId>org.codehaus.mojo</groupId>\n                        <artifactId>flatten-maven-plugin</artifactId>\n                        <version>${flatten-maven-plugin.version}</version>\n                        <configuration>\n                            <flattenMode>bom</flattenMode>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>flatten</id>\n                                <phase>process-resources</phase>\n                                <goals>\n                                    <goal>flatten</goal>\n                                </goals>\n                            </execution>\n                            <execution>\n                                <id>flatten-clean</id>\n                                <phase>clean</phase>\n                                <goals>\n                                    <goal>clean</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-gpg-plugin</artifactId>\n                        <version>1.6</version>\n                        <configuration>\n                            <executable>D:\\Develop\\DevelopEnv\\GnuPG\\bin\\gpg.exe</executable>\n                            <keyname>cly</keyname>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>sign-artifacts</id>\n                                <phase>verify</phase>\n                                <goals>\n                                    <goal>sign</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.sonatype.central</groupId>\n                        <artifactId>central-publishing-maven-plugin</artifactId>\n                        <version>0.4.0</version>\n                        <extensions>true</extensions>\n                        <configuration>\n                            <publishingServerId>LnYo-Cly</publishingServerId>\n                            <tokenAuth>true</tokenAuth>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n</project>\n\n\n"
  },
  {
    "path": "ai4j-cli/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <parent>\n        <groupId>io.github.lnyo-cly</groupId>\n        <artifactId>ai4j-sdk</artifactId>\n        <version>2.3.0</version>\n    </parent>\n\n    <artifactId>ai4j-cli</artifactId>\n    <packaging>jar</packaging>\n\n    <name>ai4j-cli</name>\n    <description>ai4j CLI、TUI 与 ACP 宿主模块，用于 coding agent 与工作区自动化。 CLI, TUI, and ACP host for ai4j coding agents and workspace automation.</description>\n\n    <licenses>\n        <license>\n            <name>The Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n        </license>\n    </licenses>\n\n    <issueManagement>\n        <system>GitHub</system>\n        <url>https://github.com/LnYo-Cly/ai4j/issues</url>\n    </issueManagement>\n    <url>https://github.com/LnYo-Cly/ai4j</url>\n\n    <developers>\n        <developer>\n            <id>LnYo-Cly</id>\n            <name>LnYo-Cly</name>\n            <email>lnyocly@gmail.com</email>\n            <url>https://github.com/LnYo-Cly/ai4j</url>\n            <timezone>+8</timezone>\n        </developer>\n    </developers>\n\n    <scm>\n        <url>https://github.com/LnYo-Cly/ai4j</url>\n        <connection>scm:git:https://github.com/LnYo-Cly/ai4j.git</connection>\n        <developerConnection>scm:git:https://github.com/LnYo-Cly/ai4j.git</developerConnection>\n    </scm>\n\n    <dependencies>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j-coding</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>com.alibaba.fastjson2</groupId>\n            <artifactId>fastjson2</artifactId>\n            <version>2.0.43</version>\n        </dependency>\n        <dependency>\n            <groupId>org.jline</groupId>\n            <artifactId>jline</artifactId>\n            <version>3.30.0</version>\n        </dependency>\n        <dependency>\n            <groupId>org.projectlombok</groupId>\n            <artifactId>lombok</artifactId>\n            <version>1.18.30</version>\n        </dependency>\n        <dependency>\n            <groupId>net.java.dev.jna</groupId>\n            <artifactId>jna</artifactId>\n            <version>5.14.0</version>\n        </dependency>\n        <dependency>\n            <groupId>junit</groupId>\n            <artifactId>junit</artifactId>\n            <version>4.13.2</version>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n\n    <build>\n        <plugins>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-compiler-plugin</artifactId>\n                <version>3.11.0</version>\n                <configuration>\n                    <source>${java.version}</source>\n                    <target>${java.version}</target>\n                    <encoding>${project.build.sourceEncoding}</encoding>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-jar-plugin</artifactId>\n                <version>3.4.2</version>\n                <configuration>\n                    <archive>\n                        <manifest>\n                            <mainClass>io.github.lnyocly.ai4j.cli.Ai4jCliMain</mainClass>\n                        </manifest>\n                    </archive>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-assembly-plugin</artifactId>\n                <version>3.7.1</version>\n                <configuration>\n                    <archive>\n                        <manifest>\n                            <mainClass>io.github.lnyocly.ai4j.cli.Ai4jCliMain</mainClass>\n                        </manifest>\n                    </archive>\n                    <descriptorRefs>\n                        <descriptorRef>jar-with-dependencies</descriptorRef>\n                    </descriptorRefs>\n                    <appendAssemblyId>true</appendAssemblyId>\n                </configuration>\n                <executions>\n                    <execution>\n                        <id>bundle-cli</id>\n                        <phase>package</phase>\n                        <goals>\n                            <goal>single</goal>\n                        </goals>\n                    </execution>\n                </executions>\n            </plugin>\n        </plugins>\n    </build>\n\n    <profiles>\n        <profile>\n            <id>release</id>\n            <build>\n                <plugins>\n                    <plugin>\n                        <groupId>org.codehaus.mojo</groupId>\n                        <artifactId>flatten-maven-plugin</artifactId>\n                        <version>${flatten-maven-plugin.version}</version>\n                        <configuration>\n                            <flattenMode>ossrh</flattenMode>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>flatten</id>\n                                <phase>process-resources</phase>\n                                <goals>\n                                    <goal>flatten</goal>\n                                </goals>\n                            </execution>\n                            <execution>\n                                <id>flatten-clean</id>\n                                <phase>clean</phase>\n                                <goals>\n                                    <goal>clean</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-source-plugin</artifactId>\n                        <version>3.3.1</version>\n                        <executions>\n                            <execution>\n                                <id>attach-sources</id>\n                                <goals>\n                                    <goal>jar-no-fork</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-javadoc-plugin</artifactId>\n                        <version>3.6.3</version>\n                        <executions>\n                            <execution>\n                                <id>attach-javadocs</id>\n                                <goals>\n                                    <goal>jar</goal>\n                                </goals>\n                                <configuration>\n                                    <doclint>none</doclint>\n                                    <failOnError>false</failOnError>\n                                    <tags>\n                                        <tag>\n                                            <name>Author</name>\n                                            <placement>a</placement>\n                                            <head>Author:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Description</name>\n                                            <placement>a</placement>\n                                            <head>Description:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Date</name>\n                                            <placement>a</placement>\n                                            <head>Date:</head>\n                                        </tag>\n                                    </tags>\n                                </configuration>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-gpg-plugin</artifactId>\n                        <version>1.6</version>\n                        <configuration>\n                            <executable>D:\\Develop\\DevelopEnv\\GnuPG\\bin\\gpg.exe</executable>\n                            <keyname>cly</keyname>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>sign-artifacts</id>\n                                <phase>verify</phase>\n                                <goals>\n                                    <goal>sign</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.sonatype.central</groupId>\n                        <artifactId>central-publishing-maven-plugin</artifactId>\n                        <version>0.4.0</version>\n                        <extensions>true</extensions>\n                        <configuration>\n                            <publishingServerId>LnYo-Cly</publishingServerId>\n                            <tokenAuth>true</tokenAuth>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n</project>\n\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/Ai4jCli.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.acp.AcpCommand;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommand;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliAgentFactory;\n\nimport io.github.lnyocly.ai4j.tui.JlineTerminalIO;\nimport io.github.lnyocly.ai4j.tui.StreamsTerminalIO;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\n\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Properties;\n\npublic class Ai4jCli {\n\n    private final CodingCliAgentFactory agentFactory;\n    private final Path currentDirectory;\n\n    public Ai4jCli() {\n        this(new DefaultCodingCliAgentFactory(), Paths.get(\".\").toAbsolutePath().normalize());\n    }\n\n    Ai4jCli(CodingCliAgentFactory agentFactory, Path currentDirectory) {\n        this.agentFactory = agentFactory;\n        this.currentDirectory = currentDirectory;\n    }\n\n    public int run(String[] args, InputStream in, OutputStream out, OutputStream err) {\n        return run(args, in, out, err, System.getenv(), System.getProperties());\n    }\n\n    int run(String[] args,\n            InputStream in,\n            OutputStream out,\n            OutputStream err,\n            Map<String, String> env,\n            Properties properties) {\n        TerminalIO terminal = createTerminal(in, out, err);\n        try {\n            List<String> arguments = args == null ? Collections.<String>emptyList() : Arrays.asList(args);\n            CodeCommand codeCommand = new CodeCommand(\n                    agentFactory,\n                    env,\n                    properties,\n                    currentDirectory\n            );\n            AcpCommand acpCommand = new AcpCommand(env, properties, currentDirectory);\n\n            if (arguments.isEmpty()) {\n                return codeCommand.run(Collections.<String>emptyList(), terminal);\n            }\n\n            String first = arguments.get(0);\n            if (\"help\".equalsIgnoreCase(first) || \"-h\".equals(first) || \"--help\".equals(first)) {\n                printHelp(terminal);\n                return 0;\n            }\n            if (\"code\".equalsIgnoreCase(first)) {\n                return codeCommand.run(arguments.subList(1, arguments.size()), terminal);\n            }\n            if (\"tui\".equalsIgnoreCase(first)) {\n                return codeCommand.run(asTuiArguments(arguments.subList(1, arguments.size())), terminal);\n            }\n            if (\"acp\".equalsIgnoreCase(first)) {\n                closeQuietly(terminal);\n                return acpCommand.run(arguments.subList(1, arguments.size()), in, out, err);\n            }\n            if (first.startsWith(\"--\")) {\n                return codeCommand.run(arguments, terminal);\n            }\n\n            terminal.errorln(\"Unknown command: \" + first);\n            printHelp(terminal);\n            return 2;\n        } finally {\n            closeQuietly(terminal);\n        }\n    }\n\n    private void printHelp(TerminalIO terminal) {\n        terminal.println(\"ai4j-cli\");\n        terminal.println(\"  Minimal coding-agent CLI entry. The first release focuses on the code command.\\n\");\n        terminal.println(\"Usage:\");\n        terminal.println(\"  ai4j-cli code --model <model> [options]\");\n        terminal.println(\"  ai4j-cli tui --model <model> [options]\");\n        terminal.println(\"  ai4j-cli acp --model <model> [options]\");\n        terminal.println(\"  ai4j-cli --model <model> [options]   # handled as the code command by default\\n\");\n        terminal.println(\"Commands:\");\n        terminal.println(\"  code      Start a coding session in one-shot or interactive REPL mode\\n\");\n        terminal.println(\"  tui       Start the same coding session with a richer text UI shell\\n\");\n        terminal.println(\"  acp       Start the coding session as an ACP stdio server\\n\");\n        terminal.println(\"Examples:\");\n        terminal.println(\"  ai4j-cli code --provider zhipu --protocol chat --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --workspace .\");\n        terminal.println(\"  ai4j-cli tui --provider zhipu --protocol chat --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --workspace .\");\n        terminal.println(\"  ai4j-cli acp --provider openai --protocol responses --model gpt-5-mini --workspace .\");\n        terminal.println(\"  ai4j-cli code --provider openai --protocol responses --model gpt-5-mini --prompt \\\"Investigate why tests fail in this workspace\\\"\");\n        terminal.println(\"  ai4j-cli code --provider openai --base-url https://api.deepseek.com --protocol chat --model deepseek-chat\\n\");\n        terminal.println(\"Tip:\");\n        terminal.println(\"  Run `ai4j-cli code --help` for the full code command reference.\");\n    }\n\n    private List<String> asTuiArguments(List<String> arguments) {\n        List<String> tuiArguments = new ArrayList<String>();\n        tuiArguments.add(\"--ui\");\n        tuiArguments.add(CliUiMode.TUI.getValue());\n        if (arguments != null) {\n            tuiArguments.addAll(arguments);\n        }\n        return tuiArguments;\n    }\n\n    private TerminalIO createTerminal(InputStream in, OutputStream out, OutputStream err) {\n        if (in == System.in && out == System.out && err == System.err) {\n            try {\n                return JlineTerminalIO.openSystem(err);\n            } catch (Exception ignored) {\n            }\n        }\n        return new StreamsTerminalIO(in, out, err);\n    }\n\n    private void closeQuietly(TerminalIO terminal) {\n        if (terminal == null) {\n            return;\n        }\n        try {\n            terminal.close();\n        } catch (Exception ignored) {\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/Ai4jCliMain.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\npublic final class Ai4jCliMain {\n\n    private Ai4jCliMain() {\n    }\n\n    public static void main(String[] args) {\n        configureLogging(args);\n        int exitCode = new Ai4jCli().run(args, System.in, System.out, System.err);\n        if (exitCode != 0) {\n            System.exit(exitCode);\n        }\n    }\n\n    private static void configureLogging(String[] args) {\n        boolean verbose = hasVerboseFlag(args);\n        setIfAbsent(\"org.slf4j.simpleLogger.defaultLogLevel\", verbose ? \"debug\" : \"warn\");\n        setIfAbsent(\"org.slf4j.simpleLogger.showThreadName\", verbose ? \"true\" : \"false\");\n        setIfAbsent(\"org.slf4j.simpleLogger.showDateTime\", \"false\");\n        setIfAbsent(\"org.slf4j.simpleLogger.showLogName\", verbose ? \"true\" : \"false\");\n        setIfAbsent(\"org.slf4j.simpleLogger.showShortLogName\", verbose ? \"true\" : \"false\");\n    }\n\n    private static boolean hasVerboseFlag(String[] args) {\n        if (args == null) {\n            return false;\n        }\n        for (String arg : args) {\n            if (\"--verbose\".equals(arg)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private static void setIfAbsent(String key, String value) {\n        if (System.getProperty(key) == null) {\n            System.setProperty(key, value);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/ApprovalMode.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport java.util.Locale;\n\npublic enum ApprovalMode {\n    AUTO(\"auto\"),\n    SAFE(\"safe\"),\n    MANUAL(\"manual\");\n\n    private final String value;\n\n    ApprovalMode(String value) {\n        this.value = value;\n    }\n\n    public String getValue() {\n        return value;\n    }\n\n    public static ApprovalMode parse(String raw) {\n        if (raw == null || raw.trim().isEmpty()) {\n            return AUTO;\n        }\n        String normalized = raw.trim().toLowerCase(Locale.ROOT);\n        for (ApprovalMode mode : values()) {\n            if (mode.value.equals(normalized)) {\n                return mode;\n            }\n        }\n        throw new IllegalArgumentException(\"Unsupported approval mode: \" + raw);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/CliProtocol.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.service.PlatformType;\n\nimport java.util.Locale;\n\npublic enum CliProtocol {\n    CHAT(\"chat\"),\n    RESPONSES(\"responses\");\n\n    private final String value;\n\n    CliProtocol(String value) {\n        this.value = value;\n    }\n\n    public String getValue() {\n        return value;\n    }\n\n    public static CliProtocol parse(String value) {\n        String normalized = normalize(value);\n        for (CliProtocol protocol : values()) {\n            if (protocol.value.equals(normalized)) {\n                return protocol;\n            }\n        }\n        throw new IllegalArgumentException(\"Unsupported protocol: \" + value + \". Expected: chat, responses\");\n    }\n\n    public static CliProtocol resolveConfigured(String value, PlatformType provider, String baseUrl) {\n        String normalized = normalize(value);\n        if (normalized == null || \"auto\".equals(normalized)) {\n            return defaultProtocol(provider, baseUrl);\n        }\n        return parse(normalized);\n    }\n\n    public static CliProtocol defaultProtocol(PlatformType provider, String baseUrl) {\n        if (provider == PlatformType.OPENAI) {\n            String normalizedBaseUrl = normalize(baseUrl);\n            if (normalizedBaseUrl == null || normalizedBaseUrl.contains(\"api.openai.com\")) {\n                return RESPONSES;\n            }\n            return CHAT;\n        }\n        if (provider == PlatformType.DOUBAO || provider == PlatformType.DASHSCOPE) {\n            return RESPONSES;\n        }\n        return CHAT;\n    }\n\n    private static String normalize(String value) {\n        if (value == null) {\n            return null;\n        }\n        String normalized = value.trim();\n        if (normalized.isEmpty()) {\n            return null;\n        }\n        return normalized.toLowerCase(Locale.ROOT);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/CliUiMode.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\npublic enum CliUiMode {\n    CLI(\"cli\"),\n    TUI(\"tui\");\n\n    private final String value;\n\n    CliUiMode(String value) {\n        this.value = value;\n    }\n\n    public String getValue() {\n        return value;\n    }\n\n    public static CliUiMode parse(String value) {\n        if (value == null || value.trim().isEmpty()) {\n            return CLI;\n        }\n        String normalized = value.trim().toLowerCase();\n        for (CliUiMode mode : values()) {\n            if (mode.value.equals(normalized)) {\n                return mode;\n            }\n        }\n        throw new IllegalArgumentException(\"Unsupported ui mode: \" + value + \". Expected: cli, tui\");\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/SlashCommandController.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.command.CustomCommandRegistry;\nimport io.github.lnyocly.ai4j.cli.command.CustomCommandTemplate;\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.tui.TuiConfigManager;\nimport org.jline.keymap.KeyMap;\nimport org.jline.reader.Buffer;\nimport org.jline.reader.Candidate;\nimport org.jline.reader.Completer;\nimport org.jline.reader.LineReader;\nimport org.jline.reader.ParsedLine;\nimport org.jline.reader.Reference;\nimport org.jline.reader.Widget;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.function.Supplier;\n\npublic final class SlashCommandController implements Completer {\n\n    private static final Runnable NOOP_STATUS_REFRESH = new Runnable() {\n        @Override\n        public void run() {\n        }\n    };\n\n    private static final List<SlashCommandSpec> BUILT_IN_COMMANDS = Arrays.asList(\n            new SlashCommandSpec(\"/help\", \"Show help\", false),\n            new SlashCommandSpec(\"/status\", \"Show current session status\", false),\n            new SlashCommandSpec(\"/session\", \"Show current session metadata\", false),\n            new SlashCommandSpec(\"/theme\", \"Show or switch the active theme\", true),\n            new SlashCommandSpec(\"/save\", \"Persist the current session state\", false),\n            new SlashCommandSpec(\"/providers\", \"List saved provider profiles\", false),\n            new SlashCommandSpec(\"/provider\", \"Show or switch the active provider profile\", true),\n            new SlashCommandSpec(\"/model\", \"Show or switch the active model override\", true),\n            new SlashCommandSpec(\"/experimental\", \"Show or switch experimental runtime feature flags\", true),\n            new SlashCommandSpec(\"/skills\", \"List or inspect discovered coding skills\", true),\n            new SlashCommandSpec(\"/agents\", \"List or inspect available coding agents\", true),\n            new SlashCommandSpec(\"/commands\", \"List available custom commands\", false),\n            new SlashCommandSpec(\"/palette\", \"Alias of /commands\", false),\n            new SlashCommandSpec(\"/cmd\", \"Run a custom command template\", true),\n            new SlashCommandSpec(\"/sessions\", \"List saved sessions\", false),\n            new SlashCommandSpec(\"/history\", \"Show session lineage\", true),\n            new SlashCommandSpec(\"/tree\", \"Show the current session tree\", true),\n            new SlashCommandSpec(\"/events\", \"Show the latest session ledger events\", true),\n            new SlashCommandSpec(\"/replay\", \"Replay recent turns grouped from the event ledger\", true),\n            new SlashCommandSpec(\"/team\", \"Show the current agent team board\", false),\n            new SlashCommandSpec(\"/compacts\", \"Show recent compact history\", true),\n            new SlashCommandSpec(\"/stream\", \"Show or switch model request streaming\", true),\n            new SlashCommandSpec(\"/mcp\", \"Show or manage MCP services\", true),\n            new SlashCommandSpec(\"/processes\", \"List active and restored process metadata\", false),\n            new SlashCommandSpec(\"/process\", \"Inspect or control a process\", true),\n            new SlashCommandSpec(\"/checkpoint\", \"Show the current structured checkpoint summary\", false),\n            new SlashCommandSpec(\"/resume\", \"Resume a saved session\", true),\n            new SlashCommandSpec(\"/load\", \"Alias of /resume\", true),\n            new SlashCommandSpec(\"/fork\", \"Fork a session branch\", true),\n            new SlashCommandSpec(\"/compact\", \"Compact current session memory\", true),\n            new SlashCommandSpec(\"/clear\", \"Print a new screen section\", false),\n            new SlashCommandSpec(\"/exit\", \"Exit the session\", false),\n            new SlashCommandSpec(\"/quit\", \"Exit the session\", false)\n    );\n\n    private static final List<ProcessCommandSpec> PROCESS_COMMANDS = Arrays.asList(\n            new ProcessCommandSpec(\"status\", \"Show metadata for one process\", false, false),\n            new ProcessCommandSpec(\"follow\", \"Show process metadata with buffered logs\", true, false),\n            new ProcessCommandSpec(\"logs\", \"Read buffered logs for a process\", true, false),\n            new ProcessCommandSpec(\"write\", \"Write text to a live process stdin\", false, true),\n            new ProcessCommandSpec(\"stop\", \"Stop a live process\", false, false)\n    );\n\n    private static final List<String> PROCESS_FOLLOW_LIMITS = Arrays.asList(\"200\", \"400\", \"800\", \"1600\");\n    private static final List<String> PROCESS_LOG_LIMITS = Arrays.asList(\"200\", \"480\", \"800\", \"1600\");\n    private static final List<String> STREAM_OPTIONS = Arrays.asList(\"on\", \"off\");\n    private static final List<String> EXPERIMENTAL_FEATURES = Arrays.asList(\"subagent\", \"agent-teams\");\n    private static final List<String> TEAM_ACTIONS = Arrays.asList(\"list\", \"status\", \"messages\", \"resume\");\n    private static final List<String> TEAM_MESSAGE_LIMITS = Arrays.asList(\"10\", \"20\", \"50\", \"100\");\n    private static final List<String> MCP_ACTIONS = Arrays.asList(\"list\", \"add\", \"enable\", \"disable\", \"pause\", \"resume\", \"retry\", \"remove\");\n    private static final String MCP_TRANSPORT_FLAG = \"--transport\";\n    private static final List<String> MCP_TRANSPORT_OPTIONS = Arrays.asList(\"stdio\", \"sse\", \"http\");\n    private static final List<String> PROVIDER_ACTIONS = Arrays.asList(\"use\", \"save\", \"add\", \"edit\", \"default\", \"remove\");\n    private static final String MODEL_RESET = \"reset\";\n    private static final List<String> PROVIDER_DEFAULT_OPTIONS = Arrays.asList(\"clear\");\n    private static final List<String> PROVIDER_MUTATION_OPTIONS = Arrays.asList(\n            \"--provider\",\n            \"--protocol\",\n            \"--model\",\n            \"--base-url\",\n            \"--api-key\",\n            \"--clear-model\",\n            \"--clear-base-url\",\n            \"--clear-api-key\"\n    );\n    private static final List<String> EXECUTABLE_ROOT_COMMANDS = Arrays.asList(\n            \"/help\",\n            \"/status\",\n            \"/session\",\n            \"/theme\",\n            \"/save\",\n            \"/providers\",\n            \"/provider\",\n            \"/model\",\n            \"/experimental\",\n            \"/skills\",\n            \"/agents\",\n            \"/commands\",\n            \"/palette\",\n            \"/sessions\",\n            \"/history\",\n            \"/tree\",\n            \"/events\",\n            \"/replay\",\n            \"/team\",\n            \"/compacts\",\n            \"/stream\",\n            \"/mcp\",\n            \"/processes\",\n            \"/checkpoint\",\n            \"/fork\",\n            \"/compact\",\n            \"/clear\",\n            \"/exit\",\n            \"/quit\"\n    );\n\n    private final CustomCommandRegistry customCommandRegistry;\n    private final TuiConfigManager tuiConfigManager;\n    private final Object paletteLock = new Object();\n    private final java.util.Map<String, Widget> wrappedWidgets = new java.util.HashMap<String, Widget>();\n    private volatile CodingSessionManager sessionManager;\n    private volatile Supplier<List<ProcessCompletionCandidate>> processCandidateSupplier =\n            new Supplier<List<ProcessCompletionCandidate>>() {\n                @Override\n                public List<ProcessCompletionCandidate> get() {\n                    return Collections.emptyList();\n                }\n            };\n    private volatile Supplier<List<String>> profileCandidateSupplier =\n            new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n    private volatile Supplier<List<ModelCompletionCandidate>> modelCandidateSupplier =\n            new Supplier<List<ModelCompletionCandidate>>() {\n                @Override\n                public List<ModelCompletionCandidate> get() {\n                    return Collections.emptyList();\n                }\n            };\n    private volatile Supplier<List<String>> mcpServerCandidateSupplier =\n            new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n    private volatile Supplier<List<String>> skillCandidateSupplier =\n            new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n    private volatile Supplier<List<String>> agentCandidateSupplier =\n            new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n    private volatile Supplier<List<String>> teamCandidateSupplier =\n            new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n    private volatile Runnable statusRefresh = NOOP_STATUS_REFRESH;\n    private PaletteSnapshot paletteSnapshot = PaletteSnapshot.closed();\n\n    public SlashCommandController(CustomCommandRegistry customCommandRegistry, TuiConfigManager tuiConfigManager) {\n        this.customCommandRegistry = customCommandRegistry;\n        this.tuiConfigManager = tuiConfigManager;\n    }\n\n    public void setSessionManager(CodingSessionManager sessionManager) {\n        this.sessionManager = sessionManager;\n    }\n\n    public void setProcessCandidateSupplier(Supplier<List<ProcessCompletionCandidate>> processCandidateSupplier) {\n        if (processCandidateSupplier == null) {\n            this.processCandidateSupplier = new Supplier<List<ProcessCompletionCandidate>>() {\n                @Override\n                public List<ProcessCompletionCandidate> get() {\n                    return Collections.emptyList();\n                }\n            };\n            return;\n        }\n        this.processCandidateSupplier = processCandidateSupplier;\n    }\n\n    public void setProfileCandidateSupplier(Supplier<List<String>> profileCandidateSupplier) {\n        if (profileCandidateSupplier == null) {\n            this.profileCandidateSupplier = new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n            return;\n        }\n        this.profileCandidateSupplier = profileCandidateSupplier;\n    }\n\n    public void setModelCandidateSupplier(Supplier<List<ModelCompletionCandidate>> modelCandidateSupplier) {\n        if (modelCandidateSupplier == null) {\n            this.modelCandidateSupplier = new Supplier<List<ModelCompletionCandidate>>() {\n                @Override\n                public List<ModelCompletionCandidate> get() {\n                    return Collections.emptyList();\n                }\n            };\n            return;\n        }\n        this.modelCandidateSupplier = modelCandidateSupplier;\n    }\n\n    public void setMcpServerCandidateSupplier(Supplier<List<String>> mcpServerCandidateSupplier) {\n        if (mcpServerCandidateSupplier == null) {\n            this.mcpServerCandidateSupplier = new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n            return;\n        }\n        this.mcpServerCandidateSupplier = mcpServerCandidateSupplier;\n    }\n\n    public void setSkillCandidateSupplier(Supplier<List<String>> skillCandidateSupplier) {\n        if (skillCandidateSupplier == null) {\n            this.skillCandidateSupplier = new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n            return;\n        }\n        this.skillCandidateSupplier = skillCandidateSupplier;\n    }\n\n    public void setAgentCandidateSupplier(Supplier<List<String>> agentCandidateSupplier) {\n        if (agentCandidateSupplier == null) {\n            this.agentCandidateSupplier = new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n            return;\n        }\n        this.agentCandidateSupplier = agentCandidateSupplier;\n    }\n\n    public void setTeamCandidateSupplier(Supplier<List<String>> teamCandidateSupplier) {\n        if (teamCandidateSupplier == null) {\n            this.teamCandidateSupplier = new Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return Collections.emptyList();\n                }\n            };\n            return;\n        }\n        this.teamCandidateSupplier = teamCandidateSupplier;\n    }\n\n    public void setStatusRefresh(Runnable statusRefresh) {\n        this.statusRefresh = statusRefresh == null ? NOOP_STATUS_REFRESH : statusRefresh;\n    }\n\n    public void configure(LineReader lineReader) {\n        if (lineReader == null) {\n            return;\n        }\n        lineReader.setOpt(LineReader.Option.CASE_INSENSITIVE);\n        lineReader.setOpt(LineReader.Option.AUTO_MENU);\n        lineReader.setOpt(LineReader.Option.AUTO_MENU_LIST);\n        lineReader.setOpt(LineReader.Option.AUTO_GROUP);\n        lineReader.setOpt(LineReader.Option.LIST_PACKED);\n\n        lineReader.getWidgets().put(\"ai4j-slash-menu\", new Widget() {\n            @Override\n            public boolean apply() {\n                return openSlashMenu(lineReader);\n            }\n        });\n        lineReader.getWidgets().put(\"ai4j-command-palette\", new Widget() {\n            @Override\n            public boolean apply() {\n                return openCommandPalette(lineReader);\n            }\n        });\n        lineReader.getWidgets().put(\"ai4j-accept-line\", new Widget() {\n            @Override\n            public boolean apply() {\n                return acceptLine(lineReader);\n            }\n        });\n        lineReader.getWidgets().put(\"ai4j-accept-selection\", new Widget() {\n            @Override\n            public boolean apply() {\n                return acceptSlashSelection(lineReader, true);\n            }\n        });\n        lineReader.getWidgets().put(\"ai4j-palette-up\", new Widget() {\n            @Override\n            public boolean apply() {\n                return navigateSlashPalette(lineReader, -1, LineReader.UP_LINE_OR_HISTORY);\n            }\n        });\n        lineReader.getWidgets().put(\"ai4j-palette-down\", new Widget() {\n            @Override\n            public boolean apply() {\n                return navigateSlashPalette(lineReader, 1, LineReader.DOWN_LINE_OR_HISTORY);\n            }\n        });\n\n        wrapWidget(lineReader, LineReader.SELF_INSERT, new Widget() {\n            @Override\n            public boolean apply() {\n                return delegateAndRefreshSlashPalette(lineReader, LineReader.SELF_INSERT);\n            }\n        });\n        wrapWidget(lineReader, LineReader.BACKWARD_DELETE_CHAR, new Widget() {\n            @Override\n            public boolean apply() {\n                return delegateAndRefreshSlashPalette(lineReader, LineReader.BACKWARD_DELETE_CHAR);\n            }\n        });\n        wrapWidget(lineReader, LineReader.DELETE_CHAR, new Widget() {\n            @Override\n            public boolean apply() {\n                return delegateAndRefreshSlashPalette(lineReader, LineReader.DELETE_CHAR);\n            }\n        });\n        wrapWidget(lineReader, LineReader.UP_LINE_OR_HISTORY, new Widget() {\n            @Override\n            public boolean apply() {\n                return navigateSlashPalette(lineReader, -1, LineReader.UP_LINE_OR_HISTORY);\n            }\n        });\n        wrapWidget(lineReader, LineReader.DOWN_LINE_OR_HISTORY, new Widget() {\n            @Override\n            public boolean apply() {\n                return navigateSlashPalette(lineReader, 1, LineReader.DOWN_LINE_OR_HISTORY);\n            }\n        });\n        wrapWidget(lineReader, LineReader.UP_HISTORY, new Widget() {\n            @Override\n            public boolean apply() {\n                return navigateSlashPalette(lineReader, -1, LineReader.UP_HISTORY);\n            }\n        });\n        wrapWidget(lineReader, LineReader.DOWN_HISTORY, new Widget() {\n            @Override\n            public boolean apply() {\n                return navigateSlashPalette(lineReader, 1, LineReader.DOWN_HISTORY);\n            }\n        });\n\n        bindWidget(lineReader, \"ai4j-slash-menu\", \"/\");\n        bindWidget(lineReader, \"ai4j-command-palette\", KeyMap.ctrl('P'));\n        bindWidget(lineReader, \"ai4j-accept-line\", \"\\r\");\n        bindWidget(lineReader, \"ai4j-accept-line\", \"\\n\");\n        bindWidget(lineReader, \"ai4j-accept-selection\", \"\\t\");\n        bindWidget(lineReader, \"ai4j-palette-up\", \"\\u001b[A\");\n        bindWidget(lineReader, \"ai4j-palette-up\", \"\\u001bOA\");\n        bindWidget(lineReader, \"ai4j-palette-down\", \"\\u001b[B\");\n        bindWidget(lineReader, \"ai4j-palette-down\", \"\\u001bOB\");\n    }\n\n    @Override\n    public void complete(LineReader lineReader, ParsedLine parsedLine, List<Candidate> candidates) {\n        if (parsedLine == null || candidates == null) {\n            return;\n        }\n        candidates.addAll(suggest(parsedLine.line(), parsedLine.cursor()));\n    }\n\n    List<Candidate> suggest(String line, int cursor) {\n        String raw = line == null ? \"\" : line;\n        int safeCursor = Math.max(0, Math.min(cursor, raw.length()));\n        String prefix = raw.substring(0, safeCursor);\n        if (!prefix.startsWith(\"/\")) {\n            return Collections.emptyList();\n        }\n\n        boolean endsWithSpace = !prefix.isEmpty() && Character.isWhitespace(prefix.charAt(prefix.length() - 1));\n        List<String> tokens = splitTokens(prefix);\n        if (tokens.isEmpty()) {\n            return rootCandidates(\"\");\n        }\n\n        String command = tokens.get(0);\n        if (tokens.size() == 1 && !endsWithSpace) {\n            if (isExecutableRootCommand(command)) {\n                return rootCandidates(command);\n            }\n            List<Candidate> exactCommandCandidates = exactCommandArgumentCandidates(command);\n            if (!exactCommandCandidates.isEmpty()) {\n                return exactCommandCandidates;\n            }\n            return rootCandidates(command);\n        }\n\n        if (\"/cmd\".equalsIgnoreCase(command)) {\n            return customCommandCandidates(tokenFragment(tokens, endsWithSpace));\n        }\n        if (\"/theme\".equalsIgnoreCase(command)) {\n            return themeCandidates(tokenFragment(tokens, endsWithSpace));\n        }\n        if (\"/stream\".equalsIgnoreCase(command)) {\n            return streamCandidates(tokenFragment(tokens, endsWithSpace));\n        }\n        if (\"/experimental\".equalsIgnoreCase(command)) {\n            return experimentalCandidates(tokens, endsWithSpace);\n        }\n        if (\"/skills\".equalsIgnoreCase(command)) {\n            return skillCandidates(tokenFragment(tokens, endsWithSpace));\n        }\n        if (\"/agents\".equalsIgnoreCase(command)) {\n            return agentCandidates(tokenFragment(tokens, endsWithSpace));\n        }\n        if (\"/provider\".equalsIgnoreCase(command)) {\n            return providerCandidates(tokens, endsWithSpace);\n        }\n        if (\"/mcp\".equalsIgnoreCase(command)) {\n            return mcpCandidates(tokens, endsWithSpace);\n        }\n        if (\"/model\".equalsIgnoreCase(command)) {\n            return modelCandidates(tokenFragment(tokens, endsWithSpace));\n        }\n        if (\"/resume\".equalsIgnoreCase(command)\n                || \"/load\".equalsIgnoreCase(command)\n                || \"/history\".equalsIgnoreCase(command)\n                || \"/tree\".equalsIgnoreCase(command)\n                || \"/fork\".equalsIgnoreCase(command)) {\n            return sessionCandidates(tokenFragment(tokens, endsWithSpace));\n        }\n        if (\"/process\".equalsIgnoreCase(command)) {\n            return processCandidates(tokens, endsWithSpace);\n        }\n        if (\"/team\".equalsIgnoreCase(command)) {\n            return teamCandidates(tokens, endsWithSpace);\n        }\n        return Collections.emptyList();\n    }\n\n    private List<Candidate> exactCommandArgumentCandidates(String command) {\n        if (isBlank(command)) {\n            return Collections.emptyList();\n        }\n        if (\"/cmd\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", customCommandCandidates(\"\"));\n        }\n        if (\"/theme\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", themeCandidates(\"\"));\n        }\n        if (\"/stream\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", streamCandidates(\"\"));\n        }\n        if (\"/experimental\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", experimentalFeatureCandidates(\"\"));\n        }\n        if (\"/skills\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", skillCandidates(\"\"));\n        }\n        if (\"/agents\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", agentCandidates(\"\"));\n        }\n        if (\"/provider\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", providerActionCandidates(\"\"));\n        }\n        if (\"/model\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", modelCandidates(\"\"));\n        }\n        if (\"/resume\".equalsIgnoreCase(command)\n                || \"/load\".equalsIgnoreCase(command)\n                || \"/history\".equalsIgnoreCase(command)\n                || \"/tree\".equalsIgnoreCase(command)\n                || \"/fork\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", sessionCandidates(\"\"));\n        }\n        if (\"/process\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", processSubcommandCandidates(\"\"));\n        }\n        if (\"/team\".equalsIgnoreCase(command)) {\n            return prefixCandidates(command + \" \", teamActionCandidates(\"\"));\n        }\n        return Collections.emptyList();\n    }\n\n    boolean openSlashMenu(LineReader lineReader) {\n        Buffer buffer = lineReader.getBuffer();\n        if (buffer == null) {\n            return true;\n        }\n        String line = buffer.toString();\n        SlashMenuAction action = resolveSlashMenuAction(line, buffer.cursor());\n        if (action == SlashMenuAction.INSERT_AND_MENU) {\n            buffer.write(\"/\");\n            syncSlashPalette(lineReader);\n            refreshStatusAndDisplay(lineReader);\n            return true;\n        }\n        if (action == SlashMenuAction.MENU_ONLY) {\n            syncSlashPalette(lineReader);\n            refreshStatusAndDisplay(lineReader);\n            return true;\n        }\n        buffer.write(\"/\");\n        return true;\n    }\n\n    private boolean openCommandPalette(LineReader lineReader) {\n        Buffer buffer = lineReader.getBuffer();\n        if (buffer == null) {\n            return true;\n        }\n        if (buffer.length() == 0) {\n            buffer.write(\"/\");\n            syncSlashPalette(lineReader);\n            refreshStatusAndDisplay(lineReader);\n            return true;\n        }\n        if (buffer.toString().startsWith(\"/\")) {\n            syncSlashPalette(lineReader);\n            refreshStatusAndDisplay(lineReader);\n            return true;\n        }\n        return false;\n    }\n\n    private boolean acceptLine(LineReader lineReader) {\n        Buffer buffer = lineReader == null ? null : lineReader.getBuffer();\n        String current = buffer == null ? null : buffer.toString();\n        if (!shouldExecuteRawInputOnEnter(current) && acceptSlashSelection(lineReader, false)) {\n            return true;\n        }\n        EnterAction action = resolveAcceptLineAction(current);\n        if (action == EnterAction.IGNORE_EMPTY) {\n            if (buffer != null) {\n                buffer.clear();\n            }\n            clearPalette();\n            notifyStatusRefresh();\n            lineReader.callWidget(LineReader.REDRAW_LINE);\n            lineReader.callWidget(LineReader.REDISPLAY);\n            return true;\n        }\n        clearPalette();\n        notifyStatusRefresh();\n        lineReader.callWidget(LineReader.ACCEPT_LINE);\n        return true;\n    }\n\n    private boolean shouldExecuteRawInputOnEnter(String line) {\n        if (isBlank(line)) {\n            return false;\n        }\n        String normalized = line.trim();\n        if (!normalized.startsWith(\"/\") || normalized.indexOf(' ') >= 0) {\n            return false;\n        }\n        for (String command : EXECUTABLE_ROOT_COMMANDS) {\n            if (command.equalsIgnoreCase(normalized)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    SlashMenuAction resolveSlashMenuAction(String line, int cursor) {\n        String value = line == null ? \"\" : line;\n        if (value.isEmpty()) {\n            return SlashMenuAction.INSERT_AND_MENU;\n        }\n        if (value.startsWith(\"/\") && value.indexOf(' ') < 0) {\n            return SlashMenuAction.MENU_ONLY;\n        }\n        return SlashMenuAction.INSERT_ONLY;\n    }\n\n    EnterAction resolveAcceptLineAction(String line) {\n        return isBlank(line) ? EnterAction.IGNORE_EMPTY : EnterAction.ACCEPT;\n    }\n\n    public PaletteSnapshot getPaletteSnapshot() {\n        synchronized (paletteLock) {\n            return paletteSnapshot.copy();\n        }\n    }\n\n    void movePaletteSelection(int delta) {\n        synchronized (paletteLock) {\n            if (!paletteSnapshot.isOpen() || paletteSnapshot.items.isEmpty()) {\n                return;\n            }\n            int size = paletteSnapshot.items.size();\n            int nextIndex = Math.floorMod(paletteSnapshot.selectedIndex + delta, size);\n            paletteSnapshot = paletteSnapshot.withSelectedIndex(nextIndex);\n        }\n        notifyStatusRefresh();\n    }\n\n    boolean acceptSlashSelection(LineReader lineReader, boolean alwaysAccept) {\n        if (lineReader == null) {\n            return false;\n        }\n        Buffer buffer = lineReader.getBuffer();\n        if (buffer == null) {\n            return false;\n        }\n        PaletteItemSnapshot selected = getSelectedPaletteItem();\n        if (selected == null) {\n            return false;\n        }\n        String current = buffer.toString();\n        String replacement = applySelectedValue(current, buffer.cursor(), selected.value);\n        if (!alwaysAccept && sameText(current, replacement)) {\n            clearPalette();\n            notifyStatusRefresh();\n            return false;\n        }\n        buffer.clear();\n        buffer.write(replacement);\n        if (shouldContinuePaletteAfterAccept(replacement, selected)) {\n            syncSlashPalette(lineReader);\n            refreshStatusAndDisplay(lineReader);\n            return true;\n        }\n        clearPalette();\n        refreshStatusAndDisplay(lineReader);\n        return true;\n    }\n\n    private String applySelectedValue(String current, int cursor, String selectedValue) {\n        String line = current == null ? \"\" : current;\n        String replacement = selectedValue == null ? \"\" : selectedValue;\n        int safeCursor = Math.max(0, Math.min(cursor, line.length()));\n        int tokenStart = safeCursor;\n        while (tokenStart > 0 && !Character.isWhitespace(line.charAt(tokenStart - 1))) {\n            tokenStart--;\n        }\n        int tokenEnd = safeCursor;\n        while (tokenEnd < line.length() && !Character.isWhitespace(line.charAt(tokenEnd))) {\n            tokenEnd++;\n        }\n        return line.substring(0, tokenStart) + replacement + line.substring(tokenEnd);\n    }\n\n    private boolean shouldContinuePaletteAfterAccept(String replacement, PaletteItemSnapshot selected) {\n        if (selected == null || isBlank(replacement)) {\n            return false;\n        }\n        if (!replacement.endsWith(\" \")) {\n            return false;\n        }\n        String normalized = replacement.trim();\n        if (commandRequiresArgument(normalized)) {\n            return true;\n        }\n        return !suggest(replacement, replacement.length()).isEmpty();\n    }\n\n    private boolean commandRequiresArgument(String command) {\n        if (isBlank(command)) {\n            return false;\n        }\n        for (SlashCommandSpec spec : BUILT_IN_COMMANDS) {\n            if (sameText(spec.command, command)) {\n                return spec.requiresArgument;\n            }\n        }\n        return false;\n    }\n\n    private boolean isExecutableRootCommand(String value) {\n        if (isBlank(value)) {\n            return false;\n        }\n        for (String command : EXECUTABLE_ROOT_COMMANDS) {\n            if (command.equalsIgnoreCase(value)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private void bindWidget(LineReader lineReader, String widgetName, String keySequence) {\n        if (lineReader == null || keySequence == null) {\n            return;\n        }\n        Reference reference = new Reference(widgetName);\n        bind(lineReader, LineReader.MAIN, reference, keySequence);\n        bind(lineReader, LineReader.EMACS, reference, keySequence);\n        bind(lineReader, LineReader.VIINS, reference, keySequence);\n    }\n\n    private void bind(LineReader lineReader, String keymapName, Reference reference, String keySequence) {\n        if (lineReader == null || reference == null || keySequence == null) {\n            return;\n        }\n        java.util.Map<String, org.jline.keymap.KeyMap<org.jline.reader.Binding>> keyMaps = lineReader.getKeyMaps();\n        if (keyMaps == null) {\n            return;\n        }\n        org.jline.keymap.KeyMap<org.jline.reader.Binding> keyMap = keyMaps.get(keymapName);\n        if (keyMap != null) {\n            keyMap.bind(reference, keySequence);\n        }\n    }\n\n    private void wrapWidget(LineReader lineReader, String widgetName, Widget widget) {\n        if (lineReader == null || widget == null || isBlank(widgetName)) {\n            return;\n        }\n        java.util.Map<String, Widget> widgets = lineReader.getWidgets();\n        if (widgets == null) {\n            return;\n        }\n        Widget original = widgets.get(widgetName);\n        if (original == null) {\n            return;\n        }\n        wrappedWidgets.put(widgetName, original);\n        widgets.put(widgetName, widget);\n    }\n\n    private boolean delegateAndRefreshSlashPalette(LineReader lineReader, String widgetName) {\n        if (!delegateWidget(lineReader, widgetName)) {\n            return false;\n        }\n        refreshSlashPaletteIfNeeded(lineReader);\n        return true;\n    }\n\n    private boolean delegateWidget(LineReader lineReader, String widgetName) {\n        if (isBlank(widgetName)) {\n            return false;\n        }\n        Widget widget = wrappedWidgets.get(widgetName);\n        if (widget == null) {\n            return false;\n        }\n        return widget.apply();\n    }\n\n    private boolean navigateSlashPalette(LineReader lineReader, int delta, String fallbackWidgetName) {\n        PaletteSnapshot snapshot = getPaletteSnapshot();\n        if (!snapshot.isOpen() || snapshot.items.isEmpty()) {\n            return delegateWidget(lineReader, fallbackWidgetName);\n        }\n        movePaletteSelection(delta);\n        refreshStatusAndDisplay(lineReader);\n        return true;\n    }\n\n    private void refreshSlashPaletteIfNeeded(LineReader lineReader) {\n        if (lineReader == null) {\n            return;\n        }\n        Buffer buffer = lineReader.getBuffer();\n        String line = buffer == null ? null : buffer.toString();\n        if (!getPaletteSnapshot().isOpen() && (line == null || !line.startsWith(\"/\"))) {\n            return;\n        }\n        syncSlashPalette(lineReader);\n        refreshStatusAndDisplay(lineReader);\n    }\n\n    private void syncSlashPalette(LineReader lineReader) {\n        if (lineReader == null) {\n            clearPalette();\n            return;\n        }\n        Buffer buffer = lineReader.getBuffer();\n        if (buffer == null) {\n            clearPalette();\n            return;\n        }\n        String line = buffer.toString();\n        if (isBlank(line) || !line.startsWith(\"/\")) {\n            clearPalette();\n            return;\n        }\n        updatePalette(line, suggest(line, buffer.cursor()));\n    }\n\n    private void updatePalette(String query, List<Candidate> candidates) {\n        List<PaletteItemSnapshot> items = new ArrayList<PaletteItemSnapshot>();\n        if (candidates != null) {\n            for (Candidate candidate : candidates) {\n                if (candidate == null) {\n                    continue;\n                }\n                items.add(new PaletteItemSnapshot(\n                        candidate.value(),\n                        firstNonBlank(candidate.displ(), candidate.value()),\n                        candidate.descr(),\n                        candidate.group()\n                ));\n            }\n        }\n        synchronized (paletteLock) {\n            String selectedValue = paletteSnapshot.selectedValue();\n            int selectedIndex = resolveSelectedIndex(items, query, selectedValue);\n            paletteSnapshot = new PaletteSnapshot(true, query, selectedIndex, items);\n        }\n        notifyStatusRefresh();\n    }\n\n    private int resolveSelectedIndex(List<PaletteItemSnapshot> items, String query, String selectedValue) {\n        if (items == null || items.isEmpty()) {\n            return -1;\n        }\n        if (!isBlank(selectedValue)) {\n            for (int i = 0; i < items.size(); i++) {\n                if (sameText(selectedValue, items.get(i).value)) {\n                    return i;\n                }\n            }\n        }\n        if (!isBlank(query)) {\n            for (int i = 0; i < items.size(); i++) {\n                if (sameText(query, items.get(i).value)) {\n                    return i;\n                }\n            }\n        }\n        return 0;\n    }\n\n    private void clearPalette() {\n        synchronized (paletteLock) {\n            paletteSnapshot = PaletteSnapshot.closed();\n        }\n    }\n\n    private PaletteItemSnapshot getSelectedPaletteItem() {\n        synchronized (paletteLock) {\n            if (!paletteSnapshot.isOpen() || paletteSnapshot.selectedIndex < 0 || paletteSnapshot.selectedIndex >= paletteSnapshot.items.size()) {\n                return null;\n            }\n            return paletteSnapshot.items.get(paletteSnapshot.selectedIndex);\n        }\n    }\n\n    private void refreshStatusAndDisplay(LineReader lineReader) {\n        notifyStatusRefresh();\n        if (lineReader == null) {\n            return;\n        }\n        lineReader.callWidget(LineReader.REDRAW_LINE);\n        lineReader.callWidget(LineReader.REDISPLAY);\n    }\n\n    private void notifyStatusRefresh() {\n        Runnable refresh = statusRefresh;\n        if (refresh != null) {\n            refresh.run();\n        }\n    }\n\n    private List<Candidate> rootCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (SlashCommandSpec spec : BUILT_IN_COMMANDS) {\n            if (matches(spec.command, partial)) {\n                candidates.add(commandCandidate(spec.command, spec.command, \"Commands\", spec.description, spec.requiresArgument));\n            }\n        }\n        return candidates;\n    }\n\n    private List<Candidate> prefixCandidates(String prefix, List<Candidate> candidates) {\n        if (isBlank(prefix) || candidates == null || candidates.isEmpty()) {\n            return candidates == null ? Collections.<Candidate>emptyList() : candidates;\n        }\n        List<Candidate> prefixed = new ArrayList<Candidate>(candidates.size());\n        for (Candidate candidate : candidates) {\n            if (candidate == null) {\n                continue;\n            }\n            prefixed.add(new Candidate(\n                    prefix + firstNonBlank(candidate.value(), \"\"),\n                    firstNonBlank(candidate.displ(), candidate.value()),\n                    candidate.group(),\n                    candidate.descr(),\n                    candidate.suffix(),\n                    candidate.key(),\n                    candidate.complete()\n            ));\n        }\n        return prefixed;\n    }\n\n    private List<Candidate> customCommandCandidates(String partial) {\n        if (customCommandRegistry == null) {\n            return Collections.emptyList();\n        }\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (CustomCommandTemplate template : customCommandRegistry.list()) {\n            if (template == null || !matches(template.getName(), partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    template.getName(),\n                    template.getName(),\n                    \"Templates\",\n                    firstNonBlank(template.getDescription(), \"Run custom command \" + template.getName()),\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> themeCandidates(String partial) {\n        if (tuiConfigManager == null) {\n            return Collections.emptyList();\n        }\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String themeName : tuiConfigManager.listThemeNames()) {\n            if (!matches(themeName, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(themeName, themeName, \"Themes\", \"Switch to theme \" + themeName, null, null, true));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> streamCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String option : STREAM_OPTIONS) {\n            if (!matches(option, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    option,\n                    option,\n                    \"Stream\",\n                    \"Turn transcript streaming \" + option,\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> experimentalCandidates(List<String> tokens, boolean endsWithSpace) {\n        if (tokens == null || tokens.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 1) {\n            return experimentalFeatureCandidates(\"\");\n        }\n        if (tokens.size() == 2 && !endsWithSpace) {\n            return experimentalFeatureCandidates(tokens.get(1));\n        }\n        if (tokens.size() == 2 && endsWithSpace) {\n            return experimentalToggleCandidates(\"\");\n        }\n        if (tokens.size() == 3 && !endsWithSpace) {\n            return experimentalToggleCandidates(tokens.get(2));\n        }\n        return Collections.emptyList();\n    }\n\n    private List<Candidate> experimentalFeatureCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String feature : EXPERIMENTAL_FEATURES) {\n            if (!matches(feature, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    feature,\n                    feature,\n                    \"Experimental\",\n                    describeExperimentalFeature(feature),\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private String describeExperimentalFeature(String feature) {\n        if (\"subagent\".equalsIgnoreCase(feature)) {\n            return \"Toggle experimental subagent tool injection\";\n        }\n        if (\"agent-teams\".equalsIgnoreCase(feature)) {\n            return \"Toggle experimental agent team tool injection\";\n        }\n        return \"Experimental runtime feature\";\n    }\n\n    private List<Candidate> experimentalToggleCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String option : STREAM_OPTIONS) {\n            if (!matches(option, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    option,\n                    option,\n                    \"Experimental\",\n                    \"Set experimental feature \" + option,\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> teamCandidates(List<String> tokens, boolean endsWithSpace) {\n        if (tokens == null || tokens.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 1) {\n            return teamActionCandidates(\"\");\n        }\n        if (tokens.size() == 2 && !endsWithSpace) {\n            return teamActionCandidates(tokens.get(1));\n        }\n        String action = tokens.get(1);\n        if (\"list\".equalsIgnoreCase(action)) {\n            return Collections.emptyList();\n        }\n        if (\"status\".equalsIgnoreCase(action) || \"resume\".equalsIgnoreCase(action)) {\n            if (tokens.size() == 2 && endsWithSpace) {\n                return teamIdCandidates(\"\");\n            }\n            if (tokens.size() == 3 && !endsWithSpace) {\n                return teamIdCandidates(tokens.get(2));\n            }\n            return Collections.emptyList();\n        }\n        if (\"messages\".equalsIgnoreCase(action)) {\n            if (tokens.size() == 2 && endsWithSpace) {\n                return teamIdCandidates(\"\");\n            }\n            if (tokens.size() == 3 && !endsWithSpace) {\n                return teamIdCandidates(tokens.get(2));\n            }\n            if (tokens.size() == 3 && endsWithSpace) {\n                return teamMessageLimitCandidates(\"\");\n            }\n            if (tokens.size() == 4 && !endsWithSpace) {\n                return teamMessageLimitCandidates(tokens.get(3));\n            }\n        }\n        return Collections.emptyList();\n    }\n\n    private List<Candidate> teamActionCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String action : TEAM_ACTIONS) {\n            if (!matches(action, partial)) {\n                continue;\n            }\n            candidates.add(commandCandidate(action, action, \"Team\", describeTeamAction(action), !\"list\".equalsIgnoreCase(action)));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> teamIdCandidates(String partial) {\n        Supplier<List<String>> supplier = teamCandidateSupplier;\n        List<String> teamIds = supplier == null ? null : supplier.get();\n        if (teamIds == null || teamIds.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String teamId : teamIds) {\n            if (isBlank(teamId) || !matches(teamId, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    teamId,\n                    teamId,\n                    \"Team\",\n                    \"Inspect persisted team \" + teamId,\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> teamMessageLimitCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String option : TEAM_MESSAGE_LIMITS) {\n            if (!matches(option, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    option,\n                    option,\n                    \"Team\",\n                    \"Read up to \" + option + \" persisted team messages\",\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private String describeTeamAction(String action) {\n        if (\"list\".equalsIgnoreCase(action)) {\n            return \"List persisted teams in the current workspace\";\n        }\n        if (\"status\".equalsIgnoreCase(action)) {\n            return \"Show one persisted team's current snapshot\";\n        }\n        if (\"messages\".equalsIgnoreCase(action)) {\n            return \"Show recent messages from a persisted team mailbox\";\n        }\n        if (\"resume\".equalsIgnoreCase(action)) {\n            return \"Load a persisted team snapshot and reopen its board view\";\n        }\n        return \"Team action\";\n    }\n\n    private List<Candidate> skillCandidates(String partial) {\n        Supplier<List<String>> supplier = skillCandidateSupplier;\n        List<String> skills = supplier == null ? null : supplier.get();\n        if (skills == null || skills.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String skill : skills) {\n            if (isBlank(skill) || !matches(skill, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    skill,\n                    skill,\n                    \"Skills\",\n                    \"Inspect coding skill \" + skill,\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> agentCandidates(String partial) {\n        Supplier<List<String>> supplier = agentCandidateSupplier;\n        List<String> agents = supplier == null ? null : supplier.get();\n        if (agents == null || agents.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String agent : agents) {\n            if (isBlank(agent) || !matches(agent, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    agent,\n                    agent,\n                    \"Agents\",\n                    \"Inspect coding agent \" + agent,\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> mcpCandidates(List<String> tokens, boolean endsWithSpace) {\n        if (tokens == null || tokens.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 1) {\n            return mcpActionCandidates(\"\");\n        }\n        if (tokens.size() == 2 && !endsWithSpace) {\n            return mcpActionCandidates(tokens.get(1));\n        }\n        String action = tokens.get(1);\n        if (\"add\".equalsIgnoreCase(action)) {\n            return mcpAddCandidates(tokens, endsWithSpace);\n        }\n        if (\"list\".equalsIgnoreCase(action)) {\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 2 && endsWithSpace) {\n            return mcpServerNameCandidates(action, \"\");\n        }\n        if (tokens.size() == 3 && !endsWithSpace) {\n            return mcpServerNameCandidates(action, tokens.get(2));\n        }\n        return Collections.emptyList();\n    }\n\n    private List<Candidate> mcpActionCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String action : MCP_ACTIONS) {\n            if (!matches(action, partial)) {\n                continue;\n            }\n            candidates.add(commandCandidate(action, action, \"MCP\", describeMcpAction(action), true));\n        }\n        return candidates;\n    }\n\n    private String describeMcpAction(String action) {\n        if (\"list\".equalsIgnoreCase(action)) {\n            return \"List MCP services\";\n        }\n        if (\"add\".equalsIgnoreCase(action)) {\n            return \"Add a global MCP service\";\n        }\n        if (\"enable\".equalsIgnoreCase(action)) {\n            return \"Enable an MCP service in this workspace\";\n        }\n        if (\"disable\".equalsIgnoreCase(action)) {\n            return \"Disable an MCP service in this workspace\";\n        }\n        if (\"pause\".equalsIgnoreCase(action)) {\n            return \"Pause an MCP service for this session\";\n        }\n        if (\"resume\".equalsIgnoreCase(action)) {\n            return \"Resume an MCP service for this session\";\n        }\n        if (\"retry\".equalsIgnoreCase(action)) {\n            return \"Reconnect an MCP service\";\n        }\n        if (\"remove\".equalsIgnoreCase(action)) {\n            return \"Delete a global MCP service\";\n        }\n        return \"MCP action\";\n    }\n\n    private List<Candidate> mcpAddCandidates(List<String> tokens, boolean endsWithSpace) {\n        if (tokens == null || tokens.size() < 2) {\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 2 && endsWithSpace) {\n            return mcpAddOptionCandidates(\"\");\n        }\n        if (tokens.size() == 3 && !endsWithSpace) {\n            return mcpAddOptionCandidates(tokens.get(2));\n        }\n        if (tokens.size() == 3 && endsWithSpace) {\n            if (MCP_TRANSPORT_FLAG.equalsIgnoreCase(tokens.get(2))) {\n                return mcpTransportCandidates(\"\");\n            }\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 4 && !endsWithSpace && MCP_TRANSPORT_FLAG.equalsIgnoreCase(tokens.get(2))) {\n            return mcpTransportCandidates(tokens.get(3));\n        }\n        return Collections.emptyList();\n    }\n\n    private List<Candidate> mcpAddOptionCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        if (matches(MCP_TRANSPORT_FLAG, partial)) {\n            candidates.add(commandCandidate(\n                    MCP_TRANSPORT_FLAG,\n                    MCP_TRANSPORT_FLAG,\n                    \"MCP\",\n                    \"Choose the MCP transport: stdio, sse, http\",\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> mcpTransportCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String transport : MCP_TRANSPORT_OPTIONS) {\n            if (!matches(transport, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    transport,\n                    transport,\n                    \"MCP\",\n                    \"Use \" + transport + \" transport\",\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> mcpServerNameCandidates(String action, String partial) {\n        Supplier<List<String>> supplier = mcpServerCandidateSupplier;\n        List<String> serverNames = supplier == null ? null : supplier.get();\n        if (serverNames == null || serverNames.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String serverName : serverNames) {\n            if (isBlank(serverName) || !matches(serverName, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    serverName,\n                    serverName,\n                    \"MCP\",\n                    describeMcpServerAction(action, serverName),\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private String describeMcpServerAction(String action, String serverName) {\n        if (\"enable\".equalsIgnoreCase(action)) {\n            return \"Enable MCP service \" + serverName;\n        }\n        if (\"disable\".equalsIgnoreCase(action)) {\n            return \"Disable MCP service \" + serverName;\n        }\n        if (\"pause\".equalsIgnoreCase(action)) {\n            return \"Pause MCP service \" + serverName + \" for this session\";\n        }\n        if (\"resume\".equalsIgnoreCase(action)) {\n            return \"Resume MCP service \" + serverName + \" for this session\";\n        }\n        if (\"retry\".equalsIgnoreCase(action)) {\n            return \"Reconnect MCP service \" + serverName;\n        }\n        if (\"remove\".equalsIgnoreCase(action)) {\n            return \"Delete global MCP service \" + serverName;\n        }\n        return \"Use MCP service \" + serverName;\n    }\n\n    private List<Candidate> providerCandidates(List<String> tokens, boolean endsWithSpace) {\n        if (tokens == null || tokens.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 1) {\n            return providerActionCandidates(\"\");\n        }\n        if (tokens.size() == 2 && !endsWithSpace) {\n            String action = tokens.get(1);\n            if (isExactProviderAction(action)) {\n                List<Candidate> nestedCandidates = providerNameCandidates(action, \"\");\n                if (!nestedCandidates.isEmpty()) {\n                    return prefixCandidates(\"/provider \" + action + \" \", nestedCandidates);\n                }\n            }\n            return providerActionCandidates(action);\n        }\n        String action = tokens.get(1);\n        if ((\"add\".equalsIgnoreCase(action) || \"edit\".equalsIgnoreCase(action))\n                && (tokens.size() > 2 || endsWithSpace)) {\n            return providerMutationCandidates(action, tokens, endsWithSpace);\n        }\n        if (tokens.size() == 2 && endsWithSpace) {\n            return providerNameCandidates(action, \"\");\n        }\n        if (tokens.size() == 3 && !endsWithSpace) {\n            return providerNameCandidates(action, tokens.get(2));\n        }\n        return Collections.emptyList();\n    }\n\n    private boolean isExactProviderAction(String value) {\n        if (isBlank(value)) {\n            return false;\n        }\n        for (String action : PROVIDER_ACTIONS) {\n            if (action.equalsIgnoreCase(value)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private List<Candidate> providerActionCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String action : PROVIDER_ACTIONS) {\n            if (!matches(action, partial)) {\n                continue;\n            }\n            candidates.add(commandCandidate(action, action, \"Provider\", \"provider \" + action, true));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> providerNameCandidates(String action, String partial) {\n        if (\"add\".equalsIgnoreCase(action)) {\n            return Collections.emptyList();\n        }\n        if (\"default\".equalsIgnoreCase(action)) {\n            return providerDefaultCandidates(partial);\n        }\n        Supplier<List<String>> supplier = profileCandidateSupplier;\n        List<String> profiles = supplier == null ? null : supplier.get();\n        if (profiles == null || profiles.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String profile : profiles) {\n            if (!matches(profile, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    profile,\n                    profile,\n                    \"Profiles\",\n                    describeProviderProfileAction(action, profile),\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private String describeProviderProfileAction(String action, String profile) {\n        if (\"save\".equalsIgnoreCase(action)) {\n            return \"Overwrite saved profile \" + profile;\n        }\n        if (\"edit\".equalsIgnoreCase(action)) {\n            return \"Edit profile \" + profile;\n        }\n        if (\"remove\".equalsIgnoreCase(action)) {\n            return \"Delete profile \" + profile;\n        }\n        return \"Use profile \" + profile;\n    }\n\n    private List<Candidate> providerDefaultCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String option : PROVIDER_DEFAULT_OPTIONS) {\n            if (!matches(option, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    option,\n                    option,\n                    \"Provider\",\n                    \"Clear the global default profile\",\n                    null,\n                    null,\n                    true\n            ));\n        }\n        Supplier<List<String>> supplier = profileCandidateSupplier;\n        List<String> profiles = supplier == null ? null : supplier.get();\n        if (profiles == null) {\n            return candidates;\n        }\n        for (String profile : profiles) {\n            if (!matches(profile, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    profile,\n                    profile,\n                    \"Profiles\",\n                    \"Set the default profile to \" + profile,\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> providerMutationCandidates(String action, List<String> tokens, boolean endsWithSpace) {\n        if (tokens == null || tokens.size() < 2) {\n            return Collections.emptyList();\n        }\n        if (\"edit\".equalsIgnoreCase(action)) {\n            if (tokens.size() == 2 && endsWithSpace) {\n                return providerNameCandidates(action, \"\");\n            }\n            if (tokens.size() == 3 && !endsWithSpace) {\n                return providerNameCandidates(action, tokens.get(2));\n            }\n        } else if (tokens.size() == 2 && endsWithSpace) {\n            return Collections.emptyList();\n        }\n\n        if (tokens.size() <= 3) {\n            return endsWithSpace ? providerMutationOptionCandidates(\"\") : Collections.<Candidate>emptyList();\n        }\n\n        String lastToken = tokens.get(tokens.size() - 1);\n        if (endsWithSpace) {\n            if (isProviderMutationOption(lastToken)) {\n                return providerMutationValueCandidates(lastToken, \"\");\n            }\n            return providerMutationOptionCandidates(\"\");\n        }\n\n        if (lastToken.startsWith(\"--\")) {\n            return providerMutationOptionCandidates(lastToken);\n        }\n\n        String previousToken = tokens.get(tokens.size() - 2);\n        if (isProviderMutationOption(previousToken)) {\n            return providerMutationValueCandidates(previousToken, lastToken);\n        }\n        return Collections.emptyList();\n    }\n\n    private List<Candidate> providerMutationOptionCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String option : PROVIDER_MUTATION_OPTIONS) {\n            if (!matches(option, partial)) {\n                continue;\n            }\n            candidates.add(commandCandidate(\n                    option,\n                    option,\n                    \"Provider\",\n                    describeProviderMutationOption(option),\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private String describeProviderMutationOption(String option) {\n        if (\"--provider\".equalsIgnoreCase(option)) {\n            return \"Set provider name\";\n        }\n        if (\"--protocol\".equalsIgnoreCase(option)) {\n            return \"Set protocol: chat, responses\";\n        }\n        if (\"--model\".equalsIgnoreCase(option)) {\n            return \"Set default model\";\n        }\n        if (\"--base-url\".equalsIgnoreCase(option)) {\n            return \"Set custom base URL\";\n        }\n        if (\"--api-key\".equalsIgnoreCase(option)) {\n            return \"Set profile API key\";\n        }\n        if (\"--clear-model\".equalsIgnoreCase(option)) {\n            return \"Clear saved model\";\n        }\n        if (\"--clear-base-url\".equalsIgnoreCase(option)) {\n            return \"Clear saved base URL\";\n        }\n        if (\"--clear-api-key\".equalsIgnoreCase(option)) {\n            return \"Clear saved API key\";\n        }\n        return \"Provider option\";\n    }\n\n    private boolean isProviderMutationOption(String value) {\n        if (isBlank(value)) {\n            return false;\n        }\n        for (String option : PROVIDER_MUTATION_OPTIONS) {\n            if (option.equalsIgnoreCase(value)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private List<Candidate> providerMutationValueCandidates(String option, String partial) {\n        if (\"--provider\".equalsIgnoreCase(option)) {\n            return providerTypeCandidates(partial);\n        }\n        if (\"--protocol\".equalsIgnoreCase(option)) {\n            return providerProtocolCandidates(partial);\n        }\n        return Collections.emptyList();\n    }\n\n    private List<Candidate> providerTypeCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (PlatformType platformType : PlatformType.values()) {\n            if (platformType == null || isBlank(platformType.getPlatform()) || !matches(platformType.getPlatform(), partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    platformType.getPlatform(),\n                    platformType.getPlatform(),\n                    \"Providers\",\n                    \"Use provider \" + platformType.getPlatform(),\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> providerProtocolCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        addProviderProtocolCandidate(candidates, CliProtocol.CHAT, partial);\n        addProviderProtocolCandidate(candidates, CliProtocol.RESPONSES, partial);\n        return candidates;\n    }\n\n    private void addProviderProtocolCandidate(List<Candidate> candidates, CliProtocol protocol, String partial) {\n        if (protocol == null || isBlank(protocol.getValue()) || !matches(protocol.getValue(), partial)) {\n            return;\n        }\n        candidates.add(new Candidate(\n                protocol.getValue(),\n                protocol.getValue(),\n                \"Protocols\",\n                \"Use protocol \" + protocol.getValue(),\n                null,\n                null,\n                true\n        ));\n    }\n\n    private List<Candidate> modelCandidates(String partial) {\n        LinkedHashMap<String, Candidate> candidates = new LinkedHashMap<String, Candidate>();\n        Supplier<List<ModelCompletionCandidate>> supplier = modelCandidateSupplier;\n        List<ModelCompletionCandidate> modelCandidates = supplier == null ? null : supplier.get();\n        if (modelCandidates != null) {\n            for (ModelCompletionCandidate modelCandidate : modelCandidates) {\n                if (modelCandidate == null || isBlank(modelCandidate.getModel()) || !matches(modelCandidate.getModel(), partial)) {\n                    continue;\n                }\n                String model = modelCandidate.getModel();\n                candidates.put(model.toLowerCase(Locale.ROOT), new Candidate(\n                        model,\n                        model,\n                        \"Models\",\n                        firstNonBlank(modelCandidate.getDescription(), \"Use model \" + model),\n                        null,\n                        null,\n                        true\n                ));\n            }\n        }\n        if (matches(MODEL_RESET, partial)) {\n            candidates.put(\"__reset__\", new Candidate(\n                    MODEL_RESET,\n                    MODEL_RESET,\n                    \"Model\",\n                    \"Reset the workspace model override\",\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return new ArrayList<Candidate>(candidates.values());\n    }\n\n    private List<Candidate> sessionCandidates(String partial) {\n        CodingSessionManager manager = sessionManager;\n        if (manager == null) {\n            return Collections.emptyList();\n        }\n        try {\n            List<Candidate> candidates = new ArrayList<Candidate>();\n            for (CodingSessionDescriptor descriptor : manager.list()) {\n                if (descriptor == null || !matches(descriptor.getSessionId(), partial)) {\n                    continue;\n                }\n                candidates.add(new Candidate(\n                        descriptor.getSessionId(),\n                        descriptor.getSessionId(),\n                        \"Sessions\",\n                        firstNonBlank(descriptor.getSummary(), \"Saved session\"),\n                        null,\n                        null,\n                        true\n                ));\n            }\n            return candidates;\n        } catch (IOException ignored) {\n            return Collections.emptyList();\n        }\n    }\n\n    private List<Candidate> processCandidates(List<String> tokens, boolean endsWithSpace) {\n        if (tokens == null || tokens.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 1) {\n            return processSubcommandCandidates(\"\");\n        }\n        if (tokens.size() == 2 && !endsWithSpace) {\n            return processSubcommandCandidates(tokens.get(1));\n        }\n        ProcessCommandSpec commandSpec = findProcessCommandSpec(tokens.get(1));\n        if (commandSpec == null) {\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 2 && endsWithSpace) {\n            return processIdCandidates(commandSpec, \"\");\n        }\n        if (tokens.size() == 3 && !endsWithSpace) {\n            return processIdCandidates(commandSpec, tokens.get(2));\n        }\n        if (tokens.size() == 3 && endsWithSpace) {\n            if (commandSpec.acceptsFreeText) {\n                return Collections.emptyList();\n            }\n            if (commandSpec.supportsLimit) {\n                return processLimitCandidates(commandSpec, \"\");\n            }\n            return Collections.emptyList();\n        }\n        if (tokens.size() == 4 && !endsWithSpace && commandSpec.supportsLimit) {\n            return processLimitCandidates(commandSpec, tokens.get(3));\n        }\n        return Collections.emptyList();\n    }\n\n    private List<Candidate> processSubcommandCandidates(String partial) {\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (ProcessCommandSpec action : PROCESS_COMMANDS) {\n            if (!matches(action.name, partial)) {\n                continue;\n            }\n            candidates.add(commandCandidate(\n                    action.name,\n                    action.name,\n                    \"Process\",\n                    action.description,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> processIdCandidates(ProcessCommandSpec commandSpec, String partial) {\n        Supplier<List<ProcessCompletionCandidate>> supplier = processCandidateSupplier;\n        List<ProcessCompletionCandidate> processCandidates = supplier == null ? null : supplier.get();\n        if (processCandidates == null || processCandidates.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (ProcessCompletionCandidate processCandidate : processCandidates) {\n            if (processCandidate == null || !matches(processCandidate.getProcessId(), partial)) {\n                continue;\n            }\n            candidates.add(commandCandidate(\n                    processCandidate.getProcessId(),\n                    processCandidate.getProcessId(),\n                    \"Processes\",\n                    firstNonBlank(processCandidate.getDescription(), \"Process \" + processCandidate.getProcessId()),\n                    commandSpec.supportsLimit || commandSpec.acceptsFreeText\n            ));\n        }\n        return candidates;\n    }\n\n    private List<Candidate> processLimitCandidates(ProcessCommandSpec commandSpec, String partial) {\n        List<String> limits = \"logs\".equalsIgnoreCase(commandSpec.name) ? PROCESS_LOG_LIMITS : PROCESS_FOLLOW_LIMITS;\n        List<Candidate> candidates = new ArrayList<Candidate>();\n        for (String limit : limits) {\n            if (!matches(limit, partial)) {\n                continue;\n            }\n            candidates.add(new Candidate(\n                    limit,\n                    limit,\n                    \"Limits\",\n                    commandSpec.name + \" limit \" + limit,\n                    null,\n                    null,\n                    true\n            ));\n        }\n        return candidates;\n    }\n\n    private ProcessCommandSpec findProcessCommandSpec(String value) {\n        if (isBlank(value)) {\n            return null;\n        }\n        for (ProcessCommandSpec commandSpec : PROCESS_COMMANDS) {\n            if (commandSpec.name.equalsIgnoreCase(value)) {\n                return commandSpec;\n            }\n        }\n        return null;\n    }\n\n    private Candidate commandCandidate(String value,\n                                       String display,\n                                       String group,\n                                       String description,\n                                       boolean appendSpace) {\n        String candidateValue = appendSpace ? value + \" \" : value;\n        return new Candidate(candidateValue, display, group, description, null, null, !appendSpace);\n    }\n\n    private String tokenFragment(List<String> tokens, boolean endsWithSpace) {\n        if (tokens == null || tokens.isEmpty()) {\n            return \"\";\n        }\n        if (endsWithSpace) {\n            return \"\";\n        }\n        return tokens.get(tokens.size() - 1);\n    }\n\n    private List<String> splitTokens(String value) {\n        if (isBlank(value)) {\n            return Collections.emptyList();\n        }\n        String trimmed = value.trim();\n        if (trimmed.isEmpty()) {\n            return Collections.emptyList();\n        }\n        return Arrays.asList(trimmed.split(\"\\\\s+\"));\n    }\n\n    private boolean matches(String candidate, String partial) {\n        if (isBlank(candidate)) {\n            return false;\n        }\n        if (isBlank(partial) || \"/\".equals(partial)) {\n            return true;\n        }\n        String normalizedCandidate = candidate.toLowerCase(Locale.ROOT);\n        String normalizedPartial = partial.toLowerCase(Locale.ROOT);\n        return normalizedCandidate.startsWith(normalizedPartial);\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private boolean sameText(String left, String right) {\n        if (left == null) {\n            return right == null;\n        }\n        return left.equals(right);\n    }\n\n    private static final class SlashCommandSpec {\n\n        private final String command;\n        private final String description;\n        private final boolean requiresArgument;\n\n        private SlashCommandSpec(String command, String description, boolean requiresArgument) {\n            this.command = command;\n            this.description = description;\n            this.requiresArgument = requiresArgument;\n        }\n    }\n\n    public static final class ProcessCompletionCandidate {\n\n        private final String processId;\n        private final String description;\n\n        public ProcessCompletionCandidate(String processId, String description) {\n            this.processId = processId;\n            this.description = description;\n        }\n\n        public String getProcessId() {\n            return processId;\n        }\n\n        public String getDescription() {\n            return description;\n        }\n    }\n\n    public static final class ModelCompletionCandidate {\n\n        private final String model;\n        private final String description;\n\n        public ModelCompletionCandidate(String model, String description) {\n            this.model = model;\n            this.description = description;\n        }\n\n        public String getModel() {\n            return model;\n        }\n\n        public String getDescription() {\n            return description;\n        }\n    }\n\n    private static final class ProcessCommandSpec {\n\n        private final String name;\n        private final String description;\n        private final boolean supportsLimit;\n        private final boolean acceptsFreeText;\n\n        private ProcessCommandSpec(String name, String description, boolean supportsLimit, boolean acceptsFreeText) {\n            this.name = name;\n            this.description = description;\n            this.supportsLimit = supportsLimit;\n            this.acceptsFreeText = acceptsFreeText;\n        }\n    }\n\n    public static final class PaletteSnapshot {\n\n        private final boolean open;\n        private final String query;\n        private final int selectedIndex;\n        private final List<PaletteItemSnapshot> items;\n\n        private PaletteSnapshot(boolean open, String query, int selectedIndex, List<PaletteItemSnapshot> items) {\n            this.open = open;\n            this.query = query;\n            this.selectedIndex = selectedIndex;\n            this.items = items == null\n                    ? Collections.<PaletteItemSnapshot>emptyList()\n                    : Collections.unmodifiableList(new ArrayList<PaletteItemSnapshot>(items));\n        }\n\n        public static PaletteSnapshot closed() {\n            return new PaletteSnapshot(false, \"\", -1, Collections.<PaletteItemSnapshot>emptyList());\n        }\n\n        public boolean isOpen() {\n            return open;\n        }\n\n        public String getQuery() {\n            return query;\n        }\n\n        public int getSelectedIndex() {\n            return selectedIndex;\n        }\n\n        public List<PaletteItemSnapshot> getItems() {\n            return items;\n        }\n\n        PaletteSnapshot withSelectedIndex(int selectedIndex) {\n            return new PaletteSnapshot(open, query, selectedIndex, items);\n        }\n\n        PaletteSnapshot copy() {\n            return new PaletteSnapshot(open, query, selectedIndex, items);\n        }\n\n        String selectedValue() {\n            if (selectedIndex < 0 || selectedIndex >= items.size()) {\n                return null;\n            }\n            return items.get(selectedIndex).value;\n        }\n    }\n\n    public static final class PaletteItemSnapshot {\n\n        private final String value;\n        private final String display;\n        private final String description;\n        private final String group;\n\n        private PaletteItemSnapshot(String value, String display, String description, String group) {\n            this.value = value;\n            this.display = display;\n            this.description = description;\n            this.group = group;\n        }\n\n        public String getValue() {\n            return value;\n        }\n\n        public String getDisplay() {\n            return display;\n        }\n\n        public String getDescription() {\n            return description;\n        }\n\n        public String getGroup() {\n            return group;\n        }\n    }\n\n    enum SlashMenuAction {\n        INSERT_AND_MENU,\n        MENU_ONLY,\n        INSERT_ONLY\n    }\n\n    enum EnterAction {\n        ACCEPT,\n        IGNORE_EMPTY\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpCodingCliAgentFactory.java",
    "content": "package io.github.lnyocly.ai4j.cli.acp;\n\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\nimport io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig;\nimport io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\n\nimport java.util.Collection;\n\nfinal class AcpCodingCliAgentFactory extends DefaultCodingCliAgentFactory {\n\n    private final AcpToolApprovalDecorator.PermissionGateway permissionGateway;\n    private final CliResolvedMcpConfig resolvedMcpConfig;\n\n    AcpCodingCliAgentFactory(AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                             CliResolvedMcpConfig resolvedMcpConfig) {\n        this.permissionGateway = permissionGateway;\n        this.resolvedMcpConfig = resolvedMcpConfig;\n    }\n\n    @Override\n    protected CliMcpRuntimeManager prepareMcpRuntime(CodeCommandOptions options,\n                                                     Collection<String> pausedMcpServers,\n                                                     TerminalIO terminal) {\n        if (resolvedMcpConfig == null) {\n            return super.prepareMcpRuntime(options, pausedMcpServers, terminal);\n        }\n        try {\n            return CliMcpRuntimeManager.initialize(resolvedMcpConfig);\n        } catch (Exception ex) {\n            return null;\n        }\n    }\n\n    @Override\n    protected ToolExecutorDecorator createToolExecutorDecorator(CodeCommandOptions options,\n                                                                TerminalIO terminal,\n                                                                TuiInteractionState interactionState) {\n        return new AcpToolApprovalDecorator(\n                options == null ? ApprovalMode.AUTO : options.getApprovalMode(),\n                permissionGateway\n        );\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpCommand.java",
    "content": "package io.github.lnyocly.ai4j.cli.acp;\n\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptionsParser;\n\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Properties;\n\npublic class AcpCommand {\n\n    private final Map<String, String> env;\n    private final Properties properties;\n    private final Path currentDirectory;\n    private final CodeCommandOptionsParser parser = new CodeCommandOptionsParser();\n\n    public AcpCommand(Map<String, String> env, Properties properties, Path currentDirectory) {\n        this.env = env;\n        this.properties = properties;\n        this.currentDirectory = currentDirectory;\n    }\n\n    public int run(List<String> args, InputStream in, OutputStream out, OutputStream err) {\n        try {\n            CodeCommandOptions options = parser.parse(args, env, properties, currentDirectory);\n            if (options.isHelp()) {\n                printHelp(err);\n                return 0;\n            }\n            return new AcpJsonRpcServer(in, out, err, options).run();\n        } catch (IllegalArgumentException ex) {\n            writeLine(err, \"Argument error: \" + ex.getMessage());\n            printHelp(err);\n            return 2;\n        } catch (Exception ex) {\n            writeLine(err, \"ACP failed: \" + safeMessage(ex));\n            return 1;\n        }\n    }\n\n    private void printHelp(OutputStream err) {\n        writeLine(err, \"ai4j-cli acp\");\n        writeLine(err, \"  Start the coding agent as an ACP stdio server.\");\n        writeLine(err, \"\");\n        writeLine(err, \"Usage:\");\n        writeLine(err, \"  ai4j-cli acp --provider <name> --model <model> [options]\");\n        writeLine(err, \"\");\n        writeLine(err, \"Notes:\");\n        writeLine(err, \"  ACP mode uses newline-delimited JSON-RPC on stdin/stdout.\");\n        writeLine(err, \"  All logs and warnings are written to stderr.\");\n        writeLine(err, \"  Provider/model/api-key options follow the same parsing rules as `ai4j-cli code`.\");\n    }\n\n    private void writeLine(OutputStream stream, String line) {\n        try {\n            stream.write((line + System.lineSeparator()).getBytes(\"UTF-8\"));\n            stream.flush();\n        } catch (Exception ignored) {\n        }\n    }\n\n    private String safeMessage(Throwable throwable) {\n        String message = throwable == null ? null : throwable.getMessage();\n        return message == null || message.trim().isEmpty()\n                ? (throwable == null ? \"unknown error\" : throwable.getClass().getSimpleName())\n                : message.trim();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpJsonRpcServer.java",
    "content": "package io.github.lnyocly.ai4j.cli.acp;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpServerDefinition;\nimport io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig;\nimport io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpServer;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderProfile;\nimport io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig;\nimport io.github.lnyocly.ai4j.cli.provider.CliResolvedProviderConfig;\nimport io.github.lnyocly.ai4j.cli.runtime.HeadlessCodingSessionRuntime;\nimport io.github.lnyocly.ai4j.cli.runtime.HeadlessTurnObserver;\nimport io.github.lnyocly.ai4j.cli.runtime.CodingTaskSessionEventBridge;\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.FileCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.FileSessionEventStore;\nimport io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.coding.runtime.CodingRuntime;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport io.github.lnyocly.ai4j.service.PlatformType;\n\nimport java.io.BufferedReader;\nimport java.io.Closeable;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.io.OutputStreamWriter;\nimport java.io.PrintWriter;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Properties;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicLong;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class AcpJsonRpcServer implements Closeable {\n\n    interface AgentFactoryProvider {\n\n        CodingCliAgentFactory create(CodeCommandOptions options,\n                                     AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                                     CliResolvedMcpConfig resolvedMcpConfig);\n    }\n\n    private static final String METHOD_INITIALIZE = \"initialize\";\n    private static final String METHOD_SESSION_NEW = \"session/new\";\n    private static final String METHOD_SESSION_LOAD = \"session/load\";\n    private static final String METHOD_SESSION_LIST = \"session/list\";\n    private static final String METHOD_SESSION_PROMPT = \"session/prompt\";\n    private static final String METHOD_SESSION_SET_MODE = \"session/set_mode\";\n    private static final String METHOD_SESSION_SET_CONFIG_OPTION = \"session/set_config_option\";\n    private static final String METHOD_SESSION_CANCEL = \"session/cancel\";\n    private static final String METHOD_SESSION_UPDATE = \"session/update\";\n    private static final String METHOD_SESSION_REQUEST_PERMISSION = \"session/request_permission\";\n\n    private final InputStream inputStream;\n    private final PrintWriter stdout;\n    private final PrintWriter stderr;\n    private final CodeCommandOptions baseOptions;\n    private final AgentFactoryProvider agentFactoryProvider;\n    private final Object writeLock = new Object();\n    private final AtomicLong outboundRequestIds = new AtomicLong(1L);\n    private final ExecutorService requestExecutor = Executors.newSingleThreadExecutor();\n    private final ExecutorService promptExecutor = Executors.newCachedThreadPool();\n    private final ConcurrentMap<String, SessionHandle> sessions = new ConcurrentHashMap<String, SessionHandle>();\n    private final ConcurrentMap<String, CompletableFuture<JSONObject>> pendingClientResponses = new ConcurrentHashMap<String, CompletableFuture<JSONObject>>();\n\n    public AcpJsonRpcServer(InputStream inputStream,\n                            OutputStream outputStream,\n                            OutputStream errorStream,\n                            CodeCommandOptions baseOptions) {\n        this(inputStream, outputStream, errorStream, baseOptions, new AgentFactoryProvider() {\n            @Override\n            public CodingCliAgentFactory create(CodeCommandOptions options,\n                                                AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                                                CliResolvedMcpConfig resolvedMcpConfig) {\n                return new AcpCodingCliAgentFactory(permissionGateway, resolvedMcpConfig);\n            }\n        });\n    }\n\n    AcpJsonRpcServer(InputStream inputStream,\n                     OutputStream outputStream,\n                     OutputStream errorStream,\n                     CodeCommandOptions baseOptions,\n                     AgentFactoryProvider agentFactoryProvider) {\n        this.inputStream = inputStream;\n        this.stdout = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), true);\n        this.stderr = new PrintWriter(new OutputStreamWriter(errorStream, StandardCharsets.UTF_8), true);\n        this.baseOptions = baseOptions;\n        this.agentFactoryProvider = agentFactoryProvider;\n    }\n\n    public int run() {\n        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));\n        try {\n            String line;\n            while ((line = reader.readLine()) != null) {\n                String trimmed = line == null ? null : line.trim();\n                if (trimmed == null || trimmed.isEmpty()) {\n                    continue;\n                }\n                JSONObject message;\n                try {\n                    message = JSON.parseObject(trimmed);\n                } catch (Exception ex) {\n                    logError(\"Invalid ACP JSON message: \" + safeMessage(ex));\n                    continue;\n                }\n                if (message != null) {\n                    handleMessage(message);\n                }\n            }\n            return 0;\n        } catch (IOException ex) {\n            logError(\"ACP server failed: \" + safeMessage(ex));\n            return 1;\n        } finally {\n            awaitPendingWork();\n            close();\n        }\n    }\n\n    private void handleMessage(final JSONObject message) {\n        if (message.containsKey(\"method\")) {\n            final String method = message.getString(\"method\");\n            final Object id = message.get(\"id\");\n            final JSONObject params = message.getJSONObject(\"params\");\n            requestExecutor.submit(new Runnable() {\n                @Override\n                public void run() {\n                    if (id == null) {\n                        handleNotification(method, params);\n                    } else {\n                        handleRequest(id, method, params);\n                    }\n                }\n            });\n            return;\n        }\n\n        if (message.containsKey(\"id\")) {\n            CompletableFuture<JSONObject> future = pendingClientResponses.remove(requestIdKey(message.get(\"id\")));\n            if (future != null) {\n                future.complete(message);\n            }\n        }\n    }\n\n    private void handleRequest(Object id, String method, JSONObject params) {\n        try {\n            if (METHOD_INITIALIZE.equals(method)) {\n                sendResponse(id, buildInitializeResponse(params));\n                return;\n            }\n            if (METHOD_SESSION_NEW.equals(method)) {\n                SessionHandle handle = createSession(params, false);\n                sendResponse(id, handle.buildSessionOpenResult());\n                handle.sendAvailableCommandsUpdate();\n                return;\n            }\n            if (METHOD_SESSION_LOAD.equals(method)) {\n                SessionHandle handle = createSession(params, true);\n                handle.replayHistory();\n                sendResponse(id, handle.buildSessionOpenResult());\n                handle.sendAvailableCommandsUpdate();\n                return;\n            }\n            if (METHOD_SESSION_LIST.equals(method)) {\n                sendResponse(id, newMap(\"sessions\", listSessions(params)));\n                return;\n            }\n            if (METHOD_SESSION_PROMPT.equals(method)) {\n                promptSession(id, params);\n                return;\n            }\n            if (METHOD_SESSION_SET_MODE.equals(method)) {\n                sendResponse(id, setSessionMode(params));\n                return;\n            }\n            if (METHOD_SESSION_SET_CONFIG_OPTION.equals(method)) {\n                sendResponse(id, setSessionConfigOption(params));\n                return;\n            }\n            if (METHOD_SESSION_CANCEL.equals(method)) {\n                cancelSession(params);\n                sendResponse(id, Collections.<String, Object>emptyMap());\n                return;\n            }\n            sendError(id, -32601, \"Method not found: \" + method);\n        } catch (Exception ex) {\n            sendError(id, -32000, safeMessage(ex));\n        }\n    }\n\n    private void handleNotification(String method, JSONObject params) {\n        try {\n            if (METHOD_SESSION_CANCEL.equals(method)) {\n                cancelSession(params);\n                return;\n            }\n            logError(\"Ignoring unsupported ACP notification: \" + method);\n        } catch (Exception ex) {\n            logError(\"Failed to handle ACP notification \" + method + \": \" + safeMessage(ex));\n        }\n    }\n\n    private Map<String, Object> buildInitializeResponse(JSONObject params) {\n        int protocolVersion = 1;\n        if (params != null && params.getIntValue(\"protocolVersion\") > 0) {\n            protocolVersion = params.getIntValue(\"protocolVersion\");\n        }\n        return newMap(\n                \"protocolVersion\", protocolVersion,\n                \"agentInfo\", newMap(\n                        \"name\", \"ai4j-cli\",\n                        \"version\", \"2.1.0\"\n                ),\n                \"agentCapabilities\", newMap(\n                        \"loadSession\", Boolean.TRUE,\n                        \"mcpCapabilities\", newMap(\n                                \"http\", Boolean.TRUE,\n                                \"sse\", Boolean.TRUE\n                        ),\n                        \"promptCapabilities\", newMap(\n                                \"audio\", Boolean.FALSE,\n                                \"embeddedContext\", Boolean.FALSE,\n                                \"image\", Boolean.FALSE\n                        ),\n                        \"sessionCapabilities\", newMap(\n                                \"list\", new LinkedHashMap<String, Object>()\n                        )\n                ),\n                \"authMethods\", Collections.emptyList()\n        );\n    }\n\n    private void sendResponse(Object id, Map<String, Object> result) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"jsonrpc\", \"2.0\");\n        payload.put(\"id\", id);\n        payload.put(\"result\", result == null ? Collections.emptyMap() : result);\n        writeMessage(payload);\n    }\n\n    private void sendError(Object id, int code, String message) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"jsonrpc\", \"2.0\");\n        payload.put(\"id\", id);\n        payload.put(\"error\", newMap(\n                \"code\", code,\n                \"message\", firstNonBlank(message, \"unknown ACP error\")\n        ));\n        writeMessage(payload);\n    }\n\n    private void sendNotification(String method, Map<String, Object> params) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"jsonrpc\", \"2.0\");\n        payload.put(\"method\", method);\n        payload.put(\"params\", params == null ? Collections.emptyMap() : params);\n        writeMessage(payload);\n    }\n\n    private void sendRequest(String id, String method, Map<String, Object> params) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"jsonrpc\", \"2.0\");\n        payload.put(\"id\", id);\n        payload.put(\"method\", method);\n        payload.put(\"params\", params == null ? Collections.emptyMap() : params);\n        writeMessage(payload);\n    }\n\n    private void writeMessage(Map<String, Object> payload) {\n        synchronized (writeLock) {\n            stdout.println(JSON.toJSONString(payload));\n            stdout.flush();\n        }\n    }\n\n    private void sendSessionUpdate(String sessionId, Map<String, Object> update) {\n        sendNotification(METHOD_SESSION_UPDATE, newMap(\n                \"sessionId\", sessionId,\n                \"update\", update\n        ));\n    }\n\n    private void sendAvailableCommandsUpdate(String sessionId) {\n        if (isBlank(sessionId)) {\n            return;\n        }\n        sendSessionUpdate(sessionId, newMap(\n                \"sessionUpdate\", \"available_commands_update\",\n                \"availableCommands\", AcpSlashCommandSupport.availableCommands()\n        ));\n    }\n\n    private SessionHandle requireSession(String sessionId) {\n        SessionHandle handle = sessions.get(sessionId);\n        if (handle == null) {\n            throw new IllegalArgumentException(\"Unknown ACP session: \" + sessionId);\n        }\n        return handle;\n    }\n\n    private void logError(String message) {\n        synchronized (writeLock) {\n            stderr.println(firstNonBlank(message, \"unknown ACP error\"));\n            stderr.flush();\n        }\n    }\n\n    private SessionHandle createSession(JSONObject params, boolean loadExisting) throws Exception {\n        String cwd = requireAbsolutePath(params == null ? null : params.getString(\"cwd\"));\n        String requestedSessionId = params == null ? null : trimToNull(params.getString(\"sessionId\"));\n        CodeCommandOptions sessionOptions = resolveSessionOptions(cwd, requestedSessionId, loadExisting ? requestedSessionId : null);\n        CliResolvedMcpConfig resolvedMcpConfig = resolveMcpConfig(params == null ? null : params.getJSONArray(\"mcpServers\"));\n        final AtomicReference<String> permissionSessionIdRef = new AtomicReference<String>(requestedSessionId);\n        AcpToolApprovalDecorator.PermissionGateway permissionGateway = new AcpToolApprovalDecorator.PermissionGateway() {\n            @Override\n            public AcpToolApprovalDecorator.PermissionDecision requestApproval(String toolName,\n                                                                              AgentToolCall call,\n                                                                              Map<String, Object> rawInput) throws Exception {\n                return requestPermission(permissionSessionIdRef.get(), toolName, call, rawInput);\n            }\n        };\n        CodingCliAgentFactory factory = agentFactoryProvider.create(sessionOptions, permissionGateway, resolvedMcpConfig);\n        CodingCliAgentFactory.PreparedCodingAgent prepared = factory.prepare(sessionOptions, null, null, Collections.<String>emptySet());\n        logMcpWarnings(prepared.getMcpRuntimeManager());\n        CodingSessionManager sessionManager = createSessionManager(sessionOptions);\n        ManagedCodingSession session = loadExisting\n                ? sessionManager.resume(prepared.getAgent(), prepared.getProtocol(), sessionOptions, requestedSessionId)\n                : sessionManager.create(prepared.getAgent(), prepared.getProtocol(), sessionOptions);\n        permissionSessionIdRef.set(session.getSessionId());\n        SessionHandle handle = new SessionHandle(sessionOptions, sessionManager, factory, permissionGateway, prepared, session, resolvedMcpConfig);\n        sessions.put(session.getSessionId(), handle);\n        if (!isBlank(requestedSessionId) && !session.getSessionId().equals(requestedSessionId)) {\n            sessions.put(requestedSessionId, handle);\n        }\n        return handle;\n    }\n\n    private void promptSession(final Object requestId, JSONObject params) throws Exception {\n        String sessionId = requiredString(params, \"sessionId\");\n        final SessionHandle handle = requireSession(sessionId);\n        final String input = flattenPrompt(requiredArray(params, \"prompt\"));\n        handle.startPrompt(new Runnable() {\n            @Override\n            public void run() {\n                HeadlessCodingSessionRuntime.PromptControl promptControl = handle.beginPrompt();\n                try {\n                    HeadlessCodingSessionRuntime.PromptResult result = AcpSlashCommandSupport.supports(input)\n                            ? handle.runSlashCommand(input, promptControl)\n                            : handle.runPrompt(input, promptControl);\n                    sendResponse(requestId, newMap(\"stopReason\", result.getStopReason()));\n                } catch (Exception ex) {\n                    sendError(requestId, -32000, safeMessage(ex));\n                }\n            }\n        });\n    }\n\n    private Map<String, Object> setSessionMode(JSONObject params) throws Exception {\n        String sessionId = requiredString(params, \"sessionId\");\n        String modeId = requiredString(params, \"modeId\");\n        SessionHandle handle = requireSession(sessionId);\n        handle.setMode(modeId);\n        return Collections.<String, Object>emptyMap();\n    }\n\n    private Map<String, Object> setSessionConfigOption(JSONObject params) throws Exception {\n        String sessionId = requiredString(params, \"sessionId\");\n        String configId = requiredString(params, \"configId\");\n        Object valueObject = params == null ? null : params.get(\"value\");\n        if (valueObject == null) {\n            throw new IllegalArgumentException(\"Missing required field: value\");\n        }\n        String value = String.valueOf(valueObject);\n        SessionHandle handle = requireSession(sessionId);\n        return newMap(\n                \"configOptions\", handle.setConfigOption(configId, value)\n        );\n    }\n\n    private void cancelSession(JSONObject params) {\n        if (params == null) {\n            return;\n        }\n        String sessionId = trimToNull(params.getString(\"sessionId\"));\n        if (sessionId == null) {\n            return;\n        }\n        SessionHandle handle = sessions.get(sessionId);\n        if (handle != null) {\n            handle.cancel();\n        }\n        for (CompletableFuture<JSONObject> future : pendingClientResponses.values()) {\n            future.complete(buildCancelledPermissionResponse());\n        }\n    }\n\n    private List<Map<String, Object>> listSessions(JSONObject params) throws Exception {\n        String cwd = requireAbsolutePath(params == null ? null : params.getString(\"cwd\"));\n        CodingSessionManager sessionManager = createSessionManager(resolveSessionOptions(cwd, null, null));\n        List<CodingSessionDescriptor> descriptors = sessionManager.list();\n        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();\n        for (CodingSessionDescriptor descriptor : descriptors) {\n            result.add(newMap(\n                    \"sessionId\", descriptor.getSessionId(),\n                    \"title\", firstNonBlank(descriptor.getSummary(), descriptor.getSessionId()),\n                    \"createdAt\", descriptor.getCreatedAtEpochMs(),\n                    \"updatedAt\", descriptor.getUpdatedAtEpochMs()\n            ));\n        }\n        return result;\n    }\n\n    private AcpToolApprovalDecorator.PermissionDecision requestPermission(String sessionId,\n                                                                          String toolName,\n                                                                          AgentToolCall call,\n                                                                          Map<String, Object> rawInput) throws Exception {\n        String requestId = String.valueOf(outboundRequestIds.getAndIncrement());\n        CompletableFuture<JSONObject> future = new CompletableFuture<JSONObject>();\n        pendingClientResponses.put(requestIdKey(requestId), future);\n        sendRequest(requestId, METHOD_SESSION_REQUEST_PERMISSION, newMap(\n                \"sessionId\", sessionId,\n                \"toolCall\", buildPermissionToolCall(toolName, call, rawInput),\n                \"options\", buildPermissionOptions()\n        ));\n        JSONObject response = future.get();\n        JSONObject result = response == null ? null : response.getJSONObject(\"result\");\n        JSONObject outcome = result == null ? null : result.getJSONObject(\"outcome\");\n        if (outcome == null) {\n            return new AcpToolApprovalDecorator.PermissionDecision(false, null);\n        }\n        String kind = outcome.getString(\"outcome\");\n        if (\"selected\".equals(kind)) {\n            String optionId = outcome.getString(\"optionId\");\n            boolean approved = \"allow_once\".equals(optionId) || \"allow_always\".equals(optionId);\n            return new AcpToolApprovalDecorator.PermissionDecision(approved, optionId);\n        }\n        return new AcpToolApprovalDecorator.PermissionDecision(false, \"cancelled\");\n    }\n\n    private Map<String, Object> buildPermissionToolCall(String toolName,\n                                                        AgentToolCall call,\n                                                        Map<String, Object> rawInput) {\n        return newMap(\n                \"toolCallId\", call == null ? UUID.randomUUID().toString() : firstNonBlank(call.getCallId(), UUID.randomUUID().toString()),\n                \"kind\", mapToolKind(toolName),\n                \"title\", firstNonBlank(toolName, call == null ? null : call.getName(), \"tool\"),\n                \"rawInput\", rawInput\n        );\n    }\n\n    private List<Map<String, Object>> buildPermissionOptions() {\n        return Arrays.asList(\n                newMap(\"optionId\", \"allow_once\", \"name\", \"Allow once\", \"kind\", \"allow_once\"),\n                newMap(\"optionId\", \"allow_always\", \"name\", \"Always allow\", \"kind\", \"allow_always\"),\n                newMap(\"optionId\", \"reject_once\", \"name\", \"Reject once\", \"kind\", \"reject_once\"),\n                newMap(\"optionId\", \"reject_always\", \"name\", \"Always reject\", \"kind\", \"reject_always\")\n        );\n    }\n\n    private JSONObject buildCancelledPermissionResponse() {\n        JSONObject response = new JSONObject();\n        JSONObject result = new JSONObject();\n        JSONObject outcome = new JSONObject();\n        outcome.put(\"outcome\", \"cancelled\");\n        result.put(\"outcome\", outcome);\n        response.put(\"result\", result);\n        return response;\n    }\n\n    private void logMcpWarnings(CliMcpRuntimeManager runtimeManager) {\n        if (runtimeManager == null) {\n            return;\n        }\n        for (String warning : runtimeManager.buildStartupWarnings()) {\n            logError(\"Warning: \" + warning);\n        }\n    }\n\n    private CliResolvedMcpConfig resolveMcpConfig(JSONArray mcpServers) {\n        if (mcpServers == null || mcpServers.isEmpty()) {\n            return null;\n        }\n        Map<String, CliResolvedMcpServer> servers = new LinkedHashMap<String, CliResolvedMcpServer>();\n        List<String> enabled = new ArrayList<String>();\n        for (int i = 0; i < mcpServers.size(); i++) {\n            JSONObject server = mcpServers.getJSONObject(i);\n            if (server == null) {\n                continue;\n            }\n            String name = firstNonBlank(trimToNull(server.getString(\"name\")), \"mcp-\" + (i + 1));\n            CliMcpServerDefinition definition = CliMcpServerDefinition.builder()\n                    .type(normalizeTransportType(server.getString(\"type\"), server.getString(\"command\")))\n                    .url(trimToNull(server.getString(\"url\")))\n                    .command(trimToNull(server.getString(\"command\")))\n                    .args(toStringList(server.getJSONArray(\"args\")))\n                    .env(toStringMap(server.getJSONObject(\"env\")))\n                    .cwd(trimToNull(server.getString(\"cwd\")))\n                    .headers(toStringMap(server.getJSONObject(\"headers\")))\n                    .build();\n            String validationError = validateDefinition(definition);\n            servers.put(name, new CliResolvedMcpServer(\n                    name,\n                    definition.getType(),\n                    true,\n                    false,\n                    validationError == null,\n                    validationError,\n                    definition\n            ));\n            enabled.add(name);\n        }\n        return new CliResolvedMcpConfig(servers, enabled, Collections.<String>emptyList(), Collections.<String>emptyList());\n    }\n\n    private CodeCommandOptions resolveSessionOptions(String workspace, String sessionId, String resumeSessionId) {\n        String resolvedWorkspace = firstNonBlank(workspace, baseOptions == null ? null : baseOptions.getWorkspace(), Paths.get(\".\").toAbsolutePath().normalize().toString());\n        Path workspacePath = Paths.get(resolvedWorkspace).toAbsolutePath().normalize();\n        String defaultBaseStoreDir = baseOptions == null || isBlank(baseOptions.getWorkspace())\n                ? null\n                : Paths.get(baseOptions.getWorkspace()).resolve(\".ai4j\").resolve(\"sessions\").toAbsolutePath().normalize().toString();\n        String configuredStoreDir = baseOptions == null ? null : baseOptions.getSessionStoreDir();\n        String resolvedStoreDir = configuredStoreDir;\n        if (isBlank(resolvedStoreDir) || resolvedStoreDir.equals(defaultBaseStoreDir)) {\n            resolvedStoreDir = workspacePath.resolve(\".ai4j\").resolve(\"sessions\").toString();\n        }\n        if (baseOptions == null) {\n            throw new IllegalStateException(\"ACP base options are required\");\n        }\n        return baseOptions.withSessionContext(workspacePath.toString(), sessionId, resumeSessionId, resolvedStoreDir);\n    }\n\n    private CodingSessionManager createSessionManager(CodeCommandOptions options) {\n        if (options.isNoSession()) {\n            Path directory = Paths.get(options.getWorkspace()).resolve(\".ai4j\").resolve(\"memory-sessions\");\n            return new DefaultCodingSessionManager(\n                    new InMemoryCodingSessionStore(directory),\n                    new InMemorySessionEventStore()\n            );\n        }\n        Path sessionDirectory = Paths.get(options.getSessionStoreDir());\n        return new DefaultCodingSessionManager(\n                new FileCodingSessionStore(sessionDirectory),\n                new FileSessionEventStore(sessionDirectory.resolve(\"events\"))\n        );\n    }\n\n    private String flattenPrompt(JSONArray blocks) {\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < blocks.size(); i++) {\n            JSONObject block = blocks.getJSONObject(i);\n            if (block == null) {\n                continue;\n            }\n            String type = normalizeType(block.getString(\"type\"));\n            if (\"text\".equals(type)) {\n                appendBlock(builder, block.getString(\"text\"));\n                continue;\n            }\n            if (\"resource_link\".equals(type) || \"resource\".equals(type)) {\n                JSONObject resource = \"resource\".equals(type) ? block.getJSONObject(\"resource\") : block;\n                if (resource != null) {\n                    appendBlock(builder, \"[Resource] \" + firstNonBlank(resource.getString(\"name\"), resource.getString(\"title\"), resource.getString(\"uri\")));\n                    appendBlock(builder, resource.getString(\"text\"));\n                }\n            }\n        }\n        return builder.toString().trim();\n    }\n\n    private void appendBlock(StringBuilder builder, String text) {\n        if (isBlank(text)) {\n            return;\n        }\n        if (builder.length() > 0) {\n            builder.append(\"\\n\\n\");\n        }\n        builder.append(text);\n    }\n\n    private String normalizeType(String type) {\n        return type == null ? null : type.trim().toLowerCase(Locale.ROOT).replace('-', '_');\n    }\n\n    private String mapToolKind(String toolName) {\n        String normalized = toolName == null ? \"\" : toolName.trim().toLowerCase(Locale.ROOT);\n        if (\"write_file\".equals(normalized) || \"apply_patch\".equals(normalized)) {\n            return \"edit\";\n        }\n        if (\"read_file\".equals(normalized)) {\n            return \"read\";\n        }\n        return \"other\";\n    }\n\n    private String validateDefinition(CliMcpServerDefinition definition) {\n        if (definition == null) {\n            return \"missing MCP server definition\";\n        }\n        String type = trimToNull(definition.getType());\n        if (type == null) {\n            return \"missing MCP transport type\";\n        }\n        if (\"stdio\".equals(type)) {\n            return isBlank(definition.getCommand()) ? \"stdio transport requires command\" : null;\n        }\n        if (\"sse\".equals(type) || \"streamable_http\".equals(type)) {\n            return isBlank(definition.getUrl()) ? type + \" transport requires url\" : null;\n        }\n        return \"unsupported MCP transport: \" + type;\n    }\n\n    private String normalizeTransportType(String type, String command) {\n        String normalized = trimToNull(type);\n        if (normalized == null) {\n            return isBlank(command) ? null : \"stdio\";\n        }\n        String lowerCase = normalized.toLowerCase(Locale.ROOT);\n        return \"http\".equals(lowerCase) ? \"streamable_http\" : lowerCase;\n    }\n\n    private List<String> toStringList(JSONArray values) {\n        if (values == null || values.isEmpty()) {\n            return null;\n        }\n        List<String> items = new ArrayList<String>();\n        for (int i = 0; i < values.size(); i++) {\n            String value = trimToNull(values.getString(i));\n            if (value != null) {\n                items.add(value);\n            }\n        }\n        return items.isEmpty() ? null : items;\n    }\n\n    private Map<String, String> toStringMap(JSONObject object) {\n        if (object == null || object.isEmpty()) {\n            return null;\n        }\n        Map<String, String> map = new LinkedHashMap<String, String>();\n        for (Map.Entry<String, Object> entry : object.entrySet()) {\n            String key = trimToNull(entry.getKey());\n            if (key != null) {\n                map.put(key, entry.getValue() == null ? null : String.valueOf(entry.getValue()));\n            }\n        }\n        return map.isEmpty() ? null : map;\n    }\n\n    private Map<String, Object> newMap(Object... values) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        if (values == null) {\n            return map;\n        }\n        for (int i = 0; i + 1 < values.length; i += 2) {\n            Object key = values[i];\n            if (key != null) {\n                map.put(String.valueOf(key), values[i + 1]);\n            }\n        }\n        return map;\n    }\n\n    private String requestIdKey(Object id) {\n        return id == null ? \"null\" : String.valueOf(id);\n    }\n\n    private String requiredString(JSONObject params, String key) {\n        String value = params == null ? null : trimToNull(params.getString(key));\n        if (value == null) {\n            throw new IllegalArgumentException(\"Missing required field: \" + key);\n        }\n        return value;\n    }\n\n    private JSONArray requiredArray(JSONObject params, String key) {\n        JSONArray value = params == null ? null : params.getJSONArray(key);\n        if (value == null) {\n            throw new IllegalArgumentException(\"Missing required field: \" + key);\n        }\n        return value;\n    }\n\n    private String requireAbsolutePath(String path) {\n        String value = trimToNull(path);\n        if (value == null) {\n            throw new IllegalArgumentException(\"cwd is required\");\n        }\n        Path resolved = Paths.get(value);\n        if (!resolved.isAbsolute()) {\n            throw new IllegalArgumentException(\"cwd must be an absolute path\");\n        }\n        return resolved.normalize().toString();\n    }\n\n    private String trimToNull(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private String clip(String value, int maxChars) {\n        if (value == null) {\n            return null;\n        }\n        String normalized = value.replace('\\r', ' ').replace('\\n', ' ').trim();\n        if (normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, Math.max(0, maxChars));\n    }\n\n    private String clipPreserveNewlines(String value, int maxChars) {\n        if (value == null) {\n            return null;\n        }\n        if (value.length() <= maxChars) {\n            return value;\n        }\n        return value.substring(0, Math.max(0, maxChars));\n    }\n\n    private String safeMessage(Throwable throwable) {\n        String message = null;\n        Throwable current = throwable;\n        Throwable last = throwable;\n        while (current != null) {\n            if (!isBlank(current.getMessage())) {\n                message = current.getMessage().trim();\n            }\n            last = current;\n            current = current.getCause();\n        }\n        return isBlank(message)\n                ? (last == null ? \"unknown ACP error\" : last.getClass().getSimpleName())\n                : message;\n    }\n\n    private Map<String, Object> textContent(String text) {\n        return newMap(\n                \"type\", \"text\",\n                \"text\", text == null ? \"\" : text\n        );\n    }\n\n    private List<Map<String, Object>> toolCallTextContent(String text) {\n        if (isBlank(text)) {\n            return null;\n        }\n        return Collections.singletonList(newMap(\n                \"type\", \"content\",\n                \"content\", textContent(text)\n        ));\n    }\n\n    private Object parseJsonOrText(String value) {\n        if (isBlank(value)) {\n            return Collections.emptyMap();\n        }\n        try {\n            return JSON.parse(value);\n        } catch (Exception ex) {\n            return newMap(\"text\", value);\n        }\n    }\n\n    private Map<String, Object> toStructuredSessionUpdate(SessionEvent event) {\n        if (event == null || event.getType() == null) {\n            return null;\n        }\n        if (event.getType() == SessionEventType.TASK_CREATED) {\n            return newMap(\n                    \"sessionUpdate\", \"tool_call\",\n                    \"toolCallId\", payloadText(event, \"callId\", payloadText(event, \"taskId\", null)),\n                    \"title\", payloadText(event, \"title\", event.getSummary()),\n                    \"kind\", \"other\",\n                    \"status\", mapTaskStatusValue(payloadText(event, \"status\", null)),\n                    \"rawInput\", buildTaskRawInput(event)\n            );\n        }\n        if (event.getType() == SessionEventType.TASK_UPDATED) {\n            String text = buildTaskUpdateText(event);\n            return newMap(\n                    \"sessionUpdate\", \"tool_call_update\",\n                    \"toolCallId\", payloadText(event, \"callId\", payloadText(event, \"taskId\", null)),\n                    \"status\", mapTaskStatusValue(payloadText(event, \"status\", null)),\n                    \"content\", toolCallTextContent(text),\n                    \"rawOutput\", buildTaskRawOutput(event)\n            );\n        }\n        if (event.getType() == SessionEventType.TEAM_MESSAGE) {\n            return toTeamMessageAcpUpdate(event);\n        }\n        if (event.getType() == SessionEventType.AUTO_CONTINUE\n                || event.getType() == SessionEventType.AUTO_STOP\n                || event.getType() == SessionEventType.BLOCKED) {\n            return newMap(\n                    \"sessionUpdate\", \"agent_message_chunk\",\n                    \"content\", textContent(firstNonBlank(event.getSummary(), event.getType().name().toLowerCase(Locale.ROOT).replace('_', ' ')))\n            );\n        }\n        return null;\n    }\n\n    private String payloadText(SessionEvent event, String key, String defaultValue) {\n        if (event == null || event.getPayload() == null || key == null) {\n            return defaultValue;\n        }\n        Object value = event.getPayload().get(key);\n        return value == null ? defaultValue : String.valueOf(value);\n    }\n\n    private Object payloadValue(SessionEvent event, String key) {\n        if (event == null || event.getPayload() == null || key == null) {\n            return null;\n        }\n        return event.getPayload().get(key);\n    }\n\n    private Map<String, Object> buildTaskRawInput(SessionEvent event) {\n        return newMap(\n                \"taskId\", payloadValue(event, \"taskId\"),\n                \"definition\", payloadValue(event, \"tool\"),\n                \"subagent\", payloadValue(event, \"subagent\"),\n                \"memberId\", payloadValue(event, \"memberId\"),\n                \"memberName\", payloadValue(event, \"memberName\"),\n                \"task\", payloadValue(event, \"task\"),\n                \"context\", payloadValue(event, \"context\"),\n                \"dependsOn\", payloadValue(event, \"dependsOn\"),\n                \"childSessionId\", payloadValue(event, \"childSessionId\"),\n                \"background\", payloadValue(event, \"background\"),\n                \"sessionMode\", payloadValue(event, \"sessionMode\"),\n                \"depth\", payloadValue(event, \"depth\"),\n                \"phase\", payloadValue(event, \"phase\"),\n                \"percent\", payloadValue(event, \"percent\")\n        );\n    }\n\n    private Map<String, Object> buildTaskRawOutput(SessionEvent event) {\n        String text = extractTaskPrimaryText(event);\n        return newMap(\n                \"text\", text,\n                \"taskId\", payloadValue(event, \"taskId\"),\n                \"status\", payloadValue(event, \"status\"),\n                \"phase\", payloadValue(event, \"phase\"),\n                \"percent\", payloadValue(event, \"percent\"),\n                \"detail\", payloadValue(event, \"detail\"),\n                \"memberId\", payloadValue(event, \"memberId\"),\n                \"memberName\", payloadValue(event, \"memberName\"),\n                \"heartbeatCount\", payloadValue(event, \"heartbeatCount\"),\n                \"updatedAtEpochMs\", payloadValue(event, \"updatedAtEpochMs\"),\n                \"lastHeartbeatTime\", payloadValue(event, \"lastHeartbeatTime\"),\n                \"durationMillis\", payloadValue(event, \"durationMillis\"),\n                \"output\", payloadValue(event, \"output\"),\n                \"error\", payloadValue(event, \"error\")\n        );\n    }\n\n    private String buildTaskUpdateText(SessionEvent event) {\n        String text = extractTaskPrimaryText(event);\n        if (!isTeamTaskEvent(event)) {\n            return text;\n        }\n        String member = firstNonBlank(payloadText(event, \"memberName\", null), payloadText(event, \"memberId\", null));\n        String phase = payloadText(event, \"phase\", null);\n        String status = payloadText(event, \"status\", null);\n        String percent = payloadText(event, \"percent\", null);\n        StringBuilder prefix = new StringBuilder();\n        if (!isBlank(member)) {\n            prefix.append('[').append(member).append(\"] \");\n        }\n        if (!isBlank(phase)) {\n            prefix.append(phase);\n        } else if (!isBlank(status)) {\n            prefix.append(status);\n        }\n        if (!isBlank(percent)) {\n            if (prefix.length() > 0) {\n                prefix.append(' ');\n            }\n            prefix.append(percent).append('%');\n        }\n        if (prefix.length() == 0) {\n            return text;\n        }\n        if (isBlank(text)) {\n            return prefix.toString();\n        }\n        return prefix.append(\" - \").append(text).toString();\n    }\n\n    private String extractTaskPrimaryText(SessionEvent event) {\n        return firstNonBlank(\n                payloadText(event, \"error\", null),\n                payloadText(event, \"output\", null),\n                payloadText(event, \"detail\", event.getSummary())\n        );\n    }\n\n    private boolean isTeamTaskEvent(SessionEvent event) {\n        String callId = payloadText(event, \"callId\", null);\n        String taskId = payloadText(event, \"taskId\", null);\n        String title = payloadText(event, \"title\", null);\n        if (!isBlank(callId) && callId.startsWith(\"team-task:\")) {\n            return true;\n        }\n        if (!isBlank(taskId) && taskId.startsWith(\"team-task:\")) {\n            return true;\n        }\n        if (!isBlank(title) && title.startsWith(\"Team task\")) {\n            return true;\n        }\n        return !isBlank(payloadText(event, \"memberId\", null))\n                || !isBlank(payloadText(event, \"memberName\", null))\n                || !isBlank(payloadText(event, \"heartbeatCount\", null));\n    }\n\n    private String mapTaskStatusValue(String status) {\n        String normalized = trimToNull(status);\n        if (normalized == null) {\n            return \"pending\";\n        }\n        String lower = normalized.toLowerCase(Locale.ROOT);\n        if (\"running\".equals(lower) || \"in_progress\".equals(lower) || \"in-progress\".equals(lower) || \"started\".equals(lower)) {\n            return \"in_progress\";\n        }\n        if (\"completed\".equals(lower) || \"fallback\".equals(lower)) {\n            return \"completed\";\n        }\n        if (\"failed\".equals(lower) || \"cancelled\".equals(lower) || \"canceled\".equals(lower) || \"error\".equals(lower)) {\n            return \"failed\";\n        }\n        return \"pending\";\n    }\n\n    private Map<String, Object> toTeamMessageAcpUpdate(SessionEvent event) {\n        if (event == null) {\n            return null;\n        }\n        String taskId = payloadText(event, \"taskId\", null);\n        String toolCallId = firstNonBlank(payloadText(event, \"callId\", null),\n                isBlank(taskId) ? null : \"team-task:\" + taskId);\n        String messageType = trimToNull(payloadText(event, \"messageType\", null));\n        String fromMemberId = trimToNull(payloadText(event, \"fromMemberId\", null));\n        String toMemberId = trimToNull(payloadText(event, \"toMemberId\", null));\n        String text = firstNonBlank(\n                payloadText(event, \"content\", null),\n                payloadText(event, \"detail\", event.getSummary())\n        );\n        String rendered = formatTeamMessageText(messageType, fromMemberId, toMemberId, text);\n        if (isBlank(toolCallId)) {\n            return newMap(\n                    \"sessionUpdate\", \"agent_message_chunk\",\n                    \"content\", textContent(rendered)\n            );\n        }\n        return newMap(\n                \"sessionUpdate\", \"tool_call_update\",\n                \"toolCallId\", toolCallId,\n                \"content\", toolCallTextContent(rendered),\n                \"rawOutput\", newMap(\n                        \"type\", \"team_message\",\n                        \"messageId\", payloadText(event, \"messageId\", null),\n                        \"taskId\", taskId,\n                        \"fromMemberId\", fromMemberId,\n                        \"toMemberId\", toMemberId,\n                        \"messageType\", messageType,\n                        \"text\", text\n                )\n        );\n    }\n\n    private String formatTeamMessageText(String messageType, String fromMemberId, String toMemberId, String text) {\n        String route = formatTeamMessageRoute(fromMemberId, toMemberId);\n        String prefix;\n        if (!isBlank(route) && !isBlank(messageType)) {\n            prefix = \"[\" + messageType + \"] \" + route;\n        } else if (!isBlank(messageType)) {\n            prefix = \"[\" + messageType + \"]\";\n        } else {\n            prefix = route;\n        }\n        if (isBlank(prefix)) {\n            return text;\n        }\n        if (isBlank(text)) {\n            return prefix;\n        }\n        return prefix + \"\\n\" + text;\n    }\n\n    private String formatTeamMessageRoute(String fromMemberId, String toMemberId) {\n        if (isBlank(fromMemberId) && isBlank(toMemberId)) {\n            return null;\n        }\n        return firstNonBlank(fromMemberId, \"?\") + \" -> \" + firstNonBlank(toMemberId, \"?\");\n    }\n\n    private void awaitPendingWork() {\n        requestExecutor.shutdown();\n        try {\n            requestExecutor.awaitTermination(5, TimeUnit.SECONDS);\n        } catch (InterruptedException ex) {\n            Thread.currentThread().interrupt();\n        }\n        promptExecutor.shutdown();\n        try {\n            promptExecutor.awaitTermination(5, TimeUnit.SECONDS);\n        } catch (InterruptedException ex) {\n            Thread.currentThread().interrupt();\n        }\n    }\n\n    @Override\n    public void close() {\n        for (SessionHandle handle : new LinkedHashSet<SessionHandle>(sessions.values())) {\n            if (handle != null) {\n                handle.close();\n            }\n        }\n        sessions.clear();\n        for (CompletableFuture<JSONObject> future : pendingClientResponses.values()) {\n            future.complete(buildCancelledPermissionResponse());\n        }\n        pendingClientResponses.clear();\n        requestExecutor.shutdownNow();\n        promptExecutor.shutdownNow();\n    }\n\n    private final class SessionHandle implements Closeable {\n\n        private volatile CodeCommandOptions options;\n        private volatile CodeCommandOptions runtimeOptions;\n        private volatile CodeCommandOptions pendingRuntimeOptions;\n        private final CodingSessionManager sessionManager;\n        private volatile CodingCliAgentFactory factory;\n        private final AcpToolApprovalDecorator.PermissionGateway permissionGateway;\n        private volatile CodingCliAgentFactory.PreparedCodingAgent prepared;\n        private volatile ManagedCodingSession session;\n        private volatile HeadlessCodingSessionRuntime runtime;\n        private volatile CodingRuntime codingRuntime;\n        private volatile CodingTaskSessionEventBridge taskEventBridge;\n        private final CliResolvedMcpConfig resolvedMcpConfig;\n        private final CliProviderConfigManager providerConfigManager;\n        private volatile HeadlessCodingSessionRuntime.PromptControl activePrompt;\n\n        private SessionHandle(CodeCommandOptions options,\n                              CodingSessionManager sessionManager,\n                              CodingCliAgentFactory factory,\n                              AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                              CodingCliAgentFactory.PreparedCodingAgent prepared,\n                              ManagedCodingSession session,\n                              CliResolvedMcpConfig resolvedMcpConfig) {\n            this.options = options;\n            this.runtimeOptions = options;\n            this.sessionManager = sessionManager;\n            this.factory = factory;\n            this.permissionGateway = permissionGateway;\n            this.prepared = prepared;\n            this.session = session;\n            this.resolvedMcpConfig = resolvedMcpConfig;\n            this.providerConfigManager = new CliProviderConfigManager(Paths.get(firstNonBlank(\n                    options == null ? null : options.getWorkspace(),\n                    session == null ? null : session.getWorkspace(),\n                    \".\"\n            )));\n            this.runtime = new HeadlessCodingSessionRuntime(options, sessionManager);\n            this.codingRuntime = prepared == null || prepared.getAgent() == null ? null : prepared.getAgent().getRuntime();\n            this.taskEventBridge = registerTaskEventBridge();\n        }\n\n        private ManagedCodingSession getSession() {\n            return session;\n        }\n\n        private Map<String, Object> buildSessionOpenResult() {\n            ManagedCodingSession currentSession = session;\n            return newMap(\n                    \"sessionId\", currentSession == null ? null : currentSession.getSessionId(),\n                    \"configOptions\", buildConfigOptions(),\n                    \"modes\", buildModes()\n            );\n        }\n\n        private synchronized Map<String, Object> buildModes() {\n            List<Map<String, Object>> items = new ArrayList<Map<String, Object>>();\n            for (ApprovalMode mode : ApprovalMode.values()) {\n                if (mode == null) {\n                    continue;\n                }\n                items.add(newMap(\n                        \"id\", mode.getValue(),\n                        \"name\", approvalModeName(mode),\n                        \"description\", approvalModeDescription(mode)\n                ));\n            }\n            return newMap(\n                    \"currentModeId\", currentApprovalMode().getValue(),\n                    \"availableModes\", items\n            );\n        }\n\n        private synchronized List<Map<String, Object>> buildConfigOptions() {\n            List<Map<String, Object>> configOptions = new ArrayList<Map<String, Object>>();\n            configOptions.add(newMap(\n                    \"id\", \"mode\",\n                    \"name\", \"Permission Mode\",\n                    \"description\", \"Controls how tool approval is handled in this ACP session.\",\n                    \"category\", \"mode\",\n                    \"currentValue\", currentApprovalMode().getValue(),\n                    \"type\", \"select\",\n                    \"options\", buildModeOptionValues()\n            ));\n            configOptions.add(newMap(\n                    \"id\", \"model\",\n                    \"name\", \"Model\",\n                    \"description\", \"Selects the effective model for subsequent turns in this ACP session.\",\n                    \"category\", \"model\",\n                    \"currentValue\", options == null ? null : options.getModel(),\n                    \"type\", \"select\",\n                    \"options\", buildModelOptionValues()\n            ));\n            return configOptions;\n        }\n\n        private synchronized List<Map<String, Object>> setConfigOption(String configId, String value) throws Exception {\n            String normalizedId = trimToNull(configId);\n            if (\"mode\".equalsIgnoreCase(normalizedId)) {\n                applyModeChange(value, true);\n                return buildConfigOptions();\n            }\n            if (\"model\".equalsIgnoreCase(normalizedId)) {\n                applyModelChange(value, true, true);\n                return buildConfigOptions();\n            }\n            throw new IllegalArgumentException(\"Unknown session config option: \" + configId);\n        }\n\n        private synchronized void setMode(String modeId) throws Exception {\n            applyModeChange(modeId, true);\n        }\n\n        private void startPrompt(Runnable runnable) {\n            promptExecutor.submit(runnable);\n        }\n\n        private synchronized HeadlessCodingSessionRuntime.PromptControl beginPrompt() {\n            if (activePrompt != null && !activePrompt.isCancelled()) {\n                throw new IllegalStateException(\"session already has an active prompt\");\n            }\n            activePrompt = new HeadlessCodingSessionRuntime.PromptControl();\n            return activePrompt;\n        }\n\n        private HeadlessCodingSessionRuntime.PromptResult runPrompt(String input,\n                                                                    HeadlessCodingSessionRuntime.PromptControl promptControl) throws Exception {\n            try {\n                ManagedCodingSession currentSession = session;\n                HeadlessCodingSessionRuntime currentRuntime = runtime;\n                return currentRuntime.runPrompt(currentSession, input, promptControl, new AcpTurnObserver(currentSession.getSessionId()));\n            } finally {\n                completePrompt(promptControl);\n            }\n        }\n\n        private HeadlessCodingSessionRuntime.PromptResult runSlashCommand(String input,\n                                                                          HeadlessCodingSessionRuntime.PromptControl promptControl) throws Exception {\n            try {\n                ManagedCodingSession currentSession = session;\n                String turnId = UUID.randomUUID().toString();\n                appendSimpleEvent(SessionEventType.USER_MESSAGE, turnId, clip(input, 200), newMap(\n                        \"input\", clipPreserveNewlines(input, options != null && options.isVerbose() ? 4000 : 1200)\n                ));\n                sendSessionUpdate(currentSession.getSessionId(), newMap(\n                        \"sessionUpdate\", \"user_message_chunk\",\n                        \"content\", textContent(input)\n                ));\n\n                AcpSlashCommandSupport.ExecutionResult result = AcpSlashCommandSupport.execute(buildSlashCommandContext(), input);\n                String output = result == null ? null : result.getOutput();\n                appendSimpleEvent(SessionEventType.ASSISTANT_MESSAGE, turnId, clip(output, 200), newMap(\n                        \"kind\", \"command\",\n                        \"output\", clipPreserveNewlines(output, options != null && options.isVerbose() ? 4000 : 1200)\n                ));\n                if (!isBlank(output)) {\n                    sendSessionUpdate(session.getSessionId(), newMap(\n                            \"sessionUpdate\", \"agent_message_chunk\",\n                            \"content\", textContent(output)\n                    ));\n                }\n                persistSessionIfConfigured();\n                return HeadlessCodingSessionRuntime.PromptResult.completed(turnId, output, null);\n            } finally {\n                completePrompt(promptControl);\n            }\n        }\n\n        private void cancel() {\n            HeadlessCodingSessionRuntime.PromptControl control = activePrompt;\n            if (control != null) {\n                control.cancel();\n            }\n        }\n\n        private void replayHistory() throws IOException {\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), null, null);\n            if (events == null) {\n                return;\n            }\n            for (SessionEvent event : events) {\n                Map<String, Object> update = toHistoryUpdate(event);\n                if (update != null) {\n                    sendSessionUpdate(session.getSessionId(), update);\n                }\n            }\n        }\n\n        private void sendAvailableCommandsUpdate() {\n            AcpJsonRpcServer.this.sendAvailableCommandsUpdate(session == null ? null : session.getSessionId());\n        }\n\n        private void appendSimpleEvent(SessionEventType type, String turnId, String summary, Map<String, Object> payload) {\n            if (sessionManager == null || session == null || type == null) {\n                return;\n            }\n            try {\n                sessionManager.appendEvent(session.getSessionId(), SessionEvent.builder()\n                        .sessionId(session.getSessionId())\n                        .type(type)\n                        .turnId(turnId)\n                        .summary(summary)\n                        .payload(payload)\n                        .build());\n            } catch (IOException ignored) {\n            }\n        }\n\n        private void persistSessionIfConfigured() {\n            if (sessionManager == null || session == null || options == null || !options.isAutoSaveSession()) {\n                return;\n            }\n            try {\n                sessionManager.save(session);\n            } catch (IOException ignored) {\n            }\n        }\n\n        private AcpSlashCommandSupport.Context buildSlashCommandContext() {\n            return new AcpSlashCommandSupport.Context(\n                    session,\n                    sessionManager,\n                    options,\n                    prepared,\n                    prepared == null ? null : prepared.getMcpRuntimeManager(),\n                    new AcpSlashCommandSupport.RuntimeCommandHandler() {\n                        @Override\n                        public String executeProviders() {\n                            return executeProvidersCommand();\n                        }\n\n                        @Override\n                        public String executeProvider(String argument) throws Exception {\n                            return executeProviderCommand(argument);\n                        }\n\n                        @Override\n                        public String executeModel(String argument) throws Exception {\n                            return executeModelCommand(argument);\n                        }\n\n                        @Override\n                        public String executeExperimental(String argument) throws Exception {\n                            return executeExperimentalCommand(argument);\n                        }\n                    }\n            );\n        }\n\n        private synchronized String executeProvidersCommand() {\n            return renderProvidersOutput();\n        }\n\n        private synchronized String executeProviderCommand(String argument) throws Exception {\n            if (isBlank(argument)) {\n                return renderCurrentProviderOutput();\n            }\n            String trimmed = argument.trim();\n            String[] parts = trimmed.split(\"\\\\s+\", 2);\n            String action = parts[0].toLowerCase(Locale.ROOT);\n            String value = parts.length > 1 ? trimToNull(parts[1]) : null;\n            if (\"use\".equals(action)) {\n                return switchToProviderProfile(value);\n            }\n            if (\"save\".equals(action)) {\n                return saveCurrentProviderProfile(value);\n            }\n            if (\"add\".equals(action)) {\n                return addProviderProfile(value);\n            }\n            if (\"edit\".equals(action)) {\n                return editProviderProfile(value);\n            }\n            if (\"default\".equals(action)) {\n                return setDefaultProviderProfile(value);\n            }\n            if (\"remove\".equals(action)) {\n                return removeProviderProfile(value);\n            }\n            return \"Unknown /provider action: \" + action\n                    + \". Use /provider, /providers, /provider use <name>, /provider save <name>, \"\n                    + \"/provider add <name> ..., /provider edit <name> ..., /provider default <name|clear>, \"\n                    + \"or /provider remove <name>.\";\n        }\n\n        private synchronized String executeModelCommand(String argument) throws Exception {\n            if (isBlank(argument)) {\n                return renderModelOutput();\n            }\n            applyModelChange(argument, true, false);\n            persistSessionIfConfigured();\n            return renderModelOutput();\n        }\n\n        private synchronized String executeExperimentalCommand(String argument) throws Exception {\n            if (isBlank(argument)) {\n                return renderExperimentalOutput();\n            }\n            List<String> tokens = Arrays.asList(argument.trim().split(\"\\\\s+\"));\n            if (tokens.size() != 2) {\n                return \"Usage: /experimental <subagent|agent-teams> <on|off>\";\n            }\n            String feature = normalizeExperimentalFeature(tokens.get(0));\n            Boolean enabled = parseExperimentalToggle(tokens.get(1));\n            if (feature == null || enabled == null) {\n                return \"Usage: /experimental <subagent|agent-teams> <on|off>\";\n            }\n            CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n            if (\"subagent\".equals(feature)) {\n                workspaceConfig.setExperimentalSubagentsEnabled(enabled);\n            } else {\n                workspaceConfig.setExperimentalAgentTeamsEnabled(enabled);\n            }\n            providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n            rebindSession(options);\n            persistSessionIfConfigured();\n            return renderExperimentalOutput();\n        }\n\n        private void applyModeChange(String rawModeId, boolean emitUpdates) throws Exception {\n            ApprovalMode nextMode = ApprovalMode.parse(rawModeId);\n            CodeCommandOptions currentOptions = options;\n            if (currentOptions != null && nextMode == currentOptions.getApprovalMode()) {\n                if (emitUpdates) {\n                    emitModeUpdate();\n                    emitConfigOptionUpdate();\n                }\n                return;\n            }\n            applySessionOptionsChange(currentOptions.withApprovalMode(nextMode), emitUpdates, emitUpdates);\n        }\n\n        private void applyModelChange(String rawValue,\n                                      boolean emitUpdates,\n                                      boolean requireListedOption) throws Exception {\n            String normalized = trimToNull(rawValue);\n            if (normalized == null) {\n                throw new IllegalArgumentException(\"Model value is required\");\n            }\n            CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n            if (\"reset\".equalsIgnoreCase(normalized)) {\n                workspaceConfig.setModelOverride(null);\n                providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n                applySessionOptionsChange(resolveCurrentProviderOptions(null), false, emitUpdates);\n            } else {\n                if (requireListedOption && !isSupportedModelValue(normalized)) {\n                    throw new IllegalArgumentException(\"Unsupported model for current ACP session: \" + normalized);\n                }\n                workspaceConfig.setModelOverride(normalized);\n                providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n                CodeCommandOptions currentOptions = options;\n                CliProtocol currentProtocol = currentProtocol();\n                applySessionOptionsChange(currentOptions.withRuntime(\n                        currentOptions == null ? null : currentOptions.getProvider(),\n                        currentProtocol,\n                        normalized,\n                        currentOptions == null ? null : currentOptions.getApiKey(),\n                        currentOptions == null ? null : currentOptions.getBaseUrl()\n                ), false, emitUpdates);\n            }\n        }\n\n        private synchronized String switchToProviderProfile(String profileName) throws Exception {\n            if (isBlank(profileName)) {\n                return \"Usage: /provider use <profile-name>\";\n            }\n            String normalizedName = profileName.trim();\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            CliProviderProfile profile = providersConfig.getProfiles().get(normalizedName);\n            if (profile == null) {\n                return \"Unknown provider profile: \" + normalizedName;\n            }\n            CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n            workspaceConfig.setActiveProfile(normalizedName);\n            providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n            rebindSession(resolveProfileRuntimeOptions(profile, workspaceConfig));\n            emitConfigOptionUpdate();\n            persistSessionIfConfigured();\n            return renderCurrentProviderOutput();\n        }\n\n        private synchronized String saveCurrentProviderProfile(String profileName) throws IOException {\n            if (isBlank(profileName)) {\n                return \"Usage: /provider save <profile-name>\";\n            }\n            String normalizedName = profileName.trim();\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            CliProtocol currentProtocol = currentProtocol();\n            providersConfig.getProfiles().put(normalizedName, CliProviderProfile.builder()\n                    .provider(options == null || options.getProvider() == null ? null : options.getProvider().getPlatform())\n                    .protocol(currentProtocol == null ? null : currentProtocol.getValue())\n                    .model(options == null ? null : options.getModel())\n                    .baseUrl(options == null ? null : options.getBaseUrl())\n                    .apiKey(options == null ? null : options.getApiKey())\n                    .build());\n            if (isBlank(providersConfig.getDefaultProfile())) {\n                providersConfig.setDefaultProfile(normalizedName);\n            }\n            providerConfigManager.saveProvidersConfig(providersConfig);\n            return \"provider saved: \" + normalizedName + \" -> \" + providerConfigManager.globalProvidersPath();\n        }\n\n        private synchronized String addProviderProfile(String rawArguments) throws IOException {\n            String usage = \"Usage: /provider add <profile-name> --provider <name> [--protocol <chat|responses>] [--model <name>] [--base-url <url>] [--api-key <key>]\";\n            ProviderProfileMutation mutation = parseProviderProfileMutation(rawArguments);\n            if (mutation == null) {\n                return usage;\n            }\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            if (providersConfig.getProfiles().containsKey(mutation.profileName)) {\n                return \"Provider profile already exists: \" + mutation.profileName + \". Use /provider edit \" + mutation.profileName + \" ...\";\n            }\n            if (isBlank(mutation.provider)) {\n                return usage;\n            }\n            PlatformType provider = parseProviderType(mutation.provider);\n            if (provider == null) {\n                return \"Unsupported provider: \" + mutation.provider;\n            }\n            String baseUrlValue = mutation.clearBaseUrl ? null : mutation.baseUrl;\n            CliProtocol protocolValue = parseProviderProtocol(firstNonBlank(\n                    mutation.protocol,\n                    CliProtocol.defaultProtocol(provider, baseUrlValue).getValue()\n            ));\n            if (protocolValue == null) {\n                return \"Unsupported protocol: \" + mutation.protocol;\n            }\n            if (!isSupportedProviderProtocol(provider, protocolValue)) {\n                return \"Provider \" + provider.getPlatform() + \" does not support responses protocol in ai4j-cli yet\";\n            }\n            providersConfig.getProfiles().put(mutation.profileName, CliProviderProfile.builder()\n                    .provider(provider.getPlatform())\n                    .protocol(protocolValue.getValue())\n                    .model(mutation.clearModel ? null : mutation.model)\n                    .baseUrl(mutation.clearBaseUrl ? null : mutation.baseUrl)\n                    .apiKey(mutation.clearApiKey ? null : mutation.apiKey)\n                    .build());\n            if (isBlank(providersConfig.getDefaultProfile())) {\n                providersConfig.setDefaultProfile(mutation.profileName);\n            }\n            providerConfigManager.saveProvidersConfig(providersConfig);\n            return \"provider added: \" + mutation.profileName + \" -> \" + providerConfigManager.globalProvidersPath();\n        }\n\n        private synchronized String editProviderProfile(String rawArguments) throws Exception {\n            String usage = \"Usage: /provider edit <profile-name> [--provider <name>] [--protocol <chat|responses>] [--model <name>|--clear-model] [--base-url <url>|--clear-base-url] [--api-key <key>|--clear-api-key]\";\n            ProviderProfileMutation mutation = parseProviderProfileMutation(rawArguments);\n            if (mutation == null || !mutation.hasAnyFieldChanges()) {\n                return usage;\n            }\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            CliProviderProfile existing = providersConfig.getProfiles().get(mutation.profileName);\n            if (existing == null) {\n                return \"Unknown provider profile: \" + mutation.profileName;\n            }\n            String effectiveProfileBeforeEdit = resolveEffectiveProfileName();\n            PlatformType provider = parseProviderType(firstNonBlank(mutation.provider, existing.getProvider()));\n            if (provider == null) {\n                return \"Unsupported provider: \" + firstNonBlank(mutation.provider, existing.getProvider());\n            }\n            String baseUrlValue = mutation.clearBaseUrl ? null : firstNonBlank(mutation.baseUrl, existing.getBaseUrl());\n            String protocolRaw = mutation.protocol;\n            if (isBlank(protocolRaw)) {\n                protocolRaw = firstNonBlank(\n                        normalizeStoredProtocol(existing.getProtocol(), provider, baseUrlValue),\n                        CliProtocol.defaultProtocol(provider, baseUrlValue).getValue()\n                );\n            }\n            CliProtocol protocolValue = parseProviderProtocol(protocolRaw);\n            if (protocolValue == null) {\n                return \"Unsupported protocol: \" + protocolRaw;\n            }\n            if (!isSupportedProviderProtocol(provider, protocolValue)) {\n                return \"Provider \" + provider.getPlatform() + \" does not support responses protocol in ai4j-cli yet\";\n            }\n            existing.setProvider(provider.getPlatform());\n            existing.setProtocol(protocolValue.getValue());\n            if (mutation.clearModel) {\n                existing.setModel(null);\n            } else if (mutation.model != null) {\n                existing.setModel(mutation.model);\n            }\n            if (mutation.clearBaseUrl) {\n                existing.setBaseUrl(null);\n            } else if (mutation.baseUrl != null) {\n                existing.setBaseUrl(mutation.baseUrl);\n            }\n            if (mutation.clearApiKey) {\n                existing.setApiKey(null);\n            } else if (mutation.apiKey != null) {\n                existing.setApiKey(mutation.apiKey);\n            }\n            providersConfig.getProfiles().put(mutation.profileName, existing);\n            providerConfigManager.saveProvidersConfig(providersConfig);\n            if (mutation.profileName.equals(effectiveProfileBeforeEdit)) {\n                CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n                rebindSession(resolveProfileRuntimeOptions(existing, workspaceConfig));\n                emitConfigOptionUpdate();\n                persistSessionIfConfigured();\n                return \"provider updated: \" + mutation.profileName + \" -> \" + providerConfigManager.globalProvidersPath()\n                        + \"\\n\" + renderCurrentProviderOutput();\n            }\n            return \"provider updated: \" + mutation.profileName + \" -> \" + providerConfigManager.globalProvidersPath();\n        }\n\n        private synchronized String removeProviderProfile(String profileName) throws IOException {\n            if (isBlank(profileName)) {\n                return \"Usage: /provider remove <profile-name>\";\n            }\n            String normalizedName = profileName.trim();\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            if (providersConfig.getProfiles().remove(normalizedName) == null) {\n                return \"Unknown provider profile: \" + normalizedName;\n            }\n            if (normalizedName.equals(providersConfig.getDefaultProfile())) {\n                providersConfig.setDefaultProfile(null);\n            }\n            providerConfigManager.saveProvidersConfig(providersConfig);\n            CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n            if (normalizedName.equals(workspaceConfig.getActiveProfile())) {\n                workspaceConfig.setActiveProfile(null);\n                providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n            }\n            return \"provider removed: \" + normalizedName;\n        }\n\n        private synchronized String setDefaultProviderProfile(String profileName) throws IOException {\n            if (isBlank(profileName)) {\n                return \"Usage: /provider default <profile-name|clear>\";\n            }\n            String normalizedName = profileName.trim();\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            if (\"clear\".equalsIgnoreCase(normalizedName)) {\n                providersConfig.setDefaultProfile(null);\n                providerConfigManager.saveProvidersConfig(providersConfig);\n                return \"provider default cleared\";\n            }\n            if (!providersConfig.getProfiles().containsKey(normalizedName)) {\n                return \"Unknown provider profile: \" + normalizedName;\n            }\n            providersConfig.setDefaultProfile(normalizedName);\n            providerConfigManager.saveProvidersConfig(providersConfig);\n            return \"provider default: \" + normalizedName;\n        }\n\n        private String renderProvidersOutput() {\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n            if (providersConfig.getProfiles().isEmpty()) {\n                return \"providers: (none)\";\n            }\n            StringBuilder builder = new StringBuilder();\n            builder.append(\"providers:\\n\");\n            List<String> names = new ArrayList<String>(providersConfig.getProfiles().keySet());\n            Collections.sort(names);\n            for (String name : names) {\n                CliProviderProfile profile = providersConfig.getProfiles().get(name);\n                builder.append(\"- \").append(name);\n                if (name.equals(workspaceConfig.getActiveProfile())) {\n                    builder.append(\" [active]\");\n                }\n                if (name.equals(providersConfig.getDefaultProfile())) {\n                    builder.append(\" [default]\");\n                }\n                builder.append(\" | provider=\").append(profile == null ? null : profile.getProvider());\n                builder.append(\", protocol=\").append(profile == null ? null : profile.getProtocol());\n                builder.append(\", model=\").append(profile == null ? null : profile.getModel());\n                if (!isBlank(profile == null ? null : profile.getBaseUrl())) {\n                    builder.append(\", baseUrl=\").append(profile.getBaseUrl());\n                }\n                builder.append('\\n');\n            }\n            return builder.toString().trim();\n        }\n\n        private String renderCurrentProviderOutput() {\n            CodeCommandOptions currentOptions = options;\n            CliProtocol currentProtocol = currentProtocol();\n            CliResolvedProviderConfig resolved = providerConfigManager.resolve(\n                    currentOptions == null || currentOptions.getProvider() == null ? null : currentOptions.getProvider().getPlatform(),\n                    currentProtocol == null ? null : currentProtocol.getValue(),\n                    null,\n                    currentOptions == null ? null : currentOptions.getApiKey(),\n                    currentOptions == null ? null : currentOptions.getBaseUrl(),\n                    Collections.<String, String>emptyMap(),\n                    new Properties()\n            );\n            StringBuilder builder = new StringBuilder();\n            builder.append(\"provider:\\n\");\n            builder.append(\"- activeProfile=\").append(firstNonBlank(resolved.getActiveProfile(), \"(none)\")).append('\\n');\n            builder.append(\"- defaultProfile=\").append(firstNonBlank(resolved.getDefaultProfile(), \"(none)\")).append('\\n');\n            builder.append(\"- effectiveProfile=\").append(firstNonBlank(resolved.getEffectiveProfile(), \"(none)\")).append('\\n');\n            builder.append(\"- provider=\").append(currentOptions == null || currentOptions.getProvider() == null ? null : currentOptions.getProvider().getPlatform())\n                    .append(\", protocol=\").append(currentProtocol == null ? null : currentProtocol.getValue())\n                    .append(\", model=\").append(currentOptions == null ? null : currentOptions.getModel()).append('\\n');\n            builder.append(\"- baseUrl=\").append(firstNonBlank(currentOptions == null ? null : currentOptions.getBaseUrl(), \"(default)\")).append('\\n');\n            builder.append(\"- apiKey=\").append(isBlank(currentOptions == null ? null : currentOptions.getApiKey()) ? \"(missing)\" : maskSecret(currentOptions.getApiKey())).append('\\n');\n            builder.append(\"- store=\").append(providerConfigManager.globalProvidersPath());\n            return builder.toString().trim();\n        }\n\n        private String renderModelOutput() {\n            CodeCommandOptions currentOptions = options;\n            CliProtocol currentProtocol = currentProtocol();\n            CliResolvedProviderConfig resolved = providerConfigManager.resolve(\n                    currentOptions == null || currentOptions.getProvider() == null ? null : currentOptions.getProvider().getPlatform(),\n                    currentProtocol == null ? null : currentProtocol.getValue(),\n                    null,\n                    currentOptions == null ? null : currentOptions.getApiKey(),\n                    currentOptions == null ? null : currentOptions.getBaseUrl(),\n                    Collections.<String, String>emptyMap(),\n                    new Properties()\n            );\n            StringBuilder builder = new StringBuilder();\n            builder.append(\"model:\\n\");\n            builder.append(\"- current=\").append(currentOptions == null ? null : currentOptions.getModel()).append('\\n');\n            builder.append(\"- override=\").append(firstNonBlank(resolved.getModelOverride(), \"(none)\")).append('\\n');\n            builder.append(\"- profile=\").append(firstNonBlank(resolved.getEffectiveProfile(), \"(none)\")).append('\\n');\n            builder.append(\"- workspaceConfig=\").append(providerConfigManager.workspaceConfigPath());\n            return builder.toString().trim();\n        }\n\n        private String renderExperimentalOutput() {\n            CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n            StringBuilder builder = new StringBuilder();\n            builder.append(\"experimental:\\n\");\n            builder.append(\"- subagent=\").append(renderExperimentalState(\n                    workspaceConfig == null ? null : workspaceConfig.getExperimentalSubagentsEnabled(),\n                    DefaultCodingCliAgentFactory.isExperimentalSubagentsEnabled(workspaceConfig)\n            )).append('\\n');\n            builder.append(\"- agent-teams=\").append(renderExperimentalState(\n                    workspaceConfig == null ? null : workspaceConfig.getExperimentalAgentTeamsEnabled(),\n                    DefaultCodingCliAgentFactory.isExperimentalAgentTeamsEnabled(workspaceConfig)\n            )).append('\\n');\n            builder.append(\"- workspaceConfig=\").append(providerConfigManager.workspaceConfigPath());\n            return builder.toString().trim();\n        }\n\n        private String renderExperimentalState(Boolean configuredValue, boolean effectiveValue) {\n            String base = effectiveValue ? \"on\" : \"off\";\n            return configuredValue == null ? base + \" (default)\" : base;\n        }\n\n        private String normalizeExperimentalFeature(String raw) {\n            if (isBlank(raw)) {\n                return null;\n            }\n            String normalized = raw.trim().toLowerCase(Locale.ROOT);\n            if (\"subagent\".equals(normalized) || \"subagents\".equals(normalized)) {\n                return \"subagent\";\n            }\n            if (\"agent-teams\".equals(normalized)\n                    || \"agent-team\".equals(normalized)\n                    || \"agentteams\".equals(normalized)\n                    || \"team\".equals(normalized)\n                    || \"teams\".equals(normalized)) {\n                return \"agent-teams\";\n            }\n            return null;\n        }\n\n        private Boolean parseExperimentalToggle(String raw) {\n            if (isBlank(raw)) {\n                return null;\n            }\n            String normalized = raw.trim().toLowerCase(Locale.ROOT);\n            if (\"on\".equals(normalized) || \"enable\".equals(normalized) || \"enabled\".equals(normalized)) {\n                return Boolean.TRUE;\n            }\n            if (\"off\".equals(normalized) || \"disable\".equals(normalized) || \"disabled\".equals(normalized)) {\n                return Boolean.FALSE;\n            }\n            return null;\n        }\n\n        private List<Map<String, Object>> buildModeOptionValues() {\n            List<Map<String, Object>> values = new ArrayList<Map<String, Object>>();\n            for (ApprovalMode mode : ApprovalMode.values()) {\n                if (mode == null) {\n                    continue;\n                }\n                values.add(newMap(\n                        \"value\", mode.getValue(),\n                        \"name\", approvalModeName(mode),\n                        \"description\", approvalModeDescription(mode)\n                ));\n            }\n            return values;\n        }\n\n        private List<Map<String, Object>> buildModelOptionValues() {\n            LinkedHashMap<String, Map<String, Object>> values = new LinkedHashMap<String, Map<String, Object>>();\n            CliResolvedProviderConfig resolved = providerConfigManager.resolve(\n                    options == null || options.getProvider() == null ? null : options.getProvider().getPlatform(),\n                    currentProtocol() == null ? null : currentProtocol().getValue(),\n                    null,\n                    options == null ? null : options.getApiKey(),\n                    options == null ? null : options.getBaseUrl(),\n                    Collections.<String, String>emptyMap(),\n                    new Properties()\n            );\n            String currentProvider = options != null && options.getProvider() != null\n                    ? options.getProvider().getPlatform()\n                    : (resolved.getProvider() == null ? null : resolved.getProvider().getPlatform());\n            addModelOptionValue(values, resolved.getModelOverride(), \"Current workspace override\");\n            addModelOptionValue(values, options == null ? null : options.getModel(), \"Current effective model\");\n\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n            addProfileModelOptionValue(values, workspaceConfig == null ? null : workspaceConfig.getActiveProfile(), currentProvider, \"Active profile\");\n            addProfileModelOptionValue(values, providersConfig.getDefaultProfile(), currentProvider, \"Default profile\");\n            for (String profileName : providerConfigManager.listProfileNames()) {\n                addProfileModelOptionValue(values, profileName, currentProvider, \"Saved profile\");\n            }\n            return new ArrayList<Map<String, Object>>(values.values());\n        }\n\n        private void addProfileModelOptionValue(LinkedHashMap<String, Map<String, Object>> values,\n                                                String profileName,\n                                                String provider,\n                                                String label) {\n            if (values == null || isBlank(profileName)) {\n                return;\n            }\n            CliProviderProfile profile = providerConfigManager.getProfile(profileName);\n            if (profile == null || isBlank(profile.getModel())) {\n                return;\n            }\n            if (!isBlank(provider) && !provider.equalsIgnoreCase(firstNonBlank(profile.getProvider(), provider))) {\n                return;\n            }\n            addModelOptionValue(values, profile.getModel(), label + \" \" + profileName);\n        }\n\n        private void addModelOptionValue(LinkedHashMap<String, Map<String, Object>> values,\n                                         String model,\n                                         String description) {\n            if (values == null || isBlank(model)) {\n                return;\n            }\n            String normalizedKey = model.trim().toLowerCase(Locale.ROOT);\n            if (values.containsKey(normalizedKey)) {\n                return;\n            }\n            values.put(normalizedKey, newMap(\n                    \"value\", model.trim(),\n                    \"name\", model.trim(),\n                    \"description\", description\n            ));\n        }\n\n        private boolean isSupportedModelValue(String value) {\n            if (isBlank(value)) {\n                return false;\n            }\n            String normalized = value.trim();\n            if (options != null && normalized.equals(options.getModel())) {\n                return true;\n            }\n            for (Map<String, Object> option : buildModelOptionValues()) {\n                if (option == null) {\n                    continue;\n                }\n                Object candidate = option.get(\"value\");\n                if (candidate != null && normalized.equals(String.valueOf(candidate))) {\n                    return true;\n                }\n            }\n            return false;\n        }\n\n        private ApprovalMode currentApprovalMode() {\n            return options == null ? ApprovalMode.AUTO : options.getApprovalMode();\n        }\n\n        private String approvalModeName(ApprovalMode mode) {\n            if (mode == ApprovalMode.MANUAL) {\n                return \"Manual\";\n            }\n            if (mode == ApprovalMode.SAFE) {\n                return \"Safe\";\n            }\n            return \"Auto\";\n        }\n\n        private String approvalModeDescription(ApprovalMode mode) {\n            if (mode == ApprovalMode.MANUAL) {\n                return \"Require user approval for every tool call.\";\n            }\n            if (mode == ApprovalMode.SAFE) {\n                return \"Ask for approval on editing and command execution actions.\";\n            }\n            return \"Run tools automatically unless the ACP client enforces its own checks.\";\n        }\n\n        private void emitModeUpdate() {\n            ManagedCodingSession currentSession = session;\n            if (currentSession == null || isBlank(currentSession.getSessionId())) {\n                return;\n            }\n            sendSessionUpdate(currentSession.getSessionId(), newMap(\n                    \"sessionUpdate\", \"current_mode_update\",\n                    \"currentModeId\", currentApprovalMode().getValue(),\n                    \"modeId\", currentApprovalMode().getValue()\n            ));\n        }\n\n        private void emitConfigOptionUpdate() {\n            ManagedCodingSession currentSession = session;\n            if (currentSession == null || isBlank(currentSession.getSessionId())) {\n                return;\n            }\n            sendSessionUpdate(currentSession.getSessionId(), newMap(\n                    \"sessionUpdate\", \"config_option_update\",\n                    \"configOptions\", buildConfigOptions()\n            ));\n        }\n\n        private String resolveEffectiveProfileName() {\n            CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n            CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n            String activeProfile = workspaceConfig == null ? null : workspaceConfig.getActiveProfile();\n            if (!isBlank(activeProfile) && providersConfig.getProfiles().containsKey(activeProfile)) {\n                return activeProfile;\n            }\n            String defaultProfile = providersConfig.getDefaultProfile();\n            return !isBlank(defaultProfile) && providersConfig.getProfiles().containsKey(defaultProfile) ? defaultProfile : null;\n        }\n\n        private CodeCommandOptions resolveCurrentProviderOptions(String modelOverride) {\n            CodeCommandOptions currentOptions = options;\n            CliProtocol currentProtocol = currentProtocol();\n            CliResolvedProviderConfig resolved = providerConfigManager.resolve(\n                    currentOptions == null || currentOptions.getProvider() == null ? null : currentOptions.getProvider().getPlatform(),\n                    currentProtocol == null ? null : currentProtocol.getValue(),\n                    modelOverride,\n                    currentOptions == null ? null : currentOptions.getApiKey(),\n                    currentOptions == null ? null : currentOptions.getBaseUrl(),\n                    Collections.<String, String>emptyMap(),\n                    new Properties()\n            );\n            return currentOptions.withRuntime(\n                    resolved.getProvider(),\n                    resolved.getProtocol(),\n                    resolved.getModel(),\n                    resolved.getApiKey(),\n                    resolved.getBaseUrl()\n            );\n        }\n\n        private CodeCommandOptions resolveProfileRuntimeOptions(CliProviderProfile profile,\n                                                               CliWorkspaceConfig workspaceConfig) {\n            CodeCommandOptions currentOptions = options;\n            PlatformType currentProvider = currentOptions == null ? null : currentOptions.getProvider();\n            PlatformType provider = parseProviderType(firstNonBlank(\n                    profile == null ? null : profile.getProvider(),\n                    currentProvider == null ? null : currentProvider.getPlatform()\n            ));\n            if (provider == null) {\n                provider = currentProvider == null ? PlatformType.OPENAI : currentProvider;\n            }\n            String baseUrl = firstNonBlank(profile == null ? null : profile.getBaseUrl(), currentOptions == null ? null : currentOptions.getBaseUrl());\n            String protocolRaw = firstNonBlank(\n                    normalizeStoredProtocol(profile == null ? null : profile.getProtocol(), provider, baseUrl),\n                    currentProtocol() == null ? null : currentProtocol().getValue(),\n                    CliProtocol.defaultProtocol(provider, baseUrl).getValue()\n            );\n            CliProtocol protocolValue = parseProviderProtocol(protocolRaw);\n            if (protocolValue == null) {\n                protocolValue = currentProtocol() == null ? CliProtocol.defaultProtocol(provider, baseUrl) : currentProtocol();\n            }\n            String model = firstNonBlank(\n                    workspaceConfig == null ? null : workspaceConfig.getModelOverride(),\n                    profile == null ? null : profile.getModel(),\n                    currentOptions == null ? null : currentOptions.getModel()\n            );\n            String apiKey = firstNonBlank(profile == null ? null : profile.getApiKey(), currentOptions == null ? null : currentOptions.getApiKey());\n            return currentOptions.withRuntime(provider, protocolValue, model, apiKey, baseUrl);\n        }\n\n        private void rebindSession(CodeCommandOptions nextOptions) throws Exception {\n            if (agentFactoryProvider == null) {\n                throw new IllegalStateException(\"ACP runtime switching is unavailable\");\n            }\n            CodingCliAgentFactory nextFactory = agentFactoryProvider.create(nextOptions, permissionGateway, resolvedMcpConfig);\n            CodingCliAgentFactory.PreparedCodingAgent nextPrepared = nextFactory.prepare(nextOptions, null, null, Collections.<String>emptySet());\n            ManagedCodingSession previousSession = session;\n            CodingCliAgentFactory.PreparedCodingAgent previousPrepared = prepared;\n            CodingRuntime previousRuntime = codingRuntime;\n            CodingTaskSessionEventBridge previousBridge = taskEventBridge;\n\n            ManagedCodingSession rebound = previousSession;\n            if (previousSession != null && previousSession.getSession() != null) {\n                io.github.lnyocly.ai4j.coding.CodingSessionState state = previousSession.getSession().exportState();\n                io.github.lnyocly.ai4j.coding.CodingSession nextSession = nextPrepared.getAgent().newSession(previousSession.getSessionId(), state);\n                rebound = new ManagedCodingSession(\n                        nextSession,\n                        nextOptions.getProvider() == null ? null : nextOptions.getProvider().getPlatform(),\n                        nextPrepared.getProtocol() == null ? null : nextPrepared.getProtocol().getValue(),\n                        nextOptions.getModel(),\n                        nextOptions.getWorkspace(),\n                        nextOptions.getWorkspaceDescription(),\n                        nextOptions.getSystemPrompt(),\n                        nextOptions.getInstructions(),\n                        firstNonBlank(previousSession.getRootSessionId(), previousSession.getSessionId()),\n                        previousSession.getParentSessionId(),\n                        previousSession.getCreatedAtEpochMs(),\n                        previousSession.getUpdatedAtEpochMs()\n                );\n            }\n\n            if (previousRuntime != null && previousBridge != null) {\n                previousRuntime.removeListener(previousBridge);\n            }\n            if (previousPrepared != null && previousPrepared.getMcpRuntimeManager() != null) {\n                previousPrepared.getMcpRuntimeManager().close();\n            }\n            if (previousSession != null) {\n                previousSession.close();\n            }\n\n            this.options = nextOptions;\n            this.runtimeOptions = nextOptions;\n            this.factory = nextFactory;\n            this.prepared = nextPrepared;\n            this.session = rebound;\n            this.runtime = new HeadlessCodingSessionRuntime(nextOptions, sessionManager);\n            this.codingRuntime = nextPrepared == null || nextPrepared.getAgent() == null ? null : nextPrepared.getAgent().getRuntime();\n            this.taskEventBridge = registerTaskEventBridge();\n        }\n\n        private void applySessionOptionsChange(CodeCommandOptions nextOptions,\n                                               boolean emitModeUpdate,\n                                               boolean emitConfigOptionUpdate) throws Exception {\n            if (nextOptions == null) {\n                throw new IllegalArgumentException(\"ACP session configuration is unavailable\");\n            }\n            boolean promptActive;\n            boolean changed;\n            synchronized (this) {\n                changed = !sameRuntimeConfig(options, nextOptions);\n                promptActive = hasActivePrompt();\n                if (promptActive) {\n                    options = nextOptions;\n                    pendingRuntimeOptions = nextOptions;\n                }\n            }\n            if (!promptActive && changed) {\n                rebindSession(nextOptions);\n            } else if (!promptActive) {\n                options = nextOptions;\n            }\n            if (emitModeUpdate) {\n                emitModeUpdate();\n            }\n            if (emitConfigOptionUpdate) {\n                emitConfigOptionUpdate();\n            }\n            persistSessionIfConfigured();\n        }\n\n        private void completePrompt(HeadlessCodingSessionRuntime.PromptControl promptControl) {\n            CodeCommandOptions deferredOptions = null;\n            synchronized (this) {\n                if (activePrompt == promptControl) {\n                    activePrompt = null;\n                }\n                if (pendingRuntimeOptions != null) {\n                    deferredOptions = pendingRuntimeOptions;\n                    pendingRuntimeOptions = null;\n                }\n            }\n            if (deferredOptions == null || deferredOptions != options || sameRuntimeConfig(runtimeOptions, deferredOptions)) {\n                return;\n            }\n            try {\n                rebindSession(deferredOptions);\n            } catch (Exception ex) {\n                rollbackDeferredSessionOptions(deferredOptions, ex);\n            }\n        }\n\n        private void rollbackDeferredSessionOptions(CodeCommandOptions deferredOptions, Exception ex) {\n            CodeCommandOptions boundOptions = runtimeOptions;\n            boolean modeChanged = boundOptions != null\n                    && deferredOptions != null\n                    && boundOptions.getApprovalMode() != deferredOptions.getApprovalMode();\n            synchronized (this) {\n                if (options == deferredOptions) {\n                    options = boundOptions;\n                }\n            }\n            if (modeChanged) {\n                emitModeUpdate();\n            }\n            emitConfigOptionUpdate();\n            logError(\"Failed to apply deferred ACP session configuration: \" + safeMessage(ex));\n        }\n\n        private boolean hasActivePrompt() {\n            return activePrompt != null && !activePrompt.isCancelled();\n        }\n\n        private boolean sameRuntimeConfig(CodeCommandOptions left, CodeCommandOptions right) {\n            if (left == right) {\n                return true;\n            }\n            if (left == null || right == null) {\n                return false;\n            }\n            return samePlatform(left.getProvider(), right.getProvider())\n                    && sameProtocol(left.getProtocol(), right.getProtocol())\n                    && sameValue(left.getModel(), right.getModel())\n                    && sameValue(left.getApiKey(), right.getApiKey())\n                    && sameValue(left.getBaseUrl(), right.getBaseUrl())\n                    && left.getApprovalMode() == right.getApprovalMode();\n        }\n\n        private boolean samePlatform(PlatformType left, PlatformType right) {\n            if (left == right) {\n                return true;\n            }\n            if (left == null || right == null) {\n                return false;\n            }\n            return sameValue(left.getPlatform(), right.getPlatform());\n        }\n\n        private boolean sameProtocol(CliProtocol left, CliProtocol right) {\n            if (left == right) {\n                return true;\n            }\n            if (left == null || right == null) {\n                return false;\n            }\n            return sameValue(left.getValue(), right.getValue());\n        }\n\n        private boolean sameValue(String left, String right) {\n            return left == null ? right == null : left.equals(right);\n        }\n\n        private ProviderProfileMutation parseProviderProfileMutation(String rawArguments) {\n            if (isBlank(rawArguments)) {\n                return null;\n            }\n            String[] tokens = rawArguments.trim().split(\"\\\\s+\");\n            if (tokens.length == 0 || isBlank(tokens[0])) {\n                return null;\n            }\n            ProviderProfileMutation mutation = new ProviderProfileMutation(tokens[0].trim());\n            for (int i = 1; i < tokens.length; i++) {\n                String token = tokens[i];\n                if (\"--provider\".equalsIgnoreCase(token)) {\n                    String value = requireProviderMutationValue(tokens, ++i);\n                    if (value == null) {\n                        return null;\n                    }\n                    mutation.provider = value;\n                    continue;\n                }\n                if (\"--protocol\".equalsIgnoreCase(token)) {\n                    String value = requireProviderMutationValue(tokens, ++i);\n                    if (value == null) {\n                        return null;\n                    }\n                    mutation.protocol = value;\n                    continue;\n                }\n                if (\"--model\".equalsIgnoreCase(token)) {\n                    String value = requireProviderMutationValue(tokens, ++i);\n                    if (value == null) {\n                        return null;\n                    }\n                    mutation.model = value;\n                    mutation.clearModel = false;\n                    continue;\n                }\n                if (\"--base-url\".equalsIgnoreCase(token)) {\n                    String value = requireProviderMutationValue(tokens, ++i);\n                    if (value == null) {\n                        return null;\n                    }\n                    mutation.baseUrl = value;\n                    mutation.clearBaseUrl = false;\n                    continue;\n                }\n                if (\"--api-key\".equalsIgnoreCase(token)) {\n                    String value = requireProviderMutationValue(tokens, ++i);\n                    if (value == null) {\n                        return null;\n                    }\n                    mutation.apiKey = value;\n                    mutation.clearApiKey = false;\n                    continue;\n                }\n                if (\"--clear-model\".equalsIgnoreCase(token)) {\n                    mutation.model = null;\n                    mutation.clearModel = true;\n                    continue;\n                }\n                if (\"--clear-base-url\".equalsIgnoreCase(token)) {\n                    mutation.baseUrl = null;\n                    mutation.clearBaseUrl = true;\n                    continue;\n                }\n                if (\"--clear-api-key\".equalsIgnoreCase(token)) {\n                    mutation.apiKey = null;\n                    mutation.clearApiKey = true;\n                    continue;\n                }\n                return null;\n            }\n            return mutation;\n        }\n\n        private String requireProviderMutationValue(String[] tokens, int index) {\n            if (tokens == null || index < 0 || index >= tokens.length || isBlank(tokens[index])) {\n                return null;\n            }\n            return tokens[index].trim();\n        }\n\n        private PlatformType parseProviderType(String raw) {\n            if (isBlank(raw)) {\n                return null;\n            }\n            for (PlatformType platformType : PlatformType.values()) {\n                if (platformType.getPlatform().equalsIgnoreCase(raw.trim())) {\n                    return platformType;\n                }\n            }\n            return null;\n        }\n\n        private CliProtocol parseProviderProtocol(String raw) {\n            if (isBlank(raw)) {\n                return null;\n            }\n            try {\n                return CliProtocol.parse(raw);\n            } catch (IllegalArgumentException ex) {\n                return null;\n            }\n        }\n\n        private boolean isSupportedProviderProtocol(PlatformType provider, CliProtocol protocol) {\n            if (provider == null || protocol == null || protocol == CliProtocol.CHAT) {\n                return true;\n            }\n            return provider == PlatformType.OPENAI || provider == PlatformType.DOUBAO || provider == PlatformType.DASHSCOPE;\n        }\n\n        private String normalizeStoredProtocol(String raw, PlatformType provider, String baseUrl) {\n            if (isBlank(raw)) {\n                return null;\n            }\n            return CliProtocol.resolveConfigured(raw, provider, baseUrl).getValue();\n        }\n\n        private CliProtocol currentProtocol() {\n            return prepared == null ? null : prepared.getProtocol();\n        }\n\n        private String maskSecret(String value) {\n            if (isBlank(value)) {\n                return null;\n            }\n            String trimmed = value.trim();\n            if (trimmed.length() <= 8) {\n                return \"****\";\n            }\n            return trimmed.substring(0, 4) + \"...\" + trimmed.substring(trimmed.length() - 4);\n        }\n\n        private Map<String, Object> toHistoryUpdate(SessionEvent event) {\n            if (event == null || event.getType() == null) {\n                return null;\n            }\n            if (event.getType() == SessionEventType.USER_MESSAGE) {\n                return newMap(\n                        \"sessionUpdate\", \"user_message_chunk\",\n                        \"content\", textContent(payloadText(event, \"input\", event.getSummary()))\n                );\n            }\n            if (event.getType() == SessionEventType.ASSISTANT_MESSAGE) {\n                String kind = event.getPayload() == null ? null : String.valueOf(event.getPayload().get(\"kind\"));\n                return newMap(\n                        \"sessionUpdate\", \"reasoning\".equals(kind) ? \"agent_thought_chunk\" : \"agent_message_chunk\",\n                        \"content\", textContent(payloadText(event, \"output\", event.getSummary()))\n                );\n            }\n            if (event.getType() == SessionEventType.TOOL_CALL) {\n                return newMap(\n                        \"sessionUpdate\", \"tool_call\",\n                        \"toolCallId\", payloadText(event, \"callId\", null),\n                        \"title\", payloadText(event, \"tool\", event.getSummary()),\n                        \"kind\", mapToolKind(payloadText(event, \"tool\", null)),\n                        \"status\", \"pending\",\n                        \"rawInput\", newMap(\n                                \"tool\", payloadText(event, \"tool\", null),\n                                \"arguments\", payloadJsonText(event, \"arguments\")\n                        )\n                );\n            }\n            if (event.getType() == SessionEventType.TOOL_RESULT) {\n                return newMap(\n                        \"sessionUpdate\", \"tool_call_update\",\n                        \"toolCallId\", payloadText(event, \"callId\", null),\n                        \"status\", \"completed\",\n                        \"content\", toolCallTextContent(payloadText(event, \"output\", event.getSummary()))\n                );\n            }\n            if (event.getType() == SessionEventType.TASK_CREATED) {\n                return newMap(\n                        \"sessionUpdate\", \"tool_call\",\n                        \"toolCallId\", payloadText(event, \"callId\", payloadText(event, \"taskId\", null)),\n                        \"title\", payloadText(event, \"title\", event.getSummary()),\n                        \"kind\", \"other\",\n                        \"status\", mapTaskStatus(payloadText(event, \"status\", null)),\n                        \"rawInput\", buildTaskRawInput(event)\n                );\n            }\n            if (event.getType() == SessionEventType.TASK_UPDATED) {\n                String text = buildTaskUpdateText(event);\n                return newMap(\n                        \"sessionUpdate\", \"tool_call_update\",\n                        \"toolCallId\", payloadText(event, \"callId\", payloadText(event, \"taskId\", null)),\n                        \"status\", mapTaskStatus(payloadText(event, \"status\", null)),\n                        \"content\", toolCallTextContent(text),\n                        \"rawOutput\", buildTaskRawOutput(event)\n                );\n            }\n            if (event.getType() == SessionEventType.TEAM_MESSAGE) {\n                return toTeamMessageAcpUpdate(event);\n            }\n            if (event.getType() == SessionEventType.ERROR) {\n                return newMap(\n                        \"sessionUpdate\", \"agent_message_chunk\",\n                        \"content\", textContent(payloadText(event, \"error\", event.getSummary()))\n                );\n            }\n            if (event.getType() == SessionEventType.AUTO_CONTINUE\n                    || event.getType() == SessionEventType.AUTO_STOP\n                    || event.getType() == SessionEventType.BLOCKED) {\n                return newMap(\n                        \"sessionUpdate\", \"agent_message_chunk\",\n                        \"content\", textContent(firstNonBlank(event.getSummary(), event.getType().name().toLowerCase(Locale.ROOT).replace('_', ' ')))\n                );\n            }\n            return null;\n        }\n\n        private String payloadText(SessionEvent event, String key, String defaultValue) {\n            if (event == null || event.getPayload() == null || key == null) {\n                return defaultValue;\n            }\n            Object value = event.getPayload().get(key);\n            return value == null ? defaultValue : String.valueOf(value);\n        }\n\n        private Object payloadJsonText(SessionEvent event, String key) {\n            return parseJsonOrText(payloadText(event, key, null));\n        }\n\n        private CodingTaskSessionEventBridge registerTaskEventBridge() {\n            if (codingRuntime == null || sessionManager == null) {\n                return null;\n            }\n            CodingTaskSessionEventBridge bridge = new CodingTaskSessionEventBridge(sessionManager, new CodingTaskSessionEventBridge.SessionEventConsumer() {\n                @Override\n                public void onEvent(SessionEvent event) {\n                    if (event == null || !session.getSessionId().equals(event.getSessionId())) {\n                        return;\n                    }\n                    Map<String, Object> update = toHistoryUpdate(event);\n                    if (update != null) {\n                        sendSessionUpdate(session.getSessionId(), update);\n                    }\n                }\n            });\n            codingRuntime.addListener(bridge);\n            return bridge;\n        }\n\n        private String mapTaskStatus(String status) {\n            String normalized = trimToNull(status);\n            if (normalized == null) {\n                return \"pending\";\n            }\n            String lower = normalized.toLowerCase(Locale.ROOT);\n            if (\"running\".equals(lower) || \"in_progress\".equals(lower) || \"in-progress\".equals(lower) || \"started\".equals(lower)) {\n                return \"in_progress\";\n            }\n            if (\"completed\".equals(lower)) {\n                return \"completed\";\n            }\n            if (\"fallback\".equals(lower)) {\n                return \"completed\";\n            }\n            if (\"failed\".equals(lower) || \"cancelled\".equals(lower) || \"canceled\".equals(lower) || \"error\".equals(lower)) {\n                return \"failed\";\n            }\n            return \"pending\";\n        }\n\n        @Override\n        public void close() {\n            CodingRuntime currentRuntime = codingRuntime;\n            CodingTaskSessionEventBridge currentBridge = taskEventBridge;\n            CodingCliAgentFactory.PreparedCodingAgent currentPrepared = prepared;\n            ManagedCodingSession currentSession = session;\n            if (currentRuntime != null && currentBridge != null) {\n                currentRuntime.removeListener(currentBridge);\n            }\n            cancel();\n            if (currentPrepared != null && currentPrepared.getMcpRuntimeManager() != null) {\n                currentPrepared.getMcpRuntimeManager().close();\n            }\n            if (currentSession != null) {\n                currentSession.close();\n            }\n        }\n\n        private final class ProviderProfileMutation {\n            private final String profileName;\n            private String provider;\n            private String protocol;\n            private String model;\n            private boolean clearModel;\n            private String baseUrl;\n            private boolean clearBaseUrl;\n            private String apiKey;\n            private boolean clearApiKey;\n\n            private ProviderProfileMutation(String profileName) {\n                this.profileName = profileName;\n            }\n\n            private boolean hasAnyFieldChanges() {\n                return provider != null\n                        || protocol != null\n                        || model != null\n                        || clearModel\n                        || baseUrl != null\n                        || clearBaseUrl\n                        || apiKey != null\n                        || clearApiKey;\n            }\n        }\n    }\n\n    private final class AcpTurnObserver extends HeadlessTurnObserver.Adapter {\n\n        private final String sessionId;\n\n        private AcpTurnObserver(String sessionId) {\n            this.sessionId = sessionId;\n        }\n\n        @Override\n        public void onTurnStarted(ManagedCodingSession session, String turnId, String input) {\n            sendSessionUpdate(sessionId, newMap(\n                    \"sessionUpdate\", \"user_message_chunk\",\n                    \"content\", textContent(input)\n            ));\n        }\n\n        @Override\n        public void onReasoningDelta(ManagedCodingSession session, String turnId, Integer step, String delta) {\n            sendSessionUpdate(sessionId, newMap(\n                    \"sessionUpdate\", \"agent_thought_chunk\",\n                    \"content\", textContent(delta)\n            ));\n        }\n\n        @Override\n        public void onAssistantDelta(ManagedCodingSession session, String turnId, Integer step, String delta) {\n            sendSessionUpdate(sessionId, newMap(\n                    \"sessionUpdate\", \"agent_message_chunk\",\n                    \"content\", textContent(delta)\n            ));\n        }\n\n        @Override\n        public void onToolCall(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call) {\n            sendSessionUpdate(sessionId, newMap(\n                    \"sessionUpdate\", \"tool_call\",\n                    \"toolCallId\", call == null ? UUID.randomUUID().toString() : firstNonBlank(call.getCallId(), UUID.randomUUID().toString()),\n                    \"title\", call == null ? \"tool\" : firstNonBlank(call.getName(), \"tool\"),\n                    \"kind\", mapToolKind(call == null ? null : call.getName()),\n                    \"status\", \"pending\",\n                    \"rawInput\", newMap(\n                            \"tool\", call == null ? null : call.getName(),\n                            \"arguments\", parseJsonOrText(call == null ? null : call.getArguments())\n                    )\n            ));\n        }\n\n        @Override\n        public void onToolResult(ManagedCodingSession session,\n                                 String turnId,\n                                 Integer step,\n                                 AgentToolCall call,\n                                 AgentToolResult result,\n                                 boolean failed) {\n            sendSessionUpdate(sessionId, newMap(\n                    \"sessionUpdate\", \"tool_call_update\",\n                    \"toolCallId\", result == null ? null : firstNonBlank(result.getCallId(), call == null ? null : call.getCallId()),\n                    \"status\", failed ? \"failed\" : \"completed\",\n                    \"content\", toolCallTextContent(result == null ? null : result.getOutput()),\n                    \"rawOutput\", newMap(\"text\", result == null ? null : result.getOutput())\n            ));\n        }\n\n        @Override\n        public void onTurnError(ManagedCodingSession session, String turnId, Integer step, String message) {\n            sendSessionUpdate(sessionId, newMap(\n                    \"sessionUpdate\", \"agent_message_chunk\",\n                    \"content\", textContent(message)\n            ));\n        }\n\n        @Override\n        public void onSessionEvent(ManagedCodingSession session, SessionEvent event) {\n            Map<String, Object> update = toStructuredSessionUpdate(event);\n            if (update != null) {\n                sendSessionUpdate(sessionId, update);\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpSlashCommandSupport.java",
    "content": "package io.github.lnyocly.ai4j.cli.acp;\n\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpStatusSnapshot;\nimport io.github.lnyocly.ai4j.cli.runtime.CliTeamStateManager;\nimport io.github.lnyocly.ai4j.cli.runtime.TeamBoardRenderSupport;\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.StoredCodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpointFormatter;\nimport io.github.lnyocly.ai4j.coding.CodingSessionSnapshot;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessInfo;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.text.SimpleDateFormat;\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.Date;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.TimeZone;\n\nfinal class AcpSlashCommandSupport {\n\n    private static final int DEFAULT_EVENT_LIMIT = 12;\n    private static final int DEFAULT_PROCESS_LOG_LIMIT = 800;\n\n    private static final List<CommandSpec> COMMANDS = Collections.unmodifiableList(Arrays.asList(\n            new CommandSpec(\"help\", \"Show ACP slash commands\", null),\n            new CommandSpec(\"status\", \"Show current session status\", null),\n            new CommandSpec(\"session\", \"Show current session metadata\", null),\n            new CommandSpec(\"save\", \"Persist the current session state\", null),\n            new CommandSpec(\"providers\", \"List saved provider profiles\", null),\n            new CommandSpec(\"provider\", \"Show or switch the active provider profile\", \"use <profile> | save <profile> | add/edit/default/remove ...\"),\n            new CommandSpec(\"model\", \"Show or switch the active model override\", \"optional model name | reset\"),\n            new CommandSpec(\"experimental\", \"Show or switch experimental runtime features\", \"optional feature | <subagent|agent-teams> <on|off>\"),\n            new CommandSpec(\"skills\", \"List coding skills or inspect one skill\", \"optional skill name\"),\n            new CommandSpec(\"agents\", \"List coding agents or inspect one agent\", \"optional agent name\"),\n            new CommandSpec(\"mcp\", \"Show MCP services and status\", null),\n            new CommandSpec(\"sessions\", \"List saved sessions\", null),\n            new CommandSpec(\"history\", \"Show session lineage\", \"optional target session id\"),\n            new CommandSpec(\"tree\", \"Show the session tree\", \"optional root session id\"),\n            new CommandSpec(\"events\", \"Show recent session events\", \"optional limit\"),\n            new CommandSpec(\"team\", \"Show current team board or persisted team state\", \"optional: list | status [team-id] | messages [team-id] [limit] | resume [team-id]\"),\n            new CommandSpec(\"compacts\", \"Show compact history\", \"optional limit\"),\n            new CommandSpec(\"checkpoint\", \"Show current checkpoint summary\", null),\n            new CommandSpec(\"processes\", \"List managed processes\", null),\n            new CommandSpec(\"process\", \"Inspect process status or logs\", \"status <process-id> | logs <process-id> [limit]\")\n    ));\n\n    private AcpSlashCommandSupport() {\n    }\n\n    static List<Map<String, Object>> availableCommands() {\n        List<Map<String, Object>> commands = new ArrayList<Map<String, Object>>();\n        for (CommandSpec spec : COMMANDS) {\n            Map<String, Object> command = new LinkedHashMap<String, Object>();\n            command.put(\"name\", spec.name);\n            command.put(\"description\", spec.description);\n            if (!isBlank(spec.inputHint)) {\n                Map<String, Object> input = new LinkedHashMap<String, Object>();\n                input.put(\"hint\", spec.inputHint);\n                command.put(\"input\", input);\n            }\n            commands.add(command);\n        }\n        return commands;\n    }\n\n    static boolean supports(String input) {\n        return parse(input) != null;\n    }\n\n    static ExecutionResult execute(Context context, String input) throws Exception {\n        ParsedCommand command = parse(input);\n        if (context == null || command == null) {\n            return null;\n        }\n        String name = command.name;\n        if (\"help\".equals(name)) {\n            return ExecutionResult.of(renderHelp());\n        }\n        if (\"status\".equals(name)) {\n            return ExecutionResult.of(renderStatus(context));\n        }\n        if (\"session\".equals(name)) {\n            return ExecutionResult.of(renderSession(context));\n        }\n        if (\"save\".equals(name)) {\n            StoredCodingSession stored = context.sessionManager.save(context.session);\n            return ExecutionResult.of(\"saved session: \" + stored.getSessionId() + \" -> \" + stored.getStorePath());\n        }\n        if (\"providers\".equals(name)) {\n            return ExecutionResult.of(executeRuntimeCommand(context, RuntimeCommand.PROVIDERS, null));\n        }\n        if (\"provider\".equals(name)) {\n            return ExecutionResult.of(executeRuntimeCommand(context, RuntimeCommand.PROVIDER, command.argument));\n        }\n        if (\"model\".equals(name)) {\n            return ExecutionResult.of(executeRuntimeCommand(context, RuntimeCommand.MODEL, command.argument));\n        }\n        if (\"experimental\".equals(name)) {\n            return ExecutionResult.of(executeRuntimeCommand(context, RuntimeCommand.EXPERIMENTAL, command.argument));\n        }\n        if (\"skills\".equals(name)) {\n            return ExecutionResult.of(renderSkills(context, command.argument));\n        }\n        if (\"agents\".equals(name)) {\n            return ExecutionResult.of(renderAgents(context, command.argument));\n        }\n        if (\"mcp\".equals(name)) {\n            return ExecutionResult.of(renderMcp(context));\n        }\n        if (\"sessions\".equals(name)) {\n            return ExecutionResult.of(renderSessions(context));\n        }\n        if (\"history\".equals(name)) {\n            return ExecutionResult.of(renderHistory(context, command.argument));\n        }\n        if (\"tree\".equals(name)) {\n            return ExecutionResult.of(renderTree(context, command.argument));\n        }\n        if (\"events\".equals(name)) {\n            return ExecutionResult.of(renderEvents(context, command.argument));\n        }\n        if (\"team\".equals(name)) {\n            return ExecutionResult.of(renderTeam(context, command.argument));\n        }\n        if (\"compacts\".equals(name)) {\n            return ExecutionResult.of(renderCompacts(context, command.argument));\n        }\n        if (\"checkpoint\".equals(name)) {\n            return ExecutionResult.of(renderCheckpoint(context));\n        }\n        if (\"processes\".equals(name)) {\n            return ExecutionResult.of(renderProcesses(context));\n        }\n        if (\"process\".equals(name)) {\n            return ExecutionResult.of(renderProcess(context, command.argument));\n        }\n        return null;\n    }\n\n    private static String executeRuntimeCommand(Context context,\n                                                RuntimeCommand command,\n                                                String argument) throws Exception {\n        if (context == null || context.runtimeCommandHandler == null || command == null) {\n            return \"command unavailable in this ACP session\";\n        }\n        if (command == RuntimeCommand.PROVIDERS) {\n            return context.runtimeCommandHandler.executeProviders();\n        }\n        if (command == RuntimeCommand.PROVIDER) {\n            return context.runtimeCommandHandler.executeProvider(argument);\n        }\n        if (command == RuntimeCommand.MODEL) {\n            return context.runtimeCommandHandler.executeModel(argument);\n        }\n        if (command == RuntimeCommand.EXPERIMENTAL) {\n            return context.runtimeCommandHandler.executeExperimental(argument);\n        }\n        return \"command unavailable in this ACP session\";\n    }\n\n    private static ParsedCommand parse(String input) {\n        String normalized = trimToNull(input);\n        if (normalized == null || !normalized.startsWith(\"/\")) {\n            return null;\n        }\n        String body = normalized.substring(1).trim();\n        if (body.isEmpty()) {\n            return null;\n        }\n        int space = body.indexOf(' ');\n        String name = (space < 0 ? body : body.substring(0, space)).trim().toLowerCase(Locale.ROOT);\n        if (findSpec(name) == null) {\n            return null;\n        }\n        String argument = space < 0 ? null : trimToNull(body.substring(space + 1));\n        return new ParsedCommand(name, argument);\n    }\n\n    private static CommandSpec findSpec(String name) {\n        if (isBlank(name)) {\n            return null;\n        }\n        for (CommandSpec spec : COMMANDS) {\n            if (spec.name.equalsIgnoreCase(name.trim())) {\n                return spec;\n            }\n        }\n        return null;\n    }\n\n    private static String renderHelp() {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"ACP slash commands:\\n\");\n        for (CommandSpec spec : COMMANDS) {\n            builder.append(\"- /\").append(spec.name).append(\" | \").append(spec.description);\n            if (!isBlank(spec.inputHint)) {\n                builder.append(\" | input: \").append(spec.inputHint);\n            }\n            builder.append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderStatus(Context context) {\n        ManagedCodingSession session = context.session;\n        if (session == null) {\n            return \"status: (none)\";\n        }\n        CodingSessionSnapshot snapshot = snapshot(session);\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"status:\\n\");\n        builder.append(\"- session=\").append(session.getSessionId()).append('\\n');\n        builder.append(\"- provider=\").append(firstNonBlank(session.getProvider(), \"(none)\"))\n                .append(\", protocol=\").append(firstNonBlank(session.getProtocol(), \"(none)\"))\n                .append(\", model=\").append(firstNonBlank(session.getModel(), \"(none)\")).append('\\n');\n        builder.append(\"- workspace=\").append(firstNonBlank(session.getWorkspace(), \"(none)\")).append('\\n');\n        builder.append(\"- mode=\").append(context.options != null && context.options.isNoSession() ? \"memory-only\" : \"persistent\")\n                .append(\", memory=\").append(snapshot == null ? 0 : snapshot.getMemoryItemCount())\n                .append(\", activeProcesses=\").append(snapshot == null ? 0 : snapshot.getActiveProcessCount())\n                .append(\", restoredProcesses=\").append(snapshot == null ? 0 : snapshot.getRestoredProcessCount())\n                .append(\", tokens=\").append(snapshot == null ? 0 : snapshot.getEstimatedContextTokens()).append('\\n');\n        String mcpSummary = renderMcpSummary(context.mcpRuntimeManager);\n        if (!isBlank(mcpSummary)) {\n            builder.append(\"- mcp=\").append(mcpSummary).append('\\n');\n        }\n        builder.append(\"- checkpointGoal=\").append(clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 120)).append('\\n');\n        builder.append(\"- compact=\").append(firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), \"none\"));\n        return builder.toString().trim();\n    }\n\n    private static String renderSession(Context context) {\n        ManagedCodingSession session = context.session;\n        if (session == null) {\n            return \"session: (none)\";\n        }\n        CodingSessionDescriptor descriptor = session.toDescriptor();\n        CodingSessionSnapshot snapshot = snapshot(session);\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"session:\\n\");\n        builder.append(\"- id=\").append(descriptor.getSessionId()).append('\\n');\n        builder.append(\"- root=\").append(firstNonBlank(descriptor.getRootSessionId(), \"(none)\"))\n                .append(\", parent=\").append(firstNonBlank(descriptor.getParentSessionId(), \"(none)\")).append('\\n');\n        builder.append(\"- provider=\").append(firstNonBlank(descriptor.getProvider(), \"(none)\"))\n                .append(\", protocol=\").append(firstNonBlank(descriptor.getProtocol(), \"(none)\"))\n                .append(\", model=\").append(firstNonBlank(descriptor.getModel(), \"(none)\")).append('\\n');\n        builder.append(\"- workspace=\").append(firstNonBlank(descriptor.getWorkspace(), \"(none)\"))\n                .append(\", mode=\").append(context.options != null && context.options.isNoSession() ? \"memory-only\" : \"persistent\").append('\\n');\n        builder.append(\"- created=\").append(formatTimestamp(descriptor.getCreatedAtEpochMs()))\n                .append(\", updated=\").append(formatTimestamp(descriptor.getUpdatedAtEpochMs())).append('\\n');\n        builder.append(\"- memory=\").append(descriptor.getMemoryItemCount())\n                .append(\", processes=\").append(descriptor.getProcessCount())\n                .append(\" (active=\").append(descriptor.getActiveProcessCount())\n                .append(\", restored=\").append(descriptor.getRestoredProcessCount()).append(\")\").append('\\n');\n        builder.append(\"- tokens=\").append(snapshot == null ? 0 : snapshot.getEstimatedContextTokens()).append('\\n');\n        builder.append(\"- checkpoint=\").append(clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 160)).append('\\n');\n        builder.append(\"- compact=\").append(firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), \"none\")).append('\\n');\n        builder.append(\"- summary=\").append(clip(descriptor.getSummary(), 220));\n        return builder.toString().trim();\n    }\n\n    private static String renderSkills(Context context, String argument) {\n        WorkspaceContext workspaceContext = context.session == null || context.session.getSession() == null\n                ? null\n                : context.session.getSession().getWorkspaceContext();\n        if (workspaceContext == null) {\n            return \"skills: (none)\";\n        }\n        List<CodingSkillDescriptor> skills = workspaceContext.getAvailableSkills();\n        if (!isBlank(argument)) {\n            CodingSkillDescriptor selected = findSkill(skills, argument);\n            if (selected == null) {\n                return \"skills: unknown skill `\" + argument.trim() + \"`\";\n            }\n            StringBuilder builder = new StringBuilder();\n            builder.append(\"skill:\\n\");\n            builder.append(\"- name=\").append(firstNonBlank(selected.getName(), \"skill\")).append('\\n');\n            builder.append(\"- source=\").append(firstNonBlank(selected.getSource(), \"unknown\")).append('\\n');\n            builder.append(\"- path=\").append(firstNonBlank(selected.getSkillFilePath(), \"(missing)\")).append('\\n');\n            builder.append(\"- description=\").append(firstNonBlank(selected.getDescription(), \"No description available.\"));\n            return builder.toString().trim();\n        }\n        if (skills == null || skills.isEmpty()) {\n            return \"skills: (none)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"skills:\\n\");\n        builder.append(\"- count=\").append(skills.size()).append('\\n');\n        for (CodingSkillDescriptor skill : skills) {\n            if (skill == null) {\n                continue;\n            }\n            builder.append(\"- \").append(firstNonBlank(skill.getName(), \"skill\"))\n                    .append(\" | source=\").append(firstNonBlank(skill.getSource(), \"unknown\"))\n                    .append(\" | path=\").append(firstNonBlank(skill.getSkillFilePath(), \"(missing)\"))\n                    .append(\" | description=\").append(firstNonBlank(skill.getDescription(), \"No description available.\"))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderAgents(Context context, String argument) {\n        CodingCliAgentFactory.PreparedCodingAgent prepared = context.prepared;\n        CodingAgentDefinitionRegistry registry = prepared == null || prepared.getAgent() == null\n                ? null\n                : prepared.getAgent().getDefinitionRegistry();\n        List<CodingAgentDefinition> definitions = registry == null ? null : registry.listDefinitions();\n        if (!isBlank(argument)) {\n            CodingAgentDefinition selected = findAgent(definitions, argument);\n            if (selected == null) {\n                return \"agents: unknown agent `\" + argument.trim() + \"`\";\n            }\n            StringBuilder builder = new StringBuilder();\n            builder.append(\"agent:\\n\");\n            builder.append(\"- name=\").append(firstNonBlank(selected.getName(), \"agent\")).append('\\n');\n            builder.append(\"- tool=\").append(firstNonBlank(selected.getToolName(), \"(none)\")).append('\\n');\n            builder.append(\"- model=\").append(firstNonBlank(selected.getModel(), \"(inherit)\")).append('\\n');\n            builder.append(\"- background=\").append(selected.isBackground()).append('\\n');\n            builder.append(\"- tools=\").append(renderAllowedTools(selected)).append('\\n');\n            builder.append(\"- description=\").append(firstNonBlank(selected.getDescription(), \"No description available.\"));\n            return builder.toString().trim();\n        }\n        if (definitions == null || definitions.isEmpty()) {\n            return \"agents: (none)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"agents:\\n\");\n        builder.append(\"- count=\").append(definitions.size()).append('\\n');\n        for (CodingAgentDefinition definition : definitions) {\n            if (definition == null) {\n                continue;\n            }\n            builder.append(\"- \").append(firstNonBlank(definition.getName(), \"agent\"))\n                    .append(\" | tool=\").append(firstNonBlank(definition.getToolName(), \"(none)\"))\n                    .append(\" | model=\").append(firstNonBlank(definition.getModel(), \"(inherit)\"))\n                    .append(\" | background=\").append(definition.isBackground())\n                    .append(\" | tools=\").append(renderAllowedTools(definition))\n                    .append(\" | description=\").append(firstNonBlank(definition.getDescription(), \"No description available.\"))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderMcp(Context context) {\n        CliMcpRuntimeManager runtimeManager = context.mcpRuntimeManager;\n        if (runtimeManager == null || !runtimeManager.hasStatuses()) {\n            return \"mcp: (none)\";\n        }\n        List<CliMcpStatusSnapshot> statuses = runtimeManager.getStatuses();\n        if (statuses == null || statuses.isEmpty()) {\n            return \"mcp: (none)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"mcp:\\n\");\n        for (CliMcpStatusSnapshot status : statuses) {\n            if (status == null) {\n                continue;\n            }\n            builder.append(\"- \").append(firstNonBlank(status.getServerName(), \"(server)\"))\n                    .append(\" | type=\").append(firstNonBlank(status.getTransportType(), \"(unknown)\"))\n                    .append(\" | state=\").append(firstNonBlank(status.getState(), \"(unknown)\"))\n                    .append(\" | workspace=\").append(status.isWorkspaceEnabled() ? \"enabled\" : \"disabled\")\n                    .append(\" | paused=\").append(status.isSessionPaused() ? \"yes\" : \"no\")\n                    .append(\" | tools=\").append(status.getToolCount());\n            if (!isBlank(status.getErrorSummary())) {\n                builder.append(\" | error=\").append(clip(status.getErrorSummary(), 120));\n            }\n            builder.append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderSessions(Context context) throws Exception {\n        List<CodingSessionDescriptor> sessions = context.sessionManager.list();\n        if (sessions == null || sessions.isEmpty()) {\n            return \"sessions: (none)\";\n        }\n        List<CodingSessionDescriptor> sorted = new ArrayList<CodingSessionDescriptor>(sessions);\n        Collections.sort(sorted, new Comparator<CodingSessionDescriptor>() {\n            @Override\n            public int compare(CodingSessionDescriptor left, CodingSessionDescriptor right) {\n                long l = left == null ? 0L : left.getUpdatedAtEpochMs();\n                long r = right == null ? 0L : right.getUpdatedAtEpochMs();\n                return Long.compare(r, l);\n            }\n        });\n        StringBuilder builder = new StringBuilder(\"sessions:\\n\");\n        for (CodingSessionDescriptor session : sorted) {\n            if (session == null) {\n                continue;\n            }\n            builder.append(\"- \").append(session.getSessionId())\n                    .append(\" | root=\").append(clip(session.getRootSessionId(), 24))\n                    .append(\" | parent=\").append(clip(firstNonBlank(session.getParentSessionId(), \"-\"), 24))\n                    .append(\" | updated=\").append(formatTimestamp(session.getUpdatedAtEpochMs()))\n                    .append(\" | memory=\").append(session.getMemoryItemCount())\n                    .append(\" | processes=\").append(session.getProcessCount())\n                    .append(\" | \").append(clip(session.getSummary(), 120))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderHistory(Context context, String targetSessionId) throws Exception {\n        List<CodingSessionDescriptor> sessions = mergeCurrentSession(context.sessionManager.list(), context.session);\n        CodingSessionDescriptor target = resolveTargetDescriptor(sessions, context.session, targetSessionId);\n        if (target == null) {\n            return \"history: (none)\";\n        }\n        List<CodingSessionDescriptor> history = resolveHistory(sessions, target);\n        if (history.isEmpty()) {\n            return \"history: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"history:\\n\");\n        for (CodingSessionDescriptor session : history) {\n            builder.append(\"- \").append(session.getSessionId())\n                    .append(\" | parent=\").append(firstNonBlank(session.getParentSessionId(), \"(root)\"))\n                    .append(\" | updated=\").append(formatTimestamp(session.getUpdatedAtEpochMs()))\n                    .append(\" | \").append(clip(session.getSummary(), 120))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderTree(Context context, String rootArgument) throws Exception {\n        List<CodingSessionDescriptor> sessions = mergeCurrentSession(context.sessionManager.list(), context.session);\n        List<String> lines = renderTreeLines(sessions, rootArgument, context.session == null ? null : context.session.getSessionId());\n        if (lines.isEmpty()) {\n            return \"tree: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"tree:\\n\");\n        for (String line : lines) {\n            builder.append(line).append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderEvents(Context context, String limitArgument) throws Exception {\n        ManagedCodingSession session = context.session;\n        if (session == null) {\n            return \"events: (no current session)\";\n        }\n        Integer limit = parseLimit(limitArgument);\n        List<SessionEvent> events = context.sessionManager.listEvents(session.getSessionId(), limit, null);\n        if (events == null || events.isEmpty()) {\n            return \"events: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"events:\\n\");\n        for (SessionEvent event : events) {\n            if (event == null) {\n                continue;\n            }\n            builder.append(\"- \").append(formatTimestamp(event.getTimestamp()))\n                    .append(\" | \").append(event.getType())\n                    .append(event.getStep() == null ? \"\" : \" | step=\" + event.getStep())\n                    .append(\" | \").append(clip(event.getSummary(), 160))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderCompacts(Context context, String limitArgument) throws Exception {\n        ManagedCodingSession session = context.session;\n        if (session == null) {\n            return \"compacts: (no current session)\";\n        }\n        int limit = parseLimit(limitArgument);\n        List<SessionEvent> events = context.sessionManager.listEvents(session.getSessionId(), null, null);\n        List<String> lines = buildCompactLines(events, limit);\n        if (lines.isEmpty()) {\n            return \"compacts: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"compacts:\\n\");\n        for (String line : lines) {\n            builder.append(line).append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderCheckpoint(Context context) {\n        CodingSessionSnapshot snapshot = snapshot(context.session);\n        String summary = snapshot == null ? null : snapshot.getSummary();\n        if (isBlank(summary)) {\n            return \"checkpoint: (none)\";\n        }\n        return \"checkpoint:\\n\" + CodingSessionCheckpointFormatter.render(CodingSessionCheckpointFormatter.parse(summary));\n    }\n\n    private static String renderTeam(Context context, String argument) throws Exception {\n        if (context == null || context.session == null || context.sessionManager == null) {\n            return \"team: (none)\";\n        }\n        if (!isBlank(argument)) {\n            CliTeamStateManager manager = new CliTeamStateManager(resolveWorkspaceRoot(context));\n            List<String> tokens = splitWhitespace(argument);\n            if (tokens.isEmpty()) {\n                return manager.renderListOutput();\n            }\n            String action = tokens.get(0).toLowerCase(Locale.ROOT);\n            if (\"list\".equals(action)) {\n                return manager.renderListOutput();\n            }\n            if (\"status\".equals(action)) {\n                return manager.renderStatusOutput(tokens.size() > 1 ? tokens.get(1) : null);\n            }\n            if (\"messages\".equals(action)) {\n                Integer limit = tokens.size() > 2 ? Integer.valueOf(parseLimit(tokens.get(2))) : null;\n                return manager.renderMessagesOutput(tokens.size() > 1 ? tokens.get(1) : null, limit);\n            }\n            if (\"resume\".equals(action)) {\n                return manager.renderResumeOutput(tokens.size() > 1 ? tokens.get(1) : null);\n            }\n            return \"Usage: /team | /team list | /team status [team-id] | /team messages [team-id] [limit] | /team resume [team-id]\";\n        }\n        List<SessionEvent> events = context.sessionManager.listEvents(context.session.getSessionId(), null, null);\n        return TeamBoardRenderSupport.renderBoardOutput(TeamBoardRenderSupport.renderBoardLines(events));\n    }\n\n    private static java.nio.file.Path resolveWorkspaceRoot(Context context) {\n        String workspace = context == null || context.session == null ? null : trimToNull(context.session.getWorkspace());\n        if (workspace == null && context != null && context.options != null) {\n            workspace = trimToNull(context.options.getWorkspace());\n        }\n        if (workspace == null) {\n            return java.nio.file.Paths.get(\".\").toAbsolutePath().normalize();\n        }\n        return java.nio.file.Paths.get(workspace).toAbsolutePath().normalize();\n    }\n\n    private static String renderProcesses(Context context) {\n        CodingSessionSnapshot snapshot = snapshot(context.session);\n        List<BashProcessInfo> processes = snapshot == null ? null : snapshot.getProcesses();\n        if (processes == null || processes.isEmpty()) {\n            return \"processes: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"processes:\\n\");\n        for (BashProcessInfo process : processes) {\n            if (process == null) {\n                continue;\n            }\n            builder.append(\"- \").append(firstNonBlank(process.getProcessId(), \"(process)\"))\n                    .append(\" | status=\").append(process.getStatus())\n                    .append(\" | mode=\").append(process.isControlAvailable() ? \"live\" : \"metadata-only\")\n                    .append(\" | restored=\").append(process.isRestored())\n                    .append(\" | cwd=\").append(clip(process.getWorkingDirectory(), 48))\n                    .append(\" | cmd=\").append(clip(process.getCommand(), 72))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static String renderProcess(Context context, String rawArguments) throws Exception {\n        if (context.session == null || context.session.getSession() == null) {\n            return \"process: (no current session)\";\n        }\n        if (isBlank(rawArguments)) {\n            return \"Usage: /process status <process-id>\\nUsage: /process logs <process-id> [limit]\";\n        }\n        String[] parts = rawArguments.trim().split(\"\\\\s+\", 3);\n        String action = parts[0].toLowerCase(Locale.ROOT);\n        if (\"status\".equals(action)) {\n            if (parts.length < 2) {\n                return \"Usage: /process status <process-id>\";\n            }\n            return renderProcessStatusOutput(context.session.getSession().processStatus(parts[1]));\n        }\n        if (\"logs\".equals(action)) {\n            if (parts.length < 2) {\n                return \"Usage: /process logs <process-id> [limit]\";\n            }\n            Integer limit = parts.length >= 3 ? parseLimit(parts[2]) : DEFAULT_PROCESS_LOG_LIMIT;\n            BashProcessInfo info = context.session.getSession().processStatus(parts[1]);\n            BashProcessLogChunk logs = context.session.getSession().processLogs(parts[1], null, limit);\n            return renderProcessDetailsOutput(info, logs);\n        }\n        return \"Unknown process action: \" + action + \". Use /process status <process-id> or /process logs <process-id> [limit].\";\n    }\n\n    private static CodingSessionSnapshot snapshot(ManagedCodingSession session) {\n        return session == null || session.getSession() == null ? null : session.getSession().snapshot();\n    }\n\n    private static List<CodingSessionDescriptor> mergeCurrentSession(List<CodingSessionDescriptor> sessions, ManagedCodingSession currentSession) {\n        LinkedHashMap<String, CodingSessionDescriptor> merged = new LinkedHashMap<String, CodingSessionDescriptor>();\n        if (sessions != null) {\n            for (CodingSessionDescriptor session : sessions) {\n                if (session != null && !isBlank(session.getSessionId())) {\n                    merged.put(session.getSessionId(), session);\n                }\n            }\n        }\n        if (currentSession != null && !isBlank(currentSession.getSessionId())) {\n            merged.put(currentSession.getSessionId(), currentSession.toDescriptor());\n        }\n        return new ArrayList<CodingSessionDescriptor>(merged.values());\n    }\n\n    private static CodingSessionDescriptor resolveTargetDescriptor(List<CodingSessionDescriptor> sessions,\n                                                                   ManagedCodingSession currentSession,\n                                                                   String targetSessionId) {\n        String requested = trimToNull(targetSessionId);\n        if (requested == null && currentSession != null) {\n            requested = currentSession.getSessionId();\n        }\n        if (requested == null) {\n            return null;\n        }\n        for (CodingSessionDescriptor session : sessions) {\n            if (session != null && requested.equals(session.getSessionId())) {\n                return session;\n            }\n        }\n        return null;\n    }\n\n    private static List<CodingSessionDescriptor> resolveHistory(List<CodingSessionDescriptor> sessions, CodingSessionDescriptor target) {\n        if (target == null || sessions == null || sessions.isEmpty()) {\n            return Collections.emptyList();\n        }\n        Map<String, CodingSessionDescriptor> byId = new LinkedHashMap<String, CodingSessionDescriptor>();\n        for (CodingSessionDescriptor session : sessions) {\n            if (session != null && !isBlank(session.getSessionId())) {\n                byId.put(session.getSessionId(), session);\n            }\n        }\n        ArrayDeque<CodingSessionDescriptor> chain = new ArrayDeque<CodingSessionDescriptor>();\n        Set<String> seen = new LinkedHashSet<String>();\n        CodingSessionDescriptor current = target;\n        while (current != null && !seen.contains(current.getSessionId())) {\n            chain.addFirst(current);\n            seen.add(current.getSessionId());\n            current = byId.get(current.getParentSessionId());\n        }\n        return new ArrayList<CodingSessionDescriptor>(chain);\n    }\n\n    private static List<String> renderTreeLines(List<CodingSessionDescriptor> sessions, String rootArgument, String currentSessionId) {\n        if (sessions == null || sessions.isEmpty()) {\n            return Collections.emptyList();\n        }\n        Map<String, CodingSessionDescriptor> byId = new LinkedHashMap<String, CodingSessionDescriptor>();\n        Map<String, List<CodingSessionDescriptor>> children = new LinkedHashMap<String, List<CodingSessionDescriptor>>();\n        for (CodingSessionDescriptor session : sessions) {\n            if (session == null || isBlank(session.getSessionId())) {\n                continue;\n            }\n            byId.put(session.getSessionId(), session);\n            String parentId = trimToNull(session.getParentSessionId());\n            List<CodingSessionDescriptor> siblings = children.get(parentId);\n            if (siblings == null) {\n                siblings = new ArrayList<CodingSessionDescriptor>();\n                children.put(parentId, siblings);\n            }\n            siblings.add(session);\n        }\n        for (List<CodingSessionDescriptor> siblings : children.values()) {\n            Collections.sort(siblings, new Comparator<CodingSessionDescriptor>() {\n                @Override\n                public int compare(CodingSessionDescriptor left, CodingSessionDescriptor right) {\n                    long l = left == null ? 0L : left.getCreatedAtEpochMs();\n                    long r = right == null ? 0L : right.getCreatedAtEpochMs();\n                    int createdCompare = Long.compare(l, r);\n                    if (createdCompare != 0) {\n                        return createdCompare;\n                    }\n                    String leftId = left == null ? \"\" : firstNonBlank(left.getSessionId(), \"\");\n                    String rightId = right == null ? \"\" : firstNonBlank(right.getSessionId(), \"\");\n                    return leftId.compareTo(rightId);\n                }\n            });\n        }\n        String requestedRoot = trimToNull(rootArgument);\n        List<CodingSessionDescriptor> roots = new ArrayList<CodingSessionDescriptor>();\n        if (requestedRoot != null) {\n            CodingSessionDescriptor root = byId.get(requestedRoot);\n            if (root != null) {\n                roots.add(root);\n            }\n        } else {\n            List<CodingSessionDescriptor> top = children.get(null);\n            if (top != null) {\n                roots.addAll(top);\n            }\n        }\n        if (roots.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>();\n        for (int i = 0; i < roots.size(); i++) {\n            renderTreeNode(lines, children, roots.get(i), \"\", i == roots.size() - 1, currentSessionId);\n        }\n        return lines;\n    }\n\n    private static void renderTreeNode(List<String> lines,\n                                       Map<String, List<CodingSessionDescriptor>> children,\n                                       CodingSessionDescriptor session,\n                                       String prefix,\n                                       boolean last,\n                                       String currentSessionId) {\n        if (lines == null || session == null) {\n            return;\n        }\n        String branch = prefix + (prefix.isEmpty() ? \"\" : (last ? \"\\\\- \" : \"|- \"));\n        lines.add((prefix.isEmpty() ? \"\" : branch) + renderTreeLabel(session, currentSessionId));\n        List<CodingSessionDescriptor> descendants = children.get(session.getSessionId());\n        if (descendants == null || descendants.isEmpty()) {\n            return;\n        }\n        String childPrefix = prefix + (prefix.isEmpty() ? \"\" : (last ? \"   \" : \"|  \"));\n        if (prefix.isEmpty()) {\n            childPrefix = \"\";\n        }\n        for (int i = 0; i < descendants.size(); i++) {\n            boolean childLast = i == descendants.size() - 1;\n            String nextPrefix = prefix.isEmpty() ? \"\" : childPrefix;\n            renderTreeNode(lines, children, descendants.get(i), nextPrefix, childLast, currentSessionId);\n        }\n    }\n\n    private static String renderTreeLabel(CodingSessionDescriptor session, String currentSessionId) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(session.getSessionId());\n        if (!isBlank(currentSessionId) && currentSessionId.equals(session.getSessionId())) {\n            builder.append(\" [current]\");\n        }\n        builder.append(\" | updated=\").append(formatTimestamp(session.getUpdatedAtEpochMs()));\n        builder.append(\" | \").append(clip(session.getSummary(), 120));\n        return builder.toString();\n    }\n\n    private static List<String> buildCompactLines(List<SessionEvent> events, int limit) {\n        List<SessionEvent> compactEvents = new ArrayList<SessionEvent>();\n        if (events != null) {\n            for (SessionEvent event : events) {\n                if (event != null && event.getType() == SessionEventType.COMPACT) {\n                    compactEvents.add(event);\n                }\n            }\n        }\n        if (compactEvents.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int safeLimit = limit <= 0 ? DEFAULT_EVENT_LIMIT : limit;\n        int from = Math.max(0, compactEvents.size() - safeLimit);\n        List<String> lines = new ArrayList<String>();\n        for (int i = from; i < compactEvents.size(); i++) {\n            SessionEvent event = compactEvents.get(i);\n            Map<String, Object> payload = event.getPayload();\n            StringBuilder line = new StringBuilder();\n            line.append(formatTimestamp(event.getTimestamp()))\n                    .append(\" | mode=\").append(resolveCompactMode(event))\n                    .append(\" | tokens=\").append(formatCompactDelta(payloadInt(payload, \"estimatedTokensBefore\"), payloadInt(payload, \"estimatedTokensAfter\")))\n                    .append(\" | items=\").append(formatCompactDelta(payloadInt(payload, \"beforeItemCount\"), payloadInt(payload, \"afterItemCount\")));\n            if (payloadBoolean(payload, \"splitTurn\")) {\n                line.append(\" | splitTurn\");\n            }\n            String checkpointGoal = clip(payloadString(payload, \"checkpointGoal\"), 64);\n            if (!isBlank(checkpointGoal) && !\"(none)\".equals(checkpointGoal)) {\n                line.append(\" | goal=\").append(checkpointGoal);\n            }\n            lines.add(line.toString());\n            String summary = firstNonBlank(payloadString(payload, \"summary\"), event.getSummary());\n            if (!isBlank(summary)) {\n                lines.add(\"  - \" + clip(summary, 140));\n            }\n        }\n        return lines;\n    }\n\n    private static String renderProcessStatusOutput(BashProcessInfo processInfo) {\n        if (processInfo == null) {\n            return \"process status: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"process status:\\n\");\n        appendProcessSummary(builder, processInfo);\n        return builder.toString().trim();\n    }\n\n    private static String renderProcessDetailsOutput(BashProcessInfo processInfo, BashProcessLogChunk logs) {\n        StringBuilder builder = new StringBuilder(renderProcessStatusOutput(processInfo));\n        String content = logs == null ? null : logs.getContent();\n        if (!isBlank(content)) {\n            builder.append('\\n').append('\\n').append(\"process logs:\\n\").append(content.trim());\n        }\n        return builder.toString().trim();\n    }\n\n    private static void appendProcessSummary(StringBuilder builder, BashProcessInfo processInfo) {\n        if (builder == null || processInfo == null) {\n            return;\n        }\n        builder.append(\"- id=\").append(firstNonBlank(processInfo.getProcessId(), \"(process)\")).append('\\n');\n        builder.append(\"- status=\").append(processInfo.getStatus())\n                .append(\", mode=\").append(processInfo.isControlAvailable() ? \"live\" : \"metadata-only\")\n                .append(\", restored=\").append(processInfo.isRestored()).append('\\n');\n        builder.append(\"- pid=\").append(processInfo.getPid() == null ? \"(none)\" : processInfo.getPid())\n                .append(\", exitCode=\").append(processInfo.getExitCode() == null ? \"(running)\" : processInfo.getExitCode()).append('\\n');\n        builder.append(\"- cwd=\").append(firstNonBlank(processInfo.getWorkingDirectory(), \"(none)\")).append('\\n');\n        builder.append(\"- command=\").append(firstNonBlank(processInfo.getCommand(), \"(none)\")).append('\\n');\n        builder.append(\"- started=\").append(formatTimestamp(processInfo.getStartedAt()))\n                .append(\", ended=\").append(processInfo.getEndedAt() == null ? \"(running)\" : formatTimestamp(processInfo.getEndedAt()));\n    }\n\n    private static CodingSkillDescriptor findSkill(List<CodingSkillDescriptor> skills, String name) {\n        if (skills == null || isBlank(name)) {\n            return null;\n        }\n        String normalized = name.trim();\n        for (CodingSkillDescriptor skill : skills) {\n            if (skill != null && !isBlank(skill.getName()) && skill.getName().trim().equalsIgnoreCase(normalized)) {\n                return skill;\n            }\n        }\n        return null;\n    }\n\n    private static CodingAgentDefinition findAgent(List<CodingAgentDefinition> definitions, String nameOrToolName) {\n        if (definitions == null || isBlank(nameOrToolName)) {\n            return null;\n        }\n        String normalized = nameOrToolName.trim();\n        for (CodingAgentDefinition definition : definitions) {\n            if (definition == null) {\n                continue;\n            }\n            if ((!isBlank(definition.getName()) && definition.getName().trim().equalsIgnoreCase(normalized))\n                    || (!isBlank(definition.getToolName()) && definition.getToolName().trim().equalsIgnoreCase(normalized))) {\n                return definition;\n            }\n        }\n        return null;\n    }\n\n    private static String renderAllowedTools(CodingAgentDefinition definition) {\n        if (definition == null || definition.getAllowedToolNames() == null || definition.getAllowedToolNames().isEmpty()) {\n            return \"(inherit/all available)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (String toolName : definition.getAllowedToolNames()) {\n            if (isBlank(toolName)) {\n                continue;\n            }\n            if (builder.length() > 0) {\n                builder.append(\", \");\n            }\n            builder.append(toolName.trim());\n        }\n        return builder.length() == 0 ? \"(inherit/all available)\" : builder.toString();\n    }\n\n    private static String renderMcpSummary(CliMcpRuntimeManager runtimeManager) {\n        if (runtimeManager == null || !runtimeManager.hasStatuses()) {\n            return null;\n        }\n        int connected = 0;\n        int errors = 0;\n        int paused = 0;\n        int disabled = 0;\n        int missing = 0;\n        int tools = 0;\n        for (CliMcpStatusSnapshot status : runtimeManager.getStatuses()) {\n            if (status == null) {\n                continue;\n            }\n            tools += Math.max(0, status.getToolCount());\n            if (CliMcpRuntimeManager.STATE_CONNECTED.equals(status.getState())) {\n                connected++;\n            } else if (CliMcpRuntimeManager.STATE_ERROR.equals(status.getState())) {\n                errors++;\n            } else if (CliMcpRuntimeManager.STATE_PAUSED.equals(status.getState())) {\n                paused++;\n            } else if (CliMcpRuntimeManager.STATE_DISABLED.equals(status.getState())) {\n                disabled++;\n            } else if (CliMcpRuntimeManager.STATE_MISSING.equals(status.getState())) {\n                missing++;\n            }\n        }\n        return \"connected \" + connected\n                + \", errors \" + errors\n                + \", paused \" + paused\n                + \", disabled \" + disabled\n                + \", missing \" + missing\n                + \", tools \" + tools;\n    }\n\n    private static String resolveCompactMode(SessionEvent event) {\n        if (event == null || event.getPayload() == null) {\n            return \"unknown\";\n        }\n        Object automatic = event.getPayload().get(\"automatic\");\n        if (automatic instanceof Boolean) {\n            return Boolean.TRUE.equals(automatic) ? \"auto\" : \"manual\";\n        }\n        String summary = firstNonBlank(event.getSummary(), \"\");\n        if (summary.startsWith(\"auto compact\")) {\n            return \"auto\";\n        }\n        if (summary.startsWith(\"manual compact\")) {\n            return \"manual\";\n        }\n        return \"unknown\";\n    }\n\n    private static String formatCompactDelta(Integer before, Integer after) {\n        return defaultInt(before) + \"->\" + defaultInt(after);\n    }\n\n    private static Integer parseLimit(String raw) {\n        String trimmed = trimToNull(raw);\n        if (trimmed == null) {\n            return DEFAULT_EVENT_LIMIT;\n        }\n        try {\n            int parsed = Integer.parseInt(trimmed);\n            return parsed <= 0 ? DEFAULT_EVENT_LIMIT : parsed;\n        } catch (NumberFormatException ignore) {\n            return DEFAULT_EVENT_LIMIT;\n        }\n    }\n\n    private static int defaultInt(Integer value) {\n        return value == null ? 0 : value;\n    }\n\n    private static Integer payloadInt(Map<String, Object> payload, String key) {\n        if (payload == null || key == null) {\n            return null;\n        }\n        Object value = payload.get(key);\n        if (value instanceof Number) {\n            return Integer.valueOf(((Number) value).intValue());\n        }\n        try {\n            return value == null ? null : Integer.valueOf(String.valueOf(value));\n        } catch (Exception ignore) {\n            return null;\n        }\n    }\n\n    private static boolean payloadBoolean(Map<String, Object> payload, String key) {\n        if (payload == null || key == null) {\n            return false;\n        }\n        Object value = payload.get(key);\n        if (value instanceof Boolean) {\n            return Boolean.TRUE.equals(value);\n        }\n        return value != null && \"true\".equalsIgnoreCase(String.valueOf(value));\n    }\n\n    private static String payloadString(Map<String, Object> payload, String key) {\n        if (payload == null || key == null) {\n            return null;\n        }\n        Object value = payload.get(key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static String formatTimestamp(long epochMs) {\n        if (epochMs <= 0L) {\n            return \"(unknown)\";\n        }\n        SimpleDateFormat format = new SimpleDateFormat(\"yyyy-MM-dd HH:mm:ss\", Locale.ROOT);\n        format.setTimeZone(TimeZone.getDefault());\n        return format.format(new Date(epochMs));\n    }\n\n    private static String clip(String value, int maxChars) {\n        String normalized = value == null ? null : value.replace('\\r', ' ').replace('\\n', ' ').trim();\n        if (isBlank(normalized)) {\n            return \"(none)\";\n        }\n        if (normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, Math.max(0, maxChars));\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static List<String> splitWhitespace(String value) {\n        if (isBlank(value)) {\n            return Collections.emptyList();\n        }\n        return Arrays.asList(value.trim().split(\"\\\\s+\"));\n    }\n\n    static final class Context {\n        final ManagedCodingSession session;\n        final CodingSessionManager sessionManager;\n        final CodeCommandOptions options;\n        final CodingCliAgentFactory.PreparedCodingAgent prepared;\n        final CliMcpRuntimeManager mcpRuntimeManager;\n        final RuntimeCommandHandler runtimeCommandHandler;\n\n        Context(ManagedCodingSession session,\n                CodingSessionManager sessionManager,\n                CodeCommandOptions options,\n                CodingCliAgentFactory.PreparedCodingAgent prepared,\n                CliMcpRuntimeManager mcpRuntimeManager) {\n            this(session, sessionManager, options, prepared, mcpRuntimeManager, null);\n        }\n\n        Context(ManagedCodingSession session,\n                CodingSessionManager sessionManager,\n                CodeCommandOptions options,\n                CodingCliAgentFactory.PreparedCodingAgent prepared,\n                CliMcpRuntimeManager mcpRuntimeManager,\n                RuntimeCommandHandler runtimeCommandHandler) {\n            this.session = session;\n            this.sessionManager = sessionManager;\n            this.options = options;\n            this.prepared = prepared;\n            this.mcpRuntimeManager = mcpRuntimeManager;\n            this.runtimeCommandHandler = runtimeCommandHandler;\n        }\n    }\n\n    interface RuntimeCommandHandler {\n\n        String executeProviders() throws Exception;\n\n        String executeProvider(String argument) throws Exception;\n\n        String executeModel(String argument) throws Exception;\n\n        String executeExperimental(String argument) throws Exception;\n    }\n\n    static final class ExecutionResult {\n        private final String output;\n\n        private ExecutionResult(String output) {\n            this.output = output;\n        }\n\n        static ExecutionResult of(String output) {\n            return new ExecutionResult(output);\n        }\n\n        String getOutput() {\n            return output;\n        }\n    }\n\n    private static final class CommandSpec {\n        private final String name;\n        private final String description;\n        private final String inputHint;\n\n        private CommandSpec(String name, String description, String inputHint) {\n            this.name = name;\n            this.description = description;\n            this.inputHint = inputHint;\n        }\n    }\n\n    private static final class ParsedCommand {\n        private final String name;\n        private final String argument;\n\n        private ParsedCommand(String name, String argument) {\n            this.name = name;\n            this.argument = argument;\n        }\n    }\n\n    private enum RuntimeCommand {\n        PROVIDERS,\n        PROVIDER,\n        MODEL,\n        EXPERIMENTAL\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpToolApprovalDecorator.java",
    "content": "package io.github.lnyocly.ai4j.cli.acp;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.runtime.CliToolApprovalDecorator;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class AcpToolApprovalDecorator implements ToolExecutorDecorator {\n\n    public interface PermissionGateway {\n\n        PermissionDecision requestApproval(String toolName,\n                                           AgentToolCall call,\n                                           Map<String, Object> rawInput) throws Exception;\n    }\n\n    public static final class PermissionDecision {\n\n        private final boolean approved;\n        private final String optionId;\n\n        public PermissionDecision(boolean approved, String optionId) {\n            this.approved = approved;\n            this.optionId = optionId;\n        }\n\n        public boolean isApproved() {\n            return approved;\n        }\n\n        public String getOptionId() {\n            return optionId;\n        }\n    }\n\n    private final ApprovalMode approvalMode;\n    private final PermissionGateway permissionGateway;\n\n    public AcpToolApprovalDecorator(ApprovalMode approvalMode, PermissionGateway permissionGateway) {\n        this.approvalMode = approvalMode == null ? ApprovalMode.AUTO : approvalMode;\n        this.permissionGateway = permissionGateway;\n    }\n\n    @Override\n    public ToolExecutor decorate(final String toolName, final ToolExecutor delegate) {\n        if (delegate == null || approvalMode == ApprovalMode.AUTO) {\n            return delegate;\n        }\n        return new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) throws Exception {\n                if (requiresApproval(toolName, call)) {\n                    requestApproval(toolName, call);\n                }\n                return delegate.execute(call);\n            }\n        };\n    }\n\n    private boolean requiresApproval(String toolName, AgentToolCall call) {\n        if (approvalMode == ApprovalMode.MANUAL) {\n            return true;\n        }\n        if (CodingToolNames.APPLY_PATCH.equals(toolName)) {\n            return true;\n        }\n        if (!CodingToolNames.BASH.equals(toolName)) {\n            return false;\n        }\n        JSONObject arguments = parseArguments(call == null ? null : call.getArguments());\n        String action = arguments.getString(\"action\");\n        if (isBlank(action)) {\n            action = \"exec\";\n        }\n        return \"exec\".equals(action) || \"start\".equals(action) || \"stop\".equals(action) || \"write\".equals(action);\n    }\n\n    private void requestApproval(String toolName, AgentToolCall call) throws Exception {\n        if (permissionGateway == null) {\n            throw new IllegalStateException(\"Tool call requires approval but no ACP permission gateway is available\");\n        }\n        PermissionDecision decision = permissionGateway.requestApproval(toolName, call, buildRawInput(toolName, call));\n        if (decision == null || !decision.isApproved()) {\n            throw new IllegalStateException(buildApprovalRejectedMessage(toolName, call, decision == null ? null : decision.getOptionId()));\n        }\n    }\n\n    private Map<String, Object> buildRawInput(String toolName, AgentToolCall call) {\n        Map<String, Object> rawInput = new LinkedHashMap<String, Object>();\n        rawInput.put(\"tool\", firstNonBlank(toolName, call == null ? null : call.getName()));\n        rawInput.put(\"callId\", call == null ? null : call.getCallId());\n        rawInput.put(\"arguments\", parseArguments(call == null ? null : call.getArguments()));\n        return rawInput;\n    }\n\n    private String buildApprovalRejectedMessage(String toolName, AgentToolCall call, String optionId) {\n        return CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX + \" \"\n                + firstNonBlank(optionId, toolName, call == null ? null : call.getName(), \"tool\");\n    }\n\n    private JSONObject parseArguments(String rawArguments) {\n        if (isBlank(rawArguments)) {\n            return new JSONObject();\n        }\n        try {\n            JSONObject arguments = JSON.parseObject(rawArguments);\n            return arguments == null ? new JSONObject() : arguments;\n        } catch (Exception ex) {\n            return new JSONObject();\n        }\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/agent/CliCodingAgentRegistry.java",
    "content": "package io.github.lnyocly.ai4j.cli.agent;\n\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.definition.CodingApprovalMode;\nimport io.github.lnyocly.ai4j.coding.definition.CodingIsolationMode;\nimport io.github.lnyocly.ai4j.coding.definition.CodingMemoryScope;\nimport io.github.lnyocly.ai4j.coding.definition.CodingSessionMode;\nimport io.github.lnyocly.ai4j.coding.definition.StaticCodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.DirectoryStream;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Iterator;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Set;\n\npublic class CliCodingAgentRegistry {\n\n    private final Path workspaceRoot;\n    private final List<String> configuredDirectories;\n\n    public CliCodingAgentRegistry(Path workspaceRoot, List<String> configuredDirectories) {\n        this.workspaceRoot = workspaceRoot == null\n                ? Paths.get(\".\").toAbsolutePath().normalize()\n                : workspaceRoot.toAbsolutePath().normalize();\n        this.configuredDirectories = configuredDirectories == null\n                ? Collections.<String>emptyList()\n                : new ArrayList<String>(configuredDirectories);\n    }\n\n    public CodingAgentDefinitionRegistry loadRegistry() {\n        return new StaticCodingAgentDefinitionRegistry(listDefinitions());\n    }\n\n    public List<CodingAgentDefinition> listDefinitions() {\n        List<CodingAgentDefinition> ordered = new ArrayList<CodingAgentDefinition>();\n        for (Path directory : listRoots()) {\n            loadDirectory(directory, ordered);\n        }\n        return ordered.isEmpty()\n                ? Collections.<CodingAgentDefinition>emptyList()\n                : Collections.unmodifiableList(ordered);\n    }\n\n    public List<Path> listRoots() {\n        LinkedHashSet<Path> roots = new LinkedHashSet<Path>();\n        Path home = homeAgentsDirectory();\n        if (home != null) {\n            roots.add(home);\n        }\n        roots.add(workspaceAgentsDirectory());\n        for (String configuredDirectory : configuredDirectories) {\n            if (isBlank(configuredDirectory)) {\n                continue;\n            }\n            Path root = Paths.get(configuredDirectory.trim());\n            if (!root.isAbsolute()) {\n                root = workspaceRoot.resolve(configuredDirectory.trim());\n            }\n            roots.add(root.toAbsolutePath().normalize());\n        }\n        return new ArrayList<Path>(roots);\n    }\n\n    private void loadDirectory(Path directory, List<CodingAgentDefinition> ordered) {\n        if (directory == null || ordered == null || !Files.isDirectory(directory)) {\n            return;\n        }\n        try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {\n            for (Path file : stream) {\n                if (!hasSupportedExtension(file)) {\n                    continue;\n                }\n                CodingAgentDefinition definition = loadDefinition(file);\n                if (definition != null) {\n                    mergeDefinition(ordered, definition);\n                }\n            }\n        } catch (IOException ignored) {\n        }\n    }\n\n    private CodingAgentDefinition loadDefinition(Path file) {\n        if (file == null || !Files.isRegularFile(file)) {\n            return null;\n        }\n        try {\n            String raw = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);\n            ParsedDefinition parsed = parse(raw);\n            String fallbackName = defaultName(file);\n            String name = firstNonBlank(parsed.meta(\"name\"), fallbackName);\n            String instructions = trimToNull(parsed.body);\n            String description = trimToNull(parsed.meta(\"description\"));\n            if (instructions == null) {\n                instructions = description;\n            }\n            if (isBlank(name) || isBlank(instructions)) {\n                return null;\n            }\n            Set<String> allowedTools = parseAllowedTools(parsed.meta(\"tools\"));\n            CodingIsolationMode isolationMode = parseEnum(\n                    parsed.meta(\"isolationmode\"),\n                    CodingIsolationMode.class,\n                    allowedTools != null && allowedTools.equals(CodingToolNames.readOnlyBuiltIn())\n                            ? CodingIsolationMode.READ_ONLY\n                            : CodingIsolationMode.INHERIT\n            );\n            return CodingAgentDefinition.builder()\n                    .name(name)\n                    .description(description)\n                    .toolName(firstNonBlank(parsed.meta(\"toolname\"), defaultToolName(name)))\n                    .model(trimToNull(parsed.meta(\"model\")))\n                    .instructions(instructions)\n                    .systemPrompt(trimToNull(parsed.meta(\"systemprompt\")))\n                    .allowedToolNames(allowedTools)\n                    .sessionMode(parseEnum(parsed.meta(\"sessionmode\"), CodingSessionMode.class, CodingSessionMode.FORK))\n                    .isolationMode(isolationMode)\n                    .memoryScope(parseEnum(parsed.meta(\"memoryscope\"), CodingMemoryScope.class, CodingMemoryScope.INHERIT))\n                    .approvalMode(parseEnum(parsed.meta(\"approvalmode\"), CodingApprovalMode.class, CodingApprovalMode.INHERIT))\n                    .background(parseBoolean(parsed.meta(\"background\"), false))\n                    .build();\n        } catch (IOException ignored) {\n            return null;\n        }\n    }\n\n    private ParsedDefinition parse(String raw) {\n        String value = raw == null ? \"\" : raw.replace(\"\\r\\n\", \"\\n\");\n        if (!value.startsWith(\"---\\n\")) {\n            return new ParsedDefinition(Collections.<MetadataEntry>emptyList(), value.trim());\n        }\n        int end = value.indexOf(\"\\n---\\n\", 4);\n        if (end < 0) {\n            end = value.indexOf(\"\\n---\", 4);\n        }\n        if (end < 0) {\n            return new ParsedDefinition(Collections.<MetadataEntry>emptyList(), value.trim());\n        }\n        String header = value.substring(4, end).trim();\n        String body = value.substring(Math.min(value.length(), end + 5)).trim();\n        List<MetadataEntry> metadata = new ArrayList<MetadataEntry>();\n        if (!isBlank(header)) {\n            String[] lines = header.split(\"\\n\");\n            for (String line : lines) {\n                String trimmed = trimToNull(line);\n                if (trimmed == null || trimmed.startsWith(\"#\")) {\n                    continue;\n                }\n                int separator = trimmed.indexOf(':');\n                if (separator <= 0) {\n                    continue;\n                }\n                String key = normalizeKey(trimmed.substring(0, separator));\n                String content = trimToNull(trimmed.substring(separator + 1));\n                if (key != null) {\n                    metadata.add(new MetadataEntry(key, stripQuotes(content)));\n                }\n            }\n        }\n        return new ParsedDefinition(metadata, body);\n    }\n\n    private Set<String> parseAllowedTools(String raw) {\n        String value = trimToNull(raw);\n        if (value == null) {\n            return null;\n        }\n        String normalized = normalizeToken(value);\n        if (\"all\".equals(normalized) || \"builtin_all\".equals(normalized) || \"all_built_in\".equals(normalized)) {\n            return CodingToolNames.allBuiltIn();\n        }\n        if (\"read_only\".equals(normalized) || \"readonly\".equals(normalized)) {\n            return CodingToolNames.readOnlyBuiltIn();\n        }\n        if (\"inherit\".equals(normalized) || \"default\".equals(normalized)) {\n            return null;\n        }\n        String flattened = value;\n        if (flattened.startsWith(\"[\") && flattened.endsWith(\"]\") && flattened.length() >= 2) {\n            flattened = flattened.substring(1, flattened.length() - 1);\n        }\n        String[] parts = flattened.split(\",\");\n        LinkedHashSet<String> toolNames = new LinkedHashSet<String>();\n        for (String part : parts) {\n            String token = trimToNull(part);\n            if (token != null) {\n                toolNames.add(token);\n            }\n        }\n        return toolNames.isEmpty() ? null : Collections.unmodifiableSet(toolNames);\n    }\n\n    private <E extends Enum<E>> E parseEnum(String raw, Class<E> type, E defaultValue) {\n        String value = normalizeToken(raw);\n        if (value == null || type == null) {\n            return defaultValue;\n        }\n        for (E constant : type.getEnumConstants()) {\n            if (constant != null && normalizeToken(constant.name()).equals(value)) {\n                return constant;\n            }\n        }\n        return defaultValue;\n    }\n\n    private boolean parseBoolean(String raw, boolean defaultValue) {\n        String value = normalizeToken(raw);\n        if (value == null) {\n            return defaultValue;\n        }\n        if (\"true\".equals(value) || \"yes\".equals(value) || \"on\".equals(value)) {\n            return true;\n        }\n        if (\"false\".equals(value) || \"no\".equals(value) || \"off\".equals(value)) {\n            return false;\n        }\n        return defaultValue;\n    }\n\n    private void mergeDefinition(List<CodingAgentDefinition> ordered, CodingAgentDefinition candidate) {\n        if (ordered == null || candidate == null || isBlank(candidate.getName())) {\n            return;\n        }\n        String name = normalizeToken(candidate.getName());\n        String toolName = normalizeToken(candidate.getToolName());\n        for (Iterator<CodingAgentDefinition> iterator = ordered.iterator(); iterator.hasNext(); ) {\n            CodingAgentDefinition existing = iterator.next();\n            if (existing == null) {\n                iterator.remove();\n                continue;\n            }\n            if (sameKey(name, existing.getName())\n                    || sameKey(toolName, existing.getToolName())\n                    || sameKey(name, existing.getToolName())\n                    || sameKey(toolName, existing.getName())) {\n                iterator.remove();\n            }\n        }\n        ordered.add(candidate);\n    }\n\n    private boolean sameKey(String left, String right) {\n        String normalizedRight = normalizeToken(right);\n        return left != null && normalizedRight != null && left.equals(normalizedRight);\n    }\n\n    private Path workspaceAgentsDirectory() {\n        return workspaceRoot.resolve(\".ai4j\").resolve(\"agents\").toAbsolutePath().normalize();\n    }\n\n    private Path homeAgentsDirectory() {\n        String userHome = System.getProperty(\"user.home\");\n        return isBlank(userHome)\n                ? null\n                : Paths.get(userHome).resolve(\".ai4j\").resolve(\"agents\").toAbsolutePath().normalize();\n    }\n\n    private boolean hasSupportedExtension(Path file) {\n        if (file == null || file.getFileName() == null) {\n            return false;\n        }\n        String name = file.getFileName().toString().toLowerCase(Locale.ROOT);\n        return name.endsWith(\".md\") || name.endsWith(\".txt\") || name.endsWith(\".prompt\");\n    }\n\n    private String defaultName(Path file) {\n        if (file == null || file.getFileName() == null) {\n            return null;\n        }\n        String fileName = file.getFileName().toString();\n        int dot = fileName.lastIndexOf('.');\n        return dot > 0 ? fileName.substring(0, dot) : fileName;\n    }\n\n    private String defaultToolName(String name) {\n        String slug = normalizeToolSegment(name);\n        if (slug == null) {\n            return null;\n        }\n        return slug.startsWith(\"delegate_\") ? slug : \"delegate_\" + slug;\n    }\n\n    private String normalizeToolSegment(String value) {\n        String normalized = normalizeToken(value);\n        if (normalized == null) {\n            return null;\n        }\n        normalized = normalized.replace('-', '_');\n        StringBuilder builder = new StringBuilder();\n        boolean previousUnderscore = false;\n        for (int i = 0; i < normalized.length(); i++) {\n            char current = normalized.charAt(i);\n            boolean allowed = (current >= 'a' && current <= 'z')\n                    || (current >= '0' && current <= '9')\n                    || current == '_';\n            char next = allowed ? current : '_';\n            if (next == '_') {\n                if (!previousUnderscore && builder.length() > 0) {\n                    builder.append(next);\n                }\n                previousUnderscore = true;\n            } else {\n                builder.append(next);\n                previousUnderscore = false;\n            }\n        }\n        String result = builder.toString();\n        while (result.endsWith(\"_\")) {\n            result = result.substring(0, result.length() - 1);\n        }\n        return result.isEmpty() ? null : result;\n    }\n\n    private String normalizeKey(String value) {\n        String normalized = normalizeToken(value);\n        if (normalized == null) {\n            return null;\n        }\n        return normalized.replace(\"-\", \"\").replace(\"_\", \"\");\n    }\n\n    private String normalizeToken(String value) {\n        if (isBlank(value)) {\n            return null;\n        }\n        return value.trim().toLowerCase(Locale.ROOT).replace(' ', '_').replace('-', '_');\n    }\n\n    private String stripQuotes(String value) {\n        if (value == null || value.length() < 2) {\n            return value;\n        }\n        if ((value.startsWith(\"\\\"\") && value.endsWith(\"\\\"\"))\n                || (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n            return value.substring(1, value.length() - 1).trim();\n        }\n        return value;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            String normalized = trimToNull(value);\n            if (normalized != null) {\n                return normalized;\n            }\n        }\n        return null;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static final class ParsedDefinition {\n        private final List<MetadataEntry> metadata;\n        private final String body;\n\n        private ParsedDefinition(List<MetadataEntry> metadata, String body) {\n            this.metadata = metadata == null ? Collections.<MetadataEntry>emptyList() : metadata;\n            this.body = body;\n        }\n\n        private String meta(String key) {\n            if (metadata.isEmpty() || key == null) {\n                return null;\n            }\n            String normalized = key.trim().toLowerCase(Locale.ROOT);\n            for (MetadataEntry entry : metadata) {\n                if (entry != null && normalized.equals(entry.key)) {\n                    return entry.value;\n                }\n            }\n            return null;\n        }\n    }\n\n    private static final class MetadataEntry {\n        private final String key;\n        private final String value;\n\n        private MetadataEntry(String key, String value) {\n            this.key = key;\n            this.value = value;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CodeCommand.java",
    "content": "package io.github.lnyocly.ai4j.cli.command;\n\nimport io.github.lnyocly.ai4j.cli.render.CliAnsi;\nimport io.github.lnyocly.ai4j.cli.CliUiMode;\nimport io.github.lnyocly.ai4j.cli.SlashCommandController;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliTuiFactory;\nimport io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliTuiFactory;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\nimport io.github.lnyocly.ai4j.cli.runtime.CodingCliSessionRunner;\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.FileCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.FileSessionEventStore;\nimport io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore;\nimport io.github.lnyocly.ai4j.cli.shell.JlineCodeCommandRunner;\nimport io.github.lnyocly.ai4j.cli.shell.JlineShellContext;\nimport io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO;\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.tui.JlineTerminalIO;\nimport io.github.lnyocly.ai4j.tui.StreamsTerminalIO;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\n\nimport java.io.PrintWriter;\nimport java.io.StringWriter;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Properties;\n\npublic class CodeCommand {\n\n    private enum InteractiveBackend {\n        LEGACY,\n        JLINE\n    }\n\n    private final CodingCliAgentFactory agentFactory;\n    private final CodingCliTuiFactory tuiFactory;\n    private final Map<String, String> env;\n    private final Properties properties;\n    private final Path currentDirectory;\n    private final CodeCommandOptionsParser parser = new CodeCommandOptionsParser();\n\n    public CodeCommand(CodingCliAgentFactory agentFactory,\n                       Map<String, String> env,\n                       Properties properties,\n                       Path currentDirectory) {\n        this(agentFactory, new DefaultCodingCliTuiFactory(), env, properties, currentDirectory);\n    }\n\n    public CodeCommand(CodingCliAgentFactory agentFactory,\n                       CodingCliTuiFactory tuiFactory,\n                       Map<String, String> env,\n                       Properties properties,\n                       Path currentDirectory) {\n        this.agentFactory = agentFactory;\n        this.tuiFactory = tuiFactory == null ? new DefaultCodingCliTuiFactory() : tuiFactory;\n        this.env = env;\n        this.properties = properties;\n        this.currentDirectory = currentDirectory;\n    }\n\n    public int run(List<String> args, TerminalIO terminal) {\n        CodeCommandOptions options = null;\n        TerminalIO runtimeTerminal = terminal;\n        JlineShellContext shellContext = null;\n        SlashCommandController slashCommandController = null;\n        try {\n            options = parser.parse(args, env, properties, currentDirectory);\n            if (options.isHelp()) {\n                printHelp(terminal);\n                return 0;\n            }\n            CodingSessionManager sessionManager = createSessionManager(options);\n            InteractiveBackend interactiveBackend = resolveInteractiveBackend(options, terminal);\n            if (interactiveBackend == InteractiveBackend.JLINE) {\n                closeQuietly(terminal);\n                slashCommandController = new SlashCommandController(\n                        new CustomCommandRegistry(Paths.get(options.getWorkspace())),\n                        new io.github.lnyocly.ai4j.tui.TuiConfigManager(Paths.get(options.getWorkspace()))\n                );\n                shellContext = JlineShellContext.openSystem(slashCommandController);\n                runtimeTerminal = new JlineShellTerminalIO(shellContext, slashCommandController);\n            }\n            TuiInteractionState interactionState = new TuiInteractionState();\n            CodingCliAgentFactory.PreparedCodingAgent prepared = agentFactory.prepare(\n                    options,\n                    runtimeTerminal,\n                    interactionState,\n                    java.util.Collections.<String>emptySet()\n            );\n            printMcpStartupWarnings(runtimeTerminal, prepared.getMcpRuntimeManager());\n            CodingAgent agent = prepared.getAgent();\n            if (interactiveBackend == InteractiveBackend.JLINE) {\n                JlineCodeCommandRunner runner = new JlineCodeCommandRunner(\n                        agent,\n                        prepared.getProtocol(),\n                        prepared.getMcpRuntimeManager(),\n                        options,\n                        runtimeTerminal,\n                        sessionManager,\n                        interactionState,\n                        shellContext,\n                        slashCommandController,\n                        agentFactory,\n                        env,\n                        properties\n                );\n                shellContext = null;\n                return runner.runCommand();\n            }\n            CodingCliSessionRunner runner = new CodingCliSessionRunner(agent,\n                    prepared.getProtocol(),\n                    options,\n                    runtimeTerminal,\n                    sessionManager,\n                    interactionState,\n                    tuiFactory,\n                    null,\n                    agentFactory,\n                    env,\n                    properties);\n            runner.setMcpRuntimeManager(prepared.getMcpRuntimeManager());\n            return runner.run();\n        } catch (IllegalArgumentException ex) {\n            terminal.errorln(\"Argument error: \" + ex.getMessage());\n            printHelp(terminal);\n            return 2;\n        } catch (Exception ex) {\n            terminal.errorln(\"CLI failed: \" + safeMessage(ex));\n            if (options != null && options.isVerbose()) {\n                terminal.errorln(stackTraceOf(ex));\n            }\n            return 1;\n        } finally {\n            if (shellContext != null) {\n                try {\n                    shellContext.close();\n                } catch (Exception ignored) {\n                }\n            }\n        }\n    }\n\n    private void printHelp(TerminalIO terminal) {\n        terminal.println(\"ai4j-cli code\");\n        terminal.println(\"  Start a minimal coding session in one-shot or interactive REPL mode.\\n\");\n        terminal.println(\"Usage:\");\n        terminal.println(\"  ai4j-cli code --model <model> [options]\");\n        terminal.println(\"  ai4j-cli code --model <model> --prompt \\\"Fix the failing tests in this workspace\\\"\\n\");\n        terminal.println(\"Core options:\");\n        terminal.println(\"  --ui <cli|tui>                     Interaction mode, default: cli\");\n        terminal.println(\"  --provider <name>                  Provider name, default: openai\");\n        terminal.println(\"  --protocol <chat|responses>       Protocol family; provider default is used when omitted\");\n        terminal.println(\"  --model <name>                     Model name, required unless config provides one\");\n        terminal.println(\"  --api-key <key>                    API key, or use env/config provider profiles\");\n        terminal.println(\"  --base-url <url>                   Custom baseUrl for OpenAI-compatible providers\");\n        terminal.println(\"  --workspace <path>                 Workspace root, default: current directory\");\n        terminal.println(\"  --workspace-description <text>     Extra workspace description\");\n        terminal.println(\"  --prompt <text>                    One-shot mode; omit to enter interactive mode\");\n        terminal.println(\"  --system <text>                    Additional system prompt\");\n        terminal.println(\"  --instructions <text>              Additional instructions\");\n        terminal.println(\"  --theme <name>                     TUI theme name or override\");\n        terminal.println(\"  --approval <auto|safe|manual>     Tool approval strategy, default: auto\");\n        terminal.println(\"  --session-id <id>                  Use a fixed session id for a new session\");\n        terminal.println(\"  --resume <id>                      Resume a saved session\");\n        terminal.println(\"  --fork <id>                        Fork a saved session into a new branch\");\n        terminal.println(\"  --no-session                       Keep session state in memory only\");\n        terminal.println(\"  --session-dir <path>               Session store directory, default: <workspace>/.ai4j/sessions\\n\");\n        terminal.println(\"Advanced options:\");\n        terminal.println(\"  --max-steps <n>                    Default: unlimited (0)\");\n        terminal.println(\"  --temperature <n>\");\n        terminal.println(\"  --top-p <n>\");\n        terminal.println(\"  --max-output-tokens <n>\");\n        terminal.println(\"  --parallel-tool-calls <bool>       Default: false\");\n        terminal.println(\"  --auto-save-session <bool>         Default: true\");\n        terminal.println(\"  --auto-compact <bool>              Default: true\");\n        terminal.println(\"  --compact-context-window-tokens <n>  Default: 128000\");\n        terminal.println(\"  --compact-reserve-tokens <n>       Default: 16384\");\n        terminal.println(\"  --compact-keep-recent-tokens <n>   Default: 20000\");\n        terminal.println(\"  --compact-summary-max-output-tokens <n>  Default: 400\");\n        terminal.println(\"  --allow-outside-workspace          Allow explicit paths outside the workspace\");\n        terminal.println(\"  --verbose                          Enable detailed CLI logs\\n\");\n        terminal.println(\"Environment variables:\");\n        terminal.println(\"  AI4J_UI, AI4J_PROVIDER, AI4J_PROTOCOL, AI4J_MODEL, AI4J_API_KEY, AI4J_BASE_URL\");\n        terminal.println(\"  AI4J_WORKSPACE, AI4J_SYSTEM_PROMPT, AI4J_INSTRUCTIONS, AI4J_PROMPT\");\n        terminal.println(\"  AI4J_SESSION_ID, AI4J_RESUME_SESSION, AI4J_FORK_SESSION, AI4J_NO_SESSION, AI4J_SESSION_DIR, AI4J_THEME, AI4J_APPROVAL, AI4J_AUTO_SAVE_SESSION\");\n        terminal.println(\"  AI4J_AUTO_COMPACT, AI4J_COMPACT_CONTEXT_WINDOW_TOKENS, AI4J_COMPACT_RESERVE_TOKENS\");\n        terminal.println(\"  AI4J_COMPACT_KEEP_RECENT_TOKENS, AI4J_COMPACT_SUMMARY_MAX_OUTPUT_TOKENS\");\n        terminal.println(\"  Provider-specific keys also work, for example ZHIPU_API_KEY / OPENAI_API_KEY\\n\");\n        terminal.println(\"Interactive commands:\");\n        terminal.println(\"  /help    Show in-session help\");\n        terminal.println(\"  /status  Show current session status\");\n        terminal.println(\"  /session Show current session metadata\");\n        terminal.println(\"  /theme [name]  Show or switch the active TUI theme\");\n        terminal.println(\"  /save    Persist the current session state\");\n        terminal.println(\"  /providers  List saved provider profiles\");\n        terminal.println(\"  /provider  Show current provider/profile state\");\n        terminal.println(\"  /provider use <name>  Switch workspace to a saved provider profile\");\n        terminal.println(\"  /provider save <name>  Save the current runtime as a provider profile\");\n        terminal.println(\"  /provider add <name> [options]  Create a provider profile from explicit fields\");\n        terminal.println(\"  /provider edit <name> [options]  Update a saved provider profile\");\n        terminal.println(\"  /provider default <name|clear>  Set or clear the global default profile\");\n        terminal.println(\"  /provider remove <name>  Delete a saved provider profile\");\n        terminal.println(\"  /model  Show current model/profile state\");\n        terminal.println(\"  /model <name>  Save a workspace model override\");\n        terminal.println(\"  /model reset  Clear the workspace model override\");\n        terminal.println(\"  /skills [name]  List discovered coding skills or inspect one skill\");\n        terminal.println(\"  /commands  List custom command templates\");\n        terminal.println(\"  /palette  Alias of /commands\");\n        terminal.println(\"  /cmd <name> [args]  Run a custom command template\");\n        terminal.println(\"  /sessions  List saved sessions in the current store\");\n        terminal.println(\"  /history [id]  Show session lineage from root to target\");\n        terminal.println(\"  /tree [id]     Show the saved session tree\");\n        terminal.println(\"  /events [n]  Show the latest session ledger events\");\n        terminal.println(\"  /replay [n]  Replay recent turns from the event ledger\");\n        terminal.println(\"  /team  Show the current agent team board by member lane\");\n        terminal.println(\"  /team list|status [team-id]|messages [team-id] [limit]|resume [team-id]  Manage persisted team snapshots\");\n        terminal.println(\"  /stream [on|off]  Show or switch model request streaming\");\n        terminal.println(\"  /processes  List active and restored process metadata\");\n        terminal.println(\"  /process status <id>  Show metadata for one process\");\n        terminal.println(\"  /process follow <id> [limit]  Show metadata with buffered logs\");\n        terminal.println(\"  /process logs <id> [limit]  Read buffered process logs\");\n        terminal.println(\"  /process write <id> <text>  Write to process stdin\");\n        terminal.println(\"  /process stop <id>  Stop a live process\");\n        terminal.println(\"  /checkpoint  Show the current structured checkpoint summary\");\n        terminal.println(\"  /resume <id>  Resume a saved session\");\n        terminal.println(\"  /load <id>    Alias of /resume\");\n        terminal.println(\"  /fork [new-id] or /fork <source-id> <new-id>  Fork a session branch\");\n        terminal.println(\"  /compact [summary]  Compact current session memory\");\n        terminal.println(\"  /clear   Print a new screen section\");\n        terminal.println(\"  /exit    Exit the session\");\n        terminal.println(\"  /quit    Exit the session\");\n        terminal.println(\"  TUI keys: / opens command list, Tab accepts completion, Ctrl+P palette, Ctrl+R replay, /team opens the team board, Enter submit, Esc interrupts an active raw-TUI turn or clears input\\n\");\n        terminal.println(\"Examples:\");\n        terminal.println(\"  ai4j-cli code --provider zhipu --protocol chat --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --workspace .\");\n        terminal.println(\"  ai4j-cli code --ui tui --provider zhipu --protocol chat --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --workspace .\");\n        terminal.println(\"  ai4j-cli code --provider openai --protocol responses --model gpt-5-mini --prompt \\\"Read README and summarize the project structure\\\"\");\n        terminal.println(\"  ai4j-cli code --provider openai --base-url https://api.deepseek.com --protocol chat --model deepseek-chat\");\n    }\n\n    private void printMcpStartupWarnings(TerminalIO terminal, CliMcpRuntimeManager runtimeManager) {\n        if (terminal == null || runtimeManager == null) {\n            return;\n        }\n        List<String> warnings = runtimeManager.buildStartupWarnings();\n        if (warnings == null || warnings.isEmpty()) {\n            return;\n        }\n        boolean ansi = terminal.supportsAnsi();\n        for (String warning : warnings) {\n            terminal.println(CliAnsi.warning(\"Warning: \" + safeMessage(warning), ansi));\n        }\n    }\n\n    private String safeMessage(Throwable throwable) {\n        String message = throwable == null ? null : throwable.getMessage();\n        return message == null || message.trim().isEmpty() ? throwable.getClass().getSimpleName() : message;\n    }\n\n    private String safeMessage(String value) {\n        return value == null || value.trim().isEmpty() ? \"unknown warning\" : value.trim();\n    }\n\n    private String stackTraceOf(Throwable throwable) {\n        StringWriter writer = new StringWriter();\n        PrintWriter printWriter = new PrintWriter(writer);\n        throwable.printStackTrace(printWriter);\n        printWriter.flush();\n        return writer.toString();\n    }\n\n    private CodingSessionManager createSessionManager(CodeCommandOptions options) {\n        if (options.isNoSession()) {\n            Path directory = Paths.get(options.getWorkspace()).resolve(\".ai4j\").resolve(\"memory-sessions\");\n            return new DefaultCodingSessionManager(\n                    new InMemoryCodingSessionStore(directory),\n                    new InMemorySessionEventStore()\n            );\n        }\n        Path sessionDirectory = Paths.get(options.getSessionStoreDir());\n        return new DefaultCodingSessionManager(\n                new FileCodingSessionStore(sessionDirectory),\n                new FileSessionEventStore(sessionDirectory.resolve(\"events\"))\n        );\n    }\n\n    private InteractiveBackend resolveInteractiveBackend(CodeCommandOptions options, TerminalIO terminal) {\n        if (options == null || terminal == null) {\n            return InteractiveBackend.LEGACY;\n        }\n        if (options.getUiMode() != CliUiMode.TUI || !isBlank(options.getPrompt())) {\n            return InteractiveBackend.LEGACY;\n        }\n        if (!(terminal instanceof JlineTerminalIO)) {\n            return InteractiveBackend.LEGACY;\n        }\n        String backend = firstNonBlank(\n                System.getProperty(\"ai4j.tui.backend\"),\n                env == null ? null : env.get(\"AI4J_TUI_BACKEND\")\n        );\n        if (isBlank(backend)) {\n            return InteractiveBackend.JLINE;\n        }\n        String normalized = backend.trim().toLowerCase();\n        if (\"legacy\".equals(normalized) || \"append-only\".equals(normalized)) {\n            return InteractiveBackend.LEGACY;\n        }\n        return InteractiveBackend.JLINE;\n    }\n\n    private void closeQuietly(TerminalIO terminal) {\n        if (terminal == null) {\n            return;\n        }\n        try {\n            terminal.close();\n        } catch (Exception ignored) {\n        }\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CodeCommandOptions.java",
    "content": "package io.github.lnyocly.ai4j.cli.command;\n\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.CliUiMode;\nimport io.github.lnyocly.ai4j.service.PlatformType;\n\npublic final class CodeCommandOptions {\n\n    private static final ApprovalMode DEFAULT_APPROVAL_MODE = ApprovalMode.AUTO;\n    private static final boolean DEFAULT_NO_SESSION = true;\n    private static final boolean DEFAULT_AUTO_SAVE_SESSION = true;\n    private static final boolean DEFAULT_AUTO_COMPACT = true;\n    private static final int DEFAULT_COMPACT_CONTEXT_WINDOW_TOKENS = 128000;\n    private static final int DEFAULT_COMPACT_RESERVE_TOKENS = 16384;\n    private static final int DEFAULT_COMPACT_KEEP_RECENT_TOKENS = 20000;\n    private static final int DEFAULT_COMPACT_SUMMARY_MAX_OUTPUT_TOKENS = 400;\n    private static final boolean DEFAULT_STREAM = true;\n\n    private final boolean help;\n    private final CliUiMode uiMode;\n    private final PlatformType provider;\n    private final CliProtocol protocol;\n    private final String model;\n    private final String apiKey;\n    private final String baseUrl;\n    private final String workspace;\n    private final String workspaceDescription;\n    private final String systemPrompt;\n    private final String instructions;\n    private final String prompt;\n    private final int maxSteps;\n    private final Double temperature;\n    private final Double topP;\n    private final Integer maxOutputTokens;\n    private final Boolean parallelToolCalls;\n    private final boolean allowOutsideWorkspace;\n    private final String sessionId;\n    private final String resumeSessionId;\n    private final String forkSessionId;\n    private final String sessionStoreDir;\n    private final String theme;\n    private final ApprovalMode approvalMode;\n    private final boolean noSession;\n    private final boolean autoSaveSession;\n    private final boolean autoCompact;\n    private final int compactContextWindowTokens;\n    private final int compactReserveTokens;\n    private final int compactKeepRecentTokens;\n    private final int compactSummaryMaxOutputTokens;\n    private final boolean stream;\n    private final boolean verbose;\n\n    public CodeCommandOptions(boolean help,\n                              CliUiMode uiMode,\n                              PlatformType provider,\n                              CliProtocol protocol,\n                              String model,\n                              String apiKey,\n                              String baseUrl,\n                              String workspace,\n                              String workspaceDescription,\n                              String systemPrompt,\n                              String instructions,\n                              String prompt,\n                              int maxSteps,\n                              Double temperature,\n                              Double topP,\n                              Integer maxOutputTokens,\n                              Boolean parallelToolCalls,\n                              boolean allowOutsideWorkspace,\n                              boolean verbose) {\n        this(help,\n                uiMode,\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                workspaceDescription,\n                systemPrompt,\n                instructions,\n                prompt,\n                maxSteps,\n                temperature,\n                topP,\n                maxOutputTokens,\n                parallelToolCalls,\n                allowOutsideWorkspace,\n                null,\n                null,\n                null,\n                null,\n                null,\n                DEFAULT_APPROVAL_MODE,\n                DEFAULT_NO_SESSION,\n                DEFAULT_AUTO_SAVE_SESSION,\n                DEFAULT_AUTO_COMPACT,\n                DEFAULT_COMPACT_CONTEXT_WINDOW_TOKENS,\n                DEFAULT_COMPACT_RESERVE_TOKENS,\n                DEFAULT_COMPACT_KEEP_RECENT_TOKENS,\n                DEFAULT_COMPACT_SUMMARY_MAX_OUTPUT_TOKENS,\n                DEFAULT_STREAM,\n                verbose);\n    }\n\n    public CodeCommandOptions(boolean help,\n                              CliUiMode uiMode,\n                              PlatformType provider,\n                              CliProtocol protocol,\n                              String model,\n                              String apiKey,\n                              String baseUrl,\n                              String workspace,\n                              String workspaceDescription,\n                              String systemPrompt,\n                              String instructions,\n                              String prompt,\n                              int maxSteps,\n                              Double temperature,\n                              Double topP,\n                              Integer maxOutputTokens,\n                              Boolean parallelToolCalls,\n                              boolean allowOutsideWorkspace,\n                              String sessionId,\n                              String resumeSessionId,\n                              String sessionStoreDir,\n                              String theme,\n                              boolean autoSaveSession,\n                              boolean autoCompact,\n                              int compactContextWindowTokens,\n                              int compactReserveTokens,\n                              int compactKeepRecentTokens,\n                              int compactSummaryMaxOutputTokens,\n                              boolean verbose) {\n        this(help,\n                uiMode,\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                workspaceDescription,\n                systemPrompt,\n                instructions,\n                prompt,\n                maxSteps,\n                temperature,\n                topP,\n                maxOutputTokens,\n                parallelToolCalls,\n                allowOutsideWorkspace,\n                sessionId,\n                resumeSessionId,\n                null,\n                sessionStoreDir,\n                theme,\n                DEFAULT_APPROVAL_MODE,\n                DEFAULT_NO_SESSION,\n                autoSaveSession,\n                autoCompact,\n                compactContextWindowTokens,\n                compactReserveTokens,\n                compactKeepRecentTokens,\n                compactSummaryMaxOutputTokens,\n                false,\n                verbose);\n    }\n\n    public CodeCommandOptions(boolean help,\n                              CliUiMode uiMode,\n                              PlatformType provider,\n                              CliProtocol protocol,\n                              String model,\n                              String apiKey,\n                              String baseUrl,\n                              String workspace,\n                              String workspaceDescription,\n                              String systemPrompt,\n                              String instructions,\n                              String prompt,\n                              int maxSteps,\n                              Double temperature,\n                              Double topP,\n                              Integer maxOutputTokens,\n                              Boolean parallelToolCalls,\n                              boolean allowOutsideWorkspace,\n                              String sessionId,\n                              String resumeSessionId,\n                              String forkSessionId,\n                              String sessionStoreDir,\n                              String theme,\n                              ApprovalMode approvalMode,\n                              boolean noSession,\n                              boolean autoSaveSession,\n                              boolean autoCompact,\n                              int compactContextWindowTokens,\n                              int compactReserveTokens,\n                              int compactKeepRecentTokens,\n                              int compactSummaryMaxOutputTokens,\n                              boolean verbose) {\n        this(help,\n                uiMode,\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                workspaceDescription,\n                systemPrompt,\n                instructions,\n                prompt,\n                maxSteps,\n                temperature,\n                topP,\n                maxOutputTokens,\n                parallelToolCalls,\n                allowOutsideWorkspace,\n                sessionId,\n                resumeSessionId,\n                forkSessionId,\n                sessionStoreDir,\n                theme,\n                approvalMode,\n                noSession,\n                autoSaveSession,\n                autoCompact,\n                compactContextWindowTokens,\n                compactReserveTokens,\n                compactKeepRecentTokens,\n                compactSummaryMaxOutputTokens,\n                false,\n                verbose);\n    }\n\n    private CodeCommandOptions(boolean help,\n                               CliUiMode uiMode,\n                               PlatformType provider,\n                               CliProtocol protocol,\n                               String model,\n                               String apiKey,\n                               String baseUrl,\n                               String workspace,\n                               String workspaceDescription,\n                               String systemPrompt,\n                               String instructions,\n                               String prompt,\n                               int maxSteps,\n                               Double temperature,\n                               Double topP,\n                               Integer maxOutputTokens,\n                               Boolean parallelToolCalls,\n                               boolean allowOutsideWorkspace,\n                               String sessionId,\n                               String resumeSessionId,\n                               String forkSessionId,\n                               String sessionStoreDir,\n                               String theme,\n                               ApprovalMode approvalMode,\n                               boolean noSession,\n                               boolean autoSaveSession,\n                               boolean autoCompact,\n                               int compactContextWindowTokens,\n                               int compactReserveTokens,\n                               int compactKeepRecentTokens,\n                               int compactSummaryMaxOutputTokens,\n                               boolean stream,\n                               boolean verbose) {\n        this.help = help;\n        this.uiMode = uiMode;\n        this.provider = provider;\n        this.protocol = protocol;\n        this.model = model;\n        this.apiKey = apiKey;\n        this.baseUrl = baseUrl;\n        this.workspace = workspace;\n        this.workspaceDescription = workspaceDescription;\n        this.systemPrompt = systemPrompt;\n        this.instructions = instructions;\n        this.prompt = prompt;\n        this.maxSteps = maxSteps;\n        this.temperature = temperature;\n        this.topP = topP;\n        this.maxOutputTokens = maxOutputTokens;\n        this.parallelToolCalls = parallelToolCalls;\n        this.allowOutsideWorkspace = allowOutsideWorkspace;\n        this.sessionId = sessionId;\n        this.resumeSessionId = resumeSessionId;\n        this.forkSessionId = forkSessionId;\n        this.sessionStoreDir = sessionStoreDir;\n        this.theme = theme;\n        this.approvalMode = approvalMode == null ? DEFAULT_APPROVAL_MODE : approvalMode;\n        this.noSession = noSession;\n        this.autoSaveSession = autoSaveSession;\n        this.autoCompact = autoCompact;\n        this.compactContextWindowTokens = compactContextWindowTokens;\n        this.compactReserveTokens = compactReserveTokens;\n        this.compactKeepRecentTokens = compactKeepRecentTokens;\n        this.compactSummaryMaxOutputTokens = compactSummaryMaxOutputTokens;\n        this.stream = stream;\n        this.verbose = verbose;\n    }\n\n    public boolean isHelp() {\n        return help;\n    }\n\n    public CliUiMode getUiMode() {\n        return uiMode;\n    }\n\n    public PlatformType getProvider() {\n        return provider;\n    }\n\n    public CliProtocol getProtocol() {\n        return protocol;\n    }\n\n    public String getModel() {\n        return model;\n    }\n\n    public String getApiKey() {\n        return apiKey;\n    }\n\n    public String getBaseUrl() {\n        return baseUrl;\n    }\n\n    public String getWorkspace() {\n        return workspace;\n    }\n\n    public String getWorkspaceDescription() {\n        return workspaceDescription;\n    }\n\n    public String getSystemPrompt() {\n        return systemPrompt;\n    }\n\n    public String getInstructions() {\n        return instructions;\n    }\n\n    public String getPrompt() {\n        return prompt;\n    }\n\n    public int getMaxSteps() {\n        return maxSteps;\n    }\n\n    public Double getTemperature() {\n        return temperature;\n    }\n\n    public Double getTopP() {\n        return topP;\n    }\n\n    public Integer getMaxOutputTokens() {\n        return maxOutputTokens;\n    }\n\n    public Boolean getParallelToolCalls() {\n        return parallelToolCalls;\n    }\n\n    public boolean isAllowOutsideWorkspace() {\n        return allowOutsideWorkspace;\n    }\n\n    public String getSessionId() {\n        return sessionId;\n    }\n\n    public String getResumeSessionId() {\n        return resumeSessionId;\n    }\n\n    public String getForkSessionId() {\n        return forkSessionId;\n    }\n\n    public String getSessionStoreDir() {\n        return sessionStoreDir;\n    }\n\n    public String getTheme() {\n        return theme;\n    }\n\n    public ApprovalMode getApprovalMode() {\n        return approvalMode;\n    }\n\n    public boolean isNoSession() {\n        return noSession;\n    }\n\n    public boolean isAutoSaveSession() {\n        return autoSaveSession;\n    }\n\n    public boolean isAutoCompact() {\n        return autoCompact;\n    }\n\n    public int getCompactContextWindowTokens() {\n        return compactContextWindowTokens;\n    }\n\n    public int getCompactReserveTokens() {\n        return compactReserveTokens;\n    }\n\n    public int getCompactKeepRecentTokens() {\n        return compactKeepRecentTokens;\n    }\n\n    public int getCompactSummaryMaxOutputTokens() {\n        return compactSummaryMaxOutputTokens;\n    }\n\n    public boolean isStream() {\n        return stream;\n    }\n\n    public boolean isVerbose() {\n        return verbose;\n    }\n\n    public CodeCommandOptions withRuntime(PlatformType provider,\n                                          CliProtocol protocol,\n                                          String model,\n                                          String apiKey,\n                                          String baseUrl) {\n        return copy(\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                sessionId,\n                resumeSessionId,\n                sessionStoreDir,\n                approvalMode,\n                stream\n        );\n    }\n\n    public CodeCommandOptions withStream(boolean stream) {\n        return copy(\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                sessionId,\n                resumeSessionId,\n                sessionStoreDir,\n                approvalMode,\n                stream\n        );\n    }\n\n    public CodeCommandOptions withApprovalMode(ApprovalMode approvalMode) {\n        return copy(\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                sessionId,\n                resumeSessionId,\n                sessionStoreDir,\n                approvalMode,\n                stream\n        );\n    }\n\n    public CodeCommandOptions withSessionContext(String workspace,\n                                                 String sessionId,\n                                                 String resumeSessionId,\n                                                 String sessionStoreDir) {\n        return copy(\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                sessionId,\n                resumeSessionId,\n                sessionStoreDir,\n                approvalMode,\n                stream\n        );\n    }\n\n    private CodeCommandOptions copy(PlatformType provider,\n                                    CliProtocol protocol,\n                                    String model,\n                                    String apiKey,\n                                    String baseUrl,\n                                    String workspace,\n                                    String sessionId,\n                                    String resumeSessionId,\n                                    String sessionStoreDir,\n                                    ApprovalMode approvalMode,\n                                    boolean stream) {\n        return new CodeCommandOptions(\n                help,\n                uiMode,\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                workspaceDescription,\n                systemPrompt,\n                instructions,\n                prompt,\n                maxSteps,\n                temperature,\n                topP,\n                maxOutputTokens,\n                parallelToolCalls,\n                allowOutsideWorkspace,\n                sessionId,\n                resumeSessionId,\n                forkSessionId,\n                sessionStoreDir,\n                theme,\n                approvalMode == null ? this.approvalMode : approvalMode,\n                noSession,\n                autoSaveSession,\n                autoCompact,\n                compactContextWindowTokens,\n                compactReserveTokens,\n                compactKeepRecentTokens,\n                compactSummaryMaxOutputTokens,\n                stream,\n                verbose\n        );\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CodeCommandOptionsParser.java",
    "content": "package io.github.lnyocly.ai4j.cli.command;\n\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.CliUiMode;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager;\nimport io.github.lnyocly.ai4j.cli.provider.CliResolvedProviderConfig;\nimport io.github.lnyocly.ai4j.service.PlatformType;\n\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Properties;\n\npublic class CodeCommandOptionsParser {\n\n    public CodeCommandOptions parse(List<String> args,\n                                    Map<String, String> env,\n                                    Properties properties,\n                                    Path currentDirectory) {\n        Map<String, String> values = new LinkedHashMap<String, String>();\n        boolean help = false;\n        boolean allowOutsideWorkspace = false;\n        boolean noSession = false;\n        boolean noSessionSpecified = false;\n        boolean verbose = false;\n\n        if (args != null) {\n            for (int i = 0; i < args.size(); i++) {\n                String arg = args.get(i);\n                if (\"-h\".equals(arg) || \"--help\".equals(arg)) {\n                    help = true;\n                    continue;\n                }\n                if (!arg.startsWith(\"--\")) {\n                    throw new IllegalArgumentException(\"Unsupported argument: \" + arg);\n                }\n\n                String option = arg.substring(2);\n                String value = null;\n                int equalsIndex = option.indexOf('=');\n                if (equalsIndex >= 0) {\n                    value = option.substring(equalsIndex + 1);\n                    option = option.substring(0, equalsIndex);\n                }\n\n                if (\"allow-outside-workspace\".equals(option)) {\n                    if (value == null && i + 1 < args.size() && !args.get(i + 1).startsWith(\"--\")) {\n                        value = args.get(++i);\n                    }\n                    allowOutsideWorkspace = value == null || parseBoolean(value);\n                    continue;\n                }\n                if (\"no-session\".equals(option)) {\n                    if (value == null && i + 1 < args.size() && !args.get(i + 1).startsWith(\"--\")) {\n                        value = args.get(++i);\n                    }\n                    noSession = value == null || parseBoolean(value);\n                    noSessionSpecified = true;\n                    continue;\n                }\n                if (\"verbose\".equals(option)) {\n                    if (value == null && i + 1 < args.size() && !args.get(i + 1).startsWith(\"--\")) {\n                        value = args.get(++i);\n                    }\n                    verbose = value == null || parseBoolean(value);\n                    continue;\n                }\n\n                if (value == null) {\n                    if (i + 1 >= args.size() || args.get(i + 1).startsWith(\"--\")) {\n                        throw new IllegalArgumentException(\"Missing value for --\" + option);\n                    }\n                    value = args.get(++i);\n                }\n                values.put(normalizeOptionName(option), value);\n            }\n        }\n\n        if (help) {\n            return new CodeCommandOptions(\n                    true,\n                    resolveUiMode(values, env, properties, CliUiMode.CLI),\n                    PlatformType.OPENAI,\n                    CliProtocol.defaultProtocol(PlatformType.OPENAI, null),\n                    null,\n                    null,\n                    null,\n                    currentDirectory.toAbsolutePath().normalize().toString(),\n                    null,\n                    null,\n                    null,\n                    null,\n                    0,\n                    null,\n                    null,\n                    null,\n                    Boolean.FALSE,\n                    allowOutsideWorkspace,\n                    null,\n                    null,\n                    null,\n                    null,\n                    true,\n                    true,\n                    128000,\n                    16384,\n                    20000,\n                    400,\n                    verbose\n            );\n        }\n\n        CliUiMode uiMode = resolveUiMode(values, env, properties, CliUiMode.CLI);\n        String cliProtocolOverride = values.get(\"protocol\");\n        if (\"auto\".equalsIgnoreCase(trimToNull(cliProtocolOverride))) {\n            throw new IllegalArgumentException(\"Unsupported protocol: auto. Expected: chat, responses\");\n        }\n        String workspace = firstNonBlank(\n                values.get(\"workspace\"),\n                envValue(env, \"AI4J_WORKSPACE\"),\n                propertyValue(properties, \"ai4j.workspace\"),\n                currentDirectory.toAbsolutePath().normalize().toString()\n        );\n        CliProviderConfigManager providerConfigManager = new CliProviderConfigManager(Paths.get(workspace));\n        CliResolvedProviderConfig resolvedProviderConfig = providerConfigManager.resolve(\n                values.get(\"provider\"),\n                values.get(\"protocol\"),\n                values.get(\"model\"),\n                values.get(\"api-key\"),\n                values.get(\"base-url\"),\n                env,\n                properties\n        );\n        PlatformType provider = resolvedProviderConfig.getProvider();\n        String model = resolvedProviderConfig.getModel();\n        if (isBlank(model)) {\n            throw new IllegalArgumentException(\"model is required\");\n        }\n\n        CliProtocol protocol = resolvedProviderConfig.getProtocol();\n\n        String sessionId = firstNonBlank(\n                values.get(\"session-id\"),\n                envValue(env, \"AI4J_SESSION_ID\"),\n                propertyValue(properties, \"ai4j.session.id\")\n        );\n\n        String resumeSessionId = firstNonBlank(\n                values.get(\"resume\"),\n                values.get(\"load\"),\n                envValue(env, \"AI4J_RESUME_SESSION\"),\n                propertyValue(properties, \"ai4j.session.resume\")\n        );\n\n        String forkSessionId = firstNonBlank(\n                values.get(\"fork\"),\n                envValue(env, \"AI4J_FORK_SESSION\"),\n                propertyValue(properties, \"ai4j.session.fork\")\n        );\n\n        if (!noSessionSpecified) {\n            noSession = parseBooleanOrDefault(firstNonBlank(\n                    envValue(env, \"AI4J_NO_SESSION\"),\n                    propertyValue(properties, \"ai4j.session.no-session\")\n            ), false);\n        }\n\n        String sessionStoreDir = firstNonBlank(\n                values.get(\"session-dir\"),\n                envValue(env, \"AI4J_SESSION_DIR\"),\n                propertyValue(properties, \"ai4j.session.dir\"),\n                Paths.get(workspace).resolve(\".ai4j\").resolve(\"sessions\").toAbsolutePath().normalize().toString()\n        );\n\n        String theme = firstNonBlank(\n                values.get(\"theme\"),\n                envValue(env, \"AI4J_THEME\"),\n                propertyValue(properties, \"ai4j.theme\")\n        );\n\n        ApprovalMode approvalMode = ApprovalMode.parse(firstNonBlank(\n                values.get(\"approval\"),\n                envValue(env, \"AI4J_APPROVAL\"),\n                propertyValue(properties, \"ai4j.approval\"),\n                ApprovalMode.AUTO.getValue()\n        ));\n\n        String apiKey = resolvedProviderConfig.getApiKey();\n\n        String baseUrl = resolvedProviderConfig.getBaseUrl();\n\n        String workspaceDescription = firstNonBlank(\n                values.get(\"workspace-description\"),\n                envValue(env, \"AI4J_WORKSPACE_DESCRIPTION\"),\n                propertyValue(properties, \"ai4j.workspace.description\")\n        );\n\n        String systemPrompt = firstNonBlank(\n                values.get(\"system\"),\n                envValue(env, \"AI4J_SYSTEM_PROMPT\"),\n                propertyValue(properties, \"ai4j.system\")\n        );\n\n        String instructions = firstNonBlank(\n                values.get(\"instructions\"),\n                envValue(env, \"AI4J_INSTRUCTIONS\"),\n                propertyValue(properties, \"ai4j.instructions\")\n        );\n\n        String prompt = firstNonBlank(\n                values.get(\"prompt\"),\n                envValue(env, \"AI4J_PROMPT\"),\n                propertyValue(properties, \"ai4j.prompt\")\n        );\n\n        int maxSteps = parseInteger(firstNonBlank(\n                values.get(\"max-steps\"),\n                envValue(env, \"AI4J_MAX_STEPS\"),\n                propertyValue(properties, \"ai4j.max.steps\"),\n                \"0\"\n        ), \"max-steps\");\n\n        Double temperature = parseDoubleOrNull(firstNonBlank(\n                values.get(\"temperature\"),\n                envValue(env, \"AI4J_TEMPERATURE\"),\n                propertyValue(properties, \"ai4j.temperature\")\n        ), \"temperature\");\n\n        Double topP = parseDoubleOrNull(firstNonBlank(\n                values.get(\"top-p\"),\n                envValue(env, \"AI4J_TOP_P\"),\n                propertyValue(properties, \"ai4j.top.p\")\n        ), \"top-p\");\n\n        Integer maxOutputTokens = parseIntegerOrNull(firstNonBlank(\n                values.get(\"max-output-tokens\"),\n                envValue(env, \"AI4J_MAX_OUTPUT_TOKENS\"),\n                propertyValue(properties, \"ai4j.max.output.tokens\")\n        ), \"max-output-tokens\");\n\n        Boolean parallelToolCalls = parseBooleanOrDefault(firstNonBlank(\n                values.get(\"parallel-tool-calls\"),\n                envValue(env, \"AI4J_PARALLEL_TOOL_CALLS\"),\n                propertyValue(properties, \"ai4j.parallel.tool.calls\")\n        ), false);\n\n        boolean stream = parseBooleanOrDefault(firstNonBlank(\n                values.get(\"stream\"),\n                envValue(env, \"AI4J_STREAM\"),\n                propertyValue(properties, \"ai4j.stream\")\n        ), true);\n\n        boolean autoSaveSession = parseBooleanOrDefault(firstNonBlank(\n                values.get(\"auto-save-session\"),\n                envValue(env, \"AI4J_AUTO_SAVE_SESSION\"),\n                propertyValue(properties, \"ai4j.session.auto-save\")\n        ), true);\n\n        boolean autoCompact = parseBooleanOrDefault(firstNonBlank(\n                values.get(\"auto-compact\"),\n                envValue(env, \"AI4J_AUTO_COMPACT\"),\n                propertyValue(properties, \"ai4j.compact.auto\")\n        ), true);\n\n        int compactContextWindowTokens = parseInteger(firstNonBlank(\n                values.get(\"compact-context-window-tokens\"),\n                envValue(env, \"AI4J_COMPACT_CONTEXT_WINDOW_TOKENS\"),\n                propertyValue(properties, \"ai4j.compact.context-window-tokens\"),\n                \"128000\"\n        ), \"compact-context-window-tokens\");\n\n        int compactReserveTokens = parseInteger(firstNonBlank(\n                values.get(\"compact-reserve-tokens\"),\n                envValue(env, \"AI4J_COMPACT_RESERVE_TOKENS\"),\n                propertyValue(properties, \"ai4j.compact.reserve-tokens\"),\n                \"16384\"\n        ), \"compact-reserve-tokens\");\n\n        int compactKeepRecentTokens = parseInteger(firstNonBlank(\n                values.get(\"compact-keep-recent-tokens\"),\n                envValue(env, \"AI4J_COMPACT_KEEP_RECENT_TOKENS\"),\n                propertyValue(properties, \"ai4j.compact.keep-recent-tokens\"),\n                \"20000\"\n        ), \"compact-keep-recent-tokens\");\n\n        int compactSummaryMaxOutputTokens = parseInteger(firstNonBlank(\n                values.get(\"compact-summary-max-output-tokens\"),\n                envValue(env, \"AI4J_COMPACT_SUMMARY_MAX_OUTPUT_TOKENS\"),\n                propertyValue(properties, \"ai4j.compact.summary-max-output-tokens\"),\n                \"400\"\n        ), \"compact-summary-max-output-tokens\");\n\n        if (!isBlank(resumeSessionId) && !isBlank(forkSessionId)) {\n            throw new IllegalArgumentException(\"--resume and --fork cannot be used together\");\n        }\n        if (noSession && !isBlank(resumeSessionId)) {\n            throw new IllegalArgumentException(\"--no-session cannot be combined with --resume\");\n        }\n        if (noSession && !isBlank(forkSessionId)) {\n            throw new IllegalArgumentException(\"--no-session cannot be combined with --fork\");\n        }\n\n        return new CodeCommandOptions(\n                false,\n                uiMode,\n                provider,\n                protocol,\n                model,\n                apiKey,\n                baseUrl,\n                workspace,\n                workspaceDescription,\n                systemPrompt,\n                instructions,\n                prompt,\n                maxSteps,\n                temperature,\n                topP,\n                maxOutputTokens,\n                parallelToolCalls,\n                allowOutsideWorkspace,\n                sessionId,\n                resumeSessionId,\n                forkSessionId,\n                sessionStoreDir,\n                theme,\n                approvalMode,\n                noSession,\n                autoSaveSession,\n                autoCompact,\n                compactContextWindowTokens,\n                compactReserveTokens,\n                compactKeepRecentTokens,\n                compactSummaryMaxOutputTokens,\n                verbose\n        ).withStream(stream);\n    }\n\n    private CliUiMode resolveUiMode(Map<String, String> values,\n                                    Map<String, String> env,\n                                    Properties properties,\n                                    CliUiMode defaultValue) {\n        return CliUiMode.parse(firstNonBlank(\n                values.get(\"ui\"),\n                envValue(env, \"AI4J_UI\"),\n                propertyValue(properties, \"ai4j.ui\"),\n                defaultValue.getValue()\n        ));\n    }\n\n    private String envValue(Map<String, String> env, String name) {\n        return env == null ? null : env.get(name);\n    }\n\n    private String propertyValue(Properties properties, String name) {\n        return properties == null ? null : properties.getProperty(name);\n    }\n\n    private String normalizeOptionName(String option) {\n        if (option == null) {\n            return null;\n        }\n        return option.trim().toLowerCase(Locale.ROOT);\n    }\n\n    private int parseInteger(String value, String name) {\n        try {\n            return Integer.parseInt(value);\n        } catch (NumberFormatException ex) {\n            throw new IllegalArgumentException(\"Invalid integer for --\" + name + \": \" + value, ex);\n        }\n    }\n\n    private Integer parseIntegerOrNull(String value, String name) {\n        if (isBlank(value)) {\n            return null;\n        }\n        return parseInteger(value, name);\n    }\n\n    private Double parseDoubleOrNull(String value, String name) {\n        if (isBlank(value)) {\n            return null;\n        }\n        try {\n            return Double.parseDouble(value);\n        } catch (NumberFormatException ex) {\n            throw new IllegalArgumentException(\"Invalid number for --\" + name + \": \" + value, ex);\n        }\n    }\n\n    private Boolean parseBooleanOrDefault(String value, boolean defaultValue) {\n        if (isBlank(value)) {\n            return defaultValue;\n        }\n        return parseBoolean(value);\n    }\n\n    private boolean parseBoolean(String value) {\n        if (\"true\".equalsIgnoreCase(value) || \"1\".equals(value) || \"yes\".equalsIgnoreCase(value)) {\n            return true;\n        }\n        if (\"false\".equalsIgnoreCase(value) || \"0\".equals(value) || \"no\".equalsIgnoreCase(value)) {\n            return false;\n        }\n        throw new IllegalArgumentException(\"Invalid boolean value: \" + value);\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private String trimToNull(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CustomCommandRegistry.java",
    "content": "package io.github.lnyocly.ai4j.cli.command;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.DirectoryStream;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class CustomCommandRegistry {\n\n    private final Path workspaceRoot;\n\n    public CustomCommandRegistry(Path workspaceRoot) {\n        this.workspaceRoot = workspaceRoot;\n    }\n\n    public List<CustomCommandTemplate> list() {\n        Map<String, CustomCommandTemplate> commands = new LinkedHashMap<String, CustomCommandTemplate>();\n        loadDirectory(homeCommandsDirectory(), commands);\n        loadDirectory(workspaceCommandsDirectory(), commands);\n        List<CustomCommandTemplate> values = new ArrayList<CustomCommandTemplate>(commands.values());\n        Collections.sort(values, java.util.Comparator.comparing(CustomCommandTemplate::getName));\n        return values;\n    }\n\n    public CustomCommandTemplate find(String name) {\n        if (isBlank(name)) {\n            return null;\n        }\n        for (CustomCommandTemplate command : list()) {\n            if (name.equalsIgnoreCase(command.getName())) {\n                return command;\n            }\n        }\n        return null;\n    }\n\n    private void loadDirectory(Path directory, Map<String, CustomCommandTemplate> commands) {\n        if (directory == null || !Files.isDirectory(directory)) {\n            return;\n        }\n        try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {\n            for (Path file : stream) {\n                if (!hasSupportedExtension(file)) {\n                    continue;\n                }\n                CustomCommandTemplate command = loadTemplate(file);\n                if (command != null) {\n                    commands.put(command.getName(), command);\n                }\n            }\n        } catch (IOException ignored) {\n        }\n    }\n\n    private CustomCommandTemplate loadTemplate(Path file) {\n        if (file == null || !Files.isRegularFile(file)) {\n            return null;\n        }\n        try {\n            String raw = new String(Files.readAllBytes(file), StandardCharsets.UTF_8).trim();\n            if (raw.isEmpty()) {\n                return null;\n            }\n            String fileName = file.getFileName().toString();\n            int dot = fileName.lastIndexOf('.');\n            String name = dot > 0 ? fileName.substring(0, dot) : fileName;\n            String description = null;\n            String template = raw;\n            int newline = raw.indexOf('\\n');\n            if (raw.startsWith(\"#\") && newline > 0) {\n                description = raw.substring(1, newline).trim();\n                template = raw.substring(newline + 1).trim();\n            }\n            return new CustomCommandTemplate(name, description, template, file.toAbsolutePath().normalize().toString());\n        } catch (IOException ex) {\n            return null;\n        }\n    }\n\n    private Path workspaceCommandsDirectory() {\n        return workspaceRoot == null ? null : workspaceRoot.resolve(\".ai4j\").resolve(\"commands\");\n    }\n\n    private Path homeCommandsDirectory() {\n        String home = System.getProperty(\"user.home\");\n        if (isBlank(home)) {\n            return null;\n        }\n        return Paths.get(home).resolve(\".ai4j\").resolve(\"commands\");\n    }\n\n    private boolean hasSupportedExtension(Path file) {\n        if (file == null || file.getFileName() == null) {\n            return false;\n        }\n        String fileName = file.getFileName().toString().toLowerCase();\n        return fileName.endsWith(\".md\") || fileName.endsWith(\".txt\") || fileName.endsWith(\".prompt\");\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/command/CustomCommandTemplate.java",
    "content": "package io.github.lnyocly.ai4j.cli.command;\n\nimport java.util.Map;\n\npublic class CustomCommandTemplate {\n\n    private final String name;\n    private final String description;\n    private final String template;\n    private final String source;\n\n    public CustomCommandTemplate(String name, String description, String template, String source) {\n        this.name = name;\n        this.description = description;\n        this.template = template;\n        this.source = source;\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public String getDescription() {\n        return description;\n    }\n\n    public String getTemplate() {\n        return template;\n    }\n\n    public String getSource() {\n        return source;\n    }\n\n    public String render(Map<String, String> variables) {\n        String result = template == null ? \"\" : template;\n        if (variables == null) {\n            return result;\n        }\n        for (Map.Entry<String, String> entry : variables.entrySet()) {\n            String key = entry.getKey();\n            if (key == null) {\n                continue;\n            }\n            result = result.replace(\"$\" + key, entry.getValue() == null ? \"\" : entry.getValue());\n        }\n        return result;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/config/CliWorkspaceConfig.java",
    "content": "package io.github.lnyocly.ai4j.cli.config;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class CliWorkspaceConfig {\n\n    private String activeProfile;\n    private String modelOverride;\n    private Boolean experimentalSubagentsEnabled;\n    private Boolean experimentalAgentTeamsEnabled;\n    private List<String> enabledMcpServers;\n    private List<String> skillDirectories;\n    private List<String> agentDirectories;\n\n    public CliWorkspaceConfig() {\n    }\n\n    public CliWorkspaceConfig(String activeProfile,\n                              String modelOverride,\n                              Boolean experimentalSubagentsEnabled,\n                              Boolean experimentalAgentTeamsEnabled,\n                              List<String> enabledMcpServers,\n                              List<String> skillDirectories,\n                              List<String> agentDirectories) {\n        this.activeProfile = activeProfile;\n        this.modelOverride = modelOverride;\n        this.experimentalSubagentsEnabled = experimentalSubagentsEnabled;\n        this.experimentalAgentTeamsEnabled = experimentalAgentTeamsEnabled;\n        this.enabledMcpServers = copy(enabledMcpServers);\n        this.skillDirectories = copy(skillDirectories);\n        this.agentDirectories = copy(agentDirectories);\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public Builder toBuilder() {\n        return new Builder()\n                .activeProfile(activeProfile)\n                .modelOverride(modelOverride)\n                .experimentalSubagentsEnabled(experimentalSubagentsEnabled)\n                .experimentalAgentTeamsEnabled(experimentalAgentTeamsEnabled)\n                .enabledMcpServers(enabledMcpServers)\n                .skillDirectories(skillDirectories)\n                .agentDirectories(agentDirectories);\n    }\n\n    public String getActiveProfile() {\n        return activeProfile;\n    }\n\n    public void setActiveProfile(String activeProfile) {\n        this.activeProfile = activeProfile;\n    }\n\n    public String getModelOverride() {\n        return modelOverride;\n    }\n\n    public void setModelOverride(String modelOverride) {\n        this.modelOverride = modelOverride;\n    }\n\n    public Boolean getExperimentalSubagentsEnabled() {\n        return experimentalSubagentsEnabled;\n    }\n\n    public void setExperimentalSubagentsEnabled(Boolean experimentalSubagentsEnabled) {\n        this.experimentalSubagentsEnabled = experimentalSubagentsEnabled;\n    }\n\n    public Boolean getExperimentalAgentTeamsEnabled() {\n        return experimentalAgentTeamsEnabled;\n    }\n\n    public void setExperimentalAgentTeamsEnabled(Boolean experimentalAgentTeamsEnabled) {\n        this.experimentalAgentTeamsEnabled = experimentalAgentTeamsEnabled;\n    }\n\n    public List<String> getEnabledMcpServers() {\n        return copy(enabledMcpServers);\n    }\n\n    public void setEnabledMcpServers(List<String> enabledMcpServers) {\n        this.enabledMcpServers = copy(enabledMcpServers);\n    }\n\n    public List<String> getSkillDirectories() {\n        return copy(skillDirectories);\n    }\n\n    public void setSkillDirectories(List<String> skillDirectories) {\n        this.skillDirectories = copy(skillDirectories);\n    }\n\n    public List<String> getAgentDirectories() {\n        return copy(agentDirectories);\n    }\n\n    public void setAgentDirectories(List<String> agentDirectories) {\n        this.agentDirectories = copy(agentDirectories);\n    }\n\n    private List<String> copy(List<String> values) {\n        return values == null ? null : new ArrayList<String>(values);\n    }\n\n    public static final class Builder {\n\n        private String activeProfile;\n        private String modelOverride;\n        private Boolean experimentalSubagentsEnabled;\n        private Boolean experimentalAgentTeamsEnabled;\n        private List<String> enabledMcpServers;\n        private List<String> skillDirectories;\n        private List<String> agentDirectories;\n\n        private Builder() {\n        }\n\n        public Builder activeProfile(String activeProfile) {\n            this.activeProfile = activeProfile;\n            return this;\n        }\n\n        public Builder modelOverride(String modelOverride) {\n            this.modelOverride = modelOverride;\n            return this;\n        }\n\n        public Builder experimentalSubagentsEnabled(Boolean experimentalSubagentsEnabled) {\n            this.experimentalSubagentsEnabled = experimentalSubagentsEnabled;\n            return this;\n        }\n\n        public Builder experimentalAgentTeamsEnabled(Boolean experimentalAgentTeamsEnabled) {\n            this.experimentalAgentTeamsEnabled = experimentalAgentTeamsEnabled;\n            return this;\n        }\n\n        public Builder enabledMcpServers(List<String> enabledMcpServers) {\n            this.enabledMcpServers = enabledMcpServers == null ? null : new ArrayList<String>(enabledMcpServers);\n            return this;\n        }\n\n        public Builder skillDirectories(List<String> skillDirectories) {\n            this.skillDirectories = skillDirectories == null ? null : new ArrayList<String>(skillDirectories);\n            return this;\n        }\n\n        public Builder agentDirectories(List<String> agentDirectories) {\n            this.agentDirectories = agentDirectories == null ? null : new ArrayList<String>(agentDirectories);\n            return this;\n        }\n\n        public CliWorkspaceConfig build() {\n            return new CliWorkspaceConfig(\n                    activeProfile,\n                    modelOverride,\n                    experimentalSubagentsEnabled,\n                    experimentalAgentTeamsEnabled,\n                    enabledMcpServers,\n                    skillDirectories,\n                    agentDirectories\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/factory/CodingCliAgentFactory.java",
    "content": "package io.github.lnyocly.ai4j.cli.factory;\n\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\n\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\n\nimport java.util.Collection;\n\npublic interface CodingCliAgentFactory {\n\n    PreparedCodingAgent prepare(CodeCommandOptions options) throws Exception;\n\n    default PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) throws Exception {\n        return prepare(options);\n    }\n\n    default PreparedCodingAgent prepare(CodeCommandOptions options,\n                                        TerminalIO terminal,\n                                        TuiInteractionState interactionState) throws Exception {\n        return prepare(options, terminal);\n    }\n\n    default PreparedCodingAgent prepare(CodeCommandOptions options,\n                                        TerminalIO terminal,\n                                        TuiInteractionState interactionState,\n                                        Collection<String> pausedMcpServers) throws Exception {\n        return prepare(options, terminal, interactionState);\n    }\n\n    final class PreparedCodingAgent {\n\n        private final CodingAgent agent;\n        private final CliProtocol protocol;\n        private final CliMcpRuntimeManager mcpRuntimeManager;\n\n        public PreparedCodingAgent(CodingAgent agent, CliProtocol protocol) {\n            this(agent, protocol, null);\n        }\n\n        public PreparedCodingAgent(CodingAgent agent,\n                                   CliProtocol protocol,\n                                   CliMcpRuntimeManager mcpRuntimeManager) {\n            this.agent = agent;\n            this.protocol = protocol;\n            this.mcpRuntimeManager = mcpRuntimeManager;\n        }\n\n        public CodingAgent getAgent() {\n            return agent;\n        }\n\n        public CliProtocol getProtocol() {\n            return protocol;\n        }\n\n        public CliMcpRuntimeManager getMcpRuntimeManager() {\n            return mcpRuntimeManager;\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/factory/CodingCliTuiFactory.java",
    "content": "package io.github.lnyocly.ai4j.cli.factory;\n\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.runtime.CodingCliTuiSupport;\n\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiConfigManager;\n\npublic interface CodingCliTuiFactory {\n\n    CodingCliTuiSupport create(CodeCommandOptions options,\n                               TerminalIO terminal,\n                               TuiConfigManager configManager);\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/factory/DefaultCodingCliAgentFactory.java",
    "content": "package io.github.lnyocly.ai4j.cli.factory;\n\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.agent.CliCodingAgentRegistry;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager;\nimport io.github.lnyocly.ai4j.cli.render.CliAnsi;\nimport io.github.lnyocly.ai4j.cli.runtime.CliToolApprovalDecorator;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamOptions;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.coding.CodingAgents;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.CodingAgentBuilder;\nimport io.github.lnyocly.ai4j.coding.definition.BuiltInCodingAgentDefinitions;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.definition.CompositeCodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry;\nimport io.github.lnyocly.ai4j.coding.prompt.CodingContextPromptAssembler;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator;\nimport io.github.lnyocly.ai4j.config.BaichuanConfig;\nimport io.github.lnyocly.ai4j.config.DashScopeConfig;\nimport io.github.lnyocly.ai4j.config.DeepSeekConfig;\nimport io.github.lnyocly.ai4j.config.DoubaoConfig;\nimport io.github.lnyocly.ai4j.config.HunyuanConfig;\nimport io.github.lnyocly.ai4j.config.LingyiConfig;\nimport io.github.lnyocly.ai4j.config.MinimaxConfig;\nimport io.github.lnyocly.ai4j.config.MoonshotConfig;\nimport io.github.lnyocly.ai4j.config.OllamaConfig;\nimport io.github.lnyocly.ai4j.config.OpenAiConfig;\nimport io.github.lnyocly.ai4j.config.ZhipuConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.listener.StreamExecutionOptions;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\n\nimport java.util.Arrays;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.concurrent.TimeUnit;\n\npublic class DefaultCodingCliAgentFactory implements CodingCliAgentFactory {\n\n    static final long CLI_STREAM_FIRST_TOKEN_TIMEOUT_MS = 30000L;\n    static final int CLI_STREAM_MAX_RETRIES = 2;\n    static final long CLI_STREAM_RETRY_BACKOFF_MS = 1500L;\n    static final String EXPERIMENTAL_SUBAGENT_TOOL_NAME = \"subagent_background_worker\";\n    static final String EXPERIMENTAL_TEAM_TOOL_NAME = \"subagent_delivery_team\";\n    static final String EXPERIMENTAL_TEAM_ID = \"experimental-delivery-team\";\n\n    @Override\n    public PreparedCodingAgent prepare(CodeCommandOptions options) throws Exception {\n        return prepare(options, null);\n    }\n\n    @Override\n    public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) throws Exception {\n        return prepare(options, terminal, null);\n    }\n\n    @Override\n    public PreparedCodingAgent prepare(CodeCommandOptions options,\n                                       TerminalIO terminal,\n                                       TuiInteractionState interactionState) throws Exception {\n        return prepare(options, terminal, interactionState, Collections.<String>emptySet());\n    }\n\n    @Override\n    public PreparedCodingAgent prepare(CodeCommandOptions options,\n                                       TerminalIO terminal,\n                                       TuiInteractionState interactionState,\n                                       Collection<String> pausedMcpServers) throws Exception {\n        CliProtocol protocol = resolveProtocol(options);\n        AgentModelClient modelClient = createModelClient(options, protocol);\n        CliMcpRuntimeManager mcpRuntimeManager = prepareMcpRuntime(options, pausedMcpServers, terminal);\n        CodingAgent agent = buildAgent(options, terminal, interactionState, modelClient, mcpRuntimeManager);\n        return new PreparedCodingAgent(agent, protocol, mcpRuntimeManager);\n    }\n\n    CliProtocol resolveProtocol(CodeCommandOptions options) {\n        CliProtocol requested = options.getProtocol();\n        if (requested != null) {\n            assertSupportedProtocol(options.getProvider(), requested);\n            return requested;\n        }\n        CliProtocol resolved = CliProtocol.defaultProtocol(options.getProvider(), options.getBaseUrl());\n        assertSupportedProtocol(options.getProvider(), resolved);\n        return resolved;\n    }\n\n    protected AgentModelClient createModelClient(CodeCommandOptions options, CliProtocol protocol) {\n        Configuration configuration = createConfiguration(options);\n        AiService aiService = new AiService(configuration);\n        String runtimeBaseUrl = normalizeRuntimeBaseUrl(options);\n        if (protocol == CliProtocol.RESPONSES) {\n            return new ResponsesModelClient(\n                    aiService.getResponsesService(options.getProvider()),\n                    runtimeBaseUrl,\n                    options.getApiKey()\n            );\n        }\n        return new ChatModelClient(\n                aiService.getChatService(options.getProvider()),\n                runtimeBaseUrl,\n                options.getApiKey()\n        );\n    }\n\n    private Configuration createConfiguration(CodeCommandOptions options) {\n        Configuration configuration = new Configuration();\n        configuration.setOkHttpClient(createHttpClient(options.isVerbose()));\n        applyProviderConfig(configuration, options.getProvider(), options.getBaseUrl(), options.getApiKey());\n        return configuration;\n    }\n\n    protected CliMcpRuntimeManager prepareMcpRuntime(CodeCommandOptions options,\n                                                     Collection<String> pausedMcpServers,\n                                                     TerminalIO terminal) {\n        try {\n            return CliMcpRuntimeManager.initialize(\n                    Paths.get(options.getWorkspace()),\n                    pausedMcpServers == null ? Collections.<String>emptySet() : pausedMcpServers\n            );\n        } catch (Exception ex) {\n            if (terminal != null) {\n                terminal.println(CliAnsi.warning(\n                        \"Warning: MCP runtime unavailable: \" + safeMessage(ex),\n                        terminal.supportsAnsi()\n                ));\n            }\n            return null;\n        }\n    }\n\n    private CodingAgent buildAgent(CodeCommandOptions options,\n                                   TerminalIO terminal,\n                                   TuiInteractionState interactionState,\n                                   AgentModelClient modelClient,\n                                   CliMcpRuntimeManager mcpRuntimeManager) {\n        String workspace = options == null ? \".\" : defaultIfBlank(options.getWorkspace(), \".\");\n        CliWorkspaceConfig workspaceConfig = loadWorkspaceConfig(workspace);\n        WorkspaceContext workspaceContext = buildWorkspaceContext(options, workspaceConfig);\n        CodingAgentDefinitionRegistry definitionRegistry = loadDefinitionRegistry(options, workspaceConfig);\n        CodingAgentOptions codingOptions = buildCodingOptions(options, terminal, interactionState);\n        AgentOptions agentOptions = buildAgentOptions(options);\n\n        io.github.lnyocly.ai4j.coding.CodingAgentBuilder builder = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(options.getModel())\n                .workspaceContext(workspaceContext)\n                .definitionRegistry(definitionRegistry)\n                .codingOptions(codingOptions)\n                .systemPrompt(options.getSystemPrompt())\n                .instructions(options.getInstructions())\n                .temperature(options.getTemperature())\n                .topP(options.getTopP())\n                .maxOutputTokens(options.getMaxOutputTokens())\n                .parallelToolCalls(options.getParallelToolCalls())\n                .store(Boolean.FALSE)\n                .agentOptions(agentOptions);\n\n        attachMcpRuntime(builder, mcpRuntimeManager);\n        attachExperimentalAgents(builder, options, workspaceConfig, modelClient, workspaceContext, codingOptions, agentOptions);\n        return builder.build();\n    }\n\n    protected ToolExecutorDecorator createToolExecutorDecorator(CodeCommandOptions options,\n                                                                TerminalIO terminal,\n                                                                TuiInteractionState interactionState) {\n        return new CliToolApprovalDecorator(options.getApprovalMode(), terminal, interactionState);\n    }\n\n    WorkspaceContext buildWorkspaceContext(CodeCommandOptions options) {\n        String workspace = options == null ? \".\" : defaultIfBlank(options.getWorkspace(), \".\");\n        return buildWorkspaceContext(options, loadWorkspaceConfig(workspace));\n    }\n\n    WorkspaceContext buildWorkspaceContext(CodeCommandOptions options, CliWorkspaceConfig workspaceConfig) {\n        String workspace = options == null ? \".\" : defaultIfBlank(options.getWorkspace(), \".\");\n        return WorkspaceContext.builder()\n                .rootPath(workspace)\n                .description(options == null ? null : options.getWorkspaceDescription())\n                .allowOutsideWorkspace(options != null && options.isAllowOutsideWorkspace())\n                .skillDirectories(workspaceConfig == null ? null : workspaceConfig.getSkillDirectories())\n                .build();\n    }\n\n    private CliWorkspaceConfig loadWorkspaceConfig(String workspace) {\n        return new CliProviderConfigManager(Paths.get(defaultIfBlank(workspace, \".\"))).loadWorkspaceConfig();\n    }\n\n    CodingAgentDefinitionRegistry loadDefinitionRegistry(CodeCommandOptions options) {\n        String workspace = options == null ? \".\" : defaultIfBlank(options.getWorkspace(), \".\");\n        return loadDefinitionRegistry(options, loadWorkspaceConfig(workspace));\n    }\n\n    CodingAgentDefinitionRegistry loadDefinitionRegistry(CodeCommandOptions options, CliWorkspaceConfig workspaceConfig) {\n        String workspace = options == null ? \".\" : defaultIfBlank(options.getWorkspace(), \".\");\n        CliCodingAgentRegistry customRegistry = new CliCodingAgentRegistry(\n                Paths.get(workspace),\n                workspaceConfig == null ? null : workspaceConfig.getAgentDirectories()\n        );\n        CodingAgentDefinitionRegistry loadedRegistry = customRegistry.loadRegistry();\n        if (loadedRegistry == null || loadedRegistry.listDefinitions() == null || loadedRegistry.listDefinitions().isEmpty()) {\n            return BuiltInCodingAgentDefinitions.registry();\n        }\n        return new CompositeCodingAgentDefinitionRegistry(\n                BuiltInCodingAgentDefinitions.registry(),\n                loadedRegistry\n        );\n    }\n\n    private CodingAgentOptions buildCodingOptions(CodeCommandOptions options,\n                                                  TerminalIO terminal,\n                                                  TuiInteractionState interactionState) {\n        return CodingAgentOptions.builder()\n                .autoCompactEnabled(options.isAutoCompact())\n                .compactContextWindowTokens(options.getCompactContextWindowTokens())\n                .compactReserveTokens(options.getCompactReserveTokens())\n                .compactKeepRecentTokens(options.getCompactKeepRecentTokens())\n                .compactSummaryMaxOutputTokens(options.getCompactSummaryMaxOutputTokens())\n                .toolExecutorDecorator(createToolExecutorDecorator(options, terminal, interactionState))\n                .build();\n    }\n\n    private AgentOptions buildAgentOptions(CodeCommandOptions options) {\n        return AgentOptions.builder()\n                .maxSteps(options.getMaxSteps())\n                .stream(options.isStream())\n                .streamExecution(buildStreamExecutionOptions())\n                .build();\n    }\n\n    private StreamExecutionOptions buildStreamExecutionOptions() {\n        return StreamExecutionOptions.builder()\n                .firstTokenTimeoutMs(CLI_STREAM_FIRST_TOKEN_TIMEOUT_MS)\n                .maxRetries(CLI_STREAM_MAX_RETRIES)\n                .retryBackoffMs(CLI_STREAM_RETRY_BACKOFF_MS)\n                .build();\n    }\n\n    private void attachMcpRuntime(io.github.lnyocly.ai4j.coding.CodingAgentBuilder builder,\n                                  CliMcpRuntimeManager mcpRuntimeManager) {\n        if (mcpRuntimeManager == null\n                || mcpRuntimeManager.getToolRegistry() == null\n                || mcpRuntimeManager.getToolExecutor() == null) {\n            return;\n        }\n        builder.toolRegistry(mcpRuntimeManager.getToolRegistry());\n        builder.toolExecutor(mcpRuntimeManager.getToolExecutor());\n    }\n\n    private void attachExperimentalAgents(io.github.lnyocly.ai4j.coding.CodingAgentBuilder builder,\n                                          CodeCommandOptions options,\n                                          CliWorkspaceConfig workspaceConfig,\n                                          AgentModelClient modelClient,\n                                          WorkspaceContext workspaceContext,\n                                          CodingAgentOptions codingOptions,\n                                          AgentOptions agentOptions) {\n        if (builder == null || options == null || modelClient == null || workspaceContext == null || codingOptions == null) {\n            return;\n        }\n        if (isExperimentalSubagentsEnabled(workspaceConfig)) {\n            builder.subAgent(buildBackgroundWorkerSubAgent(options, modelClient, workspaceContext, codingOptions, agentOptions));\n        }\n        if (isExperimentalAgentTeamsEnabled(workspaceConfig)) {\n            builder.subAgent(buildDeliveryTeamSubAgent(options, modelClient, workspaceContext, codingOptions, agentOptions));\n        }\n    }\n\n    private SubAgentDefinition buildBackgroundWorkerSubAgent(CodeCommandOptions options,\n                                                             AgentModelClient modelClient,\n                                                             WorkspaceContext workspaceContext,\n                                                             CodingAgentOptions codingOptions,\n                                                             AgentOptions agentOptions) {\n        return SubAgentDefinition.builder()\n                .name(\"background-worker\")\n                .toolName(EXPERIMENTAL_SUBAGENT_TOOL_NAME)\n                .description(\"Delegate long-running shell work, repository scans, builds, test runs, and process monitoring to a focused background worker.\")\n                .agent(buildExperimentalCodingAgent(\n                        modelClient,\n                        options == null ? null : options.getModel(),\n                        workspaceContext,\n                        codingOptions,\n                        agentOptions,\n                        \"You are the background worker subagent.\\n\"\n                                + \"Use this role for scoped tasks that may take time, require repeated shell inspection, or need background process management.\\n\"\n                                + \"Prefer bash action=start for long-running commands, then use bash action=status/logs/write/stop to drive them.\\n\"\n                                + \"Return concise, concrete findings and avoid broad unrelated edits.\"\n                ))\n                .build();\n    }\n\n    private SubAgentDefinition buildDeliveryTeamSubAgent(CodeCommandOptions options,\n                                                         AgentModelClient modelClient,\n                                                         WorkspaceContext workspaceContext,\n                                                         CodingAgentOptions codingOptions,\n                                                         AgentOptions agentOptions) {\n        Path storageDirectory = resolveExperimentalTeamStorageDirectory(workspaceContext);\n        Agent teamAgent = Agents.team()\n                .teamId(EXPERIMENTAL_TEAM_ID)\n                .storageDirectory(storageDirectory)\n                .planner((objective, members, teamOptions) -> AgentTeamPlan.builder()\n                        .rawPlanText(\"experimental-delivery-team-plan\")\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"architecture\")\n                                        .memberId(\"architect\")\n                                        .task(\"Design the demo application architecture, delivery plan, and workspace layout.\")\n                                        .context(\"Write a concrete implementation plan. Tell backend and frontend where they should work inside this workspace. Objective: \"\n                                                + safeText(objective))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"backend\")\n                                        .memberId(\"backend\")\n                                        .task(\"Implement backend or service-side functionality for the requested demo application.\")\n                                        .context(\"Prefer backend/, server/, api/, or src/main/java style locations when creating new server-side code. Objective: \"\n                                                + safeText(objective))\n                                        .dependsOn(Arrays.asList(\"architecture\"))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"frontend\")\n                                        .memberId(\"frontend\")\n                                        .task(\"Implement frontend or client-side functionality for the requested demo application.\")\n                                        .context(\"Prefer frontend/, web/, ui/, or src/ style locations when creating new client-side code. Coordinate contracts with backend if needed. Objective: \"\n                                                + safeText(objective))\n                                        .dependsOn(Arrays.asList(\"architecture\"))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"qa\")\n                                        .memberId(\"qa\")\n                                        .task(\"Validate the resulting demo application, run tests or smoke checks, and report concrete risks.\")\n                                        .context(\"Inspect what architect/backend/frontend produced, run the most relevant verification commands, and report gaps. Objective: \"\n                                                + safeText(objective))\n                                        .dependsOn(Arrays.asList(\"backend\", \"frontend\"))\n                                        .build()\n                        ))\n                        .build())\n                .synthesizer((objective, plan, memberResults, teamOptions) -> AgentResult.builder()\n                        .outputText(renderDeliveryTeamSummary(objective, memberResults))\n                        .build())\n                .member(teamMember(\n                        \"architect\",\n                        \"Architect\",\n                        \"System design, delivery plan, workspace layout, and API boundaries.\",\n                        \"You are the architect in a delivery team.\\n\"\n                                + \"Define the implementation approach, directory layout, and interface boundaries before others proceed.\\n\"\n                                + \"Use team_send_message or team_broadcast when another member needs concrete guidance.\\n\"\n                                + \"Do not try to finish everyone else's work yourself.\",\n                        modelClient,\n                        options == null ? null : options.getModel(),\n                        workspaceContext,\n                        codingOptions,\n                        agentOptions\n                ))\n                .member(teamMember(\n                        \"backend\",\n                        \"Backend\",\n                        \"Server-side implementation, APIs, persistence, and service integration.\",\n                        \"You are the backend engineer in a delivery team.\\n\"\n                                + \"Implement the server-side portion of the task, keep changes scoped, and communicate API contracts or blockers to the frontend and QA members.\\n\"\n                                + \"Use team_send_message when contracts, payloads, or test hooks need coordination.\",\n                        modelClient,\n                        options == null ? null : options.getModel(),\n                        workspaceContext,\n                        codingOptions,\n                        agentOptions\n                ))\n                .member(teamMember(\n                        \"frontend\",\n                        \"Frontend\",\n                        \"UI implementation, client integration, and user-facing polish.\",\n                        \"You are the frontend engineer in a delivery team.\\n\"\n                                + \"Implement the client-facing portion of the task, keep the UI runnable, and coordinate API assumptions with backend and QA.\\n\"\n                                + \"Use team_send_message when you need contract confirmation or test setup details.\",\n                        modelClient,\n                        options == null ? null : options.getModel(),\n                        workspaceContext,\n                        codingOptions,\n                        agentOptions\n                ))\n                .member(teamMember(\n                        \"qa\",\n                        \"QA\",\n                        \"Verification, test execution, regression checks, and release risk review.\",\n                        \"You are the QA engineer in a delivery team.\\n\"\n                                + \"Run the most relevant verification steps, summarize failures precisely, and report concrete release risks.\\n\"\n                                + \"Use team_send_message or team_broadcast when other members need to fix a blocker before sign-off.\",\n                        modelClient,\n                        options == null ? null : options.getModel(),\n                        workspaceContext,\n                        codingOptions,\n                        agentOptions\n                ))\n                .options(AgentTeamOptions.builder()\n                        .parallelDispatch(true)\n                        .maxConcurrency(2)\n                        .enableMessageBus(true)\n                        .includeMessageHistoryInDispatch(true)\n                        .enableMemberTeamTools(true)\n                        .build())\n                .buildAgent();\n\n        return SubAgentDefinition.builder()\n                .name(\"delivery-team\")\n                .toolName(EXPERIMENTAL_TEAM_TOOL_NAME)\n                .description(\"Delegate medium or large implementation work to an architect/backend/frontend/qa delivery team that coordinates through team tools.\")\n                .agent(teamAgent)\n                .build();\n    }\n\n    private Path resolveExperimentalTeamStorageDirectory(WorkspaceContext workspaceContext) {\n        if (workspaceContext == null || workspaceContext.getRoot() == null) {\n            return Paths.get(\".\").toAbsolutePath().normalize().resolve(\".ai4j\").resolve(\"teams\");\n        }\n        return workspaceContext.getRoot().resolve(\".ai4j\").resolve(\"teams\");\n    }\n\n    private AgentTeamMember teamMember(String id,\n                                       String name,\n                                       String description,\n                                       String rolePrompt,\n                                       AgentModelClient modelClient,\n                                       String model,\n                                       WorkspaceContext workspaceContext,\n                                       CodingAgentOptions codingOptions,\n                                       AgentOptions agentOptions) {\n        return AgentTeamMember.builder()\n                .id(id)\n                .name(name)\n                .description(description)\n                .agent(buildExperimentalCodingAgent(\n                        modelClient,\n                        model,\n                        workspaceContext,\n                        codingOptions,\n                        agentOptions,\n                        rolePrompt\n                ))\n                .build();\n    }\n\n    private Agent buildExperimentalCodingAgent(AgentModelClient modelClient,\n                                               String model,\n                                               WorkspaceContext workspaceContext,\n                                               CodingAgentOptions codingOptions,\n                                               AgentOptions agentOptions,\n                                               String systemPrompt) {\n        SessionProcessRegistry processRegistry = new SessionProcessRegistry(workspaceContext, codingOptions);\n        AgentToolRegistry toolRegistry = CodingAgentBuilder.createBuiltInRegistry(codingOptions);\n        ToolExecutor toolExecutor = CodingAgentBuilder.createBuiltInToolExecutor(\n                workspaceContext,\n                codingOptions,\n                processRegistry\n        );\n        return Agents.react()\n                .modelClient(modelClient)\n                .model(model)\n                .systemPrompt(CodingContextPromptAssembler.mergeSystemPrompt(systemPrompt, workspaceContext))\n                .toolRegistry(toolRegistry)\n                .toolExecutor(toolExecutor)\n                .options(agentOptions)\n                .build();\n    }\n\n    private String renderDeliveryTeamSummary(String objective, List<AgentTeamMemberResult> memberResults) {\n        Map<String, String> outputs = new LinkedHashMap<String, String>();\n        if (memberResults != null) {\n            for (AgentTeamMemberResult memberResult : memberResults) {\n                if (memberResult == null || isBlank(memberResult.getMemberId())) {\n                    continue;\n                }\n                outputs.put(\n                        memberResult.getMemberId(),\n                        memberResult.isSuccess()\n                                ? safeText(memberResult.getOutput())\n                                : \"FAILED: \" + safeText(memberResult.getError())\n                );\n            }\n        }\n\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"Delivery Team Summary\\n\");\n        builder.append(\"Objective: \").append(safeText(objective)).append(\"\\n\\n\");\n        appendTeamMemberSummary(builder, \"Architect\", outputs.get(\"architect\"));\n        appendTeamMemberSummary(builder, \"Backend\", outputs.get(\"backend\"));\n        appendTeamMemberSummary(builder, \"Frontend\", outputs.get(\"frontend\"));\n        appendTeamMemberSummary(builder, \"QA\", outputs.get(\"qa\"));\n        return builder.toString().trim();\n    }\n\n    private void appendTeamMemberSummary(StringBuilder builder, String name, String output) {\n        if (builder == null) {\n            return;\n        }\n        builder.append('[').append(name).append(\"]\\n\");\n        builder.append(safeText(output)).append(\"\\n\\n\");\n    }\n\n    public static boolean isExperimentalSubagentsEnabled(CliWorkspaceConfig workspaceConfig) {\n        return workspaceConfig == null || workspaceConfig.getExperimentalSubagentsEnabled() == null\n                || workspaceConfig.getExperimentalSubagentsEnabled().booleanValue();\n    }\n\n    public static boolean isExperimentalAgentTeamsEnabled(CliWorkspaceConfig workspaceConfig) {\n        return workspaceConfig == null || workspaceConfig.getExperimentalAgentTeamsEnabled() == null\n                || workspaceConfig.getExperimentalAgentTeamsEnabled().booleanValue();\n    }\n\n    private OkHttpClient createHttpClient(boolean verbose) {\n        OkHttpClient.Builder builder = new OkHttpClient.Builder()\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS);\n        if (verbose) {\n            HttpLoggingInterceptor logging = new HttpLoggingInterceptor();\n            logging.setLevel(HttpLoggingInterceptor.Level.BASIC);\n            builder.addInterceptor(logging);\n        }\n        return builder.build();\n    }\n\n    private void applyProviderConfig(Configuration configuration,\n                                     PlatformType provider,\n                                     String baseUrl,\n                                     String apiKey) {\n        switch (provider) {\n            case OPENAI:\n                OpenAiConfig openAiConfig = new OpenAiConfig();\n                openAiConfig.setApiHost(defaultIfBlank(baseUrl, openAiConfig.getApiHost()));\n                openAiConfig.setApiKey(defaultIfBlank(apiKey, openAiConfig.getApiKey()));\n                configuration.setOpenAiConfig(openAiConfig);\n                return;\n            case ZHIPU:\n                ZhipuConfig zhipuConfig = new ZhipuConfig();\n                zhipuConfig.setApiHost(defaultIfBlank(normalizeZhipuBaseUrl(baseUrl), zhipuConfig.getApiHost()));\n                zhipuConfig.setApiKey(defaultIfBlank(apiKey, zhipuConfig.getApiKey()));\n                configuration.setZhipuConfig(zhipuConfig);\n                return;\n            case DEEPSEEK:\n                DeepSeekConfig deepSeekConfig = new DeepSeekConfig();\n                deepSeekConfig.setApiHost(defaultIfBlank(baseUrl, deepSeekConfig.getApiHost()));\n                deepSeekConfig.setApiKey(defaultIfBlank(apiKey, deepSeekConfig.getApiKey()));\n                configuration.setDeepSeekConfig(deepSeekConfig);\n                return;\n            case MOONSHOT:\n                MoonshotConfig moonshotConfig = new MoonshotConfig();\n                moonshotConfig.setApiHost(defaultIfBlank(baseUrl, moonshotConfig.getApiHost()));\n                moonshotConfig.setApiKey(defaultIfBlank(apiKey, moonshotConfig.getApiKey()));\n                configuration.setMoonshotConfig(moonshotConfig);\n                return;\n            case HUNYUAN:\n                HunyuanConfig hunyuanConfig = new HunyuanConfig();\n                hunyuanConfig.setApiHost(defaultIfBlank(baseUrl, hunyuanConfig.getApiHost()));\n                hunyuanConfig.setApiKey(defaultIfBlank(apiKey, hunyuanConfig.getApiKey()));\n                configuration.setHunyuanConfig(hunyuanConfig);\n                return;\n            case LINGYI:\n                LingyiConfig lingyiConfig = new LingyiConfig();\n                lingyiConfig.setApiHost(defaultIfBlank(baseUrl, lingyiConfig.getApiHost()));\n                lingyiConfig.setApiKey(defaultIfBlank(apiKey, lingyiConfig.getApiKey()));\n                configuration.setLingyiConfig(lingyiConfig);\n                return;\n            case OLLAMA:\n                OllamaConfig ollamaConfig = new OllamaConfig();\n                ollamaConfig.setApiHost(defaultIfBlank(baseUrl, ollamaConfig.getApiHost()));\n                ollamaConfig.setApiKey(defaultIfBlank(apiKey, ollamaConfig.getApiKey()));\n                configuration.setOllamaConfig(ollamaConfig);\n                return;\n            case MINIMAX:\n                MinimaxConfig minimaxConfig = new MinimaxConfig();\n                minimaxConfig.setApiHost(defaultIfBlank(baseUrl, minimaxConfig.getApiHost()));\n                minimaxConfig.setApiKey(defaultIfBlank(apiKey, minimaxConfig.getApiKey()));\n                configuration.setMinimaxConfig(minimaxConfig);\n                return;\n            case BAICHUAN:\n                BaichuanConfig baichuanConfig = new BaichuanConfig();\n                baichuanConfig.setApiHost(defaultIfBlank(baseUrl, baichuanConfig.getApiHost()));\n                baichuanConfig.setApiKey(defaultIfBlank(apiKey, baichuanConfig.getApiKey()));\n                configuration.setBaichuanConfig(baichuanConfig);\n                return;\n            case DASHSCOPE:\n                DashScopeConfig dashScopeConfig = new DashScopeConfig();\n                dashScopeConfig.setApiHost(defaultIfBlank(baseUrl, dashScopeConfig.getApiHost()));\n                dashScopeConfig.setApiKey(defaultIfBlank(apiKey, dashScopeConfig.getApiKey()));\n                configuration.setDashScopeConfig(dashScopeConfig);\n                return;\n            case DOUBAO:\n                DoubaoConfig doubaoConfig = new DoubaoConfig();\n                doubaoConfig.setApiHost(defaultIfBlank(baseUrl, doubaoConfig.getApiHost()));\n                doubaoConfig.setApiKey(defaultIfBlank(apiKey, doubaoConfig.getApiKey()));\n                configuration.setDoubaoConfig(doubaoConfig);\n                return;\n            default:\n                throw new IllegalArgumentException(\"Unsupported provider: \" + provider);\n        }\n    }\n\n    private void assertSupportedProtocol(PlatformType provider, CliProtocol protocol) {\n        if (protocol == CliProtocol.RESPONSES) {\n            if (provider != PlatformType.OPENAI && provider != PlatformType.DOUBAO && provider != PlatformType.DASHSCOPE) {\n                throw new IllegalArgumentException(\n                        \"Provider \" + provider.getPlatform() + \" does not support responses protocol in ai4j-cli yet\"\n                );\n            }\n        }\n    }\n\n    private String defaultIfBlank(String value, String defaultValue) {\n        return isBlank(value) ? defaultValue : value;\n    }\n\n    private String safeText(String value) {\n        return isBlank(value) ? \"(none)\" : value.trim();\n    }\n\n    String normalizeZhipuBaseUrl(String baseUrl) {\n        if (isBlank(baseUrl)) {\n            return baseUrl;\n        }\n        String normalized = baseUrl.trim();\n        normalized = stripSuffixIgnoreCase(normalized, \"/v4/chat/completions\");\n        normalized = stripSuffixIgnoreCase(normalized, \"/chat/completions\");\n        normalized = stripSuffixIgnoreCase(normalized, \"/v4\");\n        if (!normalized.endsWith(\"/\")) {\n            normalized = normalized + \"/\";\n        }\n        return normalized;\n    }\n\n    private String normalizeRuntimeBaseUrl(CodeCommandOptions options) {\n        if (options == null) {\n            return null;\n        }\n        if (options.getProvider() == PlatformType.ZHIPU) {\n            return normalizeZhipuBaseUrl(options.getBaseUrl());\n        }\n        return options.getBaseUrl();\n    }\n\n    private String stripSuffixIgnoreCase(String value, String suffix) {\n        if (isBlank(value) || isBlank(suffix)) {\n            return value;\n        }\n        String lowerValue = value.toLowerCase();\n        String lowerSuffix = suffix.toLowerCase();\n        if (lowerValue.endsWith(lowerSuffix)) {\n            return value.substring(0, value.length() - suffix.length());\n        }\n        return value;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private String safeMessage(Throwable throwable) {\n        String message = throwable == null ? null : throwable.getMessage();\n        if (isBlank(message) && throwable != null && throwable.getCause() != null) {\n            message = throwable.getCause().getMessage();\n        }\n        return isBlank(message)\n                ? (throwable == null ? \"unknown MCP error\" : throwable.getClass().getSimpleName())\n                : message.trim();\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/factory/DefaultCodingCliTuiFactory.java",
    "content": "package io.github.lnyocly.ai4j.cli.factory;\n\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.runtime.CodingCliTuiSupport;\nimport io.github.lnyocly.ai4j.tui.AnsiTuiRuntime;\nimport io.github.lnyocly.ai4j.tui.AppendOnlyTuiRuntime;\nimport io.github.lnyocly.ai4j.tui.JlineTerminalIO;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiConfig;\nimport io.github.lnyocly.ai4j.tui.TuiConfigManager;\nimport io.github.lnyocly.ai4j.tui.TuiRenderer;\nimport io.github.lnyocly.ai4j.tui.TuiRuntime;\nimport io.github.lnyocly.ai4j.tui.TuiSessionView;\nimport io.github.lnyocly.ai4j.tui.TuiTheme;\n\npublic class DefaultCodingCliTuiFactory implements CodingCliTuiFactory {\n\n    @Override\n    public CodingCliTuiSupport create(CodeCommandOptions options,\n                                      TerminalIO terminal,\n                                      TuiConfigManager configManager) {\n        TuiConfig config = configManager == null ? new TuiConfig() : configManager.load(options == null ? null : options.getTheme());\n        TuiTheme theme = configManager == null ? new TuiTheme() : configManager.resolveTheme(config.getTheme());\n        TuiRenderer renderer = new TuiSessionView(config, theme, terminal != null && terminal.supportsAnsi());\n        boolean useAlternateScreen = config != null && config.isUseAlternateScreen();\n        TuiRuntime runtime = !useAlternateScreen && terminal instanceof JlineTerminalIO\n                ? new AppendOnlyTuiRuntime(terminal)\n                : new AnsiTuiRuntime(terminal, renderer, useAlternateScreen);\n        return new CodingCliTuiSupport(config, theme, renderer, runtime);\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpConfig.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class CliMcpConfig {\n\n    private Map<String, CliMcpServerDefinition> mcpServers = new LinkedHashMap<String, CliMcpServerDefinition>();\n\n    public CliMcpConfig() {\n    }\n\n    public CliMcpConfig(Map<String, CliMcpServerDefinition> mcpServers) {\n        this.mcpServers = mcpServers == null\n                ? new LinkedHashMap<String, CliMcpServerDefinition>()\n                : new LinkedHashMap<String, CliMcpServerDefinition>(mcpServers);\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public Builder toBuilder() {\n        return new Builder().mcpServers(mcpServers);\n    }\n\n    public Map<String, CliMcpServerDefinition> getMcpServers() {\n        return mcpServers;\n    }\n\n    public void setMcpServers(Map<String, CliMcpServerDefinition> mcpServers) {\n        this.mcpServers = mcpServers == null\n                ? new LinkedHashMap<String, CliMcpServerDefinition>()\n                : new LinkedHashMap<String, CliMcpServerDefinition>(mcpServers);\n    }\n\n    public static final class Builder {\n\n        private Map<String, CliMcpServerDefinition> mcpServers = new LinkedHashMap<String, CliMcpServerDefinition>();\n\n        private Builder() {\n        }\n\n        public Builder mcpServers(Map<String, CliMcpServerDefinition> mcpServers) {\n            this.mcpServers = mcpServers == null\n                    ? new LinkedHashMap<String, CliMcpServerDefinition>()\n                    : new LinkedHashMap<String, CliMcpServerDefinition>(mcpServers);\n            return this;\n        }\n\n        public CliMcpConfig build() {\n            return new CliMcpConfig(mcpServers);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpConfigManager.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONWriter;\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class CliMcpConfigManager {\n\n    private final Path workspaceRoot;\n\n    public CliMcpConfigManager(Path workspaceRoot) {\n        this.workspaceRoot = workspaceRoot == null\n                ? Paths.get(\".\").toAbsolutePath().normalize()\n                : workspaceRoot.toAbsolutePath().normalize();\n    }\n\n    public CliMcpConfig loadGlobalConfig() {\n        CliMcpConfig config = loadConfig(globalMcpPath(), CliMcpConfig.class);\n        if (config == null) {\n            config = new CliMcpConfig();\n        }\n        boolean changed = normalize(config);\n        if (changed) {\n            persistNormalizedGlobalConfig(config);\n        }\n        return config;\n    }\n\n    public CliMcpConfig saveGlobalConfig(CliMcpConfig config) throws IOException {\n        CliMcpConfig normalized = config == null ? new CliMcpConfig() : config;\n        normalize(normalized);\n        Path path = globalMcpPath();\n        if (path == null) {\n            throw new IOException(\"user home directory is unavailable\");\n        }\n        Files.createDirectories(path.getParent());\n        Files.write(path, JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8));\n        return normalized;\n    }\n\n    public CliWorkspaceConfig loadWorkspaceConfig() {\n        CliWorkspaceConfig config = loadConfig(workspaceConfigPath(), CliWorkspaceConfig.class);\n        if (config == null) {\n            config = new CliWorkspaceConfig();\n        }\n        normalize(config);\n        return config;\n    }\n\n    public CliWorkspaceConfig saveWorkspaceConfig(CliWorkspaceConfig config) throws IOException {\n        CliWorkspaceConfig normalized = config == null ? new CliWorkspaceConfig() : config;\n        normalize(normalized);\n        Path path = workspaceConfigPath();\n        Files.createDirectories(path.getParent());\n        Files.write(path, JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8));\n        return normalized;\n    }\n\n    public CliResolvedMcpConfig resolve(Collection<String> pausedServerNames) {\n        CliMcpConfig globalConfig = loadGlobalConfig();\n        CliWorkspaceConfig workspaceConfig = loadWorkspaceConfig();\n\n        Set<String> enabledNames = toOrderedSet(workspaceConfig.getEnabledMcpServers());\n        Set<String> pausedNames = toOrderedSet(pausedServerNames);\n        Map<String, CliResolvedMcpServer> resolvedServers = new LinkedHashMap<String, CliResolvedMcpServer>();\n        List<String> effectiveEnabledNames = new ArrayList<String>();\n        List<String> unknownEnabledNames = new ArrayList<String>();\n\n        Map<String, CliMcpServerDefinition> definitions = globalConfig.getMcpServers();\n        if (definitions == null) {\n            definitions = Collections.emptyMap();\n        }\n\n        for (Map.Entry<String, CliMcpServerDefinition> entry : definitions.entrySet()) {\n            String name = entry.getKey();\n            CliMcpServerDefinition definition = entry.getValue();\n            boolean workspaceEnabled = enabledNames.contains(name);\n            boolean sessionPaused = pausedNames.contains(name);\n            String validationError = validate(definition);\n            boolean active = workspaceEnabled && !sessionPaused && validationError == null;\n            if (workspaceEnabled) {\n                effectiveEnabledNames.add(name);\n            }\n            resolvedServers.put(name, new CliResolvedMcpServer(\n                    name,\n                    definition == null ? null : definition.getType(),\n                    workspaceEnabled,\n                    sessionPaused,\n                    active,\n                    validationError,\n                    definition\n            ));\n        }\n\n        for (String enabledName : enabledNames) {\n            if (!definitions.containsKey(enabledName)) {\n                unknownEnabledNames.add(enabledName);\n            }\n        }\n\n        List<String> effectivePausedNames = new ArrayList<String>();\n        for (String pausedName : pausedNames) {\n            if (definitions.containsKey(pausedName)) {\n                effectivePausedNames.add(pausedName);\n            }\n        }\n\n        return new CliResolvedMcpConfig(\n                resolvedServers,\n                effectiveEnabledNames,\n                effectivePausedNames,\n                unknownEnabledNames\n        );\n    }\n\n    public Path globalMcpPath() {\n        String userHome = System.getProperty(\"user.home\");\n        return isBlank(userHome) ? null : Paths.get(userHome).resolve(\".ai4j\").resolve(\"mcp.json\");\n    }\n\n    public Path workspaceConfigPath() {\n        return workspaceRoot.resolve(\".ai4j\").resolve(\"workspace.json\");\n    }\n\n    private <T> T loadConfig(Path path, Class<T> type) {\n        if (path == null || type == null || !Files.exists(path)) {\n            return null;\n        }\n        try {\n            return JSON.parseObject(Files.readAllBytes(path), type);\n        } catch (IOException ex) {\n            return null;\n        }\n    }\n\n    private void persistNormalizedGlobalConfig(CliMcpConfig config) {\n        Path path = globalMcpPath();\n        if (path == null) {\n            return;\n        }\n        try {\n            Files.createDirectories(path.getParent());\n            Files.write(path, JSON.toJSONString(config, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8));\n        } catch (IOException ignored) {\n        }\n    }\n\n    private boolean normalize(CliMcpConfig config) {\n        if (config == null) {\n            return false;\n        }\n        boolean changed = false;\n        Map<String, CliMcpServerDefinition> currentServers = config.getMcpServers();\n        if (currentServers == null) {\n            config.setMcpServers(new LinkedHashMap<String, CliMcpServerDefinition>());\n            return true;\n        }\n\n        Map<String, CliMcpServerDefinition> normalizedServers = new LinkedHashMap<String, CliMcpServerDefinition>();\n        for (Map.Entry<String, CliMcpServerDefinition> entry : currentServers.entrySet()) {\n            String normalizedName = normalizeName(entry.getKey());\n            if (isBlank(normalizedName)) {\n                changed = true;\n                continue;\n            }\n            CliMcpServerDefinition definition = entry.getValue();\n            CliMcpServerDefinition normalizedDefinition = normalize(definition);\n            normalizedServers.put(normalizedName, normalizedDefinition);\n            if (!normalizedName.equals(entry.getKey()) || normalizedDefinition != definition) {\n                changed = true;\n            }\n        }\n\n        if (!normalizedServers.equals(currentServers)) {\n            config.setMcpServers(normalizedServers);\n            changed = true;\n        }\n        return changed;\n    }\n\n    private void normalize(CliWorkspaceConfig config) {\n        if (config == null) {\n            return;\n        }\n        config.setActiveProfile(normalizeName(config.getActiveProfile()));\n        config.setModelOverride(normalizeValue(config.getModelOverride()));\n        config.setEnabledMcpServers(normalizeNames(config.getEnabledMcpServers()));\n        config.setSkillDirectories(normalizeNames(config.getSkillDirectories()));\n        config.setAgentDirectories(normalizeNames(config.getAgentDirectories()));\n    }\n\n    private CliMcpServerDefinition normalize(CliMcpServerDefinition definition) {\n        String command = definition == null ? null : normalizeValue(definition.getCommand());\n        return CliMcpServerDefinition.builder()\n                .type(normalizeTransportType(definition == null ? null : definition.getType(), command))\n                .url(definition == null ? null : normalizeValue(definition.getUrl()))\n                .command(command)\n                .args(definition == null ? null : normalizeNames(definition.getArgs()))\n                .env(definition == null ? null : normalizeMap(definition.getEnv()))\n                .cwd(definition == null ? null : normalizeValue(definition.getCwd()))\n                .headers(definition == null ? null : normalizeMap(definition.getHeaders()))\n                .build();\n    }\n\n    private String validate(CliMcpServerDefinition definition) {\n        if (definition == null) {\n            return \"missing MCP server definition\";\n        }\n        String type = normalizeValue(definition.getType());\n        if (isBlank(type)) {\n            return \"missing MCP transport type\";\n        }\n        if (\"stdio\".equals(type)) {\n            return isBlank(definition.getCommand()) ? \"stdio transport requires command\" : null;\n        }\n        if (\"sse\".equals(type) || \"streamable_http\".equals(type)) {\n            return isBlank(definition.getUrl()) ? type + \" transport requires url\" : null;\n        }\n        return \"unsupported MCP transport: \" + type;\n    }\n\n    private String normalizeTransportType(String rawType, String command) {\n        String normalizedType = normalizeValue(rawType);\n        if (isBlank(normalizedType)) {\n            return isBlank(command) ? null : \"stdio\";\n        }\n        String lowerCaseType = normalizedType.toLowerCase(Locale.ROOT);\n        if (\"http\".equals(lowerCaseType)) {\n            return \"streamable_http\";\n        }\n        return lowerCaseType;\n    }\n\n    private Set<String> toOrderedSet(Collection<String> values) {\n        if (values == null || values.isEmpty()) {\n            return Collections.emptySet();\n        }\n        LinkedHashSet<String> normalized = new LinkedHashSet<String>();\n        for (String value : values) {\n            String candidate = normalizeName(value);\n            if (!isBlank(candidate)) {\n                normalized.add(candidate);\n            }\n        }\n        return normalized;\n    }\n\n    private List<String> normalizeNames(List<String> values) {\n        if (values == null || values.isEmpty()) {\n            return null;\n        }\n        LinkedHashSet<String> normalized = new LinkedHashSet<String>();\n        for (String value : values) {\n            String candidate = normalizeName(value);\n            if (!isBlank(candidate)) {\n                normalized.add(candidate);\n            }\n        }\n        return normalized.isEmpty() ? null : new ArrayList<String>(normalized);\n    }\n\n    private Map<String, String> normalizeMap(Map<String, String> values) {\n        if (values == null || values.isEmpty()) {\n            return null;\n        }\n        LinkedHashMap<String, String> normalized = new LinkedHashMap<String, String>();\n        for (Map.Entry<String, String> entry : values.entrySet()) {\n            String key = normalizeName(entry.getKey());\n            if (isBlank(key)) {\n                continue;\n            }\n            normalized.put(key, normalizeValue(entry.getValue()));\n        }\n        return normalized.isEmpty() ? null : normalized;\n    }\n\n    private String normalizeName(String value) {\n        return normalizeValue(value);\n    }\n\n    private String normalizeValue(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpConnectionHandle.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic final class CliMcpConnectionHandle {\n\n    private final CliResolvedMcpServer server;\n    private CliMcpRuntimeManager.ClientSession clientSession;\n    private String state;\n    private String errorSummary;\n    private List<Tool> tools = Collections.emptyList();\n\n    CliMcpConnectionHandle(CliResolvedMcpServer server) {\n        this.server = server;\n    }\n\n    String getServerName() {\n        return server == null ? null : server.getName();\n    }\n\n    String getTransportType() {\n        return server == null ? null : server.getTransportType();\n    }\n\n    CliResolvedMcpServer getServer() {\n        return server;\n    }\n\n    CliMcpRuntimeManager.ClientSession getClientSession() {\n        return clientSession;\n    }\n\n    void setClientSession(CliMcpRuntimeManager.ClientSession clientSession) {\n        this.clientSession = clientSession;\n    }\n\n    String getState() {\n        return state;\n    }\n\n    void setState(String state) {\n        this.state = state;\n    }\n\n    String getErrorSummary() {\n        return errorSummary;\n    }\n\n    void setErrorSummary(String errorSummary) {\n        this.errorSummary = errorSummary;\n    }\n\n    List<Tool> getTools() {\n        return tools;\n    }\n\n    void setTools(List<Tool> tools) {\n        if (tools == null || tools.isEmpty()) {\n            this.tools = Collections.emptyList();\n            return;\n        }\n        this.tools = Collections.unmodifiableList(new ArrayList<Tool>(tools));\n    }\n\n    CliMcpStatusSnapshot toStatusSnapshot() {\n        return new CliMcpStatusSnapshot(\n                getServerName(),\n                getTransportType(),\n                state,\n                tools == null ? 0 : tools.size(),\n                errorSummary,\n                server != null && server.isWorkspaceEnabled(),\n                server != null && server.isSessionPaused()\n        );\n    }\n\n    void closeQuietly() {\n        CliMcpRuntimeManager.ClientSession session = clientSession;\n        clientSession = null;\n        if (session == null) {\n            return;\n        }\n        try {\n            session.close();\n        } catch (Exception ignored) {\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpRuntimeManager.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.mcp.client.McpClient;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransport;\nimport io.github.lnyocly.ai4j.mcp.transport.McpTransportFactory;\nimport io.github.lnyocly.ai4j.mcp.transport.TransportConfig;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\n\npublic final class CliMcpRuntimeManager implements AutoCloseable {\n\n    public static final String STATE_CONNECTED = \"connected\";\n    public static final String STATE_DISABLED = \"disabled\";\n    public static final String STATE_PAUSED = \"paused\";\n    public static final String STATE_ERROR = \"error\";\n    public static final String STATE_MISSING = \"missing\";\n\n    private static final Set<String> RESERVED_TOOL_NAMES = new LinkedHashSet<String>(Arrays.asList(\n            CodingToolNames.BASH,\n            CodingToolNames.READ_FILE,\n            CodingToolNames.WRITE_FILE,\n            CodingToolNames.APPLY_PATCH\n    ));\n\n    private final CliResolvedMcpConfig resolvedConfig;\n    private final ClientFactory clientFactory;\n    private final Map<String, CliMcpConnectionHandle> handlesByServerName = new LinkedHashMap<String, CliMcpConnectionHandle>();\n    private final Map<String, CliMcpConnectionHandle> handlesByToolName = new LinkedHashMap<String, CliMcpConnectionHandle>();\n    private List<CliMcpStatusSnapshot> statuses = Collections.emptyList();\n    private AgentToolRegistry toolRegistry;\n    private ToolExecutor toolExecutor;\n\n    public static CliMcpRuntimeManager initialize(Path workspaceRoot, Collection<String> pausedServerNames) {\n        CliResolvedMcpConfig resolvedConfig = new CliMcpConfigManager(workspaceRoot).resolve(pausedServerNames);\n        return initialize(resolvedConfig);\n    }\n\n    public static CliMcpRuntimeManager initialize(CliResolvedMcpConfig resolvedConfig) {\n        if (!hasRelevantConfiguration(resolvedConfig)) {\n            return null;\n        }\n        CliMcpRuntimeManager runtimeManager = new CliMcpRuntimeManager(resolvedConfig);\n        runtimeManager.start();\n        return runtimeManager;\n    }\n\n    CliMcpRuntimeManager(CliResolvedMcpConfig resolvedConfig) {\n        this(resolvedConfig, new DefaultClientFactory());\n    }\n\n    CliMcpRuntimeManager(CliResolvedMcpConfig resolvedConfig, ClientFactory clientFactory) {\n        this.resolvedConfig = resolvedConfig == null\n                ? new CliResolvedMcpConfig(null, null, null, null)\n                : resolvedConfig;\n        this.clientFactory = clientFactory == null ? new DefaultClientFactory() : clientFactory;\n    }\n\n    public void start() {\n        close();\n        List<CliMcpStatusSnapshot> nextStatuses = new ArrayList<CliMcpStatusSnapshot>();\n        Map<String, String> claimedToolOwners = new LinkedHashMap<String, String>();\n\n        for (CliResolvedMcpServer server : resolvedConfig.getServers().values()) {\n            CliMcpConnectionHandle handle = new CliMcpConnectionHandle(server);\n            handlesByServerName.put(server.getName(), handle);\n\n            if (!server.isWorkspaceEnabled()) {\n                handle.setState(STATE_DISABLED);\n                nextStatuses.add(handle.toStatusSnapshot());\n                continue;\n            }\n            if (server.isSessionPaused()) {\n                handle.setState(STATE_PAUSED);\n                nextStatuses.add(handle.toStatusSnapshot());\n                continue;\n            }\n            if (!server.isValid()) {\n                handle.setState(STATE_ERROR);\n                handle.setErrorSummary(server.getValidationError());\n                nextStatuses.add(handle.toStatusSnapshot());\n                continue;\n            }\n\n            try {\n                ClientSession clientSession = clientFactory.create(server);\n                handle.setClientSession(clientSession);\n                clientSession.connect();\n\n                List<McpToolDefinition> toolDefinitions = clientSession.listTools();\n                validateToolNames(server, toolDefinitions, claimedToolOwners);\n                List<Tool> tools = convertTools(toolDefinitions);\n                handle.setTools(tools);\n                handle.setState(STATE_CONNECTED);\n\n                for (Tool tool : tools) {\n                    if (tool == null || tool.getFunction() == null || isBlank(tool.getFunction().getName())) {\n                        continue;\n                    }\n                    String toolName = tool.getFunction().getName().trim();\n                    claimedToolOwners.put(toolName.toLowerCase(Locale.ROOT), server.getName());\n                    handlesByToolName.put(toolName, handle);\n                }\n            } catch (Exception ex) {\n                handle.setState(STATE_ERROR);\n                handle.setErrorSummary(safeMessage(ex));\n                handle.closeQuietly();\n            }\n            nextStatuses.add(handle.toStatusSnapshot());\n        }\n\n        for (String missing : resolvedConfig.getUnknownEnabledServerNames()) {\n            nextStatuses.add(new CliMcpStatusSnapshot(missing, null, STATE_MISSING, 0,\n                    \"workspace references undefined MCP server\", true, false));\n        }\n\n        rebuildToolView();\n        statuses = Collections.unmodifiableList(nextStatuses);\n    }\n\n    public AgentToolRegistry getToolRegistry() {\n        return toolRegistry;\n    }\n\n    public ToolExecutor getToolExecutor() {\n        return toolExecutor;\n    }\n\n    public List<CliMcpStatusSnapshot> getStatuses() {\n        return statuses;\n    }\n\n    public List<String> buildStartupWarnings() {\n        if (statuses == null || statuses.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> warnings = new ArrayList<String>();\n        for (CliMcpStatusSnapshot status : statuses) {\n            if (status == null) {\n                continue;\n            }\n            if (!STATE_ERROR.equals(status.getState()) && !STATE_MISSING.equals(status.getState())) {\n                continue;\n            }\n            warnings.add(\"MCP unavailable: \"\n                    + firstNonBlank(status.getServerName(), \"(unknown)\")\n                    + \" (\"\n                    + firstNonBlank(status.getErrorSummary(), \"unknown MCP error\")\n                    + \")\");\n        }\n        return warnings.isEmpty()\n                ? Collections.<String>emptyList()\n                : Collections.unmodifiableList(warnings);\n    }\n\n    public boolean hasStatuses() {\n        return statuses != null && !statuses.isEmpty();\n    }\n\n    public String findServerNameByToolName(String toolName) {\n        if (isBlank(toolName)) {\n            return null;\n        }\n        CliMcpConnectionHandle handle = handlesByToolName.get(toolName.trim());\n        return handle == null ? null : handle.getServerName();\n    }\n\n    @Override\n    public void close() {\n        for (CliMcpConnectionHandle handle : handlesByServerName.values()) {\n            if (handle != null) {\n                handle.closeQuietly();\n            }\n        }\n        handlesByServerName.clear();\n        handlesByToolName.clear();\n        toolRegistry = null;\n        toolExecutor = null;\n        if (statuses == null) {\n            statuses = Collections.emptyList();\n        }\n    }\n\n    private void rebuildToolView() {\n        List<Object> tools = new ArrayList<Object>();\n        for (CliMcpConnectionHandle handle : handlesByServerName.values()) {\n            if (!STATE_CONNECTED.equals(handle.getState()) || handle.getTools().isEmpty()) {\n                continue;\n            }\n            tools.addAll(handle.getTools());\n        }\n        if (tools.isEmpty()) {\n            toolRegistry = null;\n            toolExecutor = null;\n            return;\n        }\n        toolRegistry = new StaticToolRegistry(tools);\n        toolExecutor = new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) throws Exception {\n                if (call == null || isBlank(call.getName())) {\n                    throw new IllegalArgumentException(\"MCP tool call is missing tool name\");\n                }\n                CliMcpConnectionHandle handle = handlesByToolName.get(call.getName());\n                if (handle == null || handle.getClientSession() == null) {\n                    throw new IllegalArgumentException(\"Unknown MCP tool: \" + call.getName());\n                }\n                Object arguments = parseArguments(call.getArguments());\n                return handle.getClientSession().callTool(call.getName(), arguments);\n            }\n        };\n    }\n\n    private void validateToolNames(CliResolvedMcpServer server,\n                                   List<McpToolDefinition> toolDefinitions,\n                                   Map<String, String> claimedToolOwners) {\n        Set<String> localToolNames = new LinkedHashSet<String>();\n        if (toolDefinitions == null) {\n            return;\n        }\n        for (McpToolDefinition toolDefinition : toolDefinitions) {\n            String toolName = toolDefinition == null ? null : normalizeToolName(toolDefinition.getName());\n            if (isBlank(toolName)) {\n                throw new IllegalStateException(\"MCP server \" + server.getName() + \" returned a tool without a name\");\n            }\n            String normalizedToolName = toolName.toLowerCase(Locale.ROOT);\n            if (RESERVED_TOOL_NAMES.contains(normalizedToolName)) {\n                throw new IllegalStateException(\"MCP tool name conflicts with built-in tool: \" + toolName);\n            }\n            if (!localToolNames.add(normalizedToolName)) {\n                throw new IllegalStateException(\"MCP server \" + server.getName() + \" returned duplicate tool: \" + toolName);\n            }\n            String existingOwner = claimedToolOwners.get(normalizedToolName);\n            if (!isBlank(existingOwner)) {\n                throw new IllegalStateException(\"MCP tool name conflict: \" + toolName + \" already provided by \" + existingOwner);\n            }\n        }\n    }\n\n    private List<Tool> convertTools(List<McpToolDefinition> toolDefinitions) {\n        if (toolDefinitions == null || toolDefinitions.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Tool> tools = new ArrayList<Tool>();\n        for (McpToolDefinition toolDefinition : toolDefinitions) {\n            if (toolDefinition == null || isBlank(toolDefinition.getName())) {\n                continue;\n            }\n            Tool tool = new Tool();\n            tool.setType(\"function\");\n            Tool.Function function = new Tool.Function();\n            function.setName(normalizeToolName(toolDefinition.getName()));\n            function.setDescription(toolDefinition.getDescription());\n            function.setParameters(convertInputSchema(toolDefinition.getInputSchema()));\n            tool.setFunction(function);\n            tools.add(tool);\n        }\n        return tools;\n    }\n\n    private Tool.Function.Parameter convertInputSchema(Map<String, Object> inputSchema) {\n        Tool.Function.Parameter parameter = new Tool.Function.Parameter();\n        parameter.setType(\"object\");\n        if (inputSchema == null || inputSchema.isEmpty()) {\n            return parameter;\n        }\n\n        Object properties = inputSchema.get(\"properties\");\n        if (properties instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> propertyMap = (Map<String, Object>) properties;\n            Map<String, Tool.Function.Property> convertedProperties = new LinkedHashMap<String, Tool.Function.Property>();\n            for (Map.Entry<String, Object> entry : propertyMap.entrySet()) {\n                if (isBlank(entry.getKey()) || !(entry.getValue() instanceof Map)) {\n                    continue;\n                }\n                @SuppressWarnings(\"unchecked\")\n                Map<String, Object> rawProperty = (Map<String, Object>) entry.getValue();\n                convertedProperties.put(entry.getKey(), convertProperty(rawProperty));\n            }\n            parameter.setProperties(convertedProperties);\n        }\n\n        Object required = inputSchema.get(\"required\");\n        if (required instanceof List) {\n            @SuppressWarnings(\"unchecked\")\n            List<String> requiredNames = (List<String>) required;\n            parameter.setRequired(requiredNames);\n        }\n        return parameter;\n    }\n\n    private Tool.Function.Property convertProperty(Map<String, Object> rawProperty) {\n        Tool.Function.Property property = new Tool.Function.Property();\n        property.setType(asString(rawProperty.get(\"type\")));\n        property.setDescription(asString(rawProperty.get(\"description\")));\n\n        Object enumValues = rawProperty.get(\"enum\");\n        if (enumValues instanceof List) {\n            @SuppressWarnings(\"unchecked\")\n            List<String> values = (List<String>) enumValues;\n            property.setEnumValues(values);\n        }\n\n        Object items = rawProperty.get(\"items\");\n        if (items instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> rawItems = (Map<String, Object>) items;\n            property.setItems(convertProperty(rawItems));\n        }\n        return property;\n    }\n\n    private Object parseArguments(String rawArguments) {\n        if (isBlank(rawArguments)) {\n            return Collections.emptyMap();\n        }\n        try {\n            return JSON.parseObject(rawArguments, Map.class);\n        } catch (Exception ex) {\n            throw new IllegalArgumentException(\"Invalid MCP tool arguments: \" + safeMessage(ex), ex);\n        }\n    }\n\n    private String normalizeToolName(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private String asString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private String safeMessage(Throwable throwable) {\n        String message = null;\n        Throwable last = throwable;\n        Throwable current = throwable;\n        while (current != null) {\n            if (!isBlank(current.getMessage())) {\n                message = current.getMessage().trim();\n            }\n            last = current;\n            current = current.getCause();\n        }\n        return isBlank(message)\n                ? (last == null ? \"unknown MCP error\" : last.getClass().getSimpleName())\n                : message;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static boolean hasRelevantConfiguration(CliResolvedMcpConfig resolvedConfig) {\n        if (resolvedConfig == null) {\n            return false;\n        }\n        if (!resolvedConfig.getServers().isEmpty()) {\n            return true;\n        }\n        return !resolvedConfig.getUnknownEnabledServerNames().isEmpty();\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    interface ClientFactory {\n\n        ClientSession create(CliResolvedMcpServer server) throws Exception;\n    }\n\n    interface ClientSession extends AutoCloseable {\n\n        void connect() throws Exception;\n\n        List<McpToolDefinition> listTools() throws Exception;\n\n        String callTool(String toolName, Object arguments) throws Exception;\n\n        @Override\n        void close() throws Exception;\n    }\n\n    private static final class DefaultClientFactory implements ClientFactory {\n\n        @Override\n        public ClientSession create(CliResolvedMcpServer server) throws Exception {\n            CliMcpServerDefinition definition = server == null ? null : server.getDefinition();\n            if (definition == null) {\n                throw new IllegalArgumentException(\"missing MCP definition\");\n            }\n\n            TransportConfig config = new TransportConfig();\n            config.setType(definition.getType());\n            config.setUrl(definition.getUrl());\n            config.setCommand(definition.getCommand());\n            config.setArgs(definition.getArgs());\n            config.setEnv(definition.getEnv());\n            config.setHeaders(definition.getHeaders());\n\n            McpTransport transport = McpTransportFactory.createTransport(definition.getType(), config);\n            McpClient client = new McpClient(\"ai4j-cli-\" + server.getName(), \"2.1.0\", transport, false);\n            return new McpClientSessionAdapter(client);\n        }\n    }\n\n    private static final class McpClientSessionAdapter implements ClientSession {\n\n        private final McpClient delegate;\n\n        private McpClientSessionAdapter(McpClient delegate) {\n            this.delegate = delegate;\n        }\n\n        @Override\n        public void connect() {\n            delegate.connect().join();\n        }\n\n        @Override\n        public List<McpToolDefinition> listTools() {\n            return delegate.getAvailableTools().join();\n        }\n\n        @Override\n        public String callTool(String toolName, Object arguments) {\n            return delegate.callTool(toolName, arguments).join();\n        }\n\n        @Override\n        public void close() {\n            delegate.disconnect().join();\n        }\n    }\n}\n\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpServerDefinition.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class CliMcpServerDefinition {\n\n    private String type;\n    private String url;\n    private String command;\n    private List<String> args;\n    private Map<String, String> env;\n    private String cwd;\n    private Map<String, String> headers;\n\n    public CliMcpServerDefinition() {\n    }\n\n    public CliMcpServerDefinition(String type,\n                                  String url,\n                                  String command,\n                                  List<String> args,\n                                  Map<String, String> env,\n                                  String cwd,\n                                  Map<String, String> headers) {\n        this.type = type;\n        this.url = url;\n        this.command = command;\n        this.args = copyList(args);\n        this.env = copyMap(env);\n        this.cwd = cwd;\n        this.headers = copyMap(headers);\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public Builder toBuilder() {\n        return new Builder()\n                .type(type)\n                .url(url)\n                .command(command)\n                .args(args)\n                .env(env)\n                .cwd(cwd)\n                .headers(headers);\n    }\n\n    public String getType() {\n        return type;\n    }\n\n    public void setType(String type) {\n        this.type = type;\n    }\n\n    public String getUrl() {\n        return url;\n    }\n\n    public void setUrl(String url) {\n        this.url = url;\n    }\n\n    public String getCommand() {\n        return command;\n    }\n\n    public void setCommand(String command) {\n        this.command = command;\n    }\n\n    public List<String> getArgs() {\n        return copyList(args);\n    }\n\n    public void setArgs(List<String> args) {\n        this.args = copyList(args);\n    }\n\n    public Map<String, String> getEnv() {\n        return copyMap(env);\n    }\n\n    public void setEnv(Map<String, String> env) {\n        this.env = copyMap(env);\n    }\n\n    public String getCwd() {\n        return cwd;\n    }\n\n    public void setCwd(String cwd) {\n        this.cwd = cwd;\n    }\n\n    public Map<String, String> getHeaders() {\n        return copyMap(headers);\n    }\n\n    public void setHeaders(Map<String, String> headers) {\n        this.headers = copyMap(headers);\n    }\n\n    private List<String> copyList(List<String> values) {\n        return values == null ? null : new ArrayList<String>(values);\n    }\n\n    private Map<String, String> copyMap(Map<String, String> values) {\n        return values == null ? null : new LinkedHashMap<String, String>(values);\n    }\n\n    public static final class Builder {\n\n        private String type;\n        private String url;\n        private String command;\n        private List<String> args;\n        private Map<String, String> env;\n        private String cwd;\n        private Map<String, String> headers;\n\n        private Builder() {\n        }\n\n        public Builder type(String type) {\n            this.type = type;\n            return this;\n        }\n\n        public Builder url(String url) {\n            this.url = url;\n            return this;\n        }\n\n        public Builder command(String command) {\n            this.command = command;\n            return this;\n        }\n\n        public Builder args(List<String> args) {\n            this.args = args == null ? null : new ArrayList<String>(args);\n            return this;\n        }\n\n        public Builder env(Map<String, String> env) {\n            this.env = env == null ? null : new LinkedHashMap<String, String>(env);\n            return this;\n        }\n\n        public Builder cwd(String cwd) {\n            this.cwd = cwd;\n            return this;\n        }\n\n        public Builder headers(Map<String, String> headers) {\n            this.headers = headers == null ? null : new LinkedHashMap<String, String>(headers);\n            return this;\n        }\n\n        public CliMcpServerDefinition build() {\n            return new CliMcpServerDefinition(type, url, command, args, env, cwd, headers);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliMcpStatusSnapshot.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\npublic final class CliMcpStatusSnapshot {\n\n    private final String serverName;\n    private final String transportType;\n    private final String state;\n    private final int toolCount;\n    private final String errorSummary;\n    private final boolean workspaceEnabled;\n    private final boolean sessionPaused;\n\n    public CliMcpStatusSnapshot(String serverName,\n                                String transportType,\n                                String state,\n                                int toolCount,\n                                String errorSummary,\n                                boolean workspaceEnabled,\n                                boolean sessionPaused) {\n        this.serverName = serverName;\n        this.transportType = transportType;\n        this.state = state;\n        this.toolCount = toolCount;\n        this.errorSummary = errorSummary;\n        this.workspaceEnabled = workspaceEnabled;\n        this.sessionPaused = sessionPaused;\n    }\n\n    public String getServerName() {\n        return serverName;\n    }\n\n    public String getTransportType() {\n        return transportType;\n    }\n\n    public String getState() {\n        return state;\n    }\n\n    public int getToolCount() {\n        return toolCount;\n    }\n\n    public String getErrorSummary() {\n        return errorSummary;\n    }\n\n    public boolean isWorkspaceEnabled() {\n        return workspaceEnabled;\n    }\n\n    public boolean isSessionPaused() {\n        return sessionPaused;\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliResolvedMcpConfig.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic final class CliResolvedMcpConfig {\n\n    private final Map<String, CliResolvedMcpServer> servers;\n    private final List<String> enabledServerNames;\n    private final List<String> pausedServerNames;\n    private final List<String> unknownEnabledServerNames;\n\n    public CliResolvedMcpConfig(Map<String, CliResolvedMcpServer> servers,\n                                List<String> enabledServerNames,\n                                List<String> pausedServerNames,\n                                List<String> unknownEnabledServerNames) {\n        this.servers = servers == null\n                ? Collections.<String, CliResolvedMcpServer>emptyMap()\n                : Collections.unmodifiableMap(new LinkedHashMap<String, CliResolvedMcpServer>(servers));\n        this.enabledServerNames = enabledServerNames == null\n                ? Collections.<String>emptyList()\n                : Collections.unmodifiableList(new ArrayList<String>(enabledServerNames));\n        this.pausedServerNames = pausedServerNames == null\n                ? Collections.<String>emptyList()\n                : Collections.unmodifiableList(new ArrayList<String>(pausedServerNames));\n        this.unknownEnabledServerNames = unknownEnabledServerNames == null\n                ? Collections.<String>emptyList()\n                : Collections.unmodifiableList(new ArrayList<String>(unknownEnabledServerNames));\n    }\n\n    public Map<String, CliResolvedMcpServer> getServers() {\n        return servers;\n    }\n\n    public List<String> getEnabledServerNames() {\n        return enabledServerNames;\n    }\n\n    public List<String> getPausedServerNames() {\n        return pausedServerNames;\n    }\n\n    public List<String> getUnknownEnabledServerNames() {\n        return unknownEnabledServerNames;\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/mcp/CliResolvedMcpServer.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\npublic final class CliResolvedMcpServer {\n\n    private final String name;\n    private final String transportType;\n    private final boolean workspaceEnabled;\n    private final boolean sessionPaused;\n    private final boolean active;\n    private final String validationError;\n    private final CliMcpServerDefinition definition;\n\n    public CliResolvedMcpServer(String name,\n                                String transportType,\n                                boolean workspaceEnabled,\n                                boolean sessionPaused,\n                                boolean active,\n                                String validationError,\n                                CliMcpServerDefinition definition) {\n        this.name = name;\n        this.transportType = transportType;\n        this.workspaceEnabled = workspaceEnabled;\n        this.sessionPaused = sessionPaused;\n        this.active = active;\n        this.validationError = validationError;\n        this.definition = definition;\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public String getTransportType() {\n        return transportType;\n    }\n\n    public boolean isWorkspaceEnabled() {\n        return workspaceEnabled;\n    }\n\n    public boolean isSessionPaused() {\n        return sessionPaused;\n    }\n\n    public boolean isActive() {\n        return active;\n    }\n\n    public String getValidationError() {\n        return validationError;\n    }\n\n    public CliMcpServerDefinition getDefinition() {\n        return definition;\n    }\n\n    public boolean isValid() {\n        return validationError == null || validationError.isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/provider/CliProviderConfigManager.java",
    "content": "package io.github.lnyocly.ai4j.cli.provider;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONWriter;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport io.github.lnyocly.ai4j.service.PlatformType;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Properties;\n\npublic class CliProviderConfigManager {\n\n    private final Path workspaceRoot;\n\n    public CliProviderConfigManager(Path workspaceRoot) {\n        this.workspaceRoot = workspaceRoot == null\n                ? Paths.get(\".\").toAbsolutePath().normalize()\n                : workspaceRoot.toAbsolutePath().normalize();\n    }\n\n    public CliProvidersConfig loadProvidersConfig() {\n        CliProvidersConfig config = loadConfig(globalProvidersPath(), CliProvidersConfig.class);\n        if (config == null) {\n            config = new CliProvidersConfig();\n        }\n        boolean changed = normalize(config);\n        if (changed) {\n            persistNormalizedProvidersConfig(config);\n        }\n        return config;\n    }\n\n    public CliProvidersConfig saveProvidersConfig(CliProvidersConfig config) throws IOException {\n        CliProvidersConfig normalized = config == null ? new CliProvidersConfig() : config;\n        normalize(normalized);\n        Path path = globalProvidersPath();\n        if (path == null) {\n            throw new IOException(\"user home directory is unavailable\");\n        }\n        Files.createDirectories(path.getParent());\n        Files.write(path, JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8));\n        return normalized;\n    }\n\n    public CliWorkspaceConfig loadWorkspaceConfig() {\n        CliWorkspaceConfig config = loadConfig(workspaceConfigPath(), CliWorkspaceConfig.class);\n        if (config == null) {\n            config = new CliWorkspaceConfig();\n        }\n        normalize(config);\n        return config;\n    }\n\n    public CliWorkspaceConfig saveWorkspaceConfig(CliWorkspaceConfig config) throws IOException {\n        CliWorkspaceConfig normalized = config == null ? new CliWorkspaceConfig() : config;\n        normalize(normalized);\n        Path path = workspaceConfigPath();\n        Files.createDirectories(path.getParent());\n        Files.write(path, JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8));\n        return normalized;\n    }\n\n    public List<String> listProfileNames() {\n        Map<String, CliProviderProfile> profiles = loadProvidersConfig().getProfiles();\n        if (profiles == null || profiles.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> names = new ArrayList<String>(profiles.keySet());\n        Collections.sort(names);\n        return names;\n    }\n\n    public CliProviderProfile getProfile(String name) {\n        if (isBlank(name)) {\n            return null;\n        }\n        return loadProvidersConfig().getProfiles().get(name.trim());\n    }\n\n    public CliResolvedProviderConfig resolve(String providerOverride,\n                                             String protocolOverride,\n                                             String modelOverride,\n                                             String apiKeyOverride,\n                                             String baseUrlOverride,\n                                             Map<String, String> env,\n                                             Properties properties) {\n        CliProvidersConfig providersConfig = loadProvidersConfig();\n        CliWorkspaceConfig workspaceConfig = loadWorkspaceConfig();\n        CliProviderProfile activeProfile = resolveProfile(providersConfig, workspaceConfig.getActiveProfile());\n        CliProviderProfile defaultProfile = resolveProfile(providersConfig, providersConfig.getDefaultProfile());\n        PlatformType explicitProvider = isBlank(providerOverride) ? null : resolveProvider(providerOverride);\n        if (explicitProvider != null) {\n            activeProfile = alignProfileWithProvider(activeProfile, explicitProvider);\n            defaultProfile = alignProfileWithProvider(defaultProfile, explicitProvider);\n        }\n\n        String effectiveProfile = !isBlank(workspaceConfig.getActiveProfile()) && activeProfile != null\n                ? workspaceConfig.getActiveProfile().trim()\n                : (!isBlank(providersConfig.getDefaultProfile()) && defaultProfile != null\n                ? providersConfig.getDefaultProfile().trim()\n                : null);\n\n        PlatformType provider = resolveProvider(firstNonBlank(\n                providerOverride,\n                activeProfile == null ? null : activeProfile.getProvider(),\n                defaultProfile == null ? null : defaultProfile.getProvider(),\n                envValue(env, \"AI4J_PROVIDER\"),\n                propertyValue(properties, \"ai4j.provider\"),\n                \"openai\"\n        ));\n\n        String baseUrl = firstNonBlank(\n                baseUrlOverride,\n                activeProfile == null ? null : activeProfile.getBaseUrl(),\n                defaultProfile == null ? null : defaultProfile.getBaseUrl(),\n                envValue(env, \"AI4J_BASE_URL\"),\n                propertyValue(properties, \"ai4j.base-url\")\n        );\n\n        CliProtocol protocol = CliProtocol.resolveConfigured(firstNonBlank(\n                protocolOverride,\n                activeProfile == null ? null : activeProfile.getProtocol(),\n                defaultProfile == null ? null : defaultProfile.getProtocol(),\n                envValue(env, \"AI4J_PROTOCOL\"),\n                propertyValue(properties, \"ai4j.protocol\")\n        ), provider, baseUrl);\n\n        String model = firstNonBlank(\n                modelOverride,\n                workspaceConfig.getModelOverride(),\n                activeProfile == null ? null : activeProfile.getModel(),\n                defaultProfile == null ? null : defaultProfile.getModel(),\n                envValue(env, \"AI4J_MODEL\"),\n                propertyValue(properties, \"ai4j.model\")\n        );\n\n        String apiKey = firstNonBlank(\n                apiKeyOverride,\n                activeProfile == null ? null : activeProfile.getApiKey(),\n                defaultProfile == null ? null : defaultProfile.getApiKey(),\n                envValue(env, \"AI4J_API_KEY\"),\n                propertyValue(properties, \"ai4j.api.key\"),\n                providerApiKeyEnv(env, provider)\n        );\n\n        return new CliResolvedProviderConfig(\n                normalizeName(workspaceConfig.getActiveProfile()),\n                normalizeName(providersConfig.getDefaultProfile()),\n                effectiveProfile,\n                normalizeValue(workspaceConfig.getModelOverride()),\n                provider,\n                protocol,\n                normalizeValue(model),\n                normalizeValue(apiKey),\n                normalizeValue(baseUrl)\n        );\n    }\n\n    public Path globalProvidersPath() {\n        String userHome = System.getProperty(\"user.home\");\n        return isBlank(userHome) ? null : Paths.get(userHome).resolve(\".ai4j\").resolve(\"providers.json\");\n    }\n\n    public Path workspaceConfigPath() {\n        return workspaceRoot.resolve(\".ai4j\").resolve(\"workspace.json\");\n    }\n\n    private <T> T loadConfig(Path path, Class<T> type) {\n        if (path == null || type == null || !Files.exists(path)) {\n            return null;\n        }\n        try {\n            return JSON.parseObject(Files.readAllBytes(path), type);\n        } catch (IOException ex) {\n            return null;\n        }\n    }\n\n    private CliProviderProfile resolveProfile(CliProvidersConfig config, String name) {\n        if (config == null || config.getProfiles() == null || isBlank(name)) {\n            return null;\n        }\n        return config.getProfiles().get(name.trim());\n    }\n\n    private CliProviderProfile alignProfileWithProvider(CliProviderProfile profile, PlatformType provider) {\n        if (profile == null || provider == null) {\n            return profile;\n        }\n        String profileProvider = normalizeValue(profile.getProvider());\n        if (isBlank(profileProvider)) {\n            return null;\n        }\n        return provider.getPlatform().equalsIgnoreCase(profileProvider) ? profile : null;\n    }\n\n    private PlatformType resolveProvider(String raw) {\n        if (isBlank(raw)) {\n            return PlatformType.OPENAI;\n        }\n        for (PlatformType platformType : PlatformType.values()) {\n            if (platformType.getPlatform().equalsIgnoreCase(raw.trim())) {\n                return platformType;\n            }\n        }\n        throw new IllegalArgumentException(\"Unsupported provider: \" + raw);\n    }\n\n    private String providerApiKeyEnv(Map<String, String> env, PlatformType provider) {\n        if (provider == null || env == null) {\n            return null;\n        }\n        String envName = provider.name().toUpperCase(Locale.ROOT) + \"_API_KEY\";\n        return envValue(env, envName);\n    }\n\n    private String envValue(Map<String, String> env, String name) {\n        return env == null ? null : env.get(name);\n    }\n\n    private String propertyValue(Properties properties, String name) {\n        return properties == null ? null : properties.getProperty(name);\n    }\n\n    private void persistNormalizedProvidersConfig(CliProvidersConfig config) {\n        Path path = globalProvidersPath();\n        if (path == null) {\n            return;\n        }\n        try {\n            Files.createDirectories(path.getParent());\n            Files.write(path, JSON.toJSONString(config, JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8));\n        } catch (IOException ignored) {\n        }\n    }\n\n    private boolean normalize(CliProvidersConfig config) {\n        if (config == null) {\n            return false;\n        }\n        boolean changed = false;\n        Map<String, CliProviderProfile> profiles = config.getProfiles();\n        if (profiles == null) {\n            profiles = new LinkedHashMap<String, CliProviderProfile>();\n            config.setProfiles(profiles);\n            changed = true;\n        }\n        List<String> invalidNames = new ArrayList<String>();\n        for (Map.Entry<String, CliProviderProfile> entry : profiles.entrySet()) {\n            String normalizedName = normalizeName(entry.getKey());\n            if (isBlank(normalizedName)) {\n                invalidNames.add(entry.getKey());\n                changed = true;\n                continue;\n            }\n            changed = normalize(entry.getValue()) || changed;\n        }\n        for (String invalidName : invalidNames) {\n            profiles.remove(invalidName);\n        }\n        String normalizedDefaultProfile = normalizeName(config.getDefaultProfile());\n        if (!equalsValue(normalizedDefaultProfile, config.getDefaultProfile())) {\n            config.setDefaultProfile(normalizedDefaultProfile);\n            changed = true;\n        }\n        if (!isBlank(config.getDefaultProfile()) && !profiles.containsKey(config.getDefaultProfile())) {\n            config.setDefaultProfile(null);\n            changed = true;\n        }\n        return changed;\n    }\n\n    private void normalize(CliWorkspaceConfig config) {\n        if (config == null) {\n            return;\n        }\n        config.setActiveProfile(normalizeName(config.getActiveProfile()));\n        config.setModelOverride(normalizeValue(config.getModelOverride()));\n        config.setEnabledMcpServers(normalizeNames(config.getEnabledMcpServers()));\n        config.setSkillDirectories(normalizeNames(config.getSkillDirectories()));\n        config.setAgentDirectories(normalizeNames(config.getAgentDirectories()));\n    }\n\n    private boolean normalize(CliProviderProfile profile) {\n        if (profile == null) {\n            return false;\n        }\n        boolean changed = false;\n        String provider = normalizeValue(profile.getProvider());\n        if (!equalsValue(provider, profile.getProvider())) {\n            profile.setProvider(provider);\n            changed = true;\n        }\n        String baseUrl = normalizeValue(profile.getBaseUrl());\n        if (!equalsValue(baseUrl, profile.getBaseUrl())) {\n            profile.setBaseUrl(baseUrl);\n            changed = true;\n        }\n        String protocol = normalizeProtocol(profile.getProtocol(), provider, baseUrl);\n        if (!equalsValue(protocol, profile.getProtocol())) {\n            profile.setProtocol(protocol);\n            changed = true;\n        }\n        String model = normalizeValue(profile.getModel());\n        if (!equalsValue(model, profile.getModel())) {\n            profile.setModel(model);\n            changed = true;\n        }\n        String apiKey = normalizeValue(profile.getApiKey());\n        if (!equalsValue(apiKey, profile.getApiKey())) {\n            profile.setApiKey(apiKey);\n            changed = true;\n        }\n        return changed;\n    }\n\n    private String normalizeProtocol(String value, String providerValue, String baseUrl) {\n        String normalized = normalizeValue(value);\n        if (isBlank(normalized)) {\n            return null;\n        }\n        PlatformType provider = resolveProviderOrNull(providerValue);\n        if (\"auto\".equalsIgnoreCase(normalized)) {\n            return provider == null ? normalized : CliProtocol.defaultProtocol(provider, baseUrl).getValue();\n        }\n        try {\n            return CliProtocol.parse(normalized).getValue();\n        } catch (IllegalArgumentException ignored) {\n            return normalized;\n        }\n    }\n\n    private String normalizeName(String value) {\n        return normalizeValue(value);\n    }\n\n    private String normalizeValue(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private PlatformType resolveProviderOrNull(String raw) {\n        if (isBlank(raw)) {\n            return null;\n        }\n        for (PlatformType platformType : PlatformType.values()) {\n            if (platformType.getPlatform().equalsIgnoreCase(raw.trim())) {\n                return platformType;\n            }\n        }\n        return null;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private boolean equalsValue(String left, String right) {\n        if (left == null) {\n            return right == null;\n        }\n        return left.equals(right);\n    }\n\n    private List<String> normalizeNames(List<String> values) {\n        if (values == null || values.isEmpty()) {\n            return null;\n        }\n        LinkedHashSet<String> normalized = new LinkedHashSet<String>();\n        for (String value : values) {\n            String candidate = normalizeName(value);\n            if (!isBlank(candidate)) {\n                normalized.add(candidate);\n            }\n        }\n        return normalized.isEmpty() ? null : new ArrayList<String>(normalized);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/provider/CliProviderProfile.java",
    "content": "package io.github.lnyocly.ai4j.cli.provider;\n\npublic class CliProviderProfile {\n\n    private String provider;\n    private String protocol;\n    private String model;\n    private String baseUrl;\n    private String apiKey;\n\n    public CliProviderProfile() {\n    }\n\n    public CliProviderProfile(String provider, String protocol, String model, String baseUrl, String apiKey) {\n        this.provider = provider;\n        this.protocol = protocol;\n        this.model = model;\n        this.baseUrl = baseUrl;\n        this.apiKey = apiKey;\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public Builder toBuilder() {\n        return new Builder()\n                .provider(provider)\n                .protocol(protocol)\n                .model(model)\n                .baseUrl(baseUrl)\n                .apiKey(apiKey);\n    }\n\n    public String getProvider() {\n        return provider;\n    }\n\n    public void setProvider(String provider) {\n        this.provider = provider;\n    }\n\n    public String getProtocol() {\n        return protocol;\n    }\n\n    public void setProtocol(String protocol) {\n        this.protocol = protocol;\n    }\n\n    public String getModel() {\n        return model;\n    }\n\n    public void setModel(String model) {\n        this.model = model;\n    }\n\n    public String getBaseUrl() {\n        return baseUrl;\n    }\n\n    public void setBaseUrl(String baseUrl) {\n        this.baseUrl = baseUrl;\n    }\n\n    public String getApiKey() {\n        return apiKey;\n    }\n\n    public void setApiKey(String apiKey) {\n        this.apiKey = apiKey;\n    }\n\n    public static final class Builder {\n\n        private String provider;\n        private String protocol;\n        private String model;\n        private String baseUrl;\n        private String apiKey;\n\n        private Builder() {\n        }\n\n        public Builder provider(String provider) {\n            this.provider = provider;\n            return this;\n        }\n\n        public Builder protocol(String protocol) {\n            this.protocol = protocol;\n            return this;\n        }\n\n        public Builder model(String model) {\n            this.model = model;\n            return this;\n        }\n\n        public Builder baseUrl(String baseUrl) {\n            this.baseUrl = baseUrl;\n            return this;\n        }\n\n        public Builder apiKey(String apiKey) {\n            this.apiKey = apiKey;\n            return this;\n        }\n\n        public CliProviderProfile build() {\n            return new CliProviderProfile(provider, protocol, model, baseUrl, apiKey);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/provider/CliProvidersConfig.java",
    "content": "package io.github.lnyocly.ai4j.cli.provider;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class CliProvidersConfig {\n\n    private String defaultProfile;\n    private Map<String, CliProviderProfile> profiles = new LinkedHashMap<String, CliProviderProfile>();\n\n    public CliProvidersConfig() {\n    }\n\n    public CliProvidersConfig(String defaultProfile, Map<String, CliProviderProfile> profiles) {\n        this.defaultProfile = defaultProfile;\n        this.profiles = profiles == null\n                ? new LinkedHashMap<String, CliProviderProfile>()\n                : new LinkedHashMap<String, CliProviderProfile>(profiles);\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public Builder toBuilder() {\n        return new Builder()\n                .defaultProfile(defaultProfile)\n                .profiles(profiles);\n    }\n\n    public String getDefaultProfile() {\n        return defaultProfile;\n    }\n\n    public void setDefaultProfile(String defaultProfile) {\n        this.defaultProfile = defaultProfile;\n    }\n\n    public Map<String, CliProviderProfile> getProfiles() {\n        return profiles;\n    }\n\n    public void setProfiles(Map<String, CliProviderProfile> profiles) {\n        this.profiles = profiles == null\n                ? new LinkedHashMap<String, CliProviderProfile>()\n                : new LinkedHashMap<String, CliProviderProfile>(profiles);\n    }\n\n    public static final class Builder {\n\n        private String defaultProfile;\n        private Map<String, CliProviderProfile> profiles = new LinkedHashMap<String, CliProviderProfile>();\n\n        private Builder() {\n        }\n\n        public Builder defaultProfile(String defaultProfile) {\n            this.defaultProfile = defaultProfile;\n            return this;\n        }\n\n        public Builder profiles(Map<String, CliProviderProfile> profiles) {\n            this.profiles = profiles == null\n                    ? new LinkedHashMap<String, CliProviderProfile>()\n                    : new LinkedHashMap<String, CliProviderProfile>(profiles);\n            return this;\n        }\n\n        public CliProvidersConfig build() {\n            return new CliProvidersConfig(defaultProfile, profiles);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/provider/CliResolvedProviderConfig.java",
    "content": "package io.github.lnyocly.ai4j.cli.provider;\n\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.service.PlatformType;\n\npublic final class CliResolvedProviderConfig {\n\n    private final String activeProfile;\n    private final String defaultProfile;\n    private final String effectiveProfile;\n    private final String modelOverride;\n    private final PlatformType provider;\n    private final CliProtocol protocol;\n    private final String model;\n    private final String apiKey;\n    private final String baseUrl;\n\n    public CliResolvedProviderConfig(String activeProfile,\n                                     String defaultProfile,\n                                     String effectiveProfile,\n                                     String modelOverride,\n                                     PlatformType provider,\n                                     CliProtocol protocol,\n                                     String model,\n                                     String apiKey,\n                                     String baseUrl) {\n        this.activeProfile = activeProfile;\n        this.defaultProfile = defaultProfile;\n        this.effectiveProfile = effectiveProfile;\n        this.modelOverride = modelOverride;\n        this.provider = provider;\n        this.protocol = protocol;\n        this.model = model;\n        this.apiKey = apiKey;\n        this.baseUrl = baseUrl;\n    }\n\n    public String getActiveProfile() {\n        return activeProfile;\n    }\n\n    public String getDefaultProfile() {\n        return defaultProfile;\n    }\n\n    public String getEffectiveProfile() {\n        return effectiveProfile;\n    }\n\n    public String getModelOverride() {\n        return modelOverride;\n    }\n\n    public PlatformType getProvider() {\n        return provider;\n    }\n\n    public CliProtocol getProtocol() {\n        return protocol;\n    }\n\n    public String getModel() {\n        return model;\n    }\n\n    public String getApiKey() {\n        return apiKey;\n    }\n\n    public String getBaseUrl() {\n        return baseUrl;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/AssistantTranscriptRenderer.java",
    "content": "package io.github.lnyocly.ai4j.cli.render;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic final class AssistantTranscriptRenderer {\n\n    public List<Line> render(String markdown) {\n        List<String> rawLines = trimBlankEdges(splitLines(markdown));\n        if (rawLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Line> lines = new ArrayList<Line>();\n        boolean insideCodeBlock = false;\n        String codeBlockLanguage = null;\n        for (String rawLine : rawLines) {\n            if (isCodeFenceLine(rawLine)) {\n                if (!insideCodeBlock) {\n                    insideCodeBlock = true;\n                    codeBlockLanguage = codeFenceLanguage(rawLine);\n                } else {\n                    insideCodeBlock = false;\n                    codeBlockLanguage = null;\n                }\n                continue;\n            }\n            if (insideCodeBlock) {\n                lines.add(Line.code(formatCodeContentLine(rawLine), codeBlockLanguage));\n            } else {\n                lines.add(Line.text(rawLine == null ? \"\" : rawLine));\n            }\n        }\n        return trimBlankEdgeLines(lines);\n    }\n\n    public List<String> plainLines(String markdown) {\n        List<Line> lines = render(markdown);\n        if (lines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> plain = new ArrayList<String>(lines.size());\n        for (Line line : lines) {\n            plain.add(line == null ? \"\" : line.text());\n        }\n        return plain;\n    }\n\n    public String styleBlock(String markdown, CliThemeStyler themeStyler) {\n        if (themeStyler == null) {\n            return \"\";\n        }\n        List<Line> lines = render(markdown);\n        if (lines.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int index = 0; index < lines.size(); index++) {\n            if (index > 0) {\n                builder.append('\\n');\n            }\n            Line line = lines.get(index);\n            if (line == null) {\n                continue;\n            }\n            builder.append(line.code()\n                    ? themeStyler.styleTranscriptCodeLine(line.text(), line.language())\n                    : themeStyler.styleTranscriptLine(line.text(), new CliThemeStyler.TranscriptStyleState()));\n        }\n        return builder.toString();\n    }\n\n    public int rowCount(String markdown, int terminalWidth) {\n        List<Line> lines = render(markdown);\n        if (lines.isEmpty()) {\n            return 0;\n        }\n        int rows = 0;\n        int width = Math.max(1, terminalWidth);\n        for (Line line : lines) {\n            String text = line == null ? \"\" : line.text();\n            if (text.isEmpty()) {\n                rows += 1;\n                continue;\n            }\n            String wrapped = CliDisplayWidth.wrapAnsi(text, width, 0).text();\n            rows += Math.max(1, wrapped.split(\"\\n\", -1).length);\n        }\n        return rows;\n    }\n\n    private List<String> splitLines(String markdown) {\n        if (markdown == null) {\n            return Collections.emptyList();\n        }\n        String normalized = markdown.replace(\"\\r\", \"\");\n        String[] rawLines = normalized.split(\"\\n\", -1);\n        List<String> lines = new ArrayList<String>(rawLines.length);\n        for (String rawLine : rawLines) {\n            lines.add(rawLine == null ? \"\" : rawLine);\n        }\n        return lines;\n    }\n\n    private List<String> trimBlankEdges(List<String> rawLines) {\n        if (rawLines == null || rawLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int start = 0;\n        int end = rawLines.size() - 1;\n        while (start <= end && isBlank(rawLines.get(start))) {\n            start++;\n        }\n        while (end >= start && isBlank(rawLines.get(end))) {\n            end--;\n        }\n        if (start > end) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>(end - start + 1);\n        for (int index = start; index <= end; index++) {\n            lines.add(rawLines.get(index) == null ? \"\" : rawLines.get(index));\n        }\n        return lines;\n    }\n\n    private List<Line> trimBlankEdgeLines(List<Line> rawLines) {\n        if (rawLines == null || rawLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int start = 0;\n        int end = rawLines.size() - 1;\n        while (start <= end && isBlank(rawLines.get(start) == null ? null : rawLines.get(start).text())) {\n            start++;\n        }\n        while (end >= start && isBlank(rawLines.get(end) == null ? null : rawLines.get(end).text())) {\n            end--;\n        }\n        if (start > end) {\n            return Collections.emptyList();\n        }\n        List<Line> lines = new ArrayList<Line>(end - start + 1);\n        for (int index = start; index <= end; index++) {\n            lines.add(rawLines.get(index));\n        }\n        return lines;\n    }\n\n    private boolean isCodeFenceLine(String rawLine) {\n        return rawLine != null && rawLine.trim().startsWith(\"```\");\n    }\n\n    private String codeFenceLanguage(String rawLine) {\n        if (!isCodeFenceLine(rawLine)) {\n            return null;\n        }\n        String value = rawLine.trim().substring(3).trim();\n        return isBlank(value) ? null : value;\n    }\n\n    private String formatCodeContentLine(String rawLine) {\n        if (rawLine == null || rawLine.isEmpty()) {\n            return CodexStyleBlockFormatter.CODE_BLOCK_EMPTY_LINE;\n        }\n        return CodexStyleBlockFormatter.CODE_BLOCK_LINE_PREFIX + rawLine;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    public static final class Line {\n\n        private final String text;\n        private final boolean code;\n        private final String language;\n\n        private Line(String text, boolean code, String language) {\n            this.text = text == null ? \"\" : text;\n            this.code = code;\n            this.language = language;\n        }\n\n        public static Line text(String text) {\n            return new Line(text, false, null);\n        }\n\n        public static Line code(String text, String language) {\n            return new Line(text, true, language);\n        }\n\n        public String text() {\n            return text;\n        }\n\n        public boolean code() {\n            return code;\n        }\n\n        public String language() {\n            return language;\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/CliAnsi.java",
    "content": "package io.github.lnyocly.ai4j.cli.render;\n\npublic final class CliAnsi {\n\n    private static final String ESC = \"\\u001b[\";\n    private static final String RESET = ESC + \"0m\";\n\n    private CliAnsi() {\n    }\n\n    public static String warning(String value, boolean ansi) {\n        return style(value, \"#f4d35e\", null, ansi, true);\n    }\n\n    static String colorize(String value, String foreground, boolean ansi, boolean bold) {\n        return style(value, foreground, null, ansi, bold);\n    }\n\n    static String style(String value, String foreground, String background, boolean ansi, boolean bold) {\n        if (!ansi || isEmpty(value)) {\n            return value;\n        }\n        StringBuilder codes = new StringBuilder();\n        if (bold) {\n            codes.append('1');\n        }\n        if (!isBlank(foreground)) {\n            appendColorCode(codes, \"38;2;\", parseHex(foreground));\n        }\n        if (!isBlank(background)) {\n            appendColorCode(codes, \"48;2;\", parseHex(background));\n        }\n        if (codes.length() == 0) {\n            return value;\n        }\n        return ESC + codes + \"m\" + value + RESET;\n    }\n\n    private static void appendColorCode(StringBuilder codes, String prefix, int[] rgb) {\n        if (codes == null || rgb == null || rgb.length < 3) {\n            return;\n        }\n        if (codes.length() > 0) {\n            codes.append(';');\n        }\n        codes.append(prefix)\n                .append(rgb[0]).append(';')\n                .append(rgb[1]).append(';')\n                .append(rgb[2]);\n    }\n\n    private static int[] parseHex(String hexColor) {\n        String normalized = hexColor == null ? \"\" : hexColor.trim();\n        if (normalized.startsWith(\"#\")) {\n            normalized = normalized.substring(1);\n        }\n        if (normalized.length() != 6) {\n            return new int[]{255, 255, 255};\n        }\n        return new int[]{\n                Integer.parseInt(normalized.substring(0, 2), 16),\n                Integer.parseInt(normalized.substring(2, 4), 16),\n                Integer.parseInt(normalized.substring(4, 6), 16)\n        };\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static boolean isEmpty(String value) {\n        return value == null || value.isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/CliDisplayWidth.java",
    "content": "package io.github.lnyocly.ai4j.cli.render;\n\nimport org.jline.utils.AttributedString;\nimport org.jline.utils.WCWidth;\n\npublic final class CliDisplayWidth {\n\n    private static final String LINE_HEAD_FORBIDDEN = \",.:;!?)]}>%}，。！？；：、）》」』】〕〉》”’\";\n\n    private CliDisplayWidth() {\n    }\n\n    public static String clip(String value, int maxWidth) {\n        if (value == null) {\n            return \"\";\n        }\n        String normalized = value.replace('\\r', ' ').replace('\\n', ' ').trim();\n        if (maxWidth <= 0 || normalized.isEmpty()) {\n            return maxWidth <= 0 ? \"\" : normalized;\n        }\n        if (displayWidth(normalized) <= maxWidth) {\n            return normalized;\n        }\n        if (maxWidth <= 3) {\n            return sliceByColumns(normalized, maxWidth);\n        }\n        return sliceByColumns(normalized, maxWidth - 3) + \"...\";\n    }\n\n    public static int displayWidth(String text) {\n        if (text == null || text.isEmpty()) {\n            return 0;\n        }\n        int width = 0;\n        for (int i = 0; i < text.length(); i++) {\n            width += charWidth(text.charAt(i));\n        }\n        return width;\n    }\n\n    public static String sliceByColumns(String text, int width) {\n        if (text == null || text.isEmpty() || width <= 0) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        int used = 0;\n        for (int i = 0; i < text.length(); i++) {\n            char ch = text.charAt(i);\n            int charWidth = charWidth(ch);\n            if (used + charWidth > width) {\n                break;\n            }\n            builder.append(ch);\n            used += charWidth;\n        }\n        return builder.toString();\n    }\n\n    public static WrappedAnsi wrapAnsi(String ansiText, int terminalWidth, int startColumn) {\n        String safe = ansiText == null ? \"\" : ansiText.replace(\"\\r\", \"\");\n        if (safe.isEmpty() || terminalWidth <= 0) {\n            return new WrappedAnsi(safe, startColumn);\n        }\n        int column = Math.max(0, startColumn);\n        StringBuilder builder = new StringBuilder();\n        int start = 0;\n        while (start <= safe.length()) {\n            int newlineIndex = safe.indexOf('\\n', start);\n            String line = newlineIndex >= 0 ? safe.substring(start, newlineIndex) : safe.substring(start);\n            if (!line.isEmpty()) {\n                AttributedString attributed = AttributedString.fromAnsi(line);\n                int offset = 0;\n                int totalColumns = attributed.columnLength();\n                while (offset < totalColumns) {\n                    if (column >= terminalWidth) {\n                        builder.append('\\n');\n                        column = 0;\n                    }\n                    int remaining = Math.max(1, terminalWidth - column);\n                    int end = Math.min(totalColumns, offset + remaining);\n                    AttributedString fragment = attributed.columnSubSequence(offset, end);\n                    if (end < totalColumns && startsWithLineHeadForbidden(attributed.columnSubSequence(end, totalColumns).toString())) {\n                        int adjustedEnd = retreatBreak(attributed, offset, end, column);\n                        if (adjustedEnd <= offset && column > 0) {\n                            builder.append('\\n');\n                            column = 0;\n                            continue;\n                        }\n                        if (adjustedEnd > offset && adjustedEnd < end) {\n                            end = adjustedEnd;\n                            fragment = attributed.columnSubSequence(offset, end);\n                        }\n                    }\n                    builder.append(fragment.toAnsi());\n                    int fragmentWidth = fragment.columnLength();\n                    column += fragmentWidth;\n                    offset += fragmentWidth;\n                    if (offset < totalColumns && column >= terminalWidth) {\n                        builder.append('\\n');\n                        column = 0;\n                    }\n                }\n            }\n            if (newlineIndex < 0) {\n                break;\n            }\n            builder.append('\\n');\n            column = 0;\n            start = newlineIndex + 1;\n        }\n        return new WrappedAnsi(builder.toString(), column);\n    }\n\n    private static int charWidth(char ch) {\n        int width = WCWidth.wcwidth(ch);\n        return width <= 0 ? 1 : width;\n    }\n\n    private static int retreatBreak(AttributedString attributed, int start, int end, int currentColumn) {\n        int adjustedEnd = end;\n        while (adjustedEnd > start) {\n            AttributedString fragment = attributed.columnSubSequence(start, adjustedEnd);\n            String text = fragment.toString();\n            if (text.isEmpty()) {\n                break;\n            }\n            char last = text.charAt(text.length() - 1);\n            int lastWidth = charWidth(last);\n            if (adjustedEnd - lastWidth < start) {\n                break;\n            }\n            adjustedEnd -= lastWidth;\n            if (adjustedEnd <= start) {\n                return currentColumn > 0 ? adjustedEnd : end;\n            }\n            if (!startsWithLineHeadForbidden(attributed.columnSubSequence(adjustedEnd, attributed.columnLength()).toString())) {\n                return adjustedEnd;\n            }\n        }\n        return end;\n    }\n\n    private static boolean startsWithLineHeadForbidden(String text) {\n        if (text == null || text.isEmpty()) {\n            return false;\n        }\n        return LINE_HEAD_FORBIDDEN.indexOf(text.charAt(0)) >= 0;\n    }\n\n    public static final class WrappedAnsi {\n        private final String text;\n        private final int endColumn;\n\n        public WrappedAnsi(String text, int endColumn) {\n            this.text = text == null ? \"\" : text;\n            this.endColumn = Math.max(0, endColumn);\n        }\n\n        public String text() {\n            return text;\n        }\n\n        public int endColumn() {\n            return endColumn;\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/CliThemeStyler.java",
    "content": "package io.github.lnyocly.ai4j.cli.render;\n\nimport io.github.lnyocly.ai4j.tui.TuiTheme;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.Locale;\nimport java.util.Set;\n\npublic final class CliThemeStyler {\n\n    private static final String FALLBACK_BRAND = \"#7cc6fe\";\n    private static final String FALLBACK_ACCENT = \"#f5b14c\";\n    private static final String FALLBACK_SUCCESS = \"#8fd694\";\n    private static final String FALLBACK_WARNING = \"#f4d35e\";\n    private static final String FALLBACK_DANGER = \"#ef6f6c\";\n    private static final String FALLBACK_TEXT = \"#f3f4f6\";\n    private static final String FALLBACK_MUTED = \"#9ca3af\";\n    private static final String FALLBACK_CODE_BACKGROUND = \"#161b22\";\n    private static final String FALLBACK_CODE_TEXT = \"#c9d1d9\";\n    private static final String FALLBACK_CODE_KEYWORD = \"#ff7b72\";\n    private static final String FALLBACK_CODE_STRING = \"#a5d6ff\";\n    private static final String FALLBACK_CODE_COMMENT = \"#8b949e\";\n    private static final String FALLBACK_CODE_NUMBER = \"#79c0ff\";\n    private static final Set<String> JAVA_LIKE_KEYWORDS = new HashSet<String>(Arrays.asList(\n            \"abstract\", \"assert\", \"boolean\", \"break\", \"byte\", \"case\", \"catch\", \"char\", \"class\", \"const\",\n            \"continue\", \"default\", \"do\", \"double\", \"else\", \"enum\", \"extends\", \"final\", \"finally\", \"float\",\n            \"for\", \"goto\", \"if\", \"implements\", \"import\", \"instanceof\", \"int\", \"interface\", \"long\", \"native\",\n            \"new\", \"package\", \"private\", \"protected\", \"public\", \"record\", \"return\", \"sealed\", \"short\",\n            \"static\", \"strictfp\", \"super\", \"switch\", \"synchronized\", \"this\", \"throw\", \"throws\", \"transient\",\n            \"try\", \"var\", \"void\", \"volatile\", \"while\", \"yield\"\n    ));\n    private static final Set<String> SCRIPT_KEYWORDS = new HashSet<String>(Arrays.asList(\n            \"as\", \"async\", \"await\", \"break\", \"case\", \"catch\", \"class\", \"const\", \"continue\", \"def\", \"default\",\n            \"del\", \"do\", \"elif\", \"else\", \"esac\", \"except\", \"export\", \"extends\", \"fi\", \"finally\", \"for\",\n            \"from\", \"function\", \"if\", \"import\", \"in\", \"lambda\", \"let\", \"local\", \"pass\", \"raise\", \"return\",\n            \"then\", \"try\", \"var\", \"while\", \"with\", \"yield\"\n    ));\n    private static final Set<String> LITERAL_KEYWORDS = new HashSet<String>(Arrays.asList(\n            \"true\", \"false\", \"null\", \"none\", \"undefined\"\n    ));\n\n    private final boolean ansi;\n    private volatile TuiTheme theme;\n\n    public CliThemeStyler(TuiTheme theme, boolean ansi) {\n        this.theme = theme == null ? new TuiTheme() : theme;\n        this.ansi = ansi;\n    }\n\n    public void updateTheme(TuiTheme theme) {\n        this.theme = theme == null ? new TuiTheme() : theme;\n    }\n\n    public String styleTranscriptLine(String line) {\n        return styleTranscriptLine(line, new TranscriptStyleState());\n    }\n\n    public String styleTranscriptLine(String line, TranscriptStyleState state) {\n        if (line == null || line.isEmpty() || line.indexOf('\\u001b') >= 0) {\n            return line;\n        }\n        TranscriptStyleState renderState = state == null ? new TranscriptStyleState() : state;\n        if (isCodeBlockHeaderLine(line)) {\n            renderState.enterCodeBlock(codeBlockLanguage(line));\n            return styleCodeFenceHeader(line);\n        }\n        if (renderState.isInsideCodeBlock()) {\n            if (isCodeBlockLine(line)) {\n                return styleCodeBlockLine(line, renderState.getCodeBlockLanguage());\n            }\n            renderState.exitCodeBlock();\n        }\n        if (line.startsWith(\"Thinking: \")) {\n            return styleReasoningFragment(line);\n        }\n        if (line.startsWith(\"• \")) {\n            return CliAnsi.colorize(line, transcriptBulletColor(line), ansi, true);\n        }\n        if (isMarkdownHeadingLine(line)) {\n            return styleMarkdownHeadingLine(line);\n        }\n        if (isMarkdownQuoteLine(line)) {\n            return styleMarkdownQuoteLine(line);\n        }\n        if (line.startsWith(\"  └ \") || line.startsWith(\"  │ \") || line.startsWith(\"    \")) {\n            return styleMutedFragment(line);\n        }\n        return styleInlineMarkdown(line, text(), false);\n    }\n\n    public String buildPrimaryStatusLine(String statusLabel, boolean spinnerActive, String spinner, String statusDetail) {\n        String label = firstNonBlank(statusLabel, \"Idle\");\n        String color = statusColor(label);\n        StringBuilder builder = new StringBuilder();\n        builder.append(CliAnsi.colorize(\"• \" + label, color, ansi, true));\n        if (spinnerActive && !isBlank(spinner)) {\n            builder.append(' ').append(CliAnsi.colorize(spinner, color, ansi, true));\n        }\n        if (!isBlank(statusDetail)) {\n            builder.append(\"  \").append(CliAnsi.colorize(statusDetail, \"idle\".equalsIgnoreCase(label) ? muted() : text(), ansi, false));\n        }\n        return builder.toString();\n    }\n\n    public String buildCompactStatusLine(String statusLabel,\n                                         boolean spinnerActive,\n                                         String spinner,\n                                         String statusDetail,\n                                         String model,\n                                         String workspace,\n                                         String hint) {\n        StringBuilder builder = new StringBuilder(buildPrimaryStatusLine(statusLabel, spinnerActive, spinner, statusDetail));\n        if (!isBlank(model)) {\n            builder.append(\"  \")\n                    .append(styleMutedFragment(\"model\"))\n                    .append(' ')\n                    .append(CliAnsi.colorize(firstNonBlank(model, \"(unknown)\"), accent(), ansi, false));\n        }\n        if (!isBlank(workspace)) {\n            builder.append(\"  \")\n                    .append(styleMutedFragment(\"workspace\"))\n                    .append(' ')\n                    .append(styleAssistantFragment(firstNonBlank(workspace, \".\")));\n        }\n        if (\"idle\".equalsIgnoreCase(firstNonBlank(statusLabel, \"Idle\")) && !isBlank(hint)) {\n            builder.append(\"  \")\n                    .append(styleMutedFragment(\"hint\"))\n                    .append(' ')\n                    .append(styleMutedFragment(hint));\n        }\n        return builder.toString();\n    }\n\n    public String buildSessionLine(String sessionId, String model, String workspace) {\n        return styleMutedFragment(\"session\")\n                + \" \" + CliAnsi.colorize(firstNonBlank(sessionId, \"(new)\"), brand(), ansi, true)\n                + \"  \" + styleMutedFragment(\"model\")\n                + \" \" + CliAnsi.colorize(firstNonBlank(model, \"(unknown)\"), accent(), ansi, false)\n                + \"  \" + styleMutedFragment(\"workspace\")\n                + \" \" + styleAssistantFragment(firstNonBlank(workspace, \".\"));\n    }\n\n    public String buildHintLine(String hint) {\n        return styleMutedFragment(\"hint\")\n                + \" \" + styleMutedFragment(firstNonBlank(hint, \"Enter a prompt or /command\"));\n    }\n\n    public String styleAssistantFragment(String text) {\n        return CliAnsi.colorize(text, text(), ansi, false);\n    }\n\n    public String styleReasoningFragment(String text) {\n        return CliAnsi.colorize(text, muted(), ansi, false);\n    }\n\n    public String styleMutedFragment(String text) {\n        return CliAnsi.colorize(text, muted(), ansi, false);\n    }\n\n    public String styleTranscriptCodeLine(String line, String language) {\n        return styleCodeBlockLine(line == null ? \"\" : line, language);\n    }\n\n    private String styleCodeFenceHeader(String line) {\n        return CliAnsi.style(line, codeComment(), codeBackground(), ansi, true);\n    }\n\n    private String styleCodeBlockLine(String line, String language) {\n        String prefix = CodexStyleBlockFormatter.CODE_BLOCK_LINE_PREFIX;\n        String body = line.length() <= prefix.length() ? \"\" : line.substring(prefix.length());\n        if (!ansi) {\n            return line;\n        }\n        return styleCodeTextFragment(prefix) + highlightCodeBody(body, language);\n    }\n\n    private String styleMarkdownHeadingLine(String line) {\n        if (!ansi) {\n            return stripInlineMarkdown(line);\n        }\n        return styleInlineMarkdown(line, brand(), true);\n    }\n\n    private String styleMarkdownQuoteLine(String line) {\n        return styleInlineMarkdown(line, muted(), false);\n    }\n\n    private String styleInlineMarkdown(String line, String baseColor, boolean boldByDefault) {\n        if (line == null) {\n            return \"\";\n        }\n        String normalized = stripMarkdownLinks(line);\n        if (!ansi) {\n            return stripInlineMarkdown(normalized);\n        }\n        StringBuilder builder = new StringBuilder();\n        StringBuilder buffer = new StringBuilder();\n        boolean bold = boldByDefault;\n        boolean code = false;\n        for (int index = 0; index < normalized.length(); index++) {\n            char current = normalized.charAt(index);\n            if ((current == '*' || current == '_')\n                    && index + 1 < normalized.length()\n                    && normalized.charAt(index + 1) == current) {\n                appendStyledMarkdownSegment(builder, buffer.toString(), baseColor, bold, code);\n                buffer.setLength(0);\n                bold = !bold;\n                index++;\n                continue;\n            }\n            if (current == '`') {\n                appendStyledMarkdownSegment(builder, buffer.toString(), baseColor, bold, code);\n                buffer.setLength(0);\n                code = !code;\n                continue;\n            }\n            if ((current == '*' || current == '_') && isSingleMarkerToggle(normalized, index, current)) {\n                continue;\n            }\n            buffer.append(current);\n        }\n        appendStyledMarkdownSegment(builder, buffer.toString(), baseColor, bold, code);\n        return builder.toString();\n    }\n\n    private String highlightCodeBody(String body, String language) {\n        String normalizedLanguage = normalizeLanguage(language);\n        if (\"json\".equals(normalizedLanguage)) {\n            return highlightJson(body);\n        }\n        if (\"xml\".equals(normalizedLanguage) || \"html\".equals(normalizedLanguage)) {\n            return highlightXml(body);\n        }\n        if (\"bash\".equals(normalizedLanguage)) {\n            return highlightGenericCode(body, SCRIPT_KEYWORDS, true, true, false, true);\n        }\n        if (\"python\".equals(normalizedLanguage)) {\n            return highlightGenericCode(body, SCRIPT_KEYWORDS, false, true, true, false);\n        }\n        if (\"yaml\".equals(normalizedLanguage)) {\n            return highlightGenericCode(body, Collections.<String>emptySet(), false, true, false, false);\n        }\n        if (\"java\".equals(normalizedLanguage)\n                || \"kotlin\".equals(normalizedLanguage)\n                || \"javascript\".equals(normalizedLanguage)\n                || \"typescript\".equals(normalizedLanguage)\n                || \"csharp\".equals(normalizedLanguage)) {\n            return highlightGenericCode(body, JAVA_LIKE_KEYWORDS, true, false, true, false);\n        }\n        return highlightGenericCode(body, JAVA_LIKE_KEYWORDS, true, true, true, true);\n    }\n\n    private String highlightGenericCode(String code,\n                                        Set<String> keywords,\n                                        boolean slashComment,\n                                        boolean hashComment,\n                                        boolean annotations,\n                                        boolean variables) {\n        if (code == null || code.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder styled = new StringBuilder();\n        StringBuilder plain = new StringBuilder();\n        int index = 0;\n        while (index < code.length()) {\n            char ch = code.charAt(index);\n            if (slashComment && index + 1 < code.length() && ch == '/' && code.charAt(index + 1) == '/') {\n                flushPlain(styled, plain);\n                styled.append(styleCodeCommentFragment(code.substring(index)));\n                return styled.toString();\n            }\n            if (slashComment && index + 1 < code.length() && ch == '/' && code.charAt(index + 1) == '*') {\n                int end = code.indexOf(\"*/\", index + 2);\n                int stop = end >= 0 ? end + 2 : code.length();\n                flushPlain(styled, plain);\n                styled.append(styleCodeCommentFragment(code.substring(index, stop)));\n                index = stop;\n                continue;\n            }\n            if (hashComment && ch == '#') {\n                flushPlain(styled, plain);\n                styled.append(styleCodeCommentFragment(code.substring(index)));\n                return styled.toString();\n            }\n            if (variables && ch == '$') {\n                int stop = consumeVariable(code, index);\n                if (stop > index + 1) {\n                    flushPlain(styled, plain);\n                    styled.append(styleCodeNumberFragment(code.substring(index, stop), false));\n                    index = stop;\n                    continue;\n                }\n            }\n            if (annotations && ch == '@') {\n                int stop = consumeIdentifier(code, index + 1);\n                if (stop > index + 1) {\n                    flushPlain(styled, plain);\n                    styled.append(styleCodeNumberFragment(code.substring(index, stop), false));\n                    index = stop;\n                    continue;\n                }\n            }\n            if (ch == '\"' || ch == '\\'') {\n                int stop = consumeQuoted(code, index, ch);\n                flushPlain(styled, plain);\n                styled.append(styleCodeStringFragment(code.substring(index, stop)));\n                index = stop;\n                continue;\n            }\n            if (Character.isDigit(ch)) {\n                int stop = consumeNumber(code, index);\n                flushPlain(styled, plain);\n                styled.append(styleCodeNumberFragment(code.substring(index, stop), false));\n                index = stop;\n                continue;\n            }\n            if (isIdentifierStart(ch)) {\n                int stop = consumeIdentifier(code, index + 1);\n                String word = code.substring(index, stop);\n                if (keywords.contains(word)) {\n                    flushPlain(styled, plain);\n                    styled.append(styleCodeKeywordFragment(word, true));\n                } else if (LITERAL_KEYWORDS.contains(word.toLowerCase(Locale.ROOT))) {\n                    flushPlain(styled, plain);\n                    styled.append(styleCodeNumberFragment(word, true));\n                } else {\n                    plain.append(word);\n                }\n                index = stop;\n                continue;\n            }\n            if (isCodePunctuation(ch)) {\n                flushPlain(styled, plain);\n                styled.append(styleCodeTextFragment(String.valueOf(ch)));\n                index++;\n                continue;\n            }\n            plain.append(ch);\n            index++;\n        }\n        flushPlain(styled, plain);\n        return styled.toString();\n    }\n\n    private String highlightJson(String code) {\n        if (code == null || code.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder styled = new StringBuilder();\n        StringBuilder plain = new StringBuilder();\n        int index = 0;\n        while (index < code.length()) {\n            char ch = code.charAt(index);\n            if (ch == '\"' || ch == '\\'') {\n                int stop = consumeQuoted(code, index, ch);\n                String token = code.substring(index, stop);\n                int next = skipWhitespace(code, stop);\n                flushPlain(styled, plain);\n                styled.append(next < code.length() && code.charAt(next) == ':'\n                        ? styleCodeKeywordFragment(token, true)\n                        : styleCodeStringFragment(token));\n                index = stop;\n                continue;\n            }\n            if (Character.isDigit(ch) || (ch == '-' && index + 1 < code.length() && Character.isDigit(code.charAt(index + 1)))) {\n                int stop = consumeNumber(code, index);\n                flushPlain(styled, plain);\n                styled.append(styleCodeNumberFragment(code.substring(index, stop), false));\n                index = stop;\n                continue;\n            }\n            if (isIdentifierStart(ch)) {\n                int stop = consumeIdentifier(code, index + 1);\n                String word = code.substring(index, stop);\n                if (LITERAL_KEYWORDS.contains(word.toLowerCase(Locale.ROOT))) {\n                    flushPlain(styled, plain);\n                    styled.append(styleCodeNumberFragment(word, true));\n                } else {\n                    plain.append(word);\n                }\n                index = stop;\n                continue;\n            }\n            if (isCodePunctuation(ch) || ch == ':' || ch == ',') {\n                flushPlain(styled, plain);\n                styled.append(styleCodeTextFragment(String.valueOf(ch)));\n                index++;\n                continue;\n            }\n            plain.append(ch);\n            index++;\n        }\n        flushPlain(styled, plain);\n        return styled.toString();\n    }\n\n    private String highlightXml(String code) {\n        if (code == null || code.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder styled = new StringBuilder();\n        StringBuilder plain = new StringBuilder();\n        int index = 0;\n        while (index < code.length()) {\n            if (code.startsWith(\"<!--\", index)) {\n                int end = code.indexOf(\"-->\", index + 4);\n                int stop = end >= 0 ? end + 3 : code.length();\n                flushPlain(styled, plain);\n                styled.append(styleCodeCommentFragment(code.substring(index, stop)));\n                index = stop;\n                continue;\n            }\n            if (code.charAt(index) == '<') {\n                int end = code.indexOf('>', index + 1);\n                int stop = end >= 0 ? end + 1 : code.length();\n                flushPlain(styled, plain);\n                styled.append(highlightXmlTag(code.substring(index, stop)));\n                index = stop;\n                continue;\n            }\n            plain.append(code.charAt(index));\n            index++;\n        }\n        flushPlain(styled, plain);\n        return styled.toString();\n    }\n\n    private String highlightXmlTag(String tag) {\n        if (tag == null || tag.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder styled = new StringBuilder();\n        StringBuilder plain = new StringBuilder();\n        int index = 0;\n        while (index < tag.length()) {\n            char ch = tag.charAt(index);\n            if (ch == '\"' || ch == '\\'') {\n                int stop = consumeQuoted(tag, index, ch);\n                flushPlain(styled, plain);\n                styled.append(styleCodeStringFragment(tag.substring(index, stop)));\n                index = stop;\n                continue;\n            }\n            if (ch == '<' || ch == '>' || ch == '/' || ch == '=') {\n                flushPlain(styled, plain);\n                styled.append(styleCodeTextFragment(String.valueOf(ch)));\n                index++;\n                continue;\n            }\n            if (isIdentifierStart(ch) || ch == ':') {\n                int stop = consumeXmlIdentifier(tag, index + 1);\n                String word = tag.substring(index, stop);\n                flushPlain(styled, plain);\n                styled.append(styleCodeKeywordFragment(word, true));\n                index = stop;\n                continue;\n            }\n            plain.append(ch);\n            index++;\n        }\n        flushPlain(styled, plain);\n        return styled.toString();\n    }\n\n    private void flushPlain(StringBuilder styled, StringBuilder plain) {\n        if (plain.length() == 0) {\n            return;\n        }\n        styled.append(styleCodeTextFragment(plain.toString()));\n        plain.setLength(0);\n    }\n\n    private boolean isCodeBlockHeaderLine(String line) {\n        return line != null\n                && line.startsWith(CodexStyleBlockFormatter.CODE_BLOCK_HEADER_PREFIX)\n                && line.endsWith(CodexStyleBlockFormatter.CODE_BLOCK_HEADER_SUFFIX);\n    }\n\n    private boolean isCodeBlockLine(String line) {\n        return line != null\n                && (line.startsWith(CodexStyleBlockFormatter.CODE_BLOCK_LINE_PREFIX)\n                || CodexStyleBlockFormatter.CODE_BLOCK_EMPTY_LINE.equals(line));\n    }\n\n    private String codeBlockLanguage(String line) {\n        if (!isCodeBlockHeaderLine(line)) {\n            return null;\n        }\n        return line.substring(\n                CodexStyleBlockFormatter.CODE_BLOCK_HEADER_PREFIX.length(),\n                Math.max(CodexStyleBlockFormatter.CODE_BLOCK_HEADER_PREFIX.length(), line.length() - CodexStyleBlockFormatter.CODE_BLOCK_HEADER_SUFFIX.length())\n        ).trim();\n    }\n\n    private String styleCodeTextFragment(String text) {\n        return CliAnsi.style(text, codeText(), codeBackground(), ansi, false);\n    }\n\n    private String styleCodeKeywordFragment(String text, boolean bold) {\n        return CliAnsi.style(text, codeKeyword(), codeBackground(), ansi, bold);\n    }\n\n    private String styleCodeStringFragment(String text) {\n        return CliAnsi.style(text, codeString(), codeBackground(), ansi, false);\n    }\n\n    private String styleCodeCommentFragment(String text) {\n        return CliAnsi.style(text, codeComment(), codeBackground(), ansi, false);\n    }\n\n    private String styleCodeNumberFragment(String text, boolean bold) {\n        return CliAnsi.style(text, codeNumber(), codeBackground(), ansi, bold);\n    }\n\n    private int consumeQuoted(String text, int start, char quote) {\n        int index = start + 1;\n        while (index < text.length()) {\n            char current = text.charAt(index);\n            if (current == '\\\\' && index + 1 < text.length()) {\n                index += 2;\n                continue;\n            }\n            if (current == quote) {\n                return index + 1;\n            }\n            index++;\n        }\n        return text.length();\n    }\n\n    private int consumeIdentifier(String text, int start) {\n        int index = start;\n        while (index < text.length()) {\n            char current = text.charAt(index);\n            if (!Character.isLetterOrDigit(current) && current != '_' && current != '$') {\n                break;\n            }\n            index++;\n        }\n        return index;\n    }\n\n    private int consumeXmlIdentifier(String text, int start) {\n        int index = start;\n        while (index < text.length()) {\n            char current = text.charAt(index);\n            if (!Character.isLetterOrDigit(current) && current != '_' && current != '-' && current != ':' && current != '.') {\n                break;\n            }\n            index++;\n        }\n        return index;\n    }\n\n    private int consumeNumber(String text, int start) {\n        int index = start;\n        if (index < text.length() && (text.charAt(index) == '-' || text.charAt(index) == '+')) {\n            index++;\n        }\n        while (index < text.length()) {\n            char current = text.charAt(index);\n            if (!Character.isDigit(current) && current != '.' && current != '_' && current != 'x' && current != 'X'\n                    && current != 'b' && current != 'B' && current != 'o' && current != 'O'\n                    && current != 'e' && current != 'E' && current != '+' && current != '-') {\n                break;\n            }\n            index++;\n        }\n        return index;\n    }\n\n    private int consumeVariable(String text, int start) {\n        if (start + 1 >= text.length()) {\n            return start + 1;\n        }\n        if (text.charAt(start + 1) == '{') {\n            int end = text.indexOf('}', start + 2);\n            return end >= 0 ? end + 1 : text.length();\n        }\n        return consumeIdentifier(text, start + 1);\n    }\n\n    private int skipWhitespace(String text, int start) {\n        int index = start;\n        while (index < text.length() && Character.isWhitespace(text.charAt(index))) {\n            index++;\n        }\n        return index;\n    }\n\n    private boolean isIdentifierStart(char ch) {\n        return Character.isLetter(ch) || ch == '_' || ch == '$';\n    }\n\n    private boolean isCodePunctuation(char ch) {\n        return \"{}[]()<>\".indexOf(ch) >= 0;\n    }\n\n    private boolean isMarkdownHeadingLine(String line) {\n        if (line == null) {\n            return false;\n        }\n        String trimmed = line.trim();\n        if (!trimmed.startsWith(\"#\")) {\n            return false;\n        }\n        return trimmed.length() == 1 || (trimmed.length() > 1 && trimmed.charAt(1) == '#') || Character.isWhitespace(trimmed.charAt(1));\n    }\n\n    private boolean isMarkdownQuoteLine(String line) {\n        return line != null && line.trim().startsWith(\">\");\n    }\n\n    private boolean isSingleMarkerToggle(String text, int index, char marker) {\n        if (index > 0 && text.charAt(index - 1) == marker) {\n            return false;\n        }\n        if (index + 1 < text.length() && text.charAt(index + 1) == marker) {\n            return false;\n        }\n        return true;\n    }\n\n    private String normalizeLanguage(String language) {\n        if (isBlank(language)) {\n            return \"\";\n        }\n        String normalized = language.trim().toLowerCase(Locale.ROOT);\n        if (\"sh\".equals(normalized) || \"shell\".equals(normalized) || \"zsh\".equals(normalized) || \"powershell\".equals(normalized) || \"ps1\".equals(normalized)) {\n            return \"bash\";\n        }\n        if (\"js\".equals(normalized)) {\n            return \"javascript\";\n        }\n        if (\"ts\".equals(normalized)) {\n            return \"typescript\";\n        }\n        if (\"kt\".equals(normalized)) {\n            return \"kotlin\";\n        }\n        if (\"py\".equals(normalized)) {\n            return \"python\";\n        }\n        if (\"yml\".equals(normalized)) {\n            return \"yaml\";\n        }\n        if (\"htm\".equals(normalized)) {\n            return \"html\";\n        }\n        if (\"c#\".equals(normalized) || \"cs\".equals(normalized)) {\n            return \"csharp\";\n        }\n        return normalized;\n    }\n\n    private void appendStyledMarkdownSegment(StringBuilder builder,\n                                             String segment,\n                                             String baseColor,\n                                             boolean bold,\n                                             boolean code) {\n        if (builder == null || segment == null || segment.isEmpty()) {\n            return;\n        }\n        if (code) {\n            builder.append(CliAnsi.style(segment, codeText(), codeBackground(), ansi, true));\n            return;\n        }\n        builder.append(CliAnsi.style(segment, baseColor, null, ansi, bold));\n    }\n\n    private String stripMarkdownLinks(String text) {\n        if (text == null || text.indexOf('[') < 0 || text.indexOf('(') < 0) {\n            return text;\n        }\n        StringBuilder builder = new StringBuilder();\n        int index = 0;\n        while (index < text.length()) {\n            int openLabel = text.indexOf('[', index);\n            if (openLabel < 0) {\n                builder.append(text.substring(index));\n                break;\n            }\n            int closeLabel = text.indexOf(']', openLabel + 1);\n            int openUrl = closeLabel >= 0 && closeLabel + 1 < text.length() && text.charAt(closeLabel + 1) == '('\n                    ? closeLabel + 1\n                    : -1;\n            int closeUrl = openUrl >= 0 ? text.indexOf(')', openUrl + 1) : -1;\n            if (closeLabel < 0 || openUrl < 0 || closeUrl < 0) {\n                builder.append(text.substring(index));\n                break;\n            }\n            builder.append(text, index, openLabel);\n            builder.append(text, openLabel + 1, closeLabel);\n            index = closeUrl + 1;\n        }\n        return builder.toString();\n    }\n\n    private String stripInlineMarkdown(String text) {\n        if (text == null) {\n            return \"\";\n        }\n        return stripMarkdownLinks(text)\n                .replace(\"**\", \"\")\n                .replace(\"__\", \"\")\n                .replace(\"`\", \"\");\n    }\n\n    private String transcriptBulletColor(String line) {\n        String normalized = line.trim().toLowerCase(Locale.ROOT);\n        if (normalized.startsWith(\"• error\")\n                || normalized.startsWith(\"• tool failed\")\n                || normalized.startsWith(\"• command failed\")\n                || normalized.startsWith(\"• rejected\")) {\n            return danger();\n        }\n        if (normalized.startsWith(\"• approved\")) {\n            return success();\n        }\n        if (normalized.startsWith(\"• approval required\")\n                || normalized.startsWith(\"• applying\")\n                || normalized.startsWith(\"• running\")\n                || normalized.startsWith(\"• reading\")\n                || normalized.startsWith(\"• writing\")\n                || normalized.startsWith(\"• checking\")\n                || normalized.startsWith(\"• stopping\")) {\n            return warning();\n        }\n        if (normalized.startsWith(\"• auto-compacted\")\n                || normalized.startsWith(\"• compacted\")\n                || normalized.startsWith(\"• thinking\")\n                || normalized.startsWith(\"• responding\")\n                || normalized.startsWith(\"• working\")) {\n            return brand();\n        }\n        return accent();\n    }\n\n    private String statusColor(String statusLabel) {\n        String normalized = firstNonBlank(statusLabel, \"Idle\").trim().toLowerCase(Locale.ROOT);\n        if (\"thinking\".equals(normalized)) {\n            return accent();\n        }\n        if (\"connecting\".equals(normalized)) {\n            return accent();\n        }\n        if (\"responding\".equals(normalized)) {\n            return brand();\n        }\n        if (\"retrying\".equals(normalized)) {\n            return warning();\n        }\n        if (\"working\".equals(normalized)) {\n            return warning();\n        }\n        if (\"waiting\".equals(normalized)) {\n            return accent();\n        }\n        if (\"stalled\".equals(normalized)) {\n            return warning();\n        }\n        if (\"error\".equals(normalized)) {\n            return danger();\n        }\n        return muted();\n    }\n\n    private String brand() {\n        return firstNonBlank(theme == null ? null : theme.getBrand(), FALLBACK_BRAND);\n    }\n\n    private String accent() {\n        return firstNonBlank(theme == null ? null : theme.getAccent(), FALLBACK_ACCENT);\n    }\n\n    private String success() {\n        return firstNonBlank(theme == null ? null : theme.getSuccess(), FALLBACK_SUCCESS);\n    }\n\n    private String warning() {\n        return firstNonBlank(theme == null ? null : theme.getWarning(), FALLBACK_WARNING);\n    }\n\n    private String danger() {\n        return firstNonBlank(theme == null ? null : theme.getDanger(), FALLBACK_DANGER);\n    }\n\n    private String text() {\n        return firstNonBlank(theme == null ? null : theme.getText(), FALLBACK_TEXT);\n    }\n\n    private String muted() {\n        return firstNonBlank(theme == null ? null : theme.getMuted(), FALLBACK_MUTED);\n    }\n\n    private String codeBackground() {\n        return firstNonBlank(theme == null ? null : theme.getCodeBackground(), FALLBACK_CODE_BACKGROUND);\n    }\n\n    private String codeText() {\n        return firstNonBlank(theme == null ? null : theme.getCodeText(), FALLBACK_CODE_TEXT);\n    }\n\n    private String codeKeyword() {\n        return firstNonBlank(theme == null ? null : theme.getCodeKeyword(), FALLBACK_CODE_KEYWORD);\n    }\n\n    private String codeString() {\n        return firstNonBlank(theme == null ? null : theme.getCodeString(), FALLBACK_CODE_STRING);\n    }\n\n    private String codeComment() {\n        return firstNonBlank(theme == null ? null : theme.getCodeComment(), FALLBACK_CODE_COMMENT);\n    }\n\n    private String codeNumber() {\n        return firstNonBlank(theme == null ? null : theme.getCodeNumber(), FALLBACK_CODE_NUMBER);\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    public static final class TranscriptStyleState {\n\n        private String codeBlockLanguage;\n        private boolean insideCodeBlock;\n\n        public void enterCodeBlock(String language) {\n            codeBlockLanguage = language;\n            insideCodeBlock = true;\n        }\n\n        public void exitCodeBlock() {\n            codeBlockLanguage = null;\n            insideCodeBlock = false;\n        }\n\n        public void reset() {\n            exitCodeBlock();\n        }\n\n        public String getCodeBlockLanguage() {\n            return codeBlockLanguage;\n        }\n\n        public boolean isInsideCodeBlock() {\n            return insideCodeBlock;\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/CodexStyleBlockFormatter.java",
    "content": "package io.github.lnyocly.ai4j.cli.render;\n\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\nimport io.github.lnyocly.ai4j.tui.TuiAssistantToolView;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\n\npublic final class CodexStyleBlockFormatter {\n\n    public static final String CODE_BLOCK_HEADER_PREFIX = \"  [\";\n    public static final String CODE_BLOCK_HEADER_SUFFIX = \"]\";\n    public static final String CODE_BLOCK_LINE_PREFIX = \"    \";\n    public static final String CODE_BLOCK_EMPTY_LINE = \"    \";\n\n    private final int maxWidth;\n    private final int maxToolPreviewLines;\n\n    public CodexStyleBlockFormatter(int maxWidth, int maxToolPreviewLines) {\n        this.maxWidth = Math.max(48, maxWidth);\n        this.maxToolPreviewLines = Math.max(1, maxToolPreviewLines);\n    }\n\n    public List<String> formatAssistant(String text) {\n        List<String> rawLines = trimBlankEdges(splitLines(text));\n        if (rawLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>();\n        boolean insideCodeBlock = false;\n        for (String rawLine : rawLines) {\n            if (isCodeFenceLine(rawLine)) {\n                insideCodeBlock = !insideCodeBlock;\n                continue;\n            }\n            if (insideCodeBlock) {\n                lines.add(formatCodeContentLine(rawLine));\n                continue;\n            }\n            lines.add(rawLine == null ? \"\" : rawLine);\n        }\n        return trimBlankEdges(lines);\n    }\n\n    public boolean isCodeFenceLine(String rawLine) {\n        if (rawLine == null) {\n            return false;\n        }\n        String trimmed = rawLine.trim();\n        return trimmed.startsWith(\"```\");\n    }\n\n    public String formatCodeFenceOpen(String rawLine) {\n        return null;\n    }\n\n    public String formatCodeFenceClose() {\n        return null;\n    }\n\n    public String formatCodeContentLine(String rawLine) {\n        if (rawLine == null || rawLine.isEmpty()) {\n            return CODE_BLOCK_EMPTY_LINE;\n        }\n        return CODE_BLOCK_LINE_PREFIX + rawLine;\n    }\n\n    public String codeFenceLanguage(String rawLine) {\n        if (rawLine == null) {\n            return null;\n        }\n        String trimmed = rawLine.trim();\n        if (!trimmed.startsWith(\"```\")) {\n            return null;\n        }\n        String value = trimmed.substring(3).trim();\n        return isBlank(value) ? null : value;\n    }\n\n    public List<String> formatTool(TuiAssistantToolView toolView) {\n        if (toolView == null) {\n            return Collections.emptyList();\n        }\n\n        String toolName = firstNonBlank(toolView.getToolName(), \"tool\");\n        String status = firstNonBlank(toolView.getStatus(), \"done\").toLowerCase(Locale.ROOT);\n        List<String> lines = new ArrayList<String>(formatToolPrimaryLines(toolName, toolView.getTitle(), status));\n\n        String detail = safeTrimToNull(toolView.getDetail());\n        if (\"bash\".equals(toolName) && !\"timed out\".equalsIgnoreCase(firstNonBlank(detail, \"\"))) {\n            if (!\"error\".equalsIgnoreCase(firstNonBlank(toolView.getStatus(), \"\"))) {\n                detail = null;\n            }\n        }\n        if (!isBlank(detail)) {\n            lines.addAll(wrapPrefixedText(\"  \\u2514 \", \"    \", detail, maxWidth));\n        }\n\n        List<String> previewLines = normalizeToolPreviewLines(toolName, status, toolView.getPreviewLines());\n        int max = Math.min(previewLines.size(), maxToolPreviewLines);\n        for (int i = 0; i < max; i++) {\n            String prefix = i == 0 && isBlank(detail) ? \"  \\u2514 \" : \"    \";\n            lines.addAll(wrapPrefixedText(prefix, \"    \", previewLines.get(i), maxWidth));\n        }\n        if (previewLines.size() > max) {\n            lines.add(\"    \\u2026 +\" + (previewLines.size() - max) + \" lines\");\n        }\n        return trimBlankEdges(lines);\n    }\n\n    public String formatRunningStatus(TuiAssistantToolView toolView) {\n        if (toolView == null) {\n            return \"Planning the next step\";\n        }\n        List<String> primaryLines = formatToolPrimaryLines(toolView.getToolName(), toolView.getTitle(), \"pending\");\n        if (primaryLines.isEmpty()) {\n            return \"Planning the next step\";\n        }\n        return clip(stripLeadingBullet(primaryLines.get(0)), maxWidth);\n    }\n\n    public List<String> formatCompact(CodingSessionCompactResult result) {\n        if (result == null) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>();\n        lines.add(result.isAutomatic() ? \"\\u2022 Auto-compacted session context\" : \"\\u2022 Compacted session context\");\n\n        StringBuilder metrics = new StringBuilder();\n        metrics.append(\"tokens \")\n                .append(result.getEstimatedTokensBefore())\n                .append(\"->\")\n                .append(result.getEstimatedTokensAfter())\n                .append(\", items \")\n                .append(result.getBeforeItemCount())\n                .append(\"->\")\n                .append(result.getAfterItemCount());\n        if (result.isSplitTurn()) {\n            metrics.append(\", split turn\");\n        }\n        lines.addAll(wrapPrefixedText(\"  \\u2514 \", \"    \", metrics.toString(), maxWidth));\n\n        String summary = safeTrimToNull(result.getSummary());\n        if (!isBlank(summary)) {\n            lines.addAll(wrapPrefixedText(\"    \", \"    \", summary, maxWidth));\n        }\n        return lines;\n    }\n\n    public List<String> formatError(String message) {\n        List<String> lines = new ArrayList<String>();\n        lines.add(\"\\u2022 Error\");\n        lines.addAll(wrapPrefixedText(\"  \\u2514 \", \"    \", firstNonBlank(safeTrimToNull(message), \"Agent run failed.\"), maxWidth));\n        return lines;\n    }\n\n    public List<String> formatInfoBlock(String title, List<String> rawLines) {\n        List<String> lines = new ArrayList<String>();\n        lines.add(\"\\u2022 \" + firstNonBlank(safeTrimToNull(title), \"Info\"));\n        boolean hasDetail = false;\n        if (rawLines != null) {\n            for (String rawLine : rawLines) {\n                if (isBlank(rawLine)) {\n                    if (hasDetail) {\n                        lines.add(\"\");\n                    }\n                    continue;\n                }\n                String detail = normalizeInfoLine(rawLine);\n                if (isBlank(detail)) {\n                    continue;\n                }\n                lines.addAll(wrapPrefixedText(hasDetail ? \"    \" : \"  \\u2514 \", \"    \", detail, maxWidth));\n                hasDetail = true;\n            }\n        }\n        if (!hasDetail) {\n            lines.add(\"  \\u2514 (none)\");\n        }\n        return trimBlankEdges(lines);\n    }\n\n    public List<String> formatOutput(String text) {\n        List<String> rawLines = trimBlankEdges(splitLines(text));\n        if (rawLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        ParsedInfoBlock parsed = parseOutputBlock(rawLines);\n        if (parsed != null) {\n            return formatInfoBlock(parsed.title, parsed.bodyLines);\n        }\n        return formatInfoBlock(\"Info\", rawLines);\n    }\n\n    private List<String> formatToolPrimaryLines(String toolName, String title, String status) {\n        List<String> lines = new ArrayList<String>();\n        String normalizedTool = firstNonBlank(toolName, \"tool\");\n        String normalizedStatus = firstNonBlank(status, \"done\").toLowerCase(Locale.ROOT);\n        String label = normalizeToolPrimaryLabel(firstNonBlank(title, normalizedTool));\n        if (\"error\".equals(normalizedStatus)) {\n            if (\"bash\".equals(normalizedTool)) {\n                return wrapPrefixedText(\"\\u2022 Command failed \", \"  \\u2502 \", label, maxWidth);\n            }\n            lines.add(\"\\u2022 Tool failed \" + clip(label, Math.max(24, maxWidth - 16)));\n            return lines;\n        }\n        if (\"apply_patch\".equals(normalizedTool)) {\n            lines.add(\"pending\".equals(normalizedStatus) ? \"\\u2022 Applying patch\" : \"\\u2022 Applied patch\");\n            return lines;\n        }\n        return wrapPrefixedText(resolveToolPrimaryPrefix(normalizedTool, title, normalizedStatus), \"  \\u2502 \", label, maxWidth);\n    }\n\n    private ParsedInfoBlock parseOutputBlock(List<String> rawLines) {\n        if (rawLines == null || rawLines.isEmpty()) {\n            return null;\n        }\n        String firstLine = safeTrimToNull(rawLines.get(0));\n        if (isBlank(firstLine)) {\n            return null;\n        }\n        if (firstLine.endsWith(\":\")) {\n            return new ParsedInfoBlock(normalizeBlockTitle(firstLine.substring(0, firstLine.length() - 1)), rawLines.subList(1, rawLines.size()));\n        }\n        int colonIndex = firstLine.indexOf(':');\n        if (colonIndex <= 0 || colonIndex >= firstLine.length() - 1 || firstLine.contains(\"://\")) {\n            return null;\n        }\n        String rawTitle = firstLine.substring(0, colonIndex).trim();\n        if (!isStructuredOutputTitle(rawTitle)) {\n            return null;\n        }\n        List<String> bodyLines = new ArrayList<String>();\n        String inlineDetail = safeTrimToNull(firstLine.substring(colonIndex + 1));\n        if (!isBlank(inlineDetail)) {\n            bodyLines.add(inlineDetail);\n        }\n        for (int i = 1; i < rawLines.size(); i++) {\n            bodyLines.add(rawLines.get(i));\n        }\n        return new ParsedInfoBlock(normalizeBlockTitle(rawTitle), bodyLines);\n    }\n\n    private boolean isStructuredOutputTitle(String rawTitle) {\n        if (isBlank(rawTitle)) {\n            return false;\n        }\n        String normalized = rawTitle.trim().toLowerCase(Locale.ROOT);\n        return \"status\".equals(normalized)\n                || \"session\".equals(normalized)\n                || \"sessions\".equals(normalized)\n                || \"history\".equals(normalized)\n                || \"tree\".equals(normalized)\n                || \"events\".equals(normalized)\n                || \"replay\".equals(normalized)\n                || \"compacts\".equals(normalized)\n                || \"processes\".equals(normalized)\n                || \"process status\".equals(normalized)\n                || \"process logs\".equals(normalized)\n                || \"process write\".equals(normalized)\n                || \"process stopped\".equals(normalized)\n                || \"checkpoint\".equals(normalized)\n                || \"commands\".equals(normalized)\n                || \"themes\".equals(normalized)\n                || \"stream\".equals(normalized)\n                || \"compact\".equals(normalized);\n    }\n\n    private String normalizeBlockTitle(String rawTitle) {\n        if (isBlank(rawTitle)) {\n            return \"Info\";\n        }\n        String title = rawTitle.trim();\n        if (title.length() == 1) {\n            return title.toUpperCase(Locale.ROOT);\n        }\n        return Character.toUpperCase(title.charAt(0)) + title.substring(1);\n    }\n\n    private String normalizeInfoLine(String rawLine) {\n        if (rawLine == null) {\n            return null;\n        }\n        String value = rawLine.trim();\n        if (value.startsWith(\"- \")) {\n            return value.substring(2).trim();\n        }\n        return value;\n    }\n\n    private String resolveToolPrimaryPrefix(String toolName, String title, String status) {\n        String normalizedTool = firstNonBlank(toolName, \"tool\");\n        String normalizedTitle = firstNonBlank(title, normalizedTool);\n        boolean pending = \"pending\".equalsIgnoreCase(status);\n        if (\"read_file\".equals(normalizedTool)) {\n            return pending ? \"\\u2022 Reading \" : \"\\u2022 Read \";\n        }\n        if (\"write_file\".equals(normalizedTool)) {\n            return pending ? \"\\u2022 Writing \" : \"\\u2022 Wrote \";\n        }\n        if (\"bash\".equals(normalizedTool)) {\n            if (normalizedTitle.startsWith(\"bash logs \")) {\n                return pending ? \"\\u2022 Reading logs \" : \"\\u2022 Read logs \";\n            }\n            if (normalizedTitle.startsWith(\"bash status \")) {\n                return pending ? \"\\u2022 Checking \" : \"\\u2022 Checked \";\n            }\n            if (normalizedTitle.startsWith(\"bash write \")) {\n                return pending ? \"\\u2022 Writing to \" : \"\\u2022 Wrote to \";\n            }\n            if (normalizedTitle.startsWith(\"bash stop \")) {\n                return pending ? \"\\u2022 Stopping \" : \"\\u2022 Stopped \";\n            }\n        }\n        return pending ? \"\\u2022 Running \" : \"\\u2022 Ran \";\n    }\n\n    private String normalizeToolPrimaryLabel(String title) {\n        String normalizedTitle = firstNonBlank(title, \"tool\").trim();\n        if (normalizedTitle.startsWith(\"$ \")) {\n            return normalizedTitle.substring(2).trim();\n        }\n        if (normalizedTitle.startsWith(\"read \")) {\n            return normalizedTitle.substring(5).trim();\n        }\n        if (normalizedTitle.startsWith(\"write \")) {\n            return normalizedTitle.substring(6).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash logs \")) {\n            return normalizedTitle.substring(\"bash logs \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash status \")) {\n            return normalizedTitle.substring(\"bash status \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash write \")) {\n            return normalizedTitle.substring(\"bash write \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash stop \")) {\n            return normalizedTitle.substring(\"bash stop \".length()).trim();\n        }\n        return normalizedTitle;\n    }\n\n    private List<String> normalizeToolPreviewLines(String toolName, String status, List<String> previewLines) {\n        if (previewLines == null || previewLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> normalized = new ArrayList<String>();\n        for (String previewLine : previewLines) {\n            String candidate = stripPreviewLabel(previewLine);\n            if (isBlank(candidate)) {\n                continue;\n            }\n            if (\"bash\".equals(toolName)) {\n                if (\"pending\".equalsIgnoreCase(status)) {\n                    continue;\n                }\n                if (\"(no command output)\".equalsIgnoreCase(candidate)) {\n                    continue;\n                }\n            }\n            if (\"apply_patch\".equals(toolName) && \"(no changed files)\".equalsIgnoreCase(candidate)) {\n                continue;\n            }\n            normalized.add(candidate);\n        }\n        return normalized;\n    }\n\n    private String stripPreviewLabel(String previewLine) {\n        if (isBlank(previewLine)) {\n            return null;\n        }\n        String value = previewLine.trim();\n        int separator = value.indexOf(\"> \");\n        if (separator > 0) {\n            String prefix = value.substring(0, separator).trim().toLowerCase(Locale.ROOT);\n            if (\"stdout\".equals(prefix)\n                    || \"stderr\".equals(prefix)\n                    || \"log\".equals(prefix)\n                    || \"file\".equals(prefix)\n                    || \"path\".equals(prefix)\n                    || \"cwd\".equals(prefix)\n                    || \"timeout\".equals(prefix)\n                    || \"process\".equals(prefix)\n                    || \"status\".equals(prefix)\n                    || \"command\".equals(prefix)\n                    || \"stdin\".equals(prefix)\n                    || \"meta\".equals(prefix)\n                    || \"out\".equals(prefix)) {\n                return value.substring(separator + 2).trim();\n            }\n        }\n        return value;\n    }\n\n    private List<String> wrapPrefixedText(String firstPrefix, String continuationPrefix, String rawText, int maxWidth) {\n        List<String> lines = new ArrayList<String>();\n        if (isBlank(rawText)) {\n            return lines;\n        }\n        String first = firstPrefix == null ? \"\" : firstPrefix;\n        String continuation = continuationPrefix == null ? \"\" : continuationPrefix;\n        int firstWidth = Math.max(12, maxWidth - first.length());\n        int continuationWidth = Math.max(12, maxWidth - continuation.length());\n        boolean firstLine = true;\n        String[] paragraphs = rawText.replace(\"\\r\", \"\").split(\"\\n\");\n        for (String paragraph : paragraphs) {\n            String text = safeTrimToNull(paragraph);\n            if (isBlank(text)) {\n                continue;\n            }\n            while (!isBlank(text)) {\n                int width = firstLine ? firstWidth : continuationWidth;\n                int split = findWrapIndex(text, width);\n                lines.add((firstLine ? first : continuation) + text.substring(0, split).trim());\n                text = text.substring(split).trim();\n                firstLine = false;\n            }\n        }\n        return lines;\n    }\n\n    private int findWrapIndex(String text, int width) {\n        if (isBlank(text) || text.length() <= width) {\n            return text == null ? 0 : text.length();\n        }\n        int whitespace = -1;\n        for (int i = Math.min(width, text.length() - 1); i >= 0; i--) {\n            if (Character.isWhitespace(text.charAt(i))) {\n                whitespace = i;\n                break;\n            }\n        }\n        return whitespace > 0 ? whitespace : width;\n    }\n\n    private List<String> splitLines(String text) {\n        if (text == null) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>();\n        String[] rawLines = text.replace(\"\\r\", \"\").split(\"\\n\", -1);\n        for (String rawLine : rawLines) {\n            lines.add(rawLine == null ? \"\" : rawLine);\n        }\n        return lines;\n    }\n\n    private List<String> trimBlankEdges(List<String> rawLines) {\n        if (rawLines == null || rawLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int start = 0;\n        int end = rawLines.size() - 1;\n        while (start <= end && isBlank(rawLines.get(start))) {\n            start++;\n        }\n        while (end >= start && isBlank(rawLines.get(end))) {\n            end--;\n        }\n        if (start > end) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>();\n        for (int i = start; i <= end; i++) {\n            lines.add(rawLines.get(i) == null ? \"\" : rawLines.get(i));\n        }\n        return lines;\n    }\n\n    private String stripLeadingBullet(String text) {\n        if (isBlank(text)) {\n            return \"\";\n        }\n        String value = text.trim();\n        if (value.startsWith(\"\\u2022 \")) {\n            return value.substring(2).trim();\n        }\n        if (value.startsWith(\"\\u2502 \")) {\n            return value.substring(2).trim();\n        }\n        return value;\n    }\n\n    private String clip(String value, int maxChars) {\n        if (value == null) {\n            return \"\";\n        }\n        String normalized = value.replace('\\r', ' ').replace('\\n', ' ').trim();\n        if (normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, Math.max(0, maxChars)) + \"...\";\n    }\n\n    private String safeTrimToNull(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static final class ParsedInfoBlock {\n        private final String title;\n        private final List<String> bodyLines;\n\n        private ParsedInfoBlock(String title, List<String> bodyLines) {\n            this.title = title;\n            this.bodyLines = bodyLines == null ? Collections.<String>emptyList() : new ArrayList<String>(bodyLines);\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/PatchSummaryFormatter.java",
    "content": "package io.github.lnyocly.ai4j.cli.render;\n\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\n\npublic final class PatchSummaryFormatter {\n\n    private static final String ADD_FILE = \"*** Add File: \";\n    private static final String ADD_FILE_ALIAS = \"*** Add: \";\n    private static final String UPDATE_FILE = \"*** Update File: \";\n    private static final String UPDATE_FILE_ALIAS = \"*** Update: \";\n    private static final String DELETE_FILE = \"*** Delete File: \";\n    private static final String DELETE_FILE_ALIAS = \"*** Delete: \";\n\n    private PatchSummaryFormatter() {\n    }\n\n    public static List<String> summarizePatchRequest(String patch, int maxItems) {\n        if (isBlank(patch) || maxItems <= 0) {\n            return Collections.emptyList();\n        }\n        String[] rawLines = patch.replace(\"\\r\", \"\").split(\"\\n\");\n        List<String> lines = new ArrayList<String>();\n        for (String rawLine : rawLines) {\n            String line = rawLine == null ? \"\" : rawLine.trim();\n            String summary = summarizePatchDirective(line);\n            if (!isBlank(summary)) {\n                lines.add(summary);\n            }\n            if (lines.size() >= maxItems) {\n                break;\n            }\n        }\n        return lines;\n    }\n\n    public static List<String> summarizePatchResult(JSONObject output, int maxItems) {\n        if (output == null || maxItems <= 0) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>();\n        JSONArray fileChanges = output.getJSONArray(\"fileChanges\");\n        if (fileChanges != null) {\n            for (int i = 0; i < fileChanges.size() && lines.size() < maxItems; i++) {\n                String summary = formatPatchFileChange(fileChanges.getJSONObject(i));\n                if (!isBlank(summary)) {\n                    lines.add(summary);\n                }\n            }\n            if (!lines.isEmpty()) {\n                return lines;\n            }\n        }\n        JSONArray changedFiles = output.getJSONArray(\"changedFiles\");\n        if (changedFiles != null) {\n            for (int i = 0; i < changedFiles.size() && lines.size() < maxItems; i++) {\n                Object changedFile = changedFiles.get(i);\n                if (changedFile != null) {\n                    lines.add(\"Edited \" + String.valueOf(changedFile));\n                }\n            }\n        }\n        return lines;\n    }\n\n    public static String formatPatchFileChange(JSONObject change) {\n        if (change == null) {\n            return null;\n        }\n        String path = safeTrimToNull(change.getString(\"path\"));\n        if (isBlank(path)) {\n            return null;\n        }\n        String operation = firstNonBlank(change.getString(\"operation\"), \"update\").toLowerCase(Locale.ROOT);\n        int linesAdded = change.getIntValue(\"linesAdded\");\n        int linesRemoved = change.getIntValue(\"linesRemoved\");\n        String verb;\n        if (\"add\".equals(operation)) {\n            verb = \"Created\";\n        } else if (\"delete\".equals(operation)) {\n            verb = \"Deleted\";\n        } else {\n            verb = \"Edited\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(verb).append(\" \").append(path);\n        if (linesAdded > 0 || linesRemoved > 0) {\n            builder.append(\" (+\").append(linesAdded).append(\" -\").append(linesRemoved).append(\")\");\n        }\n        return builder.toString();\n    }\n\n    private static String summarizePatchDirective(String line) {\n        if (isBlank(line)) {\n            return null;\n        }\n        if (line.startsWith(ADD_FILE)) {\n            return \"Add \" + line.substring(ADD_FILE.length()).trim();\n        }\n        if (line.startsWith(ADD_FILE_ALIAS)) {\n            return \"Add \" + line.substring(ADD_FILE_ALIAS.length()).trim();\n        }\n        if (line.startsWith(UPDATE_FILE)) {\n            return \"Update \" + line.substring(UPDATE_FILE.length()).trim();\n        }\n        if (line.startsWith(UPDATE_FILE_ALIAS)) {\n            return \"Update \" + line.substring(UPDATE_FILE_ALIAS.length()).trim();\n        }\n        if (line.startsWith(DELETE_FILE)) {\n            return \"Delete \" + line.substring(DELETE_FILE.length()).trim();\n        }\n        if (line.startsWith(DELETE_FILE_ALIAS)) {\n            return \"Delete \" + line.substring(DELETE_FILE_ALIAS.length()).trim();\n        }\n        return null;\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static String safeTrimToNull(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/render/TranscriptPrinter.java",
    "content": "package io.github.lnyocly.ai4j.cli.render;\n\nimport io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic final class TranscriptPrinter {\n\n    private final TerminalIO terminal;\n    private boolean printedBlock;\n\n    public TranscriptPrinter(TerminalIO terminal) {\n        this.terminal = terminal;\n    }\n\n    public synchronized void printBlock(List<String> rawLines) {\n        List<String> lines = trimBlankEdges(rawLines);\n        if (lines.isEmpty()) {\n            return;\n        }\n        separateFromPreviousBlockIfNeeded();\n        if (terminal instanceof JlineShellTerminalIO) {\n            ((JlineShellTerminalIO) terminal).printTranscriptBlock(lines);\n            printedBlock = true;\n            return;\n        }\n        for (String line : lines) {\n            terminal.println(line == null ? \"\" : line);\n        }\n        printedBlock = true;\n    }\n\n    public synchronized void beginStreamingBlock() {\n        printedBlock = true;\n    }\n\n    public synchronized void printSectionBreak() {\n        if (!printedBlock) {\n            return;\n        }\n        terminal.println(\"\");\n        printedBlock = false;\n    }\n\n    public synchronized void resetPrintedBlock() {\n        printedBlock = false;\n    }\n\n    private List<String> trimBlankEdges(List<String> rawLines) {\n        if (rawLines == null || rawLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int start = 0;\n        int end = rawLines.size() - 1;\n        while (start <= end && isBlank(rawLines.get(start))) {\n            start++;\n        }\n        while (end >= start && isBlank(rawLines.get(end))) {\n            end--;\n        }\n        if (start > end) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>();\n        for (int i = start; i <= end; i++) {\n            lines.add(rawLines.get(i) == null ? \"\" : rawLines.get(i));\n        }\n        return lines;\n    }\n\n    private void separateFromPreviousBlockIfNeeded() {\n        if (printedBlock) {\n            terminal.println(\"\");\n        }\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/AgentHandoffSessionEventSupport.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic final class AgentHandoffSessionEventSupport {\n\n    private AgentHandoffSessionEventSupport() {\n    }\n\n    public static boolean supports(AgentEvent event) {\n        if (event == null || event.getType() == null) {\n            return false;\n        }\n        return event.getType() == AgentEventType.HANDOFF_START || event.getType() == AgentEventType.HANDOFF_END;\n    }\n\n    public static SessionEventType resolveSessionEventType(AgentEvent event) {\n        if (!supports(event)) {\n            return null;\n        }\n        return event.getType() == AgentEventType.HANDOFF_START\n                ? SessionEventType.TASK_CREATED\n                : SessionEventType.TASK_UPDATED;\n    }\n\n    public static SessionEvent toSessionEvent(String sessionId, String turnId, AgentEvent event) {\n        SessionEventType type = resolveSessionEventType(event);\n        if (type == null || isBlank(sessionId)) {\n            return null;\n        }\n        return SessionEvent.builder()\n                .eventId(UUID.randomUUID().toString())\n                .sessionId(sessionId)\n                .type(type)\n                .timestamp(System.currentTimeMillis())\n                .turnId(turnId)\n                .step(event == null ? null : event.getStep())\n                .summary(buildSummary(event))\n                .payload(buildPayload(event))\n                .build();\n    }\n\n    public static String buildSummary(AgentEvent event) {\n        Map<String, Object> payload = payload(event);\n        String title = firstNonBlank(payloadString(payload, \"title\"), event == null ? null : event.getMessage(), \"Subagent task\");\n        String status = firstNonBlank(payloadString(payload, \"status\"), event == null || event.getType() == null ? null : event.getType().name().toLowerCase());\n        return title + \" [\" + status + \"]\";\n    }\n\n    public static Map<String, Object> buildPayload(AgentEvent event) {\n        Map<String, Object> source = payload(event);\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        String handoffId = firstNonBlank(payloadString(source, \"handoffId\"), payloadString(source, \"callId\"));\n        String detail = firstNonBlank(payloadString(source, \"detail\"), payloadString(source, \"error\"), payloadString(source, \"output\"));\n        String output = trimToNull(payloadString(source, \"output\"));\n        String error = trimToNull(payloadString(source, \"error\"));\n        payload.put(\"taskId\", handoffId);\n        payload.put(\"callId\", handoffId);\n        payload.put(\"tool\", payloadString(source, \"tool\"));\n        payload.put(\"subagent\", payloadString(source, \"subagent\"));\n        payload.put(\"title\", firstNonBlank(payloadString(source, \"title\"), \"Subagent task\"));\n        payload.put(\"detail\", detail);\n        payload.put(\"status\", payloadString(source, \"status\"));\n        payload.put(\"sessionMode\", payloadString(source, \"sessionMode\"));\n        payload.put(\"depth\", payloadValue(source, \"depth\"));\n        payload.put(\"attempts\", payloadValue(source, \"attempts\"));\n        payload.put(\"durationMillis\", payloadValue(source, \"durationMillis\"));\n        payload.put(\"output\", output);\n        payload.put(\"error\", error);\n        payload.put(\"previewLines\", previewLines(firstNonBlank(error, output, detail)));\n        return payload;\n    }\n\n    private static Map<String, Object> payload(AgentEvent event) {\n        Object payload = event == null ? null : event.getPayload();\n        if (payload instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> map = (Map<String, Object>) payload;\n            return map;\n        }\n        return new LinkedHashMap<String, Object>();\n    }\n\n    private static Object payloadValue(Map<String, Object> payload, String key) {\n        return payload == null || key == null ? null : payload.get(key);\n    }\n\n    private static String payloadString(Map<String, Object> payload, String key) {\n        Object value = payloadValue(payload, key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static List<String> previewLines(String raw) {\n        List<String> lines = new ArrayList<String>();\n        if (isBlank(raw)) {\n            return lines;\n        }\n        String[] split = raw.replace(\"\\r\", \"\").split(\"\\n\");\n        int max = Math.min(4, split.length);\n        for (int i = 0; i < max; i++) {\n            String line = trimToNull(split[i]);\n            if (line != null) {\n                lines.add(line);\n            }\n        }\n        return lines;\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/AgentTeamMessageSessionEventSupport.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic final class AgentTeamMessageSessionEventSupport {\n\n    private AgentTeamMessageSessionEventSupport() {\n    }\n\n    public static boolean supports(AgentEvent event) {\n        return event != null && event.getType() == AgentEventType.TEAM_MESSAGE;\n    }\n\n    public static SessionEvent toSessionEvent(String sessionId, String turnId, AgentEvent event) {\n        if (!supports(event) || isBlank(sessionId)) {\n            return null;\n        }\n        return SessionEvent.builder()\n                .eventId(UUID.randomUUID().toString())\n                .sessionId(sessionId)\n                .type(SessionEventType.TEAM_MESSAGE)\n                .timestamp(System.currentTimeMillis())\n                .turnId(turnId)\n                .step(event.getStep())\n                .summary(buildSummary(event))\n                .payload(buildPayload(event))\n                .build();\n    }\n\n    public static String buildSummary(AgentEvent event) {\n        Map<String, Object> payload = payload(event);\n        String messageType = firstNonBlank(payloadString(payload, \"type\"), \"message\");\n        String route = route(payloadString(payload, \"fromMemberId\"), payloadString(payload, \"toMemberId\"));\n        if (isBlank(route)) {\n            return \"Team message [\" + messageType + \"]\";\n        }\n        return \"Team message \" + route + \" [\" + messageType + \"]\";\n    }\n\n    public static Map<String, Object> buildPayload(AgentEvent event) {\n        Map<String, Object> source = payload(event);\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        String content = trimToNull(firstNonBlank(payloadString(source, \"content\"), payloadString(source, \"detail\")));\n        String messageType = firstNonBlank(payloadString(source, \"type\"), \"message\");\n        String taskId = trimToNull(payloadString(source, \"taskId\"));\n        payload.put(\"messageId\", payloadValue(source, \"messageId\"));\n        payload.put(\"taskId\", taskId);\n        payload.put(\"callId\", taskId == null ? null : \"team-task:\" + taskId);\n        payload.put(\"fromMemberId\", payloadValue(source, \"fromMemberId\"));\n        payload.put(\"toMemberId\", payloadValue(source, \"toMemberId\"));\n        payload.put(\"messageType\", messageType);\n        payload.put(\"title\", \"Team message\");\n        payload.put(\"detail\", content);\n        payload.put(\"content\", content);\n        payload.put(\"createdAt\", payloadValue(source, \"createdAt\"));\n        payload.put(\"previewLines\", previewLines(content));\n        return payload;\n    }\n\n    private static Map<String, Object> payload(AgentEvent event) {\n        Object payload = event == null ? null : event.getPayload();\n        if (payload instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> map = (Map<String, Object>) payload;\n            return map;\n        }\n        return new LinkedHashMap<String, Object>();\n    }\n\n    private static Object payloadValue(Map<String, Object> payload, String key) {\n        return payload == null || key == null ? null : payload.get(key);\n    }\n\n    private static String payloadString(Map<String, Object> payload, String key) {\n        Object value = payloadValue(payload, key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static String route(String fromMemberId, String toMemberId) {\n        String from = trimToNull(fromMemberId);\n        String to = trimToNull(toMemberId);\n        if (from == null && to == null) {\n            return null;\n        }\n        return firstNonBlank(from, \"?\") + \" -> \" + firstNonBlank(to, \"?\");\n    }\n\n    private static List<String> previewLines(String raw) {\n        List<String> lines = new ArrayList<String>();\n        if (isBlank(raw)) {\n            return lines;\n        }\n        String[] split = raw.replace(\"\\r\", \"\").split(\"\\n\");\n        int max = Math.min(4, split.length);\n        for (int i = 0; i < max; i++) {\n            String line = trimToNull(split[i]);\n            if (line != null) {\n                lines.add(line);\n            }\n        }\n        return lines;\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/AgentTeamSessionEventSupport.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic final class AgentTeamSessionEventSupport {\n\n    private AgentTeamSessionEventSupport() {\n    }\n\n    public static boolean supports(AgentEvent event) {\n        if (event == null || event.getType() == null) {\n            return false;\n        }\n        return event.getType() == AgentEventType.TEAM_TASK_CREATED\n                || event.getType() == AgentEventType.TEAM_TASK_UPDATED;\n    }\n\n    public static SessionEventType resolveSessionEventType(AgentEvent event) {\n        if (!supports(event)) {\n            return null;\n        }\n        return event.getType() == AgentEventType.TEAM_TASK_CREATED\n                ? SessionEventType.TASK_CREATED\n                : SessionEventType.TASK_UPDATED;\n    }\n\n    public static SessionEvent toSessionEvent(String sessionId, String turnId, AgentEvent event) {\n        SessionEventType type = resolveSessionEventType(event);\n        if (type == null || isBlank(sessionId)) {\n            return null;\n        }\n        return SessionEvent.builder()\n                .eventId(UUID.randomUUID().toString())\n                .sessionId(sessionId)\n                .type(type)\n                .timestamp(System.currentTimeMillis())\n                .turnId(turnId)\n                .step(event == null ? null : event.getStep())\n                .summary(buildSummary(event))\n                .payload(buildPayload(event))\n                .build();\n    }\n\n    public static String buildSummary(AgentEvent event) {\n        Map<String, Object> payload = payload(event);\n        String title = firstNonBlank(payloadString(payload, \"title\"), event == null ? null : event.getMessage(), \"Team task\");\n        String status = firstNonBlank(payloadString(payload, \"status\"),\n                event == null || event.getType() == null ? null : event.getType().name().toLowerCase());\n        return title + \" [\" + status + \"]\";\n    }\n\n    public static Map<String, Object> buildPayload(AgentEvent event) {\n        Map<String, Object> source = payload(event);\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        String taskId = firstNonBlank(payloadString(source, \"taskId\"), payloadString(source, \"callId\"));\n        String callId = firstNonBlank(payloadString(source, \"callId\"), taskId);\n        String detail = firstNonBlank(payloadString(source, \"detail\"), payloadString(source, \"error\"), payloadString(source, \"output\"));\n        String output = trimToNull(payloadString(source, \"output\"));\n        String error = trimToNull(payloadString(source, \"error\"));\n        payload.put(\"taskId\", taskId);\n        payload.put(\"callId\", callId);\n        payload.put(\"tool\", firstNonBlank(payloadString(source, \"tool\"), \"team\"));\n        payload.put(\"title\", firstNonBlank(payloadString(source, \"title\"), \"Team task\"));\n        payload.put(\"detail\", detail);\n        payload.put(\"status\", payloadString(source, \"status\"));\n        payload.put(\"phase\", payloadValue(source, \"phase\"));\n        payload.put(\"percent\", payloadValue(source, \"percent\"));\n        payload.put(\"updatedAtEpochMs\", payloadValue(source, \"updatedAtEpochMs\"));\n        payload.put(\"heartbeatCount\", payloadValue(source, \"heartbeatCount\"));\n        payload.put(\"startTime\", payloadValue(source, \"startTime\"));\n        payload.put(\"endTime\", payloadValue(source, \"endTime\"));\n        payload.put(\"lastHeartbeatTime\", payloadValue(source, \"lastHeartbeatTime\"));\n        payload.put(\"memberId\", payloadValue(source, \"memberId\"));\n        payload.put(\"memberName\", payloadValue(source, \"memberName\"));\n        payload.put(\"task\", payloadValue(source, \"task\"));\n        payload.put(\"context\", payloadValue(source, \"context\"));\n        payload.put(\"dependsOn\", payloadValue(source, \"dependsOn\"));\n        payload.put(\"durationMillis\", payloadValue(source, \"durationMillis\"));\n        payload.put(\"output\", output);\n        payload.put(\"error\", error);\n        payload.put(\"previewLines\", previewLines(firstNonBlank(error, output, detail)));\n        return payload;\n    }\n\n    private static Map<String, Object> payload(AgentEvent event) {\n        Object payload = event == null ? null : event.getPayload();\n        if (payload instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> map = (Map<String, Object>) payload;\n            return map;\n        }\n        return new LinkedHashMap<String, Object>();\n    }\n\n    private static Object payloadValue(Map<String, Object> payload, String key) {\n        return payload == null || key == null ? null : payload.get(key);\n    }\n\n    private static String payloadString(Map<String, Object> payload, String key) {\n        Object value = payloadValue(payload, key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static List<String> previewLines(String raw) {\n        List<String> lines = new ArrayList<String>();\n        if (isBlank(raw)) {\n            return lines;\n        }\n        String[] split = raw.replace(\"\\r\", \"\").split(\"\\n\");\n        int max = Math.min(4, split.length);\n        for (int i = 0; i < max; i++) {\n            String line = trimToNull(split[i]);\n            if (line != null) {\n                lines.add(line);\n            }\n        }\n        return lines;\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CliTeamStateManager.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.agent.team.FileAgentTeamMessageBus;\nimport io.github.lnyocly.ai4j.agent.team.FileAgentTeamStateStore;\n\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.text.SimpleDateFormat;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.Date;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.TimeZone;\n\npublic final class CliTeamStateManager {\n\n    private static final int DEFAULT_MESSAGE_LIMIT = 20;\n\n    private final Path workspaceRoot;\n    private final Path teamDirectory;\n    private final FileAgentTeamStateStore stateStore;\n\n    public CliTeamStateManager(Path workspaceRoot) {\n        Path normalizedRoot = workspaceRoot == null\n                ? Paths.get(\".\").toAbsolutePath().normalize()\n                : workspaceRoot.toAbsolutePath().normalize();\n        this.workspaceRoot = normalizedRoot;\n        this.teamDirectory = normalizedRoot.resolve(\".ai4j\").resolve(\"teams\");\n        this.stateStore = new FileAgentTeamStateStore(teamDirectory.resolve(\"state\"));\n    }\n\n    public List<String> listKnownTeamIds() {\n        List<AgentTeamState> states = listStates();\n        if (states.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> ids = new ArrayList<String>();\n        for (AgentTeamState state : states) {\n            if (state == null || isBlank(state.getTeamId())) {\n                continue;\n            }\n            ids.add(state.getTeamId());\n        }\n        return ids;\n    }\n\n    public String renderListOutput() {\n        List<AgentTeamState> states = listStates();\n        if (states.isEmpty()) {\n            return \"teams: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"teams:\\n\");\n        for (AgentTeamState rawState : states) {\n            AgentTeamState state = hydrate(rawState);\n            TaskSummary summary = summarizeTasks(state == null ? null : state.getTaskStates());\n            int messageCount = state == null || state.getMessages() == null ? 0 : state.getMessages().size();\n            builder.append(\"- \").append(firstNonBlank(state == null ? null : state.getTeamId(), \"(team)\"))\n                    .append(\" | updated=\").append(formatTimestamp(state == null ? 0L : state.getUpdatedAt()))\n                    .append(\" | active=\").append(state != null && state.isRunActive() ? \"yes\" : \"no\")\n                    .append(\" | tasks=\").append(summary.total)\n                    .append(\" (running=\").append(summary.running)\n                    .append(\", completed=\").append(summary.completed)\n                    .append(\", failed=\").append(summary.failed)\n                    .append(\", blocked=\").append(summary.blocked).append(')')\n                    .append(\" | messages=\").append(messageCount);\n            String objective = clip(state == null ? null : state.getObjective(), 64);\n            if (!isBlank(objective)) {\n                builder.append(\" | objective=\").append(objective);\n            }\n            builder.append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    public String renderStatusOutput(String requestedTeamId) {\n        ResolvedTeamState resolved = resolveState(requestedTeamId);\n        if (resolved == null || resolved.state == null) {\n            return teamMissingOutput(requestedTeamId);\n        }\n        AgentTeamState state = resolved.state;\n        TaskSummary summary = summarizeTasks(state.getTaskStates());\n        int messageCount = state.getMessages() == null ? 0 : state.getMessages().size();\n        int memberCount = state.getMembers() == null ? 0 : state.getMembers().size();\n        StringBuilder builder = new StringBuilder(\"team status:\\n\");\n        builder.append(\"- teamId=\").append(resolved.teamId).append('\\n');\n        builder.append(\"- storage=\").append(teamDirectory).append('\\n');\n        builder.append(\"- updated=\").append(formatTimestamp(state.getUpdatedAt()))\n                .append(\", active=\").append(state.isRunActive() ? \"yes\" : \"no\").append('\\n');\n        builder.append(\"- objective=\").append(firstNonBlank(clip(state.getObjective(), 220), \"(none)\")).append('\\n');\n        builder.append(\"- members=\").append(memberCount)\n                .append(\", rounds=\").append(state.getLastRounds())\n                .append(\", messages=\").append(messageCount).append('\\n');\n        builder.append(\"- tasks=\").append(summary.total)\n                .append(\" (pending=\").append(summary.pending)\n                .append(\", ready=\").append(summary.ready)\n                .append(\", running=\").append(summary.running)\n                .append(\", completed=\").append(summary.completed)\n                .append(\", failed=\").append(summary.failed)\n                .append(\", blocked=\").append(summary.blocked).append(')').append('\\n');\n        builder.append(\"- lastRunStartedAt=\").append(formatTimestamp(state.getLastRunStartedAt()))\n                .append(\", lastRunCompletedAt=\").append(formatTimestamp(state.getLastRunCompletedAt())).append('\\n');\n        builder.append(\"- lastOutput=\").append(firstNonBlank(clip(state.getLastOutput(), 220), \"(none)\"));\n        return builder.toString().trim();\n    }\n\n    public String renderMessagesOutput(String requestedTeamId, Integer limit) {\n        ResolvedTeamState resolved = resolveState(requestedTeamId);\n        if (resolved == null || resolved.state == null) {\n            return teamMissingOutput(requestedTeamId);\n        }\n        int safeLimit = limit == null || limit.intValue() <= 0 ? DEFAULT_MESSAGE_LIMIT : limit.intValue();\n        List<AgentTeamMessage> messages = loadMessages(resolved.teamId, resolved.state);\n        if (messages.isEmpty()) {\n            return \"team messages:\\n- teamId=\" + resolved.teamId + \"\\n- count=0\";\n        }\n        Collections.sort(messages, new Comparator<AgentTeamMessage>() {\n            @Override\n            public int compare(AgentTeamMessage left, AgentTeamMessage right) {\n                long l = left == null ? 0L : left.getCreatedAt();\n                long r = right == null ? 0L : right.getCreatedAt();\n                return l == r ? 0 : (l < r ? 1 : -1);\n            }\n        });\n        if (messages.size() > safeLimit) {\n            messages = new ArrayList<AgentTeamMessage>(messages.subList(0, safeLimit));\n        }\n\n        StringBuilder builder = new StringBuilder(\"team messages:\\n\");\n        builder.append(\"- teamId=\").append(resolved.teamId).append('\\n');\n        builder.append(\"- count=\").append(messages.size()).append('\\n');\n        for (AgentTeamMessage message : messages) {\n            if (message == null) {\n                continue;\n            }\n            builder.append(\"- \").append(formatTimestamp(message.getCreatedAt()))\n                    .append(\" | \").append(firstNonBlank(message.getFromMemberId(), \"?\"))\n                    .append(\" -> \").append(firstNonBlank(message.getToMemberId(), \"*\"));\n            if (!isBlank(message.getType())) {\n                builder.append(\" [\").append(message.getType()).append(']');\n            }\n            if (!isBlank(message.getTaskId())) {\n                builder.append(\" | task=\").append(message.getTaskId());\n            }\n            if (!isBlank(message.getContent())) {\n                builder.append(\" | \").append(clip(singleLine(message.getContent()), 120));\n            }\n            builder.append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    public ResolvedTeamState resolveState(String requestedTeamId) {\n        String normalized = trimToNull(requestedTeamId);\n        AgentTeamState state = normalized == null ? latestState() : stateStore.load(normalized);\n        if (state == null || isBlank(state.getTeamId())) {\n            return null;\n        }\n        AgentTeamState hydrated = hydrate(state);\n        return new ResolvedTeamState(hydrated.getTeamId(), hydrated);\n    }\n\n    public List<String> renderBoardLines(String requestedTeamId) {\n        ResolvedTeamState resolved = resolveState(requestedTeamId);\n        if (resolved == null || resolved.state == null) {\n            return Collections.emptyList();\n        }\n        return TeamBoardRenderSupport.renderBoardLines(resolved.state);\n    }\n\n    public String renderResumeOutput(String requestedTeamId) {\n        ResolvedTeamState resolved = resolveState(requestedTeamId);\n        if (resolved == null || resolved.state == null) {\n            return teamMissingOutput(requestedTeamId);\n        }\n        List<String> boardLines = TeamBoardRenderSupport.renderBoardLines(resolved.state);\n        StringBuilder builder = new StringBuilder(\"team resumed: \").append(resolved.teamId);\n        if (!boardLines.isEmpty()) {\n            builder.append('\\n').append(TeamBoardRenderSupport.renderBoardOutput(boardLines));\n        }\n        return builder.toString().trim();\n    }\n\n    private List<AgentTeamState> listStates() {\n        return stateStore.list();\n    }\n\n    private AgentTeamState latestState() {\n        List<AgentTeamState> states = listStates();\n        return states.isEmpty() ? null : states.get(0);\n    }\n\n    private AgentTeamState hydrate(AgentTeamState state) {\n        if (state == null || isBlank(state.getTeamId())) {\n            return state;\n        }\n        return state.toBuilder()\n                .messages(loadMessages(state.getTeamId(), state))\n                .build();\n    }\n\n    private List<AgentTeamMessage> loadMessages(String teamId, AgentTeamState fallbackState) {\n        if (isBlank(teamId)) {\n            return fallbackState == null || fallbackState.getMessages() == null\n                    ? Collections.<AgentTeamMessage>emptyList()\n                    : new ArrayList<AgentTeamMessage>(fallbackState.getMessages());\n        }\n        Path mailboxFile = teamDirectory.resolve(\"mailbox\").resolve(teamId + \".jsonl\");\n        if (Files.exists(mailboxFile)) {\n            return new FileAgentTeamMessageBus(mailboxFile).snapshot();\n        }\n        return fallbackState == null || fallbackState.getMessages() == null\n                ? Collections.<AgentTeamMessage>emptyList()\n                : new ArrayList<AgentTeamMessage>(fallbackState.getMessages());\n    }\n\n    private TaskSummary summarizeTasks(List<AgentTeamTaskState> taskStates) {\n        TaskSummary summary = new TaskSummary();\n        if (taskStates == null || taskStates.isEmpty()) {\n            return summary;\n        }\n        for (AgentTeamTaskState taskState : taskStates) {\n            if (taskState == null || taskState.getStatus() == null) {\n                continue;\n            }\n            summary.total++;\n            AgentTeamTaskStatus status = taskState.getStatus();\n            if (status == AgentTeamTaskStatus.PENDING) {\n                summary.pending++;\n            } else if (status == AgentTeamTaskStatus.READY) {\n                summary.ready++;\n            } else if (status == AgentTeamTaskStatus.IN_PROGRESS) {\n                summary.running++;\n            } else if (status == AgentTeamTaskStatus.COMPLETED) {\n                summary.completed++;\n            } else if (status == AgentTeamTaskStatus.FAILED) {\n                summary.failed++;\n            } else if (status == AgentTeamTaskStatus.BLOCKED) {\n                summary.blocked++;\n            }\n        }\n        return summary;\n    }\n\n    private String teamMissingOutput(String requestedTeamId) {\n        String normalized = trimToNull(requestedTeamId);\n        return normalized == null ? \"team: (none)\" : \"team not found: \" + normalized;\n    }\n\n    private String formatTimestamp(long epochMillis) {\n        if (epochMillis <= 0L) {\n            return \"(none)\";\n        }\n        SimpleDateFormat format = new SimpleDateFormat(\"yyyy-MM-dd HH:mm:ss\", Locale.ROOT);\n        format.setTimeZone(TimeZone.getDefault());\n        return format.format(new Date(epochMillis));\n    }\n\n    private String singleLine(String value) {\n        if (value == null) {\n            return null;\n        }\n        return value.replace('\\r', ' ').replace('\\n', ' ').trim();\n    }\n\n    private String clip(String value, int maxChars) {\n        String normalized = trimToNull(value);\n        if (normalized == null || normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, Math.max(0, maxChars)) + \"...\";\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    public static final class ResolvedTeamState {\n        private final String teamId;\n        private final AgentTeamState state;\n\n        private ResolvedTeamState(String teamId, AgentTeamState state) {\n            this.teamId = teamId;\n            this.state = state;\n        }\n\n        public String getTeamId() {\n            return teamId;\n        }\n\n        public AgentTeamState getState() {\n            return state;\n        }\n    }\n\n    private static final class TaskSummary {\n        private int total;\n        private int pending;\n        private int ready;\n        private int running;\n        private int completed;\n        private int failed;\n        private int blocked;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CliToolApprovalDecorator.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.render.CodexStyleBlockFormatter;\nimport io.github.lnyocly.ai4j.cli.render.PatchSummaryFormatter;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class CliToolApprovalDecorator implements ToolExecutorDecorator {\n\n    public static final String APPROVAL_REJECTED_PREFIX = \"[approval-rejected]\";\n    private static final CodexStyleBlockFormatter APPROVAL_BLOCK_FORMATTER = new CodexStyleBlockFormatter(120, 4);\n\n    private final ApprovalMode approvalMode;\n    private final TerminalIO terminal;\n    private final TuiInteractionState interactionState;\n\n    public CliToolApprovalDecorator(ApprovalMode approvalMode, TerminalIO terminal) {\n        this(approvalMode, terminal, null);\n    }\n\n    public CliToolApprovalDecorator(ApprovalMode approvalMode,\n                                    TerminalIO terminal,\n                                    TuiInteractionState interactionState) {\n        this.approvalMode = approvalMode == null ? ApprovalMode.AUTO : approvalMode;\n        this.terminal = terminal;\n        this.interactionState = interactionState;\n    }\n\n    @Override\n    public ToolExecutor decorate(final String toolName, final ToolExecutor delegate) {\n        if (delegate == null || approvalMode == ApprovalMode.AUTO) {\n            return delegate;\n        }\n        return new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) throws Exception {\n                if (requiresApproval(toolName, call)) {\n                    requestApproval(toolName, call);\n                }\n                return delegate.execute(call);\n            }\n        };\n    }\n\n    private boolean requiresApproval(String toolName, AgentToolCall call) {\n        if (approvalMode == ApprovalMode.MANUAL) {\n            return true;\n        }\n        if (CodingToolNames.APPLY_PATCH.equals(toolName)) {\n            return true;\n        }\n        if (!CodingToolNames.BASH.equals(toolName)) {\n            return false;\n        }\n        JSONObject arguments = parseArguments(call == null ? null : call.getArguments());\n        String action = arguments.getString(\"action\");\n        if (isBlank(action)) {\n            action = \"exec\";\n        }\n        return \"exec\".equals(action) || \"start\".equals(action) || \"stop\".equals(action) || \"write\".equals(action);\n    }\n\n    private void requestApproval(String toolName, AgentToolCall call) {\n        if (terminal == null) {\n            throw new IllegalStateException(\"Tool call requires approval but no terminal is available\");\n        }\n        JSONObject arguments = parseArguments(call == null ? null : call.getArguments());\n        String summary = summarize(toolName, arguments);\n        if (interactionState != null) {\n            interactionState.showApproval(approvalMode.getValue(), toolName, summary);\n        }\n        printApprovalBlock(toolName, arguments, summary);\n        String answer;\n        try {\n            answer = terminal.readLine(\"• Approve? [y/N] \");\n        } catch (java.io.IOException ex) {\n            if (interactionState != null) {\n                interactionState.resolveApproval(toolName, false);\n            }\n            throw new IllegalStateException(\"Failed to read approval input\", ex);\n        }\n        boolean approved = answer != null && (answer.trim().equalsIgnoreCase(\"y\") || answer.trim().equalsIgnoreCase(\"yes\"));\n        if (interactionState != null) {\n            interactionState.resolveApproval(toolName, approved);\n        }\n        printApprovalResolution(toolName, arguments, approved);\n        if (!approved) {\n            throw new IllegalStateException(buildApprovalRejectedMessage(toolName, arguments));\n        }\n    }\n\n    private void printApprovalBlock(String toolName, JSONObject arguments, String summary) {\n        List<String> lines = new ArrayList<String>();\n        lines.add(\"• Approval required for \" + firstNonBlank(toolName, \"tool\"));\n        if (CodingToolNames.BASH.equals(toolName)) {\n            appendBashApprovalLines(lines, arguments);\n        } else if (CodingToolNames.APPLY_PATCH.equals(toolName)) {\n            appendPatchApprovalLines(lines, arguments);\n        } else {\n            lines.add(\"  └ \" + clip(summary, 120));\n        }\n        for (String line : lines) {\n            terminal.println(line);\n        }\n    }\n\n    private void appendBashApprovalLines(List<String> lines, JSONObject arguments) {\n        String action = firstNonBlank(arguments.getString(\"action\"), \"exec\");\n        String cwd = defaultText(arguments.getString(\"cwd\"), \".\");\n        String command = safeTrimToNull(arguments.getString(\"command\"));\n        String processId = safeTrimToNull(arguments.getString(\"processId\"));\n        if (\"exec\".equals(action) || \"start\".equals(action)) {\n            lines.add(\"  └ \" + action + \" in \" + clip(cwd, 72));\n            if (!isBlank(command)) {\n                lines.add(\"    \" + clip(command, 120));\n            }\n            return;\n        }\n        lines.add(\"  └ \" + action + \" process \" + defaultText(processId, \"(process)\"));\n        if (!isBlank(command)) {\n            lines.add(\"    \" + clip(command, 120));\n        }\n    }\n\n    private void appendPatchApprovalLines(List<String> lines, JSONObject arguments) {\n        String patch = arguments.getString(\"patch\");\n        List<String> changes = summarizePatchChanges(patch);\n        if (changes.isEmpty()) {\n            lines.add(\"  └ \" + clip(defaultText(patch, \"(empty patch)\"), 120));\n            return;\n        }\n        for (int i = 0; i < changes.size(); i++) {\n            lines.add((i == 0 ? \"  └ \" : \"    \") + changes.get(i));\n        }\n    }\n\n    private String summarize(String toolName, JSONObject arguments) {\n        if (CodingToolNames.BASH.equals(toolName)) {\n            String action = arguments.getString(\"action\");\n            if (isBlank(action)) {\n                action = \"exec\";\n            }\n            return \"action=\" + action\n                    + \", cwd=\" + defaultText(arguments.getString(\"cwd\"), \".\")\n                    + \", command=\" + clip(arguments.getString(\"command\"), 120)\n                    + \", processId=\" + defaultText(arguments.getString(\"processId\"), \"-\");\n        }\n        if (CodingToolNames.APPLY_PATCH.equals(toolName)) {\n            return \"patch=\" + clip(arguments.getString(\"patch\"), 120);\n        }\n        return \"args=\" + clip(arguments.toJSONString(), 120);\n    }\n\n    private void printApprovalResolution(String toolName, JSONObject arguments, boolean approved) {\n        List<String> lines = APPROVAL_BLOCK_FORMATTER.formatInfoBlock(\n                approved ? \"Approved\" : \"Rejected\",\n                summarizeApprovalResolution(toolName, arguments)\n        );\n        for (String line : lines) {\n            terminal.println(line);\n        }\n    }\n\n    private List<String> summarizeApprovalResolution(String toolName, JSONObject arguments) {\n        List<String> lines = new ArrayList<String>();\n        if (CodingToolNames.BASH.equals(toolName)) {\n            String action = firstNonBlank(arguments.getString(\"action\"), \"exec\");\n            String command = safeTrimToNull(arguments.getString(\"command\"));\n            String processId = safeTrimToNull(arguments.getString(\"processId\"));\n            if (\"exec\".equals(action) || \"start\".equals(action)) {\n                lines.add(isBlank(command) ? \"bash \" + action : command);\n                return lines;\n            }\n            if (\"stop\".equals(action)) {\n                lines.add(\"Stop process \" + defaultText(processId, \"(process)\"));\n                return lines;\n            }\n            if (\"write\".equals(action)) {\n                lines.add(\"Write to process \" + defaultText(processId, \"(process)\"));\n                return lines;\n            }\n            lines.add(\"bash \" + action);\n            return lines;\n        }\n        if (CodingToolNames.APPLY_PATCH.equals(toolName)) {\n            lines.addAll(summarizePatchChanges(arguments.getString(\"patch\")));\n            if (!lines.isEmpty()) {\n                return lines;\n            }\n            lines.add(\"apply_patch\");\n            return lines;\n        }\n        lines.add(firstNonBlank(summarize(toolName, arguments), firstNonBlank(toolName, \"tool\")));\n        return lines;\n    }\n\n    private String buildApprovalRejectedMessage(String toolName, JSONObject arguments) {\n        List<String> lines = summarizeApprovalResolution(toolName, arguments);\n        String detail = lines.isEmpty() ? firstNonBlank(toolName, \"tool\") : lines.get(0);\n        return APPROVAL_REJECTED_PREFIX + \" \" + detail;\n    }\n\n    private List<String> summarizePatchChanges(String patch) {\n        return new ArrayList<String>(PatchSummaryFormatter.summarizePatchRequest(patch, 4));\n    }\n\n    private JSONObject parseArguments(String rawArguments) {\n        if (isBlank(rawArguments)) {\n            return new JSONObject();\n        }\n        try {\n            JSONObject arguments = JSON.parseObject(rawArguments);\n            return arguments == null ? new JSONObject() : arguments;\n        } catch (Exception ex) {\n            return new JSONObject();\n        }\n    }\n\n    private String defaultText(String value, String defaultValue) {\n        return isBlank(value) ? defaultValue : value;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private String safeTrimToNull(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private String clip(String value, int maxChars) {\n        if (value == null) {\n            return \"\";\n        }\n        String normalized = value.replace('\\r', ' ').replace('\\n', ' ').trim();\n        if (normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, maxChars) + \"...\";\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CodingCliSessionRunner.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.CliUiMode;\nimport io.github.lnyocly.ai4j.cli.SlashCommandController;\nimport io.github.lnyocly.ai4j.cli.agent.CliCodingAgentRegistry;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.command.CustomCommandRegistry;\nimport io.github.lnyocly.ai4j.cli.command.CustomCommandTemplate;\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliTuiFactory;\nimport io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliTuiFactory;\nimport io.github.lnyocly.ai4j.cli.factory.DefaultCodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpConfig;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpConfigManager;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpServerDefinition;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpStatusSnapshot;\nimport io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig;\nimport io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpServer;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderProfile;\nimport io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig;\nimport io.github.lnyocly.ai4j.cli.provider.CliResolvedProviderConfig;\nimport io.github.lnyocly.ai4j.cli.render.AssistantTranscriptRenderer;\nimport io.github.lnyocly.ai4j.cli.render.CliDisplayWidth;\nimport io.github.lnyocly.ai4j.cli.render.CliThemeStyler;\nimport io.github.lnyocly.ai4j.cli.render.CodexStyleBlockFormatter;\nimport io.github.lnyocly.ai4j.cli.render.PatchSummaryFormatter;\nimport io.github.lnyocly.ai4j.cli.render.TranscriptPrinter;\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.StoredCodingSession;\nimport io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.coding.CodingAgentResult;\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.coding.CodingAgentRequest;\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpointFormatter;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\nimport io.github.lnyocly.ai4j.coding.CodingSessionSnapshot;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.loop.CodingLoopDecision;\nimport io.github.lnyocly.ai4j.coding.loop.CodingStopReason;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessInfo;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessStatus;\nimport io.github.lnyocly.ai4j.coding.runtime.CodingRuntime;\nimport io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiAssistantPhase;\nimport io.github.lnyocly.ai4j.tui.TuiAssistantToolView;\nimport io.github.lnyocly.ai4j.tui.TuiAssistantViewModel;\nimport io.github.lnyocly.ai4j.tui.TuiConfig;\nimport io.github.lnyocly.ai4j.tui.TuiConfigManager;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\nimport io.github.lnyocly.ai4j.tui.TuiKeyStroke;\nimport io.github.lnyocly.ai4j.tui.TuiKeyType;\nimport io.github.lnyocly.ai4j.tui.TuiPaletteItem;\nimport io.github.lnyocly.ai4j.tui.TuiRenderContext;\nimport io.github.lnyocly.ai4j.tui.TuiRenderer;\nimport io.github.lnyocly.ai4j.tui.TuiRuntime;\nimport io.github.lnyocly.ai4j.tui.TuiScreenModel;\nimport io.github.lnyocly.ai4j.tui.AnsiTuiRuntime;\nimport io.github.lnyocly.ai4j.tui.AppendOnlyTuiRuntime;\nimport io.github.lnyocly.ai4j.tui.TuiTheme;\nimport io.github.lnyocly.ai4j.tui.TuiSessionView;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.text.SimpleDateFormat;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.Date;\nimport java.util.LinkedHashSet;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Properties;\nimport java.util.Set;\nimport java.util.TimeZone;\nimport java.util.UUID;\n\npublic class CodingCliSessionRunner {\n\n    private static final int DEFAULT_EVENT_LIMIT = 12;\n    private static final int DEFAULT_PROCESS_LOG_LIMIT = 800;\n    private static final int DEFAULT_REPLAY_LIMIT = 40;\n    private static final long PROCESS_FOLLOW_POLL_MS = 250L;\n    private static final int PROCESS_FOLLOW_MAX_IDLE_POLLS = 8;\n    private static final long TUI_TURN_ANIMATION_POLL_MS = 160L;\n    private static final long NON_ALTERNATE_SCREEN_RENDER_THROTTLE_MS = 120L;\n    private static final String TURN_INTERRUPTED_MESSAGE = \"Conversation interrupted by user.\";\n    private CodingAgent agent;\n    private CliProtocol protocol;\n    private CodeCommandOptions options;\n    private final TerminalIO terminal;\n    private final CodingSessionManager sessionManager;\n    private final TuiConfigManager tuiConfigManager;\n    private final CliProviderConfigManager providerConfigManager;\n    private final CliMcpConfigManager mcpConfigManager;\n    private final CustomCommandRegistry customCommandRegistry;\n    private final TuiInteractionState interactionState;\n    private final TuiRenderer tuiRenderer;\n    private final TuiRuntime tuiRuntime;\n    private final CodingCliAgentFactory agentFactory;\n    private final Map<String, String> env;\n    private final Properties properties;\n    private TuiConfig tuiConfig;\n    private TuiTheme tuiTheme;\n    private final CodexStyleBlockFormatter codexStyleBlockFormatter = new CodexStyleBlockFormatter(120, 4);\n    private final AssistantTranscriptRenderer assistantTranscriptRenderer = new AssistantTranscriptRenderer();\n    private final Set<String> pausedMcpServers = new LinkedHashSet<String>();\n    private List<CodingSessionDescriptor> tuiSessions = new ArrayList<CodingSessionDescriptor>();\n    private List<String> tuiHistory = new ArrayList<String>();\n    private List<String> tuiTree = new ArrayList<String>();\n    private List<String> tuiCommands = new ArrayList<String>();\n    private List<SessionEvent> tuiEvents = new ArrayList<SessionEvent>();\n    private List<String> tuiReplay = new ArrayList<String>();\n    private List<String> tuiTeamBoard = new ArrayList<String>();\n    private String selectedPersistedTeamId;\n    private boolean tuiPersistedTeamBoard;\n    private BashProcessInfo tuiInspectedProcess;\n    private BashProcessLogChunk tuiInspectedProcessLogs;\n    private String tuiAssistantOutput;\n    private final TuiLiveTurnState tuiLiveTurnState = new TuiLiveTurnState();\n    private final MainBufferTurnPrinter mainBufferTurnPrinter = new MainBufferTurnPrinter();\n    private final Object mainBufferTurnInterruptLock = new Object();\n    private volatile boolean tuiTurnAnimationRunning;\n    private Thread tuiTurnAnimationThread;\n    private volatile long lastNonAlternateScreenRenderAtMs;\n    private ManagedCodingSession activeSession;\n    private CliMcpRuntimeManager mcpRuntimeManager;\n    private boolean streamEnabled = false;\n    private volatile ActiveTuiTurn activeTuiTurn;\n    private volatile Thread activeMainBufferTurnThread;\n    private volatile String activeMainBufferTurnId;\n    private volatile boolean activeMainBufferTurnInterrupted;\n    private CodingRuntime bridgedRuntime;\n    private CodingTaskSessionEventBridge codingTaskEventBridge;\n\n    public CodingCliSessionRunner(CodingAgent agent,\n                                  CliProtocol protocol,\n                                  CodeCommandOptions options,\n                                  TerminalIO terminal,\n                                  CodingSessionManager sessionManager,\n                                  TuiInteractionState interactionState) {\n        this(agent, protocol, options, terminal, sessionManager, interactionState,\n                new DefaultCodingCliTuiFactory(), null, null, null, null);\n    }\n\n    public CodingCliSessionRunner(CodingAgent agent,\n                                  CliProtocol protocol,\n                                  CodeCommandOptions options,\n                                  TerminalIO terminal,\n                                  CodingSessionManager sessionManager,\n                                  TuiInteractionState interactionState,\n                                  SlashCommandController slashCommandController) {\n        this(agent, protocol, options, terminal, sessionManager, interactionState,\n                new DefaultCodingCliTuiFactory(), slashCommandController, null, null, null);\n    }\n\n    public CodingCliSessionRunner(CodingAgent agent,\n                                  CliProtocol protocol,\n                                  CodeCommandOptions options,\n                                  TerminalIO terminal,\n                                  CodingSessionManager sessionManager,\n                                  TuiInteractionState interactionState,\n                                  CodingCliTuiFactory tuiFactory) {\n        this(agent, protocol, options, terminal, sessionManager, interactionState,\n                tuiFactory, null, null, null, null);\n    }\n\n    public CodingCliSessionRunner(CodingAgent agent,\n                                  CliProtocol protocol,\n                                  CodeCommandOptions options,\n                                  TerminalIO terminal,\n                                  CodingSessionManager sessionManager,\n                                  TuiInteractionState interactionState,\n                                  CodingCliTuiFactory tuiFactory,\n                                  SlashCommandController slashCommandController,\n                                  CodingCliAgentFactory agentFactory,\n                                  Map<String, String> env,\n                                  Properties properties) {\n        this(agent, protocol, options, terminal, sessionManager, interactionState,\n                tuiFactory, slashCommandController, agentFactory, env, properties, true);\n    }\n\n    private CodingCliSessionRunner(CodingAgent agent,\n                                   CliProtocol protocol,\n                                   CodeCommandOptions options,\n                                   TerminalIO terminal,\n                                   CodingSessionManager sessionManager,\n                                   TuiInteractionState interactionState,\n                                   CodingCliTuiFactory tuiFactory,\n                                   SlashCommandController slashCommandController,\n                                   CodingCliAgentFactory agentFactory,\n                                   Map<String, String> env,\n                                   Properties properties,\n                                   boolean unused) {\n        this.agent = agent;\n        this.protocol = protocol;\n        this.options = options;\n        this.streamEnabled = options != null && options.isStream();\n        this.terminal = terminal;\n        this.sessionManager = sessionManager;\n        this.tuiConfigManager = new TuiConfigManager(java.nio.file.Paths.get(options.getWorkspace()));\n        this.providerConfigManager = new CliProviderConfigManager(java.nio.file.Paths.get(options.getWorkspace()));\n        this.mcpConfigManager = new CliMcpConfigManager(java.nio.file.Paths.get(options.getWorkspace()));\n        this.customCommandRegistry = new CustomCommandRegistry(java.nio.file.Paths.get(options.getWorkspace()));\n        this.interactionState = interactionState == null ? new TuiInteractionState() : interactionState;\n        this.agentFactory = agentFactory;\n        this.env = env == null ? Collections.<String, String>emptyMap() : new LinkedHashMap<String, String>(env);\n        this.properties = properties == null ? new Properties() : properties;\n        attachCodingTaskEventBridge();\n        if (options.getUiMode() == CliUiMode.TUI) {\n            CodingCliTuiSupport tuiSupport = (tuiFactory == null ? new DefaultCodingCliTuiFactory() : tuiFactory)\n                    .create(options, terminal, tuiConfigManager);\n            this.tuiConfig = tuiSupport == null || tuiSupport.getConfig() == null ? new TuiConfig() : tuiSupport.getConfig();\n            this.tuiTheme = tuiSupport == null || tuiSupport.getTheme() == null ? new TuiTheme() : tuiSupport.getTheme();\n            this.tuiRenderer = tuiSupport == null ? null : tuiSupport.getRenderer();\n            this.tuiRuntime = tuiSupport == null ? null : tuiSupport.getRuntime();\n            this.interactionState.setRenderCallback(new Runnable() {\n                @Override\n                public void run() {\n                    if (activeSession != null) {\n                        renderTuiFromCache(activeSession);\n                    }\n                }\n            });\n        } else {\n            this.tuiRenderer = null;\n            this.tuiRuntime = null;\n        }\n        if (terminal instanceof JlineShellTerminalIO) {\n            ((JlineShellTerminalIO) terminal).updateTheme(tuiTheme);\n        }\n        if (slashCommandController != null) {\n            slashCommandController.setProcessCandidateSupplier(\n                    new java.util.function.Supplier<List<SlashCommandController.ProcessCompletionCandidate>>() {\n                        @Override\n                        public List<SlashCommandController.ProcessCompletionCandidate> get() {\n                            return buildProcessCompletionCandidates();\n                    }\n                }\n            );\n            slashCommandController.setProfileCandidateSupplier(new java.util.function.Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return providerConfigManager.listProfileNames();\n                }\n            });\n            slashCommandController.setModelCandidateSupplier(\n                    new java.util.function.Supplier<List<SlashCommandController.ModelCompletionCandidate>>() {\n                        @Override\n                        public List<SlashCommandController.ModelCompletionCandidate> get() {\n                            return buildModelCompletionCandidates();\n                        }\n                    }\n            );\n            slashCommandController.setMcpServerCandidateSupplier(new java.util.function.Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return listKnownMcpServerNames();\n                }\n            });\n            slashCommandController.setSkillCandidateSupplier(new java.util.function.Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return listKnownSkillNames();\n                }\n            });\n            slashCommandController.setAgentCandidateSupplier(new java.util.function.Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return listKnownAgentNames();\n                }\n            });\n            slashCommandController.setTeamCandidateSupplier(new java.util.function.Supplier<List<String>>() {\n                @Override\n                public List<String> get() {\n                    return listKnownTeamIds();\n                }\n            });\n        }\n    }\n\n    public int run() throws Exception {\n        ManagedCodingSession session = openInitialSession();\n        activeSession = session;\n        boolean mainBufferInteractive = useMainBufferInteractiveShell();\n        boolean interactiveTui = options.getUiMode() == CliUiMode.TUI\n                && tuiRuntime != null\n                && tuiRuntime.supportsRawInput()\n                && !mainBufferInteractive\n                && isBlank(options.getPrompt());\n        try {\n            if (options.getUiMode() == CliUiMode.TUI\n                    && isBlank(options.getPrompt())\n                    && !interactiveTui\n                    && !mainBufferInteractive) {\n                terminal.errorln(\"Interactive TUI input is unavailable on this terminal, falling back to CLI mode.\");\n            }\n            if (!interactiveTui) {\n                printSessionHeader(session);\n            }\n\n            if (!isBlank(options.getPrompt())) {\n                runTurn(session, options.getPrompt());\n                return 0;\n            }\n\n            if (mainBufferInteractive) {\n                return runCliLoop(session);\n            }\n\n            if (interactiveTui) {\n                return runTuiLoop(session);\n            }\n            terminal.println(\"Use /help for in-session commands, /exit or /quit to leave.\\n\");\n            return runCliLoop(session);\n        } finally {\n            detachCodingTaskEventBridge();\n            closeQuietly(activeSession);\n            closeMcpRuntimeQuietly(mcpRuntimeManager);\n            persistSession(activeSession, false);\n        }\n    }\n\n    public void setMcpRuntimeManager(CliMcpRuntimeManager mcpRuntimeManager) {\n        this.mcpRuntimeManager = mcpRuntimeManager;\n    }\n\n    private int runCliLoop(ManagedCodingSession session) throws Exception {\n        while (true) {\n            // Keep readLine and transcript output on the same thread. When the\n            // prompt lived on a background thread, JLine could still report\n            // itself as \"reading\" for a short window after Enter, which routed\n            // the next assistant block through printAbove() and created the\n            // blank region the user was seeing before the actual text.\n            String input = terminal.readLine(\"> \");\n            if (input == null) {\n                terminal.println(\"\");\n                return 0;\n            }\n            if (isBlank(input)) {\n                continue;\n            }\n            JlineShellTerminalIO shellTerminal = terminal instanceof JlineShellTerminalIO\n                    ? (JlineShellTerminalIO) terminal\n                    : null;\n            if (shellTerminal != null && useMainBufferInteractiveShell()) {\n                shellTerminal.beginDirectOutputWindow();\n            }\n            try {\n                DispatchResult result = dispatchInteractiveInput(session, input);\n                session = result.getSession();\n                activeSession = session;\n                if (result.isExitRequested()) {\n                    if (!useMainBufferInteractiveShell()) {\n                        terminal.println(\"Session closed.\");\n                    }\n                    return 0;\n                }\n            } finally {\n                if (shellTerminal != null && useMainBufferInteractiveShell()) {\n                    shellTerminal.endDirectOutputWindow();\n                }\n            }\n        }\n    }\n\n    private int runTuiLoop(ManagedCodingSession session) throws Exception {\n        tuiRuntime.enter();\n        try {\n            setTuiAssistantOutput(\"Ask AI4J to inspect this repository\\nOpen the command palette with /\\nReplay recent history with Ctrl+R\\nOpen the team board with /team\");\n            renderTui(session);\n            while (true) {\n                session = reapCompletedTuiTurn(session);\n                activeSession = session;\n                TuiKeyStroke keyStroke = tuiRuntime.readKeyStroke(TUI_TURN_ANIMATION_POLL_MS);\n                if (keyStroke == null) {\n                    if (hasActiveTuiTurn()) {\n                        continue;\n                    }\n                    if (terminal != null && terminal.isInputClosed()) {\n                        return 0;\n                    }\n                    if (shouldAnimateAppendOnlyFooter() && tuiLiveTurnState.advanceAnimationTick()) {\n                        renderTuiFromCache(session);\n                        continue;\n                    }\n                    if (shouldAutoRefresh(session)) {\n                        renderTui(session);\n                    }\n                    continue;\n                }\n                DispatchResult result = handleTuiKey(session, keyStroke);\n                session = result.getSession();\n                activeSession = session;\n                if (result.isExitRequested()) {\n                    return 0;\n                }\n            }\n        } finally {\n            tuiRuntime.exit();\n        }\n    }\n\n    private DispatchResult handleTuiKey(ManagedCodingSession session, TuiKeyStroke keyStroke) throws Exception {\n        if (keyStroke == null) {\n            return DispatchResult.stay(session);\n        }\n        if (hasActiveTuiTurn()) {\n            return handleActiveTuiTurnKey(session, keyStroke);\n        }\n        TuiKeyType keyType = keyStroke.getType();\n        if (interactionState.isPaletteOpen()) {\n            if (interactionState.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH) {\n                switch (keyType) {\n                    case ESCAPE:\n                        interactionState.closePalette();\n                        return DispatchResult.stay(session);\n                    case ARROW_UP:\n                        interactionState.movePaletteSelection(-1);\n                        return DispatchResult.stay(session);\n                    case ARROW_DOWN:\n                        interactionState.movePaletteSelection(1);\n                        return DispatchResult.stay(session);\n                    case BACKSPACE:\n                        backspaceInputAndRefreshSlashPalette();\n                        return DispatchResult.stay(session);\n                    case TAB:\n                        applySlashSelection();\n                        return DispatchResult.stay(session);\n                    case ENTER:\n                        TuiPaletteItem slashItem = interactionState.getSelectedPaletteItem();\n                        if (slashItem != null) {\n                            interactionState.replaceInputBufferAndClosePalette(slashItem.getCommand());\n                            return DispatchResult.stay(session);\n                        }\n                        interactionState.closePaletteSilently();\n                        String slashInput = interactionState.consumeInputBufferSilently();\n                        if (isBlank(slashInput)) {\n                            return DispatchResult.stay(session);\n                        }\n                        return dispatchInteractiveInput(session, slashInput);\n                    case CHARACTER:\n                        appendInputAndRefreshSlashPalette(keyStroke.getText());\n                        return DispatchResult.stay(session);\n                    default:\n                        return DispatchResult.stay(session);\n                }\n            }\n            switch (keyType) {\n                case ESCAPE:\n                    interactionState.closePalette();\n                    return DispatchResult.stay(session);\n                case ARROW_UP:\n                    interactionState.movePaletteSelection(-1);\n                    return DispatchResult.stay(session);\n                case ARROW_DOWN:\n                    interactionState.movePaletteSelection(1);\n                    return DispatchResult.stay(session);\n                case BACKSPACE:\n                    interactionState.backspacePaletteQuery();\n                    return DispatchResult.stay(session);\n                case ENTER:\n                    TuiPaletteItem item = interactionState.getSelectedPaletteItem();\n                    interactionState.closePalette();\n                    return item == null ? DispatchResult.stay(session) : dispatchInteractiveInput(session, item.getCommand());\n                case CHARACTER:\n                    interactionState.appendPaletteQuery(keyStroke.getText());\n                    return DispatchResult.stay(session);\n                default:\n                    return DispatchResult.stay(session);\n            }\n        }\n\n        if (keyType == TuiKeyType.CTRL_P) {\n            interactionState.openPalette(buildPaletteItems(session));\n            return DispatchResult.stay(session);\n        }\n        if (keyType == TuiKeyType.CTRL_R) {\n            if (interactionState.isReplayViewerOpen()) {\n                interactionState.closeReplayViewer();\n            } else {\n                openReplayViewer(session, DEFAULT_REPLAY_LIMIT);\n            }\n            return DispatchResult.stay(session);\n        }\n        if (keyType == TuiKeyType.CTRL_L) {\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (interactionState.isTeamBoardOpen()) {\n            return handleTeamBoardKey(session, keyType);\n        }\n        if (interactionState.isReplayViewerOpen()) {\n            return handleReplayViewerKey(session, keyType);\n        }\n        if (interactionState.isProcessInspectorOpen()) {\n            return handleProcessInspectorKey(session, keyStroke);\n        }\n\n        switch (keyType) {\n            case TAB:\n                return DispatchResult.stay(session);\n            case ARROW_UP:\n                interactionState.moveTranscriptScroll(1);\n                return DispatchResult.stay(session);\n            case ARROW_DOWN:\n                interactionState.moveTranscriptScroll(-1);\n                return DispatchResult.stay(session);\n            case ESCAPE:\n                interactionState.clearInputBuffer();\n                closeSlashPaletteIfNeeded();\n                return DispatchResult.stay(session);\n            case BACKSPACE:\n                backspaceInputAndRefreshSlashPalette();\n                return DispatchResult.stay(session);\n            case ENTER:\n                if (interactionState.getFocusedPanel() == io.github.lnyocly.ai4j.tui.TuiPanelId.PROCESSES) {\n                    String processId = firstNonBlank(interactionState.getSelectedProcessId(), firstProcessId(session));\n                    if (!isBlank(processId)) {\n                        openProcessInspector(session, processId);\n                        return DispatchResult.stay(session);\n                    }\n                }\n                String input = interactionState.consumeInputBufferSilently();\n                closeSlashPaletteIfNeededSilently();\n                if (isBlank(input)) {\n                    return DispatchResult.stay(session);\n                }\n                return dispatchInteractiveInput(session, input);\n            case CHARACTER:\n                appendInputAndRefreshSlashPalette(keyStroke.getText());\n                return DispatchResult.stay(session);\n            default:\n                return DispatchResult.stay(session);\n        }\n    }\n\n    private DispatchResult handleActiveTuiTurnKey(ManagedCodingSession session, TuiKeyStroke keyStroke) {\n        if (keyStroke == null || keyStroke.getType() == null) {\n            return DispatchResult.stay(session);\n        }\n        if (keyStroke.getType() == TuiKeyType.ESCAPE) {\n            interruptActiveTuiTurn(session);\n        }\n        return DispatchResult.stay(session);\n    }\n\n    private DispatchResult handleReplayViewerKey(ManagedCodingSession session, TuiKeyType keyType) {\n        switch (keyType) {\n            case ESCAPE:\n                interactionState.closeReplayViewer();\n                return DispatchResult.stay(session);\n            case ARROW_UP:\n                interactionState.moveReplayScroll(-1);\n                return DispatchResult.stay(session);\n            case ARROW_DOWN:\n                interactionState.moveReplayScroll(1);\n                return DispatchResult.stay(session);\n            default:\n                return DispatchResult.stay(session);\n        }\n    }\n\n    private DispatchResult handleTeamBoardKey(ManagedCodingSession session, TuiKeyType keyType) {\n        switch (keyType) {\n            case ESCAPE:\n                interactionState.closeTeamBoard();\n                return DispatchResult.stay(session);\n            case ARROW_UP:\n                interactionState.moveTeamBoardScroll(-1);\n                return DispatchResult.stay(session);\n            case ARROW_DOWN:\n                interactionState.moveTeamBoardScroll(1);\n                return DispatchResult.stay(session);\n            default:\n                return DispatchResult.stay(session);\n        }\n    }\n\n    private void appendInputAndRefreshSlashPalette(String text) {\n        interactionState.appendInputAndSyncSlashPalette(text, buildCommandPaletteItems());\n    }\n\n    private void backspaceInputAndRefreshSlashPalette() {\n        interactionState.backspaceInputAndSyncSlashPalette(buildCommandPaletteItems());\n    }\n\n    private void refreshSlashPalette() {\n        interactionState.syncSlashPalette(buildCommandPaletteItems());\n    }\n\n    private void closeSlashPaletteIfNeeded() {\n        if (interactionState.isPaletteOpen()\n                && interactionState.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH) {\n            interactionState.closePalette();\n        }\n    }\n\n    private void closeSlashPaletteIfNeededSilently() {\n        if (interactionState.isPaletteOpen()\n                && interactionState.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH) {\n            interactionState.closePaletteSilently();\n        }\n    }\n\n    private void applySlashSelection() {\n        TuiPaletteItem item = interactionState.getSelectedPaletteItem();\n        if (item == null) {\n            return;\n        }\n        interactionState.replaceInputBufferAndClosePalette(item.getCommand());\n    }\n\n    private DispatchResult handleProcessInspectorKey(ManagedCodingSession session, TuiKeyStroke keyStroke) {\n        TuiKeyType keyType = keyStroke == null ? null : keyStroke.getType();\n        switch (keyType) {\n            case ESCAPE:\n                interactionState.closeProcessInspector();\n                return DispatchResult.stay(session);\n            case ARROW_UP:\n                interactionState.selectAdjacentProcess(resolveProcessIds(session), -1);\n                return DispatchResult.stay(session);\n            case ARROW_DOWN:\n                interactionState.selectAdjacentProcess(resolveProcessIds(session), 1);\n                return DispatchResult.stay(session);\n            case BACKSPACE:\n                interactionState.backspaceProcessInput();\n                return DispatchResult.stay(session);\n            case ENTER:\n                writeSelectedProcessInput(session);\n                return DispatchResult.stay(session);\n            case CHARACTER:\n                interactionState.appendProcessInput(keyStroke.getText());\n                return DispatchResult.stay(session);\n            default:\n                return DispatchResult.stay(session);\n        }\n    }\n\n    private DispatchResult moveFocusedSelection(ManagedCodingSession session, int delta) {\n        if (interactionState.getFocusedPanel() == io.github.lnyocly.ai4j.tui.TuiPanelId.PROCESSES) {\n            interactionState.selectAdjacentProcess(resolveProcessIds(session), delta);\n        }\n        return DispatchResult.stay(session);\n    }\n\n    private DispatchResult dispatchInteractiveInput(ManagedCodingSession session, String input) throws Exception {\n        if (isBlank(input)) {\n            return DispatchResult.stay(session);\n        }\n        interactionState.resetTranscriptScroll();\n        String normalized = input.trim();\n        if (\"/exit\".equalsIgnoreCase(normalized) || \"/quit\".equalsIgnoreCase(normalized)) {\n            return DispatchResult.exit(session);\n        }\n        if (\"/help\".equalsIgnoreCase(normalized)) {\n            printSessionHelp();\n            return DispatchResult.stay(session);\n        }\n        if (\"/status\".equalsIgnoreCase(normalized)) {\n            printStatus(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/session\".equalsIgnoreCase(normalized)) {\n            printCurrentSession(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/theme\".equalsIgnoreCase(normalized)) {\n            printThemes();\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/theme \")) {\n            applyTheme(extractCommandArgument(normalized), session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/save\".equalsIgnoreCase(normalized)) {\n            persistSession(session, true);\n            return DispatchResult.stay(session);\n        }\n        if (\"/providers\".equalsIgnoreCase(normalized)) {\n            printProviders();\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/provider\".equalsIgnoreCase(normalized)) {\n            printCurrentProvider();\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/provider \")) {\n            ManagedCodingSession switched = handleProviderCommand(session, extractCommandArgument(normalized));\n            activeSession = switched;\n            renderTui(switched);\n            return DispatchResult.stay(switched);\n        }\n        if (\"/model\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/model \")) {\n            ManagedCodingSession switched = handleModelCommand(session, extractCommandArgument(normalized));\n            activeSession = switched;\n            renderTui(switched);\n            return DispatchResult.stay(switched);\n        }\n        if (\"/experimental\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/experimental \")) {\n            ManagedCodingSession switched = handleExperimentalCommand(session, extractCommandArgument(normalized));\n            activeSession = switched;\n            renderTui(switched);\n            return DispatchResult.stay(switched);\n        }\n        if (\"/skills\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/skills \")) {\n            printSkills(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/agents\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/agents \")) {\n            printAgents(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/mcp\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/mcp \")) {\n            ManagedCodingSession switched = handleMcpCommand(session, extractCommandArgument(normalized));\n            activeSession = switched;\n            renderTui(switched);\n            return DispatchResult.stay(switched);\n        }\n        if (\"/commands\".equalsIgnoreCase(normalized) || \"/palette\".equalsIgnoreCase(normalized)) {\n            printCommands();\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/cmd \")) {\n            runCustomCommand(session, extractCommandArgument(normalized));\n            return DispatchResult.stay(session);\n        }\n        if (\"/sessions\".equalsIgnoreCase(normalized)) {\n            printSessions();\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/history\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/history \")) {\n            printHistory(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/tree\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/tree \")) {\n            printTree(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/events\")) {\n            printEvents(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/replay\")) {\n            printReplay(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/team\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/team \")) {\n            handleTeamCommand(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/compacts\")) {\n            printCompacts(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/stream\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/stream \")) {\n            ManagedCodingSession switched = handleStreamCommand(session, extractCommandArgument(normalized));\n            activeSession = switched;\n            renderTui(switched);\n            return DispatchResult.stay(switched);\n        }\n        if (\"/processes\".equalsIgnoreCase(normalized)) {\n            printProcesses(session);\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/process \")) {\n            handleProcessCommand(session, extractCommandArgument(normalized));\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/checkpoint\")) {\n            printCheckpoint(session);\n            return DispatchResult.stay(session);\n        }\n        if (\"/clear\".equalsIgnoreCase(normalized)) {\n            printClearMarker(session);\n            return DispatchResult.stay(session);\n        }\n        if (normalized.startsWith(\"/resume \") || normalized.startsWith(\"/load \")) {\n            ManagedCodingSession resumed = resumeSession(session, extractCommandArgument(normalized));\n            activeSession = resumed;\n            return DispatchResult.stay(resumed);\n        }\n        if (\"/fork\".equalsIgnoreCase(normalized) || normalized.startsWith(\"/fork \")) {\n            ManagedCodingSession forked = forkSession(session, extractCommandArgument(normalized));\n            activeSession = forked;\n            return DispatchResult.stay(forked);\n        }\n        if (normalized.startsWith(\"/compact\")) {\n            String turnId = newTurnId();\n            CodingSessionCompactResult result = resolveCompact(session.getSession(), normalized);\n            printCompactResult(result);\n            appendCompactEvent(session, result, turnId);\n            persistSession(session, false);\n            renderTui(session);\n            return DispatchResult.stay(session);\n        }\n\n        runAgentTurn(session, input);\n        return DispatchResult.stay(session);\n    }\n\n    private void runTurn(ManagedCodingSession session, String input) throws Exception {\n        runTurn(session, input, null, newTurnId());\n    }\n\n    private void runTurn(ManagedCodingSession session, String input, ActiveTuiTurn activeTurn) throws Exception {\n        runTurn(session, input, activeTurn, activeTurn == null ? newTurnId() : activeTurn.getTurnId());\n    }\n\n    private void runTurn(ManagedCodingSession session, String input, ActiveTuiTurn activeTurn, String turnId) throws Exception {\n        if (isTurnInterrupted(turnId, activeTurn)) {\n            return;\n        }\n        beginTuiTurn(input);\n        if (useMainBufferInteractiveShell()) {\n            mainBufferTurnPrinter.beginTurn(input);\n        } else {\n            startTuiTurnAnimation(session);\n        }\n        appendEvent(session, SessionEventType.USER_MESSAGE, turnId, null, clip(input, 200), payloadOf(\n                \"input\", clip(input, options.isVerbose() ? 4000 : 1200)\n        ));\n        renderTuiIfEnabled(session);\n\n        CliAgentListener listener = new CliAgentListener(session, turnId, activeTurn);\n        try {\n            CodingAgentResult result = session.getSession().runStream(CodingAgentRequest.builder().input(input).build(), listener);\n            if (activeTurn != null && activeTurn.isInterrupted()) {\n                return;\n            }\n            if (isMainBufferTurnInterrupted(turnId)) {\n                handleMainBufferTurnInterrupted(session, turnId);\n                return;\n            }\n            listener.flushFinalOutput();\n            if (activeTurn != null && activeTurn.isInterrupted()) {\n                return;\n            }\n            if (isMainBufferTurnInterrupted(turnId)) {\n                handleMainBufferTurnInterrupted(session, turnId);\n                return;\n            }\n            appendLoopDecisionEvents(session, turnId, result);\n            printAutoCompactOutcome(session, turnId);\n            renderTui(session);\n        } catch (Exception ex) {\n            if (activeTurn != null && activeTurn.isInterrupted()) {\n                return;\n            }\n            if (isMainBufferTurnInterrupted(turnId)) {\n                handleMainBufferTurnInterrupted(session, turnId);\n                return;\n            }\n            tuiLiveTurnState.onError(null, safeMessage(ex));\n            renderTuiIfEnabled(session);\n            appendEvent(session, SessionEventType.ERROR, turnId, null, safeMessage(ex), payloadOf(\n                    \"error\", safeMessage(ex)\n            ));\n            throw ex;\n        } finally {\n            listener.close();\n            try {\n                stopTuiTurnAnimation();\n            } finally {\n                mainBufferTurnPrinter.finishTurn();\n            }\n        }\n    }\n\n    private void runAgentTurn(ManagedCodingSession session, String input) throws Exception {\n        if (useMainBufferInteractiveShell()) {\n            runMainBufferTurn(session, input);\n            persistSession(session, false);\n            return;\n        }\n        if (!shouldRunTurnsAsync()) {\n            runTurn(session, input);\n            persistSession(session, false);\n            return;\n        }\n        startAsyncTuiTurn(session, input);\n    }\n\n    private void printSessionHelp() {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"In-session commands:\\n\");\n        builder.append(\"  /help    Show help\\n\");\n        builder.append(\"  /status  Show current session status\\n\");\n        builder.append(\"  /session Show current session metadata\\n\");\n        builder.append(\"  /theme [name]  Show or switch the active TUI theme\\n\");\n        builder.append(\"  /save    Persist the current session state\\n\");\n        builder.append(\"  /providers  List saved provider profiles\\n\");\n        builder.append(\"  /provider  Show current provider/profile state\\n\");\n        builder.append(\"  /provider use <name>  Switch workspace to a saved provider profile\\n\");\n        builder.append(\"  /provider save <name>  Save the current runtime as a provider profile\\n\");\n        builder.append(\"  /provider add <name> [options]  Create a provider profile from explicit fields\\n\");\n        builder.append(\"  /provider edit <name> [options]  Update a saved provider profile\\n\");\n        builder.append(\"  /provider default <name|clear>  Set or clear the global default profile\\n\");\n        builder.append(\"  /provider remove <name>  Delete a saved provider profile\\n\");\n        builder.append(\"  /model  Show the current effective model and override state\\n\");\n        builder.append(\"  /model <name>  Save a workspace model override and switch immediately\\n\");\n        builder.append(\"  /model reset  Clear the workspace model override\\n\");\n        builder.append(\"  /experimental  Show experimental runtime feature state\\n\");\n        builder.append(\"  /experimental <subagent|agent-teams> <on|off>  Toggle experimental subagent or team runtime tools\\n\");\n        builder.append(\"  /skills [name]  List discovered coding skills or inspect one skill in detail\\n\");\n        builder.append(\"  /agents [name]  List available coding agents or inspect one worker definition\\n\");\n        builder.append(\"  /mcp  Show current MCP services and status\\n\");\n        builder.append(\"  /mcp add --transport <stdio|sse|http> <name> <target>  Add a global MCP service\\n\");\n        builder.append(\"  /mcp enable|disable <name>  Toggle workspace MCP enablement\\n\");\n        builder.append(\"  /mcp pause|resume <name>  Toggle current session MCP activation\\n\");\n        builder.append(\"  /mcp retry <name>  Reconnect an enabled MCP service\\n\");\n        builder.append(\"  /mcp remove <name>  Delete a global MCP service\\n\");\n        builder.append(\"  /commands  List available custom commands\\n\");\n        builder.append(\"  /palette  Alias of /commands\\n\");\n        builder.append(\"  /cmd <name> [args]  Run a custom command template\\n\");\n        builder.append(\"  /sessions  List saved sessions\\n\");\n        builder.append(\"  /history [id]  Show session lineage from root to target\\n\");\n        builder.append(\"  /tree [id]  Show the current session tree\\n\");\n        builder.append(\"  /events [n]  Show the latest session ledger events\\n\");\n        builder.append(\"  /replay [n]  Replay recent turns grouped from the event ledger\\n\");\n        builder.append(\"  /team    Show the current agent team board grouped by member lane\\n\");\n        builder.append(\"  /team list|status [team-id]|messages [team-id] [limit]|resume [team-id]  Manage persisted team snapshots\\n\");\n        builder.append(\"  /compacts [n]  Show recent compact history from the event ledger\\n\");\n        builder.append(\"  /stream [on|off]  Show or switch model request streaming\\n\");\n        builder.append(\"  /processes  List active and restored process metadata\\n\");\n        builder.append(\"  /process status <id>  Show metadata for one process\\n\");\n        builder.append(\"  /process follow <id> [limit]  Show process metadata with buffered logs\\n\");\n        builder.append(\"  /process logs <id> [limit]  Read buffered logs for a process\\n\");\n        builder.append(\"  /process write <id> <text>  Write text to a live process stdin\\n\");\n        builder.append(\"  /process stop <id>  Stop a live process\\n\");\n        builder.append(\"  /checkpoint  Show the current structured checkpoint summary\\n\");\n        builder.append(\"  /resume <id>  Resume a saved session\\n\");\n        builder.append(\"  /load <id>    Alias of /resume\\n\");\n        builder.append(\"  /fork [new-id] or /fork <source-id> <new-id>  Fork a session branch\\n\");\n        builder.append(\"  /compact [summary]  Compact current session memory\\n\");\n        builder.append(\"  /clear   Print a new screen section\\n\");\n        builder.append(\"  /exit    Exit the session\\n\");\n        builder.append(\"  /quit    Exit the session\\n\");\n        builder.append(\"TUI keys: / opens command list, Tab accepts completion, Ctrl+P palette, Ctrl+R replay, /team opens the team board, Enter submit, Esc interrupts an active raw-TUI turn or clears input.\\n\");\n        builder.append(\"Type natural language instructions to drive the coding agent.\\n\");\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            setTuiAssistantOutput(builder.toString().trim());\n            return;\n        }\n        terminal.println(builder.toString());\n    }\n\n    private void printSessionHeader(ManagedCodingSession session) {\n        if (options.getUiMode() == CliUiMode.TUI) {\n            if (useMainBufferInteractiveShell()) {\n                refreshSessionContext(session);\n                String model = session == null ? \"model\" : firstNonBlank(session.getModel(), \"model\");\n                String workspace = session == null ? \".\" : lastPathSegment(firstNonBlank(session.getWorkspace(), \".\"));\n                terminal.println(\"AI4J  \" + clip(model, 28) + \"  \" + clip(workspace, 32));\n                terminal.println(\"\");\n                return;\n            }\n            renderTui(session);\n            return;\n        }\n\n        terminal.println(\"ai4j-cli code\");\n        terminal.println(\"session=\" + session.getSessionId());\n        terminal.println(\"provider=\" + session.getProvider()\n                + \", protocol=\" + session.getProtocol()\n                + \", model=\" + session.getModel());\n        terminal.println(\"workspace=\" + session.getWorkspace());\n        terminal.println(\"mode=\" + (options.isNoSession() ? \"memory-only\" : \"persistent\")\n                + \", root=\" + clip(session.getRootSessionId(), 64)\n                + \", parent=\" + clip(session.getParentSessionId(), 64));\n        terminal.println(\"store=\" + sessionManager.getDirectory());\n    }\n\n    private void printStatus(ManagedCodingSession session) {\n        CodingSessionSnapshot snapshot = session == null || session.getSession() == null ? null : session.getSession().snapshot();\n        if (useMainBufferInteractiveShell()) {\n            emitOutput(renderStatusOutput(session, snapshot));\n            return;\n        }\n        if (useAppendOnlyTranscriptTui()) {\n            emitOutput(renderStatusOutput(session, snapshot));\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            renderTui(session);\n            return;\n        }\n        terminal.println(\"status: session=\" + session.getSessionId()\n                + \", provider=\" + session.getProvider()\n                + \", protocol=\" + session.getProtocol()\n                + \", model=\" + session.getModel()\n                + \", workspace=\" + session.getWorkspace()\n                + \", mode=\" + (options.isNoSession() ? \"memory-only\" : \"persistent\")\n                + \", memory=\" + (snapshot == null ? 0 : snapshot.getMemoryItemCount())\n                + \", activeProcesses=\" + (snapshot == null ? 0 : snapshot.getActiveProcessCount())\n                + \", restoredProcesses=\" + (snapshot == null ? 0 : snapshot.getRestoredProcessCount())\n                + \", tokens=\" + (snapshot == null ? 0 : snapshot.getEstimatedContextTokens())\n                + \", checkpointGoal=\" + clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 80)\n                + \", compact=\" + firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), \"none\"));\n    }\n\n    private void printCurrentSession(ManagedCodingSession session) {\n        if (useMainBufferInteractiveShell()) {\n            emitOutput(renderSessionOutput(session));\n            return;\n        }\n        if (useAppendOnlyTranscriptTui()) {\n            emitOutput(renderSessionOutput(session));\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            renderTui(session);\n            return;\n        }\n        CodingSessionDescriptor descriptor = session == null ? null : session.toDescriptor();\n        if (descriptor == null) {\n            terminal.println(\"session: (none)\");\n            return;\n        }\n        CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties);\n        CodingSessionSnapshot snapshot = session.getSession() == null ? null : session.getSession().snapshot();\n        terminal.println(renderPanel(\"session\",\n                \"id        : \" + descriptor.getSessionId(),\n                \"root      : \" + descriptor.getRootSessionId(),\n                \"parent    : \" + firstNonBlank(descriptor.getParentSessionId(), \"(none)\"),\n                \"provider  : \" + descriptor.getProvider(),\n                \"protocol  : \" + descriptor.getProtocol(),\n                \"model     : \" + descriptor.getModel(),\n                \"profile   : \" + firstNonBlank(resolved.getActiveProfile(), resolved.getEffectiveProfile(), \"(none)\"),\n                \"override  : \" + firstNonBlank(resolved.getModelOverride(), \"(none)\"),\n                \"workspace : \" + descriptor.getWorkspace(),\n                \"mode      : \" + (options.isNoSession() ? \"memory-only\" : \"persistent\"),\n                \"created   : \" + formatTimestamp(descriptor.getCreatedAtEpochMs()),\n                \"updated   : \" + formatTimestamp(descriptor.getUpdatedAtEpochMs()),\n                \"memory    : \" + descriptor.getMemoryItemCount(),\n                \"processes : \" + descriptor.getProcessCount()\n                        + \" (active=\" + descriptor.getActiveProcessCount()\n                        + \", restored=\" + descriptor.getRestoredProcessCount() + \")\",\n                \"tokens    : \" + (snapshot == null ? 0 : snapshot.getEstimatedContextTokens()),\n                \"checkpoint: \" + clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 220),\n                \"compact   : \" + firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), \"none\")\n                        + \" \" + (snapshot == null ? \"\" : snapshot.getLastCompactTokensBefore() + \"->\" + snapshot.getLastCompactTokensAfter()),\n                \"summary   : \" + clip(descriptor.getSummary(), 220)\n        ));\n    }\n\n    private void printThemes() {\n        List<String> themes = tuiConfigManager.listThemeNames();\n        String currentTheme = tuiRenderer == null ? options.getTheme() : tuiRenderer.getThemeName();\n        if (useMainBufferInteractiveShell()) {\n            StringBuilder builder = new StringBuilder(\"themes:\\n\");\n            for (String themeName : themes) {\n                builder.append(\"- \").append(themeName);\n                if (themeName.equalsIgnoreCase(currentTheme)) {\n                    builder.append(\" (active)\");\n                }\n                builder.append('\\n');\n            }\n            emitOutput(builder.toString().trim());\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            if (tuiRenderer != null) {\n                StringBuilder builder = new StringBuilder();\n                builder.append(\"Available themes:\\n\");\n                for (String themeName : themes) {\n                    builder.append(\"- \").append(themeName);\n                    if (themeName.equalsIgnoreCase(currentTheme)) {\n                        builder.append(\"  (active)\");\n                    }\n                    builder.append('\\n');\n                }\n                setTuiAssistantOutput(builder.toString().trim());\n            }\n            return;\n        }\n        terminal.println(\"themes:\");\n        for (String themeName : themes) {\n            terminal.println(\"  \" + themeName + (themeName.equalsIgnoreCase(currentTheme) ? \"  *\" : \"\"));\n        }\n    }\n\n    private void printProviders() {\n        emitOutput(renderProvidersOutput());\n    }\n\n    private void printCurrentProvider() {\n        emitOutput(renderCurrentProviderOutput());\n    }\n\n    private void printSkills(ManagedCodingSession session, String argument) {\n        emitOutput(renderSkillsOutput(session, argument));\n    }\n\n    private void printAgents(ManagedCodingSession session, String argument) {\n        emitOutput(renderAgentsOutput(session, argument));\n    }\n\n    private ManagedCodingSession handleProviderCommand(ManagedCodingSession session, String argument) throws Exception {\n        if (isBlank(argument)) {\n            printCurrentProvider();\n            return session;\n        }\n        String trimmed = argument.trim();\n        String[] parts = trimmed.split(\"\\\\s+\", 2);\n        String action = parts[0].toLowerCase(Locale.ROOT);\n        String value = parts.length > 1 ? parts[1].trim() : null;\n        if (\"use\".equals(action)) {\n            return switchToProviderProfile(session, value);\n        }\n        if (\"save\".equals(action)) {\n            saveCurrentProviderProfile(value);\n            return session;\n        }\n        if (\"add\".equals(action)) {\n            addProviderProfile(value);\n            return session;\n        }\n        if (\"edit\".equals(action)) {\n            return editProviderProfile(session, value);\n        }\n        if (\"default\".equals(action)) {\n            setDefaultProviderProfile(value);\n            return session;\n        }\n        if (\"remove\".equals(action)) {\n            removeProviderProfile(value);\n            return session;\n        }\n        emitError(\"Unknown /provider action: \" + action + \". Use /provider, /providers, /provider use <name>, /provider save <name>, /provider add <name> ..., /provider edit <name> ..., /provider default <name|clear>, or /provider remove <name>.\");\n        return session;\n    }\n\n    private ManagedCodingSession handleModelCommand(ManagedCodingSession session, String argument) throws Exception {\n        if (isBlank(argument)) {\n            emitOutput(renderModelOutput());\n            return session;\n        }\n        String normalized = argument.trim();\n        CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n        if (\"reset\".equalsIgnoreCase(normalized)) {\n            workspaceConfig.setModelOverride(null);\n            providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n            CodeCommandOptions nextOptions = resolveConfiguredRuntimeOptions();\n            ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions);\n            emitOutput(renderModelOutput());\n            persistSession(rebound, false);\n            return rebound;\n        }\n        workspaceConfig.setModelOverride(normalized);\n        providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n        CodeCommandOptions nextOptions = resolveConfiguredRuntimeOptions();\n        ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions);\n        emitOutput(renderModelOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private ManagedCodingSession handleExperimentalCommand(ManagedCodingSession session, String argument) throws Exception {\n        if (isBlank(argument)) {\n            emitOutput(renderExperimentalOutput());\n            return session;\n        }\n        List<String> tokens = splitWhitespace(argument);\n        if (tokens.size() != 2) {\n            emitError(\"Usage: /experimental <subagent|agent-teams> <on|off>\");\n            return session;\n        }\n        String feature = normalizeExperimentalFeature(tokens.get(0));\n        Boolean enabled = parseExperimentalToggle(tokens.get(1));\n        if (feature == null || enabled == null) {\n            emitError(\"Usage: /experimental <subagent|agent-teams> <on|off>\");\n            return session;\n        }\n        CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n        if (\"subagent\".equals(feature)) {\n            workspaceConfig.setExperimentalSubagentsEnabled(enabled);\n        } else {\n            workspaceConfig.setExperimentalAgentTeamsEnabled(enabled);\n        }\n        providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n        ManagedCodingSession rebound = switchSessionRuntime(session, options);\n        emitOutput(renderExperimentalOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private ManagedCodingSession handleMcpCommand(ManagedCodingSession session, String argument) throws Exception {\n        if (isBlank(argument)) {\n            emitOutput(renderMcpOutput());\n            return session;\n        }\n        String trimmed = argument.trim();\n        String[] parts = trimmed.split(\"\\\\s+\", 2);\n        String action = parts[0].toLowerCase(Locale.ROOT);\n        String value = parts.length > 1 ? parts[1].trim() : null;\n        if (\"list\".equals(action)) {\n            emitOutput(renderMcpOutput());\n            return session;\n        }\n        if (\"add\".equals(action)) {\n            return addMcpServer(session, value);\n        }\n        if (\"remove\".equals(action) || \"delete\".equals(action)) {\n            return removeMcpServer(session, value);\n        }\n        if (\"enable\".equals(action)) {\n            return enableMcpServer(session, value);\n        }\n        if (\"disable\".equals(action)) {\n            return disableMcpServer(session, value);\n        }\n        if (\"pause\".equals(action)) {\n            return pauseMcpServer(session, value);\n        }\n        if (\"resume\".equals(action)) {\n            return resumeMcpServer(session, value);\n        }\n        if (\"retry\".equals(action)) {\n            return retryMcpServer(session, value);\n        }\n        emitError(\"Unknown /mcp action: \" + action + \". Use /mcp, /mcp list, /mcp add --transport <stdio|sse|http> <name> <target>, /mcp enable <name>, /mcp disable <name>, /mcp pause <name>, /mcp resume <name>, /mcp retry <name>, or /mcp remove <name>.\");\n        return session;\n    }\n\n    private ManagedCodingSession addMcpServer(ManagedCodingSession session, String rawArguments) throws Exception {\n        McpAddCommand command = parseMcpAddCommand(rawArguments);\n        if (command == null) {\n            return session;\n        }\n        CliMcpConfig globalConfig = mcpConfigManager.loadGlobalConfig();\n        if (globalConfig.getMcpServers().containsKey(command.name)) {\n            emitError(\"MCP server already exists: \" + command.name);\n            return session;\n        }\n        globalConfig.getMcpServers().put(command.name, command.definition);\n        mcpConfigManager.saveGlobalConfig(globalConfig);\n        emitOutput(\"mcp added: \" + command.name + \" -> \" + mcpConfigManager.globalMcpPath());\n\n        CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig();\n        if (containsIgnoreCase(workspaceConfig.getEnabledMcpServers(), command.name)) {\n            ManagedCodingSession rebound = switchSessionRuntime(session, options);\n            emitOutput(renderMcpOutput());\n            persistSession(rebound, false);\n            return rebound;\n        }\n        return session;\n    }\n\n    private ManagedCodingSession removeMcpServer(ManagedCodingSession session, String name) throws Exception {\n        if (isBlank(name)) {\n            emitError(\"Usage: /mcp remove <name>\");\n            return session;\n        }\n        String normalizedName = name.trim();\n        CliMcpConfig globalConfig = mcpConfigManager.loadGlobalConfig();\n        if (globalConfig.getMcpServers().remove(normalizedName) == null) {\n            emitError(\"Unknown MCP server: \" + normalizedName);\n            return session;\n        }\n        mcpConfigManager.saveGlobalConfig(globalConfig);\n\n        CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig();\n        workspaceConfig.setEnabledMcpServers(removeName(workspaceConfig.getEnabledMcpServers(), normalizedName));\n        mcpConfigManager.saveWorkspaceConfig(workspaceConfig);\n        pausedMcpServers.remove(normalizedName);\n\n        ManagedCodingSession rebound = switchSessionRuntime(session, options);\n        emitOutput(\"mcp removed: \" + normalizedName);\n        emitOutput(renderMcpOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private ManagedCodingSession enableMcpServer(ManagedCodingSession session, String name) throws Exception {\n        if (isBlank(name)) {\n            emitError(\"Usage: /mcp enable <name>\");\n            return session;\n        }\n        String normalizedName = name.trim();\n        if (!mcpConfigManager.loadGlobalConfig().getMcpServers().containsKey(normalizedName)) {\n            emitError(\"Unknown MCP server: \" + normalizedName);\n            return session;\n        }\n        CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig();\n        workspaceConfig.setEnabledMcpServers(addName(workspaceConfig.getEnabledMcpServers(), normalizedName));\n        mcpConfigManager.saveWorkspaceConfig(workspaceConfig);\n\n        ManagedCodingSession rebound = switchSessionRuntime(session, options);\n        emitOutput(renderMcpOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private ManagedCodingSession disableMcpServer(ManagedCodingSession session, String name) throws Exception {\n        if (isBlank(name)) {\n            emitError(\"Usage: /mcp disable <name>\");\n            return session;\n        }\n        String normalizedName = name.trim();\n        CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig();\n        if (!containsIgnoreCase(workspaceConfig.getEnabledMcpServers(), normalizedName)) {\n            emitError(\"MCP server is not enabled in this workspace: \" + normalizedName);\n            return session;\n        }\n        workspaceConfig.setEnabledMcpServers(removeName(workspaceConfig.getEnabledMcpServers(), normalizedName));\n        mcpConfigManager.saveWorkspaceConfig(workspaceConfig);\n        pausedMcpServers.remove(normalizedName);\n\n        ManagedCodingSession rebound = switchSessionRuntime(session, options);\n        emitOutput(renderMcpOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private ManagedCodingSession pauseMcpServer(ManagedCodingSession session, String name) throws Exception {\n        if (isBlank(name)) {\n            emitError(\"Usage: /mcp pause <name>\");\n            return session;\n        }\n        String normalizedName = name.trim();\n        CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig();\n        if (!containsIgnoreCase(workspaceConfig.getEnabledMcpServers(), normalizedName)) {\n            emitError(\"MCP server is not enabled in this workspace: \" + normalizedName);\n            return session;\n        }\n        if (!pausedMcpServers.add(normalizedName)) {\n            emitOutput(\"mcp already paused: \" + normalizedName);\n            return session;\n        }\n        ManagedCodingSession rebound = switchSessionRuntime(session, options);\n        emitOutput(renderMcpOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private ManagedCodingSession resumeMcpServer(ManagedCodingSession session, String name) throws Exception {\n        if (isBlank(name)) {\n            emitError(\"Usage: /mcp resume <name>\");\n            return session;\n        }\n        String normalizedName = name.trim();\n        if (!pausedMcpServers.remove(normalizedName)) {\n            emitError(\"MCP server is not paused in this session: \" + normalizedName);\n            return session;\n        }\n        ManagedCodingSession rebound = switchSessionRuntime(session, options);\n        emitOutput(renderMcpOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private ManagedCodingSession retryMcpServer(ManagedCodingSession session, String name) throws Exception {\n        if (isBlank(name)) {\n            emitError(\"Usage: /mcp retry <name>\");\n            return session;\n        }\n        String normalizedName = name.trim();\n        if (!containsMcpServer(normalizedName)) {\n            emitError(\"Unknown MCP server: \" + normalizedName);\n            return session;\n        }\n        ManagedCodingSession rebound = switchSessionRuntime(session, options);\n        emitOutput(renderMcpOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private McpAddCommand parseMcpAddCommand(String rawArguments) {\n        if (isBlank(rawArguments)) {\n            emitError(\"Usage: /mcp add --transport <stdio|sse|http> <name> <target>\");\n            return null;\n        }\n        List<String> tokens = splitWhitespace(rawArguments);\n        if (tokens.size() < 4 || !\"--transport\".equalsIgnoreCase(tokens.get(0))) {\n            emitError(\"Usage: /mcp add --transport <stdio|sse|http> <name> <target>\");\n            return null;\n        }\n        String transport = normalizeMcpTransport(tokens.get(1));\n        if (transport == null) {\n            emitError(\"Unsupported MCP transport: \" + tokens.get(1) + \". Use stdio, sse, or http.\");\n            return null;\n        }\n        String name = tokens.get(2).trim();\n        if (isBlank(name)) {\n            emitError(\"Usage: /mcp add --transport <stdio|sse|http> <name> <target>\");\n            return null;\n        }\n\n        CliMcpServerDefinition definition = new CliMcpServerDefinition();\n        definition.setType(transport);\n        if (\"stdio\".equals(transport)) {\n            if (tokens.size() < 4) {\n                emitError(\"Usage: /mcp add --transport stdio <name> <command> [args...]\");\n                return null;\n            }\n            definition.setCommand(tokens.get(3));\n            if (tokens.size() > 4) {\n                definition.setArgs(new ArrayList<String>(tokens.subList(4, tokens.size())));\n            }\n        } else {\n            if (tokens.size() != 4) {\n                emitError(\"Usage: /mcp add --transport \" + tokens.get(1) + \" <name> <url>\");\n                return null;\n            }\n            definition.setUrl(tokens.get(3));\n        }\n        return new McpAddCommand(name, definition);\n    }\n\n    private String renderMcpOutput() {\n        CliResolvedMcpConfig resolvedConfig = mcpConfigManager.resolve(pausedMcpServers);\n        List<CliMcpStatusSnapshot> statuses = mcpRuntimeManager != null && mcpRuntimeManager.hasStatuses()\n                ? mcpRuntimeManager.getStatuses()\n                : deriveMcpStatuses(resolvedConfig);\n        if ((statuses == null || statuses.isEmpty()) && resolvedConfig.getServers().isEmpty()) {\n            return \"mcp: (none)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"mcp:\\n\");\n        for (CliMcpStatusSnapshot status : statuses) {\n            if (status == null) {\n                continue;\n            }\n            builder.append(\"- \").append(status.getServerName())\n                    .append(\" | type=\").append(firstNonBlank(status.getTransportType(), \"(unknown)\"))\n                    .append(\" | state=\").append(firstNonBlank(status.getState(), \"(unknown)\"))\n                    .append(\" | workspace=\").append(status.isWorkspaceEnabled() ? \"enabled\" : \"disabled\")\n                    .append(\" | paused=\").append(status.isSessionPaused() ? \"yes\" : \"no\")\n                    .append(\" | tools=\").append(status.getToolCount());\n            if (!isBlank(status.getErrorSummary())) {\n                builder.append(\" | error=\").append(clip(status.getErrorSummary(), 120));\n            }\n            builder.append('\\n');\n        }\n        builder.append(\"- store=\").append(mcpConfigManager.globalMcpPath()).append('\\n');\n        builder.append(\"- workspaceConfig=\").append(mcpConfigManager.workspaceConfigPath());\n        return builder.toString().trim();\n    }\n\n    private List<CliMcpStatusSnapshot> deriveMcpStatuses(CliResolvedMcpConfig resolvedConfig) {\n        List<CliMcpStatusSnapshot> statuses = new ArrayList<CliMcpStatusSnapshot>();\n        if (resolvedConfig == null) {\n            return statuses;\n        }\n        for (CliResolvedMcpServer server : resolvedConfig.getServers().values()) {\n            String state = server.isWorkspaceEnabled()\n                    ? (server.isSessionPaused()\n                    ? CliMcpRuntimeManager.STATE_PAUSED\n                    : (server.isValid() ? \"configured\" : CliMcpRuntimeManager.STATE_ERROR))\n                    : CliMcpRuntimeManager.STATE_DISABLED;\n            statuses.add(new CliMcpStatusSnapshot(\n                    server.getName(),\n                    server.getTransportType(),\n                    state,\n                    0,\n                    server.getValidationError(),\n                    server.isWorkspaceEnabled(),\n                    server.isSessionPaused()\n            ));\n        }\n        for (String missing : resolvedConfig.getUnknownEnabledServerNames()) {\n            statuses.add(new CliMcpStatusSnapshot(\n                    missing,\n                    null,\n                    CliMcpRuntimeManager.STATE_MISSING,\n                    0,\n                    \"workspace references undefined MCP server\",\n                    true,\n                    false\n            ));\n        }\n        return statuses;\n    }\n\n    private ManagedCodingSession switchToProviderProfile(ManagedCodingSession session, String profileName) throws Exception {\n        if (isBlank(profileName)) {\n            emitError(\"Usage: /provider use <profile-name>\");\n            return session;\n        }\n        CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n        String normalizedName = profileName.trim();\n        if (!providersConfig.getProfiles().containsKey(normalizedName)) {\n            emitError(\"Unknown provider profile: \" + normalizedName);\n            return session;\n        }\n        CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n        workspaceConfig.setActiveProfile(normalizedName);\n        providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n        CodeCommandOptions nextOptions = resolveConfiguredRuntimeOptions();\n        ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions);\n        emitOutput(renderCurrentProviderOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private void saveCurrentProviderProfile(String profileName) throws IOException {\n        if (isBlank(profileName)) {\n            emitError(\"Usage: /provider save <profile-name>\");\n            return;\n        }\n        CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n        String normalizedName = profileName.trim();\n        providersConfig.getProfiles().put(normalizedName, CliProviderProfile.builder()\n                .provider(options.getProvider() == null ? null : options.getProvider().getPlatform())\n                .protocol(protocol == null ? null : protocol.getValue())\n                .model(options.getModel())\n                .baseUrl(options.getBaseUrl())\n                .apiKey(options.getApiKey())\n                .build());\n        if (isBlank(providersConfig.getDefaultProfile())) {\n            providersConfig.setDefaultProfile(normalizedName);\n        }\n        providerConfigManager.saveProvidersConfig(providersConfig);\n        emitOutput(\"provider saved: \" + normalizedName + \" -> \" + providerConfigManager.globalProvidersPath());\n    }\n\n    private void addProviderProfile(String rawArguments) throws IOException {\n        ProviderProfileMutation mutation = parseProviderProfileMutation(\n                rawArguments,\n                \"Usage: /provider add <profile-name> --provider <name> [--protocol <chat|responses>] [--model <name>] [--base-url <url>] [--api-key <key>]\"\n        );\n        if (mutation == null) {\n            return;\n        }\n        CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n        if (providersConfig.getProfiles().containsKey(mutation.profileName)) {\n            emitError(\"Provider profile already exists: \" + mutation.profileName + \". Use /provider edit \" + mutation.profileName + \" ...\");\n            return;\n        }\n        if (isBlank(mutation.provider)) {\n            emitError(\"Usage: /provider add <profile-name> --provider <name> [--protocol <chat|responses>] [--model <name>] [--base-url <url>] [--api-key <key>]\");\n            return;\n        }\n        PlatformType provider = parseProviderType(mutation.provider);\n        if (provider == null) {\n            return;\n        }\n        String baseUrlValue = mutation.clearBaseUrl ? null : mutation.baseUrl;\n        CliProtocol protocolValue = parseProviderProtocol(\n                firstNonBlank(mutation.protocol, CliProtocol.defaultProtocol(provider, baseUrlValue).getValue())\n        );\n        if (protocolValue == null) {\n            return;\n        }\n        if (!isSupportedProviderProtocol(provider, protocolValue)) {\n            return;\n        }\n\n        providersConfig.getProfiles().put(mutation.profileName, CliProviderProfile.builder()\n                .provider(provider.getPlatform())\n                .protocol(protocolValue.getValue())\n                .model(mutation.clearModel ? null : mutation.model)\n                .baseUrl(mutation.clearBaseUrl ? null : mutation.baseUrl)\n                .apiKey(mutation.clearApiKey ? null : mutation.apiKey)\n                .build());\n        if (isBlank(providersConfig.getDefaultProfile())) {\n            providersConfig.setDefaultProfile(mutation.profileName);\n        }\n        providerConfigManager.saveProvidersConfig(providersConfig);\n        emitOutput(\"provider added: \" + mutation.profileName + \" -> \" + providerConfigManager.globalProvidersPath());\n    }\n\n    private ManagedCodingSession editProviderProfile(ManagedCodingSession session, String rawArguments) throws Exception {\n        ProviderProfileMutation mutation = parseProviderProfileMutation(\n                rawArguments,\n                \"Usage: /provider edit <profile-name> [--provider <name>] [--protocol <chat|responses>] [--model <name>|--clear-model] [--base-url <url>|--clear-base-url] [--api-key <key>|--clear-api-key]\"\n        );\n        if (mutation == null) {\n            return session;\n        }\n        if (!mutation.hasAnyFieldChanges()) {\n            emitError(\"Usage: /provider edit <profile-name> [--provider <name>] [--protocol <chat|responses>] [--model <name>|--clear-model] [--base-url <url>|--clear-base-url] [--api-key <key>|--clear-api-key]\");\n            return session;\n        }\n        CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n        CliProviderProfile existing = providersConfig.getProfiles().get(mutation.profileName);\n        if (existing == null) {\n            emitError(\"Unknown provider profile: \" + mutation.profileName);\n            return session;\n        }\n\n        PlatformType provider = parseProviderType(firstNonBlank(mutation.provider, existing.getProvider()));\n        if (provider == null) {\n            return session;\n        }\n        String baseUrlValue = mutation.clearBaseUrl\n                ? null\n                : firstNonBlank(mutation.baseUrl, existing.getBaseUrl());\n        String protocolRaw = mutation.protocol;\n        if (isBlank(protocolRaw)) {\n            protocolRaw = firstNonBlank(\n                    normalizeStoredProtocol(existing.getProtocol(), provider, baseUrlValue),\n                    CliProtocol.defaultProtocol(provider, baseUrlValue).getValue()\n            );\n        }\n        CliProtocol protocolValue = parseProviderProtocol(protocolRaw);\n        if (protocolValue == null) {\n            return session;\n        }\n        if (!isSupportedProviderProtocol(provider, protocolValue)) {\n            return session;\n        }\n\n        existing.setProvider(provider.getPlatform());\n        existing.setProtocol(protocolValue.getValue());\n        if (mutation.clearModel) {\n            existing.setModel(null);\n        } else if (mutation.model != null) {\n            existing.setModel(mutation.model);\n        }\n        if (mutation.clearBaseUrl) {\n            existing.setBaseUrl(null);\n        } else if (mutation.baseUrl != null) {\n            existing.setBaseUrl(mutation.baseUrl);\n        }\n        if (mutation.clearApiKey) {\n            existing.setApiKey(null);\n        } else if (mutation.apiKey != null) {\n            existing.setApiKey(mutation.apiKey);\n        }\n\n        String effectiveProfileBeforeEdit = providerConfigManager.resolve(null, null, null, null, null, env, properties).getEffectiveProfile();\n        providersConfig.getProfiles().put(mutation.profileName, existing);\n        providerConfigManager.saveProvidersConfig(providersConfig);\n        emitOutput(\"provider updated: \" + mutation.profileName + \" -> \" + providerConfigManager.globalProvidersPath());\n        if (!mutation.profileName.equals(effectiveProfileBeforeEdit)) {\n            return session;\n        }\n        CodeCommandOptions nextOptions = resolveConfiguredRuntimeOptions();\n        ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions);\n        emitOutput(renderCurrentProviderOutput());\n        persistSession(rebound, false);\n        return rebound;\n    }\n\n    private void removeProviderProfile(String profileName) throws IOException {\n        if (isBlank(profileName)) {\n            emitError(\"Usage: /provider remove <profile-name>\");\n            return;\n        }\n        String normalizedName = profileName.trim();\n        CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n        if (providersConfig.getProfiles().remove(normalizedName) == null) {\n            emitError(\"Unknown provider profile: \" + normalizedName);\n            return;\n        }\n        if (normalizedName.equals(providersConfig.getDefaultProfile())) {\n            providersConfig.setDefaultProfile(null);\n        }\n        providerConfigManager.saveProvidersConfig(providersConfig);\n        CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n        if (normalizedName.equals(workspaceConfig.getActiveProfile())) {\n            workspaceConfig.setActiveProfile(null);\n            providerConfigManager.saveWorkspaceConfig(workspaceConfig);\n        }\n        emitOutput(\"provider removed: \" + normalizedName);\n    }\n\n    private void setDefaultProviderProfile(String profileName) throws IOException {\n        if (isBlank(profileName)) {\n            emitError(\"Usage: /provider default <profile-name|clear>\");\n            return;\n        }\n        CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n        String normalizedName = profileName.trim();\n        if (\"clear\".equalsIgnoreCase(normalizedName)) {\n            providersConfig.setDefaultProfile(null);\n            providerConfigManager.saveProvidersConfig(providersConfig);\n            emitOutput(\"provider default cleared\");\n            return;\n        }\n        if (!providersConfig.getProfiles().containsKey(normalizedName)) {\n            emitError(\"Unknown provider profile: \" + normalizedName);\n            return;\n        }\n        providersConfig.setDefaultProfile(normalizedName);\n        providerConfigManager.saveProvidersConfig(providersConfig);\n        emitOutput(\"provider default: \" + normalizedName);\n    }\n\n    private ProviderProfileMutation parseProviderProfileMutation(String rawArguments, String usage) {\n        if (isBlank(rawArguments)) {\n            emitError(usage);\n            return null;\n        }\n        String[] tokens = rawArguments.trim().split(\"\\\\s+\");\n        if (tokens.length == 0 || isBlank(tokens[0])) {\n            emitError(usage);\n            return null;\n        }\n        ProviderProfileMutation mutation = new ProviderProfileMutation(tokens[0].trim());\n        for (int i = 1; i < tokens.length; i++) {\n            String token = tokens[i];\n            if (\"--provider\".equalsIgnoreCase(token)) {\n                String value = requireProviderMutationValue(tokens, ++i, token, usage);\n                if (value == null) {\n                    return null;\n                }\n                mutation.provider = value;\n                continue;\n            }\n            if (\"--protocol\".equalsIgnoreCase(token)) {\n                String value = requireProviderMutationValue(tokens, ++i, token, usage);\n                if (value == null) {\n                    return null;\n                }\n                mutation.protocol = value;\n                continue;\n            }\n            if (\"--model\".equalsIgnoreCase(token)) {\n                String value = requireProviderMutationValue(tokens, ++i, token, usage);\n                if (value == null) {\n                    return null;\n                }\n                mutation.model = value;\n                mutation.clearModel = false;\n                continue;\n            }\n            if (\"--base-url\".equalsIgnoreCase(token)) {\n                String value = requireProviderMutationValue(tokens, ++i, token, usage);\n                if (value == null) {\n                    return null;\n                }\n                mutation.baseUrl = value;\n                mutation.clearBaseUrl = false;\n                continue;\n            }\n            if (\"--api-key\".equalsIgnoreCase(token)) {\n                String value = requireProviderMutationValue(tokens, ++i, token, usage);\n                if (value == null) {\n                    return null;\n                }\n                mutation.apiKey = value;\n                mutation.clearApiKey = false;\n                continue;\n            }\n            if (\"--clear-model\".equalsIgnoreCase(token)) {\n                mutation.model = null;\n                mutation.clearModel = true;\n                continue;\n            }\n            if (\"--clear-base-url\".equalsIgnoreCase(token)) {\n                mutation.baseUrl = null;\n                mutation.clearBaseUrl = true;\n                continue;\n            }\n            if (\"--clear-api-key\".equalsIgnoreCase(token)) {\n                mutation.apiKey = null;\n                mutation.clearApiKey = true;\n                continue;\n            }\n            emitError(\"Unknown provider option: \" + token + \". \" + usage);\n            return null;\n        }\n        return mutation;\n    }\n\n    private String requireProviderMutationValue(String[] tokens, int index, String option, String usage) {\n        if (tokens == null || index < 0 || index >= tokens.length || isBlank(tokens[index])) {\n            emitError(\"Missing value for \" + option + \". \" + usage);\n            return null;\n        }\n        return tokens[index].trim();\n    }\n\n    private PlatformType parseProviderType(String raw) {\n        if (isBlank(raw)) {\n            emitError(\"Provider is required\");\n            return null;\n        }\n        for (PlatformType platformType : PlatformType.values()) {\n            if (platformType.getPlatform().equalsIgnoreCase(raw.trim())) {\n                return platformType;\n            }\n        }\n        emitError(\"Unsupported provider: \" + raw);\n        return null;\n    }\n\n    private CliProtocol parseProviderProtocol(String raw) {\n        try {\n            return CliProtocol.parse(raw);\n        } catch (IllegalArgumentException ex) {\n            emitError(ex.getMessage());\n            return null;\n        }\n    }\n\n    private boolean isSupportedProviderProtocol(PlatformType provider, CliProtocol protocol) {\n        if (provider == null || protocol == null || protocol == CliProtocol.CHAT) {\n            return true;\n        }\n        if (provider != PlatformType.OPENAI && provider != PlatformType.DOUBAO && provider != PlatformType.DASHSCOPE) {\n            emitError(\"Provider \" + provider.getPlatform() + \" does not support responses protocol in ai4j-cli yet\");\n            return false;\n        }\n        return true;\n    }\n\n    private String normalizeStoredProtocol(String raw, PlatformType provider, String baseUrl) {\n        if (isBlank(raw)) {\n            return null;\n        }\n        return CliProtocol.resolveConfigured(raw, provider, baseUrl).getValue();\n    }\n\n    private CodeCommandOptions resolveConfiguredRuntimeOptions() {\n        CliResolvedProviderConfig resolved = providerConfigManager.resolve(\n                null,\n                null,\n                null,\n                null,\n                null,\n                env,\n                properties\n        );\n        return options.withRuntime(\n                resolved.getProvider(),\n                resolved.getProtocol(),\n                resolved.getModel(),\n                resolved.getApiKey(),\n                resolved.getBaseUrl()\n        );\n    }\n\n    private ManagedCodingSession switchSessionRuntime(ManagedCodingSession session, CodeCommandOptions nextOptions) throws Exception {\n        if (agentFactory == null) {\n            throw new IllegalStateException(\"Runtime switching is unavailable in this shell\");\n        }\n        CodingCliAgentFactory.PreparedCodingAgent prepared = agentFactory.prepare(\n                nextOptions,\n                terminal,\n                interactionState,\n                pausedMcpServers\n        );\n        if (session == null || session.getSession() == null) {\n            closeMcpRuntimeQuietly(mcpRuntimeManager);\n            this.agent = prepared.getAgent();\n            this.protocol = prepared.getProtocol();\n            this.options = nextOptions;\n            this.streamEnabled = nextOptions != null && nextOptions.isStream();\n            this.mcpRuntimeManager = prepared.getMcpRuntimeManager();\n            attachCodingTaskEventBridge();\n            refreshSessionContext(null);\n            return session;\n        }\n\n        io.github.lnyocly.ai4j.coding.CodingSessionState state = session.getSession().exportState();\n        CodingSession nextSession = prepared.getAgent().newSession(session.getSessionId(), state);\n        ManagedCodingSession rebound = new ManagedCodingSession(\n                nextSession,\n                nextOptions.getProvider().getPlatform(),\n                prepared.getProtocol().getValue(),\n                nextOptions.getModel(),\n                nextOptions.getWorkspace(),\n                nextOptions.getWorkspaceDescription(),\n                nextOptions.getSystemPrompt(),\n                nextOptions.getInstructions(),\n                firstNonBlank(session.getRootSessionId(), session.getSessionId()),\n                session.getParentSessionId(),\n                session.getCreatedAtEpochMs(),\n                session.getUpdatedAtEpochMs()\n        );\n        closeQuietly(session);\n        closeMcpRuntimeQuietly(mcpRuntimeManager);\n        this.agent = prepared.getAgent();\n        this.protocol = prepared.getProtocol();\n        this.options = nextOptions;\n        this.streamEnabled = nextOptions != null && nextOptions.isStream();\n        this.mcpRuntimeManager = prepared.getMcpRuntimeManager();\n        attachCodingTaskEventBridge();\n        refreshSessionContext(rebound);\n        return rebound;\n    }\n\n    private void printCommands() {\n        List<CustomCommandTemplate> commands = customCommandRegistry.list();\n        if (commands.isEmpty()) {\n            emitOutput(\"commands: (none)\");\n            return;\n        }\n        if (useMainBufferInteractiveShell()) {\n            StringBuilder builder = new StringBuilder(\"commands:\\n\");\n            for (CustomCommandTemplate command : commands) {\n                builder.append(\"- \").append(command.getName())\n                        .append(\" | \").append(firstNonBlank(command.getDescription(), \"(no description)\"))\n                        .append(\" | \").append(command.getSource())\n                        .append('\\n');\n            }\n            emitOutput(builder.toString().trim());\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            StringBuilder builder = new StringBuilder();\n            builder.append(\"commands:\\n\");\n            for (CustomCommandTemplate command : commands) {\n                builder.append(\"- \").append(command.getName())\n                        .append(\" | \").append(firstNonBlank(command.getDescription(), \"(no description)\"))\n                        .append(\" | \").append(command.getSource())\n                        .append('\\n');\n            }\n            setTuiAssistantOutput(builder.toString().trim());\n            return;\n        }\n        terminal.println(\"commands:\");\n        for (CustomCommandTemplate command : commands) {\n            terminal.println(\"  \" + command.getName()\n                    + \" | \" + firstNonBlank(command.getDescription(), \"(no description)\")\n                    + \" | \" + command.getSource());\n        }\n    }\n\n    private void runCustomCommand(ManagedCodingSession session, String rawArguments) throws Exception {\n        if (session == null) {\n            emitError(\"No current session.\");\n            return;\n        }\n        if (isBlank(rawArguments)) {\n            emitError(\"Usage: /cmd <name> [args]\");\n            return;\n        }\n        String trimmed = rawArguments.trim();\n        int firstSpace = trimmed.indexOf(' ');\n        String name = firstSpace < 0 ? trimmed : trimmed.substring(0, firstSpace).trim();\n        String args = firstSpace < 0 ? \"\" : trimmed.substring(firstSpace + 1).trim();\n        CustomCommandTemplate command = customCommandRegistry.find(name);\n        if (command == null) {\n            emitError(\"Unknown custom command: \" + name);\n            return;\n        }\n        String rendered = renderCustomCommand(command, session, args);\n        runAgentTurn(session, rendered);\n    }\n\n    private String renderCustomCommand(CustomCommandTemplate command, ManagedCodingSession session, String args) {\n        Map<String, String> variables = new LinkedHashMap<String, String>();\n        variables.put(\"ARGUMENTS\", args);\n        variables.put(\"WORKSPACE\", options.getWorkspace());\n        variables.put(\"SESSION_ID\", session == null ? \"\" : session.getSessionId());\n        variables.put(\"ROOT_SESSION_ID\", session == null ? \"\" : firstNonBlank(session.getRootSessionId(), \"\"));\n        variables.put(\"PARENT_SESSION_ID\", session == null ? \"\" : firstNonBlank(session.getParentSessionId(), \"\"));\n        String rendered = command.render(variables).trim();\n        if (!isBlank(args) && (command.getTemplate() == null || !command.getTemplate().contains(\"$ARGUMENTS\"))) {\n            rendered = rendered + \"\\n\\nArguments:\\n\" + args;\n        }\n        return rendered;\n    }\n\n    private void applyTheme(String themeName, ManagedCodingSession session) {\n        if (isBlank(themeName)) {\n            emitError(\"Usage: /theme <name>\");\n            return;\n        }\n        try {\n            TuiConfig config = tuiConfigManager.switchTheme(themeName);\n            TuiTheme theme = tuiConfigManager.resolveTheme(config.getTheme());\n            if (terminal instanceof JlineShellTerminalIO) {\n                ((JlineShellTerminalIO) terminal).updateTheme(theme);\n            }\n            if (tuiRenderer != null) {\n                tuiConfig = config;\n                tuiTheme = theme;\n                tuiRenderer.updateTheme(config, theme);\n                setTuiAssistantOutput(\"Theme switched to `\" + theme.getName() + \"`.\");\n            }\n            if (options.getUiMode() != CliUiMode.TUI || useMainBufferInteractiveShell()) {\n                terminal.println(\"theme switched to: \" + theme.getName());\n            }\n            renderTui(session);\n        } catch (Exception ex) {\n            emitError(\"Failed to switch theme: \" + safeMessage(ex));\n        }\n    }\n\n    private void printCheckpoint(ManagedCodingSession session) {\n        CodingSessionCheckpoint checkpoint = session == null || session.getSession() == null\n                ? null\n                : session.getSession().exportState().getCheckpoint();\n        if (useMainBufferInteractiveShell()) {\n            emitOutput(renderCheckpointOutput(checkpoint));\n            return;\n        }\n        if (useAppendOnlyTranscriptTui()) {\n            emitOutput(renderCheckpointOutput(checkpoint));\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            renderTui(session);\n            return;\n        }\n        if (checkpoint == null) {\n            terminal.println(renderPanel(\"checkpoint\", \"(none)\"));\n            return;\n        }\n        terminal.println(renderPanel(\"checkpoint\", toPanelLines(CodingSessionCheckpointFormatter.render(checkpoint), 180, 32)));\n    }\n\n    private void printClearMarker(ManagedCodingSession session) {\n        if (useAppendOnlyTranscriptTui()) {\n            terminal.println(\"\");\n            return;\n        }\n        if (useMainBufferInteractiveShell()) {\n            mainBufferTurnPrinter.printSectionBreak();\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            renderTui(session);\n            return;\n        }\n        terminal.println(\"\");\n    }\n\n    private CodingSessionCompactResult resolveCompact(CodingSession session, String normalizedCommand) {\n        String summary = null;\n        if (normalizedCommand != null) {\n            String trimmed = normalizedCommand.trim();\n            if (trimmed.length() > \"/compact\".length()) {\n                summary = trimmed.substring(\"/compact\".length()).trim();\n            }\n        }\n        return isBlank(summary) ? session.compact() : session.compact(summary);\n    }\n\n    private void printCompactResult(CodingSessionCompactResult result) {\n        if (result == null) {\n            return;\n        }\n        if (useMainBufferInteractiveShell()) {\n            mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatCompact(result));\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI) {\n            setTuiAssistantOutput(result.getSummary());\n            return;\n        }\n        terminal.println(\"compact: mode=\" + (result.isAutomatic() ? \"auto\" : \"manual\")\n                + \", strategy=\" + firstNonBlank(result.getStrategy(), \"checkpoint\")\n                + \", before=\" + result.getBeforeItemCount()\n                + \", after=\" + result.getAfterItemCount()\n                + \", tokens=\" + result.getEstimatedTokensBefore() + \"->\" + result.getEstimatedTokensAfter()\n                + \", split=\" + result.isSplitTurn()\n                + \", fallback=\" + result.isFallbackSummary()\n                + \", summary=\" + clip(result.getSummary(), options.isVerbose() ? 320 : 180));\n    }\n\n    private void printAutoCompactOutcome(ManagedCodingSession session, String turnId) {\n        List<CodingSessionCompactResult> results = session == null || session.getSession() == null\n                ? Collections.<CodingSessionCompactResult>emptyList()\n                : session.getSession().drainAutoCompactResults();\n        for (CodingSessionCompactResult result : results) {\n            if (result == null) {\n                continue;\n            }\n            printCompactResult(result);\n            appendCompactEvent(session, result, turnId);\n        }\n        List<Exception> errors = session == null || session.getSession() == null\n                ? Collections.<Exception>emptyList()\n                : session.getSession().drainAutoCompactErrors();\n        for (Exception error : errors) {\n            if (error == null) {\n                continue;\n            }\n            appendEvent(session, SessionEventType.ERROR, turnId, null, safeMessage(error), payloadOf(\n                    \"error\", safeMessage(error),\n                    \"source\", \"auto-compact\"\n            ));\n            emitError(\"Auto compact failed: \" + error.getMessage());\n        }\n    }\n\n    private void appendLoopDecisionEvents(ManagedCodingSession session, String turnId, CodingAgentResult result) {\n        List<CodingLoopDecision> decisions = session == null || session.getSession() == null\n                ? Collections.<CodingLoopDecision>emptyList()\n                : session.getSession().drainLoopDecisions();\n        for (CodingLoopDecision decision : decisions) {\n            if (decision == null) {\n                continue;\n            }\n            SessionEventType eventType = decision.isContinueLoop()\n                    ? SessionEventType.AUTO_CONTINUE\n                    : decision.isBlocked() ? SessionEventType.BLOCKED : SessionEventType.AUTO_STOP;\n            appendEvent(session, eventType, turnId, null,\n                    firstNonBlank(decision.getSummary(), formatLoopDecisionSummary(decision, result)),\n                    payloadOf(\n                            \"turnNumber\", decision.getTurnNumber(),\n                            \"continueReason\", decision.getContinueReason(),\n                            \"stopReason\", decision.getStopReason() == null ? null : decision.getStopReason().name().toLowerCase(Locale.ROOT),\n                            \"compactApplied\", decision.isCompactApplied()\n                    ));\n            emitLoopDecision(decision, result);\n        }\n    }\n\n    private void emitLoopDecision(CodingLoopDecision decision, CodingAgentResult result) {\n        String summary = firstNonBlank(decision == null ? null : decision.getSummary(), formatLoopDecisionSummary(decision, result));\n        if (isBlank(summary)) {\n            return;\n        }\n        if (useMainBufferInteractiveShell()) {\n            String title = decision != null && decision.isContinueLoop()\n                    ? \"Auto continue\"\n                    : decision != null && decision.isBlocked() ? \"Blocked\" : \"Auto stop\";\n            mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatInfoBlock(\n                    title,\n                    Collections.singletonList(summary)\n            ));\n            return;\n        }\n        if (options.getUiMode() != CliUiMode.TUI) {\n            terminal.println(\"[loop] \" + summary);\n        }\n    }\n\n    private String formatLoopDecisionSummary(CodingLoopDecision decision, CodingAgentResult result) {\n        if (decision == null) {\n            return result == null || result.getStopReason() == null ? null : formatStopReason(result.getStopReason());\n        }\n        if (decision.isContinueLoop()) {\n            return firstNonBlank(decision.getSummary(), \"Auto continue.\");\n        }\n        if (decision.isBlocked()) {\n            return firstNonBlank(decision.getSummary(), \"Blocked.\");\n        }\n        return firstNonBlank(decision.getSummary(),\n                result == null || result.getStopReason() == null ? \"Stopped.\" : formatStopReason(result.getStopReason()));\n    }\n\n    private String formatStopReason(CodingStopReason stopReason) {\n        if (stopReason == null) {\n            return \"Stopped.\";\n        }\n        String normalized = stopReason.name().toLowerCase(Locale.ROOT).replace('_', ' ');\n        return Character.toUpperCase(normalized.charAt(0)) + normalized.substring(1) + \".\";\n    }\n\n    private ManagedCodingSession openInitialSession() throws Exception {\n        if (!isBlank(options.getResumeSessionId())) {\n            return sessionManager.resume(agent, protocol, options, options.getResumeSessionId());\n        }\n        if (!isBlank(options.getForkSessionId())) {\n            return sessionManager.fork(agent, protocol, options, options.getForkSessionId(), options.getSessionId());\n        }\n        return sessionManager.create(agent, protocol, options);\n    }\n\n    private ManagedCodingSession resumeSession(ManagedCodingSession currentSession, String sessionId) throws Exception {\n        if (isBlank(sessionId)) {\n            emitError(\"Usage: /resume <session-id>\");\n            return currentSession;\n        }\n\n        closeQuietly(currentSession);\n        persistSession(currentSession, true);\n        ManagedCodingSession resumed = sessionManager.resume(agent, protocol, options, sessionId);\n\n        if (options.getUiMode() != CliUiMode.TUI || useMainBufferInteractiveShell()) {\n            terminal.println(\"resumed session: \" + resumed.getSessionId());\n        }\n        printSessionHeader(resumed);\n        return resumed;\n    }\n\n    private ManagedCodingSession forkSession(ManagedCodingSession currentSession, String rawArguments) throws Exception {\n        if (currentSession == null) {\n            emitError(\"No current session to fork.\");\n            return null;\n        }\n        String[] parts = isBlank(rawArguments) ? new String[0] : rawArguments.trim().split(\"\\\\s+\");\n        String sourceSessionId;\n        String targetSessionId;\n        if (parts.length == 0) {\n            sourceSessionId = currentSession.getSessionId();\n            targetSessionId = null;\n        } else if (parts.length == 1) {\n            sourceSessionId = currentSession.getSessionId();\n            targetSessionId = parts[0];\n        } else {\n            sourceSessionId = parts[0];\n            targetSessionId = parts[1];\n        }\n\n        if (currentSession != null && sourceSessionId.equals(currentSession.getSessionId())) {\n            persistSession(currentSession, true);\n        }\n        ManagedCodingSession forked = sessionManager.fork(agent, protocol, options, sourceSessionId, targetSessionId);\n        closeQuietly(currentSession);\n        persistSession(forked, true);\n        if (options.getUiMode() != CliUiMode.TUI || useMainBufferInteractiveShell()) {\n            terminal.println(\"forked session: \" + forked.getSessionId() + \" <- \" + sourceSessionId);\n        }\n        printSessionHeader(forked);\n        return forked;\n    }\n\n    private void persistSession(ManagedCodingSession session, boolean force) {\n        if (session == null || (!force && !options.isAutoSaveSession())) {\n            return;\n        }\n        try {\n            StoredCodingSession stored = sessionManager.save(session);\n            refreshTuiSessions();\n            refreshTuiEvents(session);\n            if (force && (options.getUiMode() != CliUiMode.TUI || useMainBufferInteractiveShell())) {\n                terminal.println(\"saved session: \" + stored.getSessionId() + \" -> \" + stored.getStorePath());\n            }\n        } catch (IOException ex) {\n            emitError(\"Failed to save session: \" + ex.getMessage());\n        }\n    }\n\n    private void printSessions() {\n        try {\n            List<CodingSessionDescriptor> sessions = sessionManager.list();\n            if (sessions.isEmpty()) {\n                emitOutput(\"No saved sessions found in \" + sessionManager.getDirectory());\n                return;\n            }\n\n            if (useAppendOnlyTranscriptTui()) {\n                emitOutput(renderSessionsOutput(sessions));\n                return;\n            }\n            if (useMainBufferInteractiveShell()) {\n                emitOutput(renderSessionsOutput(sessions));\n                return;\n            }\n            if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n                setTuiCachedSessions(sessions);\n                return;\n            }\n\n            terminal.println(\"sessions:\");\n            for (CodingSessionDescriptor session : sessions) {\n                terminal.println(\"  \" + session.getSessionId()\n                        + \" | root=\" + clip(session.getRootSessionId(), 24)\n                        + \" | parent=\" + clip(firstNonBlank(session.getParentSessionId(), \"-\"), 24)\n                        + \" | updated=\" + formatTimestamp(session.getUpdatedAtEpochMs())\n                        + \" | memory=\" + session.getMemoryItemCount()\n                        + \" | processes=\" + session.getProcessCount()\n                        + \" | \" + clip(session.getSummary(), 120));\n            }\n        } catch (IOException ex) {\n            emitError(\"Failed to list sessions: \" + ex.getMessage());\n        }\n    }\n\n    private void printHistory(ManagedCodingSession currentSession, String targetSessionId) {\n        try {\n            List<CodingSessionDescriptor> sessions = mergeCurrentSession(sessionManager.list(), currentSession);\n            CodingSessionDescriptor target = resolveTargetDescriptor(sessions, currentSession, targetSessionId);\n            if (target == null) {\n                emitOutput(\"history: (none)\");\n                return;\n            }\n            List<CodingSessionDescriptor> history = resolveHistory(sessions, target);\n            if (history.isEmpty()) {\n                emitOutput(\"history: (none)\");\n                return;\n            }\n            if (useMainBufferInteractiveShell()) {\n                StringBuilder builder = new StringBuilder();\n                builder.append(\"history:\\n\");\n                for (CodingSessionDescriptor session : history) {\n                    builder.append(\"- \")\n                            .append(session.getSessionId())\n                            .append(\" | parent=\").append(firstNonBlank(session.getParentSessionId(), \"(root)\"))\n                            .append(\" | updated=\").append(formatTimestamp(session.getUpdatedAtEpochMs()))\n                            .append(\" | \").append(clip(session.getSummary(), 120))\n                            .append('\\n');\n                }\n                emitOutput(builder.toString().trim());\n                return;\n            }\n            if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n                StringBuilder builder = new StringBuilder();\n                builder.append(\"history:\\n\");\n                for (CodingSessionDescriptor session : history) {\n                    builder.append(\"- \")\n                            .append(session.getSessionId())\n                            .append(\" | parent=\").append(firstNonBlank(session.getParentSessionId(), \"(root)\"))\n                            .append(\" | updated=\").append(formatTimestamp(session.getUpdatedAtEpochMs()))\n                            .append(\" | \").append(clip(session.getSummary(), 120))\n                            .append('\\n');\n                }\n                setTuiAssistantOutput(builder.toString().trim());\n                return;\n            }\n            terminal.println(\"history:\");\n            for (CodingSessionDescriptor session : history) {\n                terminal.println(\"  \" + session.getSessionId()\n                        + \" | root=\" + clip(session.getRootSessionId(), 24)\n                        + \" | parent=\" + clip(firstNonBlank(session.getParentSessionId(), \"(root)\"), 24)\n                        + \" | updated=\" + formatTimestamp(session.getUpdatedAtEpochMs())\n                        + \" | \" + clip(session.getSummary(), 120));\n            }\n        } catch (IOException ex) {\n            emitError(\"Failed to build history: \" + ex.getMessage());\n        }\n    }\n\n    private void printTree(ManagedCodingSession currentSession, String rootArgument) {\n        try {\n            List<CodingSessionDescriptor> sessions = mergeCurrentSession(sessionManager.list(), currentSession);\n            List<String> lines = renderTreeLines(sessions, rootArgument, currentSession == null ? null : currentSession.getSessionId());\n            if (lines.isEmpty()) {\n                emitOutput(\"tree: (none)\");\n                return;\n            }\n            if (useMainBufferInteractiveShell()) {\n                StringBuilder builder = new StringBuilder();\n                builder.append(\"tree:\\n\");\n                for (String line : lines) {\n                    builder.append(line).append('\\n');\n                }\n                emitOutput(builder.toString().trim());\n                return;\n            }\n            if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n                StringBuilder builder = new StringBuilder();\n                builder.append(\"tree:\\n\");\n                for (String line : lines) {\n                    builder.append(line).append('\\n');\n                }\n                setTuiAssistantOutput(builder.toString().trim());\n                return;\n            }\n            terminal.println(\"tree:\");\n            for (String line : lines) {\n                terminal.println(\"  \" + line);\n            }\n        } catch (IOException ex) {\n            emitError(\"Failed to build session tree: \" + ex.getMessage());\n        }\n    }\n\n    private void printEvents(ManagedCodingSession session, String limitArgument) {\n        if (session == null) {\n            emitOutput(\"events: (no current session)\");\n            return;\n        }\n        Integer limit = parseLimit(limitArgument);\n        try {\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), limit, null);\n            if (useAppendOnlyTranscriptTui()) {\n                emitOutput(renderEventsOutput(events));\n                return;\n            }\n            if (useMainBufferInteractiveShell()) {\n                emitOutput(renderEventsOutput(events));\n                return;\n            }\n            if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n                setTuiCachedEvents(events);\n                return;\n            }\n            if (events.isEmpty()) {\n                terminal.println(\"events: (none)\");\n                return;\n            }\n            terminal.println(\"events:\");\n            for (SessionEvent event : events) {\n                terminal.println(\"  \" + formatTimestamp(event.getTimestamp())\n                        + \" | \" + event.getType()\n                        + (event.getStep() == null ? \"\" : \" | step=\" + event.getStep())\n                        + \" | \" + clip(event.getSummary(), 160));\n            }\n        } catch (IOException ex) {\n            emitError(\"Failed to list session events: \" + ex.getMessage());\n        }\n    }\n\n    private void printReplay(ManagedCodingSession session, String limitArgument) {\n        if (session == null) {\n            emitOutput(\"replay: (no current session)\");\n            return;\n        }\n        Integer limit = parseLimit(limitArgument);\n        try {\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), limit, null);\n            List<String> replayLines = buildReplayLines(events);\n            if (useAppendOnlyTranscriptTui()) {\n                emitOutput(renderReplayOutput(replayLines));\n                return;\n            }\n            if (useMainBufferInteractiveShell()) {\n                emitOutput(renderReplayOutput(replayLines));\n                return;\n            }\n            if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n                setTuiCachedReplay(replayLines);\n                if (!replayLines.isEmpty()) {\n                    interactionState.openReplayViewer();\n                    setTuiAssistantOutput(\"Replay viewer opened.\");\n                } else {\n                    setTuiAssistantOutput(\"history: (none)\");\n                }\n                return;\n            }\n            if (replayLines.isEmpty()) {\n                emitOutput(\"replay: (none)\");\n                return;\n            }\n            StringBuilder builder = new StringBuilder(\"replay:\\n\");\n            for (String replayLine : replayLines) {\n                builder.append(replayLine).append('\\n');\n            }\n            emitOutput(builder.toString().trim());\n        } catch (IOException ex) {\n            emitError(\"Failed to replay events: \" + ex.getMessage());\n        }\n    }\n\n    private void printTeamBoard(ManagedCodingSession session) {\n        if (session == null) {\n            emitOutput(\"team: (no current session)\");\n            return;\n        }\n        try {\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), null, null);\n            List<String> teamBoardLines = TeamBoardRenderSupport.renderBoardLines(events);\n            if (useAppendOnlyTranscriptTui()) {\n                tuiPersistedTeamBoard = false;\n                emitOutput(TeamBoardRenderSupport.renderBoardOutput(teamBoardLines));\n                return;\n            }\n            if (useMainBufferInteractiveShell()) {\n                tuiPersistedTeamBoard = false;\n                emitOutput(TeamBoardRenderSupport.renderBoardOutput(teamBoardLines));\n                return;\n            }\n            if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n                tuiPersistedTeamBoard = false;\n                setTuiCachedTeamBoard(teamBoardLines);\n                if (!teamBoardLines.isEmpty()) {\n                    interactionState.openTeamBoard();\n                    setTuiAssistantOutput(\"Team board opened.\");\n                } else {\n                    setTuiAssistantOutput(\"team: (none)\");\n                }\n                return;\n            }\n            emitOutput(TeamBoardRenderSupport.renderBoardOutput(teamBoardLines));\n        } catch (IOException ex) {\n            emitError(\"Failed to render team board: \" + ex.getMessage());\n        }\n    }\n\n    private void handleTeamCommand(ManagedCodingSession session, String argument) {\n        if (isBlank(argument)) {\n            printTeamBoard(session);\n            return;\n        }\n        CliTeamStateManager teamStateManager = createTeamStateManager(session);\n        List<String> tokens = splitWhitespace(argument);\n        if (tokens.isEmpty()) {\n            printTeamBoard(session);\n            return;\n        }\n        String action = tokens.get(0).toLowerCase(Locale.ROOT);\n        if (\"list\".equals(action)) {\n            emitOutput(teamStateManager.renderListOutput());\n            return;\n        }\n        if (\"status\".equals(action)) {\n            String requestedTeamId = teamArgument(tokens, 1);\n            emitOutput(teamStateManager.renderStatusOutput(firstNonBlank(requestedTeamId, selectedPersistedTeamId)));\n            return;\n        }\n        if (\"messages\".equals(action)) {\n            String requestedTeamId = teamArgument(tokens, 1);\n            Integer limit = tokens.size() > 2 ? Integer.valueOf(parseLimit(tokens.get(2))) : null;\n            emitOutput(teamStateManager.renderMessagesOutput(firstNonBlank(requestedTeamId, selectedPersistedTeamId), limit));\n            return;\n        }\n        if (\"resume\".equals(action)) {\n            resumePersistedTeamBoard(session, teamStateManager, firstNonBlank(teamArgument(tokens, 1), selectedPersistedTeamId));\n            return;\n        }\n        emitError(\"Usage: /team | /team list | /team status [team-id] | /team messages [team-id] [limit] | /team resume [team-id]\");\n    }\n\n    private void resumePersistedTeamBoard(ManagedCodingSession session,\n                                          CliTeamStateManager teamStateManager,\n                                          String requestedTeamId) {\n        CliTeamStateManager.ResolvedTeamState resolved = teamStateManager == null\n                ? null\n                : teamStateManager.resolveState(requestedTeamId);\n        if (resolved == null || resolved.getState() == null) {\n            emitOutput(isBlank(requestedTeamId) ? \"team: (none)\" : \"team not found: \" + requestedTeamId.trim());\n            return;\n        }\n        selectedPersistedTeamId = resolved.getTeamId();\n        List<String> boardLines = TeamBoardRenderSupport.renderBoardLines(resolved.getState());\n        if (useAppendOnlyTranscriptTui() || useMainBufferInteractiveShell()) {\n            emitOutput(teamStateManager.renderResumeOutput(resolved.getTeamId()));\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            tuiPersistedTeamBoard = true;\n            setTuiCachedTeamBoard(boardLines);\n            if (!boardLines.isEmpty()) {\n                interactionState.openTeamBoard();\n                setTuiAssistantOutput(\"Team board resumed: \" + resolved.getTeamId());\n            } else {\n                setTuiAssistantOutput(\"team: (none)\");\n            }\n            return;\n        }\n        emitOutput(teamStateManager.renderResumeOutput(resolved.getTeamId()));\n    }\n\n    private String teamArgument(List<String> tokens, int index) {\n        if (tokens == null || index < 0 || tokens.size() <= index) {\n            return null;\n        }\n        String value = tokens.get(index);\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private CliTeamStateManager createTeamStateManager(ManagedCodingSession session) {\n        return new CliTeamStateManager(resolveWorkspaceRoot(session));\n    }\n\n    private List<String> listKnownTeamIds() {\n        return createTeamStateManager(activeSession).listKnownTeamIds();\n    }\n\n    private Path resolveWorkspaceRoot(ManagedCodingSession session) {\n        String workspace = session == null ? null : session.getWorkspace();\n        if (isBlank(workspace) && options != null) {\n            workspace = options.getWorkspace();\n        }\n        if (isBlank(workspace)) {\n            return Paths.get(\".\").toAbsolutePath().normalize();\n        }\n        return Paths.get(workspace).toAbsolutePath().normalize();\n    }\n\n    private void printCompacts(ManagedCodingSession session, String limitArgument) {\n        if (session == null) {\n            emitOutput(\"compacts: (no current session)\");\n            return;\n        }\n        int limit = parseLimit(limitArgument);\n        try {\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), null, null);\n            List<String> compactLines = buildCompactLines(events, limit);\n            if (compactLines.isEmpty()) {\n                emitOutput(\"compacts: (none)\");\n                return;\n            }\n            StringBuilder builder = new StringBuilder(\"compacts:\\n\");\n            for (String compactLine : compactLines) {\n                builder.append(compactLine).append('\\n');\n            }\n            emitOutput(builder.toString().trim());\n        } catch (IOException ex) {\n            emitError(\"Failed to list compact history: \" + ex.getMessage());\n        }\n    }\n\n    private void printProcesses(ManagedCodingSession session) {\n        CodingSessionSnapshot snapshot = session == null || session.getSession() == null ? null : session.getSession().snapshot();\n        List<BashProcessInfo> processes = snapshot == null\n                ? null\n                : snapshot.getProcesses();\n        if (processes == null || processes.isEmpty()) {\n            emitOutput(\"processes: (none)\");\n            return;\n        }\n\n        if (useMainBufferInteractiveShell()) {\n            emitOutput(renderProcessesOutput(processes));\n            return;\n        }\n        if (useAppendOnlyTranscriptTui()) {\n            emitOutput(renderProcessesOutput(processes));\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            if (isBlank(interactionState.getSelectedProcessId())) {\n                interactionState.selectProcess(processes.get(0).getProcessId());\n            }\n            return;\n        }\n\n        terminal.println(\"processes:\");\n        for (BashProcessInfo process : processes) {\n            terminal.println(\"  \" + process.getProcessId()\n                    + \" | status=\" + process.getStatus()\n                    + \" | mode=\" + (process.isControlAvailable() ? \"live\" : \"metadata-only\")\n                    + \" | restored=\" + process.isRestored()\n                    + \" | cwd=\" + clip(process.getWorkingDirectory(), 48)\n                    + \" | cmd=\" + clip(process.getCommand(), 72));\n        }\n    }\n\n    private void handleProcessCommand(ManagedCodingSession session, String rawArguments) {\n        if (session == null || session.getSession() == null) {\n            emitError(\"No current session.\");\n            return;\n        }\n        if (isBlank(rawArguments)) {\n            emitError(\"Usage: /process <logs|write|stop> ...\");\n            return;\n        }\n        String[] parts = rawArguments.trim().split(\"\\\\s+\", 3);\n        String action = parts[0];\n        try {\n            if (\"status\".equalsIgnoreCase(action)) {\n                if (parts.length < 2) {\n                    emitError(\"Usage: /process status <process-id>\");\n                    return;\n                }\n                showProcessStatus(session, parts[1], false, DEFAULT_PROCESS_LOG_LIMIT);\n                return;\n            }\n            if (\"follow\".equalsIgnoreCase(action)) {\n                if (parts.length < 2) {\n                    emitError(\"Usage: /process follow <process-id> [limit]\");\n                    return;\n                }\n                Integer limit = parts.length >= 3 ? parseLimit(parts[2]) : DEFAULT_PROCESS_LOG_LIMIT;\n                showProcessStatus(session, parts[1], true, limit);\n                return;\n            }\n            if (\"logs\".equalsIgnoreCase(action)) {\n                if (parts.length < 2) {\n                    emitError(\"Usage: /process logs <process-id> [limit]\");\n                    return;\n                }\n                Integer limit = parts.length >= 3 ? parseLimit(parts[2]) : DEFAULT_EVENT_LIMIT * 40;\n                BashProcessLogChunk logs = session.getSession().processLogs(parts[1], null, limit);\n                emitOutput(\"process logs:\\n\" + (logs == null ? \"(none)\" : firstNonBlank(logs.getContent(), \"(none)\")));\n                return;\n            }\n            if (\"write\".equalsIgnoreCase(action)) {\n                if (parts.length < 3) {\n                    emitError(\"Usage: /process write <process-id> <text>\");\n                    return;\n                }\n                int bytesWritten = session.getSession().writeProcess(parts[1], parts[2]);\n                emitOutput(\"process write: \" + parts[1] + \" bytes=\" + bytesWritten);\n                return;\n            }\n            if (\"stop\".equalsIgnoreCase(action)) {\n                if (parts.length < 2) {\n                    emitError(\"Usage: /process stop <process-id>\");\n                    return;\n                }\n                BashProcessInfo processInfo = session.getSession().stopProcess(parts[1]);\n                emitOutput(\"process stopped: \" + processInfo.getProcessId() + \" status=\" + processInfo.getStatus());\n                return;\n            }\n            emitError(\"Unknown process action: \" + action);\n        } catch (Exception ex) {\n            emitError(\"Process command failed: \" + safeMessage(ex));\n        }\n    }\n\n    private void openReplayViewer(ManagedCodingSession session, int limit) {\n        if (session == null) {\n            setTuiAssistantOutput(\"history: (no current session)\");\n            return;\n        }\n        try {\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), limit, null);\n            List<String> replayLines = buildReplayLines(events);\n            if (useAppendOnlyTranscriptTui()) {\n                emitOutput(renderReplayOutput(replayLines));\n                return;\n            }\n            setTuiCachedReplay(replayLines);\n            interactionState.openReplayViewer();\n            setTuiAssistantOutput(replayLines.isEmpty() ? \"history: (none)\" : \"History opened.\");\n        } catch (IOException ex) {\n            emitError(\"Failed to open replay viewer: \" + ex.getMessage());\n        }\n    }\n\n    private void openProcessInspector(ManagedCodingSession session, String processId) {\n        if (session == null || session.getSession() == null || isBlank(processId)) {\n            return;\n        }\n        if (useAppendOnlyTranscriptTui()) {\n            try {\n                BashProcessInfo processInfo = session.getSession().processStatus(processId);\n                BashProcessLogChunk logs = session.getSession().processLogs(processId, null, DEFAULT_PROCESS_LOG_LIMIT);\n                emitOutput(renderProcessDetailsOutput(processInfo, logs));\n            } catch (Exception ex) {\n                setTuiAssistantOutput(\"process inspector failed: \" + safeMessage(ex));\n            }\n            return;\n        }\n        interactionState.selectProcess(processId);\n        interactionState.openProcessInspector(processId);\n        try {\n            setTuiProcessInspector(\n                    session.getSession().processStatus(processId),\n                    session.getSession().processLogs(processId, null, DEFAULT_PROCESS_LOG_LIMIT)\n            );\n            setTuiAssistantOutput(\"Process inspector opened: \" + processId);\n        } catch (Exception ex) {\n            setTuiAssistantOutput(\"process inspector failed: \" + safeMessage(ex));\n        }\n    }\n\n    private List<String> buildReplayLines(List<SessionEvent> events) {\n        if (events == null || events.isEmpty()) {\n            return new ArrayList<String>();\n        }\n        Map<String, List<String>> byTurn = new LinkedHashMap<String, List<String>>();\n        Map<String, java.util.Set<String>> completedToolKeysByTurn = buildCompletedToolKeysByTurn(events);\n        for (SessionEvent event : events) {\n            if (event == null || event.getType() == null) {\n                continue;\n            }\n            String turnId = isBlank(event.getTurnId()) ? \"session\" : event.getTurnId();\n            if (event.getType() == SessionEventType.TOOL_CALL\n                    && completedToolKeysByTurn.containsKey(turnId)\n                    && completedToolKeysByTurn.get(turnId).contains(buildToolEventKey(event.getPayload()))) {\n                continue;\n            }\n            List<String> eventLines = buildReplayEventLines(event);\n            if (eventLines == null || eventLines.isEmpty()) {\n                continue;\n            }\n            List<String> turnLines = byTurn.get(turnId);\n            if (turnLines == null) {\n                turnLines = new ArrayList<String>();\n                byTurn.put(turnId, turnLines);\n            }\n            turnLines.addAll(eventLines);\n        }\n\n        List<String> lines = new ArrayList<String>();\n        for (List<String> turnLines : byTurn.values()) {\n            if (turnLines == null || turnLines.isEmpty()) {\n                continue;\n            }\n            if (!lines.isEmpty()) {\n                lines.add(\"\");\n            }\n            lines.addAll(turnLines);\n        }\n        return lines;\n    }\n\n    private Map<String, java.util.Set<String>> buildCompletedToolKeysByTurn(List<SessionEvent> events) {\n        Map<String, java.util.Set<String>> keysByTurn = new LinkedHashMap<String, java.util.Set<String>>();\n        if (events == null || events.isEmpty()) {\n            return keysByTurn;\n        }\n        for (SessionEvent event : events) {\n            if (event == null || event.getType() != SessionEventType.TOOL_RESULT) {\n                continue;\n            }\n            String key = buildToolEventKey(event.getPayload());\n            if (isBlank(key)) {\n                continue;\n            }\n            String turnId = isBlank(event.getTurnId()) ? \"session\" : event.getTurnId();\n            java.util.Set<String> turnKeys = keysByTurn.get(turnId);\n            if (turnKeys == null) {\n                turnKeys = new java.util.HashSet<String>();\n                keysByTurn.put(turnId, turnKeys);\n            }\n            turnKeys.add(key);\n        }\n        return keysByTurn;\n    }\n\n    private String buildToolEventKey(Map<String, Object> payload) {\n        if (payload == null || payload.isEmpty()) {\n            return null;\n        }\n        String callId = safeTrimToNull(payloadString(payload, \"callId\"));\n        if (!isBlank(callId)) {\n            return callId;\n        }\n        String toolName = payloadString(payload, \"tool\");\n        JSONObject arguments = parseObject(payloadString(payload, \"arguments\"));\n        return firstNonBlank(toolName, \"tool\") + \"|\"\n                + firstNonBlank(safeTrimToNull(payloadString(payload, \"title\")), buildToolTitle(toolName, arguments));\n    }\n\n    private List<String> buildReplayEventLines(SessionEvent event) {\n        List<String> lines = new ArrayList<String>();\n        SessionEventType type = event == null ? null : event.getType();\n        Map<String, Object> payload = event == null ? null : event.getPayload();\n        if (type == SessionEventType.USER_MESSAGE) {\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatAssistant(\n                    firstNonBlank(payloadString(payload, \"input\"), event.getSummary())\n            ));\n            return lines;\n        }\n        if (type == SessionEventType.ASSISTANT_MESSAGE) {\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatAssistant(\n                    firstNonBlank(payloadString(payload, \"output\"), event.getSummary())\n            ));\n            return lines;\n        }\n        if (type == SessionEventType.TOOL_CALL) {\n            appendReplayToolLines(lines, payload, true);\n            lines.add(\"\");\n            return lines;\n        }\n        if (type == SessionEventType.TOOL_RESULT) {\n            appendReplayToolLines(lines, payload, false);\n            lines.add(\"\");\n            return lines;\n        }\n        if (type == SessionEventType.ERROR) {\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatError(\n                    firstNonBlank(payloadString(payload, \"error\"), event.getSummary())\n            ));\n            return lines;\n        }\n        if (type == SessionEventType.COMPACT) {\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatCompact(buildReplayCompactResult(event)));\n            return lines;\n        }\n        if (type == SessionEventType.AUTO_CONTINUE || type == SessionEventType.AUTO_STOP || type == SessionEventType.BLOCKED) {\n            String title = type == SessionEventType.AUTO_CONTINUE\n                    ? \"Auto continue\"\n                    : type == SessionEventType.BLOCKED ? \"Blocked\" : \"Auto stop\";\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock(\n                    title,\n                    Collections.singletonList(firstNonBlank(event.getSummary(), title.toLowerCase(Locale.ROOT)))\n            ));\n            return lines;\n        }\n        if (type == SessionEventType.SESSION_RESUMED) {\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock(\n                    \"Session resumed\",\n                    Collections.singletonList(firstNonBlank(event.getSummary(), \"session resumed\"))\n            ));\n            return lines;\n        }\n        if (type == SessionEventType.SESSION_FORKED) {\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock(\n                    \"Session forked\",\n                    Collections.singletonList(firstNonBlank(event.getSummary(), \"session forked\"))\n            ));\n            return lines;\n        }\n        if (type == SessionEventType.TASK_CREATED || type == SessionEventType.TASK_UPDATED) {\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock(\n                    \"Delegate task\",\n                    buildReplayTaskLines(event)\n            ));\n            return lines;\n        }\n        if (type == SessionEventType.TEAM_MESSAGE) {\n            appendReplayBlock(lines, codexStyleBlockFormatter.formatInfoBlock(\n                    \"Team message\",\n                    buildReplayTeamMessageLines(event)\n            ));\n            return lines;\n        }\n        return lines;\n    }\n\n    private List<String> buildReplayTaskLines(SessionEvent event) {\n        List<String> lines = new ArrayList<String>();\n        Map<String, Object> payload = event == null ? null : event.getPayload();\n        lines.add(firstNonBlank(event == null ? null : event.getSummary(), \"delegate task\"));\n        String detail = firstNonBlank(payloadString(payload, \"detail\"), payloadString(payload, \"error\"), payloadString(payload, \"output\"));\n        if (!isBlank(detail)) {\n            lines.add(detail);\n        }\n        String member = firstNonBlank(payloadString(payload, \"memberName\"), payloadString(payload, \"memberId\"));\n        if (!isBlank(member)) {\n            lines.add(\"member: \" + member);\n        }\n        String childSessionId = payloadString(payload, \"childSessionId\");\n        if (!isBlank(childSessionId)) {\n            lines.add(\"child session: \" + childSessionId);\n        }\n        String status = payloadString(payload, \"status\");\n        String phase = payloadString(payload, \"phase\");\n        String percent = payloadString(payload, \"percent\");\n        if (!isBlank(status) || !isBlank(phase) || !isBlank(percent)) {\n            StringBuilder stateLine = new StringBuilder();\n            if (!isBlank(status)) {\n                stateLine.append(\"status: \").append(status);\n            }\n            if (!isBlank(phase)) {\n                if (stateLine.length() > 0) {\n                    stateLine.append(\" | \");\n                }\n                stateLine.append(\"phase: \").append(phase);\n            }\n            if (!isBlank(percent)) {\n                if (stateLine.length() > 0) {\n                    stateLine.append(\" | \");\n                }\n                stateLine.append(\"progress: \").append(percent).append('%');\n            }\n            lines.add(stateLine.toString());\n        }\n        String heartbeatCount = payloadString(payload, \"heartbeatCount\");\n        if (!isBlank(heartbeatCount) && !\"0\".equals(heartbeatCount)) {\n            lines.add(\"heartbeats: \" + heartbeatCount);\n        }\n        return lines;\n    }\n\n    private List<String> buildReplayTeamMessageLines(SessionEvent event) {\n        List<String> lines = new ArrayList<String>();\n        Map<String, Object> payload = event == null ? null : event.getPayload();\n        lines.add(firstNonBlank(event == null ? null : event.getSummary(), \"team message\"));\n        String taskId = payloadString(payload, \"taskId\");\n        if (!isBlank(taskId)) {\n            lines.add(\"task: \" + taskId);\n        }\n        String detail = firstNonBlank(payloadString(payload, \"content\"), payloadString(payload, \"detail\"));\n        if (!isBlank(detail)) {\n            lines.add(detail);\n        }\n        return lines;\n    }\n\n    private void appendReplayBlock(List<String> lines, List<String> blockLines) {\n        if (lines == null || blockLines == null || blockLines.isEmpty()) {\n            return;\n        }\n        lines.addAll(blockLines);\n        lines.add(\"\");\n    }\n\n    private CodingSessionCompactResult buildReplayCompactResult(SessionEvent event) {\n        Map<String, Object> payload = event == null ? null : event.getPayload();\n        return CodingSessionCompactResult.builder()\n                .automatic(\"auto\".equalsIgnoreCase(resolveCompactMode(event)))\n                .beforeItemCount(defaultInt(payloadInt(payload, \"beforeItemCount\")))\n                .afterItemCount(defaultInt(payloadInt(payload, \"afterItemCount\")))\n                .estimatedTokensBefore(defaultInt(payloadInt(payload, \"estimatedTokensBefore\")))\n                .estimatedTokensAfter(defaultInt(payloadInt(payload, \"estimatedTokensAfter\")))\n                .splitTurn(payloadBoolean(payload, \"splitTurn\"))\n                .summary(firstNonBlank(payloadString(payload, \"summary\"), event == null ? null : event.getSummary()))\n                .build();\n    }\n\n    private void appendReplayMultiline(List<String> lines, String prefix, String text) {\n        if (lines == null || isBlank(text)) {\n            return;\n        }\n        String[] rawLines = text.replace(\"\\r\", \"\").split(\"\\n\");\n        String continuation = replayContinuation(prefix);\n        for (int i = 0; i < rawLines.length; i++) {\n            lines.add((i == 0 ? firstNonBlank(prefix, \"\") : continuation) + (rawLines[i] == null ? \"\" : rawLines[i]));\n        }\n    }\n\n    private void appendReplayToolLines(List<String> lines, Map<String, Object> payload, boolean pending) {\n        if (lines == null || payload == null) {\n            return;\n        }\n        String toolName = payloadString(payload, \"tool\");\n        JSONObject arguments = parseObject(payloadString(payload, \"arguments\"));\n        JSONObject output = parseObject(payloadString(payload, \"output\"));\n        String title = firstNonBlank(payloadString(payload, \"title\"), buildToolTitle(toolName, arguments));\n        List<String> previewLines = payloadLines(payload, \"previewLines\");\n\n        if (pending) {\n            lines.addAll(buildToolPrimaryLines(toolName, title, \"pending\", 120));\n            if (previewLines.isEmpty()) {\n                previewLines = buildPendingToolPreviewLines(toolName, arguments);\n            }\n            previewLines = normalizeToolPreviewLines(toolName, \"pending\", previewLines);\n            for (int i = 0; i < previewLines.size(); i++) {\n                String prefix = i == 0 ? \"  \\u2514 \" : \"    \";\n                lines.addAll(wrapPrefixedText(prefix, \"    \", previewLines.get(i), 120));\n            }\n            return;\n        }\n\n        String rawOutput = payloadString(payload, \"output\");\n        if (isApprovalRejectedToolError(rawOutput, output)) {\n            lines.addAll(codexStyleBlockFormatter.formatInfoBlock(\n                    \"Rejected\",\n                    Collections.singletonList(firstNonBlank(\n                            payloadString(payload, \"detail\"),\n                            buildCompletedToolDetail(toolName, arguments, output, rawOutput),\n                            normalizeToolPrimaryLabel(firstNonBlank(title, toolName))\n                    ))\n            ));\n            return;\n        }\n        lines.addAll(buildToolPrimaryLines(toolName, title, isBlank(extractToolError(rawOutput, output)) ? \"done\" : \"error\", 120));\n        String detail = firstNonBlank(payloadString(payload, \"detail\"),\n                buildCompletedToolDetail(toolName, arguments, output, rawOutput));\n        if (!isBlank(detail)) {\n            lines.addAll(wrapPrefixedText(\"  \\u2514 \", \"    \", detail, 120));\n        }\n        if (previewLines.isEmpty()) {\n            previewLines = buildToolPreviewLines(toolName, arguments, output, rawOutput);\n        }\n        previewLines = normalizeToolPreviewLines(toolName, \"done\", previewLines);\n        for (int i = 0; i < previewLines.size(); i++) {\n            String prefix = i == 0 && isBlank(detail) ? \"  \\u2514 \" : \"    \";\n            lines.addAll(wrapPrefixedText(prefix, \"    \", previewLines.get(i), 120));\n        }\n    }\n\n    private List<String> buildToolPrimaryLines(String toolName, String title, String status, int width) {\n        List<String> lines = new ArrayList<String>();\n        String normalizedTool = firstNonBlank(toolName, \"tool\");\n        String normalizedStatus = firstNonBlank(status, \"done\").toLowerCase(Locale.ROOT);\n        String label = normalizeToolPrimaryLabel(firstNonBlank(title, normalizedTool));\n        if (\"error\".equals(normalizedStatus)) {\n            if (\"bash\".equals(normalizedTool)) {\n                return wrapPrefixedText(\"\\u2022 Command failed \", \"  \\u2502 \", label, width);\n            }\n            lines.add(\"\\u2022 Tool failed \" + clip(label, Math.max(24, width - 16)));\n            return lines;\n        }\n        if (\"apply_patch\".equals(normalizedTool)) {\n            lines.add(\"pending\".equals(normalizedStatus) ? \"\\u2022 Applying patch\" : \"\\u2022 Applied patch\");\n            return lines;\n        }\n        return wrapPrefixedText(resolveToolPrimaryPrefix(normalizedTool, title, normalizedStatus), \"  \\u2502 \", label, width);\n    }\n\n    private String resolveToolPrimaryPrefix(String toolName, String title, String status) {\n        String normalizedTool = firstNonBlank(toolName, \"tool\");\n        String normalizedTitle = firstNonBlank(title, normalizedTool);\n        boolean pending = \"pending\".equalsIgnoreCase(status);\n        if (\"read_file\".equals(normalizedTool)) {\n            return pending ? \"\\u2022 Reading \" : \"\\u2022 Read \";\n        }\n        if (\"write_file\".equals(normalizedTool)) {\n            return pending ? \"\\u2022 Writing \" : \"\\u2022 Wrote \";\n        }\n        if (\"bash\".equals(normalizedTool)) {\n            if (normalizedTitle.startsWith(\"bash logs \")) {\n                return pending ? \"\\u2022 Reading logs \" : \"\\u2022 Read logs \";\n            }\n            if (normalizedTitle.startsWith(\"bash status \")) {\n                return pending ? \"\\u2022 Checking \" : \"\\u2022 Checked \";\n            }\n            if (normalizedTitle.startsWith(\"bash write \")) {\n                return pending ? \"\\u2022 Writing to \" : \"\\u2022 Wrote to \";\n            }\n            if (normalizedTitle.startsWith(\"bash stop \")) {\n                return pending ? \"\\u2022 Stopping \" : \"\\u2022 Stopped \";\n            }\n        }\n        return pending ? \"\\u2022 Running \" : \"\\u2022 Ran \";\n    }\n\n    private String normalizeToolPrimaryLabel(String title) {\n        String normalizedTitle = firstNonBlank(title, \"tool\").trim();\n        if (normalizedTitle.startsWith(\"$ \")) {\n            return normalizedTitle.substring(2).trim();\n        }\n        if (normalizedTitle.startsWith(\"read \")) {\n            return normalizedTitle.substring(5).trim();\n        }\n        if (normalizedTitle.startsWith(\"write \")) {\n            return normalizedTitle.substring(6).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash logs \")) {\n            return normalizedTitle.substring(\"bash logs \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash status \")) {\n            return normalizedTitle.substring(\"bash status \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash write \")) {\n            return normalizedTitle.substring(\"bash write \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash stop \")) {\n            return normalizedTitle.substring(\"bash stop \".length()).trim();\n        }\n        return normalizedTitle;\n    }\n\n    private String stripToolTitlePrefix(String title, String prefix) {\n        if (isBlank(title) || isBlank(prefix)) {\n            return firstNonBlank(title, \"\");\n        }\n        return title.startsWith(prefix) ? title.substring(prefix.length()).trim() : title;\n    }\n\n    private String replayContinuation(String prefix) {\n        int length = prefix == null ? 0 : prefix.length();\n        StringBuilder builder = new StringBuilder(length);\n        for (int i = 0; i < length; i++) {\n            builder.append(' ');\n        }\n        return builder.toString();\n    }\n\n    private List<String> buildCompactLines(List<SessionEvent> events, int limit) {\n        List<SessionEvent> compactEvents = new ArrayList<SessionEvent>();\n        if (events != null) {\n            for (SessionEvent event : events) {\n                if (event != null && event.getType() == SessionEventType.COMPACT) {\n                    compactEvents.add(event);\n                }\n            }\n        }\n        if (compactEvents.isEmpty()) {\n            return new ArrayList<String>();\n        }\n\n        int safeLimit = limit <= 0 ? DEFAULT_EVENT_LIMIT : limit;\n        int from = Math.max(0, compactEvents.size() - safeLimit);\n        List<String> lines = new ArrayList<String>();\n        for (int i = from; i < compactEvents.size(); i++) {\n            SessionEvent event = compactEvents.get(i);\n            Map<String, Object> payload = event.getPayload();\n            StringBuilder line = new StringBuilder();\n            line.append(formatTimestamp(event.getTimestamp()))\n                    .append(\" | mode=\").append(resolveCompactMode(event))\n                    .append(\" | tokens=\").append(formatCompactDelta(\n                            payloadInt(payload, \"estimatedTokensBefore\"),\n                            payloadInt(payload, \"estimatedTokensAfter\")\n                    ))\n                    .append(\" | items=\").append(formatCompactDelta(\n                            payloadInt(payload, \"beforeItemCount\"),\n                            payloadInt(payload, \"afterItemCount\")\n                    ));\n            if (payloadBoolean(payload, \"splitTurn\")) {\n                line.append(\" | splitTurn\");\n            }\n            if (payloadBoolean(payload, \"fallbackSummary\")) {\n                line.append(\" | fallback\");\n            }\n            String checkpointGoal = clip(payloadString(payload, \"checkpointGoal\"), 64);\n            if (!isBlank(checkpointGoal)) {\n                line.append(\" | goal=\").append(checkpointGoal);\n            }\n            lines.add(line.toString());\n\n            String summary = firstNonBlank(payloadString(payload, \"summary\"), event.getSummary());\n            if (!isBlank(summary)) {\n                lines.add(\"  - \" + clip(summary, 140));\n            }\n        }\n        return lines;\n    }\n\n    private void showProcessStatus(ManagedCodingSession session,\n                                   String processId,\n                                   boolean includeLogs,\n                                   int logLimit) throws Exception {\n        BashProcessInfo processInfo = session.getSession().processStatus(processId);\n        if (useMainBufferInteractiveShell() && !includeLogs) {\n            emitOutput(renderProcessStatusOutput(processInfo));\n            return;\n        }\n        if (useAppendOnlyTranscriptTui() && !includeLogs) {\n            emitOutput(renderProcessStatusOutput(processInfo));\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI && !useMainBufferInteractiveShell()) {\n            BashProcessLogChunk logs = includeLogs ? session.getSession().processLogs(processId, null, logLimit) : null;\n            interactionState.selectProcess(processId);\n            interactionState.openProcessInspector(processId);\n            setTuiAssistantOutput(includeLogs\n                    ? \"Process inspector opened: \" + processId\n                    : \"Selected process: \" + processId);\n            setTuiProcessInspector(processInfo, logs);\n            return;\n        }\n\n        if (includeLogs) {\n            followProcessLogs(session, processInfo, logLimit);\n            return;\n        }\n\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"process status:\\n\");\n        appendProcessSummary(builder, processInfo);\n        terminal.println(builder.toString());\n    }\n\n    private void followProcessLogs(ManagedCodingSession session,\n                                   BashProcessInfo initialProcessInfo,\n                                   int logLimit) throws Exception {\n        BashProcessInfo processInfo = initialProcessInfo;\n        long nextOffset = 0L;\n        int idlePolls = 0;\n\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"process status:\\n\");\n        appendProcessSummary(builder, processInfo);\n        builder.append('\\n').append(\"process follow:\");\n        terminal.println(builder.toString());\n\n        while (true) {\n            BashProcessLogChunk logs = session.getSession().processLogs(processInfo.getProcessId(), Long.valueOf(nextOffset), logLimit);\n            boolean advanced = logs != null && logs.getNextOffset() > nextOffset;\n            if (advanced) {\n                String content = logs.getContent();\n                if (!isBlank(content)) {\n                    terminal.println(content);\n                }\n                nextOffset = logs.getNextOffset();\n                idlePolls = 0;\n            }\n            processInfo = session.getSession().processStatus(processInfo.getProcessId());\n            BashProcessStatus status = logs != null && logs.getStatus() != null ? logs.getStatus() : processInfo.getStatus();\n            if (status != BashProcessStatus.RUNNING) {\n                if (advanced) {\n                    continue;\n                }\n                terminal.println(\"process final: status=\" + status + \", exitCode=\" + processInfo.getExitCode());\n                return;\n            }\n            if (advanced) {\n                continue;\n            }\n            idlePolls++;\n            if (idlePolls >= PROCESS_FOLLOW_MAX_IDLE_POLLS) {\n                terminal.println(\"(follow paused: no new output yet, process still running; rerun /process follow to continue)\");\n                return;\n            }\n            try {\n                Thread.sleep(PROCESS_FOLLOW_POLL_MS);\n            } catch (InterruptedException ex) {\n                Thread.currentThread().interrupt();\n                terminal.println(\"(follow interrupted)\");\n                return;\n            }\n        }\n    }\n\n    private void appendProcessSummary(StringBuilder builder, BashProcessInfo processInfo) {\n        builder.append(\"  id=\").append(processInfo.getProcessId())\n                .append(\" | status=\").append(processInfo.getStatus())\n                .append(\" | mode=\").append(processInfo.isControlAvailable() ? \"live\" : \"metadata-only\")\n                .append(\" | restored=\").append(processInfo.isRestored())\n                .append('\\n');\n        builder.append(\"  cwd=\").append(clip(processInfo.getWorkingDirectory(), 120)).append('\\n');\n        builder.append(\"  cmd=\").append(clip(processInfo.getCommand(), 120));\n    }\n\n    private void writeSelectedProcessInput(ManagedCodingSession session) {\n        String processId = interactionState.getSelectedProcessId();\n        String input = interactionState.consumeProcessInputBuffer();\n        if (isBlank(processId) || isBlank(input) || session == null || session.getSession() == null) {\n            return;\n        }\n        try {\n            int bytesWritten = session.getSession().writeProcess(processId, input);\n            setTuiAssistantOutput(\"process write: \" + processId + \" bytes=\" + bytesWritten);\n            renderTui(session);\n        } catch (Exception ex) {\n            setTuiAssistantOutput(\"process write failed: \" + safeMessage(ex));\n        }\n    }\n\n    private List<String> resolveProcessIds(ManagedCodingSession session) {\n        CodingSessionSnapshot snapshot = session == null || session.getSession() == null ? null : session.getSession().snapshot();\n        List<BashProcessInfo> processes = snapshot == null ? null : snapshot.getProcesses();\n        List<String> processIds = new ArrayList<String>();\n        if (processes == null) {\n            return processIds;\n        }\n        for (BashProcessInfo process : processes) {\n            if (process != null && !isBlank(process.getProcessId())) {\n                processIds.add(process.getProcessId());\n            }\n        }\n        return processIds;\n    }\n\n    private List<SlashCommandController.ProcessCompletionCandidate> buildProcessCompletionCandidates() {\n        CodingSessionSnapshot snapshot = activeSession == null || activeSession.getSession() == null\n                ? null\n                : activeSession.getSession().snapshot();\n        List<BashProcessInfo> processes = snapshot == null ? null : snapshot.getProcesses();\n        List<SlashCommandController.ProcessCompletionCandidate> candidates =\n                new ArrayList<SlashCommandController.ProcessCompletionCandidate>();\n        if (processes == null) {\n            return candidates;\n        }\n        for (BashProcessInfo process : processes) {\n            if (process == null || isBlank(process.getProcessId())) {\n                continue;\n            }\n            candidates.add(new SlashCommandController.ProcessCompletionCandidate(\n                    process.getProcessId(),\n                    buildProcessCompletionDescription(process)\n            ));\n        }\n        return candidates;\n    }\n\n    private List<SlashCommandController.ModelCompletionCandidate> buildModelCompletionCandidates() {\n        LinkedHashMap<String, SlashCommandController.ModelCompletionCandidate> candidates =\n                new LinkedHashMap<String, SlashCommandController.ModelCompletionCandidate>();\n        CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties);\n        String currentProvider = options.getProvider() == null\n                ? (resolved.getProvider() == null ? null : resolved.getProvider().getPlatform())\n                : options.getProvider().getPlatform();\n\n        addModelCompletionCandidate(candidates, resolved.getModelOverride(), \"Current workspace override\");\n        addModelCompletionCandidate(candidates, options.getModel(), \"Current effective model\");\n\n        CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n        CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n        addProfileModelCompletionCandidate(candidates, workspaceConfig.getActiveProfile(), currentProvider, \"Active profile\");\n        addProfileModelCompletionCandidate(candidates, providersConfig.getDefaultProfile(), currentProvider, \"Default profile\");\n        for (String profileName : providerConfigManager.listProfileNames()) {\n            addProfileModelCompletionCandidate(candidates, profileName, currentProvider, \"Saved profile\");\n        }\n        return new ArrayList<SlashCommandController.ModelCompletionCandidate>(candidates.values());\n    }\n\n    private List<String> listKnownSkillNames() {\n        WorkspaceContext workspaceContext = activeSession == null || activeSession.getSession() == null\n                ? null\n                : activeSession.getSession().getWorkspaceContext();\n        List<CodingSkillDescriptor> skills = workspaceContext == null ? null : workspaceContext.getAvailableSkills();\n        if (skills == null || skills.isEmpty()) {\n            return Collections.emptyList();\n        }\n        LinkedHashSet<String> names = new LinkedHashSet<String>();\n        for (CodingSkillDescriptor skill : skills) {\n            if (skill != null && !isBlank(skill.getName())) {\n                names.add(skill.getName().trim());\n            }\n        }\n        return new ArrayList<String>(names);\n    }\n\n    private List<String> listKnownAgentNames() {\n        CodingAgentDefinitionRegistry registry = agent == null ? null : agent.getDefinitionRegistry();\n        List<CodingAgentDefinition> definitions = registry == null ? null : registry.listDefinitions();\n        if (definitions == null || definitions.isEmpty()) {\n            return Collections.emptyList();\n        }\n        LinkedHashSet<String> names = new LinkedHashSet<String>();\n        for (CodingAgentDefinition definition : definitions) {\n            if (definition == null) {\n                continue;\n            }\n            if (!isBlank(definition.getName())) {\n                names.add(definition.getName().trim());\n            }\n            if (!isBlank(definition.getToolName())) {\n                names.add(definition.getToolName().trim());\n            }\n        }\n        return new ArrayList<String>(names);\n    }\n\n    private List<String> listKnownMcpServerNames() {\n        LinkedHashSet<String> names = new LinkedHashSet<String>();\n        CliMcpConfig globalConfig = mcpConfigManager.loadGlobalConfig();\n        if (globalConfig.getMcpServers() != null) {\n            names.addAll(globalConfig.getMcpServers().keySet());\n        }\n        CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig();\n        if (workspaceConfig.getEnabledMcpServers() != null) {\n            names.addAll(workspaceConfig.getEnabledMcpServers());\n        }\n        names.addAll(pausedMcpServers);\n        return new ArrayList<String>(names);\n    }\n\n    private void addProfileModelCompletionCandidate(LinkedHashMap<String, SlashCommandController.ModelCompletionCandidate> candidates,\n                                                    String profileName,\n                                                    String provider,\n                                                    String label) {\n        if (candidates == null || isBlank(profileName)) {\n            return;\n        }\n        CliProviderProfile profile = providerConfigManager.getProfile(profileName);\n        if (profile == null || isBlank(profile.getModel())) {\n            return;\n        }\n        if (!isBlank(provider) && !provider.equalsIgnoreCase(firstNonBlank(profile.getProvider(), provider))) {\n            return;\n        }\n        addModelCompletionCandidate(candidates, profile.getModel(), label + \" \" + profileName);\n    }\n\n    private void addModelCompletionCandidate(LinkedHashMap<String, SlashCommandController.ModelCompletionCandidate> candidates,\n                                             String model,\n                                             String description) {\n        if (candidates == null || isBlank(model)) {\n            return;\n        }\n        String key = model.trim().toLowerCase(Locale.ROOT);\n        if (candidates.containsKey(key)) {\n            return;\n        }\n        candidates.put(key, new SlashCommandController.ModelCompletionCandidate(model.trim(), description));\n    }\n\n    private boolean containsMcpServer(String name) {\n        if (isBlank(name)) {\n            return false;\n        }\n        String normalizedName = name.trim();\n        CliMcpConfig globalConfig = mcpConfigManager.loadGlobalConfig();\n        if (globalConfig.getMcpServers().containsKey(normalizedName)) {\n            return true;\n        }\n        CliWorkspaceConfig workspaceConfig = mcpConfigManager.loadWorkspaceConfig();\n        return containsIgnoreCase(workspaceConfig.getEnabledMcpServers(), normalizedName);\n    }\n\n    private boolean containsIgnoreCase(List<String> values, String target) {\n        if (values == null || values.isEmpty() || isBlank(target)) {\n            return false;\n        }\n        for (String value : values) {\n            if (!isBlank(value) && target.trim().equalsIgnoreCase(value.trim())) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private List<String> addName(List<String> values, String name) {\n        LinkedHashSet<String> names = new LinkedHashSet<String>();\n        if (values != null) {\n            for (String value : values) {\n                if (!isBlank(value)) {\n                    names.add(value.trim());\n                }\n            }\n        }\n        if (!isBlank(name)) {\n            names.add(name.trim());\n        }\n        return names.isEmpty() ? null : new ArrayList<String>(names);\n    }\n\n    private List<String> removeName(List<String> values, String name) {\n        if (values == null || values.isEmpty()) {\n            return null;\n        }\n        List<String> remaining = new ArrayList<String>();\n        for (String value : values) {\n            if (isBlank(value)) {\n                continue;\n            }\n            if (!value.trim().equalsIgnoreCase(firstNonBlank(name, \"\"))) {\n                remaining.add(value.trim());\n            }\n        }\n        return remaining.isEmpty() ? null : remaining;\n    }\n\n    private List<String> splitWhitespace(String value) {\n        if (isBlank(value)) {\n            return Collections.emptyList();\n        }\n        return Arrays.asList(value.trim().split(\"\\\\s+\"));\n    }\n\n    private String normalizeMcpTransport(String rawTransport) {\n        if (isBlank(rawTransport)) {\n            return null;\n        }\n        String normalized = rawTransport.trim().toLowerCase(Locale.ROOT);\n        if (\"http\".equals(normalized) || \"streamable_http\".equals(normalized) || \"streamable-http\".equals(normalized)) {\n            return \"streamable_http\";\n        }\n        if (\"sse\".equals(normalized)) {\n            return \"sse\";\n        }\n        if (\"stdio\".equals(normalized)) {\n            return \"stdio\";\n        }\n        return null;\n    }\n\n    private String buildProcessCompletionDescription(BashProcessInfo process) {\n        if (process == null) {\n            return \"Process\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(firstNonBlank(\n                process.getStatus() == null ? null : String.valueOf(process.getStatus()).toLowerCase(Locale.ROOT),\n                \"unknown\"\n        ));\n        builder.append(\" | \").append(process.isControlAvailable() ? \"live\" : \"metadata-only\");\n        if (process.isRestored()) {\n            builder.append(\" | restored\");\n        }\n        String command = clip(process.getCommand(), 48);\n        if (!isBlank(command)) {\n            builder.append(\" | \").append(command);\n        }\n        return builder.toString();\n    }\n\n    private String firstProcessId(ManagedCodingSession session) {\n        List<String> processIds = resolveProcessIds(session);\n        return processIds.isEmpty() ? null : processIds.get(0);\n    }\n\n    private List<CodingSessionDescriptor> mergeCurrentSession(List<CodingSessionDescriptor> sessions, ManagedCodingSession currentSession) {\n        List<CodingSessionDescriptor> merged = sessions == null\n                ? new ArrayList<CodingSessionDescriptor>()\n                : new ArrayList<CodingSessionDescriptor>(sessions);\n        if (currentSession == null) {\n            return merged;\n        }\n        CodingSessionDescriptor currentDescriptor = currentSession.toDescriptor();\n        for (int i = 0; i < merged.size(); i++) {\n            CodingSessionDescriptor existing = merged.get(i);\n            if (existing != null && currentDescriptor.getSessionId().equals(existing.getSessionId())) {\n                merged.set(i, currentDescriptor);\n                return merged;\n            }\n        }\n        merged.add(0, currentDescriptor);\n        return merged;\n    }\n\n    private CodingSessionDescriptor resolveTargetDescriptor(List<CodingSessionDescriptor> sessions,\n                                                            ManagedCodingSession currentSession,\n                                                            String targetSessionId) {\n        if (isBlank(targetSessionId)) {\n            return currentSession == null ? null : currentSession.toDescriptor();\n        }\n        for (CodingSessionDescriptor session : sessions) {\n            if (session != null && targetSessionId.equals(session.getSessionId())) {\n                return session;\n            }\n        }\n        return null;\n    }\n\n    private List<CodingSessionDescriptor> resolveHistory(List<CodingSessionDescriptor> sessions, CodingSessionDescriptor target) {\n        List<CodingSessionDescriptor> history = new ArrayList<CodingSessionDescriptor>();\n        if (target == null) {\n            return history;\n        }\n        Map<String, CodingSessionDescriptor> byId = new LinkedHashMap<String, CodingSessionDescriptor>();\n        for (CodingSessionDescriptor session : sessions) {\n            if (session != null && !isBlank(session.getSessionId())) {\n                byId.put(session.getSessionId(), session);\n            }\n        }\n        CodingSessionDescriptor cursor = target;\n        while (cursor != null) {\n            history.add(0, cursor);\n            String parentSessionId = cursor.getParentSessionId();\n            if (isBlank(parentSessionId)) {\n                break;\n            }\n            CodingSessionDescriptor next = byId.get(parentSessionId);\n            if (next == null || parentSessionId.equals(cursor.getSessionId())) {\n                break;\n            }\n            cursor = next;\n        }\n        return history;\n    }\n\n    private List<String> resolveHistoryLines(List<CodingSessionDescriptor> sessions, CodingSessionDescriptor target) {\n        List<CodingSessionDescriptor> history = resolveHistory(sessions, target);\n        List<String> lines = new ArrayList<String>();\n        for (CodingSessionDescriptor session : history) {\n            lines.add(session.getSessionId()\n                    + \" | parent=\" + firstNonBlank(session.getParentSessionId(), \"(root)\")\n                    + \" | \" + clip(session.getSummary(), 84));\n        }\n        return lines;\n    }\n\n    private List<String> renderTreeLines(List<CodingSessionDescriptor> sessions, String rootArgument, String currentSessionId) {\n        Map<String, CodingSessionDescriptor> byId = new LinkedHashMap<String, CodingSessionDescriptor>();\n        Map<String, List<CodingSessionDescriptor>> children = new LinkedHashMap<String, List<CodingSessionDescriptor>>();\n        List<CodingSessionDescriptor> roots = new ArrayList<CodingSessionDescriptor>();\n        for (CodingSessionDescriptor session : sessions) {\n            if (session == null || isBlank(session.getSessionId())) {\n                continue;\n            }\n            byId.put(session.getSessionId(), session);\n        }\n        for (CodingSessionDescriptor session : byId.values()) {\n            String parentSessionId = session.getParentSessionId();\n            if (isBlank(parentSessionId) || !byId.containsKey(parentSessionId)) {\n                roots.add(session);\n                continue;\n            }\n            List<CodingSessionDescriptor> siblings = children.get(parentSessionId);\n            if (siblings == null) {\n                siblings = new ArrayList<CodingSessionDescriptor>();\n                children.put(parentSessionId, siblings);\n            }\n            siblings.add(session);\n        }\n        java.util.Comparator<CodingSessionDescriptor> comparator = java.util.Comparator\n                .comparingLong(CodingSessionDescriptor::getCreatedAtEpochMs)\n                .thenComparing(CodingSessionDescriptor::getSessionId);\n        java.util.Collections.sort(roots, comparator);\n        for (List<CodingSessionDescriptor> siblings : children.values()) {\n            java.util.Collections.sort(siblings, comparator);\n        }\n\n        List<String> lines = new ArrayList<String>();\n        if (isBlank(rootArgument)) {\n            for (CodingSessionDescriptor root : roots) {\n                appendTreeLines(lines, children, root, 0, currentSessionId);\n            }\n            return lines;\n        }\n\n        CodingSessionDescriptor root = byId.get(rootArgument);\n        if (root == null) {\n            for (CodingSessionDescriptor candidate : byId.values()) {\n                if (rootArgument.equals(candidate.getRootSessionId())) {\n                    root = byId.get(candidate.getRootSessionId());\n                    break;\n                }\n            }\n        }\n        if (root != null) {\n            appendTreeLines(lines, children, root, 0, currentSessionId);\n        }\n        return lines;\n    }\n\n    private void appendTreeLines(List<String> lines,\n                                 Map<String, List<CodingSessionDescriptor>> children,\n                                 CodingSessionDescriptor session,\n                                 int depth,\n                                 String currentSessionId) {\n        if (session == null) {\n            return;\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < depth; i++) {\n            builder.append(\"  \");\n        }\n        builder.append(depth == 0 ? \"* \" : \"- \");\n        builder.append(session.getSessionId());\n        if (session.getSessionId().equals(currentSessionId)) {\n            builder.append(\" [current]\");\n        }\n        builder.append(\" | updated=\").append(formatTimestamp(session.getUpdatedAtEpochMs()));\n        builder.append(\" | \").append(clip(session.getSummary(), 96));\n        lines.add(builder.toString());\n\n        List<CodingSessionDescriptor> childSessions = children.get(session.getSessionId());\n        if (childSessions == null) {\n            return;\n        }\n        for (CodingSessionDescriptor child : childSessions) {\n            appendTreeLines(lines, children, child, depth + 1, currentSessionId);\n        }\n    }\n\n    private List<String> renderCommandLines(List<CustomCommandTemplate> commands) {\n        List<String> lines = new ArrayList<String>();\n        lines.add(\"/help /status /session /theme /save /providers /provider /model /experimental /skills /agents\");\n        lines.add(\"/provider use|save|add|edit|default|remove /mcp\");\n        lines.add(\"/experimental subagent|agent-teams on|off\");\n        lines.add(\"/mcp add|enable|disable|pause|resume|retry|remove\");\n        lines.add(\"/commands /palette /cmd <name> /sessions /history /tree /events /replay /team /team list|status|messages|resume /compacts /checkpoint\");\n        lines.add(\"/processes /process status|follow|logs|write|stop\");\n        lines.add(\"/resume <id> /load <id> /fork ... /compact /clear /exit\");\n        if (commands != null) {\n            int max = Math.min(commands.size(), 5);\n            for (int i = 0; i < max; i++) {\n                CustomCommandTemplate command = commands.get(i);\n                lines.add(\"/cmd \" + command.getName() + \" | \" + clip(firstNonBlank(command.getDescription(), \"(no description)\"), 72));\n            }\n        }\n        return lines;\n    }\n\n    private List<TuiPaletteItem> buildPaletteItems(ManagedCodingSession session) {\n        List<TuiPaletteItem> items = new ArrayList<TuiPaletteItem>(buildCommandPaletteItems());\n\n        for (String themeName : tuiConfigManager.listThemeNames()) {\n            items.add(new TuiPaletteItem(\"theme-\" + themeName, \"theme\", \"Theme: \" + themeName, \"/theme \" + themeName, \"/theme \" + themeName));\n        }\n        for (String profileName : providerConfigManager.listProfileNames()) {\n            items.add(new TuiPaletteItem(\n                    \"provider-\" + profileName,\n                    \"provider\",\n                    \"Provider: \" + profileName,\n                    \"Switch to saved profile \" + profileName,\n                    \"/provider use \" + profileName\n            ));\n        }\n        for (String serverName : listKnownMcpServerNames()) {\n            items.add(new TuiPaletteItem(\n                    \"mcp-enable-\" + serverName,\n                    \"mcp\",\n                    \"MCP enable: \" + serverName,\n                    \"Enable workspace MCP service \" + serverName,\n                    \"/mcp enable \" + serverName\n            ));\n            items.add(new TuiPaletteItem(\n                    \"mcp-pause-\" + serverName,\n                    \"mcp\",\n                    \"MCP pause: \" + serverName,\n                    \"Pause MCP service \" + serverName + \" for this session\",\n                    \"/mcp pause \" + serverName\n            ));\n        }\n\n        try {\n            List<CodingSessionDescriptor> sessions = mergeCurrentSession(sessionManager.list(), session);\n            int maxSessions = Math.min(sessions.size(), 8);\n            for (int i = 0; i < maxSessions; i++) {\n                CodingSessionDescriptor descriptor = sessions.get(i);\n                items.add(new TuiPaletteItem(\n                        \"resume-\" + descriptor.getSessionId(),\n                        \"session\",\n                        \"Resume: \" + descriptor.getSessionId(),\n                        clip(firstNonBlank(descriptor.getSummary(), descriptor.getSessionId()), 96),\n                        \"/resume \" + descriptor.getSessionId()\n                ));\n            }\n        } catch (IOException ex) {\n            if (options.isVerbose()) {\n                terminal.errorln(\"Failed to build palette sessions: \" + ex.getMessage());\n            }\n        }\n        return items;\n    }\n\n    private List<TuiPaletteItem> buildCommandPaletteItems() {\n        List<TuiPaletteItem> items = new ArrayList<TuiPaletteItem>();\n        items.add(new TuiPaletteItem(\"help\", \"command\", \"/help\", \"Show help\", \"/help\"));\n        items.add(new TuiPaletteItem(\"status\", \"command\", \"/status\", \"Show current session status\", \"/status\"));\n        items.add(new TuiPaletteItem(\"session\", \"command\", \"/session\", \"Show current session metadata\", \"/session\"));\n        items.add(new TuiPaletteItem(\"theme\", \"command\", \"/theme\", \"Show or switch the active theme\", \"/theme\"));\n        items.add(new TuiPaletteItem(\"save\", \"command\", \"/save\", \"Persist the current session state\", \"/save\"));\n        items.add(new TuiPaletteItem(\"providers\", \"command\", \"/providers\", \"List saved provider profiles\", \"/providers\"));\n        items.add(new TuiPaletteItem(\"provider\", \"command\", \"/provider\", \"Show current provider/profile state\", \"/provider\"));\n        items.add(new TuiPaletteItem(\"provider-use\", \"command\", \"/provider use\", \"Switch to a saved provider profile\", \"/provider use\"));\n        items.add(new TuiPaletteItem(\"provider-save\", \"command\", \"/provider save\", \"Save the current runtime as a profile\", \"/provider save\"));\n        items.add(new TuiPaletteItem(\"provider-add\", \"command\", \"/provider add\", \"Create a provider profile from explicit fields\", \"/provider add\"));\n        items.add(new TuiPaletteItem(\"provider-edit\", \"command\", \"/provider edit\", \"Update a saved provider profile\", \"/provider edit\"));\n        items.add(new TuiPaletteItem(\"provider-default\", \"command\", \"/provider default\", \"Set the global default provider profile\", \"/provider default\"));\n        items.add(new TuiPaletteItem(\"provider-remove\", \"command\", \"/provider remove\", \"Delete a saved provider profile\", \"/provider remove\"));\n        items.add(new TuiPaletteItem(\"model\", \"command\", \"/model\", \"Show the current effective model\", \"/model\"));\n        items.add(new TuiPaletteItem(\"model-reset\", \"command\", \"/model reset\", \"Clear the workspace model override\", \"/model reset\"));\n        items.add(new TuiPaletteItem(\"experimental\", \"command\", \"/experimental\", \"Show experimental runtime feature state\", \"/experimental\"));\n        items.add(new TuiPaletteItem(\"experimental-subagent-on\", \"command\", \"/experimental subagent on\", \"Enable experimental subagent tool injection\", \"/experimental subagent on\"));\n        items.add(new TuiPaletteItem(\"experimental-subagent-off\", \"command\", \"/experimental subagent off\", \"Disable experimental subagent tool injection\", \"/experimental subagent off\"));\n        items.add(new TuiPaletteItem(\"experimental-agent-teams-on\", \"command\", \"/experimental agent-teams on\", \"Enable experimental agent team tool injection\", \"/experimental agent-teams on\"));\n        items.add(new TuiPaletteItem(\"experimental-agent-teams-off\", \"command\", \"/experimental agent-teams off\", \"Disable experimental agent team tool injection\", \"/experimental agent-teams off\"));\n        items.add(new TuiPaletteItem(\"skills\", \"command\", \"/skills\", \"List discovered coding skills\", \"/skills\"));\n        items.add(new TuiPaletteItem(\"agents\", \"command\", \"/agents\", \"List available coding agents\", \"/agents\"));\n        items.add(new TuiPaletteItem(\"mcp\", \"command\", \"/mcp\", \"Show current MCP services and status\", \"/mcp\"));\n        items.add(new TuiPaletteItem(\"mcp-add\", \"command\", \"/mcp add\", \"Add a global MCP service\", \"/mcp add --transport \"));\n        items.add(new TuiPaletteItem(\"mcp-enable\", \"command\", \"/mcp enable\", \"Enable an MCP service in this workspace\", \"/mcp enable \"));\n        items.add(new TuiPaletteItem(\"mcp-disable\", \"command\", \"/mcp disable\", \"Disable an MCP service in this workspace\", \"/mcp disable \"));\n        items.add(new TuiPaletteItem(\"mcp-pause\", \"command\", \"/mcp pause\", \"Pause an MCP service for this session\", \"/mcp pause \"));\n        items.add(new TuiPaletteItem(\"mcp-resume\", \"command\", \"/mcp resume\", \"Resume an MCP service for this session\", \"/mcp resume \"));\n        items.add(new TuiPaletteItem(\"mcp-retry\", \"command\", \"/mcp retry\", \"Reconnect an MCP service\", \"/mcp retry \"));\n        items.add(new TuiPaletteItem(\"mcp-remove\", \"command\", \"/mcp remove\", \"Delete a global MCP service\", \"/mcp remove \"));\n        items.add(new TuiPaletteItem(\"commands\", \"command\", \"/commands\", \"List available custom commands\", \"/commands\"));\n        items.add(new TuiPaletteItem(\"palette\", \"command\", \"/palette\", \"Alias of /commands\", \"/palette\"));\n        items.add(new TuiPaletteItem(\"cmd\", \"command\", \"/cmd\", \"Run a custom command template\", \"/cmd\"));\n        items.add(new TuiPaletteItem(\"sessions\", \"command\", \"/sessions\", \"List saved sessions\", \"/sessions\"));\n        items.add(new TuiPaletteItem(\"history\", \"command\", \"/history\", \"Show session lineage\", \"/history\"));\n        items.add(new TuiPaletteItem(\"tree\", \"command\", \"/tree\", \"Show the current session tree\", \"/tree\"));\n        items.add(new TuiPaletteItem(\"events\", \"command\", \"/events\", \"Show the latest session ledger events\", \"/events 20\"));\n        items.add(new TuiPaletteItem(\"replay\", \"command\", \"/replay\", \"Show recent history\", \"/replay 20\"));\n        items.add(new TuiPaletteItem(\"team\", \"command\", \"/team\", \"Open the current agent team board\", \"/team\"));\n        items.add(new TuiPaletteItem(\"team-list\", \"command\", \"/team list\", \"List persisted teams in this workspace\", \"/team list\"));\n        items.add(new TuiPaletteItem(\"team-status\", \"command\", \"/team status \", \"Inspect one persisted team snapshot\", \"/team status \"));\n        items.add(new TuiPaletteItem(\"team-messages\", \"command\", \"/team messages \", \"Inspect persisted team mailbox messages\", \"/team messages \"));\n        items.add(new TuiPaletteItem(\"team-resume\", \"command\", \"/team resume \", \"Reopen a persisted team board from disk\", \"/team resume \"));\n        items.add(new TuiPaletteItem(\"compacts\", \"command\", \"/compacts\", \"Show compact history from the event ledger\", \"/compacts 20\"));\n        items.add(new TuiPaletteItem(\"processes\", \"command\", \"/processes\", \"List active and restored process metadata\", \"/processes\"));\n        items.add(new TuiPaletteItem(\"process-status\", \"command\", \"/process status\", \"Show metadata for one process\", \"/process status\"));\n        items.add(new TuiPaletteItem(\"process-follow\", \"command\", \"/process follow\", \"Follow buffered logs for a process\", \"/process follow\"));\n        items.add(new TuiPaletteItem(\"process-logs\", \"command\", \"/process logs\", \"Read buffered logs for a process\", \"/process logs\"));\n        items.add(new TuiPaletteItem(\"process-write\", \"command\", \"/process write\", \"Write text to process stdin\", \"/process write\"));\n        items.add(new TuiPaletteItem(\"process-stop\", \"command\", \"/process stop\", \"Stop a live process\", \"/process stop\"));\n        items.add(new TuiPaletteItem(\"checkpoint\", \"command\", \"/checkpoint\", \"Show the current structured checkpoint summary\", \"/checkpoint\"));\n        items.add(new TuiPaletteItem(\"resume\", \"command\", \"/resume\", \"Resume a saved session\", \"/resume\"));\n        items.add(new TuiPaletteItem(\"load\", \"command\", \"/load\", \"Alias of /resume\", \"/load\"));\n        items.add(new TuiPaletteItem(\"fork\", \"command\", \"/fork\", \"Fork a session branch\", \"/fork\"));\n        items.add(new TuiPaletteItem(\"compact\", \"command\", \"/compact\", \"Compact current session memory\", \"/compact\"));\n        items.add(new TuiPaletteItem(\"clear\", \"command\", \"/clear\", \"Print a new screen section\", \"/clear\"));\n        items.add(new TuiPaletteItem(\"exit\", \"command\", \"/exit\", \"Exit session\", \"/exit\"));\n\n        List<CustomCommandTemplate> commands = customCommandRegistry.list();\n        for (CustomCommandTemplate command : commands) {\n            items.add(new TuiPaletteItem(\n                    \"cmd-\" + command.getName(),\n                    \"command\",\n                    \"/cmd \" + command.getName(),\n                    firstNonBlank(command.getDescription(), \"Run custom command \" + command.getName()),\n                    \"/cmd \" + command.getName()\n            ));\n        }\n        return items;\n    }\n\n    private void emitOutput(String text) {\n        if (useMainBufferInteractiveShell()) {\n            mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatOutput(text));\n            return;\n        }\n        if (options.getUiMode() == CliUiMode.TUI) {\n            setTuiAssistantOutput(text);\n            return;\n        }\n        terminal.println(text);\n    }\n\n    private void emitError(String text) {\n        if (useAppendOnlyTranscriptTui()) {\n            emitOutput(\"Error: \" + firstNonBlank(text, \"unknown error\"));\n            return;\n        }\n        if (useMainBufferInteractiveShell()) {\n            emitMainBufferError(text);\n            return;\n        }\n        terminal.errorln(text);\n    }\n\n    private boolean useMainBufferInteractiveShell() {\n        if (options.getUiMode() != CliUiMode.TUI || useAlternateScreenTui()) {\n            return false;\n        }\n        Boolean explicit = resolveMainBufferInteractiveOverride();\n        if (explicit != null) {\n            return explicit.booleanValue();\n        }\n        if (useAppendOnlyTranscriptTui()) {\n            return false;\n        }\n        // Default built-in non-alt-screen TUI sessions to Codex-style transcript mode.\n        return tuiRuntime instanceof AnsiTuiRuntime && tuiRenderer instanceof TuiSessionView;\n    }\n\n    private boolean useAppendOnlyTranscriptTui() {\n        return options.getUiMode() == CliUiMode.TUI && tuiRuntime instanceof AppendOnlyTuiRuntime;\n    }\n\n    private boolean suppressMainBufferReasoningBlocks() {\n        return false;\n    }\n\n    private boolean streamTranscriptEnabled() {\n        return useMainBufferInteractiveShell() && streamEnabled;\n    }\n\n    private boolean renderMainBufferAssistantIncrementally() {\n        return false;\n    }\n\n    private boolean renderMainBufferReasoningIncrementally() {\n        return false;\n    }\n\n    private Boolean resolveMainBufferInteractiveOverride() {\n        String value = System.getProperty(\"ai4j.tui.main-buffer\");\n        if (isBlank(value)) {\n            value = System.getenv(\"AI4J_TUI_MAIN_BUFFER\");\n        }\n        if (isBlank(value)) {\n            return null;\n        }\n        return Boolean.valueOf(\"true\".equalsIgnoreCase(value.trim()));\n    }\n\n    private List<String> buildMainBufferToolLines(TuiAssistantToolView toolView) {\n        return codexStyleBlockFormatter.formatTool(toolView);\n    }\n\n    private String buildMainBufferRunningStatus(TuiAssistantToolView toolView) {\n        return codexStyleBlockFormatter.formatRunningStatus(toolView);\n    }\n\n    private void emitMainBufferAssistant(String text) {\n        if (!isBlank(text)) {\n            mainBufferTurnPrinter.printAssistantBlock(text);\n        }\n    }\n\n    private void emitMainBufferReasoning(String text) {\n        if (isBlank(text)) {\n            return;\n        }\n        List<String> lines = new ArrayList<String>();\n        String[] rawLines = text.replace(\"\\r\", \"\").split(\"\\n\", -1);\n        int start = 0;\n        int end = rawLines.length - 1;\n        while (start <= end && isBlank(rawLines[start])) {\n            start++;\n        }\n        while (end >= start && isBlank(rawLines[end])) {\n            end--;\n        }\n        if (start > end) {\n            return;\n        }\n        String continuationPrefix = repeat(' ', \"Thinking: \".length());\n        boolean previousBlank = false;\n        for (int i = start; i <= end; i++) {\n            String rawLine = rawLines[i] == null ? \"\" : rawLines[i];\n            if (isBlank(rawLine)) {\n                if (!lines.isEmpty() && !previousBlank) {\n                    lines.add(\"\");\n                }\n                previousBlank = true;\n                continue;\n            }\n            lines.add((lines.isEmpty() ? \"Thinking: \" : continuationPrefix) + rawLine);\n            previousBlank = false;\n        }\n        if (lines.isEmpty()) {\n            return;\n        }\n        mainBufferTurnPrinter.printBlock(lines);\n    }\n\n    private void emitMainBufferError(String message) {\n        mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatError(message));\n    }\n\n    private String lastPathSegment(String value) {\n        if (isBlank(value)) {\n            return \".\";\n        }\n        String normalized = value.replace('\\\\', '/');\n        int index = normalized.lastIndexOf('/');\n        return index >= 0 && index + 1 < normalized.length() ? normalized.substring(index + 1) : normalized;\n    }\n\n    private void refreshTuiSessions() {\n        if (tuiRenderer == null) {\n            return;\n        }\n        try {\n            List<CodingSessionDescriptor> sessions = mergeCurrentSession(sessionManager.list(), activeSession);\n            setTuiCachedSessions(sessions);\n            setTuiCachedHistory(resolveHistoryLines(sessions, activeSession == null ? null : activeSession.toDescriptor()));\n            setTuiCachedTree(renderTreeLines(sessions, null, activeSession == null ? null : activeSession.getSessionId()));\n            setTuiCachedCommands(renderCommandLines(customCommandRegistry.list()));\n        } catch (IOException ex) {\n            terminal.errorln(\"Failed to refresh sessions: \" + ex.getMessage());\n        }\n    }\n\n    private void refreshTuiEvents(ManagedCodingSession session) {\n        if (tuiRenderer == null || session == null) {\n            return;\n        }\n        try {\n            setTuiCachedEvents(sessionManager.listEvents(session.getSessionId(), null, null));\n        } catch (IOException ex) {\n            terminal.errorln(\"Failed to refresh session events: \" + ex.getMessage());\n        }\n    }\n\n    private void refreshTuiReplay(ManagedCodingSession session) {\n        if (tuiRenderer == null || session == null || !interactionState.isReplayViewerOpen()) {\n            return;\n        }\n        try {\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), DEFAULT_REPLAY_LIMIT, null);\n            setTuiCachedReplay(buildReplayLines(events));\n        } catch (IOException ex) {\n            terminal.errorln(\"Failed to refresh replay viewer: \" + ex.getMessage());\n        }\n    }\n\n    private void refreshTuiTeamBoard(ManagedCodingSession session) {\n        if (tuiRenderer == null || session == null || !interactionState.isTeamBoardOpen() || tuiPersistedTeamBoard) {\n            return;\n        }\n        try {\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), null, null);\n            setTuiCachedTeamBoard(TeamBoardRenderSupport.renderBoardLines(events));\n        } catch (IOException ex) {\n            terminal.errorln(\"Failed to refresh team board: \" + ex.getMessage());\n        }\n    }\n\n    private void refreshTuiProcessInspector(ManagedCodingSession session) {\n        if (tuiRenderer == null || session == null || session.getSession() == null) {\n            return;\n        }\n        String processId = firstNonBlank(interactionState.getSelectedProcessId(), firstProcessId(session));\n        if (isBlank(processId)) {\n            setTuiProcessInspector(null, null);\n            return;\n        }\n        try {\n            BashProcessInfo processInfo = session.getSession().processStatus(processId);\n            BashProcessLogChunk logs = interactionState.isProcessInspectorOpen()\n                    ? session.getSession().processLogs(processId, null, DEFAULT_PROCESS_LOG_LIMIT)\n                    : null;\n            setTuiProcessInspector(processInfo, logs);\n        } catch (Exception ex) {\n            setTuiAssistantOutput(\"process refresh failed: \" + safeMessage(ex));\n            setTuiProcessInspector(null, null);\n        }\n    }\n\n    private void renderTui(ManagedCodingSession session) {\n        renderTui(session, true);\n    }\n\n    private void renderTuiFromCache(ManagedCodingSession session) {\n        renderTui(session, false);\n    }\n\n    private void renderTui(ManagedCodingSession session, boolean refreshCaches) {\n        if (useMainBufferInteractiveShell()) {\n            return;\n        }\n        if (tuiRenderer == null || tuiRuntime == null) {\n            return;\n        }\n        activeSession = session;\n        if (refreshCaches) {\n            refreshTuiSessions();\n            refreshTuiEvents(session);\n            refreshTuiReplay(session);\n            refreshTuiTeamBoard(session);\n            refreshTuiProcessInspector(session);\n        }\n        tuiRuntime.render(buildTuiScreenModel(session));\n    }\n\n    private TuiScreenModel buildTuiScreenModel(ManagedCodingSession session) {\n        CodingSessionSnapshot snapshot = session == null || session.getSession() == null ? null : session.getSession().snapshot();\n        CodingSessionDescriptor descriptor = session == null ? null : session.toDescriptor();\n        CodingSessionCheckpoint checkpoint = session == null || session.getSession() == null\n                ? null\n                : session.getSession().exportState().getCheckpoint();\n        return TuiScreenModel.builder()\n                .config(tuiConfig)\n                .theme(tuiTheme)\n                .descriptor(descriptor)\n                .snapshot(snapshot)\n                .checkpoint(checkpoint)\n                .renderContext(buildTuiRenderContext())\n                .interactionState(interactionState)\n                .cachedSessions(tuiSessions)\n                .cachedHistory(tuiHistory)\n                .cachedTree(tuiTree)\n                .cachedCommands(tuiCommands)\n                .cachedEvents(tuiEvents)\n                .cachedReplay(tuiReplay)\n                .cachedTeamBoard(tuiTeamBoard)\n                .inspectedProcess(tuiInspectedProcess)\n                .inspectedProcessLogs(tuiInspectedProcessLogs)\n                .assistantOutput(tuiAssistantOutput)\n                .assistantViewModel(tuiLiveTurnState.toViewModel())\n                .build();\n    }\n\n    private void setTuiAssistantOutput(String output) {\n        this.tuiAssistantOutput = isBlank(output) ? null : output;\n    }\n\n    private void setTuiCachedSessions(List<CodingSessionDescriptor> sessions) {\n        this.tuiSessions = sessions == null ? new ArrayList<CodingSessionDescriptor>() : new ArrayList<CodingSessionDescriptor>(sessions);\n    }\n\n    private void setTuiCachedHistory(List<String> historyLines) {\n        this.tuiHistory = historyLines == null ? new ArrayList<String>() : new ArrayList<String>(historyLines);\n    }\n\n    private void setTuiCachedTree(List<String> treeLines) {\n        this.tuiTree = treeLines == null ? new ArrayList<String>() : new ArrayList<String>(treeLines);\n    }\n\n    private void setTuiCachedCommands(List<String> commands) {\n        this.tuiCommands = commands == null ? new ArrayList<String>() : new ArrayList<String>(commands);\n    }\n\n    private void setTuiCachedEvents(List<SessionEvent> events) {\n        this.tuiEvents = events == null ? new ArrayList<SessionEvent>() : new ArrayList<SessionEvent>(events);\n    }\n\n    private void setTuiCachedReplay(List<String> replayLines) {\n        this.tuiReplay = replayLines == null ? new ArrayList<String>() : new ArrayList<String>(replayLines);\n    }\n\n    private void setTuiCachedTeamBoard(List<String> teamBoardLines) {\n        this.tuiTeamBoard = teamBoardLines == null ? new ArrayList<String>() : new ArrayList<String>(teamBoardLines);\n    }\n\n    private void setTuiProcessInspector(BashProcessInfo processInfo, BashProcessLogChunk logs) {\n        this.tuiInspectedProcess = processInfo;\n        this.tuiInspectedProcessLogs = logs;\n    }\n\n    private boolean shouldAutoRefresh(ManagedCodingSession session) {\n        if (session == null || session.getSession() == null) {\n            return false;\n        }\n        if (!useAlternateScreenTui()) {\n            return false;\n        }\n        return interactionState.isProcessInspectorOpen() && tuiInspectedProcess != null && tuiInspectedProcess.isControlAvailable();\n    }\n\n    private boolean shouldAnimateAppendOnlyFooter() {\n        return useAppendOnlyTranscriptTui()\n                && terminal != null\n                && terminal.supportsAnsi()\n                && tuiLiveTurnState.isSpinnerActive();\n    }\n\n    private void beginTuiTurn(String input) {\n        if (!isTuiMode()) {\n            return;\n        }\n        setTuiAssistantOutput(null);\n        tuiLiveTurnState.beginTurn(input);\n    }\n\n    private boolean isTuiMode() {\n        return options.getUiMode() == CliUiMode.TUI;\n    }\n\n    private void renderTuiIfEnabled(ManagedCodingSession session) {\n        if (isTuiMode()) {\n            renderTui(session);\n        }\n    }\n\n    private boolean shouldRunTurnsAsync() {\n        return isTuiMode()\n                && tuiRuntime != null\n                && tuiRuntime.supportsRawInput()\n                && !useMainBufferInteractiveShell();\n    }\n\n    private void runMainBufferTurn(final ManagedCodingSession session, final String input) throws Exception {\n        if (session == null || isBlank(input)) {\n            return;\n        }\n        final String turnId = newTurnId();\n        final Exception[] failure = new Exception[1];\n        Thread worker = new Thread(new Runnable() {\n            @Override\n            public void run() {\n                try {\n                    runTurn(session, input, null, turnId);\n                } catch (Exception ex) {\n                    failure[0] = ex;\n                }\n            }\n        }, \"ai4j-main-buffer-turn\");\n        registerMainBufferTurn(turnId, worker);\n        JlineShellTerminalIO shellTerminal = terminal instanceof JlineShellTerminalIO\n                ? (JlineShellTerminalIO) terminal\n                : null;\n        worker.start();\n        if (shellTerminal != null) {\n            shellTerminal.beginTurnInterruptPolling();\n        }\n        try {\n            while (worker.isAlive()) {\n                if (shellTerminal != null && shellTerminal.pollTurnInterrupt(100L)) {\n                    interruptActiveMainBufferTurn(turnId);\n                }\n                try {\n                    worker.join(25L);\n                } catch (InterruptedException ex) {\n                    Thread.currentThread().interrupt();\n                    interruptActiveMainBufferTurn(turnId);\n                    break;\n                }\n            }\n            try {\n                worker.join();\n            } catch (InterruptedException ex) {\n                Thread.currentThread().interrupt();\n                interruptActiveMainBufferTurn(turnId);\n                throw ex;\n            }\n        } finally {\n            if (shellTerminal != null) {\n                shellTerminal.endTurnInterruptPolling();\n            }\n        }\n        boolean interrupted = isMainBufferTurnInterrupted(turnId);\n        clearMainBufferTurnInterruptState(turnId);\n        if (failure[0] != null && !interrupted) {\n            throw failure[0];\n        }\n    }\n\n    private boolean isTurnInterrupted(String turnId, ActiveTuiTurn activeTurn) {\n        return (activeTurn != null && activeTurn.isInterrupted()) || isMainBufferTurnInterrupted(turnId);\n    }\n\n    private void registerMainBufferTurn(String turnId, Thread worker) {\n        synchronized (mainBufferTurnInterruptLock) {\n            activeMainBufferTurnId = turnId;\n            activeMainBufferTurnThread = worker;\n            activeMainBufferTurnInterrupted = false;\n        }\n    }\n\n    private void clearMainBufferTurnInterruptState(String turnId) {\n        synchronized (mainBufferTurnInterruptLock) {\n            if (!sameTurnId(activeMainBufferTurnId, turnId)) {\n                return;\n            }\n            activeMainBufferTurnId = null;\n            activeMainBufferTurnThread = null;\n            activeMainBufferTurnInterrupted = false;\n        }\n    }\n\n    private boolean interruptActiveMainBufferTurn(String turnId) {\n        Thread thread;\n        synchronized (mainBufferTurnInterruptLock) {\n            if (!sameTurnId(activeMainBufferTurnId, turnId)\n                    || activeMainBufferTurnInterrupted\n                    || activeMainBufferTurnThread == null) {\n                return false;\n            }\n            activeMainBufferTurnInterrupted = true;\n            thread = activeMainBufferTurnThread;\n        }\n        thread.interrupt();\n        ChatModelClient.cancelActiveStream(thread);\n        ResponsesModelClient.cancelActiveStream(thread);\n        return true;\n    }\n\n    private boolean isMainBufferTurnInterrupted(String turnId) {\n        synchronized (mainBufferTurnInterruptLock) {\n            return activeMainBufferTurnInterrupted && sameTurnId(activeMainBufferTurnId, turnId);\n        }\n    }\n\n    private String buildModelConnectionStatus(ManagedCodingSession session) {\n        String provider = session == null ? null : session.getProvider();\n        String model = session == null ? options.getModel() : firstNonBlank(session.getModel(), options.getModel());\n        if (!isBlank(provider) && !isBlank(model)) {\n            return \"Connecting to \" + clip(provider + \"/\" + model, 56);\n        }\n        if (!isBlank(model)) {\n            return \"Connecting to \" + clip(model, 56);\n        }\n        if (!isBlank(provider)) {\n            return \"Connecting to \" + clip(provider, 56);\n        }\n        return \"Opening model stream\";\n    }\n\n    private void handleMainBufferTurnInterrupted(ManagedCodingSession session, String turnId) {\n        mainBufferTurnPrinter.clearTransient();\n        tuiLiveTurnState.onError(null, TURN_INTERRUPTED_MESSAGE);\n        appendEvent(session, SessionEventType.ERROR, turnId, null, TURN_INTERRUPTED_MESSAGE, payloadOf(\n                \"error\", TURN_INTERRUPTED_MESSAGE\n        ));\n        renderTuiIfEnabled(session);\n        emitMainBufferError(TURN_INTERRUPTED_MESSAGE);\n        Thread.interrupted();\n    }\n\n    private boolean hasActiveTuiTurn() {\n        ActiveTuiTurn turn = activeTuiTurn;\n        return turn != null && !turn.isDone();\n    }\n\n    private void startAsyncTuiTurn(ManagedCodingSession session, String input) {\n        if (session == null || isBlank(input) || hasActiveTuiTurn()) {\n            return;\n        }\n        ActiveTuiTurn turn = new ActiveTuiTurn(session, input);\n        activeTuiTurn = turn;\n        turn.start();\n    }\n\n    private void interruptActiveTuiTurn(ManagedCodingSession session) {\n        ActiveTuiTurn turn = activeTuiTurn;\n        if (turn == null || !turn.requestInterrupt()) {\n            return;\n        }\n        tuiLiveTurnState.onError(null, TURN_INTERRUPTED_MESSAGE);\n        appendEvent(session, SessionEventType.ERROR, turn.getTurnId(), null, TURN_INTERRUPTED_MESSAGE, payloadOf(\n                \"error\", TURN_INTERRUPTED_MESSAGE\n        ));\n        renderTuiIfEnabled(session);\n    }\n\n    private ManagedCodingSession reapCompletedTuiTurn(ManagedCodingSession fallbackSession) throws Exception {\n        ActiveTuiTurn turn = activeTuiTurn;\n        if (turn == null || !turn.isDone()) {\n            return fallbackSession;\n        }\n        activeTuiTurn = null;\n        ManagedCodingSession session = turn.getSession() == null ? fallbackSession : turn.getSession();\n        persistSession(session, false);\n        Exception failure = turn.getFailure();\n        if (failure != null && !turn.isInterrupted()) {\n            throw failure;\n        }\n        return session;\n    }\n\n    private void startTuiTurnAnimation(final ManagedCodingSession session) {\n        if (!shouldAnimateTuiTurn()) {\n            return;\n        }\n        stopTuiTurnAnimation();\n        tuiTurnAnimationRunning = true;\n        Thread thread = new Thread(new Runnable() {\n            @Override\n            public void run() {\n                while (tuiTurnAnimationRunning) {\n                    try {\n                        renderTui(session);\n                        Thread.sleep(TUI_TURN_ANIMATION_POLL_MS);\n                    } catch (InterruptedException ex) {\n                        Thread.currentThread().interrupt();\n                        break;\n                    } catch (Exception ignored) {\n                        break;\n                    }\n                }\n            }\n        }, \"ai4j-tui-turn-animation\");\n        thread.setDaemon(true);\n        tuiTurnAnimationThread = thread;\n        thread.start();\n    }\n\n    private boolean shouldAnimateTuiTurn() {\n        return isTuiMode()\n                && tuiRuntime != null\n                && !(tuiRuntime instanceof AppendOnlyTuiRuntime)\n                && terminal != null\n                && terminal.supportsAnsi();\n    }\n\n    private void stopTuiTurnAnimation() {\n        tuiTurnAnimationRunning = false;\n        Thread thread = tuiTurnAnimationThread;\n        tuiTurnAnimationThread = null;\n        if (thread != null) {\n            thread.interrupt();\n            try {\n                thread.join(100L);\n            } catch (InterruptedException ex) {\n                Thread.currentThread().interrupt();\n            }\n        }\n    }\n\n    private boolean useAlternateScreenTui() {\n        return tuiConfig != null && tuiConfig.isUseAlternateScreen();\n    }\n\n    private boolean shouldRenderModelDelta(String delta) {\n        if (useAlternateScreenTui()) {\n            return true;\n        }\n        if (delta != null && (delta.indexOf('\\n') >= 0 || delta.indexOf('\\r') >= 0)) {\n            lastNonAlternateScreenRenderAtMs = System.currentTimeMillis();\n            return true;\n        }\n        long now = System.currentTimeMillis();\n        if (now - lastNonAlternateScreenRenderAtMs >= NON_ALTERNATE_SCREEN_RENDER_THROTTLE_MS) {\n            lastNonAlternateScreenRenderAtMs = now;\n            return true;\n        }\n        return false;\n    }\n\n    private TuiRenderContext buildTuiRenderContext() {\n        return TuiRenderContext.builder()\n                .provider(options.getProvider() == null ? null : options.getProvider().getPlatform())\n                .protocol(protocol == null ? null : protocol.getValue())\n                .model(options.getModel())\n                .workspace(options.getWorkspace())\n                .sessionStore(String.valueOf(sessionManager.getDirectory()))\n                .sessionMode(options.isNoSession() ? \"memory-only\" : \"persistent\")\n                .approvalMode(options.getApprovalMode() == null ? null : options.getApprovalMode().getValue())\n                .terminalRows(terminal == null ? 0 : terminal.getTerminalRows())\n                .terminalColumns(terminal == null ? 0 : terminal.getTerminalColumns())\n                .build();\n    }\n\n    private void closeQuietly(ManagedCodingSession session) {\n        if (session == null) {\n            return;\n        }\n        try {\n            session.close();\n        } catch (Exception ex) {\n            terminal.errorln(\"Failed to close session: \" + ex.getMessage());\n        }\n    }\n\n    private void closeMcpRuntimeQuietly(CliMcpRuntimeManager runtimeManager) {\n        if (runtimeManager == null) {\n            return;\n        }\n        try {\n            runtimeManager.close();\n        } catch (Exception ex) {\n            terminal.errorln(\"Failed to close MCP runtime: \" + ex.getMessage());\n        }\n    }\n\n    private void refreshSessionContext(ManagedCodingSession session) {\n        if (!(terminal instanceof JlineShellTerminalIO)) {\n            return;\n        }\n        ((JlineShellTerminalIO) terminal).updateSessionContext(\n                session == null ? null : session.getSessionId(),\n                session == null ? options.getModel() : session.getModel(),\n                session == null ? options.getWorkspace() : session.getWorkspace()\n        );\n        ((JlineShellTerminalIO) terminal).showIdle(\"Enter a prompt or /command\");\n    }\n\n    private void attachCodingTaskEventBridge() {\n        CodingRuntime nextRuntime = agent == null ? null : agent.getRuntime();\n        if (bridgedRuntime == nextRuntime) {\n            return;\n        }\n        detachCodingTaskEventBridge();\n        if (nextRuntime == null || sessionManager == null) {\n            return;\n        }\n        codingTaskEventBridge = new CodingTaskSessionEventBridge(sessionManager, new CodingTaskSessionEventBridge.SessionEventConsumer() {\n            @Override\n            public void onEvent(SessionEvent event) {\n                handleCodingTaskSessionEvent(event);\n            }\n        });\n        nextRuntime.addListener(codingTaskEventBridge);\n        bridgedRuntime = nextRuntime;\n    }\n\n    private void detachCodingTaskEventBridge() {\n        if (bridgedRuntime != null && codingTaskEventBridge != null) {\n            bridgedRuntime.removeListener(codingTaskEventBridge);\n        }\n        bridgedRuntime = null;\n        codingTaskEventBridge = null;\n    }\n\n    private void handleCodingTaskSessionEvent(SessionEvent event) {\n        if (event == null || activeSession == null) {\n            return;\n        }\n        if (!safeEquals(activeSession.getSessionId(), event.getSessionId())) {\n            return;\n        }\n        refreshTuiEvents(activeSession);\n        if (isTuiMode()) {\n            renderTuiFromCache(activeSession);\n        }\n    }\n\n    private String maskSecret(String value) {\n        if (isBlank(value)) {\n            return null;\n        }\n        String trimmed = value.trim();\n        if (trimmed.length() <= 8) {\n            return \"****\";\n        }\n        return trimmed.substring(0, 4) + \"...\" + trimmed.substring(trimmed.length() - 4);\n    }\n\n    private void appendCompactEvent(ManagedCodingSession session, CodingSessionCompactResult result, String turnId) {\n        if (result == null) {\n            return;\n        }\n        appendEvent(session, SessionEventType.COMPACT, turnId, null,\n                (result.isAutomatic() ? \"auto\" : \"manual\")\n                        + \" compact \" + result.getEstimatedTokensBefore() + \"->\" + result.getEstimatedTokensAfter() + \" tokens\",\n                payloadOf(\n                        \"automatic\", result.isAutomatic(),\n                        \"strategy\", result.getStrategy(),\n                        \"beforeItemCount\", result.getBeforeItemCount(),\n                        \"afterItemCount\", result.getAfterItemCount(),\n                        \"estimatedTokensBefore\", result.getEstimatedTokensBefore(),\n                        \"estimatedTokensAfter\", result.getEstimatedTokensAfter(),\n                        \"compactedToolResultCount\", result.getCompactedToolResultCount(),\n                        \"deltaItemCount\", result.getDeltaItemCount(),\n                        \"checkpointReused\", result.isCheckpointReused(),\n                        \"fallbackSummary\", result.isFallbackSummary(),\n                        \"splitTurn\", result.isSplitTurn(),\n                        \"summary\", clip(result.getSummary(), options.isVerbose() ? 4000 : 1200),\n                        \"checkpointGoal\", result.getCheckpoint() == null ? null : result.getCheckpoint().getGoal()\n                ));\n    }\n\n    private void appendEvent(ManagedCodingSession session,\n                             SessionEventType type,\n                             String turnId,\n                             Integer step,\n                             String summary,\n                             Map<String, Object> payload) {\n        if (session == null || type == null) {\n            return;\n        }\n        try {\n            sessionManager.appendEvent(session.getSessionId(), SessionEvent.builder()\n                    .sessionId(session.getSessionId())\n                    .type(type)\n                    .turnId(turnId)\n                    .step(step)\n                    .summary(summary)\n                    .payload(payload)\n                    .build());\n            refreshTuiEvents(session);\n        } catch (IOException ex) {\n            if (options.isVerbose()) {\n                terminal.errorln(\"Failed to append session event: \" + ex.getMessage());\n            }\n        }\n    }\n\n    private SessionEventType resolveProcessEventType(String action, String status) {\n        if (\"start\".equals(action)) {\n            return SessionEventType.PROCESS_STARTED;\n        }\n        if (\"stop\".equals(action)) {\n            return SessionEventType.PROCESS_STOPPED;\n        }\n        if (\"EXITED\".equalsIgnoreCase(status) || \"STOPPED\".equalsIgnoreCase(status)) {\n            return SessionEventType.PROCESS_STOPPED;\n        }\n        if (\"status\".equals(action) || \"write\".equals(action)) {\n            return SessionEventType.PROCESS_UPDATED;\n        }\n        return null;\n    }\n\n    private void appendProcessEvent(ManagedCodingSession session,\n                                    String turnId,\n                                    Integer step,\n                                    AgentToolCall call,\n                                    AgentToolResult result) {\n        if (call == null || result == null || !\"bash\".equals(call.getName())) {\n            return;\n        }\n        JSONObject arguments = parseObject(call.getArguments());\n        String action = arguments == null || isBlank(arguments.getString(\"action\")) ? \"exec\" : arguments.getString(\"action\");\n        if (\"exec\".equals(action) || \"logs\".equals(action) || \"list\".equals(action)) {\n            return;\n        }\n\n        JSONObject process = extractProcessObject(action, result.getOutput());\n        String processId = process == null ? null : process.getString(\"processId\");\n        String status = process == null ? null : process.getString(\"status\");\n        SessionEventType eventType = resolveProcessEventType(action, status);\n        if (eventType == null) {\n            return;\n        }\n\n        appendEvent(session, eventType, turnId, step,\n                buildProcessSummary(eventType, processId, status),\n                payloadOf(\n                        \"action\", action,\n                        \"processId\", processId,\n                        \"status\", status,\n                        \"command\", process == null ? arguments.getString(\"command\") : process.getString(\"command\"),\n                        \"workingDirectory\", process == null ? arguments.getString(\"cwd\") : process.getString(\"workingDirectory\"),\n                        \"output\", clip(result.getOutput(), options.isVerbose() ? 4000 : 1200)\n                ));\n    }\n\n    private JSONObject extractProcessObject(String action, String output) {\n        if (isBlank(output)) {\n            return null;\n        }\n        try {\n            if (\"write\".equals(action)) {\n                JSONObject root = JSON.parseObject(output);\n                return root == null ? null : root.getJSONObject(\"process\");\n            }\n            if (\"start\".equals(action) || \"status\".equals(action) || \"stop\".equals(action)) {\n                return JSON.parseObject(output);\n            }\n            if (\"list\".equals(action)) {\n                JSONArray array = JSON.parseArray(output);\n                return array == null || array.isEmpty() ? null : array.getJSONObject(0);\n            }\n        } catch (Exception ignored) {\n        }\n        return null;\n    }\n\n    private String buildProcessSummary(SessionEventType eventType, String processId, String status) {\n        String normalizedProcessId = isBlank(processId) ? \"unknown-process\" : processId;\n        switch (eventType) {\n            case PROCESS_STARTED:\n                return \"process started: \" + normalizedProcessId;\n            case PROCESS_STOPPED:\n                return \"process stopped: \" + normalizedProcessId;\n            case PROCESS_UPDATED:\n                return \"process updated: \" + normalizedProcessId + (isBlank(status) ? \"\" : \" (\" + status + \")\");\n            default:\n                return normalizedProcessId;\n        }\n    }\n\n    private Integer parseLimit(String raw) {\n        if (isBlank(raw)) {\n            return DEFAULT_EVENT_LIMIT;\n        }\n        try {\n            int value = Integer.parseInt(raw.trim());\n            return value <= 0 ? DEFAULT_EVENT_LIMIT : value;\n        } catch (NumberFormatException ex) {\n            emitError(\"Invalid event limit: \" + raw + \", using \" + DEFAULT_EVENT_LIMIT);\n            return DEFAULT_EVENT_LIMIT;\n        }\n    }\n\n    private String resolveCompactMode(SessionEvent event) {\n        if (event == null) {\n            return \"unknown\";\n        }\n        Map<String, Object> payload = event.getPayload();\n        if (hasPayloadKey(payload, \"automatic\")) {\n            return payloadBoolean(payload, \"automatic\") ? \"auto\" : \"manual\";\n        }\n        String summary = event.getSummary();\n        if (!isBlank(summary) && summary.toLowerCase(Locale.ROOT).startsWith(\"auto\")) {\n            return \"auto\";\n        }\n        return \"manual\";\n    }\n\n    private String formatCompactDelta(Integer before, Integer after) {\n        if (before == null || after == null) {\n            return \"n/a\";\n        }\n        return before + \"->\" + after;\n    }\n\n    private int defaultInt(Integer value) {\n        return value == null ? 0 : value.intValue();\n    }\n\n    private Integer payloadInt(Map<String, Object> payload, String key) {\n        if (payload == null || isBlank(key)) {\n            return null;\n        }\n        Object value = payload.get(key);\n        if (value instanceof Number) {\n            return Integer.valueOf(((Number) value).intValue());\n        }\n        if (value instanceof String) {\n            try {\n                return Integer.valueOf(Integer.parseInt(((String) value).trim()));\n            } catch (NumberFormatException ignored) {\n                return null;\n            }\n        }\n        return null;\n    }\n\n    private boolean payloadBoolean(Map<String, Object> payload, String key) {\n        if (payload == null || isBlank(key)) {\n            return false;\n        }\n        Object value = payload.get(key);\n        if (value instanceof Boolean) {\n            return ((Boolean) value).booleanValue();\n        }\n        if (value instanceof String) {\n            return Boolean.parseBoolean(((String) value).trim());\n        }\n        return false;\n    }\n\n    private String payloadString(Map<String, Object> payload, String key) {\n        if (payload == null || isBlank(key)) {\n            return null;\n        }\n        Object value = payload.get(key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private List<String> payloadLines(Map<String, Object> payload, String key) {\n        if (payload == null || isBlank(key)) {\n            return new ArrayList<String>();\n        }\n        Object value = payload.get(key);\n        if (value == null) {\n            return new ArrayList<String>();\n        }\n        if (value instanceof Iterable<?>) {\n            return toStringLines((Iterable<?>) value);\n        }\n        if (value instanceof String) {\n            String raw = (String) value;\n            if (isBlank(raw)) {\n                return new ArrayList<String>();\n            }\n            try {\n                return toStringLines(JSON.parseArray(raw));\n            } catch (Exception ignored) {\n                return new ArrayList<String>();\n            }\n        }\n        return new ArrayList<String>();\n    }\n\n    private List<String> toStringLines(Iterable<?> source) {\n        List<String> lines = new ArrayList<String>();\n        if (source == null) {\n            return lines;\n        }\n        for (Object item : source) {\n            if (item == null) {\n                continue;\n            }\n            String line = String.valueOf(item);\n            if (!isBlank(line)) {\n                lines.add(line);\n            }\n        }\n        return lines;\n    }\n\n    private List<String> wrapPrefixedText(String firstPrefix, String continuationPrefix, String rawText, int maxWidth) {\n        List<String> lines = new ArrayList<String>();\n        if (isBlank(rawText)) {\n            return lines;\n        }\n        String first = firstPrefix == null ? \"\" : firstPrefix;\n        String continuation = continuationPrefix == null ? \"\" : continuationPrefix;\n        int firstWidth = Math.max(12, maxWidth - first.length());\n        int continuationWidth = Math.max(12, maxWidth - continuation.length());\n        boolean firstLine = true;\n        String[] paragraphs = rawText.replace(\"\\r\", \"\").split(\"\\n\");\n        for (String paragraph : paragraphs) {\n            String text = safeTrimToNull(paragraph);\n            if (isBlank(text)) {\n                continue;\n            }\n            while (!isBlank(text)) {\n                int width = firstLine ? firstWidth : continuationWidth;\n                int split = findWrapIndex(text, width);\n                lines.add((firstLine ? first : continuation) + text.substring(0, split).trim());\n                text = text.substring(split).trim();\n                firstLine = false;\n            }\n        }\n        return lines;\n    }\n\n    private String safeTrimToNull(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private String repeat(char ch, int count) {\n        if (count <= 0) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder(count);\n        for (int i = 0; i < count; i++) {\n            builder.append(ch);\n        }\n        return builder.toString();\n    }\n\n    private int findWrapIndex(String text, int width) {\n        if (isBlank(text) || text.length() <= width) {\n            return text == null ? 0 : text.length();\n        }\n        int whitespace = -1;\n        for (int i = Math.min(width, text.length() - 1); i >= 0; i--) {\n            if (Character.isWhitespace(text.charAt(i))) {\n                whitespace = i;\n                break;\n            }\n        }\n        return whitespace > 0 ? whitespace : width;\n    }\n\n    private List<String> normalizeToolPreviewLines(String toolName, String status, List<String> previewLines) {\n        if (previewLines == null || previewLines.isEmpty()) {\n            return new ArrayList<String>();\n        }\n        List<String> normalized = new ArrayList<String>();\n        for (String previewLine : previewLines) {\n            String candidate = stripPreviewLabel(previewLine);\n            if (isBlank(candidate)) {\n                continue;\n            }\n            if (\"bash\".equals(toolName)) {\n                if (\"pending\".equalsIgnoreCase(status)) {\n                    continue;\n                }\n                if (\"(no command output)\".equalsIgnoreCase(candidate)) {\n                    continue;\n                }\n            }\n            if (\"apply_patch\".equals(toolName) && \"(no changed files)\".equalsIgnoreCase(candidate)) {\n                continue;\n            }\n            normalized.add(candidate);\n        }\n        return normalized;\n    }\n\n    private String stripPreviewLabel(String previewLine) {\n        if (isBlank(previewLine)) {\n            return null;\n        }\n        String value = previewLine.trim();\n        int separator = value.indexOf(\"> \");\n        if (separator > 0) {\n            String prefix = value.substring(0, separator).trim().toLowerCase(Locale.ROOT);\n            if (\"stdout\".equals(prefix)\n                    || \"stderr\".equals(prefix)\n                    || \"log\".equals(prefix)\n                    || \"file\".equals(prefix)\n                    || \"path\".equals(prefix)\n                    || \"cwd\".equals(prefix)\n                    || \"timeout\".equals(prefix)\n                    || \"process\".equals(prefix)\n                    || \"status\".equals(prefix)\n                    || \"command\".equals(prefix)\n                    || \"stdin\".equals(prefix)\n                    || \"meta\".equals(prefix)\n                    || \"out\".equals(prefix)) {\n                return value.substring(separator + 2).trim();\n            }\n        }\n        return value;\n    }\n\n    private boolean hasPayloadKey(Map<String, Object> payload, String key) {\n        return payload != null && !isBlank(key) && payload.containsKey(key);\n    }\n\n    private JSONObject parseObject(String value) {\n        if (isBlank(value)) {\n            return null;\n        }\n        try {\n            return JSON.parseObject(value);\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    private String[] toPanelLines(String text, int maxChars, int maxLines) {\n        if (isBlank(text)) {\n            return new String[]{\"(none)\"};\n        }\n        String[] rawLines = text.replace(\"\\r\", \"\").split(\"\\n\");\n        List<String> lines = new ArrayList<String>();\n        for (String rawLine : rawLines) {\n            if (lines.size() >= maxLines) {\n                lines.add(\"...\");\n                break;\n            }\n            lines.add(clip(rawLine, maxChars));\n        }\n        return lines.toArray(new String[0]);\n    }\n\n    private Map<String, Object> payloadOf(Object... pairs) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        if (pairs == null) {\n            return payload;\n        }\n        for (int i = 0; i + 1 < pairs.length; i += 2) {\n            Object key = pairs[i];\n            if (key != null) {\n                payload.put(String.valueOf(key), pairs[i + 1]);\n            }\n        }\n        return payload;\n    }\n\n    private String newTurnId() {\n        return \"turn_\" + UUID.randomUUID().toString().replace(\"-\", \"\");\n    }\n\n    private String formatTimestamp(long epochMs) {\n        if (epochMs <= 0) {\n            return \"unknown\";\n        }\n        SimpleDateFormat format = new SimpleDateFormat(\"yyyy-MM-dd HH:mm:ss\", Locale.ROOT);\n        format.setTimeZone(TimeZone.getDefault());\n        return format.format(new Date(epochMs));\n    }\n\n    private String extractCommandArgument(String command) {\n        if (isBlank(command)) {\n            return null;\n        }\n        int firstSpace = command.indexOf(' ');\n        if (firstSpace < 0 || firstSpace + 1 >= command.length()) {\n            return null;\n        }\n        return command.substring(firstSpace + 1).trim();\n    }\n\n    private String renderStatusOutput(ManagedCodingSession session, CodingSessionSnapshot snapshot) {\n        if (session == null) {\n            return \"status: (none)\";\n        }\n        CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties);\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"status:\\n\");\n        builder.append(\"- session=\").append(session.getSessionId()).append('\\n');\n        builder.append(\"- provider=\").append(session.getProvider())\n                .append(\", protocol=\").append(session.getProtocol())\n                .append(\", model=\").append(session.getModel()).append('\\n');\n        builder.append(\"- profile=\").append(firstNonBlank(resolved.getActiveProfile(), resolved.getEffectiveProfile(), \"(none)\"))\n                .append(\", modelOverride=\").append(firstNonBlank(resolved.getModelOverride(), \"(none)\")).append('\\n');\n        builder.append(\"- workspace=\").append(session.getWorkspace()).append('\\n');\n        builder.append(\"- mode=\").append(options.isNoSession() ? \"memory-only\" : \"persistent\")\n                .append(\", memory=\").append(snapshot == null ? 0 : snapshot.getMemoryItemCount())\n                .append(\", activeProcesses=\").append(snapshot == null ? 0 : snapshot.getActiveProcessCount())\n                .append(\", restoredProcesses=\").append(snapshot == null ? 0 : snapshot.getRestoredProcessCount())\n                .append(\", tokens=\").append(snapshot == null ? 0 : snapshot.getEstimatedContextTokens()).append('\\n');\n        builder.append(\"- stream=\").append(streamEnabled ? \"on\" : \"off\").append('\\n');\n        String mcpSummary = renderMcpSummary();\n        if (!isBlank(mcpSummary)) {\n            builder.append(\"- mcp=\").append(mcpSummary).append('\\n');\n        }\n        builder.append(\"- checkpointGoal=\").append(clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 120)).append('\\n');\n        builder.append(\"- compact=\").append(firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), \"none\"));\n        return builder.toString().trim();\n    }\n\n    private ManagedCodingSession handleStreamCommand(ManagedCodingSession session, String argument) throws Exception {\n        String normalized = argument == null ? \"\" : argument.trim().toLowerCase(Locale.ROOT);\n        if (isBlank(normalized)) {\n            emitOutput(renderStreamOutput());\n            return session;\n        }\n        if (\"on\".equals(normalized)) {\n            if (!streamEnabled) {\n                CodeCommandOptions nextOptions = options.withStream(true);\n                ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions);\n                emitOutput(renderStreamOutput());\n                persistSession(rebound, false);\n                return rebound;\n            }\n            emitOutput(renderStreamOutput());\n            return session;\n        }\n        if (\"off\".equals(normalized)) {\n            if (streamEnabled) {\n                CodeCommandOptions nextOptions = options.withStream(false);\n                ManagedCodingSession rebound = switchSessionRuntime(session, nextOptions);\n                emitOutput(renderStreamOutput());\n                persistSession(rebound, false);\n                return rebound;\n            }\n            emitOutput(renderStreamOutput());\n            return session;\n        }\n        emitError(\"Unknown /stream option: \" + argument + \". Use /stream, /stream on, or /stream off.\");\n        return session;\n    }\n\n    private String renderStreamOutput() {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"stream:\\n\");\n        builder.append(\"- status=\").append(streamEnabled ? \"on\" : \"off\").append('\\n');\n        builder.append(\"- scope=current CLI session\\n\");\n        builder.append(\"- request=\").append(streamEnabled ? \"stream=true\" : \"stream=false\").append('\\n');\n        builder.append(\"- behavior=\").append(streamEnabled\n                ? \"provider responses stream incrementally and assistant text renders incrementally\"\n                : \"provider responses return as completed payloads and assistant text renders as completed blocks\");\n        return builder.toString().trim();\n    }\n\n    private String renderSessionOutput(ManagedCodingSession session) {\n        CodingSessionDescriptor descriptor = session == null ? null : session.toDescriptor();\n        if (descriptor == null) {\n            return \"session: (none)\";\n        }\n        CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties);\n        CodingSessionSnapshot snapshot = session.getSession() == null ? null : session.getSession().snapshot();\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"session:\\n\");\n        builder.append(\"- id=\").append(descriptor.getSessionId()).append('\\n');\n        builder.append(\"- root=\").append(descriptor.getRootSessionId())\n                .append(\", parent=\").append(firstNonBlank(descriptor.getParentSessionId(), \"(none)\")).append('\\n');\n        builder.append(\"- provider=\").append(descriptor.getProvider())\n                .append(\", protocol=\").append(descriptor.getProtocol())\n                .append(\", model=\").append(descriptor.getModel()).append('\\n');\n        builder.append(\"- profile=\").append(firstNonBlank(resolved.getActiveProfile(), resolved.getEffectiveProfile(), \"(none)\"))\n                .append(\", modelOverride=\").append(firstNonBlank(resolved.getModelOverride(), \"(none)\")).append('\\n');\n        builder.append(\"- workspace=\").append(descriptor.getWorkspace())\n                .append(\", mode=\").append(options.isNoSession() ? \"memory-only\" : \"persistent\").append('\\n');\n        builder.append(\"- created=\").append(formatTimestamp(descriptor.getCreatedAtEpochMs()))\n                .append(\", updated=\").append(formatTimestamp(descriptor.getUpdatedAtEpochMs())).append('\\n');\n        builder.append(\"- memory=\").append(descriptor.getMemoryItemCount())\n                .append(\", processes=\").append(descriptor.getProcessCount())\n                .append(\" (active=\").append(descriptor.getActiveProcessCount())\n                .append(\", restored=\").append(descriptor.getRestoredProcessCount()).append(\")\").append('\\n');\n        String mcpSummary = renderMcpSummary();\n        if (!isBlank(mcpSummary)) {\n            builder.append(\"- mcp=\").append(mcpSummary).append('\\n');\n        }\n        builder.append(\"- tokens=\").append(snapshot == null ? 0 : snapshot.getEstimatedContextTokens()).append('\\n');\n        builder.append(\"- checkpoint=\").append(clip(snapshot == null ? null : snapshot.getCheckpointGoal(), 160)).append('\\n');\n        builder.append(\"- compact=\").append(firstNonBlank(snapshot == null ? null : snapshot.getLastCompactMode(), \"none\")).append('\\n');\n        builder.append(\"- summary=\").append(clip(descriptor.getSummary(), 220));\n        return builder.toString().trim();\n    }\n\n    private String renderMcpSummary() {\n        if (mcpRuntimeManager == null || !mcpRuntimeManager.hasStatuses()) {\n            return null;\n        }\n        int connected = 0;\n        int errors = 0;\n        int paused = 0;\n        int disabled = 0;\n        int missing = 0;\n        int tools = 0;\n        for (CliMcpStatusSnapshot status : mcpRuntimeManager.getStatuses()) {\n            if (status == null) {\n                continue;\n            }\n            tools += Math.max(0, status.getToolCount());\n            if (CliMcpRuntimeManager.STATE_CONNECTED.equals(status.getState())) {\n                connected++;\n            } else if (CliMcpRuntimeManager.STATE_ERROR.equals(status.getState())) {\n                errors++;\n            } else if (CliMcpRuntimeManager.STATE_PAUSED.equals(status.getState())) {\n                paused++;\n            } else if (CliMcpRuntimeManager.STATE_DISABLED.equals(status.getState())) {\n                disabled++;\n            } else if (CliMcpRuntimeManager.STATE_MISSING.equals(status.getState())) {\n                missing++;\n            }\n        }\n        return \"connected \" + connected\n                + \", errors \" + errors\n                + \", paused \" + paused\n                + \", disabled \" + disabled\n                + \", missing \" + missing\n                + \", tools \" + tools;\n    }\n\n    private String renderProvidersOutput() {\n        CliProvidersConfig providersConfig = providerConfigManager.loadProvidersConfig();\n        CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n        if (providersConfig.getProfiles().isEmpty()) {\n            return \"providers: (none)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"providers:\\n\");\n        List<String> names = new ArrayList<String>(providersConfig.getProfiles().keySet());\n        Collections.sort(names);\n        for (String name : names) {\n            CliProviderProfile profile = providersConfig.getProfiles().get(name);\n            builder.append(\"- \").append(name);\n            if (name.equals(workspaceConfig.getActiveProfile())) {\n                builder.append(\" [active]\");\n            }\n            if (name.equals(providersConfig.getDefaultProfile())) {\n                builder.append(\" [default]\");\n            }\n            builder.append(\" | provider=\").append(profile == null ? null : profile.getProvider());\n            builder.append(\", protocol=\").append(profile == null ? null : profile.getProtocol());\n            builder.append(\", model=\").append(profile == null ? null : profile.getModel());\n            if (!isBlank(profile == null ? null : profile.getBaseUrl())) {\n                builder.append(\", baseUrl=\").append(profile.getBaseUrl());\n            }\n            builder.append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private String renderCurrentProviderOutput() {\n        CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties);\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"provider:\\n\");\n        builder.append(\"- activeProfile=\").append(firstNonBlank(resolved.getActiveProfile(), \"(none)\")).append('\\n');\n        builder.append(\"- defaultProfile=\").append(firstNonBlank(resolved.getDefaultProfile(), \"(none)\")).append('\\n');\n        builder.append(\"- effectiveProfile=\").append(firstNonBlank(resolved.getEffectiveProfile(), \"(none)\")).append('\\n');\n        builder.append(\"- provider=\").append(options.getProvider() == null ? null : options.getProvider().getPlatform())\n                .append(\", protocol=\").append(protocol == null ? null : protocol.getValue())\n                .append(\", model=\").append(options.getModel()).append('\\n');\n        builder.append(\"- baseUrl=\").append(firstNonBlank(options.getBaseUrl(), \"(default)\")).append('\\n');\n        builder.append(\"- apiKey=\").append(isBlank(options.getApiKey()) ? \"(missing)\" : maskSecret(options.getApiKey())).append('\\n');\n        builder.append(\"- store=\").append(providerConfigManager.globalProvidersPath());\n        return builder.toString().trim();\n    }\n\n    private String renderModelOutput() {\n        CliResolvedProviderConfig resolved = providerConfigManager.resolve(null, null, null, null, null, env, properties);\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"model:\\n\");\n        builder.append(\"- current=\").append(options.getModel()).append('\\n');\n        builder.append(\"- override=\").append(firstNonBlank(resolved.getModelOverride(), \"(none)\")).append('\\n');\n        builder.append(\"- profile=\").append(firstNonBlank(resolved.getEffectiveProfile(), \"(none)\")).append('\\n');\n        builder.append(\"- workspaceConfig=\").append(providerConfigManager.workspaceConfigPath());\n        return builder.toString().trim();\n    }\n\n    private String renderExperimentalOutput() {\n        CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"experimental:\\n\");\n        builder.append(\"- subagent=\").append(renderExperimentalState(\n                workspaceConfig == null ? null : workspaceConfig.getExperimentalSubagentsEnabled(),\n                DefaultCodingCliAgentFactory.isExperimentalSubagentsEnabled(workspaceConfig)\n        )).append('\\n');\n        builder.append(\"- agent-teams=\").append(renderExperimentalState(\n                workspaceConfig == null ? null : workspaceConfig.getExperimentalAgentTeamsEnabled(),\n                DefaultCodingCliAgentFactory.isExperimentalAgentTeamsEnabled(workspaceConfig)\n        )).append('\\n');\n        builder.append(\"- workspaceConfig=\").append(providerConfigManager.workspaceConfigPath());\n        return builder.toString().trim();\n    }\n\n    private String renderExperimentalState(Boolean configuredValue, boolean effectiveValue) {\n        String base = effectiveValue ? \"on\" : \"off\";\n        return configuredValue == null ? base + \" (default)\" : base;\n    }\n\n    private String normalizeExperimentalFeature(String raw) {\n        if (isBlank(raw)) {\n            return null;\n        }\n        String normalized = raw.trim().toLowerCase(Locale.ROOT);\n        if (\"subagent\".equals(normalized) || \"subagents\".equals(normalized)) {\n            return \"subagent\";\n        }\n        if (\"agent-teams\".equals(normalized)\n                || \"agent-team\".equals(normalized)\n                || \"agentteams\".equals(normalized)\n                || \"team\".equals(normalized)\n                || \"teams\".equals(normalized)) {\n            return \"agent-teams\";\n        }\n        return null;\n    }\n\n    private Boolean parseExperimentalToggle(String raw) {\n        if (isBlank(raw)) {\n            return null;\n        }\n        String normalized = raw.trim().toLowerCase(Locale.ROOT);\n        if (\"on\".equals(normalized) || \"enable\".equals(normalized) || \"enabled\".equals(normalized)) {\n            return Boolean.TRUE;\n        }\n        if (\"off\".equals(normalized) || \"disable\".equals(normalized) || \"disabled\".equals(normalized)) {\n            return Boolean.FALSE;\n        }\n        return null;\n    }\n\n    private String renderSkillsOutput(ManagedCodingSession session, String argument) {\n        WorkspaceContext workspaceContext = session == null || session.getSession() == null\n                ? null\n                : session.getSession().getWorkspaceContext();\n        if (workspaceContext == null) {\n            return \"skills: (none)\";\n        }\n        List<CodingSkillDescriptor> skills = workspaceContext.getAvailableSkills();\n        if (!isBlank(argument)) {\n            CodingSkillDescriptor selected = findSkill(skills, argument);\n            if (selected == null) {\n                return \"skills: unknown skill `\" + argument.trim() + \"`\";\n            }\n            return renderSkillDetailOutput(selected, workspaceContext);\n        }\n        List<String> roots = resolveSkillRoots(workspaceContext);\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"skills:\\n\");\n        builder.append(\"- count=\").append(skills == null ? 0 : skills.size()).append('\\n');\n        builder.append(\"- workspaceConfig=\").append(providerConfigManager.workspaceConfigPath()).append('\\n');\n        builder.append(\"- roots=\").append(joinWithComma(roots)).append('\\n');\n        if (skills == null || skills.isEmpty()) {\n            builder.append(\"- entries=(none)\");\n            return builder.toString().trim();\n        }\n        for (CodingSkillDescriptor skill : skills) {\n            if (skill == null) {\n                continue;\n            }\n            builder.append(\"- \").append(firstNonBlank(skill.getName(), \"skill\"));\n            builder.append(\" | source=\").append(firstNonBlank(skill.getSource(), \"unknown\"));\n            builder.append(\" | path=\").append(firstNonBlank(skill.getSkillFilePath(), \"(missing)\"));\n            builder.append(\" | description=\").append(firstNonBlank(skill.getDescription(), \"No description available.\"));\n            builder.append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private CodingSkillDescriptor findSkill(List<CodingSkillDescriptor> skills, String name) {\n        if (skills == null || isBlank(name)) {\n            return null;\n        }\n        String normalized = name.trim();\n        for (CodingSkillDescriptor skill : skills) {\n            if (skill == null || isBlank(skill.getName())) {\n                continue;\n            }\n            if (skill.getName().trim().equalsIgnoreCase(normalized)) {\n                return skill;\n            }\n        }\n        return null;\n    }\n\n    private String renderSkillDetailOutput(CodingSkillDescriptor skill, WorkspaceContext workspaceContext) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"skill:\\n\");\n        builder.append(\"- name=\").append(firstNonBlank(skill == null ? null : skill.getName(), \"skill\")).append('\\n');\n        builder.append(\"- source=\").append(firstNonBlank(skill == null ? null : skill.getSource(), \"unknown\")).append('\\n');\n        builder.append(\"- path=\").append(firstNonBlank(skill == null ? null : skill.getSkillFilePath(), \"(missing)\")).append('\\n');\n        builder.append(\"- description=\").append(firstNonBlank(skill == null ? null : skill.getDescription(), \"No description available.\")).append('\\n');\n        builder.append(\"- roots=\").append(joinWithComma(resolveSkillRoots(workspaceContext)));\n        return builder.toString().trim();\n    }\n\n    private String renderAgentsOutput(ManagedCodingSession session, String argument) {\n        CodingAgentDefinitionRegistry registry = agent == null ? null : agent.getDefinitionRegistry();\n        List<CodingAgentDefinition> definitions = registry == null ? null : registry.listDefinitions();\n        if (!isBlank(argument)) {\n            CodingAgentDefinition selected = findAgent(definitions, argument);\n            if (selected == null) {\n                return \"agents: unknown agent `\" + argument.trim() + \"`\";\n            }\n            return renderAgentDetailOutput(selected);\n        }\n        List<String> roots = resolveAgentRoots();\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"agents:\\n\");\n        builder.append(\"- count=\").append(definitions == null ? 0 : definitions.size()).append('\\n');\n        builder.append(\"- workspaceConfig=\").append(providerConfigManager.workspaceConfigPath()).append('\\n');\n        builder.append(\"- roots=\").append(joinWithComma(roots)).append('\\n');\n        if (definitions == null || definitions.isEmpty()) {\n            builder.append(\"- entries=(none)\");\n            return builder.toString().trim();\n        }\n        for (CodingAgentDefinition definition : definitions) {\n            if (definition == null) {\n                continue;\n            }\n            builder.append(\"- \").append(firstNonBlank(definition.getName(), \"agent\"));\n            builder.append(\" | tool=\").append(firstNonBlank(definition.getToolName(), \"(none)\"));\n            builder.append(\" | model=\").append(firstNonBlank(definition.getModel(), \"(inherit)\"));\n            builder.append(\" | background=\").append(definition.isBackground());\n            builder.append(\" | tools=\").append(renderAllowedTools(definition));\n            builder.append(\" | description=\").append(firstNonBlank(definition.getDescription(), \"No description available.\"));\n            builder.append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private List<String> resolveSkillRoots(WorkspaceContext workspaceContext) {\n        if (workspaceContext == null) {\n            return Collections.emptyList();\n        }\n        LinkedHashSet<String> roots = new LinkedHashSet<String>();\n        roots.add(workspaceContext.getRoot().resolve(\".ai4j\").resolve(\"skills\").toAbsolutePath().normalize().toString());\n        String userHome = System.getProperty(\"user.home\");\n        if (!isBlank(userHome)) {\n            roots.add(Paths.get(userHome).resolve(\".ai4j\").resolve(\"skills\").toAbsolutePath().normalize().toString());\n        }\n        if (workspaceContext.getSkillDirectories() != null) {\n            for (String configuredRoot : workspaceContext.getSkillDirectories()) {\n                if (isBlank(configuredRoot)) {\n                    continue;\n                }\n                Path root = Paths.get(configuredRoot);\n                if (!root.isAbsolute()) {\n                    root = workspaceContext.getRoot().resolve(configuredRoot);\n                }\n                roots.add(root.toAbsolutePath().normalize().toString());\n            }\n        }\n        return new ArrayList<String>(roots);\n    }\n\n    private CodingAgentDefinition findAgent(List<CodingAgentDefinition> definitions, String nameOrToolName) {\n        if (definitions == null || isBlank(nameOrToolName)) {\n            return null;\n        }\n        String normalized = nameOrToolName.trim();\n        for (CodingAgentDefinition definition : definitions) {\n            if (definition == null) {\n                continue;\n            }\n            if ((!isBlank(definition.getName()) && definition.getName().trim().equalsIgnoreCase(normalized))\n                    || (!isBlank(definition.getToolName()) && definition.getToolName().trim().equalsIgnoreCase(normalized))) {\n                return definition;\n            }\n        }\n        return null;\n    }\n\n    private String renderAgentDetailOutput(CodingAgentDefinition definition) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"agent:\\n\");\n        builder.append(\"- name=\").append(firstNonBlank(definition == null ? null : definition.getName(), \"agent\")).append('\\n');\n        builder.append(\"- tool=\").append(firstNonBlank(definition == null ? null : definition.getToolName(), \"(none)\")).append('\\n');\n        builder.append(\"- model=\").append(firstNonBlank(definition == null ? null : definition.getModel(), \"(inherit)\")).append('\\n');\n        builder.append(\"- sessionMode=\").append(definition == null ? null : definition.getSessionMode()).append('\\n');\n        builder.append(\"- isolationMode=\").append(definition == null ? null : definition.getIsolationMode()).append('\\n');\n        builder.append(\"- memoryScope=\").append(definition == null ? null : definition.getMemoryScope()).append('\\n');\n        builder.append(\"- approvalMode=\").append(definition == null ? null : definition.getApprovalMode()).append('\\n');\n        builder.append(\"- background=\").append(definition != null && definition.isBackground()).append('\\n');\n        builder.append(\"- tools=\").append(renderAllowedTools(definition)).append('\\n');\n        builder.append(\"- description=\").append(firstNonBlank(definition == null ? null : definition.getDescription(), \"No description available.\")).append('\\n');\n        builder.append(\"- roots=\").append(joinWithComma(resolveAgentRoots())).append('\\n');\n        builder.append(\"- instructions=\").append(firstNonBlank(definition == null ? null : definition.getInstructions(), \"(none)\"));\n        return builder.toString().trim();\n    }\n\n    private List<String> resolveAgentRoots() {\n        CliWorkspaceConfig workspaceConfig = providerConfigManager.loadWorkspaceConfig();\n        CliCodingAgentRegistry registry = new CliCodingAgentRegistry(\n                Paths.get(firstNonBlank(options == null ? null : options.getWorkspace(), \".\")),\n                workspaceConfig == null ? null : workspaceConfig.getAgentDirectories()\n        );\n        List<Path> roots = registry.listRoots();\n        if (roots == null || roots.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> values = new ArrayList<String>(roots.size());\n        for (Path root : roots) {\n            if (root != null) {\n                values.add(root.toAbsolutePath().normalize().toString());\n            }\n        }\n        return values;\n    }\n\n    private String renderAllowedTools(CodingAgentDefinition definition) {\n        if (definition == null || definition.getAllowedToolNames() == null || definition.getAllowedToolNames().isEmpty()) {\n            return \"(inherit/all available)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (String toolName : definition.getAllowedToolNames()) {\n            if (isBlank(toolName)) {\n                continue;\n            }\n            if (builder.length() > 0) {\n                builder.append(\", \");\n            }\n            builder.append(toolName.trim());\n        }\n        return builder.length() == 0 ? \"(inherit/all available)\" : builder.toString();\n    }\n\n    private String joinWithComma(List<String> values) {\n        if (values == null || values.isEmpty()) {\n            return \"(none)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (String value : values) {\n            if (isBlank(value)) {\n                continue;\n            }\n            if (builder.length() > 0) {\n                builder.append(\", \");\n            }\n            builder.append(value);\n        }\n        return builder.length() == 0 ? \"(none)\" : builder.toString();\n    }\n\n    private String renderCheckpointOutput(CodingSessionCheckpoint checkpoint) {\n        if (checkpoint == null) {\n            return \"checkpoint: (none)\";\n        }\n        return \"checkpoint:\\n\" + firstNonBlank(CodingSessionCheckpointFormatter.render(checkpoint), \"(none)\");\n    }\n\n    private String renderSessionsOutput(List<CodingSessionDescriptor> sessions) {\n        if (sessions == null || sessions.isEmpty()) {\n            return \"sessions: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"sessions:\\n\");\n        for (CodingSessionDescriptor session : sessions) {\n            builder.append(\"- \").append(session.getSessionId())\n                    .append(\" | root=\").append(clip(session.getRootSessionId(), 24))\n                    .append(\" | parent=\").append(clip(firstNonBlank(session.getParentSessionId(), \"-\"), 24))\n                    .append(\" | updated=\").append(formatTimestamp(session.getUpdatedAtEpochMs()))\n                    .append(\" | memory=\").append(session.getMemoryItemCount())\n                    .append(\" | processes=\").append(session.getProcessCount())\n                    .append(\" | \").append(clip(session.getSummary(), 120))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private String renderEventsOutput(List<SessionEvent> events) {\n        if (events == null || events.isEmpty()) {\n            return \"events: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"events:\\n\");\n        for (SessionEvent event : events) {\n            builder.append(\"- \").append(formatTimestamp(event.getTimestamp()))\n                    .append(\" | \").append(event.getType())\n                    .append(event.getStep() == null ? \"\" : \" | step=\" + event.getStep())\n                    .append(\" | \").append(clip(event.getSummary(), 160))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private String renderReplayOutput(List<String> replayLines) {\n        if (replayLines == null || replayLines.isEmpty()) {\n            return \"replay: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"replay:\\n\");\n        for (String replayLine : replayLines) {\n            builder.append(replayLine).append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private String renderProcessesOutput(List<BashProcessInfo> processes) {\n        if (processes == null || processes.isEmpty()) {\n            return \"processes: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"processes:\\n\");\n        for (BashProcessInfo process : processes) {\n            builder.append(\"- \").append(process.getProcessId())\n                    .append(\" | status=\").append(process.getStatus())\n                    .append(\" | mode=\").append(process.isControlAvailable() ? \"live\" : \"metadata-only\")\n                    .append(\" | restored=\").append(process.isRestored())\n                    .append(\" | cwd=\").append(clip(process.getWorkingDirectory(), 48))\n                    .append(\" | cmd=\").append(clip(process.getCommand(), 72))\n                    .append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private String renderProcessStatusOutput(BashProcessInfo processInfo) {\n        if (processInfo == null) {\n            return \"process status: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"process status:\\n\");\n        appendProcessSummary(builder, processInfo);\n        return builder.toString().trim();\n    }\n\n    private String renderProcessDetailsOutput(BashProcessInfo processInfo, BashProcessLogChunk logs) {\n        StringBuilder builder = new StringBuilder(renderProcessStatusOutput(processInfo));\n        String content = logs == null ? null : logs.getContent();\n        if (!isBlank(content)) {\n            builder.append('\\n').append('\\n').append(\"process logs:\\n\").append(content.trim());\n        }\n        return builder.toString().trim();\n    }\n\n    private String renderPanel(String title, String... lines) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"----------------------------------------------------------------\").append('\\n');\n        builder.append(title).append('\\n');\n        if (lines != null) {\n            for (String line : lines) {\n                if (!isBlank(line)) {\n                    builder.append(line).append('\\n');\n                }\n            }\n        }\n        builder.append(\"----------------------------------------------------------------\");\n        return builder.toString();\n    }\n\n    private String resolveToolCallKey(AgentToolCall call) {\n        if (call == null) {\n            return UUID.randomUUID().toString();\n        }\n        if (!isBlank(call.getCallId())) {\n            return call.getCallId();\n        }\n        return firstNonBlank(call.getName(), \"tool\") + \"::\" + firstNonBlank(call.getArguments(), \"\");\n    }\n\n    private String resolveToolResultKey(AgentToolResult result) {\n        if (result == null) {\n            return UUID.randomUUID().toString();\n        }\n        if (!isBlank(result.getCallId())) {\n            return result.getCallId();\n        }\n        return firstNonBlank(result.getName(), \"tool\");\n    }\n\n    private TuiAssistantToolView buildPendingToolView(AgentToolCall call) {\n        JSONObject arguments = parseObject(call == null ? null : call.getArguments());\n        String toolName = call == null ? null : call.getName();\n        String title = buildToolTitle(toolName, arguments);\n        String detail = buildPendingToolDetail(toolName, arguments);\n        return TuiAssistantToolView.builder()\n                .callId(call == null ? null : call.getCallId())\n                .toolName(toolName)\n                .status(\"pending\")\n                .title(title)\n                .detail(detail)\n                .previewLines(buildPendingToolPreviewLines(toolName, arguments))\n                .build();\n    }\n\n    private TuiAssistantToolView buildCompletedToolView(AgentToolCall call, AgentToolResult result) {\n        String toolName = call != null && !isBlank(call.getName()) ? call.getName() : (result == null ? null : result.getName());\n        JSONObject arguments = parseObject(call == null ? null : call.getArguments());\n        JSONObject output = parseObject(result == null ? null : result.getOutput());\n        return TuiAssistantToolView.builder()\n                .callId(call == null ? null : call.getCallId())\n                .toolName(toolName)\n                .status(isToolError(result, output) ? \"error\" : \"done\")\n                .title(buildToolTitle(toolName, arguments))\n                .detail(buildCompletedToolDetail(toolName, arguments, output, result == null ? null : result.getOutput()))\n                .previewLines(buildToolPreviewLines(toolName, arguments, output, result == null ? null : result.getOutput()))\n                .build();\n    }\n\n    private boolean isToolError(AgentToolResult result, JSONObject output) {\n        return !isBlank(extractToolError(result == null ? null : result.getOutput(), output));\n    }\n\n    private boolean isApprovalRejectedToolResult(AgentToolResult result) {\n        return isApprovalRejectedToolError(result == null ? null : result.getOutput(), null);\n    }\n\n    private boolean isApprovalRejectedToolError(String rawOutput, JSONObject output) {\n        String error = extractRawToolError(rawOutput, output);\n        return !isBlank(error) && error.startsWith(CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX);\n    }\n\n    private String extractToolError(String rawOutput, JSONObject output) {\n        String rawError = extractRawToolError(rawOutput, output);\n        if (isBlank(rawError)) {\n            return null;\n        }\n        return stripApprovalRejectedPrefix(rawError);\n    }\n\n    private String extractRawToolError(String rawOutput, JSONObject output) {\n        if (!isBlank(rawOutput) && rawOutput.startsWith(\"TOOL_ERROR:\")) {\n            JSONObject errorPayload = parseObject(rawOutput.substring(\"TOOL_ERROR:\".length()).trim());\n            if (errorPayload != null && !isBlank(errorPayload.getString(\"error\"))) {\n                return errorPayload.getString(\"error\");\n            }\n            return rawOutput.substring(\"TOOL_ERROR:\".length()).trim();\n        }\n        if (!isBlank(rawOutput) && rawOutput.startsWith(\"CODE_ERROR:\")) {\n            return rawOutput.substring(\"CODE_ERROR:\".length()).trim();\n        }\n        if (output == null) {\n            return null;\n        }\n        String error = output.getString(\"error\");\n        return isBlank(error) ? null : error.trim();\n    }\n\n    private String stripApprovalRejectedPrefix(String error) {\n        if (isBlank(error)) {\n            return null;\n        }\n        if (!error.startsWith(CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX)) {\n            return error;\n        }\n        String stripped = error.substring(CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX.length()).trim();\n        return isBlank(stripped) ? \"Tool call rejected by user\" : stripped;\n    }\n\n    private String buildToolTitle(String toolName, JSONObject arguments) {\n        if (\"bash\".equals(toolName)) {\n            String action = firstNonBlank(arguments == null ? null : arguments.getString(\"action\"), \"exec\");\n            if (\"exec\".equals(action) || \"start\".equals(action)) {\n                String command = firstNonBlank(arguments == null ? null : arguments.getString(\"command\"), null);\n                return isBlank(command) ? \"bash \" + action : \"$ \" + command;\n            }\n            if (\"write\".equals(action)) {\n                return \"bash write \" + firstNonBlank(arguments == null ? null : arguments.getString(\"processId\"), \"(process)\");\n            }\n            if (\"logs\".equals(action) || \"status\".equals(action) || \"stop\".equals(action)) {\n                return \"bash \" + action + \" \" + firstNonBlank(arguments == null ? null : arguments.getString(\"processId\"), \"(process)\");\n            }\n            return \"bash \" + action;\n        }\n        if (\"read_file\".equals(toolName)) {\n            String path = arguments == null ? null : arguments.getString(\"path\");\n            Integer startLine = arguments == null ? null : arguments.getInteger(\"startLine\");\n            Integer endLine = arguments == null ? null : arguments.getInteger(\"endLine\");\n            String range = startLine == null ? \"\" : \":\" + startLine + (endLine == null ? \"\" : \"-\" + endLine);\n            return \"read \" + firstNonBlank(path, \"(path)\") + range;\n        }\n        if (\"write_file\".equals(toolName)) {\n            return \"write \" + firstNonBlank(arguments == null ? null : arguments.getString(\"path\"), \"(path)\");\n        }\n        if (\"apply_patch\".equals(toolName)) {\n            return \"apply_patch\";\n        }\n        String qualifiedToolName = qualifyMcpToolName(toolName);\n        String argumentSummary = formatToolArgumentSummary(arguments);\n        if (isBlank(argumentSummary)) {\n            return qualifiedToolName;\n        }\n        return qualifiedToolName + \"(\" + argumentSummary + \")\";\n    }\n\n    private String buildPendingToolDetail(String toolName, JSONObject arguments) {\n        if (\"bash\".equals(toolName)) {\n            String action = firstNonBlank(arguments == null ? null : arguments.getString(\"action\"), \"exec\");\n            if (\"exec\".equals(action) || \"start\".equals(action)) {\n                return \"Running command...\";\n            }\n            if (\"logs\".equals(action)) {\n                return \"Reading process logs...\";\n            }\n            if (\"status\".equals(action)) {\n                return \"Checking process status...\";\n            }\n            if (\"write\".equals(action)) {\n                return \"Writing to process...\";\n            }\n            if (\"stop\".equals(action)) {\n                return \"Stopping process...\";\n            }\n            return \"Running tool...\";\n        }\n        if (\"read_file\".equals(toolName)) {\n            return \"Reading file content...\";\n        }\n        if (\"write_file\".equals(toolName)) {\n            return \"Writing file content...\";\n        }\n        if (\"apply_patch\".equals(toolName)) {\n            return \"Applying workspace patch...\";\n        }\n        if (isMcpToolName(toolName)) {\n            return \"Calling MCP tool...\";\n        }\n        return \"Running tool...\";\n    }\n\n    private List<String> buildPendingToolPreviewLines(String toolName, JSONObject arguments) {\n        List<String> previewLines = new ArrayList<String>();\n        if (\"bash\".equals(toolName)) {\n            return previewLines;\n        }\n        if (\"apply_patch\".equals(toolName)) {\n            previewLines.addAll(PatchSummaryFormatter.summarizePatchRequest(\n                    arguments == null ? null : arguments.getString(\"patch\"),\n                    6\n            ));\n            return previewLines;\n        }\n        return previewLines;\n    }\n\n    private String buildCompletedToolDetail(String toolName,\n                                            JSONObject arguments,\n                                            JSONObject output,\n                                            String rawOutput) {\n        String toolError = extractToolError(rawOutput, output);\n        if (!isBlank(toolError)) {\n            return clip(toolError, 96);\n        }\n        if (\"bash\".equals(toolName)) {\n            String action = firstNonBlank(arguments == null ? null : arguments.getString(\"action\"), \"exec\");\n            if (\"exec\".equals(action) && output != null) {\n                if (output.getBooleanValue(\"timedOut\")) {\n                    return \"timed out\";\n                }\n                return null;\n            }\n            if ((\"start\".equals(action) || \"status\".equals(action) || \"stop\".equals(action)) && output != null) {\n                String processId = firstNonBlank(output.getString(\"processId\"), \"process\");\n                String status = safeTrimToNull(output.getString(\"status\"));\n                return isBlank(status) ? processId : processId + \" | \" + status.toLowerCase(Locale.ROOT);\n            }\n            if (\"write\".equals(action) && output != null) {\n                return output.getIntValue(\"bytesWritten\") + \" bytes written\";\n            }\n            if (\"logs\".equals(action) && output != null) {\n                return null;\n            }\n            return clip(rawOutput, 96);\n        }\n        if (\"read_file\".equals(toolName) && output != null) {\n            return firstNonBlank(output.getString(\"path\"), firstNonBlank(arguments == null ? null : arguments.getString(\"path\"), \"(path)\"))\n                    + \":\" + output.getIntValue(\"startLine\") + \"-\" + output.getIntValue(\"endLine\")\n                    + (output.getBooleanValue(\"truncated\") ? \" | truncated\" : \"\");\n        }\n        if (\"write_file\".equals(toolName) && output != null) {\n            String status = output.getBooleanValue(\"appended\")\n                    ? \"appended\"\n                    : (output.getBooleanValue(\"created\") ? \"created\" : \"overwritten\");\n            return status + \" | \" + output.getIntValue(\"bytesWritten\") + \" bytes\";\n        }\n        if (\"apply_patch\".equals(toolName) && output != null) {\n            int filesChanged = output.getIntValue(\"filesChanged\");\n            int operationsApplied = output.getIntValue(\"operationsApplied\");\n            if (filesChanged <= 0 && operationsApplied <= 0) {\n                return null;\n            }\n            if (operationsApplied > 0 && operationsApplied != filesChanged) {\n                return filesChanged + \" files changed, \" + operationsApplied + \" operations\";\n            }\n            return filesChanged == 1 ? \"1 file changed\" : filesChanged + \" files changed\";\n        }\n        if (isMcpToolName(toolName)) {\n            return null;\n        }\n        return clip(rawOutput, 96);\n    }\n\n    private boolean isMcpToolName(String toolName) {\n        return mcpRuntimeManager != null && !isBlank(mcpRuntimeManager.findServerNameByToolName(toolName));\n    }\n\n    private String qualifyMcpToolName(String toolName) {\n        String normalizedToolName = firstNonBlank(toolName, \"tool\");\n        if (mcpRuntimeManager == null || isBlank(toolName)) {\n            return normalizedToolName;\n        }\n        String serverName = mcpRuntimeManager.findServerNameByToolName(toolName);\n        if (isBlank(serverName)) {\n            return normalizedToolName;\n        }\n        return serverName + \".\" + normalizedToolName;\n    }\n\n    private String formatToolArgumentSummary(JSONObject arguments) {\n        if (arguments == null || arguments.isEmpty()) {\n            return null;\n        }\n        List<String> parts = new ArrayList<String>();\n        int count = 0;\n        for (Map.Entry<String, Object> entry : arguments.entrySet()) {\n            if (entry == null || isBlank(entry.getKey())) {\n                continue;\n            }\n            parts.add(entry.getKey() + \"=\" + formatInlineToolArgumentValue(entry.getValue()));\n            count++;\n            if (count >= 2) {\n                break;\n            }\n        }\n        if (parts.isEmpty()) {\n            return null;\n        }\n        if (arguments.size() > count) {\n            parts.add(\"...\");\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < parts.size(); i++) {\n            if (i > 0) {\n                builder.append(\", \");\n            }\n            builder.append(parts.get(i));\n        }\n        return clip(builder.toString(), 88);\n    }\n\n    private String formatInlineToolArgumentValue(Object value) {\n        if (value == null) {\n            return \"null\";\n        }\n        if (value instanceof String) {\n            return \"\\\"\" + clip((String) value, 48) + \"\\\"\";\n        }\n        if (value instanceof Number || value instanceof Boolean) {\n            return String.valueOf(value);\n        }\n        return clip(JSON.toJSONString(value), 48);\n    }\n\n    private List<String> buildToolPreviewLines(String toolName,\n                                               JSONObject arguments,\n                                               JSONObject output,\n                                               String rawOutput) {\n        List<String> previewLines = new ArrayList<String>();\n        if (!isBlank(extractToolError(rawOutput, output))) {\n            return previewLines;\n        }\n        if (\"bash\".equals(toolName)) {\n            String action = firstNonBlank(arguments == null ? null : arguments.getString(\"action\"), \"exec\");\n            if (\"exec\".equals(action) && output != null) {\n                addCommandPreviewLines(previewLines, output.getString(\"stdout\"), output.getString(\"stderr\"));\n                return previewLines;\n            }\n            if (\"logs\".equals(action) && output != null) {\n                addPlainPreviewLines(previewLines, output.getString(\"content\"));\n                return previewLines;\n            }\n            if ((\"start\".equals(action) || \"status\".equals(action) || \"stop\".equals(action)) && output != null) {\n                addPreviewLine(previewLines, \"command\", output.getString(\"command\"));\n                return previewLines;\n            }\n            if (\"write\".equals(action) && output != null) {\n                return previewLines;\n            }\n            return previewLines;\n        }\n        if (\"read_file\".equals(toolName) && output != null) {\n            addPreviewLines(previewLines, \"file\", output.getString(\"content\"), 4);\n            if (previewLines.isEmpty()) {\n                previewLines.add(\"file> (empty file)\");\n            }\n            return previewLines;\n        }\n        if (\"write_file\".equals(toolName) && output != null) {\n            addPreviewLine(previewLines, \"path\", output.getString(\"resolvedPath\"));\n            return previewLines;\n        }\n        if (\"apply_patch\".equals(toolName) && output != null) {\n            previewLines.addAll(PatchSummaryFormatter.summarizePatchResult(output, 8));\n            if (!previewLines.isEmpty()) {\n                return previewLines;\n            }\n            return previewLines;\n        }\n        addPreviewLines(previewLines, \"out\", rawOutput, 4);\n        return previewLines;\n    }\n\n    private void addPreviewLines(List<String> target, String label, String raw, int maxLines) {\n        if (target == null || isBlank(raw) || maxLines <= 0) {\n            return;\n        }\n        String[] lines = raw.replace(\"\\r\", \"\").split(\"\\n\");\n        int count = 0;\n        for (String line : lines) {\n            if (isBlank(line)) {\n                continue;\n            }\n            target.add(firstNonBlank(label, \"out\") + \"> \" + clip(line, 92));\n            count++;\n            if (count >= maxLines) {\n                break;\n            }\n        }\n    }\n\n    private void addCommandPreviewLines(List<String> target, String stdout, String stderr) {\n        if (target == null) {\n            return;\n        }\n        List<String> lines = collectNonBlankLines(stdout);\n        if (lines.isEmpty()) {\n            lines = collectNonBlankLines(stderr);\n        } else {\n            List<String> stderrLines = collectNonBlankLines(stderr);\n            for (String stderrLine : stderrLines) {\n                lines.add(\"stderr: \" + stderrLine);\n            }\n        }\n        addSummarizedPreview(target, lines);\n    }\n\n    private void addPlainPreviewLines(List<String> target, String raw) {\n        if (target == null) {\n            return;\n        }\n        addSummarizedPreview(target, collectNonBlankLines(raw));\n    }\n\n    private void addSummarizedPreview(List<String> target, List<String> lines) {\n        if (target == null || lines == null || lines.isEmpty()) {\n            return;\n        }\n        if (lines.size() <= 3) {\n            for (String line : lines) {\n                target.add(clip(line, 92));\n            }\n            return;\n        }\n        target.add(clip(lines.get(0), 92));\n        target.add(\"\\u2026 +\" + (lines.size() - 2) + \" lines\");\n        target.add(clip(lines.get(lines.size() - 1), 92));\n    }\n\n    private List<String> collectNonBlankLines(String raw) {\n        if (isBlank(raw)) {\n            return new ArrayList<String>();\n        }\n        String[] rawLines = raw.replace(\"\\r\", \"\").split(\"\\n\");\n        List<String> lines = new ArrayList<String>();\n        for (String rawLine : rawLines) {\n            if (!isBlank(rawLine)) {\n                lines.add(rawLine.trim());\n            }\n        }\n        return lines;\n    }\n\n    private void addPreviewLine(List<String> target, String label, String value) {\n        if (target == null || isBlank(value)) {\n            return;\n        }\n        target.add(firstNonBlank(label, \"meta\") + \"> \" + clip(value, 92));\n    }\n\n    private final class TuiLiveTurnState {\n\n        private TuiAssistantPhase phase = TuiAssistantPhase.IDLE;\n        private Integer step;\n        private String phaseDetail;\n        private final StringBuilder reasoningBuffer = new StringBuilder();\n        private final StringBuilder textBuffer = new StringBuilder();\n        private final Map<String, TuiAssistantToolView> toolViews = new LinkedHashMap<String, TuiAssistantToolView>();\n        private String textBeforeFinalOutput;\n        private int persistedReasoningLength;\n        private int persistedTextLength;\n        private int liveReasoningLength;\n        private int liveTextLength;\n        private long updatedAtEpochMs;\n        private int animationTick;\n\n        private synchronized void beginTurn(String input) {\n            phase = TuiAssistantPhase.THINKING;\n            step = null;\n            phaseDetail = isBlank(input) ? \"Waiting for model output...\" : \"Thinking about: \" + clip(input, 72);\n            reasoningBuffer.setLength(0);\n            textBuffer.setLength(0);\n            toolViews.clear();\n            textBeforeFinalOutput = null;\n            persistedReasoningLength = 0;\n            persistedTextLength = 0;\n            liveReasoningLength = 0;\n            liveTextLength = 0;\n            touch();\n        }\n\n        private synchronized void onStepStart(Integer step) {\n            this.step = step;\n            if (phase != TuiAssistantPhase.COMPLETE && phase != TuiAssistantPhase.ERROR) {\n                phase = TuiAssistantPhase.THINKING;\n                phaseDetail = \"Waiting for model output...\";\n                touch();\n            }\n        }\n\n        private synchronized void onModelDelta(Integer step, String delta) {\n            this.step = step;\n            if (!isBlank(delta)) {\n                textBuffer.append(delta);\n            }\n            phase = TuiAssistantPhase.GENERATING;\n            phaseDetail = \"Streaming model output...\";\n            touch();\n        }\n\n        private synchronized void onReasoningDelta(Integer step, String delta) {\n            this.step = step;\n            if (!isBlank(delta)) {\n                reasoningBuffer.append(delta);\n            }\n            if (phase != TuiAssistantPhase.GENERATING) {\n                phase = TuiAssistantPhase.THINKING;\n            }\n            phaseDetail = \"Streaming reasoning...\";\n            touch();\n        }\n\n        private synchronized void onRetry(Integer step, String detail) {\n            this.step = step;\n            phase = TuiAssistantPhase.THINKING;\n            phaseDetail = firstNonBlank(detail, \"Retrying model request...\");\n            touch();\n        }\n\n        private synchronized void onToolCall(Integer step, TuiAssistantToolView toolView) {\n            this.step = step;\n            if (toolView != null) {\n                toolViews.put(firstNonBlank(toolView.getCallId(), toolView.getToolName(), UUID.randomUUID().toString()), toolView);\n                phaseDetail = firstNonBlank(toolView.getDetail(), \"Waiting for tool result...\");\n            }\n            phase = TuiAssistantPhase.WAITING_TOOL_RESULT;\n            touch();\n        }\n\n        private synchronized void onToolResult(Integer step, TuiAssistantToolView toolView) {\n            this.step = step;\n            if (toolView != null) {\n                toolViews.put(firstNonBlank(toolView.getCallId(), toolView.getToolName(), UUID.randomUUID().toString()), toolView);\n            }\n            phase = toolView != null && \"error\".equalsIgnoreCase(toolView.getStatus())\n                    ? TuiAssistantPhase.ERROR\n                    : TuiAssistantPhase.THINKING;\n            phaseDetail = phase == TuiAssistantPhase.ERROR\n                    ? firstNonBlank(toolView == null ? null : toolView.getDetail(), \"Tool execution failed.\")\n                    : \"Tool finished, continuing...\";\n            touch();\n        }\n\n        private synchronized void onStepEnd(Integer step) {\n            this.step = step;\n            if (phase == TuiAssistantPhase.WAITING_TOOL_RESULT) {\n                phase = TuiAssistantPhase.THINKING;\n                phaseDetail = \"Preparing next step...\";\n                touch();\n            }\n        }\n\n        private synchronized void onFinalOutput(Integer step, String output) {\n            this.step = step;\n            if (!isBlank(output)) {\n                String current = textBuffer.toString();\n                textBeforeFinalOutput = current;\n                if (isBlank(current)) {\n                    textBuffer.setLength(0);\n                    textBuffer.append(output);\n                } else if (!output.equals(current)) {\n                    if (output.startsWith(current)) {\n                        textBuffer.setLength(0);\n                        textBuffer.append(output);\n                    } else {\n                        textBuffer.setLength(0);\n                        textBuffer.append(output);\n                        persistedTextLength = 0;\n                        if (liveTextLength > 0) {\n                            liveTextLength = textBuffer.length();\n                        } else {\n                            liveTextLength = 0;\n                        }\n                    }\n                }\n            }\n            phase = TuiAssistantPhase.COMPLETE;\n            phaseDetail = \"Turn complete.\";\n            touch();\n        }\n\n        private synchronized void onError(Integer step, String errorMessage) {\n            this.step = step;\n            phase = TuiAssistantPhase.ERROR;\n            phaseDetail = firstNonBlank(errorMessage, \"Agent run failed.\");\n            touch();\n        }\n\n        private synchronized void finishTurn(Integer step, String output) {\n            this.step = step == null ? this.step : step;\n            if (phase == TuiAssistantPhase.ERROR || phase == TuiAssistantPhase.COMPLETE) {\n                return;\n            }\n            if (!isBlank(output)) {\n                onFinalOutput(this.step, output);\n                return;\n            }\n            phase = TuiAssistantPhase.COMPLETE;\n            phaseDetail = \"Turn complete.\";\n            touch();\n        }\n\n        private synchronized String flushPendingText() {\n            String pending = pendingText();\n            persistedTextLength = textBuffer.length();\n            return pending;\n        }\n\n        private synchronized String flushPendingReasoning() {\n            String pending = pendingReasoning();\n            persistedReasoningLength = reasoningBuffer.length();\n            return pending;\n        }\n\n        private synchronized String flushLiveReasoning() {\n            String pending = pendingLiveReasoning();\n            liveReasoningLength = reasoningBuffer.length();\n            return pending;\n        }\n\n        private synchronized String flushLiveText() {\n            String pending = pendingLiveText();\n            liveTextLength = textBuffer.length();\n            return pending;\n        }\n\n        private synchronized String pendingReasoning() {\n            if (persistedReasoningLength >= reasoningBuffer.length()) {\n                return \"\";\n            }\n            return reasoningBuffer.substring(Math.max(0, persistedReasoningLength));\n        }\n\n        private synchronized String pendingText() {\n            if (persistedTextLength >= textBuffer.length()) {\n                return \"\";\n            }\n            return textBuffer.substring(Math.max(0, persistedTextLength));\n        }\n\n        private synchronized String pendingLiveReasoning() {\n            if (liveReasoningLength >= reasoningBuffer.length()) {\n                return \"\";\n            }\n            return reasoningBuffer.substring(Math.max(0, liveReasoningLength));\n        }\n\n        private synchronized String pendingLiveText() {\n            if (liveTextLength >= textBuffer.length()) {\n                return \"\";\n            }\n            return textBuffer.substring(Math.max(0, liveTextLength));\n        }\n\n        private synchronized boolean hasPendingReasoning() {\n            return !isBlank(pendingReasoning());\n        }\n\n        private synchronized boolean hasPendingText() {\n            return !isBlank(pendingText());\n        }\n\n        private synchronized String currentText() {\n            return textBuffer.toString();\n        }\n\n        private synchronized String textBeforeFinalOutput() {\n            return textBeforeFinalOutput == null ? textBuffer.toString() : textBeforeFinalOutput;\n        }\n\n        private synchronized TuiAssistantViewModel toViewModel() {\n            return TuiAssistantViewModel.builder()\n                    .phase(phase)\n                    .step(step)\n                    .phaseDetail(phaseDetail)\n                    .reasoningText(pendingReasoning())\n                    .text(pendingText())\n                    .updatedAtEpochMs(updatedAtEpochMs)\n                    .animationTick(animationTick)\n                    .tools(new ArrayList<TuiAssistantToolView>(toolViews.values()))\n                    .build();\n        }\n\n        private synchronized boolean advanceAnimationTick() {\n            if (!isSpinnerActive()) {\n                return false;\n            }\n            if (animationTick == Integer.MAX_VALUE) {\n                animationTick = 0;\n            } else {\n                animationTick++;\n            }\n            return true;\n        }\n\n        private synchronized boolean isSpinnerActive() {\n            return phase == TuiAssistantPhase.THINKING\n                    || phase == TuiAssistantPhase.GENERATING\n                    || phase == TuiAssistantPhase.WAITING_TOOL_RESULT;\n        }\n\n        private void touch() {\n            updatedAtEpochMs = System.currentTimeMillis();\n            animationTick = 0;\n        }\n    }\n\n    private final class MainBufferTurnPrinter {\n        private TranscriptPrinter transcriptPrinter;\n        private LiveTranscriptKind liveTranscriptKind;\n        private boolean liveTranscriptAtLineStart = true;\n        private final StringBuilder assistantLineBuffer = new StringBuilder();\n        private boolean assistantInsideCodeBlock;\n        private String assistantCodeBlockLanguage;\n\n        private synchronized void beginTurn(String input) {\n            finishLiveTranscript();\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.beginTurn(input);\n            }\n        }\n\n        private synchronized void finishTurn() {\n            finishLiveTranscript();\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.finishTurn();\n            }\n        }\n\n        private synchronized void showThinking() {\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.showThinking();\n            }\n        }\n\n        private synchronized void showConnecting(String text) {\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.showConnecting(text);\n            }\n        }\n\n        private synchronized void showRetrying(String text, int attempt, int maxAttempts) {\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.showRetrying(text, attempt, maxAttempts);\n            }\n        }\n\n        private synchronized void showResponding() {\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.showResponding();\n            }\n        }\n\n        private synchronized void showStatus(String text) {\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.showWorking(text);\n            }\n        }\n\n        private synchronized void clearTransient() {\n            finishLiveTranscript();\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.clearTransient();\n            }\n        }\n\n        private synchronized void printSectionBreak() {\n            finishLiveTranscript();\n            transcriptPrinter().printSectionBreak();\n        }\n\n        private synchronized void printBlock(List<String> lines) {\n            finishLiveTranscript();\n            transcriptPrinter().printBlock(lines);\n        }\n\n        private synchronized void printAssistantBlock(String text) {\n            finishLiveTranscript();\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            transcriptPrinter().beginStreamingBlock();\n            if (shellTerminal != null) {\n                beginAssistantBlockTracking();\n                shellTerminal.printAssistantMarkdownBlock(text);\n                return;\n            }\n            List<AssistantTranscriptRenderer.Line> lines = assistantTranscriptRenderer.render(text);\n            if (lines.isEmpty()) {\n                return;\n            }\n            CliThemeStyler fallbackStyler = new CliThemeStyler(tuiTheme, terminal.supportsAnsi());\n            for (AssistantTranscriptRenderer.Line line : lines) {\n                String renderedLine = line == null\n                        ? \"\"\n                        : (line.code()\n                        ? fallbackStyler.styleTranscriptCodeLine(line.text(), line.language())\n                        : fallbackStyler.styleTranscriptLine(line.text()));\n                terminal.println(renderedLine);\n            }\n        }\n\n        private synchronized boolean replaceAssistantBlock(String previousText, String replacementText) {\n            finishLiveTranscript();\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            return shellTerminal != null && shellTerminal.rewriteAssistantBlock(shellTerminal.assistantBlockRows(), replacementText);\n        }\n\n        private synchronized void discardAssistantBlock() {\n            if (liveTranscriptKind == LiveTranscriptKind.ASSISTANT && assistantInsideCodeBlock) {\n                exitTranscriptCodeBlock();\n            }\n            liveTranscriptKind = null;\n            liveTranscriptAtLineStart = true;\n            assistantLineBuffer.setLength(0);\n            assistantInsideCodeBlock = false;\n            assistantCodeBlockLanguage = null;\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                // Do not rewrite already-printed transcript lines in the main\n                // buffer. Once the terminal has scrolled, cursor-up clears can\n                // erase visible history and make the viewport look like it\n                // \"refreshed\". Keep the pre-tool assistant text and only\n                // forget the tracked block so later output appends normally.\n                shellTerminal.forgetAssistantBlock();\n            }\n        }\n\n        private synchronized void streamAssistant(String delta) {\n            streamLiveTranscript(LiveTranscriptKind.ASSISTANT, delta);\n        }\n\n        private synchronized void streamReasoning(String delta) {\n            streamLiveTranscript(LiveTranscriptKind.REASONING, delta);\n        }\n\n        private JlineShellTerminalIO shellTerminal() {\n            return terminal instanceof JlineShellTerminalIO ? (JlineShellTerminalIO) terminal : null;\n        }\n\n        private TranscriptPrinter transcriptPrinter() {\n            if (transcriptPrinter == null) {\n                transcriptPrinter = new TranscriptPrinter(terminal);\n            }\n            return transcriptPrinter;\n        }\n\n        private void streamLiveTranscript(LiveTranscriptKind kind, String delta) {\n            if (delta == null || delta.isEmpty()) {\n                return;\n            }\n            ensureLiveTranscript(kind);\n            if (kind == LiveTranscriptKind.ASSISTANT) {\n                streamAssistantMarkdown(delta);\n                return;\n            }\n            String normalized = delta.replace(\"\\r\", \"\");\n            int start = 0;\n            while (start <= normalized.length()) {\n                int newlineIndex = normalized.indexOf('\\n', start);\n                String fragment = newlineIndex >= 0\n                        ? normalized.substring(start, newlineIndex)\n                        : normalized.substring(start);\n                if (!fragment.isEmpty()) {\n                    if (kind == LiveTranscriptKind.REASONING && liveTranscriptAtLineStart) {\n                        emitLiveTranscriptFragment(kind, \"Thinking: \");\n                    }\n                    emitLiveTranscriptFragment(kind, fragment);\n                    liveTranscriptAtLineStart = false;\n                }\n                if (newlineIndex < 0) {\n                    break;\n                }\n                terminal.println(\"\");\n                liveTranscriptAtLineStart = true;\n                start = newlineIndex + 1;\n            }\n        }\n\n        private void ensureLiveTranscript(LiveTranscriptKind kind) {\n            if (kind == null) {\n                return;\n            }\n            if (liveTranscriptKind != null && liveTranscriptKind != kind) {\n                finishLiveTranscript();\n            }\n            if (liveTranscriptKind == null) {\n                if (kind == LiveTranscriptKind.ASSISTANT) {\n                    beginAssistantBlockTracking();\n                }\n                transcriptPrinter().beginStreamingBlock();\n                liveTranscriptKind = kind;\n                liveTranscriptAtLineStart = true;\n            }\n        }\n\n        private void finishLiveTranscript() {\n            if (liveTranscriptKind == null) {\n                return;\n            }\n            if (liveTranscriptKind == LiveTranscriptKind.ASSISTANT) {\n                flushAssistantRemainder();\n            }\n            if (assistantInsideCodeBlock) {\n                exitTranscriptCodeBlock();\n            }\n            if (!liveTranscriptAtLineStart) {\n                terminal.println(\"\");\n            }\n            liveTranscriptKind = null;\n            liveTranscriptAtLineStart = true;\n            assistantLineBuffer.setLength(0);\n            assistantInsideCodeBlock = false;\n            assistantCodeBlockLanguage = null;\n        }\n\n        private void emitLiveTranscriptFragment(LiveTranscriptKind kind, String fragment) {\n            if (fragment == null || fragment.isEmpty()) {\n                return;\n            }\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                if (kind == LiveTranscriptKind.REASONING) {\n                    shellTerminal.printReasoningFragment(fragment);\n                } else {\n                    shellTerminal.printAssistantFragment(fragment);\n                }\n                return;\n            }\n            terminal.print(fragment);\n        }\n\n        private void streamAssistantMarkdown(String delta) {\n            String normalized = delta.replace(\"\\r\", \"\");\n            int index = 0;\n            while (index < normalized.length()) {\n                int newlineIndex = normalized.indexOf('\\n', index);\n                if (newlineIndex < 0) {\n                    assistantLineBuffer.append(normalized.substring(index));\n                    return;\n                }\n                assistantLineBuffer.append(normalized.substring(index, newlineIndex));\n                emitCompletedAssistantLine();\n                index = newlineIndex + 1;\n            }\n        }\n\n        private void emitCompletedAssistantLine() {\n            if (codexStyleBlockFormatter.isCodeFenceLine(assistantLineBuffer.toString())) {\n                if (!assistantInsideCodeBlock) {\n                    assistantInsideCodeBlock = true;\n                    assistantCodeBlockLanguage = codexStyleBlockFormatter.codeFenceLanguage(assistantLineBuffer.toString());\n                    enterTranscriptCodeBlock(assistantCodeBlockLanguage);\n                } else {\n                    assistantInsideCodeBlock = false;\n                    assistantCodeBlockLanguage = null;\n                    exitTranscriptCodeBlock();\n                }\n            } else if (assistantInsideCodeBlock) {\n                emitLiveTranscriptLine(codexStyleBlockFormatter.formatCodeContentLine(assistantLineBuffer.toString()));\n            } else {\n                emitLiveTranscriptAssistantLine(assistantLineBuffer.toString(), true);\n            }\n            assistantLineBuffer.setLength(0);\n            liveTranscriptAtLineStart = true;\n        }\n\n        private void flushAssistantRemainder() {\n            if (assistantLineBuffer.length() == 0) {\n                assistantInsideCodeBlock = false;\n                assistantCodeBlockLanguage = null;\n                return;\n            }\n            if (assistantInsideCodeBlock) {\n                emitLiveTranscriptLine(codexStyleBlockFormatter.formatCodeContentLine(assistantLineBuffer.toString()));\n                assistantInsideCodeBlock = false;\n                assistantCodeBlockLanguage = null;\n                exitTranscriptCodeBlock();\n                return;\n            }\n            if (codexStyleBlockFormatter.isCodeFenceLine(assistantLineBuffer.toString())) {\n                assistantInsideCodeBlock = true;\n                assistantCodeBlockLanguage = codexStyleBlockFormatter.codeFenceLanguage(assistantLineBuffer.toString());\n                enterTranscriptCodeBlock(assistantCodeBlockLanguage);\n                return;\n            }\n            emitLiveTranscriptAssistantLine(assistantLineBuffer.toString(), false);\n        }\n\n        private void emitLiveTranscriptLine(String line) {\n            if (line == null) {\n                line = \"\";\n            }\n            if (!liveTranscriptAtLineStart) {\n                terminal.println(\"\");\n            }\n            terminal.println(line);\n            liveTranscriptAtLineStart = true;\n        }\n\n        private void emitLiveTranscriptAssistantLine(String line, boolean newline) {\n            String safe = line == null ? \"\" : line;\n            if (newline) {\n                if (!liveTranscriptAtLineStart) {\n                    terminal.println(\"\");\n                }\n                JlineShellTerminalIO shellTerminal = shellTerminal();\n                if (shellTerminal != null) {\n                    shellTerminal.printTranscriptLine(safe, true);\n                } else {\n                    terminal.println(safe);\n                }\n                liveTranscriptAtLineStart = true;\n                return;\n            }\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.printTranscriptLine(safe, false);\n            } else {\n                terminal.print(safe);\n            }\n            liveTranscriptAtLineStart = false;\n        }\n\n        private void enterTranscriptCodeBlock(String language) {\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.enterTranscriptCodeBlock(language);\n            }\n        }\n\n        private void beginAssistantBlockTracking() {\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.beginAssistantBlockTracking();\n            }\n        }\n\n        private void exitTranscriptCodeBlock() {\n            JlineShellTerminalIO shellTerminal = shellTerminal();\n            if (shellTerminal != null) {\n                shellTerminal.exitTranscriptCodeBlock();\n            }\n        }\n\n        private List<String> trimBlankEdges(String[] rawLines) {\n            List<String> lines = new ArrayList<String>();\n            if (rawLines == null || rawLines.length == 0) {\n                return lines;\n            }\n            int start = 0;\n            int end = rawLines.length - 1;\n            while (start <= end && isBlank(rawLines[start])) {\n                start++;\n            }\n            while (end >= start && isBlank(rawLines[end])) {\n                end--;\n            }\n            for (int index = start; index <= end; index++) {\n                lines.add(rawLines[index] == null ? \"\" : rawLines[index]);\n            }\n            return lines;\n        }\n\n        private boolean sameText(String left, String right) {\n            return left == null ? right == null : left.equals(right);\n        }\n    }\n\n    private enum LiveTranscriptKind {\n        REASONING,\n        ASSISTANT\n    }\n\n    private static final class DispatchResult {\n\n        private final ManagedCodingSession session;\n        private final boolean exitRequested;\n\n        private DispatchResult(ManagedCodingSession session, boolean exitRequested) {\n            this.session = session;\n            this.exitRequested = exitRequested;\n        }\n\n        private static DispatchResult stay(ManagedCodingSession session) {\n            return new DispatchResult(session, false);\n        }\n\n        private static DispatchResult exit(ManagedCodingSession session) {\n            return new DispatchResult(session, true);\n        }\n\n        private ManagedCodingSession getSession() {\n            return session;\n        }\n\n        private boolean isExitRequested() {\n            return exitRequested;\n        }\n    }\n\n    private String safeMessage(Throwable throwable) {\n        String message = throwable == null ? null : throwable.getMessage();\n        if (isBlank(message)) {\n            return throwable == null ? \"unknown error\" : throwable.getClass().getSimpleName();\n        }\n        return message;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private boolean safeEquals(String left, String right) {\n        return left == null ? right == null : left.equals(right);\n    }\n\n    private boolean sameTurnId(String left, String right) {\n        if (left == null) {\n            return right == null;\n        }\n        return left.equals(right);\n    }\n\n    private boolean isExitCommand(String input) {\n        String normalized = input == null ? \"\" : input.trim();\n        return \"/exit\".equalsIgnoreCase(normalized) || \"/quit\".equalsIgnoreCase(normalized);\n    }\n\n    private String clip(String value, int maxChars) {\n        return CliDisplayWidth.clip(value, maxChars);\n    }\n\n    private final class CliAgentListener implements AgentListener {\n\n        private final ManagedCodingSession session;\n        private final String turnId;\n        private final ActiveTuiTurn activeTurn;\n        private final Map<String, AgentToolCall> toolCalls = new LinkedHashMap<String, AgentToolCall>();\n        private String finalOutput;\n        private boolean errorOccurred;\n        private boolean suppressAssistantStreamingAfterTool;\n        private volatile boolean closed;\n\n        private CliAgentListener(ManagedCodingSession session, String turnId, ActiveTuiTurn activeTurn) {\n            this.session = session;\n            this.turnId = turnId;\n            this.activeTurn = activeTurn;\n        }\n\n        @Override\n        public void onEvent(AgentEvent event) {\n            if (shouldIgnoreEvents()) {\n                return;\n            }\n            if (event == null || event.getType() == null) {\n                return;\n            }\n            AgentEventType type = event.getType();\n            if (type == AgentEventType.STEP_START) {\n                tuiLiveTurnState.onStepStart(event.getStep());\n                if (useMainBufferInteractiveShell()) {\n                    mainBufferTurnPrinter.showThinking();\n                }\n                renderTuiIfEnabled(session);\n                if (options.isVerbose() && !isTuiMode()) {\n                    terminal.println(\"[step] \" + type.name().toLowerCase(Locale.ROOT) + \" #\" + event.getStep());\n                }\n                return;\n            }\n            if (type == AgentEventType.MODEL_REQUEST) {\n                if (useMainBufferInteractiveShell()) {\n                    mainBufferTurnPrinter.showConnecting(buildModelConnectionStatus(session));\n                }\n                renderTuiIfEnabled(session);\n                return;\n            }\n            if (type == AgentEventType.MODEL_RETRY) {\n                String detail = firstNonBlank(event.getMessage(), \"Retrying model request\");\n                int attempt = retryPayloadInt(event.getPayload(), \"attempt\");\n                int maxAttempts = retryPayloadInt(event.getPayload(), \"maxAttempts\");\n                tuiLiveTurnState.onRetry(event.getStep(), detail);\n                if (useMainBufferInteractiveShell()) {\n                    if (attempt > 0 && maxAttempts > 0) {\n                        mainBufferTurnPrinter.showRetrying(detail, attempt, maxAttempts);\n                    } else {\n                        mainBufferTurnPrinter.showStatus(detail);\n                    }\n                } else if (options.isVerbose() && !isTuiMode()) {\n                    terminal.println(\"[model] \" + detail);\n                }\n                renderTuiIfEnabled(session);\n                return;\n            }\n            if (type == AgentEventType.MODEL_RESPONSE) {\n                if (!isBlank(event.getMessage())) {\n                    if (tuiLiveTurnState.hasPendingReasoning()) {\n                        flushPendingReasoning(event.getStep());\n                    }\n                    tuiLiveTurnState.onModelDelta(event.getStep(), event.getMessage());\n                    if (streamTranscriptEnabled() && renderMainBufferAssistantIncrementally()) {\n                        streamMainBufferAssistantDelta();\n                    }\n                    if (useMainBufferInteractiveShell()) {\n                        mainBufferTurnPrinter.showResponding();\n                    }\n                    if (shouldRenderModelDelta(event.getMessage())) {\n                        renderTuiIfEnabled(session);\n                    }\n                }\n                return;\n            }\n            if (type == AgentEventType.MODEL_REASONING) {\n                if (!isBlank(event.getMessage())) {\n                    if (tuiLiveTurnState.hasPendingText()) {\n                        flushPendingText(event.getStep());\n                    }\n                    tuiLiveTurnState.onReasoningDelta(event.getStep(), event.getMessage());\n                    if (streamTranscriptEnabled() && renderMainBufferReasoningIncrementally()) {\n                        streamMainBufferReasoningDelta();\n                    }\n                    if (useMainBufferInteractiveShell()) {\n                        mainBufferTurnPrinter.showThinking();\n                    }\n                    if (shouldRenderModelDelta(event.getMessage())) {\n                        renderTuiIfEnabled(session);\n                    }\n                }\n                return;\n            }\n            if (type == AgentEventType.TOOL_CALL) {\n                handleToolCall(event);\n                return;\n            }\n            if (type == AgentEventType.TOOL_RESULT) {\n                handleToolResult(event);\n                return;\n            }\n            if (AgentHandoffSessionEventSupport.supports(event)) {\n                handleHandoffEvent(event);\n                return;\n            }\n            if (AgentTeamSessionEventSupport.supports(event)) {\n                handleTeamTaskEvent(event);\n                return;\n            }\n            if (AgentTeamMessageSessionEventSupport.supports(event)) {\n                handleTeamMessageEvent(event);\n                return;\n            }\n            if (type == AgentEventType.FINAL_OUTPUT) {\n                if (errorOccurred && isBlank(event.getMessage())) {\n                    return;\n                }\n                finalOutput = event.getMessage();\n                tuiLiveTurnState.onFinalOutput(event.getStep(), finalOutput);\n                if (streamTranscriptEnabled() && renderMainBufferAssistantIncrementally()) {\n                    streamMainBufferAssistantDelta();\n                }\n                renderTuiIfEnabled(session);\n                return;\n            }\n            if (type == AgentEventType.ERROR) {\n                errorOccurred = true;\n                if (useMainBufferInteractiveShell()) {\n                    mainBufferTurnPrinter.clearTransient();\n                } else if (!isTuiMode()) {\n                    terminal.errorln(\"[error] \" + clip(event.getMessage(), 320));\n                }\n                flushPendingAssistantText(event.getStep());\n                tuiLiveTurnState.onError(event.getStep(), event.getMessage());\n                renderTuiIfEnabled(session);\n                appendEvent(session, SessionEventType.ERROR, turnId, event.getStep(), clip(event.getMessage(), 320), payloadOf(\n                        \"error\", clip(event.getMessage(), options.isVerbose() ? 4000 : 1200)\n                ));\n                if (useMainBufferInteractiveShell()) {\n                    emitMainBufferError(event.getMessage());\n                }\n                return;\n            }\n            if (type == AgentEventType.STEP_END) {\n                tuiLiveTurnState.onStepEnd(event.getStep());\n                if (useMainBufferInteractiveShell() && isBlank(finalOutput)) {\n                    mainBufferTurnPrinter.showThinking();\n                }\n                renderTuiIfEnabled(session);\n            }\n            if (options.isVerbose() && !isTuiMode() && type == AgentEventType.STEP_END) {\n                terminal.println(\"[step] \" + type.name().toLowerCase(Locale.ROOT) + \" #\" + event.getStep());\n            }\n        }\n\n        private void close() {\n            closed = true;\n        }\n\n        private boolean shouldIgnoreEvents() {\n            return closed || isTurnInterrupted(turnId, activeTurn);\n        }\n\n        private int retryPayloadInt(Object payload, String key) {\n            if (!(payload instanceof Map) || isBlank(key)) {\n                return 0;\n            }\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> values = (Map<String, Object>) payload;\n            Object value = values.get(key);\n            if (value instanceof Number) {\n                return ((Number) value).intValue();\n            }\n            if (value instanceof String) {\n                try {\n                    return Integer.parseInt(((String) value).trim());\n                } catch (NumberFormatException ignored) {\n                    return 0;\n                }\n            }\n            return 0;\n        }\n\n        private void handleToolCall(AgentEvent event) {\n            AgentToolCall call = event.getPayload() instanceof AgentToolCall ? (AgentToolCall) event.getPayload() : null;\n            if (call == null) {\n                return;\n            }\n            boolean firstToolCallInTurn = !suppressAssistantStreamingAfterTool;\n            suppressAssistantStreamingAfterTool = true;\n            String callKey = resolveToolCallKey(call);\n            if (toolCalls.containsKey(callKey)) {\n                return;\n            }\n            toolCalls.put(callKey, call);\n            if (!isTuiMode()) {\n                terminal.println(\"[tool] \" + call.getName() + \" \" + clip(call.getArguments(), options.isVerbose() ? 320 : 160));\n            }\n            flushPendingAssistantText(event.getStep());\n            if (firstToolCallInTurn && useMainBufferInteractiveShell()) {\n                mainBufferTurnPrinter.discardAssistantBlock();\n            }\n            TuiAssistantToolView toolView = buildPendingToolView(call);\n            appendEvent(session, SessionEventType.TOOL_CALL, turnId, event.getStep(),\n                    call.getName() + \" \" + clip(call.getArguments(), 120),\n                    payloadOf(\n                            \"tool\", call.getName(),\n                            \"callId\", call.getCallId(),\n                            \"arguments\", clip(call.getArguments(), options.isVerbose() ? 4000 : 1200),\n                            \"title\", toolView.getTitle(),\n                            \"detail\", toolView.getDetail(),\n                            \"previewLines\", toolView.getPreviewLines()\n                    ));\n            tuiLiveTurnState.onToolCall(event.getStep(), toolView);\n            if (useMainBufferInteractiveShell()) {\n                mainBufferTurnPrinter.showStatus(buildMainBufferRunningStatus(toolView));\n            }\n            renderTuiIfEnabled(session);\n        }\n\n        private void handleToolResult(AgentEvent event) {\n            AgentToolResult result = event.getPayload() instanceof AgentToolResult ? (AgentToolResult) event.getPayload() : null;\n            if (result == null) {\n                return;\n            }\n            AgentToolCall call = toolCalls.remove(resolveToolResultKey(result));\n            if (!isTuiMode()) {\n                terminal.println(\"[tool-result] \" + result.getName() + \" \" + clip(result.getOutput(), options.isVerbose() ? 320 : 160));\n            }\n            TuiAssistantToolView toolView = buildCompletedToolView(call, result);\n            appendEvent(session, SessionEventType.TOOL_RESULT, turnId, event.getStep(),\n                    result.getName() + \" \" + clip(result.getOutput(), 120),\n                    payloadOf(\n                            \"tool\", result.getName(),\n                            \"callId\", result.getCallId(),\n                            \"arguments\", call == null ? null : clip(call.getArguments(), options.isVerbose() ? 4000 : 1200),\n                            \"output\", clip(result.getOutput(), options.isVerbose() ? 4000 : 1200),\n                            \"title\", toolView.getTitle(),\n                            \"detail\", toolView.getDetail(),\n                            \"previewLines\", toolView.getPreviewLines()\n                    ));\n            tuiLiveTurnState.onToolResult(event.getStep(), toolView);\n            if (useMainBufferInteractiveShell()) {\n                if (!isApprovalRejectedToolResult(result)) {\n                    mainBufferTurnPrinter.printBlock(buildMainBufferToolLines(toolView));\n                }\n            }\n            renderTuiIfEnabled(session);\n            appendProcessEvent(session, turnId, event.getStep(), call, result);\n        }\n\n        private void handleHandoffEvent(AgentEvent event) {\n            SessionEvent sessionEvent = AgentHandoffSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event);\n            if (sessionEvent == null) {\n                return;\n            }\n            appendEvent(session, sessionEvent.getType(), turnId, sessionEvent.getStep(), sessionEvent.getSummary(), sessionEvent.getPayload());\n            if (useMainBufferInteractiveShell() && sessionEvent.getType() == SessionEventType.TASK_UPDATED) {\n                mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatInfoBlock(\n                        \"Subagent task\",\n                        buildReplayTaskLines(sessionEvent)\n                ));\n            }\n            renderTuiIfEnabled(session);\n        }\n\n        private void handleTeamTaskEvent(AgentEvent event) {\n            SessionEvent sessionEvent = AgentTeamSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event);\n            if (sessionEvent == null) {\n                return;\n            }\n            appendEvent(session, sessionEvent.getType(), turnId, sessionEvent.getStep(), sessionEvent.getSummary(), sessionEvent.getPayload());\n            if (useMainBufferInteractiveShell() && sessionEvent.getType() == SessionEventType.TASK_UPDATED) {\n                mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatInfoBlock(\n                        \"Team task\",\n                        buildReplayTaskLines(sessionEvent)\n                ));\n            }\n            renderTuiIfEnabled(session);\n        }\n\n        private void handleTeamMessageEvent(AgentEvent event) {\n            SessionEvent sessionEvent = AgentTeamMessageSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event);\n            if (sessionEvent == null) {\n                return;\n            }\n            appendEvent(session, sessionEvent.getType(), turnId, sessionEvent.getStep(), sessionEvent.getSummary(), sessionEvent.getPayload());\n            if (useMainBufferInteractiveShell()) {\n                mainBufferTurnPrinter.printBlock(codexStyleBlockFormatter.formatInfoBlock(\n                        \"Team message\",\n                        buildReplayTeamMessageLines(sessionEvent)\n                ));\n            }\n            renderTuiIfEnabled(session);\n        }\n\n        private void flushFinalOutput() {\n            if (shouldIgnoreEvents()) {\n                return;\n            }\n            if (useMainBufferInteractiveShell()) {\n                flushPendingAssistantText(null);\n                // When transcript streaming is off, flushPendingAssistantText()\n                // has already emitted the completed assistant block. Printing\n                // finalOutput again duplicates the same answer at turn end.\n                if (streamEnabled && !isBlank(finalOutput)) {\n                    mainBufferTurnPrinter.printAssistantBlock(finalOutput);\n                }\n                return;\n            }\n            // In one-shot CLI mode, onFinalOutput() has already reconciled the\n            // streamed deltas with the final provider payload inside the live\n            // text buffer. Flushing the pending assistant text emits the final\n            // answer once; printing finalOutput directly here duplicates it.\n            flushPendingAssistantText(null);\n            tuiLiveTurnState.finishTurn(null, finalOutput);\n            renderTuiIfEnabled(session);\n        }\n\n        private void flushPendingAssistantText(Integer step) {\n            if (streamTranscriptEnabled() && renderMainBufferReasoningIncrementally()) {\n                streamMainBufferReasoningDelta();\n            }\n            if (streamTranscriptEnabled() && renderMainBufferAssistantIncrementally()) {\n                streamMainBufferAssistantDelta();\n            }\n            flushPendingReasoning(step);\n            flushPendingText(step);\n        }\n\n        private void flushPendingReasoning(Integer step) {\n            String pendingReasoning = tuiLiveTurnState.flushPendingReasoning();\n            if (isBlank(pendingReasoning)) {\n                return;\n            }\n            appendEvent(session, SessionEventType.ASSISTANT_MESSAGE, turnId, step, clip(pendingReasoning, 200), payloadOf(\n                    \"kind\", \"reasoning\",\n                    \"output\", clipPreserveNewlines(pendingReasoning, options.isVerbose() ? 4000 : 1200)\n            ));\n            if ((!useMainBufferInteractiveShell()\n                    || !streamEnabled\n                    || !renderMainBufferReasoningIncrementally()) && !suppressMainBufferReasoningBlocks()) {\n                emitMainBufferReasoning(pendingReasoning);\n            }\n        }\n\n        private void flushPendingText(Integer step) {\n            String pendingText = tuiLiveTurnState.flushPendingText();\n            if (isBlank(pendingText)) {\n                return;\n            }\n            appendEvent(session, SessionEventType.ASSISTANT_MESSAGE, turnId, step, clip(pendingText, 200), payloadOf(\n                    \"kind\", \"assistant\",\n                    \"output\", clipPreserveNewlines(pendingText, options.isVerbose() ? 4000 : 1200)\n            ));\n            if (!useMainBufferInteractiveShell() || !streamEnabled) {\n                emitMainBufferAssistant(pendingText);\n            }\n        }\n\n        private void streamMainBufferReasoningDelta() {\n            String delta = tuiLiveTurnState.flushLiveReasoning();\n            if (!isBlank(delta)) {\n                mainBufferTurnPrinter.streamReasoning(delta);\n            }\n        }\n\n        private void streamMainBufferAssistantDelta() {\n            if (!renderMainBufferAssistantIncrementally() || suppressAssistantStreamingAfterTool) {\n                return;\n            }\n            String delta = tuiLiveTurnState.flushLiveText();\n            if (!isBlank(delta)) {\n                mainBufferTurnPrinter.streamAssistant(delta);\n            }\n        }\n    }\n\n    private final class ActiveTuiTurn {\n\n        private final ManagedCodingSession session;\n        private final String input;\n        private final String turnId = newTurnId();\n        private volatile Thread thread;\n        private volatile boolean interrupted;\n        private volatile boolean done;\n        private volatile Exception failure;\n\n        private ActiveTuiTurn(ManagedCodingSession session, String input) {\n            this.session = session;\n            this.input = input;\n        }\n\n        private void start() {\n            Thread worker = new Thread(new Runnable() {\n                @Override\n                public void run() {\n                    try {\n                        runTurn(session, input, ActiveTuiTurn.this);\n                    } catch (Exception ex) {\n                        failure = ex;\n                    } finally {\n                        done = true;\n                    }\n                }\n            }, \"ai4j-tui-turn\");\n            thread = worker;\n            worker.start();\n        }\n\n        private boolean requestInterrupt() {\n            if (done || interrupted) {\n                return false;\n            }\n            interrupted = true;\n            Thread worker = thread;\n            if (worker != null) {\n                worker.interrupt();\n            }\n            return true;\n        }\n\n        private ManagedCodingSession getSession() {\n            return session;\n        }\n\n        private String getTurnId() {\n            return turnId;\n        }\n\n        private boolean isInterrupted() {\n            return interrupted;\n        }\n\n        private boolean isDone() {\n            return done;\n        }\n\n        private Exception getFailure() {\n            return failure;\n        }\n    }\n\n    private AssistantReplayPlan computeAssistantReplayPlan(String currentText, String finalText) {\n        List<String> finalLines = assistantTranscriptRenderer.plainLines(finalText);\n        if (finalLines.isEmpty()) {\n            return AssistantReplayPlan.none();\n        }\n        List<String> currentLines = assistantTranscriptRenderer.plainLines(currentText);\n        if (currentLines.isEmpty()) {\n            return AssistantReplayPlan.append(finalLines);\n        }\n        if (normalizeAssistantComparisonText(currentLines).equals(normalizeAssistantComparisonText(finalLines))) {\n            return AssistantReplayPlan.none();\n        }\n        int prefix = commonAssistantPrefixLength(currentLines, finalLines);\n        if (prefix >= currentLines.size()) {\n            return AssistantReplayPlan.append(finalLines.subList(prefix, finalLines.size()));\n        }\n        return AssistantReplayPlan.replace(computeRenderedSupplementalReplayLines(currentLines, finalLines));\n    }\n\n    private List<String> computeRenderedSupplementalReplayLines(List<String> currentLines, List<String> finalLines) {\n        if (finalLines == null || finalLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (currentLines == null || currentLines.isEmpty()) {\n            return new ArrayList<String>(finalLines);\n        }\n        int prefix = commonAssistantPrefixLength(currentLines, finalLines);\n        int suffix = 0;\n        while (suffix < currentLines.size() - prefix\n                && suffix < finalLines.size() - prefix\n                && assistantComparisonLine(currentLines.get(currentLines.size() - 1 - suffix))\n                .equals(assistantComparisonLine(finalLines.get(finalLines.size() - 1 - suffix)))) {\n            suffix++;\n        }\n        int finalStart = prefix;\n        int finalEnd = finalLines.size() - suffix;\n        if (finalStart >= finalEnd) {\n            return Collections.emptyList();\n        }\n        return new ArrayList<String>(finalLines.subList(finalStart, finalEnd));\n    }\n\n    private int commonAssistantPrefixLength(List<String> currentLines, List<String> finalLines) {\n        int prefix = 0;\n        while (prefix < currentLines.size()\n                && prefix < finalLines.size()\n                && assistantComparisonLine(currentLines.get(prefix)).equals(assistantComparisonLine(finalLines.get(prefix)))) {\n            prefix++;\n        }\n        return prefix;\n    }\n\n    private String normalizeAssistantComparisonText(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (String line : lines) {\n            if (builder.length() > 0) {\n                builder.append('\\n');\n            }\n            builder.append(assistantComparisonLine(line));\n        }\n        return builder.toString().trim();\n    }\n\n    private boolean assistantTextMatches(String currentText, String finalText) {\n        return normalizeAssistantComparisonText(assistantTranscriptRenderer.plainLines(currentText))\n                .equals(normalizeAssistantComparisonText(assistantTranscriptRenderer.plainLines(finalText)));\n    }\n\n    private String assistantComparisonLine(String line) {\n        String safe = line == null ? \"\" : line.trim();\n        if (codexStyleBlockFormatter.isCodeFenceLine(safe)) {\n            return \"```\";\n        }\n        safe = safe.replace(\"`\", \"\");\n        safe = safe.replace(\"**\", \"\");\n        safe = safe.replace(\"__\", \"\");\n        safe = safe.replace(\"*\", \"\");\n        safe = safe.replace(\"_\", \"\");\n        return safe;\n    }\n\n    private String joinLines(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int index = 0; index < lines.size(); index++) {\n            if (index > 0) {\n                builder.append('\\n');\n            }\n            builder.append(lines.get(index) == null ? \"\" : lines.get(index));\n        }\n        return builder.toString();\n    }\n\n    private String clipPreserveNewlines(String value, int maxChars) {\n        if (value == null) {\n            return \"\";\n        }\n        String normalized = value.replace(\"\\r\", \"\").trim();\n        if (normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, maxChars) + \"...\";\n    }\n\n    private static final class McpAddCommand {\n\n        private final String name;\n        private final CliMcpServerDefinition definition;\n\n        private McpAddCommand(String name, CliMcpServerDefinition definition) {\n            this.name = name;\n            this.definition = definition;\n        }\n    }\n\n    private static final class ProviderProfileMutation {\n\n        private final String profileName;\n        private String provider;\n        private String protocol;\n        private String model;\n        private String baseUrl;\n        private String apiKey;\n        private boolean clearModel;\n        private boolean clearBaseUrl;\n        private boolean clearApiKey;\n\n        private ProviderProfileMutation(String profileName) {\n            this.profileName = profileName;\n        }\n\n        private boolean hasAnyFieldChanges() {\n            return provider != null\n                    || protocol != null\n                    || model != null\n                    || baseUrl != null\n                    || apiKey != null\n                    || clearModel\n                    || clearBaseUrl\n                    || clearApiKey;\n        }\n    }\n\n    private static final class AssistantReplayPlan {\n\n        private final boolean replaceBlock;\n        private final List<String> supplementalLines;\n\n        private AssistantReplayPlan(boolean replaceBlock, List<String> supplementalLines) {\n            this.replaceBlock = replaceBlock;\n            this.supplementalLines = supplementalLines == null\n                    ? Collections.<String>emptyList()\n                    : new ArrayList<String>(supplementalLines);\n        }\n\n        private static AssistantReplayPlan none() {\n            return new AssistantReplayPlan(false, Collections.<String>emptyList());\n        }\n\n        private static AssistantReplayPlan append(List<String> supplementalLines) {\n            return new AssistantReplayPlan(false, supplementalLines);\n        }\n\n        private static AssistantReplayPlan replace(List<String> supplementalLines) {\n            return new AssistantReplayPlan(true, supplementalLines);\n        }\n\n        private boolean replaceBlock() {\n            return replaceBlock;\n        }\n\n        private List<String> supplementalLines() {\n            return supplementalLines;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CodingCliTuiSupport.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.tui.TuiConfig;\nimport io.github.lnyocly.ai4j.tui.TuiRenderer;\nimport io.github.lnyocly.ai4j.tui.TuiRuntime;\nimport io.github.lnyocly.ai4j.tui.TuiTheme;\n\npublic class CodingCliTuiSupport {\n\n    private final TuiConfig config;\n    private final TuiTheme theme;\n    private final TuiRenderer renderer;\n    private final TuiRuntime runtime;\n\n    public CodingCliTuiSupport(TuiConfig config,\n                               TuiTheme theme,\n                               TuiRenderer renderer,\n                               TuiRuntime runtime) {\n        this.config = config;\n        this.theme = theme;\n        this.renderer = renderer;\n        this.runtime = runtime;\n    }\n\n    public TuiConfig getConfig() {\n        return config;\n    }\n\n    public TuiTheme getTheme() {\n        return theme;\n    }\n\n    public TuiRenderer getRenderer() {\n        return renderer;\n    }\n\n    public TuiRuntime getRuntime() {\n        return runtime;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CodingTaskSessionEventBridge.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\nimport io.github.lnyocly.ai4j.coding.runtime.CodingRuntimeListener;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLink;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskProgress;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskStatus;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic class CodingTaskSessionEventBridge implements CodingRuntimeListener {\n\n    public interface SessionEventConsumer {\n\n        void onEvent(SessionEvent event);\n    }\n\n    private final CodingSessionManager sessionManager;\n    private final SessionEventConsumer consumer;\n\n    public CodingTaskSessionEventBridge(CodingSessionManager sessionManager) {\n        this(sessionManager, null);\n    }\n\n    public CodingTaskSessionEventBridge(CodingSessionManager sessionManager, SessionEventConsumer consumer) {\n        this.sessionManager = sessionManager;\n        this.consumer = consumer;\n    }\n\n    @Override\n    public void onTaskCreated(CodingTask task, CodingSessionLink link) {\n        append(toTaskCreatedEvent(task, link));\n    }\n\n    @Override\n    public void onTaskUpdated(CodingTask task) {\n        append(toTaskUpdatedEvent(task));\n    }\n\n    public SessionEvent toTaskCreatedEvent(CodingTask task, CodingSessionLink link) {\n        if (task == null || isBlank(task.getParentSessionId())) {\n            return null;\n        }\n        return SessionEvent.builder()\n                .eventId(UUID.randomUUID().toString())\n                .sessionId(task.getParentSessionId())\n                .type(SessionEventType.TASK_CREATED)\n                .timestamp(System.currentTimeMillis())\n                .summary(buildSummary(task))\n                .payload(buildPayload(task, link))\n                .build();\n    }\n\n    public SessionEvent toTaskUpdatedEvent(CodingTask task) {\n        if (task == null || isBlank(task.getParentSessionId())) {\n            return null;\n        }\n        return SessionEvent.builder()\n                .eventId(UUID.randomUUID().toString())\n                .sessionId(task.getParentSessionId())\n                .type(SessionEventType.TASK_UPDATED)\n                .timestamp(System.currentTimeMillis())\n                .summary(buildSummary(task))\n                .payload(buildPayload(task, null))\n                .build();\n    }\n\n    private void append(SessionEvent event) {\n        if (event == null) {\n            return;\n        }\n        if (sessionManager != null) {\n            try {\n                sessionManager.appendEvent(event.getSessionId(), event);\n            } catch (IOException ignored) {\n            }\n        }\n        if (consumer != null) {\n            consumer.onEvent(event);\n        }\n    }\n\n    private Map<String, Object> buildPayload(CodingTask task, CodingSessionLink link) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        CodingTaskProgress progress = task == null ? null : task.getProgress();\n        String detail = progress == null ? null : trimToNull(progress.getMessage());\n        String output = trimToNull(task == null ? null : task.getOutputText());\n        String error = trimToNull(task == null ? null : task.getError());\n        payload.put(\"taskId\", task == null ? null : task.getTaskId());\n        payload.put(\"callId\", task == null ? null : task.getTaskId());\n        payload.put(\"tool\", task == null ? null : task.getDefinitionName());\n        payload.put(\"title\", buildTitle(task));\n        payload.put(\"detail\", detail);\n        payload.put(\"status\", normalizeStatus(task == null ? null : task.getStatus()));\n        payload.put(\"background\", task != null && task.isBackground());\n        payload.put(\"childSessionId\", task == null ? null : task.getChildSessionId());\n        payload.put(\"phase\", progress == null ? null : progress.getPhase());\n        payload.put(\"percent\", progress == null ? null : progress.getPercent());\n        payload.put(\"sessionMode\", link == null || link.getSessionMode() == null ? null : link.getSessionMode().name().toLowerCase());\n        payload.put(\"output\", output);\n        payload.put(\"error\", error);\n        payload.put(\"previewLines\", previewLines(firstNonBlank(error, output, detail)));\n        return payload;\n    }\n\n    private String buildSummary(CodingTask task) {\n        String title = buildTitle(task);\n        String status = normalizeStatus(task == null ? null : task.getStatus());\n        return firstNonBlank(title, \"delegate task\") + \" [\" + firstNonBlank(status, \"unknown\") + \"]\";\n    }\n\n    private String buildTitle(CodingTask task) {\n        String definitionName = task == null ? null : trimToNull(task.getDefinitionName());\n        if (definitionName == null) {\n            return \"Delegate task\";\n        }\n        return \"Delegate \" + definitionName;\n    }\n\n    private List<String> previewLines(String raw) {\n        List<String> lines = new ArrayList<String>();\n        if (isBlank(raw)) {\n            return lines;\n        }\n        String[] split = raw.replace(\"\\r\", \"\").split(\"\\n\");\n        int max = Math.min(4, split.length);\n        for (int i = 0; i < max; i++) {\n            String line = trimToNull(split[i]);\n            if (line != null) {\n                lines.add(line);\n            }\n        }\n        return lines;\n    }\n\n    private String normalizeStatus(CodingTaskStatus status) {\n        return status == null ? null : status.name().toLowerCase();\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/HeadlessCodingSessionRuntime.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.model.ResponsesModelClient;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\nimport io.github.lnyocly.ai4j.coding.CodingAgentResult;\nimport io.github.lnyocly.ai4j.coding.CodingAgentRequest;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\nimport io.github.lnyocly.ai4j.coding.loop.CodingLoopDecision;\nimport io.github.lnyocly.ai4j.coding.loop.CodingStopReason;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\n\nimport java.io.IOException;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic class HeadlessCodingSessionRuntime {\n\n    public static final String TURN_INTERRUPTED_MESSAGE = \"Conversation interrupted by user.\";\n\n    private final CodeCommandOptions options;\n    private final CodingSessionManager sessionManager;\n\n    public HeadlessCodingSessionRuntime(CodeCommandOptions options, CodingSessionManager sessionManager) {\n        this.options = options;\n        this.sessionManager = sessionManager;\n    }\n\n    public PromptResult runPrompt(ManagedCodingSession session,\n                                  String input,\n                                  PromptControl control,\n                                  HeadlessTurnObserver observer) throws Exception {\n        if (session == null || session.getSession() == null) {\n            throw new IllegalArgumentException(\"managed session is required\");\n        }\n        String turnId = newTurnId();\n        PromptControl activeControl = control == null ? new PromptControl() : control;\n        HeadlessTurnObserver effectiveObserver = observer == null ? new HeadlessTurnObserver.Adapter() : observer;\n\n        appendEvent(session, SessionEventType.USER_MESSAGE, turnId, null, clip(input, 200), payloadOf(\n                \"input\", clipPreserveNewlines(input, options != null && options.isVerbose() ? 4000 : 1200)\n        ));\n        effectiveObserver.onTurnStarted(session, turnId, input);\n\n        HeadlessAgentListener listener = new HeadlessAgentListener(session, turnId, activeControl, effectiveObserver);\n        try {\n            activeControl.attach(Thread.currentThread());\n            CodingAgentResult result = session.getSession().runStream(CodingAgentRequest.builder().input(input).build(), listener);\n            if (activeControl.isCancelled()) {\n                appendCancelledEvent(session, turnId);\n                persistSession(session);\n                effectiveObserver.onTurnCompleted(session, turnId, listener.getFinalOutput(), true);\n                return PromptResult.cancelled(turnId, listener.getFinalOutput());\n            }\n            String finalOutput = listener.flushFinalOutput();\n            appendLoopDecisionEvents(session, turnId, result, effectiveObserver);\n            appendAutoCompactEvent(session, turnId);\n            persistSession(session);\n            effectiveObserver.onTurnCompleted(session, turnId, finalOutput, false);\n            return PromptResult.completed(turnId, finalOutput, result == null ? null : result.getStopReason());\n        } catch (Exception ex) {\n            if (activeControl.isCancelled() || Thread.currentThread().isInterrupted()) {\n                appendCancelledEvent(session, turnId);\n                persistSession(session);\n                effectiveObserver.onTurnCompleted(session, turnId, listener.getFinalOutput(), true);\n                return PromptResult.cancelled(turnId, listener.getFinalOutput());\n            }\n            String message = safeMessage(ex);\n            appendEvent(session, SessionEventType.ERROR, turnId, null, message, payloadOf(\"error\", message));\n            effectiveObserver.onTurnError(session, turnId, null, message);\n            throw ex;\n        } finally {\n            listener.close();\n            activeControl.detach();\n        }\n    }\n\n    private void appendCancelledEvent(ManagedCodingSession session, String turnId) {\n        appendEvent(session, SessionEventType.ERROR, turnId, null, TURN_INTERRUPTED_MESSAGE, payloadOf(\n                \"error\", TURN_INTERRUPTED_MESSAGE\n        ));\n    }\n\n    private void appendAutoCompactEvent(ManagedCodingSession session, String turnId) {\n        if (session == null || session.getSession() == null) {\n            return;\n        }\n        List<CodingSessionCompactResult> results = session.getSession().drainAutoCompactResults();\n        for (CodingSessionCompactResult result : results) {\n            if (result == null) {\n                continue;\n            }\n            appendEvent(session, SessionEventType.COMPACT, turnId, null,\n                    (result.isAutomatic() ? \"auto\" : \"manual\")\n                            + \" compact \" + result.getEstimatedTokensBefore() + \"->\" + result.getEstimatedTokensAfter() + \" tokens\",\n                    payloadOf(\n                            \"automatic\", result.isAutomatic(),\n                            \"strategy\", result.getStrategy(),\n                            \"beforeItemCount\", result.getBeforeItemCount(),\n                            \"afterItemCount\", result.getAfterItemCount(),\n                            \"estimatedTokensBefore\", result.getEstimatedTokensBefore(),\n                            \"estimatedTokensAfter\", result.getEstimatedTokensAfter(),\n                            \"compactedToolResultCount\", result.getCompactedToolResultCount(),\n                            \"deltaItemCount\", result.getDeltaItemCount(),\n                            \"checkpointReused\", result.isCheckpointReused(),\n                            \"fallbackSummary\", result.isFallbackSummary(),\n                            \"splitTurn\", result.isSplitTurn(),\n                            \"summary\", clip(result.getSummary(), options != null && options.isVerbose() ? 4000 : 1200),\n                            \"checkpointGoal\", result.getCheckpoint() == null ? null : result.getCheckpoint().getGoal()\n                    ));\n        }\n        List<Exception> compactErrors = session.getSession().drainAutoCompactErrors();\n        for (Exception compactError : compactErrors) {\n            if (compactError == null) {\n                continue;\n            }\n            String message = safeMessage(compactError);\n            appendEvent(session, SessionEventType.ERROR, turnId, null, message, payloadOf(\n                    \"error\", message,\n                    \"source\", \"auto-compact\"\n            ));\n        }\n    }\n\n    private void appendLoopDecisionEvents(ManagedCodingSession session,\n                                          String turnId,\n                                          CodingAgentResult result,\n                                          HeadlessTurnObserver observer) {\n        if (session == null || session.getSession() == null) {\n            return;\n        }\n        List<CodingLoopDecision> decisions = session.getSession().drainLoopDecisions();\n        for (CodingLoopDecision decision : decisions) {\n            if (decision == null) {\n                continue;\n            }\n            SessionEventType eventType = decision.isContinueLoop()\n                    ? SessionEventType.AUTO_CONTINUE\n                    : decision.isBlocked() ? SessionEventType.BLOCKED : SessionEventType.AUTO_STOP;\n            SessionEvent event = SessionEvent.builder()\n                    .sessionId(session.getSessionId())\n                    .type(eventType)\n                    .turnId(turnId)\n                    .summary(firstNonBlank(decision.getSummary(), formatStopReason(result == null ? null : result.getStopReason())))\n                    .payload(payloadOf(\n                            \"turnNumber\", decision.getTurnNumber(),\n                            \"continueReason\", decision.getContinueReason(),\n                            \"stopReason\", decision.getStopReason() == null ? null : decision.getStopReason().name().toLowerCase(),\n                            \"compactApplied\", decision.isCompactApplied()\n                    ))\n                    .build();\n            appendEvent(session, event);\n            observer.onSessionEvent(session, event);\n        }\n    }\n\n    private void persistSession(ManagedCodingSession session) {\n        if (session == null || sessionManager == null || options == null || !options.isAutoSaveSession()) {\n            return;\n        }\n        try {\n            sessionManager.save(session);\n        } catch (IOException ignored) {\n        }\n    }\n\n    private void appendEvent(ManagedCodingSession session,\n                             SessionEventType type,\n                             String turnId,\n                             Integer step,\n                             String summary,\n                             Map<String, Object> payload) {\n        if (session == null || type == null || sessionManager == null) {\n            return;\n        }\n        try {\n            sessionManager.appendEvent(session.getSessionId(), SessionEvent.builder()\n                    .sessionId(session.getSessionId())\n                    .type(type)\n                    .turnId(turnId)\n                    .step(step)\n                    .summary(summary)\n                    .payload(payload)\n                    .build());\n        } catch (IOException ignored) {\n        }\n    }\n\n    private void appendEvent(ManagedCodingSession session, SessionEvent event) {\n        if (event == null) {\n            return;\n        }\n        appendEvent(session, event.getType(), event.getTurnId(), event.getStep(), event.getSummary(), event.getPayload());\n    }\n\n    private String newTurnId() {\n        return UUID.randomUUID().toString();\n    }\n\n    private Map<String, Object> payloadOf(Object... values) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        if (values == null) {\n            return payload;\n        }\n        for (int i = 0; i + 1 < values.length; i += 2) {\n            Object key = values[i];\n            if (key != null) {\n                payload.put(String.valueOf(key), values[i + 1]);\n            }\n        }\n        return payload;\n    }\n\n    private String clip(String value, int maxChars) {\n        if (value == null) {\n            return null;\n        }\n        String normalized = value.replace('\\r', ' ').replace('\\n', ' ').trim();\n        if (normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, maxChars);\n    }\n\n    private String clipPreserveNewlines(String value, int maxChars) {\n        if (value == null) {\n            return null;\n        }\n        if (value.length() <= maxChars) {\n            return value;\n        }\n        return value.substring(0, maxChars);\n    }\n\n    private String safeMessage(Throwable throwable) {\n        String message = null;\n        Throwable current = throwable;\n        Throwable last = throwable;\n        while (current != null) {\n            if (!isBlank(current.getMessage())) {\n                message = current.getMessage().trim();\n            }\n            last = current;\n            current = current.getCause();\n        }\n        return isBlank(message)\n                ? (last == null ? \"unknown error\" : last.getClass().getSimpleName())\n                : message;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    public static final class PromptControl {\n\n        private volatile boolean cancelled;\n        private volatile Thread worker;\n\n        void attach(Thread worker) {\n            this.worker = worker;\n        }\n\n        void detach() {\n            this.worker = null;\n        }\n\n        public void cancel() {\n            cancelled = true;\n            Thread currentWorker = worker;\n            if (currentWorker != null) {\n                currentWorker.interrupt();\n                ChatModelClient.cancelActiveStream(currentWorker);\n                ResponsesModelClient.cancelActiveStream(currentWorker);\n            }\n        }\n\n        public boolean isCancelled() {\n            return cancelled;\n        }\n    }\n\n    public static final class PromptResult {\n\n        private final String turnId;\n        private final String finalOutput;\n        private final String stopReason;\n        private final CodingStopReason codingStopReason;\n\n        private PromptResult(String turnId, String finalOutput, String stopReason, CodingStopReason codingStopReason) {\n            this.turnId = turnId;\n            this.finalOutput = finalOutput;\n            this.stopReason = stopReason;\n            this.codingStopReason = codingStopReason;\n        }\n\n        public static PromptResult completed(String turnId, String finalOutput, CodingStopReason codingStopReason) {\n            return new PromptResult(turnId, finalOutput, mapPromptStopReason(codingStopReason), codingStopReason);\n        }\n\n        public static PromptResult cancelled(String turnId, String finalOutput) {\n            return new PromptResult(turnId, finalOutput, \"cancelled\", CodingStopReason.INTERRUPTED);\n        }\n\n        public String getTurnId() {\n            return turnId;\n        }\n\n        public String getFinalOutput() {\n            return finalOutput;\n        }\n\n        public String getStopReason() {\n            return stopReason;\n        }\n\n        public CodingStopReason getCodingStopReason() {\n            return codingStopReason;\n        }\n\n        private static String mapPromptStopReason(CodingStopReason codingStopReason) {\n            if (codingStopReason == CodingStopReason.BLOCKED_BY_APPROVAL\n                    || codingStopReason == CodingStopReason.BLOCKED_BY_TOOL_ERROR) {\n                return \"blocked\";\n            }\n            return \"end_turn\";\n        }\n    }\n\n    private final class HeadlessAgentListener implements AgentListener {\n\n        private final ManagedCodingSession session;\n        private final String turnId;\n        private final PromptControl control;\n        private final HeadlessTurnObserver observer;\n        private final Map<String, AgentToolCall> toolCalls = new LinkedHashMap<String, AgentToolCall>();\n        private final StringBuilder pendingReasoning = new StringBuilder();\n        private final StringBuilder pendingText = new StringBuilder();\n        private String finalOutput;\n        private boolean closed;\n\n        private HeadlessAgentListener(ManagedCodingSession session,\n                                      String turnId,\n                                      PromptControl control,\n                                      HeadlessTurnObserver observer) {\n            this.session = session;\n            this.turnId = turnId;\n            this.control = control;\n            this.observer = observer;\n        }\n\n        @Override\n        public void onEvent(AgentEvent event) {\n            if (closed || control.isCancelled() || event == null || event.getType() == null) {\n                return;\n            }\n            AgentEventType type = event.getType();\n            if (type == AgentEventType.MODEL_REASONING) {\n                handleReasoning(event);\n                return;\n            }\n            if (type == AgentEventType.MODEL_RESPONSE) {\n                handleAssistant(event);\n                return;\n            }\n            if (type == AgentEventType.TOOL_CALL) {\n                flushPendingAssistantText(event.getStep());\n                handleToolCall(event);\n                return;\n            }\n            if (type == AgentEventType.TOOL_RESULT) {\n                handleToolResult(event);\n                return;\n            }\n            if (AgentHandoffSessionEventSupport.supports(event)) {\n                handleHandoffEvent(event);\n                return;\n            }\n            if (AgentTeamSessionEventSupport.supports(event)) {\n                handleTeamEvent(event);\n                return;\n            }\n            if (AgentTeamMessageSessionEventSupport.supports(event)) {\n                handleTeamMessageEvent(event);\n                return;\n            }\n            if (type == AgentEventType.FINAL_OUTPUT) {\n                finalOutput = event.getMessage();\n                return;\n            }\n            if (type == AgentEventType.ERROR) {\n                flushPendingAssistantText(event.getStep());\n                observer.onTurnError(session, turnId, event.getStep(), event.getMessage());\n            }\n        }\n\n        private void handleReasoning(AgentEvent event) {\n            if (event.getMessage() == null || event.getMessage().isEmpty()) {\n                return;\n            }\n            pendingReasoning.append(event.getMessage());\n            observer.onReasoningDelta(session, turnId, event.getStep(), event.getMessage());\n        }\n\n        private void handleAssistant(AgentEvent event) {\n            if (event.getMessage() == null || event.getMessage().isEmpty()) {\n                return;\n            }\n            if (pendingReasoning.length() > 0) {\n                flushPendingReasoning(event.getStep());\n            }\n            pendingText.append(event.getMessage());\n            observer.onAssistantDelta(session, turnId, event.getStep(), event.getMessage());\n        }\n\n        private void handleToolCall(AgentEvent event) {\n            AgentToolCall call = event.getPayload() instanceof AgentToolCall ? (AgentToolCall) event.getPayload() : null;\n            if (call == null) {\n                return;\n            }\n            String toolCallKey = resolveToolCallKey(call);\n            if (toolCallKey != null && toolCalls.containsKey(toolCallKey)) {\n                return;\n            }\n            if (toolCallKey != null) {\n                toolCalls.put(toolCallKey, call);\n            }\n            appendEvent(session, SessionEventType.TOOL_CALL, turnId, event.getStep(),\n                    call.getName() + \" \" + clip(call.getArguments(), 120),\n                    payloadOf(\n                            \"tool\", call.getName(),\n                            \"callId\", call.getCallId(),\n                            \"arguments\", clipPreserveNewlines(call.getArguments(), options != null && options.isVerbose() ? 4000 : 1200)\n                    ));\n            observer.onToolCall(session, turnId, event.getStep(), call);\n        }\n\n        private void handleToolResult(AgentEvent event) {\n            AgentToolResult result = event.getPayload() instanceof AgentToolResult ? (AgentToolResult) event.getPayload() : null;\n            if (result == null) {\n                return;\n            }\n            AgentToolCall call = toolCalls.remove(resolveToolResultKey(result));\n            boolean failed = isApprovalRejectedToolResult(result);\n            appendEvent(session, SessionEventType.TOOL_RESULT, turnId, event.getStep(),\n                    result.getName() + \" \" + clip(result.getOutput(), 120),\n                    payloadOf(\n                            \"tool\", result.getName(),\n                            \"callId\", result.getCallId(),\n                            \"arguments\", call == null ? null : clipPreserveNewlines(call.getArguments(), options != null && options.isVerbose() ? 4000 : 1200),\n                            \"output\", clipPreserveNewlines(result.getOutput(), options != null && options.isVerbose() ? 4000 : 1200)\n                    ));\n            observer.onToolResult(session, turnId, event.getStep(), call, result, failed);\n        }\n\n        private void handleHandoffEvent(AgentEvent event) {\n            SessionEvent sessionEvent = AgentHandoffSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event);\n            if (sessionEvent == null) {\n                return;\n            }\n            appendEvent(session, sessionEvent);\n            observer.onSessionEvent(session, sessionEvent);\n        }\n\n        private void handleTeamEvent(AgentEvent event) {\n            SessionEvent sessionEvent = AgentTeamSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event);\n            if (sessionEvent == null) {\n                return;\n            }\n            appendEvent(session, sessionEvent);\n            observer.onSessionEvent(session, sessionEvent);\n        }\n\n        private void handleTeamMessageEvent(AgentEvent event) {\n            SessionEvent sessionEvent = AgentTeamMessageSessionEventSupport.toSessionEvent(session.getSessionId(), turnId, event);\n            if (sessionEvent == null) {\n                return;\n            }\n            appendEvent(session, sessionEvent);\n            observer.onSessionEvent(session, sessionEvent);\n        }\n\n        private void flushPendingAssistantText(Integer step) {\n            flushPendingReasoning(step);\n            flushPendingText(step);\n        }\n\n        private void flushPendingReasoning(Integer step) {\n            if (pendingReasoning.length() == 0) {\n                return;\n            }\n            appendEvent(session, SessionEventType.ASSISTANT_MESSAGE, turnId, step, clip(pendingReasoning.toString(), 200), payloadOf(\n                    \"kind\", \"reasoning\",\n                    \"output\", clipPreserveNewlines(pendingReasoning.toString(), options != null && options.isVerbose() ? 4000 : 1200)\n            ));\n            pendingReasoning.setLength(0);\n        }\n\n        private void flushPendingText(Integer step) {\n            String text = pendingText.length() == 0 ? finalOutput : pendingText.toString();\n            if (isBlank(text)) {\n                return;\n            }\n            appendEvent(session, SessionEventType.ASSISTANT_MESSAGE, turnId, step, clip(text, 200), payloadOf(\n                    \"kind\", \"assistant\",\n                    \"output\", clipPreserveNewlines(text, options != null && options.isVerbose() ? 4000 : 1200)\n            ));\n            pendingText.setLength(0);\n        }\n\n        private String flushFinalOutput() {\n            flushPendingAssistantText(null);\n            return finalOutput;\n        }\n\n        private String getFinalOutput() {\n            return finalOutput;\n        }\n\n        private void close() {\n            closed = true;\n        }\n\n        private String resolveToolCallKey(AgentToolCall call) {\n            return call == null ? null : firstNonBlank(call.getCallId(), call.getName());\n        }\n\n        private String resolveToolResultKey(AgentToolResult result) {\n            return result == null ? null : firstNonBlank(result.getCallId(), result.getName());\n        }\n\n        private boolean isApprovalRejectedToolResult(AgentToolResult result) {\n            return result != null\n                    && result.getOutput() != null\n                    && result.getOutput().startsWith(CliToolApprovalDecorator.APPROVAL_REJECTED_PREFIX);\n        }\n\n        private String firstNonBlank(String... values) {\n            if (values == null) {\n                return null;\n            }\n            for (String value : values) {\n                if (!isBlank(value)) {\n                    return value;\n                }\n            }\n            return null;\n        }\n    }\n\n    private String formatStopReason(CodingStopReason stopReason) {\n        if (stopReason == null) {\n            return null;\n        }\n        String normalized = stopReason.name().toLowerCase().replace('_', ' ');\n        return Character.toUpperCase(normalized.charAt(0)) + normalized.substring(1) + \".\";\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/HeadlessTurnObserver.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\n\npublic interface HeadlessTurnObserver {\n\n    void onTurnStarted(ManagedCodingSession session, String turnId, String input);\n\n    void onReasoningDelta(ManagedCodingSession session, String turnId, Integer step, String delta);\n\n    void onAssistantDelta(ManagedCodingSession session, String turnId, Integer step, String delta);\n\n    void onToolCall(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call);\n\n    void onToolResult(ManagedCodingSession session,\n                      String turnId,\n                      Integer step,\n                      AgentToolCall call,\n                      AgentToolResult result,\n                      boolean failed);\n\n    void onTurnCompleted(ManagedCodingSession session, String turnId, String finalOutput, boolean cancelled);\n\n    void onTurnError(ManagedCodingSession session, String turnId, Integer step, String message);\n\n    void onSessionEvent(ManagedCodingSession session, SessionEvent event);\n\n    class Adapter implements HeadlessTurnObserver {\n\n        @Override\n        public void onTurnStarted(ManagedCodingSession session, String turnId, String input) {\n        }\n\n        @Override\n        public void onReasoningDelta(ManagedCodingSession session, String turnId, Integer step, String delta) {\n        }\n\n        @Override\n        public void onAssistantDelta(ManagedCodingSession session, String turnId, Integer step, String delta) {\n        }\n\n        @Override\n        public void onToolCall(ManagedCodingSession session, String turnId, Integer step, AgentToolCall call) {\n        }\n\n        @Override\n        public void onToolResult(ManagedCodingSession session,\n                                 String turnId,\n                                 Integer step,\n                                 AgentToolCall call,\n                                 AgentToolResult result,\n                                 boolean failed) {\n        }\n\n        @Override\n        public void onTurnCompleted(ManagedCodingSession session, String turnId, String finalOutput, boolean cancelled) {\n        }\n\n        @Override\n        public void onTurnError(ManagedCodingSession session, String turnId, Integer step, String message) {\n        }\n\n        @Override\n        public void onSessionEvent(ManagedCodingSession session, SessionEvent event) {\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/TeamBoardRenderSupport.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberSnapshot;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\npublic final class TeamBoardRenderSupport {\n\n    private static final int MAX_RECENT_MESSAGES_PER_LANE = 2;\n\n    private TeamBoardRenderSupport() {\n    }\n\n    public static List<String> renderBoardLines(List<SessionEvent> events) {\n        Aggregation aggregation = aggregate(events);\n        if (aggregation.tasksById.isEmpty() && aggregation.messages.isEmpty()) {\n            return Collections.emptyList();\n        }\n\n        List<LaneState> lanes = buildLanes(aggregation);\n        List<String> lines = new ArrayList<String>();\n        lines.add(\"summary tasks=\" + aggregation.tasksById.size()\n                + \" running=\" + aggregation.runningCount\n                + \" completed=\" + aggregation.completedCount\n                + \" failed=\" + aggregation.failedCount\n                + \" blocked=\" + aggregation.blockedCount\n                + \" members=\" + lanes.size());\n\n        for (LaneState lane : lanes) {\n            if (!lines.isEmpty()) {\n                lines.add(\"\");\n            }\n            lines.add(\"lane \" + lane.label);\n            if (lane.tasks.isEmpty()) {\n                lines.add(\"  (no tasks)\");\n            } else {\n                for (TaskState task : lane.tasks) {\n                    lines.add(\"  [\" + taskBadge(task) + \"] \" + taskLabel(task));\n                    if (!isBlank(task.detail)) {\n                        lines.add(\"    \" + clip(singleLine(task.detail), 96));\n                    }\n                    if (!isBlank(task.childSessionId)) {\n                        lines.add(\"    child session: \" + clip(task.childSessionId, 84));\n                    }\n                    if (task.heartbeatCount > 0) {\n                        lines.add(\"    heartbeats: \" + task.heartbeatCount);\n                    }\n                }\n            }\n            if (!lane.messages.isEmpty()) {\n                lines.add(\"  messages:\");\n                for (MessageState message : lane.messages) {\n                    lines.add(\"    - \" + messageLabel(message));\n                }\n            }\n        }\n        return lines;\n    }\n\n    public static List<String> renderBoardLines(AgentTeamState state) {\n        Aggregation aggregation = aggregate(state);\n        if (aggregation.tasksById.isEmpty() && aggregation.messages.isEmpty()) {\n            return Collections.emptyList();\n        }\n\n        List<LaneState> lanes = buildLanes(aggregation);\n        List<String> lines = new ArrayList<String>();\n        lines.add(\"summary tasks=\" + aggregation.tasksById.size()\n                + \" running=\" + aggregation.runningCount\n                + \" completed=\" + aggregation.completedCount\n                + \" failed=\" + aggregation.failedCount\n                + \" blocked=\" + aggregation.blockedCount\n                + \" members=\" + lanes.size());\n\n        for (LaneState lane : lanes) {\n            if (!lines.isEmpty()) {\n                lines.add(\"\");\n            }\n            lines.add(\"lane \" + lane.label);\n            if (lane.tasks.isEmpty()) {\n                lines.add(\"  (no tasks)\");\n            } else {\n                for (TaskState task : lane.tasks) {\n                    lines.add(\"  [\" + taskBadge(task) + \"] \" + taskLabel(task));\n                    if (!isBlank(task.detail)) {\n                        lines.add(\"    \" + clip(singleLine(task.detail), 96));\n                    }\n                    if (!isBlank(task.childSessionId)) {\n                        lines.add(\"    child session: \" + clip(task.childSessionId, 84));\n                    }\n                    if (task.heartbeatCount > 0) {\n                        lines.add(\"    heartbeats: \" + task.heartbeatCount);\n                    }\n                }\n            }\n            if (!lane.messages.isEmpty()) {\n                lines.add(\"  messages:\");\n                for (MessageState message : lane.messages) {\n                    lines.add(\"    - \" + messageLabel(message));\n                }\n            }\n        }\n        return lines;\n    }\n\n    public static String renderBoardOutput(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return \"team: (none)\";\n        }\n        StringBuilder builder = new StringBuilder(\"team board:\\n\");\n        for (String line : lines) {\n            builder.append(line == null ? \"\" : line).append('\\n');\n        }\n        return builder.toString().trim();\n    }\n\n    private static Aggregation aggregate(List<SessionEvent> events) {\n        Aggregation aggregation = new Aggregation();\n        if (events == null || events.isEmpty()) {\n            return aggregation;\n        }\n        for (SessionEvent event : events) {\n            if (event == null || event.getType() == null) {\n                continue;\n            }\n            if (isTeamTaskEvent(event)) {\n                TaskState next = aggregation.tasksById.get(resolveTaskId(event));\n                if (next == null) {\n                    next = new TaskState();\n                    next.order = aggregation.nextTaskOrder++;\n                    aggregation.tasksById.put(resolveTaskId(event), next);\n                }\n                applyTaskEvent(next, event);\n                continue;\n            }\n            if (event.getType() == SessionEventType.TEAM_MESSAGE) {\n                MessageState message = toMessageState(event);\n                if (message != null) {\n                    aggregation.messages.add(message);\n                }\n            }\n        }\n        for (TaskState task : aggregation.tasksById.values()) {\n            String normalized = normalizeStatus(task.status);\n            if (\"running\".equals(normalized) || \"in_progress\".equals(normalized)) {\n                aggregation.runningCount++;\n            } else if (\"completed\".equals(normalized)) {\n                aggregation.completedCount++;\n            } else if (\"failed\".equals(normalized)) {\n                aggregation.failedCount++;\n            } else if (\"blocked\".equals(normalized)) {\n                aggregation.blockedCount++;\n            }\n        }\n        return aggregation;\n    }\n\n    private static Aggregation aggregate(AgentTeamState state) {\n        Aggregation aggregation = new Aggregation();\n        if (state == null) {\n            return aggregation;\n        }\n        Map<String, String> memberLabels = memberLabelMap(state.getMembers());\n        if (state.getTaskStates() != null) {\n            for (AgentTeamTaskState persistedTask : state.getTaskStates()) {\n                if (persistedTask == null) {\n                    continue;\n                }\n                TaskState next = new TaskState();\n                next.order = aggregation.nextTaskOrder++;\n                next.taskId = firstNonBlank(trimToNull(persistedTask.getTaskId()),\n                        trimToNull(persistedTask.getTask() == null ? null : persistedTask.getTask().getId()),\n                        \"team-task-\" + next.order);\n                next.title = trimToNull(persistedTask.getTask() == null ? null : persistedTask.getTask().getTask());\n                next.task = trimToNull(persistedTask.getTask() == null ? null : persistedTask.getTask().getTask());\n                next.status = persistedTask.getStatus() == null ? null : persistedTask.getStatus().name().toLowerCase(Locale.ROOT);\n                next.phase = trimToNull(persistedTask.getPhase());\n                next.detail = firstNonBlank(trimToNull(persistedTask.getDetail()),\n                        trimToNull(persistedTask.getError()),\n                        trimToNull(persistedTask.getOutput()));\n                next.percent = persistedTask.getPercent() == null ? null : String.valueOf(persistedTask.getPercent());\n                String memberId = firstNonBlank(trimToNull(persistedTask.getClaimedBy()),\n                        trimToNull(taskMemberId(persistedTask.getTask())));\n                next.memberKey = memberId;\n                next.memberLabel = firstNonBlank(memberLabels.get(memberId), memberId, \"unassigned\");\n                next.heartbeatCount = persistedTask.getHeartbeatCount();\n                next.updatedAtEpochMs = firstPositive(\n                        persistedTask.getUpdatedAtEpochMs(),\n                        persistedTask.getLastHeartbeatTime(),\n                        persistedTask.getEndTime(),\n                        persistedTask.getStartTime()\n                );\n                aggregation.tasksById.put(next.taskId, next);\n            }\n        }\n        if (state.getMessages() != null) {\n            for (AgentTeamMessage message : state.getMessages()) {\n                if (message == null) {\n                    continue;\n                }\n                MessageState next = new MessageState();\n                next.messageId = trimToNull(message.getId());\n                next.fromMemberId = trimToNull(message.getFromMemberId());\n                next.toMemberId = trimToNull(message.getToMemberId());\n                next.messageType = trimToNull(message.getType());\n                next.taskId = trimToNull(message.getTaskId());\n                next.text = trimToNull(message.getContent());\n                next.createdAtEpochMs = message.getCreatedAt();\n                next.memberKey = firstNonBlank(next.fromMemberId, next.toMemberId, \"team\");\n                next.memberLabel = firstNonBlank(memberLabels.get(next.memberKey), next.memberKey, \"team\");\n                aggregation.messages.add(next);\n            }\n        }\n        for (TaskState task : aggregation.tasksById.values()) {\n            String normalized = normalizeStatus(task.status);\n            if (\"running\".equals(normalized) || \"in_progress\".equals(normalized)) {\n                aggregation.runningCount++;\n            } else if (\"completed\".equals(normalized)) {\n                aggregation.completedCount++;\n            } else if (\"failed\".equals(normalized)) {\n                aggregation.failedCount++;\n            } else if (\"blocked\".equals(normalized)) {\n                aggregation.blockedCount++;\n            }\n        }\n        return aggregation;\n    }\n\n    private static List<LaneState> buildLanes(Aggregation aggregation) {\n        LinkedHashMap<String, LaneState> lanes = new LinkedHashMap<String, LaneState>();\n        for (TaskState task : aggregation.tasksById.values()) {\n            lane(lanes, task.memberKey, task.memberLabel).tasks.add(task);\n        }\n        for (MessageState message : aggregation.messages) {\n            lane(lanes, message.memberKey, message.memberLabel).messages.add(message);\n        }\n        List<LaneState> ordered = new ArrayList<LaneState>(lanes.values());\n        for (LaneState lane : ordered) {\n            Collections.sort(lane.tasks, new Comparator<TaskState>() {\n                @Override\n                public int compare(TaskState left, TaskState right) {\n                    int priorityCompare = taskPriority(left) - taskPriority(right);\n                    if (priorityCompare != 0) {\n                        return priorityCompare;\n                    }\n                    long updatedCompare = right.updatedAtEpochMs - left.updatedAtEpochMs;\n                    if (updatedCompare != 0L) {\n                        return updatedCompare > 0L ? 1 : -1;\n                    }\n                    return safeText(left.taskId).compareToIgnoreCase(safeText(right.taskId));\n                }\n            });\n            Collections.sort(lane.messages, new Comparator<MessageState>() {\n                @Override\n                public int compare(MessageState left, MessageState right) {\n                    long createdCompare = right.createdAtEpochMs - left.createdAtEpochMs;\n                    if (createdCompare != 0L) {\n                        return createdCompare > 0L ? 1 : -1;\n                    }\n                    return safeText(left.messageId).compareToIgnoreCase(safeText(right.messageId));\n                }\n            });\n            if (lane.messages.size() > MAX_RECENT_MESSAGES_PER_LANE) {\n                lane.messages = new ArrayList<MessageState>(lane.messages.subList(0, MAX_RECENT_MESSAGES_PER_LANE));\n            }\n        }\n        Collections.sort(ordered, new Comparator<LaneState>() {\n            @Override\n            public int compare(LaneState left, LaneState right) {\n                int leftPriority = lanePriority(left);\n                int rightPriority = lanePriority(right);\n                if (leftPriority != rightPriority) {\n                    return leftPriority - rightPriority;\n                }\n                return safeText(left.label).compareToIgnoreCase(safeText(right.label));\n            }\n        });\n        return ordered;\n    }\n\n    private static LaneState lane(Map<String, LaneState> lanes, String key, String label) {\n        String laneKey = isBlank(key) ? \"__unassigned__\" : key;\n        LaneState lane = lanes.get(laneKey);\n        if (lane != null) {\n            if (isBlank(lane.label) && !isBlank(label)) {\n                lane.label = label;\n            }\n            return lane;\n        }\n        LaneState created = new LaneState();\n        created.key = laneKey;\n        created.label = firstNonBlank(label, \"unassigned\");\n        lanes.put(laneKey, created);\n        return created;\n    }\n\n    private static void applyTaskEvent(TaskState state, SessionEvent event) {\n        Map<String, Object> payload = event.getPayload();\n        state.taskId = firstNonBlank(trimToNull(payloadString(payload, \"taskId\")), state.taskId);\n        state.title = firstNonBlank(trimToNull(payloadString(payload, \"title\")), state.title);\n        state.task = firstNonBlank(trimToNull(payloadString(payload, \"task\")), state.task);\n        state.status = firstNonBlank(trimToNull(payloadString(payload, \"status\")), state.status);\n        state.phase = firstNonBlank(trimToNull(payloadString(payload, \"phase\")), state.phase);\n        state.detail = firstNonBlank(trimToNull(payloadString(payload, \"detail\")),\n                trimToNull(payloadString(payload, \"error\")),\n                trimToNull(payloadString(payload, \"output\")),\n                state.detail);\n        state.percent = firstNonBlank(trimToNull(payloadString(payload, \"percent\")), state.percent);\n        state.memberKey = firstNonBlank(trimToNull(payloadString(payload, \"memberId\")),\n                trimToNull(payloadString(payload, \"memberName\")),\n                state.memberKey);\n        state.memberLabel = firstNonBlank(trimToNull(payloadString(payload, \"memberName\")),\n                trimToNull(payloadString(payload, \"memberId\")),\n                state.memberLabel,\n                \"unassigned\");\n        state.childSessionId = firstNonBlank(trimToNull(payloadString(payload, \"childSessionId\")), state.childSessionId);\n        state.heartbeatCount = intValue(payload, \"heartbeatCount\", state.heartbeatCount);\n        state.updatedAtEpochMs = longValue(payload, \"updatedAtEpochMs\", event.getTimestamp());\n        if (state.updatedAtEpochMs <= 0L) {\n            state.updatedAtEpochMs = event.getTimestamp();\n        }\n    }\n\n    private static MessageState toMessageState(SessionEvent event) {\n        Map<String, Object> payload = event.getPayload();\n        String text = firstNonBlank(trimToNull(payloadString(payload, \"content\")), trimToNull(payloadString(payload, \"detail\")));\n        String from = trimToNull(payloadString(payload, \"fromMemberId\"));\n        String to = trimToNull(payloadString(payload, \"toMemberId\"));\n        String memberLabel = firstNonBlank(from, to, \"team\");\n        MessageState state = new MessageState();\n        state.messageId = trimToNull(payloadString(payload, \"messageId\"));\n        state.memberKey = memberLabel;\n        state.memberLabel = memberLabel;\n        state.text = text;\n        state.messageType = trimToNull(payloadString(payload, \"messageType\"));\n        state.taskId = trimToNull(payloadString(payload, \"taskId\"));\n        state.fromMemberId = from;\n        state.toMemberId = to;\n        state.createdAtEpochMs = longValue(payload, \"createdAt\", event.getTimestamp());\n        if (state.createdAtEpochMs <= 0L) {\n            state.createdAtEpochMs = event.getTimestamp();\n        }\n        return state;\n    }\n\n    private static boolean isTeamTaskEvent(SessionEvent event) {\n        if (event == null) {\n            return false;\n        }\n        if (event.getType() != SessionEventType.TASK_CREATED && event.getType() != SessionEventType.TASK_UPDATED) {\n            return false;\n        }\n        Map<String, Object> payload = event.getPayload();\n        String callId = trimToNull(payloadString(payload, \"callId\"));\n        String title = firstNonBlank(trimToNull(payloadString(payload, \"title\")), trimToNull(event.getSummary()));\n        return (callId != null && callId.startsWith(\"team-task:\"))\n                || !isBlank(payloadString(payload, \"memberId\"))\n                || !isBlank(payloadString(payload, \"memberName\"))\n                || !isBlank(payloadString(payload, \"heartbeatCount\"))\n                || (title != null && title.toLowerCase(Locale.ROOT).startsWith(\"team task\"));\n    }\n\n    private static String resolveTaskId(SessionEvent event) {\n        Map<String, Object> payload = event == null ? null : event.getPayload();\n        String taskId = trimToNull(payloadString(payload, \"taskId\"));\n        if (!isBlank(taskId)) {\n            return taskId;\n        }\n        String callId = trimToNull(payloadString(payload, \"callId\"));\n        if (!isBlank(callId)) {\n            return callId;\n        }\n        return firstNonBlank(trimToNull(event == null ? null : event.getSummary()), \"team-task\");\n    }\n\n    private static String taskBadge(TaskState task) {\n        StringBuilder badge = new StringBuilder();\n        if (!isBlank(task.status)) {\n            badge.append(normalizeStatus(task.status));\n        }\n        if (!isBlank(task.phase)) {\n            if (badge.length() > 0) {\n                badge.append('/');\n            }\n            badge.append(task.phase.toLowerCase(Locale.ROOT));\n        }\n        if (!isBlank(task.percent)) {\n            if (badge.length() > 0) {\n                badge.append(' ');\n            }\n            badge.append(task.percent).append('%');\n        }\n        return badge.length() == 0 ? \"unknown\" : badge.toString();\n    }\n\n    private static String taskLabel(TaskState task) {\n        String label = firstNonBlank(trimToNull(task.task), trimToNull(task.title), trimToNull(task.taskId), \"task\");\n        if (!isBlank(task.taskId)\n                && !safeText(label).toLowerCase(Locale.ROOT).contains(task.taskId.toLowerCase(Locale.ROOT))) {\n            return label + \" (\" + task.taskId + \")\";\n        }\n        return label;\n    }\n\n    private static String messageLabel(MessageState message) {\n        StringBuilder builder = new StringBuilder();\n        if (!isBlank(message.messageType)) {\n            builder.append('[').append(message.messageType).append(']').append(' ');\n        }\n        if (!isBlank(message.fromMemberId) || !isBlank(message.toMemberId)) {\n            builder.append(firstNonBlank(message.fromMemberId, \"?\"))\n                    .append(\" -> \")\n                    .append(firstNonBlank(message.toMemberId, \"?\"));\n        } else {\n            builder.append(firstNonBlank(message.memberLabel, \"team\"));\n        }\n        if (!isBlank(message.taskId)) {\n            builder.append(\" | task=\").append(message.taskId);\n        }\n        if (!isBlank(message.text)) {\n            builder.append(\" | \").append(clip(singleLine(message.text), 76));\n        }\n        return builder.toString();\n    }\n\n    private static int lanePriority(LaneState lane) {\n        if (lane == null || lane.tasks.isEmpty()) {\n            return 3;\n        }\n        int best = Integer.MAX_VALUE;\n        for (TaskState task : lane.tasks) {\n            best = Math.min(best, taskPriority(task));\n        }\n        return best;\n    }\n\n    private static int taskPriority(TaskState task) {\n        String normalized = normalizeStatus(task == null ? null : task.status);\n        if (\"running\".equals(normalized) || \"in_progress\".equals(normalized)) {\n            return 0;\n        }\n        if (\"ready\".equals(normalized) || \"pending\".equals(normalized)) {\n            return 1;\n        }\n        if (\"failed\".equals(normalized) || \"blocked\".equals(normalized)) {\n            return 2;\n        }\n        if (\"completed\".equals(normalized)) {\n            return 3;\n        }\n        return 4;\n    }\n\n    private static int intValue(Map<String, Object> payload, String key, int fallback) {\n        String value = trimToNull(payloadString(payload, key));\n        if (value == null) {\n            return fallback;\n        }\n        try {\n            return Integer.parseInt(value);\n        } catch (NumberFormatException ignored) {\n            return fallback;\n        }\n    }\n\n    private static long longValue(Map<String, Object> payload, String key, long fallback) {\n        String value = trimToNull(payloadString(payload, key));\n        if (value == null) {\n            return fallback;\n        }\n        try {\n            return Long.parseLong(value);\n        } catch (NumberFormatException ignored) {\n            return fallback;\n        }\n    }\n\n    private static String payloadString(Map<String, Object> payload, String key) {\n        if (payload == null || key == null) {\n            return null;\n        }\n        Object value = payload.get(key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static String taskMemberId(AgentTeamTask task) {\n        return task == null ? null : task.getMemberId();\n    }\n\n    private static long firstPositive(long... values) {\n        if (values == null) {\n            return 0L;\n        }\n        for (long value : values) {\n            if (value > 0L) {\n                return value;\n            }\n        }\n        return 0L;\n    }\n\n    private static Map<String, String> memberLabelMap(List<AgentTeamMemberSnapshot> members) {\n        if (members == null || members.isEmpty()) {\n            return Collections.emptyMap();\n        }\n        Map<String, String> labels = new LinkedHashMap<String, String>();\n        for (AgentTeamMemberSnapshot member : members) {\n            if (member == null || isBlank(member.getId())) {\n                continue;\n            }\n            labels.put(member.getId(), firstNonBlank(trimToNull(member.getName()), trimToNull(member.getId())));\n        }\n        return labels;\n    }\n\n    private static String singleLine(String value) {\n        if (value == null) {\n            return null;\n        }\n        return value.replace('\\r', ' ').replace('\\n', ' ').trim();\n    }\n\n    private static String clip(String value, int maxChars) {\n        String normalized = trimToNull(value);\n        if (normalized == null || normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, Math.max(0, maxChars)) + \"...\";\n    }\n\n    private static String normalizeStatus(String value) {\n        return value == null ? null : value.trim().toLowerCase(Locale.ROOT);\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static String safeText(String value) {\n        return value == null ? \"\" : value;\n    }\n\n    private static final class Aggregation {\n        private final LinkedHashMap<String, TaskState> tasksById = new LinkedHashMap<String, TaskState>();\n        private final List<MessageState> messages = new ArrayList<MessageState>();\n        private int nextTaskOrder;\n        private int runningCount;\n        private int completedCount;\n        private int failedCount;\n        private int blockedCount;\n    }\n\n    private static final class LaneState {\n        private String key;\n        private String label;\n        private List<TaskState> tasks = new ArrayList<TaskState>();\n        private List<MessageState> messages = new ArrayList<MessageState>();\n    }\n\n    private static final class TaskState {\n        private int order;\n        private String taskId;\n        private String title;\n        private String task;\n        private String status;\n        private String phase;\n        private String detail;\n        private String percent;\n        private String memberKey;\n        private String memberLabel;\n        private String childSessionId;\n        private int heartbeatCount;\n        private long updatedAtEpochMs;\n    }\n\n    private static final class MessageState {\n        private String messageId;\n        private String memberKey;\n        private String memberLabel;\n        private String fromMemberId;\n        private String toMemberId;\n        private String messageType;\n        private String taskId;\n        private String text;\n        private long createdAtEpochMs;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/CodingSessionManager.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\n\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.List;\n\npublic interface CodingSessionManager {\n\n    ManagedCodingSession create(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options) throws Exception;\n\n    ManagedCodingSession resume(CodingAgent agent, CliProtocol protocol, CodeCommandOptions options, String sessionId) throws Exception;\n\n    ManagedCodingSession fork(CodingAgent agent,\n                              CliProtocol protocol,\n                              CodeCommandOptions options,\n                              String sourceSessionId,\n                              String targetSessionId) throws Exception;\n\n    StoredCodingSession save(ManagedCodingSession session) throws IOException;\n\n    StoredCodingSession load(String sessionId) throws IOException;\n\n    List<CodingSessionDescriptor> list() throws IOException;\n\n    void delete(String sessionId) throws IOException;\n\n    SessionEvent appendEvent(String sessionId, SessionEvent event) throws IOException;\n\n    List<SessionEvent> listEvents(String sessionId, Integer limit, Long offset) throws IOException;\n\n    Path getDirectory();\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/CodingSessionStore.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.List;\n\npublic interface CodingSessionStore {\n\n    StoredCodingSession save(StoredCodingSession session) throws IOException;\n\n    StoredCodingSession load(String sessionId) throws IOException;\n\n    List<StoredCodingSession> list() throws IOException;\n\n    Path getDirectory();\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/DefaultCodingSessionManager.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\n\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSessionSnapshot;\nimport io.github.lnyocly.ai4j.coding.CodingSessionState;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.UUID;\n\npublic class DefaultCodingSessionManager implements CodingSessionManager {\n\n    private final CodingSessionStore sessionStore;\n    private final SessionEventStore eventStore;\n\n    public DefaultCodingSessionManager(CodingSessionStore sessionStore, SessionEventStore eventStore) {\n        this.sessionStore = sessionStore;\n        this.eventStore = eventStore;\n    }\n\n    @Override\n    public ManagedCodingSession create(io.github.lnyocly.ai4j.coding.CodingAgent agent,\n                                       CliProtocol protocol,\n                                       CodeCommandOptions options) throws Exception {\n        String sessionId = isBlank(options.getSessionId()) ? null : options.getSessionId();\n        CodingSession session = sessionId == null ? agent.newSession() : agent.newSession(sessionId, null);\n        long now = System.currentTimeMillis();\n        ManagedCodingSession managed = new ManagedCodingSession(\n                session,\n                options.getProvider().getPlatform(),\n                protocol.getValue(),\n                options.getModel(),\n                options.getWorkspace(),\n                options.getWorkspaceDescription(),\n                options.getSystemPrompt(),\n                options.getInstructions(),\n                session.getSessionId(),\n                null,\n                now,\n                now\n        );\n        appendEvent(managed.getSessionId(), baseEvent(managed.getSessionId(), SessionEventType.SESSION_CREATED, \"session created\", null));\n        return managed;\n    }\n\n    @Override\n    public ManagedCodingSession resume(io.github.lnyocly.ai4j.coding.CodingAgent agent,\n                                       CliProtocol protocol,\n                                       CodeCommandOptions options,\n                                       String sessionId) throws Exception {\n        StoredCodingSession storedSession = load(sessionId);\n        if (storedSession == null) {\n            throw new IllegalArgumentException(\"Saved session not found: \" + sessionId);\n        }\n        assertWorkspaceMatches(storedSession, options.getWorkspace());\n        CodingSessionState state = storedSession.getState();\n        CodingSession session = agent.newSession(storedSession.getSessionId(), state);\n        ManagedCodingSession managed = new ManagedCodingSession(\n                session,\n                storedSession.getProvider(),\n                storedSession.getProtocol(),\n                storedSession.getModel(),\n                storedSession.getWorkspace(),\n                storedSession.getWorkspaceDescription(),\n                storedSession.getSystemPrompt(),\n                storedSession.getInstructions(),\n                normalizeRootSessionId(storedSession),\n                storedSession.getParentSessionId(),\n                storedSession.getCreatedAtEpochMs(),\n                storedSession.getUpdatedAtEpochMs()\n        );\n        appendEvent(managed.getSessionId(), baseEvent(managed.getSessionId(), SessionEventType.SESSION_RESUMED, \"session resumed\", null));\n        return managed;\n    }\n\n    @Override\n    public ManagedCodingSession fork(io.github.lnyocly.ai4j.coding.CodingAgent agent,\n                                     CliProtocol protocol,\n                                     CodeCommandOptions options,\n                                     String sourceSessionId,\n                                     String targetSessionId) throws Exception {\n        StoredCodingSession source = load(sourceSessionId);\n        if (source == null) {\n            throw new IllegalArgumentException(\"Saved session not found: \" + sourceSessionId);\n        }\n        assertWorkspaceMatches(source, options.getWorkspace());\n        String forkSessionId = isBlank(targetSessionId)\n                ? source.getSessionId() + \"-fork-\" + UUID.randomUUID().toString().substring(0, 8).toLowerCase(Locale.ROOT)\n                : targetSessionId;\n        CodingSession session = agent.newSession(forkSessionId, source.getState());\n        long now = System.currentTimeMillis();\n        ManagedCodingSession managed = new ManagedCodingSession(\n                session,\n                source.getProvider(),\n                source.getProtocol(),\n                source.getModel(),\n                source.getWorkspace(),\n                source.getWorkspaceDescription(),\n                source.getSystemPrompt(),\n                source.getInstructions(),\n                normalizeRootSessionId(source),\n                source.getSessionId(),\n                now,\n                now\n        );\n        appendEvent(managed.getSessionId(), baseEvent(\n                managed.getSessionId(),\n                SessionEventType.SESSION_FORKED,\n                \"session forked from \" + source.getSessionId(),\n                java.util.Collections.<String, Object>singletonMap(\"sourceSessionId\", source.getSessionId())\n        ));\n        return managed;\n    }\n\n    @Override\n    public StoredCodingSession save(ManagedCodingSession managedSession) throws IOException {\n        if (managedSession == null || managedSession.getSession() == null) {\n            throw new IllegalArgumentException(\"managed session is required\");\n        }\n        CodingSessionSnapshot snapshot = managedSession.getSession().snapshot();\n        StoredCodingSession stored = sessionStore.save(StoredCodingSession.builder()\n                .sessionId(managedSession.getSessionId())\n                .rootSessionId(isBlank(managedSession.getRootSessionId()) ? managedSession.getSessionId() : managedSession.getRootSessionId())\n                .parentSessionId(managedSession.getParentSessionId())\n                .provider(managedSession.getProvider())\n                .protocol(managedSession.getProtocol())\n                .model(managedSession.getModel())\n                .workspace(managedSession.getWorkspace())\n                .workspaceDescription(managedSession.getWorkspaceDescription())\n                .systemPrompt(managedSession.getSystemPrompt())\n                .instructions(managedSession.getInstructions())\n                .summary(snapshot.getSummary())\n                .memoryItemCount(snapshot.getMemoryItemCount())\n                .processCount(snapshot.getProcessCount())\n                .activeProcessCount(snapshot.getActiveProcessCount())\n                .restoredProcessCount(snapshot.getRestoredProcessCount())\n                .createdAtEpochMs(managedSession.getCreatedAtEpochMs())\n                .updatedAtEpochMs(System.currentTimeMillis())\n                .state(managedSession.getSession().exportState())\n                .build());\n        managedSession.touch(stored.getUpdatedAtEpochMs());\n        appendEvent(managedSession.getSessionId(), baseEvent(managedSession.getSessionId(), SessionEventType.SESSION_SAVED, \"session saved\", null));\n        return stored;\n    }\n\n    @Override\n    public StoredCodingSession load(String sessionId) throws IOException {\n        StoredCodingSession storedSession = sessionStore.load(sessionId);\n        if (storedSession == null) {\n            return null;\n        }\n        if (isBlank(storedSession.getRootSessionId())) {\n            storedSession.setRootSessionId(storedSession.getSessionId());\n        }\n        return storedSession;\n    }\n\n    @Override\n    public List<CodingSessionDescriptor> list() throws IOException {\n        List<StoredCodingSession> sessions = sessionStore.list();\n        List<CodingSessionDescriptor> descriptors = new ArrayList<CodingSessionDescriptor>();\n        for (StoredCodingSession session : sessions) {\n            descriptors.add(CodingSessionDescriptor.builder()\n                    .sessionId(session.getSessionId())\n                    .rootSessionId(normalizeRootSessionId(session))\n                    .parentSessionId(session.getParentSessionId())\n                    .provider(session.getProvider())\n                    .protocol(session.getProtocol())\n                    .model(session.getModel())\n                    .workspace(session.getWorkspace())\n                    .summary(session.getSummary())\n                    .memoryItemCount(session.getMemoryItemCount())\n                    .processCount(session.getProcessCount())\n                    .activeProcessCount(session.getActiveProcessCount())\n                    .restoredProcessCount(session.getRestoredProcessCount())\n                    .createdAtEpochMs(session.getCreatedAtEpochMs())\n                    .updatedAtEpochMs(session.getUpdatedAtEpochMs())\n                    .build());\n        }\n        return descriptors;\n    }\n\n    @Override\n    public void delete(String sessionId) throws IOException {\n        if (isBlank(sessionId)) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        Path file = sessionStore.getDirectory().resolve(sessionId.replaceAll(\"[^a-zA-Z0-9._-]\", \"_\") + \".json\");\n        java.nio.file.Files.deleteIfExists(file);\n        if (eventStore != null) {\n            eventStore.delete(sessionId);\n        }\n    }\n\n    @Override\n    public SessionEvent appendEvent(String sessionId, SessionEvent event) throws IOException {\n        if (eventStore == null) {\n            return event;\n        }\n        SessionEvent normalized = event == null ? null : event.toBuilder()\n                .eventId(isBlank(event.getEventId()) ? UUID.randomUUID().toString() : event.getEventId())\n                .sessionId(isBlank(event.getSessionId()) ? sessionId : event.getSessionId())\n                .timestamp(event.getTimestamp() <= 0 ? System.currentTimeMillis() : event.getTimestamp())\n                .build();\n        return eventStore.append(normalized);\n    }\n\n    @Override\n    public List<SessionEvent> listEvents(String sessionId, Integer limit, Long offset) throws IOException {\n        if (eventStore == null) {\n            return new ArrayList<SessionEvent>();\n        }\n        return eventStore.list(sessionId, limit, offset);\n    }\n\n    @Override\n    public Path getDirectory() {\n        return sessionStore.getDirectory();\n    }\n\n    public CodingSessionDescriptor describe(ManagedCodingSession session) {\n        return session == null ? null : session.toDescriptor();\n    }\n\n    private SessionEvent baseEvent(String sessionId, SessionEventType type, String summary, java.util.Map<String, Object> payload) {\n        return SessionEvent.builder()\n                .sessionId(sessionId)\n                .type(type)\n                .summary(summary)\n                .payload(payload)\n                .build();\n    }\n\n    private String normalizeRootSessionId(StoredCodingSession session) {\n        return session == null || isBlank(session.getRootSessionId()) ? (session == null ? null : session.getSessionId()) : session.getRootSessionId();\n    }\n\n    private void assertWorkspaceMatches(StoredCodingSession storedSession, String workspace) {\n        if (storedSession == null || isBlank(storedSession.getWorkspace()) || isBlank(workspace)) {\n            return;\n        }\n        if (!storedSession.getWorkspace().equals(workspace)) {\n            throw new IllegalArgumentException(\n                    \"Saved session \" + storedSession.getSessionId() + \" belongs to workspace \" + storedSession.getWorkspace()\n                            + \", current workspace is \" + workspace\n            );\n        }\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/FileCodingSessionStore.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONWriter;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.DirectoryStream;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.List;\n\npublic class FileCodingSessionStore implements CodingSessionStore {\n\n    private final Path directory;\n\n    public FileCodingSessionStore(Path directory) {\n        this.directory = directory;\n    }\n\n    @Override\n    public StoredCodingSession save(StoredCodingSession session) throws IOException {\n        if (session == null || isBlank(session.getSessionId())) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n\n        Files.createDirectories(directory);\n        Path file = resolveFile(session.getSessionId());\n        long now = System.currentTimeMillis();\n        StoredCodingSession existing = Files.exists(file) ? load(session.getSessionId()) : null;\n\n        StoredCodingSession toPersist = session.toBuilder()\n                .createdAtEpochMs(existing == null || existing.getCreatedAtEpochMs() <= 0 ? now : existing.getCreatedAtEpochMs())\n                .updatedAtEpochMs(now)\n                .rootSessionId(firstNonBlank(\n                        session.getRootSessionId(),\n                        existing == null ? null : existing.getRootSessionId(),\n                        session.getSessionId()\n                ))\n                .parentSessionId(firstNonBlank(\n                        session.getParentSessionId(),\n                        existing == null ? null : existing.getParentSessionId()\n                ))\n                .storePath(file.toAbsolutePath().normalize().toString())\n                .build();\n\n        String json = JSON.toJSONString(toPersist, JSONWriter.Feature.PrettyFormat);\n        Files.write(file, json.getBytes(StandardCharsets.UTF_8));\n        return toPersist;\n    }\n\n    @Override\n    public StoredCodingSession load(String sessionId) throws IOException {\n        if (isBlank(sessionId)) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        Path file = resolveFile(sessionId);\n        if (!Files.exists(file)) {\n            return null;\n        }\n        StoredCodingSession session = JSON.parseObject(\n                Files.readAllBytes(file),\n                StoredCodingSession.class\n        );\n        if (session == null) {\n            return null;\n        }\n        return normalize(session, file);\n    }\n\n    @Override\n    public List<StoredCodingSession> list() throws IOException {\n        if (!Files.exists(directory)) {\n            return Collections.emptyList();\n        }\n        List<StoredCodingSession> sessions = new ArrayList<StoredCodingSession>();\n        try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, \"*.json\")) {\n            for (Path file : stream) {\n                StoredCodingSession session = JSON.parseObject(\n                        Files.readAllBytes(file),\n                        StoredCodingSession.class\n                );\n                if (session != null) {\n                    sessions.add(normalize(session, file));\n                }\n            }\n        }\n        Collections.sort(sessions, Comparator.comparingLong(StoredCodingSession::getUpdatedAtEpochMs).reversed());\n        return sessions;\n    }\n\n    @Override\n    public Path getDirectory() {\n        return directory;\n    }\n\n    private Path resolveFile(String sessionId) {\n        return directory.resolve(sanitizeSessionId(sessionId) + \".json\");\n    }\n\n    private String sanitizeSessionId(String sessionId) {\n        return sessionId.replaceAll(\"[^a-zA-Z0-9._-]\", \"_\");\n    }\n\n    private StoredCodingSession normalize(StoredCodingSession session, Path file) {\n        session.setStorePath(file.toAbsolutePath().normalize().toString());\n        if (isBlank(session.getRootSessionId())) {\n            session.setRootSessionId(session.getSessionId());\n        }\n        return session;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/FileSessionEventStore.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class FileSessionEventStore implements SessionEventStore {\n\n    private final Path directory;\n\n    public FileSessionEventStore(Path directory) {\n        this.directory = directory;\n    }\n\n    @Override\n    public SessionEvent append(SessionEvent event) throws IOException {\n        if (event == null || isBlank(event.getSessionId())) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        Files.createDirectories(directory);\n        Path file = resolveFile(event.getSessionId());\n        String line = JSON.toJSONString(event) + System.lineSeparator();\n        Files.write(file, line.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND);\n        return event;\n    }\n\n    @Override\n    public List<SessionEvent> list(String sessionId, Integer limit, Long offset) throws IOException {\n        if (isBlank(sessionId)) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        Path file = resolveFile(sessionId);\n        if (!Files.exists(file)) {\n            return Collections.emptyList();\n        }\n        List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);\n        List<SessionEvent> events = new ArrayList<SessionEvent>();\n        for (String line : lines) {\n            if (!isBlank(line)) {\n                SessionEvent event = JSON.parseObject(line, SessionEvent.class);\n                if (event != null) {\n                    events.add(event);\n                }\n            }\n        }\n        if (events.isEmpty()) {\n            return events;\n        }\n        long safeOffset = offset == null ? -1L : Math.max(0L, offset.longValue());\n        int safeLimit = limit == null || limit <= 0 ? events.size() : limit.intValue();\n        int from;\n        if (safeOffset >= 0L) {\n            from = (int) Math.min(events.size(), safeOffset);\n        } else {\n            from = Math.max(0, events.size() - safeLimit);\n        }\n        int to = Math.min(events.size(), from + safeLimit);\n        return new ArrayList<SessionEvent>(events.subList(from, to));\n    }\n\n    @Override\n    public void delete(String sessionId) throws IOException {\n        if (isBlank(sessionId)) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        Files.deleteIfExists(resolveFile(sessionId));\n    }\n\n    @Override\n    public Path getDirectory() {\n        return directory;\n    }\n\n    private Path resolveFile(String sessionId) {\n        return directory.resolve(sessionId.replaceAll(\"[^a-zA-Z0-9._-]\", \"_\") + \".jsonl\");\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/InMemoryCodingSessionStore.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class InMemoryCodingSessionStore implements CodingSessionStore {\n\n    private final Path directory;\n    private final Map<String, StoredCodingSession> sessions = new LinkedHashMap<String, StoredCodingSession>();\n\n    public InMemoryCodingSessionStore(Path directory) {\n        this.directory = directory;\n    }\n\n    @Override\n    public synchronized StoredCodingSession save(StoredCodingSession session) throws IOException {\n        if (session == null || isBlank(session.getSessionId())) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        long now = System.currentTimeMillis();\n        StoredCodingSession existing = sessions.get(session.getSessionId());\n        StoredCodingSession stored = session.toBuilder()\n                .createdAtEpochMs(existing == null || existing.getCreatedAtEpochMs() <= 0 ? now : existing.getCreatedAtEpochMs())\n                .updatedAtEpochMs(now)\n                .rootSessionId(firstNonBlank(\n                        session.getRootSessionId(),\n                        existing == null ? null : existing.getRootSessionId(),\n                        session.getSessionId()\n                ))\n                .parentSessionId(firstNonBlank(\n                        session.getParentSessionId(),\n                        existing == null ? null : existing.getParentSessionId()\n                ))\n                .storePath(\"memory://\" + session.getSessionId())\n                .build();\n        sessions.put(stored.getSessionId(), stored);\n        return stored;\n    }\n\n    @Override\n    public synchronized StoredCodingSession load(String sessionId) throws IOException {\n        if (isBlank(sessionId)) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        StoredCodingSession stored = sessions.get(sessionId);\n        if (stored == null) {\n            return null;\n        }\n        return normalize(stored);\n    }\n\n    @Override\n    public synchronized List<StoredCodingSession> list() throws IOException {\n        if (sessions.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<StoredCodingSession> copy = new ArrayList<StoredCodingSession>();\n        for (StoredCodingSession session : sessions.values()) {\n            copy.add(normalize(session));\n        }\n        Collections.sort(copy, Comparator.comparingLong(StoredCodingSession::getUpdatedAtEpochMs).reversed());\n        return copy;\n    }\n\n    @Override\n    public Path getDirectory() {\n        return directory;\n    }\n\n    private StoredCodingSession normalize(StoredCodingSession session) {\n        StoredCodingSession copy = session.toBuilder().build();\n        if (isBlank(copy.getRootSessionId())) {\n            copy.setRootSessionId(copy.getSessionId());\n        }\n        if (isBlank(copy.getStorePath())) {\n            copy.setStorePath(\"memory://\" + copy.getSessionId());\n        }\n        return copy;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/InMemorySessionEventStore.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class InMemorySessionEventStore implements SessionEventStore {\n\n    private final Path directory = Paths.get(\"(memory-events)\");\n    private final Map<String, List<SessionEvent>> events = new LinkedHashMap<String, List<SessionEvent>>();\n\n    @Override\n    public synchronized SessionEvent append(SessionEvent event) throws IOException {\n        if (event == null || isBlank(event.getSessionId())) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        List<SessionEvent> sessionEvents = events.get(event.getSessionId());\n        if (sessionEvents == null) {\n            sessionEvents = new ArrayList<SessionEvent>();\n            events.put(event.getSessionId(), sessionEvents);\n        }\n        sessionEvents.add(event);\n        return event;\n    }\n\n    @Override\n    public synchronized List<SessionEvent> list(String sessionId, Integer limit, Long offset) throws IOException {\n        if (isBlank(sessionId)) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        List<SessionEvent> sessionEvents = events.get(sessionId);\n        if (sessionEvents == null || sessionEvents.isEmpty()) {\n            return new ArrayList<SessionEvent>();\n        }\n        int safeLimit = limit == null || limit <= 0 ? sessionEvents.size() : limit.intValue();\n        int from;\n        if (offset != null && offset.longValue() > 0) {\n            from = (int) Math.min(sessionEvents.size(), offset.longValue());\n        } else {\n            from = Math.max(0, sessionEvents.size() - safeLimit);\n        }\n        int to = Math.min(sessionEvents.size(), from + safeLimit);\n        return new ArrayList<SessionEvent>(sessionEvents.subList(from, to));\n    }\n\n    @Override\n    public synchronized void delete(String sessionId) throws IOException {\n        if (isBlank(sessionId)) {\n            throw new IllegalArgumentException(\"sessionId is required\");\n        }\n        events.remove(sessionId);\n    }\n\n    @Override\n    public Path getDirectory() {\n        return directory;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/SessionEventStore.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.List;\n\npublic interface SessionEventStore {\n\n    SessionEvent append(SessionEvent event) throws IOException;\n\n    List<SessionEvent> list(String sessionId, Integer limit, Long offset) throws IOException;\n\n    void delete(String sessionId) throws IOException;\n\n    Path getDirectory();\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/session/StoredCodingSession.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport io.github.lnyocly.ai4j.coding.CodingSessionState;\n\npublic class StoredCodingSession {\n\n    private String sessionId;\n    private String rootSessionId;\n    private String parentSessionId;\n    private String provider;\n    private String protocol;\n    private String model;\n    private String workspace;\n    private String workspaceDescription;\n    private String systemPrompt;\n    private String instructions;\n    private String summary;\n    private int memoryItemCount;\n    private int processCount;\n    private int activeProcessCount;\n    private int restoredProcessCount;\n    private long createdAtEpochMs;\n    private long updatedAtEpochMs;\n    private String storePath;\n    private CodingSessionState state;\n\n    public StoredCodingSession() {\n    }\n\n    public StoredCodingSession(String sessionId,\n                               String rootSessionId,\n                               String parentSessionId,\n                               String provider,\n                               String protocol,\n                               String model,\n                               String workspace,\n                               String workspaceDescription,\n                               String systemPrompt,\n                               String instructions,\n                               String summary,\n                               int memoryItemCount,\n                               int processCount,\n                               int activeProcessCount,\n                               int restoredProcessCount,\n                               long createdAtEpochMs,\n                               long updatedAtEpochMs,\n                               String storePath,\n                               CodingSessionState state) {\n        this.sessionId = sessionId;\n        this.rootSessionId = rootSessionId;\n        this.parentSessionId = parentSessionId;\n        this.provider = provider;\n        this.protocol = protocol;\n        this.model = model;\n        this.workspace = workspace;\n        this.workspaceDescription = workspaceDescription;\n        this.systemPrompt = systemPrompt;\n        this.instructions = instructions;\n        this.summary = summary;\n        this.memoryItemCount = memoryItemCount;\n        this.processCount = processCount;\n        this.activeProcessCount = activeProcessCount;\n        this.restoredProcessCount = restoredProcessCount;\n        this.createdAtEpochMs = createdAtEpochMs;\n        this.updatedAtEpochMs = updatedAtEpochMs;\n        this.storePath = storePath;\n        this.state = state;\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public Builder toBuilder() {\n        return new Builder()\n                .sessionId(sessionId)\n                .rootSessionId(rootSessionId)\n                .parentSessionId(parentSessionId)\n                .provider(provider)\n                .protocol(protocol)\n                .model(model)\n                .workspace(workspace)\n                .workspaceDescription(workspaceDescription)\n                .systemPrompt(systemPrompt)\n                .instructions(instructions)\n                .summary(summary)\n                .memoryItemCount(memoryItemCount)\n                .processCount(processCount)\n                .activeProcessCount(activeProcessCount)\n                .restoredProcessCount(restoredProcessCount)\n                .createdAtEpochMs(createdAtEpochMs)\n                .updatedAtEpochMs(updatedAtEpochMs)\n                .storePath(storePath)\n                .state(state);\n    }\n\n    public String getSessionId() {\n        return sessionId;\n    }\n\n    public void setSessionId(String sessionId) {\n        this.sessionId = sessionId;\n    }\n\n    public String getRootSessionId() {\n        return rootSessionId;\n    }\n\n    public void setRootSessionId(String rootSessionId) {\n        this.rootSessionId = rootSessionId;\n    }\n\n    public String getParentSessionId() {\n        return parentSessionId;\n    }\n\n    public void setParentSessionId(String parentSessionId) {\n        this.parentSessionId = parentSessionId;\n    }\n\n    public String getProvider() {\n        return provider;\n    }\n\n    public void setProvider(String provider) {\n        this.provider = provider;\n    }\n\n    public String getProtocol() {\n        return protocol;\n    }\n\n    public void setProtocol(String protocol) {\n        this.protocol = protocol;\n    }\n\n    public String getModel() {\n        return model;\n    }\n\n    public void setModel(String model) {\n        this.model = model;\n    }\n\n    public String getWorkspace() {\n        return workspace;\n    }\n\n    public void setWorkspace(String workspace) {\n        this.workspace = workspace;\n    }\n\n    public String getWorkspaceDescription() {\n        return workspaceDescription;\n    }\n\n    public void setWorkspaceDescription(String workspaceDescription) {\n        this.workspaceDescription = workspaceDescription;\n    }\n\n    public String getSystemPrompt() {\n        return systemPrompt;\n    }\n\n    public void setSystemPrompt(String systemPrompt) {\n        this.systemPrompt = systemPrompt;\n    }\n\n    public String getInstructions() {\n        return instructions;\n    }\n\n    public void setInstructions(String instructions) {\n        this.instructions = instructions;\n    }\n\n    public String getSummary() {\n        return summary;\n    }\n\n    public void setSummary(String summary) {\n        this.summary = summary;\n    }\n\n    public int getMemoryItemCount() {\n        return memoryItemCount;\n    }\n\n    public void setMemoryItemCount(int memoryItemCount) {\n        this.memoryItemCount = memoryItemCount;\n    }\n\n    public int getProcessCount() {\n        return processCount;\n    }\n\n    public void setProcessCount(int processCount) {\n        this.processCount = processCount;\n    }\n\n    public int getActiveProcessCount() {\n        return activeProcessCount;\n    }\n\n    public void setActiveProcessCount(int activeProcessCount) {\n        this.activeProcessCount = activeProcessCount;\n    }\n\n    public int getRestoredProcessCount() {\n        return restoredProcessCount;\n    }\n\n    public void setRestoredProcessCount(int restoredProcessCount) {\n        this.restoredProcessCount = restoredProcessCount;\n    }\n\n    public long getCreatedAtEpochMs() {\n        return createdAtEpochMs;\n    }\n\n    public void setCreatedAtEpochMs(long createdAtEpochMs) {\n        this.createdAtEpochMs = createdAtEpochMs;\n    }\n\n    public long getUpdatedAtEpochMs() {\n        return updatedAtEpochMs;\n    }\n\n    public void setUpdatedAtEpochMs(long updatedAtEpochMs) {\n        this.updatedAtEpochMs = updatedAtEpochMs;\n    }\n\n    public String getStorePath() {\n        return storePath;\n    }\n\n    public void setStorePath(String storePath) {\n        this.storePath = storePath;\n    }\n\n    public CodingSessionState getState() {\n        return state;\n    }\n\n    public void setState(CodingSessionState state) {\n        this.state = state;\n    }\n\n    public static final class Builder {\n\n        private String sessionId;\n        private String rootSessionId;\n        private String parentSessionId;\n        private String provider;\n        private String protocol;\n        private String model;\n        private String workspace;\n        private String workspaceDescription;\n        private String systemPrompt;\n        private String instructions;\n        private String summary;\n        private int memoryItemCount;\n        private int processCount;\n        private int activeProcessCount;\n        private int restoredProcessCount;\n        private long createdAtEpochMs;\n        private long updatedAtEpochMs;\n        private String storePath;\n        private CodingSessionState state;\n\n        private Builder() {\n        }\n\n        public Builder sessionId(String sessionId) {\n            this.sessionId = sessionId;\n            return this;\n        }\n\n        public Builder rootSessionId(String rootSessionId) {\n            this.rootSessionId = rootSessionId;\n            return this;\n        }\n\n        public Builder parentSessionId(String parentSessionId) {\n            this.parentSessionId = parentSessionId;\n            return this;\n        }\n\n        public Builder provider(String provider) {\n            this.provider = provider;\n            return this;\n        }\n\n        public Builder protocol(String protocol) {\n            this.protocol = protocol;\n            return this;\n        }\n\n        public Builder model(String model) {\n            this.model = model;\n            return this;\n        }\n\n        public Builder workspace(String workspace) {\n            this.workspace = workspace;\n            return this;\n        }\n\n        public Builder workspaceDescription(String workspaceDescription) {\n            this.workspaceDescription = workspaceDescription;\n            return this;\n        }\n\n        public Builder systemPrompt(String systemPrompt) {\n            this.systemPrompt = systemPrompt;\n            return this;\n        }\n\n        public Builder instructions(String instructions) {\n            this.instructions = instructions;\n            return this;\n        }\n\n        public Builder summary(String summary) {\n            this.summary = summary;\n            return this;\n        }\n\n        public Builder memoryItemCount(int memoryItemCount) {\n            this.memoryItemCount = memoryItemCount;\n            return this;\n        }\n\n        public Builder processCount(int processCount) {\n            this.processCount = processCount;\n            return this;\n        }\n\n        public Builder activeProcessCount(int activeProcessCount) {\n            this.activeProcessCount = activeProcessCount;\n            return this;\n        }\n\n        public Builder restoredProcessCount(int restoredProcessCount) {\n            this.restoredProcessCount = restoredProcessCount;\n            return this;\n        }\n\n        public Builder createdAtEpochMs(long createdAtEpochMs) {\n            this.createdAtEpochMs = createdAtEpochMs;\n            return this;\n        }\n\n        public Builder updatedAtEpochMs(long updatedAtEpochMs) {\n            this.updatedAtEpochMs = updatedAtEpochMs;\n            return this;\n        }\n\n        public Builder storePath(String storePath) {\n            this.storePath = storePath;\n            return this;\n        }\n\n        public Builder state(CodingSessionState state) {\n            this.state = state;\n            return this;\n        }\n\n        public StoredCodingSession build() {\n            return new StoredCodingSession(\n                    sessionId,\n                    rootSessionId,\n                    parentSessionId,\n                    provider,\n                    protocol,\n                    model,\n                    workspace,\n                    workspaceDescription,\n                    systemPrompt,\n                    instructions,\n                    summary,\n                    memoryItemCount,\n                    processCount,\n                    activeProcessCount,\n                    restoredProcessCount,\n                    createdAtEpochMs,\n                    updatedAtEpochMs,\n                    storePath,\n                    state\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/shell/JlineCodeCommandRunner.java",
    "content": "package io.github.lnyocly.ai4j.cli.shell;\n\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.SlashCommandController;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\nimport io.github.lnyocly.ai4j.cli.runtime.CodingCliSessionRunner;\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\n\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\n\npublic final class JlineCodeCommandRunner {\n\n    private final CodingAgent agent;\n    private final CliProtocol protocol;\n    private final CliMcpRuntimeManager mcpRuntimeManager;\n    private final CodeCommandOptions options;\n    private final TerminalIO terminal;\n    private final CodingSessionManager sessionManager;\n    private final TuiInteractionState interactionState;\n    private final JlineShellContext shellContext;\n    private final SlashCommandController slashCommandController;\n    private final CodingCliAgentFactory agentFactory;\n    private final java.util.Map<String, String> env;\n    private final java.util.Properties properties;\n\n    public JlineCodeCommandRunner(CodingAgent agent,\n                                  CliProtocol protocol,\n                                  CliMcpRuntimeManager mcpRuntimeManager,\n                                  CodeCommandOptions options,\n                                  TerminalIO terminal,\n                                  CodingSessionManager sessionManager,\n                                  TuiInteractionState interactionState,\n                                  JlineShellContext shellContext,\n                                  SlashCommandController slashCommandController,\n                                  CodingCliAgentFactory agentFactory,\n                                  java.util.Map<String, String> env,\n                                  java.util.Properties properties) {\n        this.agent = agent;\n        this.protocol = protocol;\n        this.mcpRuntimeManager = mcpRuntimeManager;\n        this.options = options;\n        this.terminal = terminal;\n        this.sessionManager = sessionManager;\n        this.interactionState = interactionState;\n        this.shellContext = shellContext;\n        this.slashCommandController = slashCommandController;\n        this.agentFactory = agentFactory;\n        this.env = env;\n        this.properties = properties;\n    }\n\n    public int runCommand() throws Exception {\n        try {\n            if (slashCommandController != null) {\n                slashCommandController.setSessionManager(sessionManager);\n            }\n            if (terminal instanceof JlineShellTerminalIO) {\n                ((JlineShellTerminalIO) terminal).updateSessionContext(null, options.getModel(), options.getWorkspace());\n                ((JlineShellTerminalIO) terminal).showIdle(\"Enter a prompt or /command\");\n            }\n            CodingCliSessionRunner runner = new CodingCliSessionRunner(\n                    agent,\n                    protocol,\n                    options,\n                    terminal,\n                    sessionManager,\n                    interactionState,\n                    null,\n                    slashCommandController,\n                    agentFactory,\n                    env,\n                    properties\n            );\n            runner.setMcpRuntimeManager(mcpRuntimeManager);\n            return runner.run();\n        } finally {\n            if (terminal != null) {\n                terminal.close();\n            }\n            if (shellContext != null) {\n                shellContext.close();\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/shell/JlineShellContext.java",
    "content": "package io.github.lnyocly.ai4j.cli.shell;\n\nimport io.github.lnyocly.ai4j.cli.SlashCommandController;\n\nimport org.jline.reader.LineReader;\nimport org.jline.reader.LineReaderBuilder;\nimport org.jline.reader.impl.DefaultParser;\nimport org.jline.terminal.Terminal;\nimport org.jline.terminal.TerminalBuilder;\nimport org.jline.utils.Status;\n\nimport java.io.IOException;\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\n\npublic final class JlineShellContext implements AutoCloseable {\n\n    private final Terminal terminal;\n    private final LineReader lineReader;\n    private final Status status;\n\n    private JlineShellContext(Terminal terminal, LineReader lineReader, Status status) {\n        this.terminal = terminal;\n        this.lineReader = lineReader;\n        this.status = status;\n    }\n\n    public static JlineShellContext openSystem(SlashCommandController slashCommandController) throws IOException {\n        TerminalBuilder builder = TerminalBuilder.builder()\n                .system(true)\n                .encoding(resolveCharset())\n                .nativeSignals(true);\n        if (isWindows()) {\n            builder.provider(\"jni\");\n        }\n        Terminal terminal = builder.build();\n        DefaultParser parser = new DefaultParser();\n        LineReader lineReader = LineReaderBuilder.builder()\n                .terminal(terminal)\n                .appName(\"ai4j-cli\")\n                .parser(parser)\n                .completer(slashCommandController)\n                .option(LineReader.Option.DISABLE_EVENT_EXPANSION, true)\n                .build();\n        if (slashCommandController != null) {\n            slashCommandController.configure(lineReader);\n        }\n        Status status = Status.getStatus(terminal, false);\n        if (status != null) {\n            status.setBorder(false);\n        }\n        return new JlineShellContext(terminal, lineReader, status);\n    }\n\n    Terminal terminal() {\n        return terminal;\n    }\n\n    LineReader lineReader() {\n        return lineReader;\n    }\n\n    Status status() {\n        return status;\n    }\n\n    @Override\n    public void close() throws IOException {\n        if (status != null) {\n            status.close();\n        }\n        if (terminal != null) {\n            terminal.close();\n        }\n    }\n\n    private static Charset resolveCharset() {\n        String[] candidates = new String[]{\n                System.getProperty(\"stdin.encoding\"),\n                System.getProperty(\"sun.stdin.encoding\"),\n                System.getProperty(\"stdout.encoding\"),\n                System.getProperty(\"sun.stdout.encoding\"),\n                System.getProperty(\"native.encoding\"),\n                System.getProperty(\"sun.jnu.encoding\"),\n                System.getProperty(\"file.encoding\")\n        };\n        for (String candidate : candidates) {\n            if (candidate == null || candidate.trim().isEmpty()) {\n                continue;\n            }\n            try {\n                return Charset.forName(candidate.trim());\n            } catch (Exception ignored) {\n            }\n        }\n        return StandardCharsets.UTF_8;\n    }\n\n    private static boolean isWindows() {\n        String osName = System.getProperty(\"os.name\", \"\");\n        return osName.toLowerCase().contains(\"win\");\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/shell/JlineShellTerminalIO.java",
    "content": "package io.github.lnyocly.ai4j.cli.shell;\n\nimport io.github.lnyocly.ai4j.cli.SlashCommandController;\nimport io.github.lnyocly.ai4j.cli.render.AssistantTranscriptRenderer;\nimport io.github.lnyocly.ai4j.cli.render.CliDisplayWidth;\nimport io.github.lnyocly.ai4j.cli.render.CliThemeStyler;\n\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiTheme;\nimport org.jline.reader.EndOfFileException;\nimport org.jline.reader.LineReader;\nimport org.jline.reader.UserInterruptException;\nimport org.jline.terminal.Attributes;\nimport org.jline.terminal.Terminal;\nimport org.jline.utils.AttributedString;\nimport org.jline.utils.NonBlockingReader;\nimport org.jline.utils.Status;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\n\npublic final class JlineShellTerminalIO implements TerminalIO {\n\n    private static final String[] SPINNER = new String[]{\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"};\n    private static final long DEFAULT_STATUS_TICK_MS = 120L;\n    private static final long DEFAULT_WAITING_THRESHOLD_MS = 8000L;\n    private static final long DEFAULT_STALLED_THRESHOLD_MS = 30000L;\n    private final JlineShellContext shellContext;\n    private final SlashCommandController slashCommandController;\n    private final Object statusLock = new Object();\n    private final Object outputLock = new Object();\n    private final Object interruptLock = new Object();\n    private final Thread spinnerThread;\n    private final CliThemeStyler themeStyler;\n    private final WindowsConsoleKeyPoller windowsConsoleKeyPoller = new WindowsConsoleKeyPoller();\n    private final AssistantTranscriptRenderer assistantTranscriptRenderer = new AssistantTranscriptRenderer();\n    private final CliThemeStyler.TranscriptStyleState transcriptStyleState = new CliThemeStyler.TranscriptStyleState();\n    private final boolean statusComponentEnabled;\n    private final boolean statusAnimationEnabled;\n    private final long statusTickMs;\n    private final long waitingThresholdMs;\n    private final long stalledThresholdMs;\n    private boolean inputClosed;\n    private volatile boolean closed;\n    private String statusLabel = \"Idle\";\n    private String statusDetail = \"Type /help for commands\";\n    private String sessionId = \"(new)\";\n    private String model = \"(unknown)\";\n    private String workspace = \".\";\n    private String hint = \"Enter a prompt or /command\";\n    private boolean spinnerActive;\n    private int spinnerIndex;\n    private int outputColumn;\n    private boolean trackingAssistantBlock;\n    private int trackedAssistantRows;\n    private int trackedInlineStatusRows;\n    private String lastRenderedStatusLine;\n    private boolean forceDirectOutput;\n    private Thread turnInterruptThread;\n    private boolean turnInterruptRunning;\n    private Attributes turnInterruptPollingAttributes;\n    private boolean turnInterruptPollingRawMode;\n    private BusyState busyState = BusyState.IDLE;\n    private long lastBusyProgressAtNanos;\n\n    public JlineShellTerminalIO(JlineShellContext shellContext, SlashCommandController slashCommandController) {\n        this.shellContext = shellContext;\n        this.slashCommandController = slashCommandController;\n        this.themeStyler = new CliThemeStyler(new TuiTheme(), supportsAnsi());\n        this.statusComponentEnabled = resolveStatusComponentEnabled();\n        this.statusAnimationEnabled = statusComponentEnabled && !isJetBrainsTerminal();\n        this.statusTickMs = Math.max(20L, resolveDurationProperty(\"ai4j.jline.status-tick-ms\", DEFAULT_STATUS_TICK_MS));\n        this.waitingThresholdMs = Math.max(1L, resolveDurationProperty(\"ai4j.jline.waiting-ms\", DEFAULT_WAITING_THRESHOLD_MS));\n        this.stalledThresholdMs = Math.max(this.waitingThresholdMs + 1L, resolveDurationProperty(\"ai4j.jline.stalled-ms\", DEFAULT_STALLED_THRESHOLD_MS));\n        if (this.slashCommandController != null) {\n            this.slashCommandController.setStatusRefresh(new Runnable() {\n                @Override\n                public void run() {\n                    redrawStatus();\n                }\n            });\n        }\n        if (statusComponentEnabled) {\n            this.spinnerThread = new Thread(new Runnable() {\n                @Override\n                public void run() {\n                    runSpinnerLoop();\n                }\n            }, \"ai4j-jline-status-spinner\");\n            this.spinnerThread.setDaemon(true);\n            this.spinnerThread.start();\n        } else {\n            this.spinnerThread = null;\n        }\n    }\n\n    @Override\n    public String readLine(String prompt) throws IOException {\n        try {\n            redrawStatus();\n            return lineReader().readLine(prompt == null ? \"\" : prompt);\n        } catch (EndOfFileException ex) {\n            inputClosed = true;\n            return null;\n        } catch (UserInterruptException ex) {\n            inputClosed = true;\n            return null;\n        } finally {\n            redrawStatus();\n        }\n    }\n\n    @Override\n    public void print(String message) {\n        writeOutput(message, false);\n    }\n\n    @Override\n    public void println(String message) {\n        writeOutput(message, true);\n    }\n\n    @Override\n    public void errorln(String message) {\n        writeOutput(message, true);\n    }\n\n    @Override\n    public boolean supportsAnsi() {\n        return terminal() != null && !\"dumb\".equalsIgnoreCase(terminal().getType());\n    }\n\n    @Override\n    public boolean supportsRawInput() {\n        return false;\n    }\n\n    @Override\n    public boolean isInputClosed() {\n        return inputClosed;\n    }\n\n    @Override\n    public int getTerminalRows() {\n        return terminal() == null ? 0 : Math.max(0, terminal().getHeight());\n    }\n\n    @Override\n    public int getTerminalColumns() {\n        return terminal() == null ? 0 : Math.max(0, terminal().getWidth());\n    }\n\n    public void updateSessionContext(String sessionId, String model, String workspace) {\n        boolean changed = false;\n        synchronized (statusLock) {\n            if (!isBlank(sessionId)) {\n                changed = changed || !sameText(this.sessionId, sessionId);\n                this.sessionId = sessionId;\n            }\n            if (!isBlank(model)) {\n                changed = changed || !sameText(this.model, model);\n                this.model = model;\n            }\n            if (!isBlank(workspace)) {\n                changed = changed || !sameText(this.workspace, workspace);\n                this.workspace = workspace;\n            }\n        }\n        if (changed) {\n            redrawStatus();\n        }\n    }\n\n    public void updateTheme(TuiTheme theme) {\n        themeStyler.updateTheme(theme);\n        synchronized (statusLock) {\n            lastRenderedStatusLine = null;\n        }\n        redrawStatus();\n    }\n\n    public void showIdle(String hint) {\n        boolean changed;\n        synchronized (statusLock) {\n            changed = !sameText(statusLabel, \"Idle\")\n                    || !sameText(statusDetail, \"Ready\")\n                    || !sameText(this.hint, firstNonBlank(hint, \"Enter a prompt or /command\"))\n                    || spinnerActive;\n            statusLabel = \"Idle\";\n            statusDetail = \"Ready\";\n            this.hint = firstNonBlank(hint, \"Enter a prompt or /command\");\n            spinnerActive = false;\n            busyState = BusyState.IDLE;\n            lastBusyProgressAtNanos = 0L;\n        }\n        if (changed) {\n            redrawStatus();\n        }\n    }\n\n    public void beginTurn(String input) {\n        transcriptStyleState.reset();\n        showBusyState(\n                BusyState.THINKING,\n                \"Thinking\",\n                isBlank(input) ? \"Analyzing workspace and tool context\" : \"Analyzing: \" + clip(input, 72)\n        );\n    }\n\n    public void beginTurnInterruptWatch(Runnable interruptHandler) {\n        synchronized (interruptLock) {\n            stopTurnInterruptWatchLocked(false);\n            if (interruptHandler == null || closed || terminal() == null) {\n                return;\n            }\n            turnInterruptRunning = true;\n            Thread thread = new Thread(new Runnable() {\n                @Override\n                public void run() {\n                    watchForTurnInterrupt(interruptHandler);\n                }\n            }, \"ai4j-jline-turn-interrupt\");\n            thread.setDaemon(true);\n            turnInterruptThread = thread;\n            thread.start();\n        }\n    }\n\n    public void beginTurnInterruptPolling() {\n        synchronized (interruptLock) {\n            stopTurnInterruptWatchLocked(true);\n            stopTurnInterruptPollingLocked();\n            if (closed || terminal() == null) {\n                return;\n            }\n            turnInterruptRunning = true;\n            try {\n                turnInterruptPollingAttributes = terminal().enterRawMode();\n                turnInterruptPollingRawMode = true;\n                windowsConsoleKeyPoller.resetEscapeState();\n            } catch (Exception ignored) {\n                turnInterruptPollingAttributes = null;\n                turnInterruptPollingRawMode = false;\n                turnInterruptRunning = false;\n            }\n        }\n    }\n\n    public boolean pollTurnInterrupt(long timeoutMs) {\n        synchronized (interruptLock) {\n            if (!turnInterruptRunning || closed || terminal() == null) {\n                return false;\n            }\n            try {\n                int next = readTurnInterruptChar(terminal(), timeoutMs <= 0L ? 120L : timeoutMs);\n                if (next == 27) {\n                    return true;\n                }\n                return windowsConsoleKeyPoller.pollEscapePressed();\n            } catch (Exception ignored) {\n                return windowsConsoleKeyPoller.pollEscapePressed();\n            }\n        }\n    }\n\n    public void showThinking() {\n        showBusyState(BusyState.THINKING, \"Thinking\", \"Analyzing workspace and tool context\");\n    }\n\n    public void showConnecting(String detail) {\n        showBusyState(BusyState.CONNECTING, \"Connecting\", firstNonBlank(detail, \"Opening model stream\"));\n    }\n\n    public void showResponding() {\n        showBusyState(BusyState.RESPONDING, \"Responding\", \"Streaming model output\");\n    }\n\n    public void showWorking(String detail) {\n        showBusyState(BusyState.WORKING, \"Working\", firstNonBlank(detail, \"Running tool\"));\n    }\n\n    public void showRetrying(String detail, int attempt, int maxAttempts) {\n        String suffix = maxAttempts > 0 ? \" (\" + Math.max(1, attempt) + \"/\" + maxAttempts + \")\" : \"\";\n        showBusyState(BusyState.RETRYING, \"Retrying\", firstNonBlank(detail, \"Retrying request\") + suffix);\n    }\n\n    public void clearTransient() {\n        transcriptStyleState.reset();\n        showIdle(\"Enter a prompt or /command\");\n    }\n\n    public void finishTurn() {\n        endTurnInterruptWatch();\n        transcriptStyleState.reset();\n        showIdle(\"Enter a prompt or /command\");\n    }\n\n    public void endTurnInterruptWatch() {\n        synchronized (interruptLock) {\n            stopTurnInterruptWatchLocked(true);\n        }\n    }\n\n    public void endTurnInterruptPolling() {\n        synchronized (interruptLock) {\n            stopTurnInterruptPollingLocked();\n        }\n    }\n\n    public void printAssistantFragment(String message) {\n        writeStyledOutput(themeStyler.styleAssistantFragment(message == null ? \"\" : message), false);\n    }\n\n    public void printReasoningFragment(String message) {\n        writeStyledOutput(themeStyler.styleReasoningFragment(message == null ? \"\" : message), false);\n    }\n\n    public void printTranscriptLine(String line, boolean newline) {\n        writeOutput(line == null ? \"\" : line, newline, true);\n    }\n\n    public void printTranscriptBlock(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return;\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int index = 0; index < lines.size(); index++) {\n            if (index > 0) {\n                builder.append('\\n');\n            }\n            builder.append(lines.get(index) == null ? \"\" : lines.get(index));\n        }\n        writeOutput(builder.toString(), true, false);\n    }\n\n    public void printAssistantMarkdownBlock(String markdown) {\n        String styled = assistantTranscriptRenderer.styleBlock(markdown, themeStyler);\n        if (styled == null || styled.isEmpty()) {\n            return;\n        }\n        writeStyledOutput(styled, true, true);\n    }\n\n    public void enterTranscriptCodeBlock(String language) {\n        transcriptStyleState.enterCodeBlock(language);\n    }\n\n    public void exitTranscriptCodeBlock() {\n        transcriptStyleState.exitCodeBlock();\n    }\n\n    public void beginAssistantBlockTracking() {\n        synchronized (outputLock) {\n            trackingAssistantBlock = true;\n            trackedAssistantRows = 0;\n        }\n    }\n\n    public void beginDirectOutputWindow() {\n        synchronized (outputLock) {\n            forceDirectOutput = true;\n        }\n    }\n\n    public void endDirectOutputWindow() {\n        synchronized (outputLock) {\n            forceDirectOutput = false;\n        }\n    }\n\n    public int assistantBlockRows() {\n        synchronized (outputLock) {\n            return trackedAssistantRows;\n        }\n    }\n\n    public void clearAssistantBlock() {\n        synchronized (outputLock) {\n            clearAssistantBlockDirect(trackedAssistantRows, lineReader() != null && lineReader().isReading());\n            trackedAssistantRows = 0;\n            trackingAssistantBlock = false;\n            transcriptStyleState.reset();\n            outputColumn = 0;\n        }\n    }\n\n    public void forgetAssistantBlock() {\n        synchronized (outputLock) {\n            trackedAssistantRows = 0;\n            trackingAssistantBlock = false;\n            transcriptStyleState.reset();\n        }\n    }\n\n    public boolean rewriteAssistantBlock(int previousRows, String replacementMarkdown) {\n        String replacement = assistantTranscriptRenderer.styleBlock(replacementMarkdown, themeStyler);\n        if (previousRows <= 0 || isBlank(replacement)) {\n            return false;\n        }\n        synchronized (outputLock) {\n            return rewriteAssistantBlockDirect(previousRows, replacement, lineReader() != null && lineReader().isReading());\n        }\n    }\n\n    @Override\n    public void close() {\n        closed = true;\n        endTurnInterruptWatch();\n        if (spinnerThread != null) {\n            spinnerThread.interrupt();\n        }\n    }\n\n    private LineReader lineReader() {\n        return shellContext.lineReader();\n    }\n\n    private Terminal terminal() {\n        return shellContext.terminal();\n    }\n\n    private Status status() {\n        return shellContext.status();\n    }\n\n    private void runSpinnerLoop() {\n        while (!closed) {\n            try {\n                Thread.sleep(statusTickMs);\n            } catch (InterruptedException ex) {\n                if (closed) {\n                    return;\n                }\n                Thread.currentThread().interrupt();\n                return;\n            }\n            boolean shouldRedraw = false;\n            synchronized (statusLock) {\n                if (spinnerActive) {\n                    if (statusAnimationEnabled) {\n                        spinnerIndex = (spinnerIndex + 1) % SPINNER.length;\n                    }\n                    shouldRedraw = true;\n                }\n            }\n            if (shouldRedraw) {\n                redrawStatus();\n            }\n        }\n    }\n\n    private void watchForTurnInterrupt(Runnable interruptHandler) {\n        Terminal terminal = terminal();\n        if (terminal == null) {\n            return;\n        }\n        Attributes previous = null;\n        boolean rawMode = false;\n        try {\n            previous = terminal.enterRawMode();\n            rawMode = true;\n            while (isTurnInterruptRunning()) {\n                int next = readTurnInterruptChar(terminal);\n                if (next == NonBlockingReader.READ_EXPIRED || next < 0) {\n                    continue;\n                }\n                if (next == 27) {\n                    interruptHandler.run();\n                    return;\n                }\n            }\n        } catch (Exception ignored) {\n        } finally {\n            if (rawMode && previous != null) {\n                try {\n                    terminal.setAttributes(previous);\n                } catch (Exception ignored) {\n                }\n            }\n        }\n    }\n\n    private int readTurnInterruptChar(Terminal terminal) throws IOException {\n        return readTurnInterruptChar(terminal, 120L);\n    }\n\n    private int readTurnInterruptChar(Terminal terminal, long timeoutMs) throws IOException {\n        if (terminal == null || terminal.reader() == null) {\n            return -1;\n        }\n        if (terminal.reader() instanceof NonBlockingReader) {\n            return ((NonBlockingReader) terminal.reader()).read(timeoutMs);\n        }\n        return terminal.reader().read();\n    }\n\n    private boolean isTurnInterruptRunning() {\n        synchronized (interruptLock) {\n            return turnInterruptRunning && !closed;\n        }\n    }\n\n    private void stopTurnInterruptWatchLocked(boolean join) {\n        turnInterruptRunning = false;\n        Thread thread = turnInterruptThread;\n        turnInterruptThread = null;\n        if (thread == null) {\n            return;\n        }\n        thread.interrupt();\n        if (join && thread != Thread.currentThread()) {\n            try {\n                thread.join(100L);\n            } catch (InterruptedException ex) {\n                Thread.currentThread().interrupt();\n            }\n        }\n    }\n\n    private void stopTurnInterruptPollingLocked() {\n        turnInterruptRunning = false;\n        if (turnInterruptPollingRawMode && turnInterruptPollingAttributes != null && terminal() != null) {\n            try {\n                terminal().setAttributes(turnInterruptPollingAttributes);\n            } catch (Exception ignored) {\n            }\n        }\n        turnInterruptPollingAttributes = null;\n        turnInterruptPollingRawMode = false;\n    }\n\n    private void showBusyState(BusyState nextBusyState, String label, String detail) {\n        boolean changed;\n        synchronized (statusLock) {\n            String nextLabel = firstNonBlank(label, \"Working\");\n            String nextDetail = firstNonBlank(detail, \"Working\");\n            changed = !sameText(statusLabel, nextLabel)\n                    || !sameText(statusDetail, nextDetail)\n                    || !sameText(hint, \"Esc to interrupt the current task\")\n                    || !spinnerActive;\n            statusLabel = nextLabel;\n            statusDetail = nextDetail;\n            hint = \"Esc to interrupt the current task\";\n            spinnerActive = true;\n            busyState = nextBusyState == null ? BusyState.WORKING : nextBusyState;\n            lastBusyProgressAtNanos = System.nanoTime();\n            spinnerIndex = 0;\n        }\n        if (changed) {\n            redrawStatus();\n        }\n    }\n\n    private void redrawStatus() {\n        synchronized (outputLock) {\n            if (closed) {\n                return;\n            }\n            List<AttributedString> lines;\n            String statusPayload;\n            synchronized (statusLock) {\n                lines = buildStatusLines();\n                statusPayload = joinStatusPayload(lines);\n                if (lines.isEmpty() && isBlank(lastRenderedStatusLine)) {\n                    return;\n                }\n                if (sameText(lastRenderedStatusLine, statusPayload)) {\n                    return;\n                }\n            }\n            Status status = status();\n            if (status == null) {\n                redrawInlineStatus(statusPayload);\n                return;\n            }\n            try {\n                status.update(lines);\n                synchronized (statusLock) {\n                    lastRenderedStatusLine = statusPayload;\n                }\n                Terminal terminal = terminal();\n                if (terminal != null) {\n                    terminal.flush();\n                }\n            } catch (Exception ignored) {\n            }\n        }\n    }\n\n    private void redrawInlineStatus(String statusPayload) {\n        LineReader lineReader = lineReader();\n        boolean reading = lineReader != null && lineReader.isReading();\n        String nextPayload = statusPayload == null ? \"\" : statusPayload;\n        String previousPayload;\n        synchronized (statusLock) {\n            previousPayload = lastRenderedStatusLine;\n            lastRenderedStatusLine = nextPayload;\n        }\n        if (isBlank(nextPayload)) {\n            if (trackedInlineStatusRows > 0) {\n                if (clearAssistantBlockDirect(trackedInlineStatusRows, reading)) {\n                    trackedInlineStatusRows = 0;\n                } else {\n                    synchronized (statusLock) {\n                        lastRenderedStatusLine = previousPayload;\n                    }\n                }\n            }\n            return;\n        }\n        if (!reading) {\n            synchronized (statusLock) {\n                lastRenderedStatusLine = previousPayload;\n            }\n            return;\n        }\n        if (trackedInlineStatusRows > 0 && !clearAssistantBlockDirect(trackedInlineStatusRows, true)) {\n            synchronized (statusLock) {\n                lastRenderedStatusLine = previousPayload;\n            }\n            return;\n        }\n        try {\n            String rendered = normalizeReadingPrintAboveMessage(nextPayload);\n            lineReader.printAbove(rendered);\n            trackedInlineStatusRows = countWrappedRows(rendered, 0);\n        } catch (Exception ignored) {\n            synchronized (statusLock) {\n                lastRenderedStatusLine = previousPayload;\n            }\n        }\n    }\n\n    private String buildPrimaryStatusLine() {\n        return themeStyler.buildPrimaryStatusLine(\n                statusLabel,\n                spinnerActive,\n                spinnerActive ? SPINNER[Math.floorMod(spinnerIndex, SPINNER.length)] : null,\n                statusDetail\n        );\n    }\n\n    private List<AttributedString> buildStatusLines() {\n        SlashCommandController.PaletteSnapshot paletteSnapshot = slashCommandController == null\n                ? SlashCommandController.PaletteSnapshot.closed()\n                : slashCommandController.getPaletteSnapshot();\n        List<String> ansiLines = new ArrayList<String>();\n        if (statusComponentEnabled) {\n            ansiLines.add(buildStatusLine());\n        }\n        if (paletteSnapshot.isOpen()) {\n            ansiLines.addAll(buildSlashPaletteLines(paletteSnapshot));\n        }\n        if (ansiLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<AttributedString> lines = new ArrayList<AttributedString>(ansiLines.size());\n        for (String ansiLine : ansiLines) {\n            lines.add(AttributedString.fromAnsi(ansiLine, terminal()));\n        }\n        return lines;\n    }\n\n    private List<String> buildSlashPaletteLines(SlashCommandController.PaletteSnapshot snapshot) {\n        if (snapshot == null || !snapshot.isOpen()) {\n            return Collections.emptyList();\n        }\n        List<String> lines = new ArrayList<String>();\n        List<SlashCommandController.PaletteItemSnapshot> items = snapshot.getItems();\n        String query = firstNonBlank(snapshot.getQuery(), \"/\");\n        String matchSummary = items.isEmpty() ? \"no matches\" : items.size() + \" matches\";\n        lines.add(\n                themeStyler.styleMutedFragment(\"commands\")\n                        + \" \"\n                        + themeStyler.styleAssistantFragment(query)\n                        + \"  \"\n                        + themeStyler.styleMutedFragment(matchSummary + \"  ↑↓ move  Tab apply  Enter run\")\n        );\n        if (items.isEmpty()) {\n            lines.add(themeStyler.styleReasoningFragment(\"  No matching slash commands\"));\n            return lines;\n        }\n        int maxItems = 6;\n        int selectedIndex = snapshot.getSelectedIndex();\n        int start = Math.max(0, selectedIndex - (maxItems / 2));\n        if (start + maxItems > items.size()) {\n            start = Math.max(0, items.size() - maxItems);\n        }\n        int end = Math.min(items.size(), start + maxItems);\n        for (int index = start; index < end; index++) {\n            SlashCommandController.PaletteItemSnapshot item = items.get(index);\n            boolean selected = index == selectedIndex;\n            String prefix = selected\n                    ? themeStyler.styleAssistantFragment(\"›\")\n                    : themeStyler.styleMutedFragment(\" \");\n            String command = selected\n                    ? themeStyler.styleAssistantFragment(item.getDisplay())\n                    : themeStyler.styleMutedFragment(item.getDisplay());\n            String description = item.getDescription();\n            if (isBlank(description)) {\n                lines.add(prefix + \" \" + command);\n            } else {\n                lines.add(prefix + \" \" + command + \"  \" + themeStyler.styleReasoningFragment(clip(description, 72)));\n            }\n        }\n        return lines;\n    }\n\n    private String buildStatusLine() {\n        BusySnapshot snapshot = snapshotBusyState();\n        return themeStyler.buildCompactStatusLine(\n                snapshot.label,\n                snapshot.spinnerActive,\n                snapshot.spinnerActive ? SPINNER[Math.floorMod(spinnerIndex, SPINNER.length)] : null,\n                clip(snapshot.detail, 64),\n                clip(firstNonBlank(model, \"(unknown)\"), 20),\n                clip(lastPathSegment(firstNonBlank(workspace, \".\")), 24),\n                clip(firstNonBlank(hint, \"Enter a prompt or /command\"), 40)\n        );\n    }\n\n    public String currentStatusLine() {\n        return buildStatusLine();\n    }\n\n    private BusySnapshot snapshotBusyState() {\n        synchronized (statusLock) {\n            return snapshotBusyStateLocked();\n        }\n    }\n\n    private BusySnapshot snapshotBusyStateLocked() {\n        String label = statusLabel;\n        String detail = statusDetail;\n        boolean active = spinnerActive;\n        if (!active || busyState == BusyState.IDLE || lastBusyProgressAtNanos <= 0L) {\n            return new BusySnapshot(label, detail, active);\n        }\n        long idleMs = Math.max(0L, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastBusyProgressAtNanos));\n        long idleSeconds = Math.max(1L, idleMs / 1000L);\n        if (idleMs >= stalledThresholdMs) {\n            return new BusySnapshot(\"Stalled\", stalledDetailFor(busyState, detail, idleSeconds), true);\n        }\n        if (idleMs >= waitingThresholdMs) {\n            return new BusySnapshot(waitingLabelFor(busyState, label), waitingDetailFor(busyState, detail, idleSeconds), true);\n        }\n        return new BusySnapshot(label, detail, active);\n    }\n\n    private String waitingLabelFor(BusyState state, String fallback) {\n        if (state == BusyState.THINKING || state == BusyState.RESPONDING) {\n            return \"Waiting\";\n        }\n        return firstNonBlank(fallback, \"Working\");\n    }\n\n    private String waitingDetailFor(BusyState state, String detail, long idleSeconds) {\n        switch (state) {\n            case CONNECTING:\n                return firstNonBlank(detail, \"Opening model stream\") + \" (\" + idleSeconds + \"s)\";\n            case THINKING:\n            case RESPONDING:\n                return \"No new model output for \" + idleSeconds + \"s\";\n            case RETRYING:\n                return firstNonBlank(detail, \"Retrying request\") + \" (\" + idleSeconds + \"s)\";\n            case WORKING:\n                return firstNonBlank(detail, \"Running tool\") + \" still running (\" + idleSeconds + \"s)\";\n            default:\n                return firstNonBlank(detail, \"Working\") + \" (\" + idleSeconds + \"s)\";\n        }\n    }\n\n    private String stalledDetailFor(BusyState state, String detail, long idleSeconds) {\n        switch (state) {\n            case CONNECTING:\n                return \"No response from model stream for \" + idleSeconds + \"s - press Esc to interrupt\";\n            case THINKING:\n            case RESPONDING:\n                return \"No new model output for \" + idleSeconds + \"s - press Esc to interrupt\";\n            case RETRYING:\n                return firstNonBlank(detail, \"Retrying request\") + \" appears stuck - press Esc to interrupt\";\n            case WORKING:\n                return firstNonBlank(detail, \"Running tool\") + \" still running after \" + idleSeconds + \"s - press Esc to interrupt\";\n            default:\n                return firstNonBlank(detail, \"Current task\") + \" appears stuck - press Esc to interrupt\";\n        }\n    }\n\n    private String buildSessionLine() {\n        return themeStyler.buildSessionLine(\n                clip(firstNonBlank(sessionId, \"(new)\"), 18),\n                clip(firstNonBlank(model, \"(unknown)\"), 24),\n                clip(lastPathSegment(firstNonBlank(workspace, \".\")), 24)\n        );\n    }\n\n    private String buildHintLine() {\n        return themeStyler.buildHintLine(firstNonBlank(hint, \"Enter a prompt or /command\"));\n    }\n\n    private String joinStatusPayload(List<AttributedString> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < lines.size(); i++) {\n            if (i > 0) {\n                builder.append('\\n');\n            }\n            builder.append(lines.get(i).toAnsi(terminal()));\n        }\n        return builder.toString();\n    }\n\n    private String stylePrintedMessage(String message) {\n        if (message == null || message.isEmpty()) {\n            return message;\n        }\n        String[] rawLines = message.replace(\"\\r\", \"\").split(\"\\n\", -1);\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < rawLines.length; i++) {\n            if (i > 0) {\n                builder.append('\\n');\n            }\n            builder.append(themeStyler.styleTranscriptLine(rawLines[i], transcriptStyleState));\n        }\n        return builder.toString();\n    }\n\n    private void writeOutput(String message, boolean newline) {\n        writeOutput(message, newline, false);\n    }\n\n    private void writeOutput(String message, boolean newline, boolean trackAssistantBlock) {\n        String value = message == null ? \"\" : message;\n        if (!newline && value.isEmpty()) {\n            return;\n        }\n        writeStyledOutput(stylePrintedMessage(value), newline, trackAssistantBlock);\n    }\n\n    private void writeStyledOutput(String message, boolean newline) {\n        writeStyledOutput(message, newline, false);\n    }\n\n    private void writeStyledOutput(String message, boolean newline, boolean trackAssistantBlock) {\n        synchronized (outputLock) {\n            LineReader lineReader = lineReader();\n            boolean reading = !forceDirectOutput && lineReader != null && lineReader.isReading();\n            if (trackAssistantBlock && trackingAssistantBlock) {\n                trackedAssistantRows += countOutputRows(message, newline, reading, outputColumn);\n            }\n            if (reading) {\n                String readingMessage = normalizeReadingPrintAboveMessage(message == null ? \"\" : message);\n                CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi(\n                        readingMessage,\n                        getTerminalColumns(),\n                        newline ? 0 : outputColumn\n                );\n                String wrappedText = wrapped.text();\n                if (!wrappedText.isEmpty()) {\n                    lineReader.printAbove(wrappedText);\n                    outputColumn = newline ? 0 : wrapped.endColumn();\n                } else if (newline) {\n                    outputColumn = 0;\n                }\n                redrawStatus();\n                return;\n            }\n            Status status = status();\n            boolean suspended = false;\n            try {\n                if (status != null) {\n                    status.suspend();\n                    suspended = true;\n                }\n                Terminal terminal = terminal();\n                if (terminal != null) {\n                    CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi(\n                            message == null ? \"\" : message,\n                            getTerminalColumns(),\n                            outputColumn\n                    );\n                    terminal.writer().print(wrapped.text());\n                    outputColumn = wrapped.endColumn();\n                    if (newline) {\n                        terminal.writer().println();\n                        outputColumn = 0;\n                    }\n                    terminal.writer().flush();\n                    terminal.flush();\n                }\n            } catch (Exception ignored) {\n            } finally {\n                if (suspended) {\n                    try {\n                        status.restore();\n                    } catch (Exception ignored) {\n                    }\n                }\n                redrawStatus();\n            }\n        }\n    }\n\n    private boolean rewriteAssistantBlockDirect(int previousRows, String replacement, boolean reading) {\n        Terminal terminal = terminal();\n        if (terminal == null) {\n            return false;\n        }\n        Status status = status();\n        boolean suspended = false;\n        try {\n            if (status != null) {\n                status.suspend();\n                suspended = true;\n            }\n            StringBuilder builder = new StringBuilder();\n            builder.append('\\r');\n            if (reading) {\n                // Clear the prompt line first so the replacement can be redrawn\n                // independently from JLine's input buffer redisplay.\n                builder.append(\"\\u001b[2K\");\n            }\n            for (int row = 0; row < previousRows; row++) {\n                builder.append(\"\\u001b[1A\");\n                builder.append('\\r');\n                builder.append(\"\\u001b[2K\");\n            }\n            builder.append('\\r');\n            builder.append(replacement);\n            builder.append('\\n');\n            terminal.writer().print(builder.toString());\n            terminal.writer().flush();\n            terminal.flush();\n            if (reading) {\n                redrawInputLine();\n            }\n            if (trackingAssistantBlock) {\n                trackedAssistantRows = countWrappedRows(replacement, 0);\n            }\n            outputColumn = 0;\n            return true;\n        } catch (Exception ignored) {\n            return false;\n        } finally {\n            if (suspended) {\n                try {\n                    status.restore();\n                } catch (Exception ignored) {\n                }\n            }\n            redrawStatus();\n        }\n    }\n\n    private boolean clearAssistantBlockDirect(int previousRows, boolean reading) {\n        if (previousRows <= 0) {\n            return false;\n        }\n        Terminal terminal = terminal();\n        if (terminal == null) {\n            return false;\n        }\n        Status status = status();\n        boolean suspended = false;\n        try {\n            if (status != null) {\n                status.suspend();\n                suspended = true;\n            }\n            StringBuilder builder = new StringBuilder();\n            builder.append('\\r');\n            if (reading) {\n                builder.append(\"\\u001b[2K\");\n            }\n            for (int row = 0; row < previousRows; row++) {\n                builder.append(\"\\u001b[1A\");\n                builder.append('\\r');\n                builder.append(\"\\u001b[2K\");\n            }\n            terminal.writer().print(builder.toString());\n            terminal.writer().flush();\n            terminal.flush();\n            if (reading) {\n                redrawInputLine();\n            }\n            return true;\n        } catch (Exception ignored) {\n            return false;\n        } finally {\n            if (suspended) {\n                try {\n                    status.restore();\n                } catch (Exception ignored) {\n                }\n            }\n            redrawStatus();\n        }\n    }\n\n    private void redrawInputLine() {\n        LineReader lineReader = lineReader();\n        if (lineReader == null) {\n            return;\n        }\n        try {\n            lineReader.callWidget(LineReader.REDRAW_LINE);\n        } catch (Exception ignored) {\n        }\n        try {\n            lineReader.callWidget(LineReader.REDISPLAY);\n        } catch (Exception ignored) {\n        }\n    }\n\n    private int countOutputRows(String message, boolean newline, boolean reading, int startColumn) {\n        String safe = message == null ? \"\" : message;\n        if (reading) {\n            if (safe.isEmpty()) {\n                return newline ? 1 : 0;\n            }\n            return countWrappedRows(safe, 0);\n        }\n        if (safe.isEmpty()) {\n            if (!newline) {\n                return 0;\n            }\n            return startColumn == 0 ? 1 : 0;\n        }\n        return countWrappedRows(safe, startColumn);\n    }\n\n    private int countWrappedRows(String message, int startColumn) {\n        String safe = message == null ? \"\" : message;\n        if (safe.isEmpty()) {\n            return 0;\n        }\n        String wrapped = CliDisplayWidth.wrapAnsi(safe, getTerminalColumns(), startColumn).text();\n        if (wrapped.isEmpty()) {\n            return 0;\n        }\n        return Math.max(1, wrapped.split(\"\\n\", -1).length);\n    }\n\n    private String normalizeReadingPrintAboveMessage(String message) {\n        String safe = message == null ? \"\" : message.replace(\"\\r\", \"\");\n        if (safe.isEmpty()) {\n            return \" \";\n        }\n        String[] rawLines = safe.split(\"\\n\", -1);\n        StringBuilder builder = new StringBuilder();\n        for (int index = 0; index < rawLines.length; index++) {\n            if (index > 0) {\n                builder.append('\\n');\n            }\n            String rawLine = rawLines[index];\n            builder.append(rawLine.isEmpty() ? \" \" : rawLine);\n        }\n        return builder.toString();\n    }\n\n    private String lastPathSegment(String value) {\n        if (isBlank(value)) {\n            return \".\";\n        }\n        String normalized = value.replace('\\\\', '/');\n        int index = normalized.lastIndexOf('/');\n        return index >= 0 && index + 1 < normalized.length() ? normalized.substring(index + 1) : normalized;\n    }\n\n    private String clip(String value, int maxChars) {\n        return CliDisplayWidth.clip(value, maxChars);\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private boolean sameText(String left, String right) {\n        if (left == null) {\n            return right == null;\n        }\n        return left.equals(right);\n    }\n\n    private long resolveDurationProperty(String key, long fallback) {\n        String value = System.getProperty(key);\n        if (isBlank(value)) {\n            return fallback;\n        }\n        try {\n            long parsed = Long.parseLong(value.trim());\n            return parsed > 0L ? parsed : fallback;\n        } catch (NumberFormatException ignored) {\n            return fallback;\n        }\n    }\n\n    private enum BusyState {\n        IDLE,\n        THINKING,\n        CONNECTING,\n        RESPONDING,\n        WORKING,\n        RETRYING\n    }\n\n    private static final class BusySnapshot {\n        private final String label;\n        private final String detail;\n        private final boolean spinnerActive;\n\n        private BusySnapshot(String label, String detail, boolean spinnerActive) {\n            this.label = label;\n            this.detail = detail;\n            this.spinnerActive = spinnerActive;\n        }\n    }\n\n    private boolean isJetBrainsTerminal() {\n        String[] candidates = new String[]{\n                System.getenv(\"TERMINAL_EMULATOR\"),\n                System.getenv(\"TERM_PROGRAM\"),\n                System.getProperty(\"terminal.emulator\"),\n                terminal() == null ? null : terminal().getName(),\n                terminal() == null ? null : terminal().getType()\n        };\n        for (String candidate : candidates) {\n            if (candidate == null) {\n                continue;\n            }\n            String normalized = candidate.toLowerCase();\n            if (normalized.contains(\"jetbrains\") || normalized.contains(\"jediterm\")) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private boolean resolveStatusComponentEnabled() {\n        String property = System.getProperty(\"ai4j.jline.status\");\n        if (!isBlank(property)) {\n            return \"true\".equalsIgnoreCase(property.trim());\n        }\n        String environment = System.getenv(\"AI4J_JLINE_STATUS\");\n        if (!isBlank(environment)) {\n            return \"true\".equalsIgnoreCase(environment.trim());\n        }\n        // Disable the JLine footer status by default. In JetBrains terminals it\n        // can drift the scroll region and create the blank streaming area the\n        // user is seeing; callers can explicitly re-enable it when needed.\n        return false;\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/shell/WindowsConsoleKeyPoller.java",
    "content": "package io.github.lnyocly.ai4j.cli.shell;\n\nimport com.sun.jna.Library;\nimport com.sun.jna.Native;\n\npublic final class WindowsConsoleKeyPoller {\n\n    private static final int VK_ESCAPE = 0x1B;\n    private static final User32 USER32 = loadUser32();\n\n    private boolean escapeDown;\n\n    boolean isSupported() {\n        return USER32 != null;\n    }\n\n    void resetEscapeState() {\n        if (!isSupported()) {\n            return;\n        }\n        short state = USER32.GetAsyncKeyState(VK_ESCAPE);\n        escapeDown = isDown(state);\n    }\n\n    boolean pollEscapePressed() {\n        if (!isSupported()) {\n            return false;\n        }\n        short state = USER32.GetAsyncKeyState(VK_ESCAPE);\n        boolean down = isDown(state);\n        boolean pressed = wasPressedSinceLastPoll(state) || (down && !escapeDown);\n        escapeDown = down;\n        return pressed;\n    }\n\n    private static boolean isDown(short state) {\n        return (state & 0x8000) != 0;\n    }\n\n    private static boolean wasPressedSinceLastPoll(short state) {\n        return (state & 0x0001) != 0;\n    }\n\n    private static User32 loadUser32() {\n        if (!isWindows()) {\n            return null;\n        }\n        try {\n            return Native.load(\"user32\", User32.class);\n        } catch (Throwable ignored) {\n            return null;\n        }\n    }\n\n    private static boolean isWindows() {\n        String osName = System.getProperty(\"os.name\", \"\");\n        return osName.toLowerCase().contains(\"win\");\n    }\n\n    interface User32 extends Library {\n        short GetAsyncKeyState(int vKey);\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/AnsiTuiRuntime.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport io.github.lnyocly.ai4j.tui.runtime.DefaultAnsiTuiRuntime;\n\npublic class AnsiTuiRuntime extends DefaultAnsiTuiRuntime {\n\n    public AnsiTuiRuntime(TerminalIO terminal, TuiRenderer renderer) {\n        super(terminal, renderer);\n    }\n\n    public AnsiTuiRuntime(TerminalIO terminal, TuiRenderer renderer, boolean useAlternateScreen) {\n        super(terminal, renderer, useAlternateScreen);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/AppendOnlyTuiRuntime.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport io.github.lnyocly.ai4j.tui.runtime.DefaultAppendOnlyTuiRuntime;\n\npublic class AppendOnlyTuiRuntime extends DefaultAppendOnlyTuiRuntime {\n\n    public AppendOnlyTuiRuntime(TerminalIO terminal) {\n        super(terminal);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/JlineTerminalIO.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport io.github.lnyocly.ai4j.tui.io.DefaultJlineTerminalIO;\nimport io.github.lnyocly.ai4j.tui.io.DefaultStreamsTerminalIO;\nimport org.jline.terminal.Terminal;\nimport org.jline.terminal.TerminalBuilder;\n\nimport java.io.IOException;\nimport java.io.OutputStream;\nimport java.nio.charset.Charset;\n\npublic class JlineTerminalIO extends DefaultJlineTerminalIO {\n\n    private JlineTerminalIO(Terminal terminal, OutputStream errStream) {\n        super(terminal, errStream);\n    }\n\n    public static JlineTerminalIO openSystem(OutputStream errStream) throws IOException {\n        Charset charset = DefaultStreamsTerminalIO.resolveTerminalCharset();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(true)\n                .encoding(charset)\n                .build();\n        return new JlineTerminalIO(terminal, errStream);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/StreamsTerminalIO.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport io.github.lnyocly.ai4j.tui.io.DefaultStreamsTerminalIO;\n\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.nio.charset.Charset;\n\npublic class StreamsTerminalIO extends DefaultStreamsTerminalIO {\n\n    public StreamsTerminalIO(InputStream in, OutputStream out, OutputStream err) {\n        super(in, out, err);\n    }\n\n    StreamsTerminalIO(InputStream in, OutputStream out, OutputStream err, boolean ansiSupported) {\n        super(in, out, err, ansiSupported);\n    }\n\n    StreamsTerminalIO(InputStream in,\n                      OutputStream out,\n                      OutputStream err,\n                      Charset charset,\n                      boolean ansiSupported) {\n        super(in, out, err, charset, ansiSupported);\n    }\n\n    public static Charset resolveTerminalCharset() {\n        return DefaultStreamsTerminalIO.resolveTerminalCharset();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TerminalIO.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport java.io.IOException;\n\npublic interface TerminalIO {\n\n    String readLine(String prompt) throws IOException;\n\n    default TuiKeyStroke readKeyStroke() throws IOException {\n        return null;\n    }\n\n    default TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException {\n        return readKeyStroke();\n    }\n\n    void print(String message);\n\n    void println(String message);\n\n    void errorln(String message);\n\n    default boolean supportsAnsi() {\n        return false;\n    }\n\n    default boolean supportsRawInput() {\n        return false;\n    }\n\n    default boolean isInputClosed() {\n        return false;\n    }\n\n    default void clearScreen() {\n    }\n\n    default void enterAlternateScreen() {\n    }\n\n    default void exitAlternateScreen() {\n    }\n\n    default void hideCursor() {\n    }\n\n    default void showCursor() {\n    }\n\n    default void moveCursorHome() {\n    }\n\n    default int getTerminalRows() {\n        return 0;\n    }\n\n    default int getTerminalColumns() {\n        return 0;\n    }\n\n    default void close() throws IOException {\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiAnsi.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nfinal class TuiAnsi {\n\n    private static final String ESC = \"\\u001b[\";\n    private static final String RESET = ESC + \"0m\";\n\n    private TuiAnsi() {\n    }\n\n    static String bold(String value, boolean ansi) {\n        return style(value, null, null, ansi, true);\n    }\n\n    static String fg(String value, String hexColor, boolean ansi) {\n        return style(value, hexColor, null, ansi, false);\n    }\n\n    static String bg(String value, String hexColor, boolean ansi) {\n        return style(value, null, hexColor, ansi, false);\n    }\n\n    static String badge(String value, String foreground, String background, boolean ansi) {\n        if (!ansi) {\n            return \"[\" + value + \"]\";\n        }\n        return style(\" \" + value + \" \", foreground, background, true, true);\n    }\n\n    static String colorize(String value, String hexColor, boolean ansi, boolean bold) {\n        return style(value, hexColor, null, ansi, bold);\n    }\n\n    static String style(String value, String foreground, String background, boolean ansi, boolean bold) {\n        if (!ansi || isEmpty(value)) {\n            return value;\n        }\n        StringBuilder codes = new StringBuilder();\n        if (bold) {\n            codes.append('1');\n        }\n        if (!isBlank(foreground)) {\n            appendColorCode(codes, \"38;2;\", parseHex(foreground));\n        }\n        if (!isBlank(background)) {\n            appendColorCode(codes, \"48;2;\", parseHex(background));\n        }\n        if (codes.length() == 0) {\n            return value;\n        }\n        return ESC + codes + \"m\" + value + RESET;\n    }\n\n    private static void appendColorCode(StringBuilder codes, String prefix, int[] rgb) {\n        if (codes == null || rgb == null || rgb.length < 3) {\n            return;\n        }\n        if (codes.length() > 0) {\n            codes.append(';');\n        }\n        codes.append(prefix)\n                .append(rgb[0]).append(';')\n                .append(rgb[1]).append(';')\n                .append(rgb[2]);\n    }\n\n    private static int[] parseHex(String hexColor) {\n        String normalized = hexColor == null ? \"\" : hexColor.trim();\n        if (normalized.startsWith(\"#\")) {\n            normalized = normalized.substring(1);\n        }\n        if (normalized.length() != 6) {\n            return new int[]{255, 255, 255};\n        }\n        return new int[]{\n                Integer.parseInt(normalized.substring(0, 2), 16),\n                Integer.parseInt(normalized.substring(2, 4), 16),\n                Integer.parseInt(normalized.substring(4, 6), 16)\n        };\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static boolean isEmpty(String value) {\n        return value == null || value.isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiAssistantPhase.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic enum TuiAssistantPhase {\n\n    IDLE,\n    THINKING,\n    GENERATING,\n    WAITING_TOOL_RESULT,\n    COMPLETE,\n    ERROR\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiAssistantToolView.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class TuiAssistantToolView {\n\n    private String callId;\n\n    private String toolName;\n\n    private String status;\n\n    private String title;\n\n    private String detail;\n\n    @Builder.Default\n    private List<String> previewLines = new ArrayList<String>();\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiAssistantViewModel.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class TuiAssistantViewModel {\n\n    @Builder.Default\n    private TuiAssistantPhase phase = TuiAssistantPhase.IDLE;\n\n    private Integer step;\n\n    private String phaseDetail;\n\n    private String reasoningText;\n\n    private String text;\n\n    private long updatedAtEpochMs;\n\n    @Builder.Default\n    private int animationTick = 0;\n\n    @Builder.Default\n    private List<TuiAssistantToolView> tools = new ArrayList<TuiAssistantToolView>();\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiConfig.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic class TuiConfig {\n\n    private String theme = \"default\";\n    private boolean denseMode;\n    private boolean showTimestamps = true;\n    private boolean showFooter = true;\n    private int maxEvents = 10;\n    private boolean useAlternateScreen;\n\n    public String getTheme() {\n        return theme;\n    }\n\n    public void setTheme(String theme) {\n        this.theme = theme;\n    }\n\n    public boolean isDenseMode() {\n        return denseMode;\n    }\n\n    public void setDenseMode(boolean denseMode) {\n        this.denseMode = denseMode;\n    }\n\n    public boolean isShowTimestamps() {\n        return showTimestamps;\n    }\n\n    public void setShowTimestamps(boolean showTimestamps) {\n        this.showTimestamps = showTimestamps;\n    }\n\n    public boolean isShowFooter() {\n        return showFooter;\n    }\n\n    public void setShowFooter(boolean showFooter) {\n        this.showFooter = showFooter;\n    }\n\n    public int getMaxEvents() {\n        return maxEvents;\n    }\n\n    public void setMaxEvents(int maxEvents) {\n        this.maxEvents = maxEvents;\n    }\n\n    public boolean isUseAlternateScreen() {\n        return useAlternateScreen;\n    }\n\n    public void setUseAlternateScreen(boolean useAlternateScreen) {\n        this.useAlternateScreen = useAlternateScreen;\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiConfigManager.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONWriter;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Set;\n\npublic class TuiConfigManager {\n\n    private static final List<String> BUILT_IN_THEMES = Arrays.asList(\n            \"default\",\n            \"amber\",\n            \"ocean\",\n            \"matrix\",\n            \"github-dark\",\n            \"github-light\"\n    );\n\n    private final Path workspaceRoot;\n\n    public TuiConfigManager(Path workspaceRoot) {\n        this.workspaceRoot = workspaceRoot == null ? Paths.get(\".\").toAbsolutePath().normalize() : workspaceRoot.toAbsolutePath().normalize();\n    }\n\n    public TuiConfig load(String overrideTheme) {\n        TuiConfig config = merge(loadConfig(homeConfigPath()), loadConfig(workspaceConfigPath()));\n        if (config == null) {\n            config = new TuiConfig();\n        }\n        normalize(config);\n        if (!isBlank(overrideTheme)) {\n            config.setTheme(overrideTheme.trim());\n        }\n        return config;\n    }\n\n    public TuiConfig save(TuiConfig config) throws IOException {\n        TuiConfig normalized = config == null ? new TuiConfig() : config;\n        normalize(normalized);\n        Files.createDirectories(workspaceConfigPath().getParent());\n        String json = JSON.toJSONString(normalized, JSONWriter.Feature.PrettyFormat);\n        Files.write(workspaceConfigPath(), json.getBytes(StandardCharsets.UTF_8));\n        return normalized;\n    }\n\n    public TuiConfig switchTheme(String themeName) throws IOException {\n        if (isBlank(themeName)) {\n            throw new IllegalArgumentException(\"theme name is required\");\n        }\n        TuiTheme theme = resolveTheme(themeName);\n        if (theme == null) {\n            throw new IllegalArgumentException(\"Unknown TUI theme: \" + themeName);\n        }\n        TuiConfig config = load(null);\n        config.setTheme(theme.getName());\n        return save(config);\n    }\n\n    public TuiTheme resolveTheme(String name) {\n        String target = isBlank(name) ? \"default\" : name.trim();\n        TuiTheme theme = loadTheme(workspaceThemePath(target));\n        if (theme != null) {\n            normalize(theme, target);\n            return theme;\n        }\n        theme = loadTheme(homeThemePath(target));\n        if (theme != null) {\n            normalize(theme, target);\n            return theme;\n        }\n        theme = loadBuiltInTheme(target);\n        if (theme != null) {\n            normalize(theme, target);\n            return theme;\n        }\n        if (!\"default\".equals(target)) {\n            return resolveTheme(\"default\");\n        }\n        return defaultTheme();\n    }\n\n    public List<String> listThemeNames() {\n        Set<String> names = new LinkedHashSet<String>();\n        names.addAll(BUILT_IN_THEMES);\n        names.addAll(scanThemeNames(homeThemesDir()));\n        names.addAll(scanThemeNames(workspaceThemesDir()));\n        return new ArrayList<String>(names);\n    }\n\n    private TuiConfig merge(TuiConfig base, TuiConfig override) {\n        return override != null ? override : base;\n    }\n\n    private TuiConfig loadConfig(Path path) {\n        if (path == null || !Files.exists(path)) {\n            return null;\n        }\n        try {\n            return JSON.parseObject(Files.readAllBytes(path), TuiConfig.class);\n        } catch (IOException ex) {\n            return null;\n        }\n    }\n\n    private TuiTheme loadTheme(Path path) {\n        if (path == null || !Files.exists(path)) {\n            return null;\n        }\n        try {\n            return JSON.parseObject(Files.readAllBytes(path), TuiTheme.class);\n        } catch (IOException ex) {\n            return null;\n        }\n    }\n\n    private TuiTheme loadBuiltInTheme(String name) {\n        String resource = \"/io/github/lnyocly/ai4j/tui/themes/\" + name + \".json\";\n        try (InputStream inputStream = TuiConfigManager.class.getResourceAsStream(resource)) {\n            if (inputStream == null) {\n                return null;\n            }\n            byte[] bytes = readAllBytes(inputStream);\n            return JSON.parseObject(bytes, TuiTheme.class);\n        } catch (IOException ex) {\n            return null;\n        }\n    }\n\n    private byte[] readAllBytes(InputStream inputStream) throws IOException {\n        byte[] buffer = new byte[4096];\n        int read;\n        java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();\n        while ((read = inputStream.read(buffer)) >= 0) {\n            out.write(buffer, 0, read);\n        }\n        return out.toByteArray();\n    }\n\n    private List<String> scanThemeNames(Path dir) {\n        if (dir == null || !Files.exists(dir)) {\n            return Collections.emptyList();\n        }\n        try {\n            List<String> names = new ArrayList<String>();\n            java.nio.file.DirectoryStream<Path> stream = Files.newDirectoryStream(dir, \"*.json\");\n            try {\n                for (Path path : stream) {\n                    String fileName = path.getFileName().toString();\n                    names.add(fileName.substring(0, fileName.length() - 5));\n                }\n            } finally {\n                stream.close();\n            }\n            Collections.sort(names);\n            return names;\n        } catch (IOException ex) {\n            return Collections.emptyList();\n        }\n    }\n\n    private void normalize(TuiConfig config) {\n        if (config == null) {\n            return;\n        }\n        if (isBlank(config.getTheme())) {\n            config.setTheme(\"default\");\n        }\n        if (config.getMaxEvents() <= 0) {\n            config.setMaxEvents(10);\n        }\n    }\n\n    private void normalize(TuiTheme theme, String fallbackName) {\n        if (theme == null) {\n            return;\n        }\n        if (isBlank(theme.getName())) {\n            theme.setName(fallbackName);\n        }\n        if (isBlank(theme.getBrand())) {\n            theme.setBrand(\"#7cc6fe\");\n        }\n        if (isBlank(theme.getAccent())) {\n            theme.setAccent(\"#f5b14c\");\n        }\n        if (isBlank(theme.getSuccess())) {\n            theme.setSuccess(\"#8fd694\");\n        }\n        if (isBlank(theme.getWarning())) {\n            theme.setWarning(\"#f4d35e\");\n        }\n        if (isBlank(theme.getDanger())) {\n            theme.setDanger(\"#ef6f6c\");\n        }\n        if (isBlank(theme.getText())) {\n            theme.setText(\"#f3f4f6\");\n        }\n        if (isBlank(theme.getMuted())) {\n            theme.setMuted(\"#9ca3af\");\n        }\n        if (isBlank(theme.getPanelBorder())) {\n            theme.setPanelBorder(\"#4b5563\");\n        }\n        if (isBlank(theme.getPanelTitle())) {\n            theme.setPanelTitle(theme.getAccent());\n        }\n        if (isBlank(theme.getBadgeForeground())) {\n            theme.setBadgeForeground(\"#111827\");\n        }\n        if (isBlank(theme.getCodeBackground())) {\n            theme.setCodeBackground(\"#161b22\");\n        }\n        if (isBlank(theme.getCodeBorder())) {\n            theme.setCodeBorder(\"#30363d\");\n        }\n        if (isBlank(theme.getCodeText())) {\n            theme.setCodeText(\"#c9d1d9\");\n        }\n        if (isBlank(theme.getCodeKeyword())) {\n            theme.setCodeKeyword(\"#ff7b72\");\n        }\n        if (isBlank(theme.getCodeString())) {\n            theme.setCodeString(\"#a5d6ff\");\n        }\n        if (isBlank(theme.getCodeComment())) {\n            theme.setCodeComment(\"#8b949e\");\n        }\n        if (isBlank(theme.getCodeNumber())) {\n            theme.setCodeNumber(\"#79c0ff\");\n        }\n    }\n\n    private TuiTheme defaultTheme() {\n        TuiTheme theme = new TuiTheme();\n        normalize(theme, \"default\");\n        return theme;\n    }\n\n    private Path workspaceConfigPath() {\n        return workspaceRoot.resolve(\".ai4j\").resolve(\"tui.json\");\n    }\n\n    private Path workspaceThemesDir() {\n        return workspaceRoot.resolve(\".ai4j\").resolve(\"themes\");\n    }\n\n    private Path workspaceThemePath(String name) {\n        return workspaceThemesDir().resolve(name + \".json\");\n    }\n\n    private Path homeConfigPath() {\n        String userHome = System.getProperty(\"user.home\");\n        return isBlank(userHome) ? null : Paths.get(userHome).resolve(\".ai4j\").resolve(\"tui.json\");\n    }\n\n    private Path homeThemesDir() {\n        String userHome = System.getProperty(\"user.home\");\n        return isBlank(userHome) ? null : Paths.get(userHome).resolve(\".ai4j\").resolve(\"themes\");\n    }\n\n    private Path homeThemePath(String name) {\n        Path homeThemes = homeThemesDir();\n        return homeThemes == null ? null : homeThemes.resolve(name + \".json\");\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiInteractionState.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\n\npublic class TuiInteractionState {\n\n    public enum PaletteMode {\n        GLOBAL,\n        SLASH\n    }\n\n    private ApprovalSnapshot approvalSnapshot = ApprovalSnapshot.idle(null);\n    private Runnable renderCallback;\n    private TuiPanelId focusedPanel = TuiPanelId.INPUT;\n    private final StringBuilder inputBuffer = new StringBuilder();\n    private final StringBuilder processInputBuffer = new StringBuilder();\n    private boolean paletteOpen;\n    private PaletteMode paletteMode = PaletteMode.GLOBAL;\n    private String paletteQuery = \"\";\n    private int paletteSelectedIndex;\n    private List<TuiPaletteItem> paletteItems = Collections.emptyList();\n    private String selectedProcessId;\n    private boolean processInspectorOpen;\n    private boolean replayViewerOpen;\n    private boolean teamBoardOpen;\n    private int replayScrollOffset;\n    private int teamBoardScrollOffset;\n    private int transcriptScrollOffset;\n\n    public synchronized void setRenderCallback(Runnable renderCallback) {\n        this.renderCallback = renderCallback;\n    }\n\n    public synchronized ApprovalSnapshot getApprovalSnapshot() {\n        return approvalSnapshot;\n    }\n\n    public synchronized TuiPanelId getFocusedPanel() {\n        return focusedPanel;\n    }\n\n    public synchronized String getInputBuffer() {\n        return inputBuffer.toString();\n    }\n\n    public synchronized String getProcessInputBuffer() {\n        return processInputBuffer.toString();\n    }\n\n    public synchronized boolean isPaletteOpen() {\n        return paletteOpen;\n    }\n\n    public synchronized String getPaletteQuery() {\n        return paletteQuery;\n    }\n\n    public synchronized PaletteMode getPaletteMode() {\n        return paletteMode;\n    }\n\n    public synchronized int getPaletteSelectedIndex() {\n        return paletteSelectedIndex;\n    }\n\n    public synchronized List<TuiPaletteItem> getPaletteItems() {\n        return new ArrayList<TuiPaletteItem>(filterPaletteItems());\n    }\n\n    public synchronized String getSelectedProcessId() {\n        return selectedProcessId;\n    }\n\n    public synchronized boolean isProcessInspectorOpen() {\n        return processInspectorOpen;\n    }\n\n    public synchronized boolean isReplayViewerOpen() {\n        return replayViewerOpen;\n    }\n\n    public synchronized boolean isTeamBoardOpen() {\n        return teamBoardOpen;\n    }\n\n    public synchronized int getReplayScrollOffset() {\n        return replayScrollOffset;\n    }\n\n    public synchronized int getTeamBoardScrollOffset() {\n        return teamBoardScrollOffset;\n    }\n\n    public synchronized int getTranscriptScrollOffset() {\n        return transcriptScrollOffset;\n    }\n\n    public void setFocusedPanel(TuiPanelId focusedPanel) {\n        updateState(() -> this.focusedPanel = focusedPanel == null ? TuiPanelId.INPUT : focusedPanel);\n    }\n\n    public void focusNextPanel() {\n        updateState(() -> {\n            TuiPanelId[] values = TuiPanelId.values();\n            int nextIndex = (focusedPanel.ordinal() + 1) % values.length;\n            focusedPanel = values[nextIndex];\n        });\n    }\n\n    public void appendInput(String text) {\n        if (isBlank(text)) {\n            return;\n        }\n        updateState(() -> inputBuffer.append(text));\n    }\n\n    public void backspaceInput() {\n        updateState(() -> {\n            if (inputBuffer.length() > 0) {\n                inputBuffer.deleteCharAt(inputBuffer.length() - 1);\n            }\n        });\n    }\n\n    public synchronized String consumeInputBuffer() {\n        String value = inputBuffer.toString();\n        inputBuffer.setLength(0);\n        triggerRender();\n        return value;\n    }\n\n    public synchronized String consumeInputBufferSilently() {\n        String value = inputBuffer.toString();\n        inputBuffer.setLength(0);\n        return value;\n    }\n\n    public void clearInputBuffer() {\n        updateState(() -> inputBuffer.setLength(0));\n    }\n\n    public void replaceInputBuffer(String value) {\n        updateState(() -> {\n            inputBuffer.setLength(0);\n            if (!isBlank(value)) {\n                inputBuffer.append(value);\n            }\n        });\n    }\n\n    public void replaceInputBufferAndClosePalette(String value) {\n        updateState(() -> {\n            inputBuffer.setLength(0);\n            if (!isBlank(value)) {\n                inputBuffer.append(value);\n            }\n            closePaletteState();\n        });\n    }\n\n    public void appendInputAndSyncSlashPalette(String text, List<TuiPaletteItem> items) {\n        if (isBlank(text)) {\n            return;\n        }\n        updateState(() -> {\n            inputBuffer.append(text);\n            syncSlashPaletteState(items);\n        });\n    }\n\n    public void backspaceInputAndSyncSlashPalette(List<TuiPaletteItem> items) {\n        updateState(() -> {\n            if (inputBuffer.length() > 0) {\n                inputBuffer.deleteCharAt(inputBuffer.length() - 1);\n            }\n            syncSlashPaletteState(items);\n        });\n    }\n\n    public void syncSlashPalette(List<TuiPaletteItem> items) {\n        updateState(() -> syncSlashPaletteState(items));\n    }\n\n    public void appendProcessInput(String text) {\n        if (isBlank(text)) {\n            return;\n        }\n        updateState(() -> processInputBuffer.append(text));\n    }\n\n    public void backspaceProcessInput() {\n        updateState(() -> {\n            if (processInputBuffer.length() > 0) {\n                processInputBuffer.deleteCharAt(processInputBuffer.length() - 1);\n            }\n        });\n    }\n\n    public synchronized String consumeProcessInputBuffer() {\n        String value = processInputBuffer.toString();\n        processInputBuffer.setLength(0);\n        triggerRender();\n        return value;\n    }\n\n    public void clearProcessInputBuffer() {\n        updateState(() -> processInputBuffer.setLength(0));\n    }\n\n    public void openPalette(List<TuiPaletteItem> items) {\n        openPalette(items, PaletteMode.GLOBAL, \"\");\n    }\n\n    public void openSlashPalette(List<TuiPaletteItem> items, String input) {\n        openPalette(items, PaletteMode.SLASH, normalizeSlashQuery(input));\n    }\n\n    public void refreshSlashPalette(List<TuiPaletteItem> items, String input) {\n        updateState(() -> {\n            openSlashPaletteState(items, input);\n        });\n    }\n\n    private void openPalette(List<TuiPaletteItem> items, PaletteMode mode, String query) {\n        updateState(() -> {\n            paletteOpen = true;\n            paletteMode = mode == null ? PaletteMode.GLOBAL : mode;\n            paletteQuery = query == null ? \"\" : query;\n            paletteSelectedIndex = 0;\n            paletteItems = items == null ? Collections.<TuiPaletteItem>emptyList() : new ArrayList<TuiPaletteItem>(items);\n        });\n    }\n\n    public void closePalette() {\n        updateState(this::closePaletteState);\n    }\n\n    public synchronized void closePaletteSilently() {\n        closePaletteState();\n    }\n\n    public void appendPaletteQuery(String text) {\n        if (isBlank(text)) {\n            return;\n        }\n        updateState(() -> {\n            paletteQuery = paletteQuery + text;\n            paletteSelectedIndex = 0;\n        });\n    }\n\n    public void backspacePaletteQuery() {\n        updateState(() -> {\n            if (!isBlank(paletteQuery)) {\n                paletteQuery = paletteQuery.substring(0, paletteQuery.length() - 1);\n            }\n            paletteSelectedIndex = 0;\n        });\n    }\n\n    public void movePaletteSelection(int delta) {\n        updateState(() -> {\n            List<TuiPaletteItem> visible = filterPaletteItems();\n            if (visible.isEmpty()) {\n                paletteSelectedIndex = 0;\n                return;\n            }\n            int size = visible.size();\n            int index = paletteSelectedIndex + delta;\n            while (index < 0) {\n                index += size;\n            }\n            paletteSelectedIndex = index % size;\n        });\n    }\n\n    public void selectProcess(String processId) {\n        updateState(() -> this.selectedProcessId = isBlank(processId) ? null : processId);\n    }\n\n    public void selectAdjacentProcess(List<String> processIds, int delta) {\n        if (processIds == null || processIds.isEmpty()) {\n            updateState(() -> selectedProcessId = null);\n            return;\n        }\n        updateState(() -> {\n            int currentIndex = processIds.indexOf(selectedProcessId);\n            if (currentIndex < 0) {\n                selectedProcessId = delta >= 0 ? processIds.get(0) : processIds.get(processIds.size() - 1);\n                return;\n            }\n            int index = currentIndex + delta;\n            while (index < 0) {\n                index += processIds.size();\n            }\n            selectedProcessId = processIds.get(index % processIds.size());\n        });\n    }\n\n    public void openProcessInspector(String processId) {\n        updateState(() -> {\n            if (!isBlank(processId)) {\n                selectedProcessId = processId;\n            }\n            processInspectorOpen = true;\n            replayViewerOpen = false;\n            teamBoardOpen = false;\n            processInputBuffer.setLength(0);\n        });\n    }\n\n    public void closeProcessInspector() {\n        updateState(() -> {\n            processInspectorOpen = false;\n            processInputBuffer.setLength(0);\n        });\n    }\n\n    public void openReplayViewer() {\n        updateState(() -> {\n            replayViewerOpen = true;\n            processInspectorOpen = false;\n            teamBoardOpen = false;\n            replayScrollOffset = 0;\n        });\n    }\n\n    public void closeReplayViewer() {\n        updateState(() -> replayViewerOpen = false);\n    }\n\n    public void moveReplayScroll(int delta) {\n        updateState(() -> replayScrollOffset = Math.max(0, replayScrollOffset + delta));\n    }\n\n    public void openTeamBoard() {\n        updateState(() -> {\n            teamBoardOpen = true;\n            replayViewerOpen = false;\n            processInspectorOpen = false;\n            teamBoardScrollOffset = 0;\n        });\n    }\n\n    public void closeTeamBoard() {\n        updateState(() -> teamBoardOpen = false);\n    }\n\n    public void moveTeamBoardScroll(int delta) {\n        updateState(() -> teamBoardScrollOffset = Math.max(0, teamBoardScrollOffset + delta));\n    }\n\n    public void resetTranscriptScroll() {\n        synchronized (this) {\n            if (transcriptScrollOffset == 0) {\n                return;\n            }\n        }\n        updateState(() -> transcriptScrollOffset = 0);\n    }\n\n    public void moveTranscriptScroll(int delta) {\n        updateState(() -> transcriptScrollOffset = Math.max(0, transcriptScrollOffset + delta));\n    }\n\n    public synchronized TuiPaletteItem getSelectedPaletteItem() {\n        List<TuiPaletteItem> visible = filterPaletteItems();\n        if (visible.isEmpty()) {\n            return null;\n        }\n        int index = Math.max(0, Math.min(paletteSelectedIndex, visible.size() - 1));\n        return visible.get(index);\n    }\n\n    public void showApproval(String mode, String toolName, String summary) {\n        updateApproval(ApprovalSnapshot.pending(mode, toolName, summary));\n    }\n\n    public void resolveApproval(String toolName, boolean approved) {\n        updateApproval(ApprovalSnapshot.resolved(toolName, approved));\n    }\n\n    public void clearApproval() {\n        updateApproval(ApprovalSnapshot.idle(null));\n    }\n\n    private synchronized List<TuiPaletteItem> filterPaletteItems() {\n        if (paletteItems == null || paletteItems.isEmpty()) {\n            return Collections.emptyList();\n        }\n        if (isBlank(paletteQuery)) {\n            return new ArrayList<TuiPaletteItem>(paletteItems);\n        }\n        if (\"/\".equals(paletteQuery.trim())) {\n            return new ArrayList<TuiPaletteItem>(paletteItems);\n        }\n        String query = paletteQuery.toLowerCase(Locale.ROOT);\n        List<TuiPaletteItem> filtered = new ArrayList<TuiPaletteItem>();\n        for (TuiPaletteItem item : paletteItems) {\n            if (item == null) {\n                continue;\n            }\n            if (matches(query, item.getLabel())\n                    || matches(query, item.getDetail())\n                    || matches(query, item.getGroup())\n                    || matches(query, item.getCommand())) {\n                filtered.add(item);\n            }\n        }\n        return filtered;\n    }\n\n    private boolean matches(String query, String value) {\n        return !isBlank(value) && value.toLowerCase(Locale.ROOT).contains(query);\n    }\n\n    private void syncSlashPaletteState(List<TuiPaletteItem> items) {\n        String input = inputBuffer.toString();\n        if (shouldOpenSlashPalette(input)) {\n            openSlashPaletteState(items, input);\n            return;\n        }\n        if (paletteMode == PaletteMode.SLASH) {\n            closePaletteState();\n        }\n    }\n\n    private void openSlashPaletteState(List<TuiPaletteItem> items, String input) {\n        paletteOpen = true;\n        paletteMode = PaletteMode.SLASH;\n        paletteQuery = normalizeSlashQuery(input);\n        paletteSelectedIndex = 0;\n        paletteItems = items == null ? Collections.<TuiPaletteItem>emptyList() : new ArrayList<TuiPaletteItem>(items);\n    }\n\n    private void closePaletteState() {\n        paletteOpen = false;\n        paletteMode = PaletteMode.GLOBAL;\n        paletteQuery = \"\";\n        paletteSelectedIndex = 0;\n    }\n\n    private boolean shouldOpenSlashPalette(String input) {\n        return !isBlank(input)\n                && input.startsWith(\"/\")\n                && input.indexOf(' ') < 0\n                && input.indexOf('\\t') < 0;\n    }\n\n    private void updateApproval(ApprovalSnapshot snapshot) {\n        synchronized (this) {\n            this.approvalSnapshot = snapshot == null ? ApprovalSnapshot.idle(null) : snapshot;\n        }\n        triggerRender();\n    }\n\n    private void updateState(StateMutation mutation) {\n        synchronized (this) {\n            mutation.apply();\n        }\n        triggerRender();\n    }\n\n    private void triggerRender() {\n        Runnable callback;\n        synchronized (this) {\n            callback = this.renderCallback;\n        }\n        if (callback != null) {\n            callback.run();\n        }\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private String normalizeSlashQuery(String input) {\n        if (input == null) {\n            return \"\";\n        }\n        String normalized = input.trim();\n        while (normalized.startsWith(\"/\")) {\n            normalized = normalized.substring(1);\n        }\n        return normalized;\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private interface StateMutation {\n        void apply();\n    }\n\n    public static final class ApprovalSnapshot {\n\n        private final boolean pending;\n        private final String mode;\n        private final String toolName;\n        private final String summary;\n        private final String lastDecision;\n\n        private ApprovalSnapshot(boolean pending,\n                                 String mode,\n                                 String toolName,\n                                 String summary,\n                                 String lastDecision) {\n            this.pending = pending;\n            this.mode = defaultText(mode, \"auto\");\n            this.toolName = toolName;\n            this.summary = summary;\n            this.lastDecision = lastDecision;\n        }\n\n        public static ApprovalSnapshot pending(String mode, String toolName, String summary) {\n            return new ApprovalSnapshot(true, mode, toolName, summary, null);\n        }\n\n        public static ApprovalSnapshot resolved(String toolName, boolean approved) {\n            return new ApprovalSnapshot(false,\n                    \"auto\",\n                    toolName,\n                    null,\n                    \"last=\" + (approved ? \"approved\" : \"rejected\") + \" tool=\" + defaultText(toolName, \"unknown\"));\n        }\n\n        public static ApprovalSnapshot idle(String lastDecision) {\n            return new ApprovalSnapshot(false, \"auto\", null, null, lastDecision);\n        }\n\n        public boolean isPending() {\n            return pending;\n        }\n\n        public String getMode() {\n            return mode;\n        }\n\n        public String getToolName() {\n            return toolName;\n        }\n\n        public String getSummary() {\n            return summary;\n        }\n\n        public String getLastDecision() {\n            return lastDecision;\n        }\n\n        private static String defaultText(String value, String defaultValue) {\n            return value == null || value.trim().isEmpty() ? defaultValue : value;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiKeyStroke.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic class TuiKeyStroke {\n\n    private final TuiKeyType type;\n    private final String text;\n\n    private TuiKeyStroke(TuiKeyType type, String text) {\n        this.type = type == null ? TuiKeyType.UNKNOWN : type;\n        this.text = text;\n    }\n\n    public static TuiKeyStroke of(TuiKeyType type) {\n        return new TuiKeyStroke(type, null);\n    }\n\n    public static TuiKeyStroke character(String text) {\n        return new TuiKeyStroke(TuiKeyType.CHARACTER, text);\n    }\n\n    public TuiKeyType getType() {\n        return type;\n    }\n\n    public String getText() {\n        return text;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiKeyType.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic enum TuiKeyType {\n\n    CHARACTER,\n    ENTER,\n    BACKSPACE,\n    TAB,\n    ESCAPE,\n    ARROW_UP,\n    ARROW_DOWN,\n    ARROW_LEFT,\n    ARROW_RIGHT,\n    CTRL_P,\n    CTRL_R,\n    CTRL_L,\n    UNKNOWN\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiPaletteItem.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic class TuiPaletteItem {\n\n    private final String id;\n    private final String group;\n    private final String label;\n    private final String detail;\n    private final String command;\n\n    public TuiPaletteItem(String id, String group, String label, String detail, String command) {\n        this.id = id;\n        this.group = group;\n        this.label = label;\n        this.detail = detail;\n        this.command = command;\n    }\n\n    public String getId() {\n        return id;\n    }\n\n    public String getGroup() {\n        return group;\n    }\n\n    public String getLabel() {\n        return label;\n    }\n\n    public String getDetail() {\n        return detail;\n    }\n\n    public String getCommand() {\n        return command;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiPanelId.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic enum TuiPanelId {\n\n    STATUS,\n    SESSIONS,\n    HISTORY,\n    TREE,\n    CHECKPOINT,\n    PROCESSES,\n    APPROVAL,\n    COMMANDS,\n    EVENTS,\n    ASSISTANT,\n    INPUT\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiRenderContext.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic class TuiRenderContext {\n\n    private final String provider;\n    private final String protocol;\n    private final String model;\n    private final String workspace;\n    private final String sessionStore;\n    private final String sessionMode;\n    private final String approvalMode;\n    private final int terminalRows;\n    private final int terminalColumns;\n\n    private TuiRenderContext(Builder builder) {\n        this.provider = builder.provider;\n        this.protocol = builder.protocol;\n        this.model = builder.model;\n        this.workspace = builder.workspace;\n        this.sessionStore = builder.sessionStore;\n        this.sessionMode = builder.sessionMode;\n        this.approvalMode = builder.approvalMode;\n        this.terminalRows = builder.terminalRows;\n        this.terminalColumns = builder.terminalColumns;\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public String getProvider() {\n        return provider;\n    }\n\n    public String getProtocol() {\n        return protocol;\n    }\n\n    public String getModel() {\n        return model;\n    }\n\n    public String getWorkspace() {\n        return workspace;\n    }\n\n    public String getSessionStore() {\n        return sessionStore;\n    }\n\n    public String getSessionMode() {\n        return sessionMode;\n    }\n\n    public String getApprovalMode() {\n        return approvalMode;\n    }\n\n    public int getTerminalRows() {\n        return terminalRows;\n    }\n\n    public int getTerminalColumns() {\n        return terminalColumns;\n    }\n\n    public static final class Builder {\n\n        private String provider;\n        private String protocol;\n        private String model;\n        private String workspace;\n        private String sessionStore;\n        private String sessionMode;\n        private String approvalMode;\n        private int terminalRows;\n        private int terminalColumns;\n\n        private Builder() {\n        }\n\n        public Builder provider(String provider) {\n            this.provider = provider;\n            return this;\n        }\n\n        public Builder protocol(String protocol) {\n            this.protocol = protocol;\n            return this;\n        }\n\n        public Builder model(String model) {\n            this.model = model;\n            return this;\n        }\n\n        public Builder workspace(String workspace) {\n            this.workspace = workspace;\n            return this;\n        }\n\n        public Builder sessionStore(String sessionStore) {\n            this.sessionStore = sessionStore;\n            return this;\n        }\n\n        public Builder sessionMode(String sessionMode) {\n            this.sessionMode = sessionMode;\n            return this;\n        }\n\n        public Builder approvalMode(String approvalMode) {\n            this.approvalMode = approvalMode;\n            return this;\n        }\n\n        public Builder terminalRows(int terminalRows) {\n            this.terminalRows = terminalRows;\n            return this;\n        }\n\n        public Builder terminalColumns(int terminalColumns) {\n            this.terminalColumns = terminalColumns;\n            return this;\n        }\n\n        public TuiRenderContext build() {\n            return new TuiRenderContext(this);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiRenderer.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic interface TuiRenderer {\n\n    int getMaxEvents();\n\n    String getThemeName();\n\n    void updateTheme(TuiConfig config, TuiTheme theme);\n\n    String render(TuiScreenModel screenModel);\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiRuntime.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport java.io.IOException;\n\npublic interface TuiRuntime {\n\n    boolean supportsRawInput();\n\n    void enter();\n\n    void exit();\n\n    TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException;\n\n    void render(TuiScreenModel screenModel);\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiScreenModel.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint;\nimport io.github.lnyocly.ai4j.coding.CodingSessionSnapshot;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessInfo;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class TuiScreenModel {\n\n    private final TuiConfig config;\n    private final TuiTheme theme;\n    private final CodingSessionDescriptor descriptor;\n    private final CodingSessionSnapshot snapshot;\n    private final CodingSessionCheckpoint checkpoint;\n    private final TuiRenderContext renderContext;\n    private final TuiInteractionState interactionState;\n    private final List<CodingSessionDescriptor> cachedSessions;\n    private final List<String> cachedHistory;\n    private final List<String> cachedTree;\n    private final List<String> cachedCommands;\n    private final List<SessionEvent> cachedEvents;\n    private final List<String> cachedReplay;\n    private final List<String> cachedTeamBoard;\n    private final BashProcessInfo inspectedProcess;\n    private final BashProcessLogChunk inspectedProcessLogs;\n    private final String assistantOutput;\n    private final TuiAssistantViewModel assistantViewModel;\n\n    private TuiScreenModel(Builder builder) {\n        this.config = builder.config;\n        this.theme = builder.theme;\n        this.descriptor = builder.descriptor;\n        this.snapshot = builder.snapshot;\n        this.checkpoint = builder.checkpoint;\n        this.renderContext = builder.renderContext;\n        this.interactionState = builder.interactionState;\n        this.cachedSessions = builder.cachedSessions;\n        this.cachedHistory = builder.cachedHistory;\n        this.cachedTree = builder.cachedTree;\n        this.cachedCommands = builder.cachedCommands;\n        this.cachedEvents = builder.cachedEvents;\n        this.cachedReplay = builder.cachedReplay;\n        this.cachedTeamBoard = builder.cachedTeamBoard;\n        this.inspectedProcess = builder.inspectedProcess;\n        this.inspectedProcessLogs = builder.inspectedProcessLogs;\n        this.assistantOutput = builder.assistantOutput;\n        this.assistantViewModel = builder.assistantViewModel;\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public TuiConfig getConfig() {\n        return config;\n    }\n\n    public TuiTheme getTheme() {\n        return theme;\n    }\n\n    public CodingSessionDescriptor getDescriptor() {\n        return descriptor;\n    }\n\n    public CodingSessionSnapshot getSnapshot() {\n        return snapshot;\n    }\n\n    public CodingSessionCheckpoint getCheckpoint() {\n        return checkpoint;\n    }\n\n    public TuiRenderContext getRenderContext() {\n        return renderContext;\n    }\n\n    public TuiInteractionState getInteractionState() {\n        return interactionState;\n    }\n\n    public List<CodingSessionDescriptor> getCachedSessions() {\n        return cachedSessions;\n    }\n\n    public List<String> getCachedHistory() {\n        return cachedHistory;\n    }\n\n    public List<String> getCachedTree() {\n        return cachedTree;\n    }\n\n    public List<String> getCachedCommands() {\n        return cachedCommands;\n    }\n\n    public List<SessionEvent> getCachedEvents() {\n        return cachedEvents;\n    }\n\n    public List<String> getCachedReplay() {\n        return cachedReplay;\n    }\n\n    public List<String> getCachedTeamBoard() {\n        return cachedTeamBoard;\n    }\n\n    public BashProcessInfo getInspectedProcess() {\n        return inspectedProcess;\n    }\n\n    public BashProcessLogChunk getInspectedProcessLogs() {\n        return inspectedProcessLogs;\n    }\n\n    public String getAssistantOutput() {\n        return assistantOutput;\n    }\n\n    public TuiAssistantViewModel getAssistantViewModel() {\n        return assistantViewModel;\n    }\n\n    public static final class Builder {\n\n        private TuiConfig config;\n        private TuiTheme theme;\n        private CodingSessionDescriptor descriptor;\n        private CodingSessionSnapshot snapshot;\n        private CodingSessionCheckpoint checkpoint;\n        private TuiRenderContext renderContext;\n        private TuiInteractionState interactionState;\n        private List<CodingSessionDescriptor> cachedSessions = new ArrayList<CodingSessionDescriptor>();\n        private List<String> cachedHistory = new ArrayList<String>();\n        private List<String> cachedTree = new ArrayList<String>();\n        private List<String> cachedCommands = new ArrayList<String>();\n        private List<SessionEvent> cachedEvents = new ArrayList<SessionEvent>();\n        private List<String> cachedReplay = new ArrayList<String>();\n        private List<String> cachedTeamBoard = new ArrayList<String>();\n        private BashProcessInfo inspectedProcess;\n        private BashProcessLogChunk inspectedProcessLogs;\n        private String assistantOutput;\n        private TuiAssistantViewModel assistantViewModel;\n\n        private Builder() {\n        }\n\n        public Builder config(TuiConfig config) {\n            this.config = config;\n            return this;\n        }\n\n        public Builder theme(TuiTheme theme) {\n            this.theme = theme;\n            return this;\n        }\n\n        public Builder descriptor(CodingSessionDescriptor descriptor) {\n            this.descriptor = descriptor;\n            return this;\n        }\n\n        public Builder snapshot(CodingSessionSnapshot snapshot) {\n            this.snapshot = snapshot;\n            return this;\n        }\n\n        public Builder checkpoint(CodingSessionCheckpoint checkpoint) {\n            this.checkpoint = checkpoint;\n            return this;\n        }\n\n        public Builder renderContext(TuiRenderContext renderContext) {\n            this.renderContext = renderContext;\n            return this;\n        }\n\n        public Builder interactionState(TuiInteractionState interactionState) {\n            this.interactionState = interactionState;\n            return this;\n        }\n\n        public Builder cachedSessions(List<CodingSessionDescriptor> cachedSessions) {\n            this.cachedSessions = copy(cachedSessions);\n            return this;\n        }\n\n        public Builder cachedHistory(List<String> cachedHistory) {\n            this.cachedHistory = copy(cachedHistory);\n            return this;\n        }\n\n        public Builder cachedTree(List<String> cachedTree) {\n            this.cachedTree = copy(cachedTree);\n            return this;\n        }\n\n        public Builder cachedCommands(List<String> cachedCommands) {\n            this.cachedCommands = copy(cachedCommands);\n            return this;\n        }\n\n        public Builder cachedEvents(List<SessionEvent> cachedEvents) {\n            this.cachedEvents = copy(cachedEvents);\n            return this;\n        }\n\n        public Builder cachedReplay(List<String> cachedReplay) {\n            this.cachedReplay = copy(cachedReplay);\n            return this;\n        }\n\n        public Builder cachedTeamBoard(List<String> cachedTeamBoard) {\n            this.cachedTeamBoard = copy(cachedTeamBoard);\n            return this;\n        }\n\n        public Builder inspectedProcess(BashProcessInfo inspectedProcess) {\n            this.inspectedProcess = inspectedProcess;\n            return this;\n        }\n\n        public Builder inspectedProcessLogs(BashProcessLogChunk inspectedProcessLogs) {\n            this.inspectedProcessLogs = inspectedProcessLogs;\n            return this;\n        }\n\n        public Builder assistantOutput(String assistantOutput) {\n            this.assistantOutput = assistantOutput;\n            return this;\n        }\n\n        public Builder assistantViewModel(TuiAssistantViewModel assistantViewModel) {\n            this.assistantViewModel = assistantViewModel;\n            return this;\n        }\n\n        public TuiScreenModel build() {\n            return new TuiScreenModel(this);\n        }\n\n        private static <T> List<T> copy(List<T> source) {\n            return source == null ? new ArrayList<T>() : new ArrayList<T>(source);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiSessionView.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint;\nimport io.github.lnyocly.ai4j.coding.CodingSessionSnapshot;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessInfo;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class TuiSessionView implements TuiRenderer {\n\n    private static final String ASSISTANT_PREFIX = \"\\u0000assistant>\";\n    private static final String ASSISTANT_CONTINUATION_PREFIX = \"\\u0000assistant+>\";\n    private static final String REASONING_PREFIX = \"\\u0000thinking>\";\n    private static final String REASONING_CONTINUATION_PREFIX = \"\\u0000thinking+>\";\n    private static final String CODE_LINE_PREFIX = \"\\u0000code>\";\n    private static final String BULLET_PREFIX = \"\\u2022 \";\n    private static final String BULLET_CONTINUATION = \"  \";\n    private static final String THINKING_LABEL = \"\\u2022 Thinking: \";\n    private static final String NOTE_LABEL = \"\\u2022 Note: \";\n    private static final String ERROR_LABEL = \"\\u2022 Error: \";\n    private static final String PROCESS_LABEL = \"\\u2022 Process: \";\n    private static final String STARTUP_LABEL = \"\\u2022 \";\n    private static final String[] STATUS_FRAMES = new String[]{\"-\", \"\\\\\", \"|\", \"/\"};\n    private static final int TRANSCRIPT_VIEWPORT_LINES = 24;\n    private static final int MAX_REPLAY_LINES = 18;\n    private static final int MAX_TOOL_PREVIEW_LINES = 4;\n    private static final int MAX_PROCESS_LOG_LINES = 8;\n    private static final int MAX_OVERLAY_ITEMS = 8;\n    private static final Set<String> CODE_KEYWORDS = Collections.unmodifiableSet(new HashSet<String>(Arrays.asList(\n            \"abstract\", \"async\", \"await\", \"boolean\", \"break\", \"byte\", \"case\", \"catch\", \"cd\",\n            \"char\", \"class\", \"const\", \"continue\", \"default\", \"delete\", \"do\", \"docker\", \"done\",\n            \"double\", \"echo\", \"elif\", \"else\", \"enum\", \"export\", \"extends\", \"false\", \"fi\",\n            \"final\", \"finally\", \"float\", \"for\", \"function\", \"git\", \"if\", \"implements\", \"import\",\n            \"in\", \"int\", \"interface\", \"java\", \"kubectl\", \"let\", \"long\", \"mvn\", \"new\", \"null\",\n            \"npm\", \"package\", \"pnpm\", \"private\", \"protected\", \"public\", \"return\", \"rg\", \"select\",\n            \"set\", \"short\", \"static\", \"switch\", \"then\", \"this\", \"throw\", \"throws\", \"true\", \"try\",\n            \"typeof\", \"unset\", \"var\", \"void\", \"while\"\n    )));\n\n    private final boolean ansi;\n    private TuiConfig config;\n    private TuiTheme theme;\n    private List<CodingSessionDescriptor> cachedSessions = Collections.emptyList();\n    private List<String> cachedHistory = Collections.emptyList();\n    private List<String> cachedTree = Collections.emptyList();\n    private List<String> cachedCommands = Collections.emptyList();\n    private List<SessionEvent> cachedEvents = Collections.emptyList();\n    private List<String> cachedReplay = Collections.emptyList();\n    private List<String> cachedTeamBoard = Collections.emptyList();\n    private BashProcessInfo inspectedProcess;\n    private BashProcessLogChunk inspectedProcessLogs;\n    private String assistantOutput;\n    private TuiAssistantViewModel assistantViewModel;\n    private TuiInteractionState currentInteractionState;\n\n    public TuiSessionView(TuiConfig config, TuiTheme theme, boolean ansi) {\n        this.config = config == null ? new TuiConfig() : config;\n        this.theme = theme == null ? new TuiTheme() : theme;\n        this.ansi = ansi;\n    }\n\n    public void updateTheme(TuiConfig config, TuiTheme theme) {\n        this.config = config == null ? new TuiConfig() : config;\n        this.theme = theme == null ? new TuiTheme() : theme;\n    }\n\n    public int getMaxEvents() { return config == null || config.getMaxEvents() <= 0 ? 10 : config.getMaxEvents(); }\n    public String getThemeName() { return theme == null ? \"default\" : theme.getName(); }\n    public void setCachedSessions(List<CodingSessionDescriptor> sessions) { this.cachedSessions = trimObjects(sessions, 12); }\n    public void setCachedEvents(List<SessionEvent> events) { this.cachedEvents = events == null ? Collections.<SessionEvent>emptyList() : new ArrayList<SessionEvent>(events); }\n    public void setCachedHistory(List<String> historyLines) { this.cachedHistory = copyLines(historyLines, 12); }\n    public void setCachedTree(List<String> treeLines) { this.cachedTree = copyLines(treeLines, 12); }\n    public void setCachedCommands(List<String> commandLines) { this.cachedCommands = copyLines(commandLines, 20); }\n    public void setAssistantOutput(String assistantOutput) { this.assistantOutput = assistantOutput; }\n    public void setAssistantViewModel(TuiAssistantViewModel assistantViewModel) { this.assistantViewModel = assistantViewModel; }\n    public void setCachedReplay(List<String> replayLines) { this.cachedReplay = replayLines == null ? Collections.<String>emptyList() : new ArrayList<String>(replayLines); }\n    public void setCachedTeamBoard(List<String> teamBoardLines) { this.cachedTeamBoard = teamBoardLines == null ? Collections.<String>emptyList() : new ArrayList<String>(teamBoardLines); }\n    public void setProcessInspector(BashProcessInfo process, BashProcessLogChunk logs) { this.inspectedProcess = process; this.inspectedProcessLogs = logs; }\n\n    @Override\n    public String render(TuiScreenModel screenModel) {\n        TuiScreenModel model = screenModel == null ? TuiScreenModel.builder().build() : screenModel;\n        applyModel(model);\n        String header = renderHeader(model.getDescriptor(), model.getRenderContext());\n        String overlay = renderOverlay(model.getInteractionState());\n        String composer = renderComposer(model.getInteractionState());\n        String composerAddon = renderComposerAddon(model.getInteractionState());\n        int transcriptViewport = resolveTranscriptViewport(model.getRenderContext(), overlay, composerAddon);\n        StringBuilder out = new StringBuilder();\n        out.append(header);\n\n        List<String> feedLines = buildFeedLines(transcriptViewport);\n        if (!feedLines.isEmpty()) {\n            out.append('\\n').append('\\n');\n            appendLines(out, feedLines);\n        }\n\n        if (!isBlank(overlay)) {\n            out.append('\\n').append('\\n').append(overlay);\n        }\n\n        out.append('\\n').append('\\n').append(composer);\n\n        if (!isBlank(composerAddon)) {\n            out.append('\\n').append('\\n').append(composerAddon);\n        }\n        return out.toString();\n    }\n\n    public String render(ManagedCodingSession session, TuiRenderContext context, TuiInteractionState interactionState) {\n        return render(TuiScreenModel.builder()\n                .config(config).theme(theme)\n                .descriptor(session == null ? null : session.toDescriptor())\n                .snapshot(session == null || session.getSession() == null ? null : session.getSession().snapshot())\n                .checkpoint(session == null || session.getSession() == null ? null : session.getSession().exportState().getCheckpoint())\n                .renderContext(context).interactionState(interactionState)\n                .cachedSessions(cachedSessions).cachedHistory(cachedHistory).cachedTree(cachedTree)\n                .cachedCommands(cachedCommands).cachedEvents(cachedEvents).cachedReplay(cachedReplay)\n                .cachedTeamBoard(cachedTeamBoard)\n                .inspectedProcess(inspectedProcess).inspectedProcessLogs(inspectedProcessLogs)\n                .assistantOutput(assistantOutput).assistantViewModel(assistantViewModel)\n                .build());\n    }\n\n    private void applyModel(TuiScreenModel screenModel) {\n        if (screenModel == null) {\n            return;\n        }\n        if (screenModel.getConfig() != null || screenModel.getTheme() != null) {\n            updateTheme(screenModel.getConfig(), screenModel.getTheme());\n        }\n        currentInteractionState = screenModel.getInteractionState();\n        setCachedSessions(screenModel.getCachedSessions());\n        setCachedHistory(screenModel.getCachedHistory());\n        setCachedTree(screenModel.getCachedTree());\n        setCachedCommands(screenModel.getCachedCommands());\n        setCachedEvents(screenModel.getCachedEvents());\n        setCachedReplay(screenModel.getCachedReplay());\n        setCachedTeamBoard(screenModel.getCachedTeamBoard());\n        setProcessInspector(screenModel.getInspectedProcess(), screenModel.getInspectedProcessLogs());\n        setAssistantOutput(screenModel.getAssistantOutput());\n        setAssistantViewModel(screenModel.getAssistantViewModel());\n    }\n\n    private String renderHeader(CodingSessionDescriptor descriptor, TuiRenderContext context) {\n        String model = clip(firstNonBlank(\n                descriptor == null ? null : descriptor.getModel(),\n                context == null ? null : context.getModel(),\n                \"model\"\n        ), 28);\n        String workspace = clip(lastPathSegment(firstNonBlank(\n                descriptor == null ? null : descriptor.getWorkspace(),\n                context == null ? null : context.getWorkspace(),\n                \".\"\n        )), 32);\n        String sessionId = shortenSessionId(descriptor == null ? null : descriptor.getSessionId());\n        StringBuilder line = new StringBuilder();\n        line.append(TuiAnsi.bold(TuiAnsi.fg(\"AI4J\", theme.getBrand(), ansi), ansi));\n        line.append(\"  \");\n        line.append(TuiAnsi.fg(model, theme.getSuccess(), ansi));\n        line.append(\"  \");\n        line.append(TuiAnsi.fg(workspace, theme.getMuted(), ansi));\n        if (!isBlank(sessionId)) {\n            line.append(\"  \");\n            line.append(TuiAnsi.fg(sessionId, theme.getMuted(), ansi));\n        }\n        return line.toString();\n    }\n\n    private List<String> buildFeedLines(int transcriptViewport) {\n        List<String> transcriptLines = new ArrayList<String>();\n        appendEventFeed(transcriptLines);\n        appendLiveAssistantFeed(transcriptLines);\n        appendAssistantNote(transcriptLines);\n        if (transcriptLines.isEmpty()) {\n            appendStartupTips(transcriptLines);\n        }\n        trimTrailingBlankLines(transcriptLines);\n        return sliceTranscriptLines(transcriptLines, transcriptViewport);\n    }\n\n    private void appendEventFeed(List<String> lines) {\n        if (cachedEvents == null || cachedEvents.isEmpty()) {\n            return;\n        }\n        Set<String> completedToolKeys = buildCompletedToolKeys(cachedEvents);\n        for (int i = 0; i < cachedEvents.size(); i++) {\n            SessionEvent event = cachedEvents.get(i);\n            if (event == null || event.getType() == null) {\n                continue;\n            }\n            SessionEventType type = event.getType();\n            if (type == SessionEventType.USER_MESSAGE) {\n                appendWrappedText(lines, BULLET_PREFIX, firstNonBlank(payloadString(event.getPayload(), \"input\"), event.getSummary()),\n                        Integer.MAX_VALUE, Integer.MAX_VALUE);\n                lines.add(\"\");\n                continue;\n            }\n            if (type == SessionEventType.ASSISTANT_MESSAGE) {\n                String output = firstNonBlank(payloadString(event.getPayload(), \"output\"), event.getSummary());\n                if (\"reasoning\".equalsIgnoreCase(payloadString(event.getPayload(), \"kind\"))) {\n                    appendReasoningMarkdown(lines, output, Integer.MAX_VALUE, Integer.MAX_VALUE);\n                } else {\n                    appendAssistantMarkdown(lines, output, Integer.MAX_VALUE, Integer.MAX_VALUE);\n                }\n                appendBlankLineIfNeeded(lines);\n                continue;\n            }\n            if (type == SessionEventType.TOOL_CALL || type == SessionEventType.TOOL_RESULT) {\n                if (type == SessionEventType.TOOL_CALL\n                        && completedToolKeys.contains(buildToolIdentity(event.getPayload()))) {\n                    continue;\n                }\n                appendToolEventLines(lines, event);\n                appendBlankLineIfNeeded(lines);\n                continue;\n            }\n            if (type == SessionEventType.ERROR) {\n                appendWrappedText(lines, ERROR_LABEL, firstNonBlank(payloadString(event.getPayload(), \"error\"), event.getSummary()),\n                        Integer.MAX_VALUE, Integer.MAX_VALUE);\n                appendBlankLineIfNeeded(lines);\n                continue;\n            }\n            if (type == SessionEventType.SESSION_RESUMED) {\n                appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), \"session resumed\"),\n                        Integer.MAX_VALUE, Integer.MAX_VALUE);\n                appendBlankLineIfNeeded(lines);\n                continue;\n            }\n            if (type == SessionEventType.SESSION_FORKED) {\n                appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), \"session forked\"),\n                        Integer.MAX_VALUE, Integer.MAX_VALUE);\n                appendBlankLineIfNeeded(lines);\n                continue;\n            }\n            if (type == SessionEventType.TASK_CREATED || type == SessionEventType.TASK_UPDATED) {\n                appendTaskEventLines(lines, event);\n                appendBlankLineIfNeeded(lines);\n                continue;\n            }\n            if (type == SessionEventType.TEAM_MESSAGE) {\n                appendTeamMessageLines(lines, event);\n                appendBlankLineIfNeeded(lines);\n                continue;\n            }\n            if (type == SessionEventType.AUTO_CONTINUE\n                    || type == SessionEventType.AUTO_STOP\n                    || type == SessionEventType.BLOCKED) {\n                appendWrappedText(lines, NOTE_LABEL, firstNonBlank(event.getSummary(), type.name().toLowerCase().replace('_', ' ')),\n                        Integer.MAX_VALUE, Integer.MAX_VALUE);\n                appendBlankLineIfNeeded(lines);\n                continue;\n            }\n            if (type == SessionEventType.COMPACT) {\n                appendWrappedText(lines, NOTE_LABEL,\n                        firstNonBlank(event.getSummary(), payloadString(event.getPayload(), \"summary\"), \"context compacted\"),\n                        Integer.MAX_VALUE, Integer.MAX_VALUE);\n                appendBlankLineIfNeeded(lines);\n            }\n        }\n    }\n\n    private void appendLiveAssistantFeed(List<String> lines) {\n        TuiAssistantViewModel viewModel = assistantViewModel;\n        if (viewModel == null) {\n            return;\n        }\n\n        String statusLine = buildAssistantStatusLine(viewModel);\n        if (!isBlank(statusLine)) {\n            lines.add(statusLine);\n        }\n\n        appendToolLines(lines, filterUnpersistedLiveTools(viewModel.getTools()));\n\n        String reasoningText = trimToNull(viewModel.getReasoningText());\n        if (!isBlank(reasoningText)) {\n            appendBlankLineIfNeeded(lines);\n            appendReasoningMarkdown(lines, reasoningText, Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n\n        String assistantText = trimToNull(viewModel.getText());\n        if (!isBlank(assistantText)\n                && !(viewModel.getPhase() == TuiAssistantPhase.COMPLETE && assistantText.equals(lastAssistantMessage()))) {\n            appendBlankLineIfNeeded(lines);\n            appendAssistantMarkdown(lines, assistantText, Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n    }\n\n    private void appendTaskEventLines(List<String> lines, SessionEvent event) {\n        if (lines == null || event == null) {\n            return;\n        }\n        Map<String, Object> payload = event.getPayload();\n        appendWrappedText(lines, NOTE_LABEL,\n                firstNonBlank(event.getSummary(), payloadString(payload, \"title\"), \"delegate task\"),\n                Integer.MAX_VALUE, Integer.MAX_VALUE);\n        String detail = firstNonBlank(payloadString(payload, \"detail\"), payloadString(payload, \"error\"), payloadString(payload, \"output\"));\n        if (!isBlank(detail)) {\n            appendWrappedText(lines, BULLET_CONTINUATION, detail, Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n        String member = firstNonBlank(payloadString(payload, \"memberName\"), payloadString(payload, \"memberId\"));\n        if (!isBlank(member)) {\n            appendWrappedText(lines, BULLET_CONTINUATION, \"member: \" + member, Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n        String childSessionId = payloadString(payload, \"childSessionId\");\n        if (!isBlank(childSessionId)) {\n            appendWrappedText(lines, BULLET_CONTINUATION, \"child session: \" + childSessionId, Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n        String status = payloadString(payload, \"status\");\n        String phase = payloadString(payload, \"phase\");\n        String percent = payloadString(payload, \"percent\");\n        if (!isBlank(status) || !isBlank(phase) || !isBlank(percent)) {\n            StringBuilder stateLine = new StringBuilder();\n            if (!isBlank(status)) {\n                stateLine.append(\"status: \").append(status);\n            }\n            if (!isBlank(phase)) {\n                if (stateLine.length() > 0) {\n                    stateLine.append(\" | \");\n                }\n                stateLine.append(\"phase: \").append(phase);\n            }\n            if (!isBlank(percent)) {\n                if (stateLine.length() > 0) {\n                    stateLine.append(\" | \");\n                }\n                stateLine.append(\"progress: \").append(percent).append('%');\n            }\n            appendWrappedText(lines, BULLET_CONTINUATION, stateLine.toString(), Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n        String heartbeatCount = payloadString(payload, \"heartbeatCount\");\n        if (!isBlank(heartbeatCount) && !\"0\".equals(heartbeatCount)) {\n            appendWrappedText(lines, BULLET_CONTINUATION, \"heartbeats: \" + heartbeatCount, Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n    }\n\n    private void appendTeamMessageLines(List<String> lines, SessionEvent event) {\n        if (lines == null || event == null) {\n            return;\n        }\n        Map<String, Object> payload = event.getPayload();\n        appendWrappedText(lines, NOTE_LABEL,\n                firstNonBlank(event.getSummary(), payloadString(payload, \"title\"), \"team message\"),\n                Integer.MAX_VALUE, Integer.MAX_VALUE);\n        String taskId = payloadString(payload, \"taskId\");\n        if (!isBlank(taskId)) {\n            appendWrappedText(lines, BULLET_CONTINUATION, \"task: \" + taskId, Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n        String detail = firstNonBlank(payloadString(payload, \"content\"), payloadString(payload, \"detail\"));\n        if (!isBlank(detail)) {\n            appendWrappedText(lines, BULLET_CONTINUATION, detail, Integer.MAX_VALUE, Integer.MAX_VALUE);\n        }\n    }\n\n    private void appendAssistantNote(List<String> lines) {\n        String note = trimToNull(assistantOutput);\n        if (isBlank(note)) {\n            return;\n        }\n        String assistantText = assistantViewModel == null ? null : trimToNull(assistantViewModel.getText());\n        if (note.equals(assistantText)) {\n            return;\n        }\n        appendBlankLineIfNeeded(lines);\n        appendWrappedText(lines, NOTE_LABEL, note, Integer.MAX_VALUE, Integer.MAX_VALUE);\n    }\n\n    private int resolveTranscriptViewport(TuiRenderContext context, String overlay, String composerAddon) {\n        int terminalRows = context == null ? 0 : context.getTerminalRows();\n        if (terminalRows <= 0) {\n            return Math.max(8, TRANSCRIPT_VIEWPORT_LINES);\n        }\n        int reservedLines = 1; // header\n        reservedLines += 2; // gap before transcript\n        reservedLines += 2; // gap before composer\n        reservedLines += 1; // composer\n        if (!isBlank(overlay)) {\n            reservedLines += 2 + countRenderedLines(overlay);\n        }\n        if (!isBlank(composerAddon)) {\n            reservedLines += 2 + countRenderedLines(composerAddon);\n        }\n        return Math.max(4, terminalRows - reservedLines);\n    }\n\n    private List<String> sliceTranscriptLines(List<String> transcriptLines, int viewportHint) {\n        if (transcriptLines == null || transcriptLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        int viewport = viewportHint > 0 ? viewportHint : Math.max(8, TRANSCRIPT_VIEWPORT_LINES);\n        int offset = currentInteractionState == null ? 0 : Math.max(0, currentInteractionState.getTranscriptScrollOffset());\n        if (transcriptLines.size() <= viewport) {\n            return new ArrayList<String>(transcriptLines);\n        }\n        int maxOffset = Math.max(0, transcriptLines.size() - viewport);\n        int clampedOffset = Math.min(offset, maxOffset);\n        int to = transcriptLines.size() - clampedOffset;\n        int from = Math.max(0, to - viewport);\n        return new ArrayList<String>(transcriptLines.subList(from, to));\n    }\n\n    private int countRenderedLines(String value) {\n        if (isBlank(value)) {\n            return 0;\n        }\n        int count = 1;\n        for (int i = 0; i < value.length(); i++) {\n            if (value.charAt(i) == '\\n') {\n                count++;\n            }\n        }\n        return count;\n    }\n\n    private void trimTrailingBlankLines(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return;\n        }\n        while (!lines.isEmpty() && isBlank(lines.get(lines.size() - 1))) {\n            lines.remove(lines.size() - 1);\n        }\n    }\n\n    private List<TuiAssistantToolView> filterUnpersistedLiveTools(List<TuiAssistantToolView> tools) {\n        if (tools == null || tools.isEmpty()) {\n            return Collections.emptyList();\n        }\n        Set<String> persistedKeys = buildPersistedToolKeys();\n        if (persistedKeys.isEmpty()) {\n            return new ArrayList<TuiAssistantToolView>(tools);\n        }\n        List<TuiAssistantToolView> visibleTools = new ArrayList<TuiAssistantToolView>();\n        for (TuiAssistantToolView tool : tools) {\n            if (tool == null) {\n                continue;\n            }\n            if (!persistedKeys.contains(buildToolIdentity(tool))) {\n                visibleTools.add(tool);\n            }\n        }\n        return visibleTools;\n    }\n\n    private Set<String> buildPersistedToolKeys() {\n        if (cachedEvents == null || cachedEvents.isEmpty()) {\n            return Collections.emptySet();\n        }\n        Set<String> keys = new HashSet<String>();\n        for (SessionEvent event : cachedEvents) {\n            if (event == null || event.getType() == null) {\n                continue;\n            }\n            if (event.getType() != SessionEventType.TOOL_CALL && event.getType() != SessionEventType.TOOL_RESULT) {\n                continue;\n            }\n            String key = buildToolIdentity(event.getPayload());\n            if (!isBlank(key)) {\n                keys.add(key);\n            }\n        }\n        return keys;\n    }\n\n    private Set<String> buildCompletedToolKeys(List<SessionEvent> events) {\n        if (events == null || events.isEmpty()) {\n            return Collections.emptySet();\n        }\n        Set<String> keys = new HashSet<String>();\n        for (SessionEvent event : events) {\n            if (event == null || event.getType() != SessionEventType.TOOL_RESULT) {\n                continue;\n            }\n            String key = buildToolIdentity(event.getPayload());\n            if (!isBlank(key)) {\n                keys.add(key);\n            }\n        }\n        return keys;\n    }\n\n    private String buildToolIdentity(Map<String, Object> payload) {\n        if (payload == null || payload.isEmpty()) {\n            return null;\n        }\n        String callId = trimToNull(payloadString(payload, \"callId\"));\n        if (!isBlank(callId)) {\n            return callId;\n        }\n        String toolName = payloadString(payload, \"tool\");\n        JSONObject arguments = parseObject(payloadString(payload, \"arguments\"));\n        return firstNonBlank(toolName, \"tool\") + \"|\"\n                + firstNonBlank(trimToNull(payloadString(payload, \"title\")), buildToolTitle(toolName, arguments));\n    }\n\n    private String buildToolIdentity(TuiAssistantToolView tool) {\n        if (tool == null) {\n            return null;\n        }\n        String callId = trimToNull(tool.getCallId());\n        if (!isBlank(callId)) {\n            return callId;\n        }\n        return firstNonBlank(tool.getToolName(), \"tool\") + \"|\"\n                + firstNonBlank(trimToNull(tool.getTitle()), extractToolLabel(tool), \"tool\");\n    }\n\n    private void appendAssistantMarkdown(List<String> lines, String rawText, int maxLines, int maxChars) {\n        appendMarkdownBlock(lines, rawText, ASSISTANT_PREFIX, ASSISTANT_CONTINUATION_PREFIX, maxLines, maxChars);\n    }\n\n    private void appendReasoningMarkdown(List<String> lines, String rawText, int maxLines, int maxChars) {\n        if (lines == null || isBlank(rawText) || maxLines <= 0) {\n            return;\n        }\n        String[] rawLines = rawText.replace(\"\\r\", \"\").split(\"\\n\");\n        boolean inCodeBlock = false;\n        int count = 0;\n        for (String rawLine : rawLines) {\n            if (count >= maxLines) {\n                lines.add(REASONING_CONTINUATION_PREFIX + (inCodeBlock ? CODE_LINE_PREFIX + \"...\" : \"...\"));\n                return;\n            }\n            String trimmed = rawLine == null ? \"\" : rawLine.trim();\n            if (trimmed.startsWith(\"```\")) {\n                inCodeBlock = !inCodeBlock;\n                continue;\n            }\n\n            String renderedLine = inCodeBlock\n                    ? CODE_LINE_PREFIX + clipCodeLine(rawLine, Math.max(0, maxChars))\n                    : clip(rawLine, maxChars);\n            lines.add((count == 0 ? REASONING_PREFIX : REASONING_CONTINUATION_PREFIX) + renderedLine);\n            count++;\n        }\n    }\n\n    private void appendMarkdownBlock(List<String> lines,\n                                     String rawText,\n                                     String firstPrefix,\n                                     String continuationPrefix,\n                                     int maxLines,\n                                     int maxChars) {\n        if (lines == null || isBlank(rawText) || maxLines <= 0) {\n            return;\n        }\n        String[] rawLines = rawText.replace(\"\\r\", \"\").split(\"\\n\");\n        boolean inCodeBlock = false;\n        int count = 0;\n        for (String rawLine : rawLines) {\n            if (count >= maxLines) {\n                lines.add(continuationPrefix + (inCodeBlock ? CODE_LINE_PREFIX + \"...\" : \"...\"));\n                return;\n            }\n            String trimmed = rawLine == null ? \"\" : rawLine.trim();\n            if (trimmed.startsWith(\"```\")) {\n                inCodeBlock = !inCodeBlock;\n                continue;\n            }\n\n            String renderedLine = inCodeBlock\n                    ? CODE_LINE_PREFIX + clipCodeLine(rawLine, Math.max(0, maxChars))\n                    : clip(rawLine, maxChars);\n            lines.add((count == 0 ? firstPrefix : continuationPrefix) + renderedLine);\n            count++;\n        }\n    }\n\n    private void appendStartupTips(List<String> lines) {\n        String tips = trimToNull(assistantOutput);\n        if (isBlank(tips)) {\n            lines.add(STARTUP_LABEL + \"Ask AI4J to inspect this repository\");\n            lines.add(STARTUP_LABEL + \"Type `/` for commands and prompt templates\");\n            lines.add(STARTUP_LABEL + \"Use `Ctrl+R` for replay, `/team` for team board, and `Tab` to accept slash-command completion\");\n            appendRecentSessionHints(lines);\n            return;\n        }\n        appendWrappedText(lines, STARTUP_LABEL, tips, Integer.MAX_VALUE, 108);\n    }\n\n    private String buildAssistantStatusLine(TuiAssistantViewModel viewModel) {\n        if (viewModel == null || viewModel.getPhase() == null || viewModel.getPhase() == TuiAssistantPhase.IDLE) {\n            return null;\n        }\n        if (viewModel.getPhase() == TuiAssistantPhase.COMPLETE && !isBlank(viewModel.getText())) {\n            return null;\n        }\n        String phrase = normalizeStatusPhrase(viewModel.getPhaseDetail(), viewModel.getPhase());\n        if (isBlank(phrase)) {\n            return null;\n        }\n        return statusPrefix(viewModel) + \" \" + phrase;\n    }\n\n    private void appendToolLines(List<String> lines, List<TuiAssistantToolView> tools) {\n        if (tools == null || tools.isEmpty()) {\n            return;\n        }\n        int from = Math.max(0, tools.size() - 4);\n        for (int i = from; i < tools.size(); i++) {\n            TuiAssistantToolView tool = tools.get(i);\n            if (tool == null) {\n                continue;\n            }\n            appendBlankLineIfNeeded(lines);\n            List<String> primaryLines = formatToolPrimaryLines(tool);\n            for (String primaryLine : primaryLines) {\n                if (!isBlank(primaryLine)) {\n                    lines.add(primaryLine);\n                }\n            }\n            String detail = formatToolDetail(tool);\n            if (!isBlank(detail)) {\n                lines.addAll(wrapPrefixedText(\"  └ \", \"    \", detail, 108));\n            }\n            List<String> previewLines = formatToolPreviewLines(tool);\n            for (String previewLine : previewLines) {\n                lines.add(previewLine);\n            }\n        }\n    }\n\n    private void appendToolEventLines(List<String> lines, SessionEvent event) {\n        if (lines == null || event == null || event.getType() == null) {\n            return;\n        }\n        TuiAssistantToolView toolView = buildToolView(event);\n        if (toolView == null) {\n            return;\n        }\n        appendToolLines(lines, Collections.singletonList(toolView));\n    }\n\n    private TuiAssistantToolView buildToolView(SessionEvent event) {\n        if (event == null || event.getType() == null) {\n            return null;\n        }\n        Map<String, Object> payload = event.getPayload();\n        String toolName = payloadString(payload, \"tool\");\n        if (isBlank(toolName)) {\n            return null;\n        }\n        JSONObject arguments = parseObject(payloadString(payload, \"arguments\"));\n        String title = firstNonBlank(trimToNull(payloadString(payload, \"title\")), buildToolTitle(toolName, arguments));\n        List<String> previewLines = payloadLines(payload, \"previewLines\");\n        if (event.getType() == SessionEventType.TOOL_CALL) {\n            if (previewLines.isEmpty()) {\n                previewLines = buildPendingToolPreviewLines(toolName, arguments);\n            }\n            return TuiAssistantToolView.builder()\n                    .callId(payloadString(payload, \"callId\"))\n                    .toolName(toolName)\n                    .status(\"pending\")\n                    .title(title)\n                    .detail(firstNonBlank(trimToNull(payloadString(payload, \"detail\")), buildPendingToolDetail(toolName, arguments)))\n                    .previewLines(previewLines)\n                    .build();\n        }\n\n        String rawOutput = payloadString(payload, \"output\");\n        JSONObject output = parseObject(rawOutput);\n        if (previewLines.isEmpty()) {\n            previewLines = buildToolPreviewLines(toolName, arguments, output, rawOutput);\n        }\n        return TuiAssistantToolView.builder()\n                .callId(payloadString(payload, \"callId\"))\n                .toolName(toolName)\n                .status(isToolError(output, rawOutput) ? \"error\" : \"done\")\n                .title(title)\n                .detail(firstNonBlank(trimToNull(payloadString(payload, \"detail\")),\n                        buildCompletedToolDetail(toolName, arguments, output, rawOutput)))\n                .previewLines(previewLines)\n                .build();\n    }\n\n    private List<String> formatToolPrimaryLines(TuiAssistantToolView tool) {\n        List<String> lines = new ArrayList<String>();\n        String toolName = firstNonBlank(tool.getToolName(), \"tool\");\n        String status = firstNonBlank(tool.getStatus(), \"pending\").toLowerCase(Locale.ROOT);\n        String title = firstNonBlank(trimToNull(tool.getTitle()), toolName);\n        String label = normalizeToolPrimaryLabel(title);\n        if (\"error\".equals(status)) {\n            if (\"bash\".equals(toolName)) {\n                return wrapPrefixedText(\"\\u2022 Command failed \", \"  \\u2502 \", label, 108);\n            }\n            lines.add(\"\\u2022 Tool failed \" + clip(label, 92));\n            return lines;\n        }\n        if (\"apply_patch\".equals(toolName)) {\n            lines.add(\"pending\".equals(status) ? \"\\u2022 Applying patch\" : \"\\u2022 Applied patch\");\n            return lines;\n        }\n        return wrapPrefixedText(resolveToolPrimaryPrefix(toolName, title, status), \"  \\u2502 \", label, 108);\n    }\n\n    private String resolveToolPrimaryPrefix(String toolName, String title, String status) {\n        String normalizedTool = firstNonBlank(toolName, \"tool\");\n        String normalizedTitle = firstNonBlank(title, normalizedTool);\n        boolean pending = \"pending\".equalsIgnoreCase(status);\n        if (\"read_file\".equals(normalizedTool)) {\n            return pending ? \"\\u2022 Reading \" : \"\\u2022 Read \";\n        }\n        if (\"bash\".equals(normalizedTool)) {\n            if (normalizedTitle.startsWith(\"bash logs \")) {\n                return pending ? \"\\u2022 Reading logs \" : \"\\u2022 Read logs \";\n            }\n            if (normalizedTitle.startsWith(\"bash status \")) {\n                return pending ? \"\\u2022 Checking \" : \"\\u2022 Checked \";\n            }\n            if (normalizedTitle.startsWith(\"bash write \")) {\n                return pending ? \"\\u2022 Writing to \" : \"\\u2022 Wrote to \";\n            }\n            if (normalizedTitle.startsWith(\"bash stop \")) {\n                return pending ? \"\\u2022 Stopping \" : \"\\u2022 Stopped \";\n            }\n        }\n        return pending ? \"\\u2022 Running \" : \"\\u2022 Ran \";\n    }\n\n    private String normalizeToolPrimaryLabel(String title) {\n        String normalizedTitle = firstNonBlank(title, \"tool\").trim();\n        if (normalizedTitle.startsWith(\"$ \")) {\n            return normalizedTitle.substring(2).trim();\n        }\n        if (normalizedTitle.startsWith(\"read \")) {\n            return normalizedTitle.substring(5).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash logs \")) {\n            return normalizedTitle.substring(\"bash logs \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash status \")) {\n            return normalizedTitle.substring(\"bash status \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash write \")) {\n            return normalizedTitle.substring(\"bash write \".length()).trim();\n        }\n        if (normalizedTitle.startsWith(\"bash stop \")) {\n            return normalizedTitle.substring(\"bash stop \".length()).trim();\n        }\n        return normalizedTitle;\n    }\n\n    private String formatToolDetail(TuiAssistantToolView tool) {\n        if (tool == null || isBlank(tool.getDetail())) {\n            return null;\n        }\n        String detail = tool.getDetail().trim();\n        if (\"bash\".equals(firstNonBlank(tool.getToolName(), \"tool\"))\n                && !\"error\".equalsIgnoreCase(firstNonBlank(tool.getStatus(), \"\"))\n                && !\"timed out\".equalsIgnoreCase(detail)) {\n            return null;\n        }\n        String normalized = detail.toLowerCase(Locale.ROOT);\n        if (normalized.startsWith(\"running command\")\n                || normalized.startsWith(\"reading process logs\")\n                || normalized.startsWith(\"checking process status\")\n                || normalized.startsWith(\"writing to process\")\n                || normalized.startsWith(\"stopping process\")\n                || normalized.startsWith(\"running tool\")\n                || normalized.startsWith(\"fetching buffered logs\")\n                || normalized.startsWith(\"refreshing process metadata\")\n                || normalized.startsWith(\"writing to process stdin\")\n                || normalized.startsWith(\"executing bash tool\")\n                || normalized.startsWith(\"reading file content\")\n                || normalized.startsWith(\"applying workspace patch\")\n                || normalized.startsWith(\"waiting for tool result\")) {\n            return null;\n        }\n        return detail;\n    }\n\n    private List<String> formatToolPreviewLines(TuiAssistantToolView tool) {\n        List<String> rawPreviewLines = tool == null || tool.getPreviewLines() == null\n                ? Collections.<String>emptyList()\n                : tool.getPreviewLines();\n        List<String> normalizedPreviewLines = normalizeToolPreviewLines(tool, rawPreviewLines);\n        if (normalizedPreviewLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> formatted = new ArrayList<String>();\n        int max = Math.min(normalizedPreviewLines.size(), MAX_TOOL_PREVIEW_LINES);\n        for (int i = 0; i < max; i++) {\n            String prefix = i == 0 ? \"  \\u2514 \" : \"    \";\n            formatted.addAll(wrapPrefixedText(prefix, \"    \", normalizedPreviewLines.get(i), 108));\n        }\n        if (normalizedPreviewLines.size() > max) {\n            formatted.add(\"    \\u2026 +\" + (normalizedPreviewLines.size() - max) + \" lines\");\n        }\n        return formatted;\n    }\n\n    private List<String> normalizeToolPreviewLines(TuiAssistantToolView tool, List<String> previewLines) {\n        if (previewLines == null || previewLines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        String toolName = firstNonBlank(tool == null ? null : tool.getToolName(), \"tool\");\n        String status = firstNonBlank(tool == null ? null : tool.getStatus(), \"pending\").toLowerCase(Locale.ROOT);\n        List<String> normalized = new ArrayList<String>();\n        for (String previewLine : previewLines) {\n            String candidate = trimToNull(stripPreviewLabel(previewLine));\n            if (isBlank(candidate)) {\n                continue;\n            }\n            if (\"bash\".equals(toolName)) {\n                if (\"pending\".equals(status)) {\n                    continue;\n                }\n                if (\"(no command output)\".equalsIgnoreCase(candidate)) {\n                    continue;\n                }\n            }\n            if (\"apply_patch\".equals(toolName) && \"(no changed files)\".equalsIgnoreCase(candidate)) {\n                continue;\n            }\n            normalized.add(candidate);\n        }\n        return normalized;\n    }\n\n    private String stripPreviewLabel(String previewLine) {\n        String value = trimToNull(previewLine);\n        if (isBlank(value)) {\n            return value;\n        }\n        int separator = value.indexOf(\"> \");\n        if (separator > 0) {\n            String prefix = value.substring(0, separator).trim().toLowerCase(Locale.ROOT);\n            if (\"stdout\".equals(prefix)\n                    || \"stderr\".equals(prefix)\n                    || \"log\".equals(prefix)\n                    || \"file\".equals(prefix)\n                    || \"path\".equals(prefix)\n                    || \"cwd\".equals(prefix)\n                    || \"timeout\".equals(prefix)\n                    || \"process\".equals(prefix)\n                    || \"status\".equals(prefix)\n                    || \"command\".equals(prefix)\n                    || \"stdin\".equals(prefix)\n                    || \"meta\".equals(prefix)\n                    || \"out\".equals(prefix)) {\n                return value.substring(separator + 2).trim();\n            }\n        }\n        return value;\n    }\n\n    private String extractToolLabel(TuiAssistantToolView tool) {\n        if (tool == null) {\n            return \"tool\";\n        }\n        String title = trimToNull(tool.getTitle());\n        if (!isBlank(title)) {\n            return normalizeToolPrimaryLabel(title);\n        }\n        return firstNonBlank(tool.getToolName(), \"tool\");\n    }\n\n    private JSONObject parseObject(String rawJson) {\n        if (isBlank(rawJson)) {\n            return null;\n        }\n        try {\n            return JSON.parseObject(rawJson);\n        } catch (Exception ex) {\n            return null;\n        }\n    }\n\n    private boolean isToolError(JSONObject output, String rawOutput) {\n        return !isBlank(extractToolError(output, rawOutput));\n    }\n\n    private String extractToolError(JSONObject output, String rawOutput) {\n        if (!isBlank(rawOutput) && rawOutput.startsWith(\"TOOL_ERROR:\")) {\n            JSONObject errorPayload = parseObject(rawOutput.substring(\"TOOL_ERROR:\".length()).trim());\n            if (errorPayload != null && !isBlank(errorPayload.getString(\"error\"))) {\n                return errorPayload.getString(\"error\");\n            }\n            return rawOutput.substring(\"TOOL_ERROR:\".length()).trim();\n        }\n        if (!isBlank(rawOutput) && rawOutput.startsWith(\"CODE_ERROR:\")) {\n            return rawOutput.substring(\"CODE_ERROR:\".length()).trim();\n        }\n        return output == null ? null : trimToNull(output.getString(\"error\"));\n    }\n\n    private String buildToolTitle(String toolName, JSONObject arguments) {\n        if (\"bash\".equals(toolName)) {\n            String action = firstNonBlank(arguments == null ? null : arguments.getString(\"action\"), \"exec\");\n            if (\"exec\".equals(action) || \"start\".equals(action)) {\n                String command = firstNonBlank(arguments == null ? null : arguments.getString(\"command\"), null);\n                return isBlank(command) ? \"bash \" + action : \"$ \" + command;\n            }\n            if (\"write\".equals(action)) {\n                return \"bash write \" + firstNonBlank(arguments == null ? null : arguments.getString(\"processId\"), \"(process)\");\n            }\n            if (\"logs\".equals(action) || \"status\".equals(action) || \"stop\".equals(action)) {\n                return \"bash \" + action + \" \" + firstNonBlank(arguments == null ? null : arguments.getString(\"processId\"), \"(process)\");\n            }\n            return \"bash \" + action;\n        }\n        if (\"read_file\".equals(toolName)) {\n            String path = arguments == null ? null : arguments.getString(\"path\");\n            Integer startLine = arguments == null ? null : arguments.getInteger(\"startLine\");\n            Integer endLine = arguments == null ? null : arguments.getInteger(\"endLine\");\n            String range = startLine == null ? \"\" : \":\" + startLine + (endLine == null ? \"\" : \"-\" + endLine);\n            return \"read \" + firstNonBlank(path, \"(path)\") + range;\n        }\n        if (\"apply_patch\".equals(toolName)) {\n            return \"apply_patch\";\n        }\n        return firstNonBlank(toolName, \"tool\");\n    }\n\n    private String buildPendingToolDetail(String toolName, JSONObject arguments) {\n        if (\"bash\".equals(toolName)) {\n            String action = firstNonBlank(arguments == null ? null : arguments.getString(\"action\"), \"exec\");\n            if (\"exec\".equals(action) || \"start\".equals(action)) {\n                return \"Running command...\";\n            }\n            if (\"logs\".equals(action)) {\n                return \"Reading process logs...\";\n            }\n            if (\"status\".equals(action)) {\n                return \"Checking process status...\";\n            }\n            if (\"write\".equals(action)) {\n                return \"Writing to process...\";\n            }\n            if (\"stop\".equals(action)) {\n                return \"Stopping process...\";\n            }\n            return \"Running tool...\";\n        }\n        if (\"read_file\".equals(toolName)) {\n            return \"Reading file content...\";\n        }\n        if (\"apply_patch\".equals(toolName)) {\n            return \"Applying workspace patch...\";\n        }\n        return \"Running tool...\";\n    }\n\n    private List<String> buildPendingToolPreviewLines(String toolName, JSONObject arguments) {\n        return new ArrayList<String>();\n    }\n\n    private String buildCompletedToolDetail(String toolName,\n                                            JSONObject arguments,\n                                            JSONObject output,\n                                            String rawOutput) {\n        String toolError = extractToolError(output, rawOutput);\n        if (!isBlank(toolError)) {\n            return clip(toolError, 96);\n        }\n        if (\"bash\".equals(toolName)) {\n            String action = firstNonBlank(arguments == null ? null : arguments.getString(\"action\"), \"exec\");\n            if (\"exec\".equals(action) && output != null) {\n                if (output.getBooleanValue(\"timedOut\")) {\n                    return \"timed out\";\n                }\n                return null;\n            }\n            if ((\"start\".equals(action) || \"status\".equals(action) || \"stop\".equals(action)) && output != null) {\n                String processId = firstNonBlank(output.getString(\"processId\"), \"process\");\n                String status = trimToNull(output.getString(\"status\"));\n                return isBlank(status) ? processId : processId + \" | \" + status.toLowerCase(Locale.ROOT);\n            }\n            if (\"write\".equals(action) && output != null) {\n                return output.getIntValue(\"bytesWritten\") + \" bytes written\";\n            }\n            if (\"logs\".equals(action) && output != null) {\n                return null;\n            }\n            return clip(rawOutput, 96);\n        }\n        if (\"read_file\".equals(toolName) && output != null) {\n            return firstNonBlank(output.getString(\"path\"), firstNonBlank(arguments == null ? null : arguments.getString(\"path\"), \"(path)\"))\n                    + \":\" + output.getIntValue(\"startLine\") + \"-\" + output.getIntValue(\"endLine\")\n                    + (output.getBooleanValue(\"truncated\") ? \" | truncated\" : \"\");\n        }\n        if (\"apply_patch\".equals(toolName) && output != null) {\n            int filesChanged = output.getIntValue(\"filesChanged\");\n            if (filesChanged <= 0) {\n                return null;\n            }\n            return filesChanged == 1 ? \"1 file changed\" : filesChanged + \" files changed\";\n        }\n        return clip(rawOutput, 96);\n    }\n\n    private List<String> buildToolPreviewLines(String toolName,\n                                               JSONObject arguments,\n                                               JSONObject output,\n                                               String rawOutput) {\n        List<String> previewLines = new ArrayList<String>();\n        if (!isBlank(extractToolError(output, rawOutput))) {\n            return previewLines;\n        }\n        if (\"bash\".equals(toolName)) {\n            String action = firstNonBlank(arguments == null ? null : arguments.getString(\"action\"), \"exec\");\n            if (\"exec\".equals(action) && output != null) {\n                addCommandPreviewLines(previewLines, output.getString(\"stdout\"), output.getString(\"stderr\"));\n                return previewLines;\n            }\n            if (\"logs\".equals(action) && output != null) {\n                addPlainPreviewLines(previewLines, output.getString(\"content\"));\n                return previewLines;\n            }\n            if ((\"start\".equals(action) || \"status\".equals(action) || \"stop\".equals(action)) && output != null) {\n                addPreviewLine(previewLines, \"command\", output.getString(\"command\"));\n                return previewLines;\n            }\n            if (\"write\".equals(action) && output != null) {\n                return previewLines;\n            }\n            return previewLines;\n        }\n        if (\"read_file\".equals(toolName) && output != null) {\n            addPreviewLines(previewLines, \"file\", output.getString(\"content\"), 4);\n            if (previewLines.isEmpty()) {\n                previewLines.add(\"file> (empty file)\");\n            }\n            return previewLines;\n        }\n        if (\"apply_patch\".equals(toolName) && output != null) {\n            JSONArray changedFiles = output.getJSONArray(\"changedFiles\");\n            if (changedFiles != null) {\n                for (int i = 0; i < changedFiles.size() && i < 4; i++) {\n                    previewLines.add(\"file> \" + String.valueOf(changedFiles.get(i)));\n                }\n            }\n            return previewLines;\n        }\n        addPreviewLines(previewLines, \"out\", rawOutput, 4);\n        return previewLines;\n    }\n\n    private void addPreviewLines(List<String> target, String label, String raw, int maxLines) {\n        if (target == null || isBlank(raw) || maxLines <= 0) {\n            return;\n        }\n        String[] lines = raw.replace(\"\\r\", \"\").split(\"\\n\");\n        int count = 0;\n        for (String line : lines) {\n            if (isBlank(line)) {\n                continue;\n            }\n            target.add(firstNonBlank(label, \"out\") + \"> \" + clip(line, 92));\n            count++;\n            if (count >= maxLines) {\n                break;\n            }\n        }\n    }\n\n    private void addCommandPreviewLines(List<String> target, String stdout, String stderr) {\n        if (target == null) {\n            return;\n        }\n        List<String> lines = collectNonBlankLines(stdout);\n        if (lines.isEmpty()) {\n            lines = collectNonBlankLines(stderr);\n        } else {\n            List<String> stderrLines = collectNonBlankLines(stderr);\n            for (String stderrLine : stderrLines) {\n                lines.add(\"stderr: \" + stderrLine);\n            }\n        }\n        addSummarizedPreview(target, lines);\n    }\n\n    private void addPlainPreviewLines(List<String> target, String raw) {\n        if (target == null) {\n            return;\n        }\n        addSummarizedPreview(target, collectNonBlankLines(raw));\n    }\n\n    private void addSummarizedPreview(List<String> target, List<String> lines) {\n        if (target == null || lines == null || lines.isEmpty()) {\n            return;\n        }\n        if (lines.size() <= 3) {\n            for (String line : lines) {\n                target.add(clip(line, 92));\n            }\n            return;\n        }\n        target.add(clip(lines.get(0), 92));\n        target.add(\"\\u2026 +\" + (lines.size() - 2) + \" lines\");\n        target.add(clip(lines.get(lines.size() - 1), 92));\n    }\n\n    private List<String> collectNonBlankLines(String raw) {\n        if (isBlank(raw)) {\n            return Collections.emptyList();\n        }\n        String[] rawLines = raw.replace(\"\\r\", \"\").split(\"\\n\");\n        List<String> lines = new ArrayList<String>();\n        for (String rawLine : rawLines) {\n            if (!isBlank(rawLine)) {\n                lines.add(rawLine.trim());\n            }\n        }\n        return lines;\n    }\n\n    private void addPreviewLine(List<String> target, String label, String value) {\n        if (target == null || isBlank(value)) {\n            return;\n        }\n        target.add(firstNonBlank(label, \"meta\") + \"> \" + clip(value, 92));\n    }\n\n    private String renderOverlay(TuiInteractionState state) {\n        if (hasPendingApproval(state)) {\n            return renderModal(\"Approve command\", buildApprovalLines(state));\n        }\n        if (state != null && state.isProcessInspectorOpen()) {\n            return renderModal(buildProcessOverlayTitle(state), buildProcessInspectorLines(state));\n        }\n        if (state != null && state.isReplayViewerOpen()) {\n            return renderModal(\"History\", buildReplayViewerLines(state));\n        }\n        if (state != null && state.isTeamBoardOpen()) {\n            return renderModal(\"Team Board\", buildTeamBoardViewerLines(state));\n        }\n        if (state != null && state.isPaletteOpen() && state.getPaletteMode() != TuiInteractionState.PaletteMode.SLASH) {\n            return renderModal(\"Commands\", buildPaletteLines(state));\n        }\n        return null;\n    }\n\n    private String renderComposerAddon(TuiInteractionState state) {\n        if (state != null && state.isPaletteOpen() && state.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH) {\n            return renderInlinePalette(buildPaletteLines(state));\n        }\n        return null;\n    }\n\n    private String renderModal(String title, List<String> lines) {\n        StringBuilder out = new StringBuilder();\n        out.append(TuiAnsi.bold(TuiAnsi.fg(BULLET_PREFIX + firstNonBlank(title, \"dialog\"), theme.getMuted(), ansi), ansi));\n        if (lines == null || lines.isEmpty()) {\n            out.append('\\n').append(TuiAnsi.fg(\"  (none)\", theme.getMuted(), ansi));\n        } else {\n            for (String line : lines) {\n                out.append('\\n').append(renderModalLine(line));\n            }\n        }\n        return out.toString();\n    }\n\n    private String renderInlinePalette(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return TuiAnsi.fg(\"  (no matches)\", theme.getMuted(), ansi);\n        }\n        StringBuilder out = new StringBuilder();\n        for (int i = 0; i < lines.size(); i++) {\n            if (i > 0) {\n                out.append('\\n');\n            }\n            out.append(styleLine(lines.get(i)));\n        }\n        return out.toString();\n    }\n\n    private List<String> buildApprovalLines(TuiInteractionState state) {\n        TuiInteractionState.ApprovalSnapshot snapshot = state == null ? null : state.getApprovalSnapshot();\n        if (snapshot == null) {\n            return Collections.singletonList(\"(none)\");\n        }\n        List<String> lines = new ArrayList<String>();\n        String command = extractApprovalCommand(snapshot.getSummary());\n        if (!isBlank(command)) {\n            lines.add(\"`\" + clip(command, 108) + \"`\");\n        } else if (!isBlank(snapshot.getSummary())) {\n            lines.add(clip(snapshot.getSummary(), 108));\n        }\n        lines.add(\"Y approve  N reject  Esc close\");\n        return lines;\n    }\n\n    private String buildProcessOverlayTitle(TuiInteractionState state) {\n        String processId = firstNonBlank(state == null ? null : state.getSelectedProcessId(), inspectedProcess == null ? null : inspectedProcess.getProcessId(), \"process\");\n        return \"Process \" + clip(processId, 32);\n    }\n\n    private List<String> buildProcessInspectorLines(TuiInteractionState state) {\n        if (inspectedProcess == null) {\n            return Collections.singletonList(\"(no process selected)\");\n        }\n        List<String> lines = new ArrayList<String>();\n        lines.add(\"status \" + firstNonBlank(inspectedProcess.getStatus() == null ? null : inspectedProcess.getStatus().name(), \"unknown\").toLowerCase(Locale.ROOT));\n        if (!isBlank(inspectedProcess.getWorkingDirectory())) {\n            lines.add(\"cwd \" + clip(inspectedProcess.getWorkingDirectory(), 104));\n        }\n        if (!isBlank(inspectedProcess.getCommand())) {\n            lines.add(\"cmd> \" + clip(inspectedProcess.getCommand(), 103));\n        }\n        if (inspectedProcessLogs != null && !isBlank(inspectedProcessLogs.getContent())) {\n            appendMultiline(lines, inspectedProcessLogs.getContent(), MAX_PROCESS_LOG_LINES, 108);\n        }\n        if (state != null && inspectedProcess.isControlAvailable()) {\n            lines.add(\"stdin> \" + clip(state.getProcessInputBuffer(), 101));\n        }\n        return lines;\n    }\n\n    private List<String> buildReplayViewerLines(TuiInteractionState state) {\n        if (cachedReplay == null || cachedReplay.isEmpty()) {\n            return Collections.singletonList(\"(none)\");\n        }\n        int offset = state == null ? 0 : Math.max(0, state.getReplayScrollOffset());\n        int from = Math.min(offset, Math.max(0, cachedReplay.size() - 1));\n        int to = Math.min(cachedReplay.size(), from + MAX_REPLAY_LINES);\n        List<String> lines = new ArrayList<String>();\n        if (from > 0) {\n            lines.add(\"...\");\n        }\n        for (int i = from; i < to; i++) {\n            lines.add(clip(normalizeLegacyTranscriptLine(cachedReplay.get(i)), 108));\n        }\n        if (to < cachedReplay.size()) {\n            lines.add(\"...\");\n        }\n        lines.add(\"\\u2191/\\u2193 scroll  Esc close\");\n        return lines;\n    }\n\n    private List<String> buildTeamBoardViewerLines(TuiInteractionState state) {\n        if (cachedTeamBoard == null || cachedTeamBoard.isEmpty()) {\n            return Collections.singletonList(\"(none)\");\n        }\n        int offset = state == null ? 0 : Math.max(0, state.getTeamBoardScrollOffset());\n        int from = Math.min(offset, Math.max(0, cachedTeamBoard.size() - 1));\n        int to = Math.min(cachedTeamBoard.size(), from + MAX_REPLAY_LINES);\n        List<String> lines = new ArrayList<String>();\n        if (from > 0) {\n            lines.add(\"...\");\n        }\n        for (int i = from; i < to; i++) {\n            lines.add(clip(cachedTeamBoard.get(i), 108));\n        }\n        if (to < cachedTeamBoard.size()) {\n            lines.add(\"...\");\n        }\n        lines.add(\"\\u2191/\\u2193 scroll  Esc close\");\n        return lines;\n    }\n\n    private List<String> buildPaletteLines(TuiInteractionState state) {\n        List<TuiPaletteItem> items = state == null ? Collections.<TuiPaletteItem>emptyList() : state.getPaletteItems();\n        if (items == null || items.isEmpty()) {\n            return Collections.singletonList(\"(no matches)\");\n        }\n        List<String> lines = new ArrayList<String>();\n        boolean slashMode = state != null && state.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH;\n        int selectedIndex = state == null ? 0 : Math.max(0, Math.min(state.getPaletteSelectedIndex(), items.size() - 1));\n        int windowSize = Math.max(1, Math.min(items.size(), MAX_OVERLAY_ITEMS));\n        int from = Math.max(0, Math.min(selectedIndex - (windowSize / 2), items.size() - windowSize));\n        int to = Math.min(items.size(), from + windowSize);\n        if (from > 0) {\n            lines.add(\"...\");\n        }\n        for (int i = from; i < to; i++) {\n            TuiPaletteItem item = items.get(i);\n            String prefix = i == selectedIndex ? \"> \" : \"  \";\n            String label = slashMode\n                    ? firstNonBlank(item.getCommand(), item.getLabel())\n                    : firstNonBlank(item.getCommand(), item.getLabel());\n            String detail = clip(firstNonBlank(item.getDetail(), \"\"), 72);\n            String value = prefix + clip(label, 32);\n            if (!isBlank(detail)) {\n                value = clip(value + \"  \" + detail, 108);\n            }\n            lines.add(value);\n        }\n        if (to < items.size()) {\n            lines.add(\"...\");\n        }\n        return lines;\n    }\n\n    private String renderComposer(TuiInteractionState state) {\n        String prompt = state != null && state.isProcessInspectorOpen() ? \"stdin> \" : \"> \";\n        String buffer = resolveActiveInputBuffer(state);\n        if (isBlank(buffer) && (state == null || !state.isProcessInspectorOpen())) {\n            StringBuilder line = new StringBuilder();\n            line.append(TuiAnsi.bold(TuiAnsi.fg(prompt, theme.getText(), ansi), ansi));\n            line.append(TuiAnsi.fg(\"Type a request or `/` for commands\", theme.getMuted(), ansi));\n            return line.toString();\n        }\n        return TuiAnsi.bold(TuiAnsi.fg(clip(prompt + defaultText(buffer, \"\"), 120), theme.getText(), ansi), ansi);\n    }\n\n    private String renderModalLine(String line) {\n        String normalized = normalizeLegacyTranscriptLine(line);\n        if (isBlank(normalized)) {\n            return \"\";\n        }\n        if (normalized.startsWith(\"> \")) {\n            return styleLine(normalized);\n        }\n        if (\"...\".equals(normalized) || normalized.startsWith(\"\\u2191/\\u2193 \")) {\n            return TuiAnsi.fg(\"  \" + normalized, theme.getMuted(), ansi);\n        }\n        return styleLine(\"  \" + normalized);\n    }\n\n    private void appendLines(StringBuilder out, List<String> lines) {\n        for (int i = 0; i < lines.size(); i++) {\n            if (i > 0) {\n                out.append('\\n');\n            }\n            out.append(styleLine(lines.get(i)));\n        }\n    }\n\n    private String styleLine(String line) {\n        if (isBlank(line)) {\n            return \"\";\n        }\n        String normalizedLine = stripStatusPrefix(line);\n        String reasoningBody = extractReasoningBody(line);\n        if (reasoningBody != null) {\n            return renderReasoningLine(line, reasoningBody);\n        }\n        String assistantBody = extractAssistantBody(line);\n        if (assistantBody != null) {\n            return renderAssistantLine(line, assistantBody);\n        }\n        if (line.startsWith(ERROR_LABEL)\n                || normalizedLine.startsWith(\"\\u2022 Command failed \")\n                || normalizedLine.startsWith(\"\\u2022 Tool failed \")\n                || normalizedLine.startsWith(\"command failed\")\n                || normalizedLine.startsWith(\"tool failed\")\n                || normalizedLine.startsWith(\"turn failed\")) {\n            return TuiAnsi.fg(line, theme.getDanger(), ansi);\n        }\n        String lowerLine = normalizedLine.toLowerCase(Locale.ROOT);\n        if (normalizedLine.startsWith(\"\\u2022 Running \")\n                || normalizedLine.startsWith(\"\\u2022 Reading \")\n                || normalizedLine.startsWith(\"\\u2022 Applying\")\n                || normalizedLine.startsWith(\"\\u2022 Writing to process\")\n                || normalizedLine.startsWith(\"\\u2022 Stopping process\")\n                || normalizedLine.startsWith(\"thinking\")\n                || normalizedLine.startsWith(\"planning\")\n                || normalizedLine.startsWith(\"reading\")\n                || normalizedLine.startsWith(\"running\")\n                || normalizedLine.startsWith(\"applying\")\n                || normalizedLine.startsWith(\"writing to process\")\n                || normalizedLine.startsWith(\"stopping process\")\n                || normalizedLine.startsWith(\"working\")\n                || normalizedLine.startsWith(\"$ \")) {\n            return TuiAnsi.fg(line, theme.getAccent(), ansi);\n        }\n        if (normalizedLine.startsWith(\"\\u2022 Ran \")\n                || normalizedLine.startsWith(\"\\u2022 Read \")\n                || normalizedLine.startsWith(\"\\u2022 Applied patch\")\n                || lowerLine.startsWith(\"ran \")\n                || lowerLine.startsWith(\"read `\")\n                || lowerLine.startsWith(\"applied patch\")) {\n            return TuiAnsi.fg(line, theme.getSuccess(), ansi);\n        }\n        if (line.startsWith(NOTE_LABEL) || line.startsWith(PROCESS_LABEL)) {\n            return TuiAnsi.fg(line, theme.getMuted(), ansi);\n        }\n        if (line.startsWith(\"> \")) {\n            return TuiAnsi.bold(TuiAnsi.fg(line, theme.getBrand(), ansi), ansi);\n        }\n        if (line.startsWith(\"  \\u2502 \")\n                || line.startsWith(\"  \\u2514 \")\n                || line.startsWith(\"    \")) {\n            return TuiAnsi.fg(line, theme.getMuted(), ansi);\n        }\n        return TuiAnsi.fg(line, theme.getText(), ansi);\n    }\n\n    private String normalizeStatusPhrase(String detail, TuiAssistantPhase phase) {\n        String normalized = trimToNull(detail);\n        if (!isBlank(normalized)) {\n            String lower = normalized.toLowerCase(Locale.ROOT);\n            if (lower.startsWith(\"thinking about:\")\n                    || lower.startsWith(\"waiting for model output\")\n                    || lower.startsWith(\"streaming model output\")\n                    || lower.startsWith(\"tool finished, continuing\")) {\n                return \"thinking...\";\n            }\n            if (lower.startsWith(\"preparing next step\")) {\n                return \"planning...\";\n            }\n            if (lower.startsWith(\"running command\")) {\n                return \"running command...\";\n            }\n            if (lower.startsWith(\"reading file content\")) {\n                return \"reading files...\";\n            }\n            if (lower.startsWith(\"applying workspace patch\")) {\n                return \"applying patch...\";\n            }\n            if (lower.startsWith(\"fetching buffered logs\")) {\n                return \"reading logs...\";\n            }\n            if (lower.startsWith(\"refreshing process metadata\")) {\n                return \"reading process state...\";\n            }\n            if (lower.startsWith(\"writing to process stdin\")) {\n                return \"writing to process...\";\n            }\n            if (lower.startsWith(\"stopping process\")) {\n                return \"stopping process...\";\n            }\n            if (lower.startsWith(\"turn failed\") || lower.startsWith(\"agent run failed\")) {\n                return clip(lower, 96);\n            }\n            if (lower.startsWith(\"turn complete\")) {\n                return null;\n            }\n            return clip(lower, 96);\n        }\n        if (phase == TuiAssistantPhase.ERROR) {\n            return \"turn failed.\";\n        }\n        if (phase == TuiAssistantPhase.WAITING_TOOL_RESULT) {\n            return \"working...\";\n        }\n        if (phase == TuiAssistantPhase.COMPLETE) {\n            return null;\n        }\n        return \"thinking...\";\n    }\n\n    private String extractApprovalCommand(String summary) {\n        if (isBlank(summary)) {\n            return null;\n        }\n        int commandIndex = summary.indexOf(\"command=\");\n        if (commandIndex >= 0) {\n            int start = commandIndex + \"command=\".length();\n            int end = summary.indexOf(\", processId=\", start);\n            if (end < 0) {\n                end = summary.length();\n            }\n            return summary.substring(start, end).trim();\n        }\n        int patchIndex = summary.indexOf(\"patch=\");\n        if (patchIndex >= 0) {\n            return summary.substring(patchIndex + \"patch=\".length()).trim();\n        }\n        return null;\n    }\n\n    private String statusPrefix(TuiAssistantViewModel viewModel) {\n        if (viewModel == null || viewModel.getPhase() == null) {\n            return \"-\";\n        }\n        if (viewModel.getPhase() == TuiAssistantPhase.ERROR) {\n            return \"!\";\n        }\n        if (viewModel.getPhase() == TuiAssistantPhase.COMPLETE) {\n            return \"*\";\n        }\n        long tick = System.currentTimeMillis() / 160L;\n        int index = (int) (tick % STATUS_FRAMES.length);\n        return STATUS_FRAMES[index];\n    }\n\n    private String stripStatusPrefix(String line) {\n        if (line == null || line.length() < 3) {\n            return defaultText(line, \"\");\n        }\n        char first = line.charAt(0);\n        if ((first == '-' || first == '\\\\' || first == '|' || first == '/' || first == '!' || first == '*')\n                && line.charAt(1) == ' ') {\n            return line.substring(2);\n        }\n        return line;\n    }\n\n    private String renderAssistantLine(String line, String assistantBody) {\n        String trimmed = assistantBody.trim();\n        if (assistantBody.isEmpty()) {\n            return \"\";\n        }\n        boolean continuation = line.startsWith(ASSISTANT_CONTINUATION_PREFIX);\n        String visiblePrefix = continuation ? BULLET_CONTINUATION : BULLET_PREFIX;\n        if (assistantBody.startsWith(CODE_LINE_PREFIX)) {\n            return renderInlineMarkdown(visiblePrefix, theme.getText()) + renderCodeBlockContent(assistantBody.substring(CODE_LINE_PREFIX.length()));\n        }\n        if (trimmed.startsWith(\"#\")) {\n            return renderInlineMarkdown(visiblePrefix, theme.getBrand())\n                    + TuiAnsi.bold(renderInlineMarkdown(assistantBody, theme.getBrand()), ansi);\n        }\n        if (trimmed.startsWith(\">\")) {\n            return renderInlineMarkdown(visiblePrefix, theme.getMuted()) + renderInlineMarkdown(assistantBody, theme.getMuted());\n        }\n        return renderInlineMarkdown(visiblePrefix, theme.getText()) + renderInlineMarkdown(assistantBody, theme.getText());\n    }\n\n    private String renderReasoningLine(String line, String reasoningBody) {\n        String trimmed = reasoningBody.trim();\n        if (reasoningBody.isEmpty()) {\n            return \"\";\n        }\n        boolean continuation = line.startsWith(REASONING_CONTINUATION_PREFIX);\n        String visiblePrefix = continuation ? repeat(' ', THINKING_LABEL.length()) : THINKING_LABEL;\n        if (reasoningBody.startsWith(CODE_LINE_PREFIX)) {\n            return renderInlineMarkdown(visiblePrefix, theme.getMuted())\n                    + renderCodeBlockContent(reasoningBody.substring(CODE_LINE_PREFIX.length()));\n        }\n        return renderInlineMarkdown(visiblePrefix, theme.getMuted()) + renderInlineMarkdown(reasoningBody, theme.getMuted());\n    }\n\n    private String renderInlineMarkdown(String text, String baseColor) {\n        if (text == null) {\n            return \"\";\n        }\n        if (!ansi) {\n            return stripInlineMarkdown(text);\n        }\n        StringBuilder out = new StringBuilder();\n        StringBuilder buffer = new StringBuilder();\n        boolean bold = false;\n        boolean code = false;\n        for (int i = 0; i < text.length(); i++) {\n            char current = text.charAt(i);\n            if (current == '*' && i + 1 < text.length() && text.charAt(i + 1) == '*') {\n                appendStyledSegment(out, buffer.toString(), baseColor, bold, code);\n                buffer.setLength(0);\n                bold = !bold;\n                i++;\n                continue;\n            }\n            if (current == '`') {\n                appendStyledSegment(out, buffer.toString(), baseColor, bold, code);\n                buffer.setLength(0);\n                code = !code;\n                continue;\n            }\n            buffer.append(current);\n        }\n        appendStyledSegment(out, buffer.toString(), baseColor, bold, code);\n        return out.toString();\n    }\n\n    private void appendStyledSegment(StringBuilder out, String segment, String baseColor, boolean bold, boolean code) {\n        if (out == null || segment == null || segment.isEmpty()) {\n            return;\n        }\n        String color = code ? theme.getAccent() : baseColor;\n        String rendered = TuiAnsi.style(segment, color, null, ansi, bold || code);\n        out.append(rendered);\n    }\n\n    private String renderCodeBlockContent(String content) {\n        if (!ansi) {\n            return content == null ? \"\" : content;\n        }\n        return renderCodeTokens(content);\n    }\n\n    private String renderCodeTokens(String content) {\n        if (content == null || content.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder out = new StringBuilder();\n        int index = 0;\n        while (index < content.length()) {\n            char current = content.charAt(index);\n            if (Character.isWhitespace(current)) {\n                int end = index + 1;\n                while (end < content.length() && Character.isWhitespace(content.charAt(end))) {\n                    end++;\n                }\n                appendCodeToken(out, content.substring(index, end), codeTextColor(), false);\n                index = end;\n                continue;\n            }\n            if (current == '/' && index + 1 < content.length() && content.charAt(index + 1) == '/') {\n                appendCodeToken(out, content.substring(index), codeCommentColor(), false);\n                break;\n            }\n            if (current == '#' && isHashCommentStart(content, index)) {\n                appendCodeToken(out, content.substring(index), codeCommentColor(), false);\n                break;\n            }\n            if (current == '\"' || current == '\\'') {\n                int end = findStringEnd(content, index);\n                appendCodeToken(out, content.substring(index, end), codeStringColor(), false);\n                index = end;\n                continue;\n            }\n            if (isNumberStart(content, index)) {\n                int end = findNumberEnd(content, index);\n                appendCodeToken(out, content.substring(index, end), codeNumberColor(), false);\n                index = end;\n                continue;\n            }\n            if (isWordStart(current)) {\n                int end = index + 1;\n                while (end < content.length() && isWordPart(content.charAt(end))) {\n                    end++;\n                }\n                String token = content.substring(index, end);\n                boolean keyword = isCodeKeyword(token);\n                appendCodeToken(out, token, keyword ? codeKeywordColor() : codeTextColor(), keyword);\n                index = end;\n                continue;\n            }\n            appendCodeToken(out, String.valueOf(current), codeTextColor(), false);\n            index++;\n        }\n        return out.toString();\n    }\n\n    private void appendCodeToken(StringBuilder out, String token, String foreground, boolean bold) {\n        if (out == null || token == null || token.isEmpty()) {\n            return;\n        }\n        out.append(TuiAnsi.style(token, foreground, codeBackgroundColor(), ansi, bold));\n    }\n\n    private boolean isCodeKeyword(String token) {\n        if (isBlank(token)) {\n            return false;\n        }\n        return CODE_KEYWORDS.contains(token) || CODE_KEYWORDS.contains(token.toLowerCase(Locale.ROOT));\n    }\n\n    private boolean isHashCommentStart(String content, int index) {\n        return index == 0 || Character.isWhitespace(content.charAt(index - 1));\n    }\n\n    private int findStringEnd(String content, int start) {\n        char quote = content.charAt(start);\n        int index = start + 1;\n        boolean escaped = false;\n        while (index < content.length()) {\n            char current = content.charAt(index);\n            if (escaped) {\n                escaped = false;\n            } else if (current == '\\\\') {\n                escaped = true;\n            } else if (current == quote) {\n                return index + 1;\n            }\n            index++;\n        }\n        return content.length();\n    }\n\n    private boolean isNumberStart(String content, int index) {\n        char current = content.charAt(index);\n        if (Character.isDigit(current)) {\n            return true;\n        }\n        return current == '-'\n                && index + 1 < content.length()\n                && Character.isDigit(content.charAt(index + 1));\n    }\n\n    private int findNumberEnd(String content, int start) {\n        int index = start;\n        if (content.charAt(index) == '-') {\n            index++;\n        }\n        if (index + 1 < content.length()\n                && content.charAt(index) == '0'\n                && (content.charAt(index + 1) == 'x' || content.charAt(index + 1) == 'X')) {\n            index += 2;\n            while (index < content.length() && isHexDigit(content.charAt(index))) {\n                index++;\n            }\n            return index;\n        }\n        while (index < content.length()) {\n            char current = content.charAt(index);\n            if (Character.isDigit(current)\n                    || current == '_'\n                    || current == '.'\n                    || current == 'e'\n                    || current == 'E'\n                    || current == '+'\n                    || current == '-'\n                    || current == 'f'\n                    || current == 'F'\n                    || current == 'd'\n                    || current == 'D'\n                    || current == 'l'\n                    || current == 'L') {\n                index++;\n                continue;\n            }\n            break;\n        }\n        return index;\n    }\n\n    private boolean isHexDigit(char value) {\n        return Character.isDigit(value)\n                || (value >= 'a' && value <= 'f')\n                || (value >= 'A' && value <= 'F')\n                || value == '_';\n    }\n\n    private boolean isWordStart(char value) {\n        return Character.isLetter(value) || value == '_' || value == '$';\n    }\n\n    private boolean isWordPart(char value) {\n        return Character.isLetterOrDigit(value) || value == '_' || value == '$';\n    }\n\n    private String codeBackgroundColor() {\n        return firstNonBlank(theme == null ? null : theme.getCodeBackground(), \"#111827\");\n    }\n\n    private String codeTextColor() {\n        return firstNonBlank(theme == null ? null : theme.getCodeText(),\n                theme == null ? null : theme.getText(), \"#f3f4f6\");\n    }\n\n    private String codeKeywordColor() {\n        return firstNonBlank(theme == null ? null : theme.getCodeKeyword(),\n                theme == null ? null : theme.getBrand(), \"#7cc6fe\");\n    }\n\n    private String codeStringColor() {\n        return firstNonBlank(theme == null ? null : theme.getCodeString(),\n                theme == null ? null : theme.getSuccess(), \"#8fd694\");\n    }\n\n    private String codeCommentColor() {\n        return firstNonBlank(theme == null ? null : theme.getCodeComment(),\n                theme == null ? null : theme.getMuted(), \"#9ca3af\");\n    }\n\n    private String codeNumberColor() {\n        return firstNonBlank(theme == null ? null : theme.getCodeNumber(),\n                theme == null ? null : theme.getAccent(), \"#f5b14c\");\n    }\n\n    private String stripInlineMarkdown(String text) {\n        if (text == null) {\n            return \"\";\n        }\n        return text.replace(\"**\", \"\").replace(\"`\", \"\");\n    }\n\n    private String normalizeLegacyTranscriptLine(String line) {\n        if (line == null) {\n            return null;\n        }\n        if (line.startsWith(\"you> \")) {\n            return BULLET_PREFIX + line.substring(\"you> \".length());\n        }\n        if (line.startsWith(\"assistant> \")) {\n            return BULLET_PREFIX + line.substring(\"assistant> \".length());\n        }\n        if (line.startsWith(\"thinking> \")) {\n            return THINKING_LABEL + line.substring(\"thinking> \".length());\n        }\n        return line;\n    }\n\n    private String extractAssistantBody(String line) {\n        if (line == null) {\n            return null;\n        }\n        if (line.startsWith(ASSISTANT_PREFIX)) {\n            return line.substring(ASSISTANT_PREFIX.length());\n        }\n        if (line.startsWith(ASSISTANT_CONTINUATION_PREFIX)) {\n            return line.substring(ASSISTANT_CONTINUATION_PREFIX.length());\n        }\n        return null;\n    }\n\n    private String extractReasoningBody(String line) {\n        if (line == null) {\n            return null;\n        }\n        if (line.startsWith(REASONING_PREFIX)) {\n            return line.substring(REASONING_PREFIX.length());\n        }\n        if (line.startsWith(REASONING_CONTINUATION_PREFIX)) {\n            return line.substring(REASONING_CONTINUATION_PREFIX.length());\n        }\n        return null;\n    }\n\n    private String resolveActiveInputBuffer(TuiInteractionState state) {\n        if (state == null) {\n            return \"\";\n        }\n        if (state.isProcessInspectorOpen()) {\n            return state.getProcessInputBuffer();\n        }\n        return state.getInputBuffer();\n    }\n\n    private boolean hasPendingApproval(TuiInteractionState state) {\n        TuiInteractionState.ApprovalSnapshot snapshot = state == null ? null : state.getApprovalSnapshot();\n        return snapshot != null && snapshot.isPending();\n    }\n\n    private String lastAssistantMessage() {\n        if (cachedEvents == null || cachedEvents.isEmpty()) {\n            return null;\n        }\n        for (int i = cachedEvents.size() - 1; i >= 0; i--) {\n            SessionEvent event = cachedEvents.get(i);\n            if (event != null && event.getType() == SessionEventType.ASSISTANT_MESSAGE) {\n                return trimToNull(firstNonBlank(payloadString(event.getPayload(), \"output\"), event.getSummary()));\n            }\n        }\n        return null;\n    }\n\n    private void appendRecentSessionHints(List<String> lines) {\n        if (lines == null || cachedSessions == null || cachedSessions.isEmpty()) {\n            return;\n        }\n        int limit = Math.min(2, cachedSessions.size());\n        for (int i = 0; i < limit; i++) {\n            CodingSessionDescriptor session = cachedSessions.get(i);\n            if (session == null) {\n                continue;\n            }\n            String model = trimToNull(session.getModel());\n            String workspace = trimToNull(lastPathSegment(session.getWorkspace()));\n            String sessionId = shortenSessionId(session.getSessionId());\n            StringBuilder line = new StringBuilder(STARTUP_LABEL).append(\"Recent \");\n            if (!isBlank(sessionId)) {\n                line.append(sessionId);\n            } else {\n                line.append(\"session\");\n            }\n            if (!isBlank(workspace)) {\n                line.append(\"  \").append(workspace);\n            }\n            if (!isBlank(model)) {\n                line.append(\"  \").append(model);\n            }\n            lines.add(clip(line.toString(), 108));\n        }\n    }\n\n    private String shortenSessionId(String sessionId) {\n        String value = trimToNull(sessionId);\n        if (isBlank(value)) {\n            return null;\n        }\n        if (value.length() <= 12) {\n            return value;\n        }\n        return value.substring(0, 12);\n    }\n\n    private String payloadString(Map<String, Object> payload, String key) {\n        if (payload == null || isBlank(key)) {\n            return null;\n        }\n        Object value = payload.get(key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private List<String> payloadLines(Map<String, Object> payload, String key) {\n        if (payload == null || isBlank(key)) {\n            return new ArrayList<String>();\n        }\n        Object value = payload.get(key);\n        if (value == null) {\n            return new ArrayList<String>();\n        }\n        if (value instanceof Iterable<?>) {\n            return toStringLines((Iterable<?>) value);\n        }\n        if (value instanceof String) {\n            String raw = (String) value;\n            if (isBlank(raw)) {\n                return new ArrayList<String>();\n            }\n            try {\n                return toStringLines(JSON.parseArray(raw));\n            } catch (Exception ignored) {\n                return new ArrayList<String>();\n            }\n        }\n        return new ArrayList<String>();\n    }\n\n    private List<String> toStringLines(Iterable<?> source) {\n        List<String> lines = new ArrayList<String>();\n        if (source == null) {\n            return lines;\n        }\n        for (Object item : source) {\n            if (item == null) {\n                continue;\n            }\n            String line = String.valueOf(item);\n            if (!isBlank(line)) {\n                lines.add(line);\n            }\n        }\n        return lines;\n    }\n\n    private void appendWrappedText(List<String> lines, String prefix, String rawText, int maxLines, int maxChars) {\n        if (lines == null || isBlank(rawText) || maxLines <= 0) {\n            return;\n        }\n        String[] rawLines = rawText.replace(\"\\r\", \"\").split(\"\\n\");\n        String continuation = repeat(' ', prefix == null ? 0 : prefix.length());\n        int count = 0;\n        for (String rawLine : rawLines) {\n            if (count >= maxLines) {\n                lines.add(continuation + \"...\");\n                return;\n            }\n            String safeLine = clip(rawLine, maxChars);\n            lines.add((count == 0 ? defaultText(prefix, \"\") : continuation) + safeLine);\n            count++;\n        }\n    }\n\n    private void appendBlankLineIfNeeded(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return;\n        }\n        if (!isBlank(lines.get(lines.size() - 1))) {\n            lines.add(\"\");\n        }\n    }\n\n    private List<String> wrapPrefixedText(String firstPrefix, String continuationPrefix, String rawText, int maxWidth) {\n        List<String> lines = new ArrayList<String>();\n        if (isBlank(rawText)) {\n            return lines;\n        }\n        String first = firstPrefix == null ? \"\" : firstPrefix;\n        String continuation = continuationPrefix == null ? \"\" : continuationPrefix;\n        int firstWidth = Math.max(12, maxWidth - first.length());\n        int continuationWidth = Math.max(12, maxWidth - continuation.length());\n        boolean firstLine = true;\n        String[] paragraphs = rawText.replace(\"\\r\", \"\").split(\"\\n\");\n        for (String paragraph : paragraphs) {\n            String text = trimToNull(paragraph);\n            if (isBlank(text)) {\n                continue;\n            }\n            while (!isBlank(text)) {\n                int width = firstLine ? firstWidth : continuationWidth;\n                int split = findWrapIndex(text, width);\n                String chunk = text.substring(0, split).trim();\n                lines.add((firstLine ? first : continuation) + chunk);\n                text = text.substring(split).trim();\n                firstLine = false;\n            }\n        }\n        return lines;\n    }\n\n    private int findWrapIndex(String text, int width) {\n        if (isBlank(text) || text.length() <= width) {\n            return text == null ? 0 : text.length();\n        }\n        int whitespace = -1;\n        for (int i = Math.min(width, text.length() - 1); i >= 0; i--) {\n            if (Character.isWhitespace(text.charAt(i))) {\n                whitespace = i;\n                break;\n            }\n        }\n        return whitespace > 0 ? whitespace : width;\n    }\n\n    private void appendMultiline(List<String> lines, String rawContent, int maxLines, int maxChars) {\n        if (lines == null || isBlank(rawContent) || maxLines <= 0) {\n            return;\n        }\n        String[] rawLines = rawContent.replace(\"\\r\", \"\").split(\"\\n\");\n        int count = 0;\n        for (String rawLine : rawLines) {\n            if (count >= maxLines) {\n                lines.add(\"...\");\n                return;\n            }\n            if (!isBlank(rawLine)) {\n                lines.add(clip(rawLine, maxChars));\n                count++;\n            }\n        }\n    }\n\n    private List<String> copyLines(List<String> lines, int maxLines) {\n        if (lines == null || lines.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> copy = new ArrayList<String>(lines);\n        return copy.size() > maxLines ? new ArrayList<String>(copy.subList(0, maxLines)) : copy;\n    }\n\n    private <T> List<T> trimObjects(List<T> source, int maxEntries) {\n        if (source == null || source.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<T> copy = new ArrayList<T>(source);\n        return copy.size() > maxEntries ? new ArrayList<T>(copy.subList(0, maxEntries)) : copy;\n    }\n\n    private <T> List<T> tailObjects(List<T> source, int maxEntries) {\n        if (source == null || source.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<T> copy = new ArrayList<T>(source);\n        return copy.size() > maxEntries ? new ArrayList<T>(copy.subList(copy.size() - maxEntries, copy.size())) : copy;\n    }\n\n    private List<String> tailLines(List<String> source, int maxEntries) {\n        if (source == null || source.isEmpty()) {\n            return Collections.emptyList();\n        }\n        return source.size() > maxEntries\n                ? new ArrayList<String>(source.subList(source.size() - maxEntries, source.size()))\n                : new ArrayList<String>(source);\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private String defaultText(String value, String fallback) {\n        return isBlank(value) ? fallback : value;\n    }\n\n    private String trimToNull(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private String lastPathSegment(String value) {\n        if (isBlank(value)) {\n            return value;\n        }\n        String normalized = value.replace('\\\\', '/');\n        int index = normalized.lastIndexOf('/');\n        return index >= 0 && index + 1 < normalized.length() ? normalized.substring(index + 1) : normalized;\n    }\n\n    private String repeat(char c, int count) {\n        if (count <= 0) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder(count);\n        for (int i = 0; i < count; i++) {\n            builder.append(c);\n        }\n        return builder.toString();\n    }\n\n    private String line(char c, int width) {\n        return repeat(c, width);\n    }\n\n    private String clip(String value, int maxChars) {\n        if (value == null) {\n            return \"\";\n        }\n        String normalized = value.replace('\\r', ' ').replace('\\n', ' ').trim();\n        return normalized.length() <= maxChars ? normalized : normalized.substring(0, maxChars) + \"...\";\n    }\n\n    private String clipCodeLine(String value, int maxChars) {\n        if (value == null) {\n            return \"\";\n        }\n        String normalized = value.replace('\\r', ' ').replace('\\t', ' ');\n        return normalized.length() <= maxChars ? normalized : normalized.substring(0, maxChars) + \"...\";\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/TuiTheme.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\npublic class TuiTheme {\n\n    private String name;\n    private String brand;\n    private String accent;\n    private String success;\n    private String warning;\n    private String danger;\n    private String text;\n    private String muted;\n    private String panelBorder;\n    private String panelTitle;\n    private String badgeForeground;\n    private String codeBackground;\n    private String codeBorder;\n    private String codeText;\n    private String codeKeyword;\n    private String codeString;\n    private String codeComment;\n    private String codeNumber;\n\n    public String getName() {\n        return name;\n    }\n\n    public void setName(String name) {\n        this.name = name;\n    }\n\n    public String getBrand() {\n        return brand;\n    }\n\n    public void setBrand(String brand) {\n        this.brand = brand;\n    }\n\n    public String getAccent() {\n        return accent;\n    }\n\n    public void setAccent(String accent) {\n        this.accent = accent;\n    }\n\n    public String getSuccess() {\n        return success;\n    }\n\n    public void setSuccess(String success) {\n        this.success = success;\n    }\n\n    public String getWarning() {\n        return warning;\n    }\n\n    public void setWarning(String warning) {\n        this.warning = warning;\n    }\n\n    public String getDanger() {\n        return danger;\n    }\n\n    public void setDanger(String danger) {\n        this.danger = danger;\n    }\n\n    public String getText() {\n        return text;\n    }\n\n    public void setText(String text) {\n        this.text = text;\n    }\n\n    public String getMuted() {\n        return muted;\n    }\n\n    public void setMuted(String muted) {\n        this.muted = muted;\n    }\n\n    public String getPanelBorder() {\n        return panelBorder;\n    }\n\n    public void setPanelBorder(String panelBorder) {\n        this.panelBorder = panelBorder;\n    }\n\n    public String getPanelTitle() {\n        return panelTitle;\n    }\n\n    public void setPanelTitle(String panelTitle) {\n        this.panelTitle = panelTitle;\n    }\n\n    public String getBadgeForeground() {\n        return badgeForeground;\n    }\n\n    public void setBadgeForeground(String badgeForeground) {\n        this.badgeForeground = badgeForeground;\n    }\n\n    public String getCodeBackground() {\n        return codeBackground;\n    }\n\n    public void setCodeBackground(String codeBackground) {\n        this.codeBackground = codeBackground;\n    }\n\n    public String getCodeBorder() {\n        return codeBorder;\n    }\n\n    public void setCodeBorder(String codeBorder) {\n        this.codeBorder = codeBorder;\n    }\n\n    public String getCodeText() {\n        return codeText;\n    }\n\n    public void setCodeText(String codeText) {\n        this.codeText = codeText;\n    }\n\n    public String getCodeKeyword() {\n        return codeKeyword;\n    }\n\n    public void setCodeKeyword(String codeKeyword) {\n        this.codeKeyword = codeKeyword;\n    }\n\n    public String getCodeString() {\n        return codeString;\n    }\n\n    public void setCodeString(String codeString) {\n        this.codeString = codeString;\n    }\n\n    public String getCodeComment() {\n        return codeComment;\n    }\n\n    public void setCodeComment(String codeComment) {\n        this.codeComment = codeComment;\n    }\n\n    public String getCodeNumber() {\n        return codeNumber;\n    }\n\n    public void setCodeNumber(String codeNumber) {\n        this.codeNumber = codeNumber;\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/io/DefaultJlineTerminalIO.java",
    "content": "package io.github.lnyocly.ai4j.tui.io;\n\nimport io.github.lnyocly.ai4j.tui.StreamsTerminalIO;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiKeyStroke;\nimport io.github.lnyocly.ai4j.tui.TuiKeyType;\nimport org.jline.reader.EndOfFileException;\nimport org.jline.reader.LineReader;\nimport org.jline.reader.LineReaderBuilder;\nimport org.jline.reader.UserInterruptException;\nimport org.jline.terminal.Attributes;\nimport org.jline.terminal.Terminal;\nimport org.jline.terminal.TerminalBuilder;\nimport org.jline.utils.InfoCmp;\nimport org.jline.utils.NonBlockingReader;\n\nimport java.io.IOException;\nimport java.io.OutputStream;\nimport java.io.OutputStreamWriter;\nimport java.io.PrintWriter;\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\n\npublic class DefaultJlineTerminalIO implements TerminalIO {\n\n    private static final long ESCAPE_SEQUENCE_TIMEOUT_MS = 25L;\n\n    private final Terminal terminal;\n    private final LineReader lineReader;\n    private final NonBlockingReader reader;\n    private final PrintWriter out;\n    private final PrintWriter err;\n    private Attributes originalAttributes;\n    private boolean rawMode;\n    private boolean closed;\n    private boolean inputClosed;\n    private Integer pendingChar;\n\n    protected DefaultJlineTerminalIO(Terminal terminal, OutputStream errStream) {\n        this.terminal = terminal;\n        this.lineReader = LineReaderBuilder.builder().terminal(terminal).build();\n        this.reader = terminal.reader();\n        this.out = terminal.writer();\n        Charset charset = terminal.encoding() == null ? StandardCharsets.UTF_8 : terminal.encoding();\n        this.err = new PrintWriter(new OutputStreamWriter(errStream, charset), true);\n    }\n\n    public static DefaultJlineTerminalIO openSystem(OutputStream errStream) throws IOException {\n        Charset charset = StreamsTerminalIO.resolveTerminalCharset();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(true)\n                .encoding(charset)\n                .build();\n        return new DefaultJlineTerminalIO(terminal, errStream);\n    }\n\n    @Override\n    public String readLine(String prompt) throws IOException {\n        restoreRawMode();\n        print(prompt == null ? \"\" : prompt);\n        StringBuilder builder = new StringBuilder();\n        while (true) {\n            int ch;\n            try {\n                ch = readChar();\n            } catch (EndOfFileException ex) {\n                inputClosed = true;\n                return builder.length() == 0 ? null : builder.toString();\n            } catch (UserInterruptException ex) {\n                return \"\";\n            }\n            if (ch < 0) {\n                inputClosed = true;\n                return builder.length() == 0 ? null : builder.toString();\n            }\n            if (ch == '\\n') {\n                return builder.toString();\n            }\n            if (ch == '\\r') {\n                int next = readChar(5L);\n                if (next >= 0 && next != '\\n') {\n                    unreadChar(next);\n                }\n                return builder.toString();\n            }\n            builder.append((char) ch);\n        }\n    }\n\n    @Override\n    public synchronized TuiKeyStroke readKeyStroke() throws IOException {\n        ensureRawMode();\n        int ch = readChar();\n        return toKeyStroke(ch);\n    }\n\n    @Override\n    public synchronized TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException {\n        ensureRawMode();\n        int ch = timeoutMs <= 0L ? readChar() : readChar(timeoutMs);\n        if (ch == NonBlockingReader.READ_EXPIRED) {\n            return null;\n        }\n        return toKeyStroke(ch);\n    }\n\n    @Override\n    public void print(String message) {\n        out.print(message == null ? \"\" : message);\n        out.flush();\n        terminal.flush();\n    }\n\n    @Override\n    public void println(String message) {\n        out.println(message == null ? \"\" : message);\n        out.flush();\n        terminal.flush();\n    }\n\n    @Override\n    public void errorln(String message) {\n        err.println(message == null ? \"\" : message);\n        err.flush();\n    }\n\n    @Override\n    public boolean supportsAnsi() {\n        return terminal != null && !\"dumb\".equalsIgnoreCase(terminal.getType());\n    }\n\n    @Override\n    public boolean supportsRawInput() {\n        return terminal != null;\n    }\n\n    @Override\n    public synchronized boolean isInputClosed() {\n        return inputClosed;\n    }\n\n    @Override\n    public void clearScreen() {\n        puts(InfoCmp.Capability.clear_screen);\n    }\n\n    @Override\n    public void enterAlternateScreen() {\n        puts(InfoCmp.Capability.enter_ca_mode);\n    }\n\n    @Override\n    public void exitAlternateScreen() {\n        puts(InfoCmp.Capability.exit_ca_mode);\n    }\n\n    @Override\n    public void hideCursor() {\n        puts(InfoCmp.Capability.cursor_invisible);\n    }\n\n    @Override\n    public void showCursor() {\n        puts(InfoCmp.Capability.cursor_normal);\n    }\n\n    @Override\n    public void moveCursorHome() {\n        puts(InfoCmp.Capability.cursor_home);\n    }\n\n    @Override\n    public int getTerminalRows() {\n        return terminal == null ? 0 : Math.max(0, terminal.getHeight());\n    }\n\n    @Override\n    public int getTerminalColumns() {\n        return terminal == null ? 0 : Math.max(0, terminal.getWidth());\n    }\n\n    @Override\n    public synchronized void close() throws IOException {\n        if (closed) {\n            return;\n        }\n        restoreRawMode();\n        terminal.close();\n        closed = true;\n    }\n\n    private TuiKeyStroke toKeyStroke(int ch) throws IOException {\n        if (ch < 0) {\n            inputClosed = true;\n            return null;\n        }\n        switch (ch) {\n            case '\\r':\n            case '\\n':\n                return TuiKeyStroke.of(TuiKeyType.ENTER);\n            case '\\t':\n                return TuiKeyStroke.of(TuiKeyType.TAB);\n            case '\\b':\n            case 127:\n                return TuiKeyStroke.of(TuiKeyType.BACKSPACE);\n            case 12:\n                return TuiKeyStroke.of(TuiKeyType.CTRL_L);\n            case 16:\n                return TuiKeyStroke.of(TuiKeyType.CTRL_P);\n            case 18:\n                return TuiKeyStroke.of(TuiKeyType.CTRL_R);\n            case 27:\n                return readEscapeSequence();\n            default:\n                if (Character.isISOControl((char) ch)) {\n                    return TuiKeyStroke.of(TuiKeyType.UNKNOWN);\n                }\n                return TuiKeyStroke.character(String.valueOf((char) ch));\n        }\n    }\n\n    private TuiKeyStroke readEscapeSequence() throws IOException {\n        int next = readChar(ESCAPE_SEQUENCE_TIMEOUT_MS);\n        if (next == NonBlockingReader.READ_EXPIRED || next < 0) {\n            return TuiKeyStroke.of(TuiKeyType.ESCAPE);\n        }\n        if (next != '[' && next != 'O') {\n            unreadChar(next);\n            return TuiKeyStroke.of(TuiKeyType.ESCAPE);\n        }\n        int code = readChar(ESCAPE_SEQUENCE_TIMEOUT_MS);\n        if (code == NonBlockingReader.READ_EXPIRED || code < 0) {\n            return TuiKeyStroke.of(TuiKeyType.ESCAPE);\n        }\n        switch (code) {\n            case 'A':\n                return TuiKeyStroke.of(TuiKeyType.ARROW_UP);\n            case 'B':\n                return TuiKeyStroke.of(TuiKeyType.ARROW_DOWN);\n            case 'C':\n                return TuiKeyStroke.of(TuiKeyType.ARROW_RIGHT);\n            case 'D':\n                return TuiKeyStroke.of(TuiKeyType.ARROW_LEFT);\n            default:\n                return TuiKeyStroke.of(TuiKeyType.ESCAPE);\n        }\n    }\n\n    private void ensureRawMode() {\n        if (rawMode || terminal == null) {\n            return;\n        }\n        originalAttributes = terminal.enterRawMode();\n        rawMode = true;\n    }\n\n    private void restoreRawMode() {\n        if (!rawMode || terminal == null || originalAttributes == null) {\n            return;\n        }\n        terminal.setAttributes(originalAttributes);\n        rawMode = false;\n    }\n\n    private void puts(InfoCmp.Capability capability) {\n        if (!supportsAnsi() || capability == null) {\n            return;\n        }\n        terminal.puts(capability);\n        terminal.flush();\n    }\n\n    private int readChar() throws IOException {\n        if (pendingChar != null) {\n            int value = pendingChar.intValue();\n            pendingChar = null;\n            return value;\n        }\n        return reader.read();\n    }\n\n    private int readChar(long timeoutMs) throws IOException {\n        if (pendingChar != null) {\n            int value = pendingChar.intValue();\n            pendingChar = null;\n            return value;\n        }\n        return reader.read(timeoutMs);\n    }\n\n    private void unreadChar(int ch) {\n        if (ch >= 0) {\n            pendingChar = Integer.valueOf(ch);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/io/DefaultStreamsTerminalIO.java",
    "content": "package io.github.lnyocly.ai4j.tui.io;\n\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiKeyStroke;\nimport io.github.lnyocly.ai4j.tui.TuiKeyType;\nimport java.io.Console;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.io.OutputStreamWriter;\nimport java.io.PrintWriter;\nimport java.io.PushbackReader;\nimport java.lang.reflect.Method;\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\n\npublic class DefaultStreamsTerminalIO implements TerminalIO {\n\n    private static final String TERMINAL_ENCODING_PROPERTY = \"ai4j.terminal.encoding\";\n    private static final String TERMINAL_ENCODING_ENV = \"AI4J_TERMINAL_ENCODING\";\n    private static final String ANSI_CLEAR = \"\\u001b[2J\\u001b[H\";\n    private static final String ANSI_ALT_SCREEN_ON = \"\\u001b[?1049h\";\n    private static final String ANSI_ALT_SCREEN_OFF = \"\\u001b[?1049l\";\n    private static final String ANSI_CURSOR_HIDE = \"\\u001b[?25l\";\n    private static final String ANSI_CURSOR_SHOW = \"\\u001b[?25h\";\n    private static final String ANSI_CURSOR_HOME = \"\\u001b[H\";\n\n    private final InputStream inputStream;\n    private final PushbackReader reader;\n    private final PrintWriter out;\n    private final PrintWriter err;\n    private final boolean ansiSupported;\n    private boolean inputClosed;\n\n    public DefaultStreamsTerminalIO(InputStream in, OutputStream out, OutputStream err) {\n        this(in, out, err, resolveTerminalCharset(), detectAnsiSupport());\n    }\n\n    protected DefaultStreamsTerminalIO(InputStream in, OutputStream out, OutputStream err, boolean ansiSupported) {\n        this(in, out, err, resolveTerminalCharset(), ansiSupported);\n    }\n\n    protected DefaultStreamsTerminalIO(InputStream in,\n                      OutputStream out,\n                      OutputStream err,\n                      Charset charset,\n                      boolean ansiSupported) {\n        this.inputStream = in;\n        Charset ioCharset = charset == null ? resolveTerminalCharset() : charset;\n        this.reader = new PushbackReader(new InputStreamReader(in, ioCharset), 8);\n        this.out = new PrintWriter(new OutputStreamWriter(out, ioCharset), true);\n        this.err = new PrintWriter(new OutputStreamWriter(err, ioCharset), true);\n        this.ansiSupported = ansiSupported;\n    }\n\n    @Override\n    public synchronized String readLine(String prompt) throws IOException {\n        print(prompt);\n        StringBuilder builder = new StringBuilder();\n        while (true) {\n            int ch = reader.read();\n            if (ch < 0) {\n                inputClosed = true;\n                return builder.length() == 0 ? null : builder.toString();\n            }\n            if (ch == '\\n') {\n                return builder.toString();\n            }\n            if (ch == '\\r') {\n                int next = reader.read();\n                if (next >= 0 && next != '\\n') {\n                    reader.unread(next);\n                }\n                return builder.toString();\n            }\n            builder.append((char) ch);\n        }\n    }\n\n    @Override\n    public synchronized TuiKeyStroke readKeyStroke() throws IOException {\n        int ch = reader.read();\n        if (ch < 0) {\n            inputClosed = true;\n            return null;\n        }\n        switch (ch) {\n            case '\\r':\n            case '\\n':\n                return TuiKeyStroke.of(TuiKeyType.ENTER);\n            case '\\t':\n                return TuiKeyStroke.of(TuiKeyType.TAB);\n            case '\\b':\n            case 127:\n                return TuiKeyStroke.of(TuiKeyType.BACKSPACE);\n            case 12:\n                return TuiKeyStroke.of(TuiKeyType.CTRL_L);\n            case 16:\n                return TuiKeyStroke.of(TuiKeyType.CTRL_P);\n            case 18:\n                return TuiKeyStroke.of(TuiKeyType.CTRL_R);\n            case 27:\n                return readEscapeSequence();\n            default:\n                if (Character.isISOControl((char) ch)) {\n                    return TuiKeyStroke.of(TuiKeyType.UNKNOWN);\n                }\n                return TuiKeyStroke.character(String.valueOf((char) ch));\n        }\n    }\n\n    @Override\n    public synchronized TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException {\n        if (inputStream != System.in) {\n            return readKeyStroke();\n        }\n        if (timeoutMs <= 0L) {\n            return readKeyStroke();\n        }\n        long deadline = System.currentTimeMillis() + timeoutMs;\n        while (System.currentTimeMillis() < deadline) {\n            if (hasBufferedInput()) {\n                return readKeyStroke();\n            }\n            try {\n                Thread.sleep(20L);\n            } catch (InterruptedException ex) {\n                Thread.currentThread().interrupt();\n                return null;\n            }\n        }\n        return hasBufferedInput() ? readKeyStroke() : null;\n    }\n\n    @Override\n    public void print(String message) {\n        out.print(message == null ? \"\" : message);\n        out.flush();\n    }\n\n    @Override\n    public void println(String message) {\n        out.println(message == null ? \"\" : message);\n        out.flush();\n    }\n\n    @Override\n    public void errorln(String message) {\n        err.println(message == null ? \"\" : message);\n        err.flush();\n    }\n\n    @Override\n    public boolean supportsAnsi() {\n        return ansiSupported;\n    }\n\n    @Override\n    public boolean supportsRawInput() {\n        return inputStream != System.in;\n    }\n\n    @Override\n    public synchronized boolean isInputClosed() {\n        return inputClosed;\n    }\n\n    @Override\n    public void clearScreen() {\n        if (!ansiSupported) {\n            return;\n        }\n        out.print(ANSI_CLEAR);\n        out.flush();\n    }\n\n    @Override\n    public void enterAlternateScreen() {\n        emitAnsi(ANSI_ALT_SCREEN_ON);\n    }\n\n    @Override\n    public void exitAlternateScreen() {\n        emitAnsi(ANSI_ALT_SCREEN_OFF);\n    }\n\n    @Override\n    public void hideCursor() {\n        emitAnsi(ANSI_CURSOR_HIDE);\n    }\n\n    @Override\n    public void showCursor() {\n        emitAnsi(ANSI_CURSOR_SHOW);\n    }\n\n    @Override\n    public void moveCursorHome() {\n        emitAnsi(ANSI_CURSOR_HOME);\n    }\n\n    @Override\n    public int getTerminalRows() {\n        return parsePositiveInt(System.getenv(\"LINES\"), 24);\n    }\n\n    @Override\n    public int getTerminalColumns() {\n        return parsePositiveInt(System.getenv(\"COLUMNS\"), 80);\n    }\n\n    private TuiKeyStroke readEscapeSequence() throws IOException {\n        int next = reader.read();\n        if (next < 0) {\n            return TuiKeyStroke.of(TuiKeyType.ESCAPE);\n        }\n        if (next != '[' && next != 'O') {\n            reader.unread(next);\n            return TuiKeyStroke.of(TuiKeyType.ESCAPE);\n        }\n        int code = reader.read();\n        if (code < 0) {\n            return TuiKeyStroke.of(TuiKeyType.ESCAPE);\n        }\n        switch (code) {\n            case 'A':\n                return TuiKeyStroke.of(TuiKeyType.ARROW_UP);\n            case 'B':\n                return TuiKeyStroke.of(TuiKeyType.ARROW_DOWN);\n            case 'C':\n                return TuiKeyStroke.of(TuiKeyType.ARROW_RIGHT);\n            case 'D':\n                return TuiKeyStroke.of(TuiKeyType.ARROW_LEFT);\n            default:\n                return TuiKeyStroke.of(TuiKeyType.ESCAPE);\n        }\n    }\n\n    private void emitAnsi(String value) {\n        if (!ansiSupported) {\n            return;\n        }\n        out.print(value);\n        out.flush();\n    }\n\n    private boolean hasBufferedInput() throws IOException {\n        return reader.ready() || (inputStream != null && inputStream.available() > 0);\n    }\n\n    private int parsePositiveInt(String value, int fallback) {\n        if (value == null) {\n            return fallback;\n        }\n        try {\n            int parsed = Integer.parseInt(value.trim());\n            return parsed > 0 ? parsed : fallback;\n        } catch (Exception ex) {\n            return fallback;\n        }\n    }\n\n    private static boolean detectAnsiSupport() {\n        if (System.console() == null) {\n            return false;\n        }\n        if (System.getenv(\"NO_COLOR\") != null) {\n            return false;\n        }\n        String os = System.getProperty(\"os.name\", \"\").toLowerCase();\n        if (!os.contains(\"win\")) {\n            return true;\n        }\n        return System.getenv(\"WT_SESSION\") != null\n                || System.getenv(\"ANSICON\") != null\n                || \"ON\".equalsIgnoreCase(System.getenv(\"ConEmuANSI\"))\n                || hasTermSupport(System.getenv(\"TERM\"));\n    }\n\n    private static boolean hasTermSupport(String term) {\n        return term != null && term.toLowerCase().contains(\"xterm\");\n    }\n\n    public static Charset resolveTerminalCharset() {\n        return resolveTerminalCharset(\n                new String[]{\n                        System.getProperty(TERMINAL_ENCODING_PROPERTY),\n                        System.getenv(TERMINAL_ENCODING_ENV)\n                },\n                new String[]{\n                        System.getProperty(\"stdin.encoding\"),\n                        System.getProperty(\"sun.stdin.encoding\"),\n                        System.getProperty(\"stdout.encoding\"),\n                        System.getProperty(\"sun.stdout.encoding\"),\n                        consoleCharsetName()\n                },\n                new String[]{\n                        System.getProperty(\"native.encoding\"),\n                        System.getProperty(\"sun.jnu.encoding\"),\n                        System.getProperty(\"file.encoding\")\n                },\n                shouldPreferUtf8()\n        );\n    }\n\n    static Charset resolveTerminalCharset(String[] explicitCandidates,\n                                          String[] ioCandidates,\n                                          String[] platformCandidates,\n                                          boolean preferUtf8) {\n        Charset explicit = firstSupportedCharset(explicitCandidates);\n        if (explicit != null) {\n            return explicit;\n        }\n        Charset io = firstSupportedCharset(ioCandidates);\n        if (io != null) {\n            return io;\n        }\n        if (preferUtf8) {\n            return StandardCharsets.UTF_8;\n        }\n        Charset platform = firstSupportedCharset(platformCandidates);\n        if (platform != null) {\n            return platform;\n        }\n        return Charset.defaultCharset();\n    }\n\n    private static Charset firstSupportedCharset(String[] candidates) {\n        if (candidates == null) {\n            return null;\n        }\n        for (String candidate : candidates) {\n            Charset resolved = toCharset(candidate);\n            if (resolved != null) {\n                return resolved;\n            }\n        }\n        return null;\n    }\n\n    private static boolean shouldPreferUtf8() {\n        return hasUtf8Locale(System.getenv(\"LC_ALL\"))\n                || hasUtf8Locale(System.getenv(\"LC_CTYPE\"))\n                || hasUtf8Locale(System.getenv(\"LANG\"))\n                || System.getenv(\"WT_SESSION\") != null\n                || hasTermSupport(System.getenv(\"TERM\"));\n    }\n\n    private static boolean hasUtf8Locale(String value) {\n        return value != null && value.toUpperCase().contains(\"UTF-8\");\n    }\n\n    private static String consoleCharsetName() {\n        Console console = System.console();\n        if (console == null) {\n            return null;\n        }\n        try {\n            Method method = console.getClass().getMethod(\"charset\");\n            Object value = method.invoke(console);\n            if (value instanceof Charset) {\n                return ((Charset) value).name();\n            }\n        } catch (Exception ignored) {\n            return null;\n        }\n        return null;\n    }\n\n    private static Charset toCharset(String value) {\n        if (isBlank(value)) {\n            return null;\n        }\n        try {\n            return Charset.forName(value.trim());\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/runtime/DefaultAnsiTuiRuntime.java",
    "content": "package io.github.lnyocly.ai4j.tui.runtime;\n\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiKeyStroke;\nimport io.github.lnyocly.ai4j.tui.TuiRenderer;\nimport io.github.lnyocly.ai4j.tui.TuiRuntime;\nimport io.github.lnyocly.ai4j.tui.TuiScreenModel;\nimport java.io.IOException;\n\npublic class DefaultAnsiTuiRuntime implements TuiRuntime {\n\n    private final TerminalIO terminal;\n    private final TuiRenderer renderer;\n    private final boolean useAlternateScreen;\n    private String lastFrame;\n\n    public DefaultAnsiTuiRuntime(TerminalIO terminal, TuiRenderer renderer) {\n        this(terminal, renderer, true);\n    }\n\n    public DefaultAnsiTuiRuntime(TerminalIO terminal, TuiRenderer renderer, boolean useAlternateScreen) {\n        this.terminal = terminal;\n        this.renderer = renderer;\n        this.useAlternateScreen = useAlternateScreen;\n    }\n\n    @Override\n    public boolean supportsRawInput() {\n        return terminal != null && terminal.supportsRawInput();\n    }\n\n    @Override\n    public void enter() {\n        if (terminal == null) {\n            return;\n        }\n        lastFrame = null;\n        if (useAlternateScreen) {\n            terminal.enterAlternateScreen();\n        }\n        terminal.hideCursor();\n    }\n\n    @Override\n    public void exit() {\n        if (terminal == null) {\n            return;\n        }\n        lastFrame = null;\n        terminal.showCursor();\n        if (useAlternateScreen) {\n            terminal.exitAlternateScreen();\n        }\n    }\n\n    @Override\n    public TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException {\n        return terminal == null ? null : terminal.readKeyStroke(timeoutMs);\n    }\n\n    @Override\n    public synchronized void render(TuiScreenModel screenModel) {\n        if (terminal == null || renderer == null) {\n            return;\n        }\n        String frame = renderer.render(screenModel);\n        if (frame == null) {\n            frame = \"\";\n        }\n        if (frame.equals(lastFrame)) {\n            return;\n        }\n        lastFrame = frame;\n        if (useAlternateScreen) {\n            terminal.clearScreen();\n            terminal.print(frame);\n            return;\n        }\n        terminal.moveCursorHome();\n        terminal.print(frame);\n        if (terminal.supportsAnsi()) {\n            terminal.print(\"\\u001b[J\");\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/java/io/github/lnyocly/ai4j/tui/runtime/DefaultAppendOnlyTuiRuntime.java",
    "content": "package io.github.lnyocly.ai4j.tui.runtime;\n\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiAssistantPhase;\nimport io.github.lnyocly.ai4j.tui.TuiAssistantToolView;\nimport io.github.lnyocly.ai4j.tui.TuiAssistantViewModel;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\nimport io.github.lnyocly.ai4j.tui.TuiKeyStroke;\nimport io.github.lnyocly.ai4j.tui.TuiPaletteItem;\nimport io.github.lnyocly.ai4j.tui.TuiRuntime;\nimport io.github.lnyocly.ai4j.tui.TuiScreenModel;\nimport org.jline.utils.WCWidth;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class DefaultAppendOnlyTuiRuntime implements TuiRuntime {\n\n    private static final String ESC = \"\\u001b[\";\n    private static final String RESET = \"\\u001b[0m\";\n    private static final String DIM = \"\\u001b[2m\";\n    private static final String CYAN = \"\\u001b[36m\";\n    private static final String GREEN = \"\\u001b[32m\";\n    private static final String YELLOW = \"\\u001b[33m\";\n    private static final String RED = \"\\u001b[31m\";\n    private static final String INVERSE = \"\\u001b[7m\";\n    private static final String BULLET = \"\\u2022 \";\n    private static final String TREE = \"  \\u2514 \";\n    private static final String INDENT = \"    \";\n    private static final String ELLIPSIS = \"\\u2026\";\n    private static final String INITIAL_HINT = \"Ask AI4J to inspect this repository\";\n    private static final int MAX_TOOL_PREVIEW_LINES = 4;\n    private static final int MAX_PALETTE_LINES = 8;\n    private static final String[] SPINNER_FRAMES = new String[]{\n            \"\\u280b\", \"\\u2819\", \"\\u2839\", \"\\u2838\", \"\\u283c\",\n            \"\\u2834\", \"\\u2826\", \"\\u2827\", \"\\u2807\", \"\\u280f\"\n    };\n\n    private final TerminalIO terminal;\n    private final Set<String> printedEventKeys = new LinkedHashSet<String>();\n\n    private boolean entered;\n    private boolean headerPrinted;\n    private int footerLineCount;\n    private String footerSignature;\n    private String currentSessionId;\n    private String lastAssistantOutput;\n    private String liveReasoningPrinted = \"\";\n    private String liveTextPrinted = \"\";\n    private LiveBlock activeLiveBlock = LiveBlock.NONE;\n\n    public DefaultAppendOnlyTuiRuntime(TerminalIO terminal) {\n        this.terminal = terminal;\n    }\n\n    @Override\n    public boolean supportsRawInput() {\n        return terminal != null && terminal.supportsRawInput();\n    }\n\n    @Override\n    public synchronized void enter() {\n        entered = true;\n        footerLineCount = 0;\n        footerSignature = null;\n        if (terminal != null) {\n            terminal.showCursor();\n        }\n    }\n\n    @Override\n    public synchronized void exit() {\n        clearFooter();\n        if (terminal != null) {\n            terminal.showCursor();\n        }\n        entered = false;\n    }\n\n    @Override\n    public TuiKeyStroke readKeyStroke(long timeoutMs) throws IOException {\n        return terminal == null ? null : terminal.readKeyStroke(timeoutMs);\n    }\n\n    @Override\n    public synchronized void render(TuiScreenModel screenModel) {\n        if (terminal == null || screenModel == null) {\n            return;\n        }\n        if (!headerPrinted) {\n            printSessionHeader(screenModel);\n        } else {\n            printSessionBoundaryIfNeeded(screenModel);\n        }\n        appendEventTranscript(screenModel);\n        appendLiveAssistantTranscript(screenModel);\n        appendAssistantOutput(screenModel);\n        renderFooter(screenModel);\n    }\n\n    private void printSessionHeader(TuiScreenModel screenModel) {\n        List<String> lines = new ArrayList<String>();\n        String model = safeTrim(screenModel.getRenderContext() == null ? null : screenModel.getRenderContext().getModel());\n        String workspace = safeTrim(screenModel.getRenderContext() == null ? null : screenModel.getRenderContext().getWorkspace());\n        String sessionId = shortenSessionId(screenModel.getDescriptor() == null ? null : screenModel.getDescriptor().getSessionId());\n        StringBuilder header = new StringBuilder();\n        header.append(colorize(CYAN, \"AI4J\"))\n                .append(\"  \")\n                .append(firstNonBlank(model, \"model\"))\n                .append(\"  \")\n                .append(lastPathSegment(firstNonBlank(workspace, \".\")));\n        if (!isBlank(sessionId)) {\n            header.append(\"  \").append(colorize(DIM, sessionId));\n        }\n        lines.add(header.toString());\n        lines.add(colorize(DIM, BULLET + \"Type / for commands, Enter to send\"));\n        lines.add(\"\");\n        printTranscriptLines(lines);\n        headerPrinted = true;\n        currentSessionId = screenModel.getDescriptor() == null ? null : safeTrim(screenModel.getDescriptor().getSessionId());\n    }\n\n    private void printSessionBoundaryIfNeeded(TuiScreenModel screenModel) {\n        String nextSessionId = screenModel.getDescriptor() == null ? null : safeTrim(screenModel.getDescriptor().getSessionId());\n        if (isBlank(nextSessionId) || equals(currentSessionId, nextSessionId)) {\n            return;\n        }\n        List<String> lines = new ArrayList<String>();\n        lines.add(colorize(DIM, BULLET + \"Note: Switched session \" + shortenSessionId(nextSessionId)));\n        printTranscriptLines(lines);\n        currentSessionId = nextSessionId;\n    }\n\n    private void appendEventTranscript(TuiScreenModel screenModel) {\n        List<SessionEvent> events = screenModel.getCachedEvents();\n        if (events == null || events.isEmpty()) {\n            return;\n        }\n        for (SessionEvent event : events) {\n            String key = eventKey(event);\n            if (isBlank(key) || printedEventKeys.contains(key)) {\n                continue;\n            }\n            if (event.getType() == SessionEventType.USER_MESSAGE) {\n                resetLiveDedupState();\n            }\n            if (consumeLiveAssistantEvent(event)) {\n                printedEventKeys.add(key);\n                continue;\n            }\n            List<String> lines = formatEvent(event);\n            printedEventKeys.add(key);\n            if (lines.isEmpty()) {\n                continue;\n            }\n            printTranscriptLines(lines);\n        }\n    }\n\n    private boolean consumeLiveAssistantEvent(SessionEvent event) {\n        if (event == null || event.getType() != SessionEventType.ASSISTANT_MESSAGE) {\n            return false;\n        }\n        Map<String, Object> payload = event.getPayload();\n        String output = firstNonBlank(payloadString(payload, \"output\"), event.getSummary());\n        if (isBlank(output)) {\n            return false;\n        }\n        boolean reasoning = \"reasoning\".equalsIgnoreCase(payloadString(payload, \"kind\"));\n        String printed = reasoning ? liveReasoningPrinted : liveTextPrinted;\n        if (isBlank(printed) || !output.startsWith(printed)) {\n            return false;\n        }\n        String suffix = output.substring(printed.length());\n        if (!isBlank(suffix)) {\n            appendLiveSuffix(reasoning ? LiveBlock.REASONING : LiveBlock.TEXT, printed, suffix);\n        }\n        if (reasoning) {\n            liveReasoningPrinted = \"\";\n        } else {\n            liveTextPrinted = \"\";\n        }\n        return true;\n    }\n\n    private void appendLiveAssistantTranscript(TuiScreenModel screenModel) {\n        TuiAssistantViewModel assistant = screenModel == null ? null : screenModel.getAssistantViewModel();\n        String reasoning = normalizeLiveBuffer(assistant == null ? null : assistant.getReasoningText());\n        String text = normalizeLiveBuffer(assistant == null ? null : assistant.getText());\n\n        if (!isBlank(reasoning)) {\n            if (!reasoning.startsWith(liveReasoningPrinted)) {\n                liveReasoningPrinted = \"\";\n            }\n            appendLiveSuffix(LiveBlock.REASONING, liveReasoningPrinted, reasoning.substring(liveReasoningPrinted.length()));\n            liveReasoningPrinted = reasoning;\n        }\n\n        if (!isBlank(text)) {\n            if (!text.startsWith(liveTextPrinted)) {\n                liveTextPrinted = \"\";\n            }\n            appendLiveSuffix(LiveBlock.TEXT, liveTextPrinted, text.substring(liveTextPrinted.length()));\n            liveTextPrinted = text;\n        }\n\n        if (isBlank(reasoning) && isBlank(text)) {\n            closeActiveLiveBlock();\n        }\n    }\n\n    private String normalizeLiveBuffer(String value) {\n        return value == null ? \"\" : value.replace(\"\\r\", \"\");\n    }\n\n    private void appendLiveSuffix(LiveBlock mode, String alreadyPrinted, String suffix) {\n        if (mode == null || mode == LiveBlock.NONE || suffix == null || suffix.isEmpty()) {\n            return;\n        }\n        clearFooter();\n        StringBuilder chunk = new StringBuilder();\n        if (activeLiveBlock != mode) {\n            if (activeLiveBlock != LiveBlock.NONE) {\n                chunk.append(\"\\r\\n\\r\\n\");\n            }\n            activeLiveBlock = mode;\n        }\n        boolean useFirstPrefix = isBlank(alreadyPrinted);\n        boolean needsPrefix = useFirstPrefix || alreadyPrinted.endsWith(\"\\n\");\n        String normalized = suffix.replace(\"\\r\", \"\");\n        for (int i = 0; i < normalized.length(); i++) {\n            char ch = normalized.charAt(i);\n            if (ch == '\\n') {\n                chunk.append(\"\\r\\n\");\n                needsPrefix = true;\n                continue;\n            }\n            if (needsPrefix) {\n                chunk.append(useFirstPrefix ? mode.firstPrefix() : mode.continuationPrefix());\n                useFirstPrefix = false;\n                needsPrefix = false;\n            }\n            chunk.append(ch);\n        }\n        String rendered = chunk.toString();\n        if (rendered.isEmpty()) {\n            return;\n        }\n        terminal.print(mode == LiveBlock.REASONING ? colorize(DIM, rendered) : rendered);\n    }\n\n    private void closeActiveLiveBlock() {\n        if (activeLiveBlock == LiveBlock.NONE) {\n            return;\n        }\n        clearFooter();\n        terminal.print(\"\\r\\n\\r\\n\");\n        activeLiveBlock = LiveBlock.NONE;\n    }\n\n    private void resetLiveDedupState() {\n        liveReasoningPrinted = \"\";\n        liveTextPrinted = \"\";\n    }\n\n    private void appendAssistantOutput(TuiScreenModel screenModel) {\n        String output = screenModel.getAssistantOutput();\n        if (isBlank(output) || output.equals(lastAssistantOutput)) {\n            return;\n        }\n        lastAssistantOutput = output;\n        if (printedEventKeys.isEmpty() && output.startsWith(INITIAL_HINT)) {\n            return;\n        }\n        List<String> lines = output.startsWith(INITIAL_HINT)\n                ? formatInitialHintLines(output)\n                : bulletBlock(splitLines(output), null);\n        if (!lines.isEmpty()) {\n            resetLiveDedupState();\n            printTranscriptLines(lines);\n        }\n    }\n\n    private void renderFooter(TuiScreenModel screenModel) {\n        if (!entered) {\n            clearFooter();\n            return;\n        }\n        Footer footer = buildFooter(screenModel);\n        if (footer == null || footer.lines.isEmpty()) {\n            clearFooter();\n            return;\n        }\n        String signature = buildFooterSignature(footer);\n        if (signature.equals(footerSignature)) {\n            return;\n        }\n        moveToFooterTop();\n        clearFooterArea();\n        printFooterLines(footer.lines);\n        placeCursor(footer);\n        footerLineCount = footer.lines.size();\n        footerSignature = signature;\n    }\n\n    private Footer buildFooter(TuiScreenModel screenModel) {\n        int width = resolveWidth(screenModel);\n        List<FooterLine> lines = new ArrayList<FooterLine>();\n        TuiAssistantViewModel assistant = screenModel.getAssistantViewModel();\n\n        FooterLine statusLine = buildStatusLine(assistant, width);\n        if (statusLine != null && !isBlank(statusLine.text)) {\n            lines.add(statusLine);\n        }\n\n        List<FooterLine> previewLines = buildPreviewLines(screenModel, assistant, width);\n        lines.addAll(previewLines);\n\n        TuiInteractionState interaction = screenModel.getInteractionState();\n        String input = interaction == null ? \"\" : firstNonBlank(interaction.getInputBuffer(), \"\");\n        InputViewport viewport = cropInputForViewport(input, width - 2);\n        int inputLineIndex = lines.size();\n        lines.add(new FooterLine(\"> \" + viewport.visibleText, null));\n\n        if (interaction != null && interaction.isPaletteOpen()) {\n            lines.addAll(buildPaletteLines(interaction, width));\n        }\n\n        int cursorColumn = 2 + viewport.cursorColumns;\n        return new Footer(lines, inputLineIndex, cursorColumn);\n    }\n\n    private FooterLine buildStatusLine(TuiAssistantViewModel assistant, int width) {\n        if (assistant == null || assistant.getPhase() == null || assistant.getPhase() == TuiAssistantPhase.IDLE) {\n            return new FooterLine(crop(BULLET + \"Ready\", width), DIM);\n        }\n        if (assistant.getPhase() == TuiAssistantPhase.ERROR) {\n            return new FooterLine(crop(BULLET + \"Error\", width), RED);\n        }\n        if (assistant.getPhase() == TuiAssistantPhase.COMPLETE) {\n            return new FooterLine(crop(BULLET + \"Done\", width), GREEN);\n        }\n        TuiAssistantToolView activeTool = assistant.getPhase() == TuiAssistantPhase.WAITING_TOOL_RESULT\n                ? firstPendingTool(assistant)\n                : null;\n        if (activeTool != null) {\n            String working = animatedStatusPrefix(\"Working\", assistant);\n            return new FooterLine(crop(working + \": \" + toolPrimaryLabel(activeTool, true), width), YELLOW);\n        }\n        String detail = safeTrim(assistant.getPhaseDetail());\n        String prefix = statusPrefix(assistant);\n        String normalizedDetail = normalizeStatusDetail(assistant.getPhase(), detail);\n        String text = isBlank(normalizedDetail) ? prefix : prefix + \": \" + normalizedDetail;\n        return new FooterLine(crop(text, width), DIM);\n    }\n\n    private String statusPrefix(TuiAssistantViewModel assistant) {\n        TuiAssistantPhase phase = assistant == null ? null : assistant.getPhase();\n        if (phase == TuiAssistantPhase.THINKING) {\n            return animatedStatusPrefix(\"Thinking\", assistant);\n        }\n        if (phase == TuiAssistantPhase.GENERATING) {\n            return animatedStatusPrefix(\"Responding\", assistant);\n        }\n        if (phase == TuiAssistantPhase.WAITING_TOOL_RESULT) {\n            return animatedStatusPrefix(\"Working\", assistant);\n        }\n        String phaseName = phase == null ? \"\" : phase.name().toLowerCase(Locale.ROOT).replace('_', ' ');\n        return BULLET + capitalize(phaseName);\n    }\n\n    private String animatedStatusPrefix(String label, TuiAssistantViewModel assistant) {\n        return BULLET + label + \" \" + spinnerFrame(assistant == null ? 0 : assistant.getAnimationTick());\n    }\n\n    private String spinnerFrame(int animationTick) {\n        int frameCount = SPINNER_FRAMES.length;\n        if (frameCount == 0) {\n            return \"\";\n        }\n        int index = animationTick % frameCount;\n        if (index < 0) {\n            index += frameCount;\n        }\n        return SPINNER_FRAMES[index];\n    }\n\n    private String normalizeStatusDetail(TuiAssistantPhase phase, String detail) {\n        if (isBlank(detail)) {\n            return \"\";\n        }\n        if (phase == TuiAssistantPhase.THINKING) {\n            if (\"Waiting for model output...\".equalsIgnoreCase(detail)\n                    || \"Streaming reasoning...\".equalsIgnoreCase(detail)\n                    || \"Preparing next step...\".equalsIgnoreCase(detail)\n                    || \"Tool finished, continuing...\".equalsIgnoreCase(detail)) {\n                return \"\";\n            }\n            if (detail.regionMatches(true, 0, \"Thinking about: \", 0, \"Thinking about: \".length())) {\n                return safeTrim(detail.substring(\"Thinking about: \".length()));\n            }\n        }\n        if (phase == TuiAssistantPhase.GENERATING && \"Streaming model output...\".equalsIgnoreCase(detail)) {\n            return \"\";\n        }\n        if (phase == TuiAssistantPhase.WAITING_TOOL_RESULT && \"Waiting for tool result...\".equalsIgnoreCase(detail)) {\n            return \"\";\n        }\n        return detail;\n    }\n\n    private List<FooterLine> buildPreviewLines(TuiScreenModel screenModel,\n                                               TuiAssistantViewModel assistant,\n                                               int width) {\n        List<FooterLine> lines = new ArrayList<FooterLine>();\n        if (printedEventKeys.isEmpty() && shouldShowInitialHint(screenModel, assistant)) {\n            List<String> hintLines = formatInitialHintLines(firstNonBlank(screenModel.getAssistantOutput(), \"\"));\n            for (String hintLine : hintLines) {\n                if (!isBlank(hintLine)) {\n                    lines.add(new FooterLine(crop(hintLine, width), DIM));\n                }\n            }\n        }\n        return lines;\n    }\n\n    private boolean shouldShowInitialHint(TuiScreenModel screenModel, TuiAssistantViewModel assistant) {\n        if (screenModel == null) {\n            return false;\n        }\n        if (assistant != null && assistant.getPhase() != null && assistant.getPhase() != TuiAssistantPhase.IDLE) {\n            return false;\n        }\n        String output = screenModel.getAssistantOutput();\n        return !isBlank(output) && output.startsWith(INITIAL_HINT);\n    }\n\n    private List<FooterLine> buildPaletteLines(TuiInteractionState interaction, int width) {\n        List<FooterLine> lines = new ArrayList<FooterLine>();\n        lines.add(new FooterLine(crop(paletteHeader(interaction), width), DIM));\n        List<TuiPaletteItem> items = interaction.getPaletteItems();\n        if (items == null || items.isEmpty()) {\n            lines.add(new FooterLine(crop(BULLET + \"No commands\", width), DIM));\n            return lines;\n        }\n        int selected = Math.max(0, Math.min(interaction.getPaletteSelectedIndex(), items.size() - 1));\n        int maxLines = Math.min(Math.max(2, MAX_PALETTE_LINES - 1), Math.max(2, resolvePaletteCapacity() - 1));\n        int start = Math.max(0, selected - (maxLines / 2));\n        int end = Math.min(items.size(), start + maxLines);\n        if (end - start < maxLines) {\n            start = Math.max(0, end - maxLines);\n        }\n        if (start > 0) {\n            lines.add(new FooterLine(crop(BULLET + ELLIPSIS, width), DIM));\n        }\n        for (int i = start; i < end; i++) {\n            TuiPaletteItem item = items.get(i);\n            String label = firstNonBlank(item.getLabel(), item.getCommand());\n            String detail = isBlank(item.getDetail()) ? \"\" : \"  \" + item.getDetail();\n            String line = crop(BULLET + firstNonBlank(label, \"\") + detail, width);\n            lines.add(new FooterLine(line, i == selected ? INVERSE : null));\n        }\n        if (end < items.size()) {\n            lines.add(new FooterLine(crop(BULLET + ELLIPSIS, width), DIM));\n        }\n        return lines;\n    }\n\n    private String paletteHeader(TuiInteractionState interaction) {\n        if (interaction == null) {\n            return BULLET + \"Commands\";\n        }\n        String query = safeTrim(interaction.getPaletteQuery());\n        if (interaction.getPaletteMode() == TuiInteractionState.PaletteMode.SLASH && !isBlank(query)) {\n            return BULLET + \"Commands: \" + (query.startsWith(\"/\") ? query : \"/\" + query);\n        }\n        return BULLET + \"Commands\";\n    }\n\n    private void printTranscriptLines(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return;\n        }\n        closeActiveLiveBlock();\n        clearFooter();\n        for (String line : lines) {\n            terminal.println(line == null ? \"\" : line);\n        }\n        terminal.println(\"\");\n    }\n\n    private void clearFooter() {\n        if (footerLineCount <= 0) {\n            footerSignature = null;\n            return;\n        }\n        moveToFooterTop();\n        clearFooterArea();\n        footerLineCount = 0;\n        footerSignature = null;\n    }\n\n    private void moveToFooterTop() {\n        if (footerLineCount <= 0 || !terminal.supportsAnsi()) {\n            return;\n        }\n        terminal.print(\"\\r\");\n        if (footerLineCount > 1) {\n            terminal.print(ESC + (footerLineCount - 1) + \"A\");\n        }\n    }\n\n    private void clearFooterArea() {\n        if (!terminal.supportsAnsi() || footerLineCount <= 0) {\n            return;\n        }\n        for (int i = 0; i < footerLineCount; i++) {\n            terminal.print(\"\\r\");\n            terminal.print(ESC + \"2K\");\n            if (i + 1 < footerLineCount) {\n                terminal.print(\"\\r\\n\");\n            }\n        }\n        terminal.print(\"\\r\");\n        if (footerLineCount > 1) {\n            terminal.print(ESC + (footerLineCount - 1) + \"A\");\n        }\n    }\n\n    private void printFooterLines(List<FooterLine> lines) {\n        for (int i = 0; i < lines.size(); i++) {\n            if (i > 0) {\n                terminal.print(\"\\r\\n\");\n            }\n            terminal.print(\"\\r\");\n            terminal.print(ESC + \"2K\");\n            FooterLine line = lines.get(i);\n            terminal.print(line == null ? \"\" : line.render(this));\n        }\n    }\n\n    private void placeCursor(Footer footer) {\n        if (!terminal.supportsAnsi() || footer.lines.isEmpty()) {\n            return;\n        }\n        terminal.print(\"\\r\");\n        int moveUp = footer.lines.size() - 1 - footer.inputLineIndex;\n        if (moveUp > 0) {\n            terminal.print(ESC + moveUp + \"A\");\n        }\n        if (footer.cursorColumn > 0) {\n            terminal.print(ESC + footer.cursorColumn + \"C\");\n        }\n    }\n\n    private int resolveWidth(TuiScreenModel screenModel) {\n        int width = screenModel.getRenderContext() == null ? 0 : screenModel.getRenderContext().getTerminalColumns();\n        if (width <= 0 && terminal != null) {\n            width = terminal.getTerminalColumns();\n        }\n        return Math.max(40, width <= 0 ? 120 : width);\n    }\n\n    private int resolvePaletteCapacity() {\n        int rows = terminal == null ? 0 : terminal.getTerminalRows();\n        if (rows <= 0) {\n            return MAX_PALETTE_LINES;\n        }\n        return Math.max(3, Math.min(MAX_PALETTE_LINES, rows / 3));\n    }\n\n    private List<String> formatEvent(SessionEvent event) {\n        if (event == null || event.getType() == null) {\n            return new ArrayList<String>();\n        }\n        SessionEventType type = event.getType();\n        Map<String, Object> payload = event.getPayload();\n        if (type == SessionEventType.USER_MESSAGE) {\n            return bulletBlock(splitLines(firstNonBlank(payloadString(payload, \"input\"), event.getSummary())), null);\n        }\n        if (type == SessionEventType.ASSISTANT_MESSAGE) {\n            String kind = payloadString(payload, \"kind\");\n            List<String> content = splitLines(firstNonBlank(payloadString(payload, \"output\"), event.getSummary()));\n            if (\"reasoning\".equalsIgnoreCase(kind)) {\n                return formatReasoning(content);\n            }\n            return bulletBlock(content, null);\n        }\n        if (type == SessionEventType.TOOL_CALL) {\n            return new ArrayList<String>();\n        }\n        if (type == SessionEventType.TOOL_RESULT) {\n            return formatToolResult(payload);\n        }\n        if (type == SessionEventType.ERROR) {\n            return bulletBlock(splitLines(firstNonBlank(payloadString(payload, \"error\"), event.getSummary())), \"Error: \");\n        }\n        if (type == SessionEventType.COMPACT) {\n            return bulletBlock(splitLines(firstNonBlank(payloadString(payload, \"summary\"), event.getSummary(), \"context compacted\")), \"Note: \");\n        }\n        if (type == SessionEventType.AUTO_CONTINUE\n                || type == SessionEventType.AUTO_STOP\n                || type == SessionEventType.BLOCKED) {\n            return bulletBlock(splitLines(firstNonBlank(event.getSummary(), type.name().toLowerCase(Locale.ROOT).replace('_', ' '))), \"Note: \");\n        }\n        if (type == SessionEventType.SESSION_RESUMED || type == SessionEventType.SESSION_FORKED) {\n            return bulletBlock(splitLines(firstNonBlank(event.getSummary(), type.name().toLowerCase(Locale.ROOT).replace('_', ' '))), \"Note: \");\n        }\n        if (type == SessionEventType.TASK_CREATED || type == SessionEventType.TASK_UPDATED) {\n            return formatTaskEvent(payload, event.getSummary());\n        }\n        if (type == SessionEventType.TEAM_MESSAGE) {\n            return formatTeamMessageEvent(payload, event.getSummary());\n        }\n        if (type == SessionEventType.PROCESS_STARTED\n                || type == SessionEventType.PROCESS_UPDATED\n                || type == SessionEventType.PROCESS_STOPPED) {\n            return formatProcessEvent(type, payload, event.getSummary());\n        }\n        return new ArrayList<String>();\n    }\n\n    private List<String> formatTaskEvent(Map<String, Object> payload, String summary) {\n        List<String> lines = new ArrayList<String>();\n        lines.add(BULLET + \"Note: \" + firstNonBlank(summary, payloadString(payload, \"title\"), \"delegate task\"));\n        String detail = firstNonBlank(payloadString(payload, \"detail\"), payloadString(payload, \"error\"), payloadString(payload, \"output\"));\n        if (!isBlank(detail)) {\n            lines.add(TREE + detail);\n        }\n        String childSessionId = payloadString(payload, \"childSessionId\");\n        if (!isBlank(childSessionId)) {\n            lines.add(INDENT + \"child session: \" + childSessionId);\n        }\n        String status = payloadString(payload, \"status\");\n        if (!isBlank(status)) {\n            lines.add(INDENT + \"status: \" + status);\n        }\n        return lines;\n    }\n\n    private List<String> formatTeamMessageEvent(Map<String, Object> payload, String summary) {\n        List<String> lines = new ArrayList<String>();\n        lines.add(BULLET + \"Note: \" + firstNonBlank(summary, payloadString(payload, \"title\"), \"team message\"));\n        String taskId = payloadString(payload, \"taskId\");\n        if (!isBlank(taskId)) {\n            lines.add(TREE + \"task: \" + taskId);\n        }\n        String detail = firstNonBlank(payloadString(payload, \"content\"), payloadString(payload, \"detail\"));\n        if (!isBlank(detail)) {\n            List<String> detailLines = splitLines(detail);\n            for (int i = 0; i < detailLines.size(); i++) {\n                lines.add((i == 0 ? INDENT : INDENT) + detailLines.get(i));\n            }\n        }\n        return lines;\n    }\n\n    private List<String> formatReasoning(List<String> content) {\n        List<String> lines = new ArrayList<String>();\n        if (content == null || content.isEmpty()) {\n            return lines;\n        }\n        String continuation = repeat(' ', (BULLET + \"Thinking: \").length());\n        for (int i = 0; i < content.size(); i++) {\n            String prefix = i == 0 ? BULLET + \"Thinking: \" : continuation;\n            lines.add(colorize(DIM, prefix + content.get(i)));\n        }\n        return lines;\n    }\n\n    private List<String> formatToolResult(Map<String, Object> payload) {\n        List<String> lines = new ArrayList<String>();\n        if (payload == null) {\n            return lines;\n        }\n        String toolName = firstNonBlank(payloadString(payload, \"tool\"), \"tool\");\n        String title = normalizeToolLabel(firstNonBlank(payloadString(payload, \"title\"), toolName));\n        String detail = safeTrim(payloadString(payload, \"detail\"));\n        String output = payloadString(payload, \"output\");\n        boolean failed = looksLikeToolFailure(output, detail);\n\n        if (\"apply_patch\".equals(toolName)) {\n            lines.add(failed ? colorize(RED, BULLET + \"Tool failed apply_patch\") : colorize(YELLOW, BULLET + \"Applied patch\"));\n        } else if (\"write_file\".equals(toolName)) {\n            String label = crop(normalizeToolLabel(title), 108);\n            lines.add(failed\n                    ? colorize(RED, BULLET + \"Tool failed write \" + crop(label, 96))\n                    : colorize(YELLOW, BULLET + \"Wrote \" + label));\n        } else if (failed) {\n            lines.add(colorize(RED, BULLET + \"Tool failed \" + crop(title, 96)));\n        } else {\n            lines.add(colorize(YELLOW, BULLET + \"Ran \" + crop(title, 108)));\n        }\n\n        if (!isBlank(detail)) {\n            lines.add(TREE + detail);\n        }\n        List<String> previewLines = payloadLines(payload, \"previewLines\");\n        int max = Math.min(MAX_TOOL_PREVIEW_LINES, previewLines.size());\n        for (int i = 0; i < max; i++) {\n            lines.add((i == 0 && isBlank(detail) ? TREE : INDENT) + previewLines.get(i));\n        }\n        if (previewLines.size() > max) {\n            lines.add(colorize(DIM, INDENT + ELLIPSIS + \" +\" + (previewLines.size() - max) + \" lines\"));\n        }\n        return lines;\n    }\n\n    private List<String> formatProcessEvent(SessionEventType type, Map<String, Object> payload, String summary) {\n        List<String> lines = new ArrayList<String>();\n        String processId = firstNonBlank(payloadString(payload, \"processId\"), \"unknown-process\");\n        String status = safeTrim(payloadString(payload, \"status\"));\n        String command = safeTrim(payloadString(payload, \"command\"));\n        String workingDirectory = safeTrim(payloadString(payload, \"workingDirectory\"));\n\n        if (type == SessionEventType.PROCESS_STARTED) {\n            lines.add(BULLET + \"Process started: \" + processId);\n        } else if (type == SessionEventType.PROCESS_STOPPED) {\n            lines.add(BULLET + \"Process stopped: \" + processId);\n        } else {\n            String label = BULLET + \"Process: \" + processId;\n            if (!isBlank(status)) {\n                label = label + \" (\" + status + \")\";\n            }\n            lines.add(label);\n        }\n\n        if (!isBlank(command)) {\n            lines.add(TREE + command);\n        } else if (!isBlank(summary)) {\n            lines.add(TREE + summary);\n        }\n        if (!isBlank(workingDirectory)) {\n            lines.add(INDENT + \"cwd \" + workingDirectory);\n        }\n        return lines;\n    }\n\n    private InputViewport cropInputForViewport(String input, int availableColumns) {\n        String safeInput = input == null ? \"\" : input;\n        int width = Math.max(8, availableColumns);\n        if (displayWidth(safeInput) <= width) {\n            return new InputViewport(safeInput, displayWidth(safeInput));\n        }\n        int visibleWidth = 0;\n        StringBuilder builder = new StringBuilder();\n        for (int i = safeInput.length() - 1; i >= 0; i--) {\n            char ch = safeInput.charAt(i);\n            int charWidth = charWidth(ch);\n            if (visibleWidth + charWidth > width - 3 && builder.length() > 0) {\n                break;\n            }\n            builder.insert(0, ch);\n            visibleWidth += charWidth;\n        }\n        String visible = \"...\" + builder.toString();\n        return new InputViewport(visible, Math.min(width, displayWidth(visible)));\n    }\n\n    private List<String> bulletBlock(List<String> content, String label) {\n        List<String> lines = new ArrayList<String>();\n        if (content == null || content.isEmpty()) {\n            return lines;\n        }\n        String firstPrefix = BULLET + firstNonBlank(label, \"\");\n        String continuation = repeat(' ', displayWidth(firstPrefix));\n        for (int i = 0; i < content.size(); i++) {\n            String line = content.get(i) == null ? \"\" : content.get(i);\n            lines.add((i == 0 ? firstPrefix : continuation) + line);\n        }\n        return lines;\n    }\n\n    private List<String> formatInitialHintLines(String output) {\n        List<String> lines = new ArrayList<String>();\n        List<String> rawLines = splitLines(output);\n        for (String rawLine : rawLines) {\n            if (!isBlank(rawLine)) {\n                lines.add(BULLET + rawLine);\n            }\n        }\n        return lines;\n    }\n\n    private List<String> appendLabelBlock(String label, List<String> content) {\n        List<String> lines = new ArrayList<String>();\n        if (!isBlank(label)) {\n            lines.add(label);\n        }\n        if (content != null) {\n            lines.addAll(content);\n        }\n        return lines;\n    }\n\n    private List<String> prefixBlock(String prefix, List<String> content) {\n        List<String> lines = new ArrayList<String>();\n        if (content == null || content.isEmpty()) {\n            return lines;\n        }\n        String continuation = repeat(' ', prefix.length());\n        for (int i = 0; i < content.size(); i++) {\n            lines.add((i == 0 ? prefix : continuation) + content.get(i));\n        }\n        return lines;\n    }\n\n    private String buildFooterSignature(Footer footer) {\n        StringBuilder builder = new StringBuilder();\n        builder.append(footer.inputLineIndex).append('|').append(footer.cursorColumn).append('|');\n        for (FooterLine line : footer.lines) {\n            if (line == null) {\n                builder.append('\\n');\n                continue;\n            }\n            builder.append(firstNonBlank(line.style, \"\"))\n                    .append('|')\n                    .append(firstNonBlank(line.text, \"\"))\n                    .append('\\n');\n        }\n        return builder.toString();\n    }\n\n    private String eventKey(SessionEvent event) {\n        if (!isBlank(event.getEventId())) {\n            return event.getEventId();\n        }\n        return firstNonBlank(event.getTurnId(), \"session\")\n                + \"|\" + event.getTimestamp()\n                + \"|\" + (event.getStep() == null ? \"\" : event.getStep().toString())\n                + \"|\" + event.getType().name()\n                + \"|\" + firstNonBlank(event.getSummary(), \"\");\n    }\n\n    private List<String> splitLines(String text) {\n        List<String> lines = new ArrayList<String>();\n        if (text == null) {\n            return lines;\n        }\n        String[] raw = text.replace(\"\\r\", \"\").split(\"\\n\", -1);\n        for (String line : raw) {\n            lines.add(line == null ? \"\" : line);\n        }\n        return lines;\n    }\n\n    private List<String> payloadLines(Map<String, Object> payload, String key) {\n        List<String> lines = new ArrayList<String>();\n        if (payload == null || isBlank(key) || !payload.containsKey(key)) {\n            return lines;\n        }\n        Object value = payload.get(key);\n        if (value instanceof List) {\n            List<?> list = (List<?>) value;\n            for (Object item : list) {\n                if (item != null) {\n                    lines.add(String.valueOf(item));\n                }\n            }\n            return lines;\n        }\n        if (value != null) {\n            lines.add(String.valueOf(value));\n        }\n        return lines;\n    }\n\n    private String payloadString(Map<String, Object> payload, String key) {\n        if (payload == null || isBlank(key) || !payload.containsKey(key)) {\n            return null;\n        }\n        Object value = payload.get(key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private TuiAssistantToolView firstPendingTool(TuiAssistantViewModel assistant) {\n        if (assistant == null || assistant.getTools() == null || assistant.getTools().isEmpty()) {\n            return null;\n        }\n        for (TuiAssistantToolView tool : assistant.getTools()) {\n            if (tool != null && \"pending\".equalsIgnoreCase(safeTrim(tool.getStatus()))) {\n                return tool;\n            }\n        }\n        return assistant.getTools().get(0);\n    }\n\n    private String toolPrimaryLabel(TuiAssistantToolView tool, boolean pending) {\n        if (tool == null) {\n            return pending ? \"Thinking\" : \"Done\";\n        }\n        String label = normalizeToolLabel(firstNonBlank(tool.getTitle(), tool.getToolName(), \"tool\"));\n        if (\"write_file\".equals(tool.getToolName())) {\n            return pending ? \"Writing \" + label : \"Wrote \" + label;\n        }\n        return pending ? \"Running \" + label : \"Ran \" + label;\n    }\n\n    private String normalizeToolLabel(String title) {\n        String value = firstNonBlank(title, \"tool\").trim();\n        String[] prefixes = new String[]{\"$ \", \"read \", \"write \", \"bash logs \", \"bash status \", \"bash write \", \"bash stop \"};\n        for (String prefix : prefixes) {\n            if (value.startsWith(prefix)) {\n                return value.substring(prefix.length()).trim();\n            }\n        }\n        return value;\n    }\n\n    private boolean looksLikeToolFailure(String output, String detail) {\n        String combined = (firstNonBlank(output, \"\") + \" \" + firstNonBlank(detail, \"\")).toLowerCase(Locale.ROOT);\n        return combined.contains(\"error\")\n                || combined.contains(\"failed\")\n                || combined.contains(\"exception\")\n                || combined.contains(\"unsupported patch line\")\n                || combined.contains(\"cannot invoke\");\n    }\n\n    private String shortenSessionId(String sessionId) {\n        String value = safeTrim(sessionId);\n        if (isBlank(value)) {\n            return null;\n        }\n        if (value.length() <= 12) {\n            return value;\n        }\n        return value.substring(0, 12);\n    }\n\n    private String crop(String value, int width) {\n        String safe = value == null ? \"\" : value;\n        if (width <= 0 || displayWidth(safe) <= width) {\n            return safe;\n        }\n        StringBuilder builder = new StringBuilder();\n        int used = 0;\n        for (int i = 0; i < safe.length(); i++) {\n            char ch = safe.charAt(i);\n            int charWidth = charWidth(ch);\n            if (used + charWidth > Math.max(0, width - 3)) {\n                break;\n            }\n            builder.append(ch);\n            used += charWidth;\n        }\n        return builder.append(\"...\").toString();\n    }\n\n    private int displayWidth(String text) {\n        if (text == null || text.isEmpty()) {\n            return 0;\n        }\n        int width = 0;\n        for (int i = 0; i < text.length(); i++) {\n            width += charWidth(text.charAt(i));\n        }\n        return width;\n    }\n\n    private int charWidth(char ch) {\n        int width = WCWidth.wcwidth(ch);\n        return width <= 0 ? 1 : width;\n    }\n\n    private String trimBlankEdges(String value) {\n        if (value == null) {\n            return null;\n        }\n        int start = 0;\n        int end = value.length();\n        while (start < end && Character.isWhitespace(value.charAt(start))) {\n            start++;\n        }\n        while (end > start && Character.isWhitespace(value.charAt(end - 1))) {\n            end--;\n        }\n        return value.substring(start, end);\n    }\n\n    private String safeTrim(String value) {\n        return value == null ? null : value.trim();\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return values.length == 0 ? null : values[values.length - 1];\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private String repeat(char ch, int count) {\n        StringBuilder builder = new StringBuilder(Math.max(0, count));\n        for (int i = 0; i < count; i++) {\n            builder.append(ch);\n        }\n        return builder.toString();\n    }\n\n    private String lastPathSegment(String value) {\n        if (isBlank(value)) {\n            return \".\";\n        }\n        String normalized = value.replace('\\\\', '/');\n        int index = normalized.lastIndexOf('/');\n        return index < 0 ? normalized : normalized.substring(index + 1);\n    }\n\n    private String capitalize(String value) {\n        if (isBlank(value)) {\n            return \"\";\n        }\n        return Character.toUpperCase(value.charAt(0)) + value.substring(1);\n    }\n\n    private boolean equals(String left, String right) {\n        return left == null ? right == null : left.equals(right);\n    }\n\n    private String colorize(String color, String text) {\n        if (!terminal.supportsAnsi() || isBlank(color)) {\n            return firstNonBlank(text, \"\");\n        }\n        return color + firstNonBlank(text, \"\") + RESET;\n    }\n\n    private static final class Footer {\n\n        private final List<FooterLine> lines;\n        private final int inputLineIndex;\n        private final int cursorColumn;\n\n        private Footer(List<FooterLine> lines, int inputLineIndex, int cursorColumn) {\n            this.lines = lines;\n            this.inputLineIndex = inputLineIndex;\n            this.cursorColumn = cursorColumn;\n        }\n    }\n\n    private static final class FooterLine {\n\n        private final String text;\n        private final String style;\n\n        private FooterLine(String text, String style) {\n            this.text = text == null ? \"\" : text;\n            this.style = style;\n        }\n\n        private String render(DefaultAppendOnlyTuiRuntime runtime) {\n            if (runtime == null || isBlank(style)) {\n                return text;\n            }\n            return runtime.colorize(style, text);\n        }\n\n        private static boolean isBlank(String value) {\n            return value == null || value.trim().isEmpty();\n        }\n    }\n\n    private static final class InputViewport {\n\n        private final String visibleText;\n        private final int cursorColumns;\n\n        private InputViewport(String visibleText, int cursorColumns) {\n            this.visibleText = visibleText == null ? \"\" : visibleText;\n            this.cursorColumns = Math.max(0, cursorColumns);\n        }\n    }\n\n    private enum LiveBlock {\n        NONE,\n        REASONING,\n        TEXT;\n\n        private String firstPrefix() {\n            return this == REASONING ? BULLET + \"Thinking: \" : BULLET;\n        }\n\n        private String continuationPrefix() {\n            int count = this == REASONING ? (BULLET + \"Thinking: \").length() : BULLET.length();\n            StringBuilder builder = new StringBuilder(Math.max(0, count));\n            for (int i = 0; i < count; i++) {\n                builder.append(' ');\n            }\n            return builder.toString();\n        }\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/amber.json",
    "content": "{\n  \"name\": \"amber\",\n  \"brand\": \"#ffcc66\",\n  \"accent\": \"#ff8a3d\",\n  \"success\": \"#c7f9a8\",\n  \"warning\": \"#ffd166\",\n  \"danger\": \"#ff6b6b\",\n  \"text\": \"#f8f5ec\",\n  \"muted\": \"#c8b89d\",\n  \"panelBorder\": \"#7c5c3b\",\n  \"panelTitle\": \"#ffcc66\",\n  \"badgeForeground\": \"#241a10\"\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/default.json",
    "content": "{\n  \"name\": \"default\",\n  \"brand\": \"#7cc6fe\",\n  \"accent\": \"#f5b14c\",\n  \"success\": \"#8fd694\",\n  \"warning\": \"#f4d35e\",\n  \"danger\": \"#ef6f6c\",\n  \"text\": \"#f3f4f6\",\n  \"muted\": \"#9ca3af\",\n  \"panelBorder\": \"#4b5563\",\n  \"panelTitle\": \"#f5b14c\",\n  \"badgeForeground\": \"#111827\"\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/github-dark.json",
    "content": "{\n  \"name\": \"github-dark\",\n  \"brand\": \"#58a6ff\",\n  \"accent\": \"#d2a8ff\",\n  \"success\": \"#3fb950\",\n  \"warning\": \"#d29922\",\n  \"danger\": \"#f85149\",\n  \"text\": \"#c9d1d9\",\n  \"muted\": \"#8b949e\",\n  \"panelBorder\": \"#30363d\",\n  \"panelTitle\": \"#58a6ff\",\n  \"badgeForeground\": \"#0d1117\",\n  \"codeBackground\": \"#161b22\",\n  \"codeBorder\": \"#30363d\",\n  \"codeText\": \"#c9d1d9\",\n  \"codeKeyword\": \"#ff7b72\",\n  \"codeString\": \"#a5d6ff\",\n  \"codeComment\": \"#8b949e\",\n  \"codeNumber\": \"#79c0ff\"\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/github-light.json",
    "content": "{\n  \"name\": \"github-light\",\n  \"brand\": \"#0969da\",\n  \"accent\": \"#8250df\",\n  \"success\": \"#1a7f37\",\n  \"warning\": \"#9a6700\",\n  \"danger\": \"#cf222e\",\n  \"text\": \"#24292f\",\n  \"muted\": \"#57606a\",\n  \"panelBorder\": \"#d0d7de\",\n  \"panelTitle\": \"#0969da\",\n  \"badgeForeground\": \"#ffffff\",\n  \"codeBackground\": \"#f6f8fa\",\n  \"codeBorder\": \"#d0d7de\",\n  \"codeText\": \"#24292f\",\n  \"codeKeyword\": \"#cf222e\",\n  \"codeString\": \"#0a3069\",\n  \"codeComment\": \"#6e7781\",\n  \"codeNumber\": \"#0550ae\"\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/matrix.json",
    "content": "{\n  \"name\": \"matrix\",\n  \"brand\": \"#66ff99\",\n  \"accent\": \"#3ddc97\",\n  \"success\": \"#98fb98\",\n  \"warning\": \"#e6ff5c\",\n  \"danger\": \"#ff5f6d\",\n  \"text\": \"#ddffe7\",\n  \"muted\": \"#7aa387\",\n  \"panelBorder\": \"#24593d\",\n  \"panelTitle\": \"#66ff99\",\n  \"badgeForeground\": \"#06110a\"\n}\n"
  },
  {
    "path": "ai4j-cli/src/main/resources/io/github/lnyocly/ai4j/tui/themes/ocean.json",
    "content": "{\n  \"name\": \"ocean\",\n  \"brand\": \"#66d9ef\",\n  \"accent\": \"#7aa2f7\",\n  \"success\": \"#9ece6a\",\n  \"warning\": \"#e0af68\",\n  \"danger\": \"#f7768e\",\n  \"text\": \"#e6edf3\",\n  \"muted\": \"#8b9bb4\",\n  \"panelBorder\": \"#355070\",\n  \"panelTitle\": \"#66d9ef\",\n  \"badgeForeground\": \"#0b1320\"\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/Ai4jCliTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.coding.CodingAgents;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory.PreparedCodingAgent;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Paths;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Properties;\n\npublic class Ai4jCliTest {\n\n    @Test\n    public void test_top_level_help() {\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        int exitCode = new Ai4jCli().run(\n                new String[]{\"help\"},\n                new ByteArrayInputStream(new byte[0]),\n                out,\n                err,\n                Collections.<String, String>emptyMap(),\n                new Properties()\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"ai4j-cli\"));\n        Assert.assertTrue(output.contains(\"code\"));\n        Assert.assertTrue(output.contains(\"acp\"));\n    }\n\n    @Test\n    public void test_unknown_command_returns_argument_error() {\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        int exitCode = new Ai4jCli().run(\n                new String[]{\"unknown\"},\n                new ByteArrayInputStream(new byte[0]),\n                out,\n                err,\n                Collections.<String, String>emptyMap(),\n                new Properties()\n        );\n\n        String error = new String(err.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(2, exitCode);\n        Assert.assertTrue(error.contains(\"Unknown command: unknown\"));\n    }\n\n    @Test\n    public void test_top_level_tui_command_routes_to_tui_mode() {\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        Ai4jCli cli = new Ai4jCli(new StubCodingCliAgentFactory(), Paths.get(\".\").toAbsolutePath().normalize());\n        int exitCode = cli.run(\n                new String[]{\"tui\", \"--model\", \"fake-model\", \"--prompt\", \"hello\"},\n                new ByteArrayInputStream(new byte[0]),\n                out,\n                err,\n                Collections.<String, String>emptyMap(),\n                new Properties()\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"AI4J\"));\n        Assert.assertTrue(output.contains(\"fake-model\"));\n        Assert.assertTrue(output.contains(\"Echo: hello\"));\n        Assert.assertFalse(output.contains(\"EVENTS\"));\n    }\n\n    private static final class StubCodingCliAgentFactory implements CodingCliAgentFactory {\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options) {\n            CodingAgent agent = CodingAgents.builder()\n                    .modelClient(new StubModelClient())\n                    .model(options.getModel())\n                    .workspaceContext(WorkspaceContext.builder().rootPath(options.getWorkspace()).build())\n                    .build();\n            return new PreparedCodingAgent(agent, CliProtocol.CHAT);\n        }\n    }\n\n    private static final class StubModelClient implements AgentModelClient {\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return AgentModelResult.builder().outputText(\"Echo: \" + findLastUserText(prompt)).build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private String findLastUserText(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return \"\";\n            }\n            List<Object> items = prompt.getItems();\n            for (int i = items.size() - 1; i >= 0; i--) {\n                Object item = items.get(i);\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"message\".equals(map.get(\"type\")) || !\"user\".equals(map.get(\"role\"))) {\n                    continue;\n                }\n                Object content = map.get(\"content\");\n                if (!(content instanceof List)) {\n                    continue;\n                }\n                List<?> parts = (List<?>) content;\n                for (Object part : parts) {\n                    if (!(part instanceof Map)) {\n                        continue;\n                    }\n                    Map<?, ?> partMap = (Map<?, ?>) part;\n                    if (\"input_text\".equals(partMap.get(\"type\"))) {\n                        Object text = partMap.get(\"text\");\n                        return text == null ? \"\" : String.valueOf(text);\n                    }\n                }\n            }\n            return \"\";\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/AssistantTranscriptRendererTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.render.AssistantTranscriptRenderer;\nimport io.github.lnyocly.ai4j.cli.render.CliThemeStyler;\nimport io.github.lnyocly.ai4j.tui.TuiTheme;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\n\npublic class AssistantTranscriptRendererTest {\n\n    @Test\n    public void renderTurnsMarkdownCodeBlocksIntoTranscriptLines() {\n        AssistantTranscriptRenderer renderer = new AssistantTranscriptRenderer();\n\n        List<String> lines = renderer.plainLines(\"`hello.py` 文件已经存在了，内容如下：\\n\\n```python\\n# Python Hello World\\n\\nprint(\\\"Hello, World!\\\")\\n```\\n\\n你可以运行它：\\n\\n```bash\\npython hello.py\\n```\");\n\n        assertEquals(Arrays.asList(\n                \"`hello.py` 文件已经存在了，内容如下：\",\n                \"\",\n                \"    # Python Hello World\",\n                \"    \",\n                \"    print(\\\"Hello, World!\\\")\",\n                \"\",\n                \"你可以运行它：\",\n                \"\",\n                \"    python hello.py\"\n        ), lines);\n    }\n\n    @Test\n    public void styleBlockHighlightsCodeWithoutLeakingFences() {\n        AssistantTranscriptRenderer renderer = new AssistantTranscriptRenderer();\n        CliThemeStyler styler = new CliThemeStyler(new TuiTheme(), true);\n\n        String styled = renderer.styleBlock(\"```python\\nprint(\\\"Hello\\\")\\n```\", styler);\n\n        assertTrue(styled.replaceAll(\"\\\\u001B\\\\[[;\\\\d]*m\", \"\").contains(\"print(\\\"Hello\\\")\"));\n        assertFalse(styled.contains(\"```\"));\n        assertTrue(styled.contains(\"\\u001b[\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliDisplayWidthTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.render.CliDisplayWidth;\nimport org.jline.utils.AttributedString;\nimport org.junit.Assert;\nimport org.junit.Test;\n\npublic class CliDisplayWidthTest {\n\n    @Test\n    public void clipUsesDisplayWidthForChineseText() {\n        String value = CliDisplayWidth.clip(\"然后说：你好世界\", 9);\n\n        Assert.assertEquals(\"然后说...\", value);\n        Assert.assertTrue(CliDisplayWidth.displayWidth(value) <= 9);\n    }\n\n    @Test\n    public void wrapAnsiBreaksBeforeWideTextPushesLastSymbolPastEdge() {\n        CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi(\n                \"\\u001b[90mab你好\\\"\\u001b[0m\",\n                6,\n                0\n        );\n\n        Assert.assertEquals(\"ab你好\\n\\\"\", AttributedString.fromAnsi(wrapped.text()).toString());\n        Assert.assertEquals(1, wrapped.endColumn());\n    }\n\n    @Test\n    public void wrapAnsiHonorsExistingColumnOffset() {\n        CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi(\n                \"\\u001b[90m你好\\\"\\u001b[0m\",\n                6,\n                2\n        );\n\n        Assert.assertEquals(\"你好\\n\\\"\", AttributedString.fromAnsi(wrapped.text()).toString());\n        Assert.assertEquals(1, wrapped.endColumn());\n    }\n\n    @Test\n    public void wrapAnsiDoesNotLeaveChinesePeriodAloneAtLineStart() {\n        CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi(\n                \"\\u001b[90mab你好。\\u001b[0m\",\n                6,\n                0\n        );\n\n        Assert.assertEquals(\"ab你\\n好。\", AttributedString.fromAnsi(wrapped.text()).toString());\n        Assert.assertEquals(4, wrapped.endColumn());\n    }\n\n    @Test\n    public void wrapAnsiDoesNotLeaveChineseColonAloneAtLineStart() {\n        CliDisplayWidth.WrappedAnsi wrapped = CliDisplayWidth.wrapAnsi(\n                \"\\u001b[90m例如：\\u001b[0m\",\n                4,\n                0\n        );\n\n        Assert.assertEquals(\"例\\n如：\", AttributedString.fromAnsi(wrapped.text()).toString());\n        Assert.assertEquals(4, wrapped.endColumn());\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliMcpConfigManagerTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class CliMcpConfigManagerTest {\n\n    @Test\n    public void saveLoadAndResolveMcpConfigs() throws Exception {\n        Path home = Files.createTempDirectory(\"ai4j-cli-mcp-home\");\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-mcp-workspace\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliMcpConfigManager manager = new CliMcpConfigManager(workspace);\n\n            Map<String, CliMcpServerDefinition> servers = new LinkedHashMap<String, CliMcpServerDefinition>();\n            servers.put(\"fetch\", CliMcpServerDefinition.builder()\n                    .type(\"sse\")\n                    .url(\" https://mcp.api-inference.modelscope.net/1e1a663049b340/sse \")\n                    .build());\n            servers.put(\"time\", CliMcpServerDefinition.builder()\n                    .command(\" uvx \")\n                    .args(Arrays.asList(\" mcp-server-time \", \"\"))\n                    .build());\n            servers.put(\"bing-cn-mcp-server\", CliMcpServerDefinition.builder()\n                    .type(\"http\")\n                    .url(\"https://mcp.api-inference.modelscope.net/0904773a8c2045/mcp\")\n                    .build());\n            manager.saveGlobalConfig(CliMcpConfig.builder()\n                    .mcpServers(servers)\n                    .build());\n\n            manager.saveWorkspaceConfig(CliWorkspaceConfig.builder()\n                    .enabledMcpServers(Arrays.asList(\" fetch \", \"time\", \"fetch\", \"missing\", \"\"))\n                    .skillDirectories(Arrays.asList(\" .ai4j/skills \", \"C:/skills/team \", \".ai4j/skills\"))\n                    .agentDirectories(Arrays.asList(\" .ai4j/agents \", \"C:/agents/team \", \".ai4j/agents\"))\n                    .build());\n\n            CliMcpConfig loadedGlobal = manager.loadGlobalConfig();\n            CliWorkspaceConfig loadedWorkspace = manager.loadWorkspaceConfig();\n            CliResolvedMcpConfig resolved = manager.resolve(Collections.singleton(\"fetch\"));\n\n            Assert.assertEquals(\"sse\", loadedGlobal.getMcpServers().get(\"fetch\").getType());\n            Assert.assertEquals(\"stdio\", loadedGlobal.getMcpServers().get(\"time\").getType());\n            Assert.assertEquals(\"uvx\", loadedGlobal.getMcpServers().get(\"time\").getCommand());\n            Assert.assertEquals(Arrays.asList(\"mcp-server-time\"), loadedGlobal.getMcpServers().get(\"time\").getArgs());\n            Assert.assertEquals(\"streamable_http\", loadedGlobal.getMcpServers().get(\"bing-cn-mcp-server\").getType());\n\n            Assert.assertEquals(Arrays.asList(\"fetch\", \"time\", \"missing\"), loadedWorkspace.getEnabledMcpServers());\n            Assert.assertEquals(Arrays.asList(\".ai4j/skills\", \"C:/skills/team\"), loadedWorkspace.getSkillDirectories());\n            Assert.assertEquals(Arrays.asList(\".ai4j/agents\", \"C:/agents/team\"), loadedWorkspace.getAgentDirectories());\n\n            CliResolvedMcpServer fetch = resolved.getServers().get(\"fetch\");\n            CliResolvedMcpServer time = resolved.getServers().get(\"time\");\n            CliResolvedMcpServer bing = resolved.getServers().get(\"bing-cn-mcp-server\");\n\n            Assert.assertNotNull(fetch);\n            Assert.assertTrue(fetch.isWorkspaceEnabled());\n            Assert.assertTrue(fetch.isSessionPaused());\n            Assert.assertFalse(fetch.isActive());\n            Assert.assertTrue(fetch.isValid());\n\n            Assert.assertNotNull(time);\n            Assert.assertTrue(time.isWorkspaceEnabled());\n            Assert.assertFalse(time.isSessionPaused());\n            Assert.assertTrue(time.isActive());\n            Assert.assertTrue(time.isValid());\n\n            Assert.assertNotNull(bing);\n            Assert.assertFalse(bing.isWorkspaceEnabled());\n            Assert.assertFalse(bing.isActive());\n\n            Assert.assertEquals(Arrays.asList(\"fetch\", \"time\"), resolved.getEnabledServerNames());\n            Assert.assertEquals(Collections.singletonList(\"fetch\"), resolved.getPausedServerNames());\n            Assert.assertEquals(Collections.singletonList(\"missing\"), resolved.getUnknownEnabledServerNames());\n\n            String persisted = new String(Files.readAllBytes(manager.globalMcpPath()), StandardCharsets.UTF_8);\n            Assert.assertTrue(persisted.contains(\"\\\"mcpServers\\\"\"));\n            Assert.assertFalse(persisted.contains(\"\\\"version\\\"\"));\n            Assert.assertFalse(persisted.contains(\"\\\"enabled\\\"\"));\n        } finally {\n            restoreUserHome(previousUserHome);\n        }\n    }\n\n    @Test\n    public void resolveMarksInvalidDefinitionsWithoutBlockingOtherServers() throws Exception {\n        Path home = Files.createTempDirectory(\"ai4j-cli-mcp-invalid-home\");\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-mcp-invalid-workspace\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliMcpConfigManager manager = new CliMcpConfigManager(workspace);\n\n            Map<String, CliMcpServerDefinition> servers = new LinkedHashMap<String, CliMcpServerDefinition>();\n            servers.put(\"broken-stdio\", CliMcpServerDefinition.builder()\n                    .type(\"stdio\")\n                    .build());\n            servers.put(\"broken-sse\", CliMcpServerDefinition.builder()\n                    .type(\"sse\")\n                    .build());\n            servers.put(\"ambiguous\", CliMcpServerDefinition.builder()\n                    .url(\"http://localhost:8080/mcp\")\n                    .build());\n            servers.put(\"working\", CliMcpServerDefinition.builder()\n                    .command(\"uvx\")\n                    .args(Collections.singletonList(\"mcp-server-time\"))\n                    .build());\n            manager.saveGlobalConfig(CliMcpConfig.builder()\n                    .mcpServers(servers)\n                    .build());\n\n            manager.saveWorkspaceConfig(CliWorkspaceConfig.builder()\n                    .enabledMcpServers(Arrays.asList(\"broken-stdio\", \"broken-sse\", \"ambiguous\", \"working\"))\n                    .build());\n\n            CliResolvedMcpConfig resolved = manager.resolve(Collections.<String>emptyList());\n\n            Assert.assertEquals(\"stdio transport requires command\", resolved.getServers().get(\"broken-stdio\").getValidationError());\n            Assert.assertEquals(\"sse transport requires url\", resolved.getServers().get(\"broken-sse\").getValidationError());\n            Assert.assertEquals(\"missing MCP transport type\", resolved.getServers().get(\"ambiguous\").getValidationError());\n            Assert.assertFalse(resolved.getServers().get(\"broken-stdio\").isActive());\n            Assert.assertFalse(resolved.getServers().get(\"broken-sse\").isActive());\n            Assert.assertFalse(resolved.getServers().get(\"ambiguous\").isActive());\n            Assert.assertTrue(resolved.getServers().get(\"working\").isActive());\n            Assert.assertEquals(\"stdio\", resolved.getServers().get(\"working\").getTransportType());\n        } finally {\n            restoreUserHome(previousUserHome);\n        }\n    }\n\n    private void restoreUserHome(String value) {\n        if (value == null) {\n            System.clearProperty(\"user.home\");\n            return;\n        }\n        System.setProperty(\"user.home\", value);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliMcpRuntimeManagerTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.mcp;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.mcp.entity.McpToolDefinition;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class CliMcpRuntimeManagerTest {\n\n    @Test\n    public void connectedServerRegistersToolsAndRoutesCalls() throws Exception {\n        FakeClientSession timeSession = new FakeClientSession(Collections.singletonList(tool(\"time_now\", \"Read current time\")));\n        CliMcpRuntimeManager runtimeManager = new CliMcpRuntimeManager(\n                resolvedConfig(\n                        Collections.singletonMap(\"time\", activeServer(\"time\", \"stdio\", \"uvx\")),\n                        Collections.singletonList(\"time\"),\n                        Collections.<String>emptyList(),\n                        Collections.<String>emptyList()\n                ),\n                new FakeClientFactory(Collections.singletonMap(\"time\", timeSession))\n        );\n\n        runtimeManager.start();\n        Assert.assertNotNull(runtimeManager.getToolRegistry());\n        Assert.assertNotNull(runtimeManager.getToolExecutor());\n        Assert.assertEquals(1, runtimeManager.getToolRegistry().getTools().size());\n        Assert.assertEquals(1, runtimeManager.getStatuses().size());\n        Assert.assertEquals(CliMcpRuntimeManager.STATE_CONNECTED, runtimeManager.getStatuses().get(0).getState());\n        Assert.assertEquals(1, runtimeManager.getStatuses().get(0).getToolCount());\n\n        String result = runtimeManager.getToolExecutor().execute(AgentToolCall.builder()\n                .name(\"time_now\")\n                .arguments(\"{\\\"timezone\\\":\\\"Asia/Shanghai\\\"}\")\n                .build());\n\n        Assert.assertEquals(\"ok:time_now\", result);\n        Assert.assertTrue(timeSession.connected);\n        Assert.assertEquals(\"time_now\", timeSession.lastToolName);\n        Assert.assertEquals(\"Asia/Shanghai\", String.valueOf(timeSession.lastArguments.get(\"timezone\")));\n\n        runtimeManager.close();\n        Assert.assertTrue(timeSession.closed);\n    }\n\n    @Test\n    public void failedAndConflictingServersDoNotBlockHealthyServer() throws Exception {\n        FakeClientSession fetchSession = new FakeClientSession(Collections.singletonList(tool(\"search_web\", \"Search the web\")));\n        FakeClientSession duplicateSession = new FakeClientSession(Collections.singletonList(tool(\"search_web\", \"Duplicate tool\")));\n        FakeClientSession pausedSession = new FakeClientSession(Collections.singletonList(tool(\"paused_tool\", \"Paused tool\")));\n\n        Map<String, CliResolvedMcpServer> servers = new LinkedHashMap<String, CliResolvedMcpServer>();\n        servers.put(\"fetch\", activeServer(\"fetch\", \"sse\", null));\n        servers.put(\"broken\", activeServer(\"broken\", \"sse\", null));\n        servers.put(\"duplicate\", activeServer(\"duplicate\", \"streamable_http\", null));\n        servers.put(\"paused\", pausedServer(\"paused\", \"stdio\", \"uvx\"));\n        servers.put(\"disabled\", disabledServer(\"disabled\", \"stdio\", \"uvx\"));\n\n        Map<String, FakeClientSession> sessions = new LinkedHashMap<String, FakeClientSession>();\n        sessions.put(\"fetch\", fetchSession);\n        sessions.put(\"duplicate\", duplicateSession);\n        sessions.put(\"paused\", pausedSession);\n\n        CliMcpRuntimeManager runtimeManager = new CliMcpRuntimeManager(\n                resolvedConfig(\n                        servers,\n                        Arrays.asList(\"fetch\", \"broken\", \"duplicate\", \"paused\"),\n                        Collections.singletonList(\"paused\"),\n                        Collections.singletonList(\"missing\")\n                ),\n                new FakeClientFactory(sessions, Collections.singletonMap(\"broken\", new IllegalStateException(\"connect failed\")))\n        );\n\n        runtimeManager.start();\n\n        Map<String, CliMcpStatusSnapshot> statuses = indexStatuses(runtimeManager.getStatuses());\n        Assert.assertEquals(CliMcpRuntimeManager.STATE_CONNECTED, statuses.get(\"fetch\").getState());\n        Assert.assertEquals(CliMcpRuntimeManager.STATE_ERROR, statuses.get(\"broken\").getState());\n        Assert.assertTrue(statuses.get(\"broken\").getErrorSummary().contains(\"connect failed\"));\n        Assert.assertEquals(CliMcpRuntimeManager.STATE_ERROR, statuses.get(\"duplicate\").getState());\n        Assert.assertTrue(statuses.get(\"duplicate\").getErrorSummary().contains(\"search_web\"));\n        Assert.assertEquals(CliMcpRuntimeManager.STATE_PAUSED, statuses.get(\"paused\").getState());\n        Assert.assertEquals(CliMcpRuntimeManager.STATE_DISABLED, statuses.get(\"disabled\").getState());\n        Assert.assertEquals(CliMcpRuntimeManager.STATE_MISSING, statuses.get(\"missing\").getState());\n\n        Assert.assertNotNull(runtimeManager.getToolRegistry());\n        Assert.assertEquals(1, runtimeManager.getToolRegistry().getTools().size());\n        Assert.assertEquals(\"ok:search_web\", runtimeManager.getToolExecutor().execute(AgentToolCall.builder()\n                .name(\"search_web\")\n                .arguments(\"{}\")\n                .build()));\n        Assert.assertEquals(Arrays.asList(\n                \"MCP unavailable: broken (connect failed)\",\n                \"MCP unavailable: duplicate (MCP tool name conflict: search_web already provided by fetch)\",\n                \"MCP unavailable: missing (workspace references undefined MCP server)\"\n        ), runtimeManager.buildStartupWarnings());\n\n        runtimeManager.close();\n        Assert.assertTrue(fetchSession.closed);\n        Assert.assertTrue(duplicateSession.closed);\n        Assert.assertFalse(pausedSession.connected);\n    }\n\n    private Map<String, CliMcpStatusSnapshot> indexStatuses(List<CliMcpStatusSnapshot> statuses) {\n        Map<String, CliMcpStatusSnapshot> index = new LinkedHashMap<String, CliMcpStatusSnapshot>();\n        for (CliMcpStatusSnapshot status : statuses) {\n            if (status != null) {\n                index.put(status.getServerName(), status);\n            }\n        }\n        return index;\n    }\n\n    private CliResolvedMcpConfig resolvedConfig(Map<String, CliResolvedMcpServer> servers,\n                                                List<String> enabled,\n                                                List<String> paused,\n                                                List<String> missing) {\n        return new CliResolvedMcpConfig(servers, enabled, paused, missing);\n    }\n\n    private CliResolvedMcpServer activeServer(String name, String type, String command) {\n        CliMcpServerDefinition definition = CliMcpServerDefinition.builder()\n                .type(type)\n                .command(command)\n                .url(command == null ? \"https://example.com/\" + name : null)\n                .build();\n        return new CliResolvedMcpServer(name, type, true, false, true, null, definition);\n    }\n\n    private CliResolvedMcpServer pausedServer(String name, String type, String command) {\n        CliMcpServerDefinition definition = CliMcpServerDefinition.builder()\n                .type(type)\n                .command(command)\n                .build();\n        return new CliResolvedMcpServer(name, type, true, true, false, null, definition);\n    }\n\n    private CliResolvedMcpServer disabledServer(String name, String type, String command) {\n        CliMcpServerDefinition definition = CliMcpServerDefinition.builder()\n                .type(type)\n                .command(command)\n                .build();\n        return new CliResolvedMcpServer(name, type, false, false, false, null, definition);\n    }\n\n    private McpToolDefinition tool(String name, String description) {\n        Map<String, Object> schema = new LinkedHashMap<String, Object>();\n        Map<String, Object> properties = new LinkedHashMap<String, Object>();\n        Map<String, Object> timezone = new LinkedHashMap<String, Object>();\n        timezone.put(\"type\", \"string\");\n        timezone.put(\"description\", \"Timezone\");\n        properties.put(\"timezone\", timezone);\n        schema.put(\"type\", \"object\");\n        schema.put(\"properties\", properties);\n        schema.put(\"required\", Collections.singletonList(\"timezone\"));\n        return McpToolDefinition.builder()\n                .name(name)\n                .description(description)\n                .inputSchema(schema)\n                .build();\n    }\n\n    private static final class FakeClientFactory implements CliMcpRuntimeManager.ClientFactory {\n\n        private final Map<String, FakeClientSession> sessions;\n        private final Map<String, RuntimeException> failures;\n\n        private FakeClientFactory(Map<String, FakeClientSession> sessions) {\n            this(sessions, Collections.<String, RuntimeException>emptyMap());\n        }\n\n        private FakeClientFactory(Map<String, FakeClientSession> sessions,\n                                  Map<String, RuntimeException> failures) {\n            this.sessions = sessions == null ? Collections.<String, FakeClientSession>emptyMap() : sessions;\n            this.failures = failures == null ? Collections.<String, RuntimeException>emptyMap() : failures;\n        }\n\n        @Override\n        public CliMcpRuntimeManager.ClientSession create(CliResolvedMcpServer server) {\n            if (server != null && failures.containsKey(server.getName())) {\n                throw failures.get(server.getName());\n            }\n            FakeClientSession session = sessions.get(server == null ? null : server.getName());\n            if (session == null) {\n                throw new IllegalStateException(\"missing fake session for \" + (server == null ? null : server.getName()));\n            }\n            return session;\n        }\n    }\n\n    private static final class FakeClientSession implements CliMcpRuntimeManager.ClientSession {\n\n        private final List<McpToolDefinition> tools;\n        private boolean connected;\n        private boolean closed;\n        private String lastToolName;\n        private Map<String, Object> lastArguments;\n\n        private FakeClientSession(List<McpToolDefinition> tools) {\n            this.tools = tools == null ? Collections.<McpToolDefinition>emptyList() : new ArrayList<McpToolDefinition>(tools);\n        }\n\n        @Override\n        public void connect() {\n            connected = true;\n        }\n\n        @Override\n        public List<McpToolDefinition> listTools() {\n            return new ArrayList<McpToolDefinition>(tools);\n        }\n\n        @Override\n        @SuppressWarnings(\"unchecked\")\n        public String callTool(String toolName, Object arguments) {\n            this.lastToolName = toolName;\n            this.lastArguments = arguments instanceof Map\n                    ? new LinkedHashMap<String, Object>((Map<String, Object>) arguments)\n                    : Collections.<String, Object>emptyMap();\n            return \"ok:\" + toolName;\n        }\n\n        @Override\n        public void close() {\n            closed = true;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliProviderConfigManagerTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderProfile;\nimport io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig;\nimport io.github.lnyocly.ai4j.cli.provider.CliResolvedProviderConfig;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.Properties;\n\npublic class CliProviderConfigManagerTest {\n\n    @Test\n    public void saveAndLoadProviderAndWorkspaceConfigs() throws Exception {\n        Path home = Files.createTempDirectory(\"ai4j-cli-home\");\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-workspace\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n\n            CliProvidersConfig providersConfig = CliProvidersConfig.builder()\n                    .defaultProfile(\"zhipu-main\")\n                    .build();\n            providersConfig.getProfiles().put(\"zhipu-main\", CliProviderProfile.builder()\n                    .provider(\"zhipu\")\n                    .protocol(\"chat\")\n                    .model(\"glm-4.7\")\n                    .baseUrl(\"https://open.bigmodel.cn/api/coding/paas/v4\")\n                    .apiKey(\"secret-zhipu\")\n                    .build());\n            manager.saveProvidersConfig(providersConfig);\n            manager.saveWorkspaceConfig(CliWorkspaceConfig.builder()\n                    .activeProfile(\"zhipu-main\")\n                    .modelOverride(\"glm-4.7-plus\")\n                    .experimentalSubagentsEnabled(Boolean.FALSE)\n                    .experimentalAgentTeamsEnabled(Boolean.TRUE)\n                    .enabledMcpServers(Arrays.asList(\" fetch \", \"time\", \"fetch\"))\n                    .skillDirectories(Arrays.asList(\" .ai4j/skills \", \"C:/skills/team \", \".ai4j/skills\"))\n                    .agentDirectories(Arrays.asList(\" .ai4j/agents \", \"C:/agents/team \", \".ai4j/agents\"))\n                    .build());\n\n            CliProvidersConfig loadedProviders = manager.loadProvidersConfig();\n            CliWorkspaceConfig loadedWorkspace = manager.loadWorkspaceConfig();\n            CliResolvedProviderConfig resolved = manager.resolve(\n                    null,\n                    null,\n                    null,\n                    null,\n                    null,\n                    Collections.<String, String>emptyMap(),\n                    new Properties()\n            );\n\n            Assert.assertEquals(\"zhipu-main\", loadedProviders.getDefaultProfile());\n            Assert.assertEquals(1, loadedProviders.getProfiles().size());\n            Assert.assertEquals(\"zhipu-main\", loadedWorkspace.getActiveProfile());\n            Assert.assertEquals(\"glm-4.7-plus\", loadedWorkspace.getModelOverride());\n            Assert.assertEquals(Boolean.FALSE, loadedWorkspace.getExperimentalSubagentsEnabled());\n            Assert.assertEquals(Boolean.TRUE, loadedWorkspace.getExperimentalAgentTeamsEnabled());\n            Assert.assertEquals(Arrays.asList(\"fetch\", \"time\"), loadedWorkspace.getEnabledMcpServers());\n            Assert.assertEquals(Arrays.asList(\".ai4j/skills\", \"C:/skills/team\"), loadedWorkspace.getSkillDirectories());\n            Assert.assertEquals(Arrays.asList(\".ai4j/agents\", \"C:/agents/team\"), loadedWorkspace.getAgentDirectories());\n            Assert.assertEquals(PlatformType.ZHIPU, resolved.getProvider());\n            Assert.assertEquals(CliProtocol.CHAT, resolved.getProtocol());\n            Assert.assertEquals(\"glm-4.7-plus\", resolved.getModel());\n            Assert.assertEquals(\"secret-zhipu\", resolved.getApiKey());\n        } finally {\n            restoreUserHome(previousUserHome);\n        }\n    }\n\n    @Test\n    public void loadProvidersConfigMigratesLegacyAutoProtocol() throws Exception {\n        Path home = Files.createTempDirectory(\"ai4j-cli-home-auto\");\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-workspace-auto\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n\n            CliProvidersConfig providersConfig = CliProvidersConfig.builder()\n                    .defaultProfile(\"openai-main\")\n                    .build();\n            providersConfig.getProfiles().put(\"openai-main\", CliProviderProfile.builder()\n                    .provider(\"openai\")\n                    .protocol(\"auto\")\n                    .model(\"gpt-5-mini\")\n                    .build());\n            manager.saveProvidersConfig(providersConfig);\n\n            CliProvidersConfig loadedProviders = manager.loadProvidersConfig();\n            CliProviderProfile loadedProfile = loadedProviders.getProfiles().get(\"openai-main\");\n            CliResolvedProviderConfig resolved = manager.resolve(\n                    null,\n                    null,\n                    null,\n                    null,\n                    null,\n                    Collections.<String, String>emptyMap(),\n                    new Properties()\n            );\n\n            Assert.assertNotNull(loadedProfile);\n            Assert.assertEquals(\"responses\", loadedProfile.getProtocol());\n            Assert.assertEquals(CliProtocol.RESPONSES, resolved.getProtocol());\n        } finally {\n            restoreUserHome(previousUserHome);\n        }\n    }\n\n    private void restoreUserHome(String value) {\n        if (value == null) {\n            System.clearProperty(\"user.home\");\n            return;\n        }\n        System.setProperty(\"user.home\", value);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CliThemeStylerTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.render.CliThemeStyler;\nimport io.github.lnyocly.ai4j.tui.TuiTheme;\nimport org.junit.Test;\n\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\n\npublic class CliThemeStylerTest {\n    @Test\n    public void styleTranscriptLineAddsAnsiForBulletBlocksWhenEnabled() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), true);\n\n        String value = styler.styleTranscriptLine(\"• Error\");\n\n        assertTrue(value.contains(\"\\u001b[\"));\n        assertTrue(value.contains(\"Error\"));\n    }\n\n    @Test\n    public void buildPrimaryStatusLineStylesSpinnerAndDetail() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), true);\n\n        String value = styler.buildPrimaryStatusLine(\"Thinking\", true, \"⠋\", \"Analyzing workspace\");\n\n        assertTrue(value.contains(\"\\u001b[\"));\n        assertTrue(value.contains(\"Thinking\"));\n        assertTrue(value.contains(\"Analyzing workspace\"));\n    }\n\n    @Test\n    public void ansiDisabledLeavesTranscriptPlain() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), false);\n\n        String value = styler.styleTranscriptLine(\"• Approved\");\n\n        assertFalse(value.contains(\"\\u001b[\"));\n        assertTrue(\"• Approved\".equals(value));\n    }\n\n    @Test\n    public void styleThinkingTranscriptLineUsesAnsiWhenEnabled() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), true);\n\n        String value = styler.styleTranscriptLine(\"Thinking: inspect request\");\n\n        assertTrue(value.contains(\"\\u001b[\"));\n        assertTrue(value.contains(\"Thinking: inspect request\"));\n    }\n\n    @Test\n    public void buildCompactStatusLineIncludesStatusAndContext() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), true);\n\n        String value = styler.buildCompactStatusLine(\n                \"Thinking\",\n                true,\n                \"⠋\",\n                \"Analyzing workspace\",\n                \"glm-4.7\",\n                \"ai4j-sdk\",\n                \"Enter a prompt or /command\"\n        );\n\n        assertTrue(value.contains(\"Thinking\"));\n        assertTrue(value.contains(\"glm-4.7\"));\n        assertTrue(value.contains(\"ai4j-sdk\"));\n        assertFalse(value.contains(\"Enter a prompt or /command\"));\n    }\n\n    @Test\n    public void styleCodeBlockLineUsesAnsiWhenEnabled() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), true);\n        CliThemeStyler.TranscriptStyleState state = new CliThemeStyler.TranscriptStyleState();\n        state.enterCodeBlock(\"java\");\n        String value = styler.styleTranscriptLine(\"    System.out.println(\\\"hi\\\");\", state);\n\n        assertTrue(value.contains(\"\\u001b[\"));\n        assertTrue(value.contains(\"System\"));\n        assertTrue(value.contains(\"println\"));\n        assertTrue(value.contains(\"\\\"hi\\\"\"));\n    }\n\n    @Test\n    public void styleJavaCodeBlockUsesTranscriptStateForSyntaxHighlighting() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), true);\n        CliThemeStyler.TranscriptStyleState state = new CliThemeStyler.TranscriptStyleState();\n        state.enterCodeBlock(\"java\");\n        String body = styler.styleTranscriptLine(\"    public class Demo { String value = \\\"hi\\\"; // note\", state);\n        String close = styler.styleTranscriptLine(\"After\");\n\n        assertTrue(body.contains(\"\\u001b[\"));\n        assertTrue(body.contains(\"public\"));\n        assertTrue(body.contains(\"\\\"hi\\\"\"));\n        assertTrue(body.contains(\"// note\"));\n        assertTrue(close.contains(\"\\u001b[\"));\n    }\n\n    @Test\n    public void styleJsonCodeBlockHighlightsKeysAndLiterals() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), true);\n        CliThemeStyler.TranscriptStyleState state = new CliThemeStyler.TranscriptStyleState();\n        state.enterCodeBlock(\"json\");\n        String value = styler.styleTranscriptLine(\"    {\\\"enabled\\\": true, \\\"count\\\": 2}\", state);\n\n        assertTrue(value.contains(\"\\u001b[\"));\n        assertTrue(value.contains(\"\\\"enabled\\\"\"));\n        assertTrue(value.contains(\"true\"));\n        assertTrue(value.contains(\"2\"));\n    }\n\n    @Test\n    public void ansiDisabledStripsInlineMarkdownMarkers() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), false);\n\n        String value = styler.styleTranscriptLine(\"Use `rg` and **fast path**\");\n\n        assertTrue(\"Use rg and fast path\".equals(value));\n    }\n\n    @Test\n    public void styleHeadingAndQuoteUseAnsiWhenEnabled() {\n        CliThemeStyler styler = new CliThemeStyler(theme(), true);\n\n        String heading = styler.styleTranscriptLine(\"## Files\");\n        String quote = styler.styleTranscriptLine(\"> note\");\n\n        assertTrue(heading.contains(\"\\u001b[\"));\n        assertTrue(heading.contains(\"## Files\"));\n        assertTrue(quote.contains(\"\\u001b[\"));\n        assertTrue(quote.contains(\"> note\"));\n    }\n\n    private TuiTheme theme() {\n        TuiTheme theme = new TuiTheme();\n        theme.setBrand(\"#7cc6fe\");\n        theme.setAccent(\"#f5b14c\");\n        theme.setSuccess(\"#8fd694\");\n        theme.setWarning(\"#f4d35e\");\n        theme.setDanger(\"#ef6f6c\");\n        theme.setText(\"#f3f4f6\");\n        theme.setMuted(\"#9ca3af\");\n        return theme;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodeCommandOptionsParserTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptionsParser;\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderProfile;\nimport io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.nio.file.Paths;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Properties;\n\npublic class CodeCommandOptionsParserTest {\n\n    private final CodeCommandOptionsParser parser = new CodeCommandOptionsParser();\n\n    @Test\n    public void test_parse_resolves_env_and_defaults() {\n        Map<String, String> env = new HashMap<String, String>();\n        env.put(\"AI4J_WORKSPACE\", \"workspace-from-env\");\n        env.put(\"ZHIPU_API_KEY\", \"zhipu-key-from-env\");\n\n        CodeCommandOptions options = parser.parse(\n                Arrays.asList(\"--provider\", \"zhipu\", \"--model\", \"GLM-4.5-Flash\"),\n                env,\n                new Properties(),\n                Paths.get(\".\")\n        );\n\n        Assert.assertFalse(options.isHelp());\n        Assert.assertEquals(CliUiMode.CLI, options.getUiMode());\n        Assert.assertEquals(PlatformType.ZHIPU, options.getProvider());\n        Assert.assertEquals(CliProtocol.CHAT, options.getProtocol());\n        Assert.assertEquals(\"GLM-4.5-Flash\", options.getModel());\n        Assert.assertEquals(\"zhipu-key-from-env\", options.getApiKey());\n        Assert.assertEquals(\"workspace-from-env\", options.getWorkspace());\n        Assert.assertEquals(0, options.getMaxSteps());\n        Assert.assertEquals(Boolean.FALSE, options.getParallelToolCalls());\n    }\n\n    @Test\n    public void test_help_does_not_require_model() {\n        CodeCommandOptions options = parser.parse(\n                Collections.singletonList(\"--help\"),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                Paths.get(\".\")\n        );\n\n        Assert.assertTrue(options.isHelp());\n        Assert.assertEquals(CliUiMode.CLI, options.getUiMode());\n        Assert.assertNull(options.getModel());\n    }\n\n    @Test\n    public void test_parse_ui_mode_from_env() {\n        Map<String, String> env = new HashMap<String, String>();\n        env.put(\"AI4J_UI\", \"tui\");\n\n        CodeCommandOptions options = parser.parse(\n                Arrays.asList(\"--model\", \"demo-model\"),\n                env,\n                new Properties(),\n                Paths.get(\".\")\n        );\n\n        Assert.assertEquals(CliUiMode.TUI, options.getUiMode());\n    }\n\n    @Test\n    public void test_parse_session_options() {\n        CodeCommandOptions options = parser.parse(\n                Arrays.asList(\n                        \"--model\", \"demo-model\",\n                        \"--workspace\", \"workspace-root\",\n                        \"--session-id\", \"session-alpha\",\n                        \"--fork\", \"session-beta\",\n                        \"--theme\", \"amber\",\n                        \"--approval\", \"safe\",\n                        \"--no-session\", \"false\",\n                        \"--session-dir\", \"custom-sessions\",\n                        \"--auto-save-session\", \"false\",\n                        \"--auto-compact\", \"true\",\n                        \"--compact-context-window-tokens\", \"64000\",\n                        \"--compact-reserve-tokens\", \"8000\",\n                        \"--compact-keep-recent-tokens\", \"12000\",\n                        \"--compact-summary-max-output-tokens\", \"512\"\n                ),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                Paths.get(\".\")\n        );\n\n        Assert.assertEquals(\"session-alpha\", options.getSessionId());\n        Assert.assertEquals(\"session-beta\", options.getForkSessionId());\n        Assert.assertEquals(\"amber\", options.getTheme());\n        Assert.assertEquals(ApprovalMode.SAFE, options.getApprovalMode());\n        Assert.assertFalse(options.isNoSession());\n        Assert.assertEquals(\"custom-sessions\", options.getSessionStoreDir());\n        Assert.assertFalse(options.isAutoSaveSession());\n        Assert.assertTrue(options.isAutoCompact());\n        Assert.assertEquals(64000, options.getCompactContextWindowTokens());\n        Assert.assertEquals(8000, options.getCompactReserveTokens());\n        Assert.assertEquals(12000, options.getCompactKeepRecentTokens());\n        Assert.assertEquals(512, options.getCompactSummaryMaxOutputTokens());\n    }\n\n    @Test\n    public void test_parse_stream_option() {\n        CodeCommandOptions options = parser.parse(\n                Arrays.asList(\"--model\", \"demo-model\", \"--stream\", \"true\"),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                Paths.get(\".\")\n        );\n\n        Assert.assertTrue(options.isStream());\n    }\n\n    @Test\n    public void test_invalid_provider_should_fail_fast() {\n        try {\n            parser.parse(\n                    Arrays.asList(\"--provider\", \"unknown\", \"--model\", \"demo-model\"),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    Paths.get(\".\")\n            );\n            Assert.fail(\"Expected invalid provider to fail fast\");\n        } catch (IllegalArgumentException ex) {\n            Assert.assertEquals(\"Unsupported provider: unknown\", ex.getMessage());\n        }\n    }\n\n    @Test\n    public void test_no_session_cannot_resume_or_fork_on_startup() {\n        try {\n            parser.parse(\n                    Arrays.asList(\"--model\", \"demo-model\", \"--no-session\", \"--resume\", \"session-alpha\"),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    Paths.get(\".\")\n            );\n            Assert.fail(\"Expected conflicting no-session and resume\");\n        } catch (IllegalArgumentException ex) {\n            Assert.assertEquals(\"--no-session cannot be combined with --resume\", ex.getMessage());\n        }\n\n        try {\n            parser.parse(\n                    Arrays.asList(\"--model\", \"demo-model\", \"--no-session\", \"--fork\", \"session-alpha\"),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    Paths.get(\".\")\n            );\n            Assert.fail(\"Expected conflicting no-session and fork\");\n        } catch (IllegalArgumentException ex) {\n            Assert.assertEquals(\"--no-session cannot be combined with --fork\", ex.getMessage());\n        }\n    }\n\n    @Test\n    public void test_parse_prefers_workspace_and_global_profile_config_before_env_fallbacks() throws Exception {\n        java.nio.file.Path home = java.nio.file.Files.createTempDirectory(\"ai4j-cli-parser-home\");\n        java.nio.file.Path workspace = java.nio.file.Files.createTempDirectory(\"ai4j-cli-parser-workspace\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n            CliProvidersConfig providersConfig = CliProvidersConfig.builder()\n                    .defaultProfile(\"openai-default\")\n                    .build();\n            providersConfig.getProfiles().put(\"openai-default\", CliProviderProfile.builder()\n                    .provider(\"openai\")\n                    .protocol(\"responses\")\n                    .model(\"gpt-5-mini\")\n                    .apiKey(\"openai-default-key\")\n                    .build());\n            providersConfig.getProfiles().put(\"zhipu-workspace\", CliProviderProfile.builder()\n                    .provider(\"zhipu\")\n                    .protocol(\"chat\")\n                    .model(\"glm-4.7\")\n                    .baseUrl(\"https://open.bigmodel.cn/api/coding/paas/v4\")\n                    .apiKey(\"zhipu-workspace-key\")\n                    .build());\n            manager.saveProvidersConfig(providersConfig);\n            manager.saveWorkspaceConfig(CliWorkspaceConfig.builder()\n                    .activeProfile(\"zhipu-workspace\")\n                    .modelOverride(\"glm-4.7-plus\")\n                    .build());\n\n            Map<String, String> env = new HashMap<String, String>();\n            env.put(\"AI4J_PROVIDER\", \"deepseek\");\n            env.put(\"AI4J_MODEL\", \"deepseek-chat\");\n            env.put(\"AI4J_API_KEY\", \"generic-env-key\");\n            env.put(\"DEEPSEEK_API_KEY\", \"deepseek-env-key\");\n\n            CodeCommandOptions options = parser.parse(\n                    Arrays.asList(\"--workspace\", workspace.toString()),\n                    env,\n                    new Properties(),\n                    workspace\n            );\n\n            Assert.assertEquals(PlatformType.ZHIPU, options.getProvider());\n            Assert.assertEquals(CliProtocol.CHAT, options.getProtocol());\n            Assert.assertEquals(\"glm-4.7-plus\", options.getModel());\n            Assert.assertEquals(\"zhipu-workspace-key\", options.getApiKey());\n            Assert.assertEquals(\"https://open.bigmodel.cn/api/coding/paas/v4\", options.getBaseUrl());\n        } finally {\n            if (previousUserHome == null) {\n                System.clearProperty(\"user.home\");\n            } else {\n                System.setProperty(\"user.home\", previousUserHome);\n            }\n        }\n    }\n\n    @Test\n    public void test_parse_cli_runtime_values_override_profile_config() throws Exception {\n        java.nio.file.Path home = java.nio.file.Files.createTempDirectory(\"ai4j-cli-parser-home-override\");\n        java.nio.file.Path workspace = java.nio.file.Files.createTempDirectory(\"ai4j-cli-parser-workspace-override\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n            CliProvidersConfig providersConfig = CliProvidersConfig.builder()\n                    .defaultProfile(\"zhipu-default\")\n                    .build();\n            providersConfig.getProfiles().put(\"zhipu-default\", CliProviderProfile.builder()\n                    .provider(\"zhipu\")\n                    .protocol(\"chat\")\n                    .model(\"glm-4.7\")\n                    .apiKey(\"zhipu-config-key\")\n                    .build());\n            manager.saveProvidersConfig(providersConfig);\n\n            CodeCommandOptions options = parser.parse(\n                    Arrays.asList(\n                            \"--workspace\", workspace.toString(),\n                            \"--provider\", \"openai\",\n                            \"--protocol\", \"responses\",\n                            \"--model\", \"gpt-5\",\n                            \"--api-key\", \"openai-cli-key\",\n                            \"--base-url\", \"https://api.openai.com/v1\"\n                    ),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    workspace\n            );\n\n            Assert.assertEquals(PlatformType.OPENAI, options.getProvider());\n            Assert.assertEquals(CliProtocol.RESPONSES, options.getProtocol());\n            Assert.assertEquals(\"gpt-5\", options.getModel());\n            Assert.assertEquals(\"openai-cli-key\", options.getApiKey());\n            Assert.assertEquals(\"https://api.openai.com/v1\", options.getBaseUrl());\n        } finally {\n            if (previousUserHome == null) {\n                System.clearProperty(\"user.home\");\n            } else {\n                System.setProperty(\"user.home\", previousUserHome);\n            }\n        }\n    }\n\n    @Test\n    public void test_parse_rejects_explicit_auto_protocol_flag() {\n        try {\n            parser.parse(\n                    Arrays.asList(\"--provider\", \"openai\", \"--protocol\", \"auto\", \"--model\", \"gpt-5-mini\"),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    Paths.get(\".\")\n            );\n            Assert.fail(\"Expected auto protocol flag to be rejected\");\n        } catch (IllegalArgumentException ex) {\n            Assert.assertEquals(\"Unsupported protocol: auto. Expected: chat, responses\", ex.getMessage());\n        }\n    }\n\n    @Test\n    public void test_parse_provider_override_does_not_reuse_mismatched_profile_credentials() throws Exception {\n        java.nio.file.Path home = java.nio.file.Files.createTempDirectory(\"ai4j-cli-parser-home-provider-only\");\n        java.nio.file.Path workspace = java.nio.file.Files.createTempDirectory(\"ai4j-cli-parser-workspace-provider-only\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n            CliProvidersConfig providersConfig = CliProvidersConfig.builder()\n                    .defaultProfile(\"zhipu-default\")\n                    .build();\n            providersConfig.getProfiles().put(\"zhipu-default\", CliProviderProfile.builder()\n                    .provider(\"zhipu\")\n                    .protocol(\"chat\")\n                    .model(\"glm-4.7\")\n                    .apiKey(\"zhipu-config-key\")\n                    .baseUrl(\"https://open.bigmodel.cn/api/coding/paas/v4\")\n                    .build());\n            manager.saveProvidersConfig(providersConfig);\n\n            Map<String, String> env = new HashMap<String, String>();\n            env.put(\"OPENAI_API_KEY\", \"openai-env-key\");\n\n            CodeCommandOptions options = parser.parse(\n                    Arrays.asList(\"--workspace\", workspace.toString(), \"--provider\", \"openai\", \"--model\", \"gpt-5-mini\"),\n                    env,\n                    new Properties(),\n                    workspace\n            );\n\n            Assert.assertEquals(PlatformType.OPENAI, options.getProvider());\n            Assert.assertEquals(\"gpt-5-mini\", options.getModel());\n            Assert.assertEquals(\"openai-env-key\", options.getApiKey());\n            Assert.assertNull(options.getBaseUrl());\n        } finally {\n            if (previousUserHome == null) {\n                System.clearProperty(\"user.home\");\n            } else {\n                System.setProperty(\"user.home\", previousUserHome);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodeCommandTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommand;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptionsParser;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderProfile;\nimport io.github.lnyocly.ai4j.cli.provider.CliProvidersConfig;\nimport io.github.lnyocly.ai4j.cli.runtime.CliToolApprovalDecorator;\nimport io.github.lnyocly.ai4j.cli.runtime.CodingCliSessionRunner;\nimport io.github.lnyocly.ai4j.cli.runtime.CodingCliTuiSupport;\nimport io.github.lnyocly.ai4j.cli.shell.JlineShellContext;\nimport io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO;\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberSnapshot;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.CodingAgents;\nimport io.github.lnyocly.ai4j.coding.CodingSessionState;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory.PreparedCodingAgent;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliTuiFactory;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpConfig;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpConfigManager;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager;\nimport io.github.lnyocly.ai4j.cli.mcp.CliMcpServerDefinition;\nimport io.github.lnyocly.ai4j.cli.session.CodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore;\nimport io.github.lnyocly.ai4j.cli.session.StoredCodingSession;\nimport io.github.lnyocly.ai4j.agent.team.FileAgentTeamMessageBus;\nimport io.github.lnyocly.ai4j.agent.team.FileAgentTeamStateStore;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessStatus;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.listener.SseListener;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletion;\nimport io.github.lnyocly.ai4j.platform.openai.chat.entity.ChatCompletionResponse;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.tui.StreamsTerminalIO;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport io.github.lnyocly.ai4j.tui.TuiInteractionState;\nimport io.github.lnyocly.ai4j.tui.TuiKeyStroke;\nimport io.github.lnyocly.ai4j.tui.TuiKeyType;\nimport io.github.lnyocly.ai4j.tui.AppendOnlyTuiRuntime;\nimport io.github.lnyocly.ai4j.tui.TuiConfig;\nimport io.github.lnyocly.ai4j.tui.TuiConfigManager;\nimport io.github.lnyocly.ai4j.tui.TuiRenderer;\nimport io.github.lnyocly.ai4j.tui.TuiRuntime;\nimport io.github.lnyocly.ai4j.tui.TuiScreenModel;\nimport io.github.lnyocly.ai4j.tui.TuiSessionView;\nimport io.github.lnyocly.ai4j.tui.TuiTheme;\nimport org.jline.reader.LineReader;\nimport org.jline.terminal.Terminal;\nimport org.jline.terminal.TerminalBuilder;\nimport org.junit.Assert;\nimport org.junit.Test;\nimport okhttp3.Protocol;\nimport okhttp3.Request;\nimport okhttp3.sse.EventSource;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.lang.reflect.Constructor;\nimport java.lang.reflect.InvocationHandler;\nimport java.lang.reflect.Method;\nimport java.lang.reflect.Proxy;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayDeque;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.Deque;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Properties;\nimport java.util.concurrent.Callable;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.TimeUnit;\n\npublic class CodeCommandTest {\n\n    @Test\n    public void test_interactive_mode_runs_until_exit() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-test\");\n        Files.write(workspace.resolve(\"sample.txt\"), Collections.singletonList(\"hello-cli\"), StandardCharsets.UTF_8);\n\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                \"Please inspect sample.txt\\n/exit\\n\".getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"[tool] read_file\"));\n        Assert.assertTrue(output.contains(\"Read result: hello-cli\"));\n        Assert.assertTrue(output.contains(\"Session closed.\"));\n    }\n\n    @Test\n    public void test_interactive_compact_command() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-compact\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"say hello\\n\"\n                        + \"/save\\n\"\n                        + \"/session\\n\"\n                        + \"/theme\\n\"\n                        + \"/theme amber\\n\"\n                        + \"/sessions\\n\"\n                        + \"/events 20\\n\"\n                        + \"/checkpoint\\n\"\n                        + \"/processes\\n\"\n                        + \"/resume session-alpha\\n\"\n                        + \"/compact\\n\"\n                        + \"/compacts 10\\n\"\n                        + \"/status\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--session-id\", \"session-alpha\"),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"saved session: session-alpha\"));\n        Assert.assertTrue(output.contains(\"session\"));\n        Assert.assertTrue(output.contains(\"themes:\"));\n        Assert.assertTrue(output.contains(\"theme switched to: amber\"));\n        Assert.assertTrue(output.contains(\"sessions:\"));\n        Assert.assertTrue(output.contains(\"events:\"));\n        Assert.assertTrue(output.contains(\"checkpoint\"));\n        Assert.assertTrue(output.contains(\"processes:\"));\n        Assert.assertTrue(output.contains(\"resumed session: session-alpha\"));\n        Assert.assertTrue(output.contains(\"compact: mode=manual\"));\n        Assert.assertTrue(output.contains(\"compacts:\"));\n        Assert.assertTrue(output.contains(\"items=\"));\n        Assert.assertTrue(output.contains(\"checkpointGoal=Continue the CLI session.\"));\n        Assert.assertTrue(output.contains(\"compact=manual\"));\n        Assert.assertTrue(output.contains(\"Session closed.\"));\n    }\n\n    @Test\n    public void test_resume_session_across_runs() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-resume\");\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int firstExit = command.run(\n                Arrays.asList(\n                        \"--model\", \"fake-model\",\n                        \"--workspace\", workspace.toString(),\n                        \"--session-id\", \"resume-me\",\n                        \"--prompt\", \"remember alpha\"\n                ),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), new ByteArrayOutputStream())\n        );\n        Assert.assertEquals(0, firstExit);\n\n        ByteArrayOutputStream resumedOut = new ByteArrayOutputStream();\n        ByteArrayOutputStream resumedErr = new ByteArrayOutputStream();\n        int resumedExit = command.run(\n                Arrays.asList(\n                        \"--model\", \"fake-model\",\n                        \"--workspace\", workspace.toString(),\n                        \"--resume\", \"resume-me\",\n                        \"--prompt\", \"what do you remember\"\n                ),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), resumedOut, resumedErr)\n        );\n\n        String output = new String(resumedOut.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, resumedExit);\n        Assert.assertTrue(output.contains(\"session=resume-me\"));\n        Assert.assertTrue(output.contains(\"history: remember alpha\"));\n    }\n\n    @Test\n    public void test_interactive_history_tree_and_fork() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-fork\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"remember alpha\\n\"\n                        + \"/save\\n\"\n                        + \"/fork session-beta\\n\"\n                        + \"what do you remember\\n\"\n                        + \"/history\\n\"\n                        + \"/tree\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--session-id\", \"session-alpha\"),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"forked session: session-beta <- session-alpha\"));\n        Assert.assertTrue(output.contains(\"history: remember alpha | what do you remember\"));\n        Assert.assertTrue(output.contains(\"history:\"));\n        Assert.assertTrue(output.contains(\"tree:\"));\n        Assert.assertTrue(output.contains(\"session-alpha\"));\n        Assert.assertTrue(output.contains(\"session-beta\"));\n    }\n\n    @Test\n    public void test_no_session_mode_avoids_file_session_store() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-no-session\");\n        Path sessionDir = workspace.resolve(\".ai4j\").resolve(\"sessions\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--no-session\", \"--prompt\", \"say hello\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Echo: say hello\"));\n        Assert.assertFalse(Files.exists(sessionDir));\n    }\n\n    @Test\n    public void test_custom_command_templates_are_listed_and_executed() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-custom-cmd\");\n        Path commandsDir = Files.createDirectories(workspace.resolve(\".ai4j\").resolve(\"commands\"));\n        Files.write(commandsDir.resolve(\"review.md\"), Collections.singletonList(\n                \"# Review workspace\\nReview the workspace at $WORKSPACE.\\nFocus: $ARGUMENTS\\nSession: $SESSION_ID\"\n        ), StandardCharsets.UTF_8);\n\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/commands\\n\"\n                        + \"/cmd review auth flow\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"commands:\"));\n        Assert.assertTrue(output.contains(\"review\"));\n        Assert.assertTrue(output.contains(\"Echo: Review the workspace at \" + workspace.toString()));\n        Assert.assertTrue(output.contains(\"Focus: auth flow\"));\n    }\n\n    @Test\n    public void test_skills_command_lists_discovered_workspace_skills() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-skills\");\n        Path skillsDir = Files.createDirectories(workspace.resolve(\".ai4j\").resolve(\"skills\").resolve(\"repo-review\"));\n        Files.write(skillsDir.resolve(\"SKILL.md\"), Arrays.asList(\n                \"---\",\n                \"name: repo-review\",\n                \"description: Review repository changes safely.\",\n                \"---\",\n                \"\",\n                \"# Repo Review\",\n                \"Review repository changes safely.\"\n        ), StandardCharsets.UTF_8);\n\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/skills\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"skills:\"));\n        Assert.assertTrue(output.contains(\"count=1\"));\n        Assert.assertTrue(output.contains(\"repo-review\"));\n        Assert.assertTrue(output.contains(\"source=workspace\"));\n        Assert.assertTrue(output.contains(\"Review repository changes safely.\"));\n    }\n\n    @Test\n    public void test_skills_command_with_name_prints_skill_detail_and_content() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-skill-detail\");\n        Path skillsDir = Files.createDirectories(workspace.resolve(\".ai4j\").resolve(\"skills\").resolve(\"repo-review\"));\n        Files.write(skillsDir.resolve(\"SKILL.md\"), Arrays.asList(\n                \"---\",\n                \"name: repo-review\",\n                \"description: Review repository changes safely.\",\n                \"---\",\n                \"\",\n                \"# Repo Review\",\n                \"Review repository changes safely.\",\n                \"\",\n                \"Use rg before grep.\"\n        ), StandardCharsets.UTF_8);\n\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/skills repo-review\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"skill:\"));\n        Assert.assertTrue(output.contains(\"name=repo-review\"));\n        Assert.assertTrue(output.contains(\"path=\"));\n        Assert.assertTrue(output.contains(\"description=Review repository changes safely.\"));\n        Assert.assertTrue(output.contains(\"roots=\"));\n        Assert.assertFalse(output.contains(\"content:\"));\n        Assert.assertFalse(output.contains(\"# Repo Review\"));\n        Assert.assertFalse(output.contains(\"Use rg before grep.\"));\n    }\n\n    @Test\n    public void test_safe_approval_mode_prompts_before_bash_exec() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-approval\");\n        Files.write(workspace.resolve(\"sample.txt\"), Collections.singletonList(\"hello-cli\"), StandardCharsets.UTF_8);\n\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"run bash sample\\n\"\n                        + \"y\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--approval\", \"safe\"),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Approval required for bash\"));\n        Assert.assertTrue(output.contains(\"type sample.txt\"));\n    }\n\n    @Test\n    public void test_one_shot_prompt_mode() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-oneshot\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"say hello\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Echo: say hello\"));\n        Assert.assertFalse(output.contains(\"assistant>\"));\n    }\n\n    @Test\n    public void test_one_shot_prompt_mode_does_not_duplicate_streamed_final_output() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-oneshot-stream\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"chunked hello\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertEquals(1, countOccurrences(output, \"Hello world from stream.\"));\n    }\n\n    @Test\n    public void test_tui_mode_renders_tui_shell() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"say hello\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"AI4J\"));\n        Assert.assertTrue(output.contains(\"fake-model\"));\n        Assert.assertFalse(output.contains(\"STATUS\"));\n        Assert.assertFalse(output.contains(\"HISTORY\"));\n        Assert.assertFalse(output.contains(\"TREE\"));\n        Assert.assertTrue(output.contains(\"Echo: say hello\"));\n    }\n\n    @Test\n    public void test_tui_mode_requests_approval_inline_for_safe_mode() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-approval\");\n        Files.write(workspace.resolve(\"sample.txt\"), Collections.singletonList(\"hello-cli\"), StandardCharsets.UTF_8);\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--approval\", \"safe\", \"--prompt\", \"run bash sample\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(\"y\\n\".getBytes(StandardCharsets.UTF_8)), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Approval required for bash\"));\n        Assert.assertTrue(output.contains(\"type sample.txt\"));\n        Assert.assertTrue(output.contains(\"Approve? [y/N]\"));\n        Assert.assertTrue(output.contains(\"Approved\"));\n    }\n\n    @Test\n    public void test_tui_mode_rejection_keeps_rejected_block_without_failed_tool_card() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-approval-rejected\");\n        Files.write(workspace.resolve(\"sample.txt\"), Collections.singletonList(\"hello-cli\"), StandardCharsets.UTF_8);\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--approval\", \"safe\", \"--prompt\", \"run bash sample\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(\"n\\n\".getBytes(StandardCharsets.UTF_8)), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Rejected\"));\n        Assert.assertFalse(output.contains(\"Tool failed\"));\n        Assert.assertFalse(output.contains(\"Command failed\"));\n    }\n\n    @Test\n    public void test_tui_mode_renders_tool_cards_for_bash_turns() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-bash\");\n        Files.write(workspace.resolve(\"sample.txt\"), Collections.singletonList(\"hello-cli\"), StandardCharsets.UTF_8);\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"run bash sample\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Ran type sample.txt\"));\n        Assert.assertFalse(output.contains(\"exit=0\"));\n        Assert.assertTrue(output.contains(\"hello-cli\"));\n        Assert.assertFalse(output.contains(\"stdout> hello-cli\"));\n    }\n\n    @Test\n    public void test_tui_mode_keeps_session_alive_when_apply_patch_fails() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-invalid-patch\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"run invalid patch\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Tool failed apply_patch\"));\n        Assert.assertTrue(output.contains(\"Unsupported patch line\"));\n        Assert.assertFalse(output.contains(\"Argument error:\"));\n    }\n\n    @Test\n    public void test_tui_mode_accepts_recoverable_apply_patch_headers() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-recoverable-patch\");\n        Files.write(workspace.resolve(\"sample.txt\"), Collections.singletonList(\"value=1\"), StandardCharsets.UTF_8);\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"run recoverable patch\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertFalse(output.contains(\"Tool failed apply_patch\"));\n        Assert.assertEquals(\"value=2\", new String(Files.readAllBytes(workspace.resolve(\"sample.txt\")), StandardCharsets.UTF_8).trim());\n    }\n\n    @Test\n    public void test_tui_mode_accepts_recoverable_unified_diff_apply_patch_headers() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-unified-patch\");\n        Files.write(workspace.resolve(\"sample.txt\"), Collections.singletonList(\"value=1\"), StandardCharsets.UTF_8);\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"run unified diff patch\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertFalse(output.contains(\"Tool failed apply_patch\"));\n        Assert.assertEquals(\"value=2\", new String(Files.readAllBytes(workspace.resolve(\"sample.txt\")), StandardCharsets.UTF_8).trim());\n    }\n\n    @Test\n    public void test_tui_mode_surfaces_invalid_bash_calls_without_hiding_tool_feedback() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-invalid-bash\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"run invalid bash\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Need to inspect the shell call.\"));\n        Assert.assertTrue(output.contains(\"Command failed bash exec\"));\n        Assert.assertTrue(output.contains(\"bash exec requires a non-empty command\"));\n        Assert.assertTrue(output.contains(\"Tool error: bash exec requires a non-empty command\"));\n        Assert.assertFalse(output.contains(\"(empty command)\"));\n    }\n\n    @Test\n    public void test_tui_interactive_main_buffer_prints_reasoning_in_transcript_before_tool_feedback() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-interactive-reasoning\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"run invalid bash\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Thinking: Need to inspect the shell call.\"));\n        Assert.assertTrue(output.contains(\"Command failed bash exec\"));\n        Assert.assertTrue(output.indexOf(\"Thinking: Need to inspect the shell call.\")\n                < output.indexOf(\"Command failed bash exec\"));\n    }\n\n    @Test\n    public void test_tui_mode_prints_main_buffer_status_without_fullscreen_redraw() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-spinner\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"slow hello\"),\n                new RecordingInteractiveTerminal(out, err)\n        );\n\n        String rawOutput = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        String output = stripAnsi(rawOutput);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Slow hello done.\"));\n        Assert.assertFalse(rawOutput.matches(\"(?s).*\\\\r(?!\\\\n).*\"));\n        Assert.assertFalse(rawOutput.contains(\"\\u001b[2K\"));\n    }\n\n    @Test\n    public void test_tui_mode_streams_chunked_main_buffer_output_without_duplicate_final_flush() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-stream\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"chunked hello\"),\n                new RecordingInteractiveTerminal(out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Hello world from stream.\"));\n        Assert.assertEquals(1, countOccurrences(output, \"Hello world from stream.\"));\n        Assert.assertFalse(output.contains(\"Hello world from stream.Hello world from stream.\"));\n    }\n\n    @Test\n    public void test_tui_mode_renders_fenced_code_blocks_in_streaming_transcript() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-code-block\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"show code block\"),\n                new RecordingInteractiveTerminal(out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertFalse(output.contains(\"[java]\"));\n        Assert.assertFalse(output.contains(\"[code]\"));\n        Assert.assertFalse(output.contains(\"\\u2063\"));\n        Assert.assertFalse(output.contains(\"\\u200b\"));\n        Assert.assertTrue(output.contains(\"System.out.println(\\\"hi\\\");\"));\n        Assert.assertFalse(output.contains(\"  ╭\"));\n        Assert.assertFalse(output.contains(\"  ╰\"));\n    }\n\n    @Test\n    public void test_tui_mode_buffers_split_code_fence_chunks_without_leaking_backticks() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-split-fence\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"show split fence\"),\n                new RecordingInteractiveTerminal(out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Here is python:\"));\n        Assert.assertTrue(output.contains(\"print(\\\"Hello, World!\\\")\"));\n        Assert.assertTrue(output.contains(\"Done.\"));\n        Assert.assertFalse(output.contains(\"```\"));\n        Assert.assertFalse(output.contains(System.lineSeparator() + \"``\"));\n    }\n\n    @Test\n    public void test_tui_mode_does_not_duplicate_final_output_when_provider_rewrites_markdown() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-rewrite-final\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"rewrite final markdown\"),\n                new RecordingInteractiveTerminal(out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertEquals(1, countOccurrences(output, \"已经为你创建了\"));\n        Assert.assertTrue(output.contains(\"print(\\\"Hello, World!\\\")\"));\n    }\n\n    @Test\n    public void test_tui_mode_replays_missing_final_code_block_segment_when_streamed_text_is_incomplete() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-final-code-block\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"final adds code block\"),\n                new RecordingInteractiveTerminal(out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"你可以通过以下命令运行它：\"));\n        Assert.assertTrue(output.contains(\"python hello_world.py\"));\n        Assert.assertTrue(output.contains(\"这个程序会输出：Hello, World!\"));\n    }\n\n    @Test\n    public void test_stream_command_turns_streaming_off_for_current_session() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-stream-command\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/stream off\\n\"\n                        + \"show code block\\n\"\n                        + \"/stream\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"status=off\"));\n        Assert.assertTrue(output.contains(\"request=stream=false\"));\n        Assert.assertTrue(output.contains(\"renders as completed blocks\"));\n        Assert.assertFalse(output.contains(\"[java]\"));\n        Assert.assertFalse(output.contains(\"[code]\"));\n        Assert.assertFalse(output.contains(\"\\u2063\"));\n        Assert.assertFalse(output.contains(\"\\u200b\"));\n        Assert.assertTrue(output.contains(\"System.out.println(\\\"hi\\\");\"));\n    }\n\n    @Test\n    public void test_stream_command_is_on_by_default_for_new_session() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-stream-default-on\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/stream\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"status=on\"));\n        Assert.assertTrue(output.contains(\"request=stream=true\"));\n        Assert.assertTrue(output.contains(\"stream incrementally\"));\n    }\n\n    @Test\n    public void test_stream_command_turns_request_streaming_on_for_current_session() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-stream-command-on\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/stream on\\n\"\n                        + \"record request mode\\n\"\n                        + \"/stream\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"status=on\"));\n        Assert.assertTrue(output.contains(\"request=stream=true\"));\n        Assert.assertTrue(output.contains(\"mode=createStream\"));\n    }\n\n    @Test\n    public void test_cli_stream_option_false_uses_non_streaming_model_request() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-stream-off-request\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--stream\", \"false\", \"--prompt\", \"record request mode\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"mode=create\"));\n        Assert.assertFalse(output.contains(\"mode=createStream\"));\n    }\n\n    @Test\n    public void test_cli_stream_option_uses_streaming_model_request() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-stream-option\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--stream\", \"true\", \"--prompt\", \"record request mode\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"mode=createStream\"));\n    }\n\n    @Test\n    public void test_stream_off_does_not_duplicate_completed_assistant_output() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-stream-off-duplicate\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/stream off\\n\"\n                        + \"chunked hello\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertEquals(1, countOccurrences(output, \"Hello world from stream.\"));\n    }\n\n    @Test\n    public void test_provider_and_model_commands_switch_runtime_and_persist_configs() throws Exception {\n        Path home = Files.createTempDirectory(\"ai4j-cli-provider-home\");\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-provider-workspace\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n            CliProvidersConfig providersConfig = CliProvidersConfig.builder()\n                    .defaultProfile(\"openai-main\")\n                    .build();\n            providersConfig.getProfiles().put(\"openai-main\", CliProviderProfile.builder()\n                    .provider(\"openai\")\n                    .protocol(\"chat\")\n                    .model(\"fake-model\")\n                    .apiKey(\"openai-main-key\")\n                    .build());\n            providersConfig.getProfiles().put(\"zhipu-main\", CliProviderProfile.builder()\n                    .provider(\"zhipu\")\n                    .protocol(\"chat\")\n                    .model(\"glm-4.7\")\n                    .baseUrl(\"https://open.bigmodel.cn/api/coding/paas/v4\")\n                    .apiKey(\"zhipu-main-key\")\n                    .build());\n            manager.saveProvidersConfig(providersConfig);\n\n            ByteArrayInputStream input = new ByteArrayInputStream(\n                    (\"/provider save openai-local\\n\"\n                            + \"/provider default openai-local\\n\"\n                            + \"/provider use zhipu-main\\n\"\n                            + \"/status\\n\"\n                            + \"/model glm-4.7-plus\\n\"\n                            + \"/status\\n\"\n                            + \"/provider default clear\\n\"\n                            + \"/model reset\\n\"\n                            + \"/status\\n\"\n                            + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n            );\n            ByteArrayOutputStream out = new ByteArrayOutputStream();\n            ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n            CodeCommand command = new CodeCommand(\n                    new FakeCodingCliAgentFactory(),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    workspace\n            );\n\n            int exitCode = command.run(\n                    Arrays.asList(\n                            \"--ui\", \"tui\",\n                            \"--workspace\", workspace.toString(),\n                            \"--provider\", \"openai\",\n                            \"--protocol\", \"chat\",\n                            \"--model\", \"fake-model\",\n                            \"--api-key\", \"openai-cli-key\"\n                    ),\n                    new StreamsTerminalIO(input, out, err)\n            );\n\n            String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n            Assert.assertEquals(0, exitCode);\n            Assert.assertTrue(output.contains(\"provider saved: openai-local\"));\n            Assert.assertTrue(output.contains(\"provider default: openai-local\"));\n            Assert.assertTrue(output.contains(\"provider=zhipu, protocol=chat, model=glm-4.7\"));\n            Assert.assertTrue(output.contains(\"modelOverride=glm-4.7-plus\"));\n            Assert.assertTrue(output.contains(\"provider=zhipu, protocol=chat, model=glm-4.7-plus\"));\n            Assert.assertTrue(output.contains(\"provider default cleared\"));\n            Assert.assertTrue(output.contains(\"modelOverride=(none)\"));\n\n            CliWorkspaceConfig workspaceConfig = manager.loadWorkspaceConfig();\n            Assert.assertEquals(\"zhipu-main\", workspaceConfig.getActiveProfile());\n            Assert.assertNull(workspaceConfig.getModelOverride());\n\n            CliProvidersConfig savedProviders = manager.loadProvidersConfig();\n            Assert.assertNull(savedProviders.getDefaultProfile());\n            CliProviderProfile savedProfile = manager.getProfile(\"openai-local\");\n            Assert.assertNotNull(savedProfile);\n            Assert.assertEquals(\"openai\", savedProfile.getProvider());\n            Assert.assertEquals(\"fake-model\", savedProfile.getModel());\n            Assert.assertEquals(\"openai-cli-key\", savedProfile.getApiKey());\n        } finally {\n            restoreUserHome(previousUserHome);\n        }\n    }\n\n    @Test\n    public void test_provider_add_and_edit_persist_explicit_protocols() throws Exception {\n        Path home = Files.createTempDirectory(\"ai4j-cli-provider-add-home\");\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-provider-add-workspace\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n\n            ByteArrayInputStream input = new ByteArrayInputStream(\n                    (\"/provider add zhipu-added --provider zhipu --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4 --api-key added-key\\n\"\n                            + \"/provider edit zhipu-added --model glm-4.7-plus\\n\"\n                            + \"/provider use zhipu-added\\n\"\n                            + \"/status\\n\"\n                            + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n            );\n            ByteArrayOutputStream out = new ByteArrayOutputStream();\n            ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n            CodeCommand command = new CodeCommand(\n                    new FakeCodingCliAgentFactory(),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    workspace\n            );\n\n            int exitCode = command.run(\n                    Arrays.asList(\n                            \"--ui\", \"tui\",\n                            \"--workspace\", workspace.toString(),\n                            \"--provider\", \"openai\",\n                            \"--protocol\", \"chat\",\n                            \"--model\", \"fake-model\",\n                            \"--api-key\", \"openai-cli-key\"\n                    ),\n                    new StreamsTerminalIO(input, out, err)\n            );\n\n            String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n            Assert.assertEquals(0, exitCode);\n            Assert.assertTrue(output.contains(\"provider added: zhipu-added\"));\n            Assert.assertTrue(output.contains(\"provider updated: zhipu-added\"));\n            Assert.assertTrue(output.contains(\"provider=zhipu, protocol=chat, model=glm-4.7-plus\"));\n\n            CliProviderProfile savedProfile = manager.getProfile(\"zhipu-added\");\n            Assert.assertNotNull(savedProfile);\n            Assert.assertEquals(\"zhipu\", savedProfile.getProvider());\n            Assert.assertEquals(\"chat\", savedProfile.getProtocol());\n            Assert.assertEquals(\"glm-4.7-plus\", savedProfile.getModel());\n            Assert.assertEquals(\"https://open.bigmodel.cn/api/coding/paas/v4\", savedProfile.getBaseUrl());\n            Assert.assertEquals(\"added-key\", savedProfile.getApiKey());\n        } finally {\n            restoreUserHome(previousUserHome);\n        }\n    }\n\n    @Test\n    public void test_experimental_command_persists_workspace_flags() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-experimental-workspace\");\n        CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/experimental\\n\"\n                        + \"/experimental subagent off\\n\"\n                        + \"/experimental agent-teams off\\n\"\n                        + \"/experimental\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\n                        \"--ui\", \"tui\",\n                        \"--workspace\", workspace.toString(),\n                        \"--provider\", \"openai\",\n                        \"--protocol\", \"chat\",\n                        \"--model\", \"fake-model\",\n                        \"--api-key\", \"openai-cli-key\"\n                ),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        Assert.assertEquals(0, exitCode);\n\n        CliWorkspaceConfig workspaceConfig = manager.loadWorkspaceConfig();\n        Assert.assertEquals(Boolean.FALSE, workspaceConfig.getExperimentalSubagentsEnabled());\n        Assert.assertEquals(Boolean.FALSE, workspaceConfig.getExperimentalAgentTeamsEnabled());\n    }\n\n    @Test\n    public void test_team_management_commands_render_persisted_team_state() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-team-management\");\n        seedPersistedTeamState(workspace, \"experimental-delivery-team\");\n\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/team list\\n\"\n                        + \"/team status experimental-delivery-team\\n\"\n                        + \"/team messages experimental-delivery-team 5\\n\"\n                        + \"/team resume experimental-delivery-team\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"teams:\"));\n        Assert.assertTrue(output.contains(\"experimental-delivery-team\"));\n        Assert.assertTrue(output.contains(\"team status:\"));\n        Assert.assertTrue(output.contains(\"objective=Deliver a travel planner demo.\"));\n        Assert.assertTrue(output.contains(\"team messages:\"));\n        Assert.assertTrue(output.contains(\"Define the backend contract first.\"));\n        Assert.assertTrue(output.contains(\"team resumed: experimental-delivery-team\"));\n        Assert.assertTrue(output.contains(\"team board:\"));\n        Assert.assertTrue(output.contains(\"lane Backend\"));\n    }\n\n    @Test\n    public void test_mcp_commands_persist_configs_and_render_statuses() throws Exception {\n        Path home = Files.createTempDirectory(\"ai4j-cli-mcp-home\");\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-mcp-workspace\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliMcpConfigManager manager = new CliMcpConfigManager(workspace);\n            CliMcpConfig globalConfig = CliMcpConfig.builder().build();\n            globalConfig.getMcpServers().put(\"fetch\", CliMcpServerDefinition.builder()\n                    .type(\"sse\")\n                    .url(\"https://mcp.api-inference.modelscope.net/1e1a663049b340/sse\")\n                    .build());\n            manager.saveGlobalConfig(globalConfig);\n\n            ByteArrayInputStream input = new ByteArrayInputStream(\n                    (\"/mcp\\n\"\n                            + \"/mcp enable fetch\\n\"\n                            + \"/mcp pause fetch\\n\"\n                            + \"/mcp resume fetch\\n\"\n                            + \"/mcp add --transport http bing-cn-mcp-server https://mcp.api-inference.modelscope.net/0904773a8c2045/mcp\\n\"\n                            + \"/mcp enable bing-cn-mcp-server\\n\"\n                            + \"/mcp remove bing-cn-mcp-server\\n\"\n                            + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n            );\n            ByteArrayOutputStream out = new ByteArrayOutputStream();\n            ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n            CodeCommand command = new CodeCommand(\n                    new FakeCodingCliAgentFactory(),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    workspace\n            );\n\n            int exitCode = command.run(\n                    Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                    new StreamsTerminalIO(input, out, err)\n            );\n\n            String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n            Assert.assertEquals(0, exitCode);\n            Assert.assertTrue(output, output.contains(\"fetch | type=sse | state=disabled | workspace=disabled | paused=no | tools=0\"));\n            Assert.assertTrue(output, output.contains(\"fetch | type=sse | state=configured | workspace=enabled | paused=no | tools=0\"));\n            Assert.assertTrue(output, output.contains(\"fetch | type=sse | state=paused | workspace=enabled | paused=yes | tools=0\"));\n            Assert.assertTrue(output, output.contains(\"mcp added: bing-cn-mcp-server\"));\n            Assert.assertTrue(output, output.contains(\"mcp removed: bing-cn-mcp-server\"));\n\n            CliWorkspaceConfig workspaceConfig = manager.loadWorkspaceConfig();\n            Assert.assertEquals(Collections.singletonList(\"fetch\"), workspaceConfig.getEnabledMcpServers());\n\n            CliMcpConfig savedGlobal = manager.loadGlobalConfig();\n            Assert.assertTrue(savedGlobal.getMcpServers().containsKey(\"fetch\"));\n            Assert.assertFalse(savedGlobal.getMcpServers().containsKey(\"bing-cn-mcp-server\"));\n        } finally {\n            restoreUserHome(previousUserHome);\n        }\n    }\n\n    @Test\n    public void test_startup_warns_for_unavailable_mcp_servers_without_aborting_session() throws Exception {\n        Path home = Files.createTempDirectory(\"ai4j-cli-mcp-warning-home\");\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-mcp-warning-workspace\");\n        String previousUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", home.toString());\n            CliMcpConfigManager manager = new CliMcpConfigManager(workspace);\n            CliMcpConfig globalConfig = CliMcpConfig.builder().build();\n            globalConfig.getMcpServers().put(\"broken\", CliMcpServerDefinition.builder()\n                    .type(\"sse\")\n                    .build());\n            manager.saveGlobalConfig(globalConfig);\n\n            CliWorkspaceConfig workspaceConfig = new CliWorkspaceConfig();\n            workspaceConfig.setEnabledMcpServers(Arrays.asList(\"broken\", \"missing\"));\n            manager.saveWorkspaceConfig(workspaceConfig);\n\n            ByteArrayOutputStream out = new ByteArrayOutputStream();\n            ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n            CodeCommand command = new CodeCommand(\n                    new ConfiguredRuntimeCodingCliAgentFactory(),\n                    Collections.<String, String>emptyMap(),\n                    new Properties(),\n                    workspace\n            );\n\n            int exitCode = command.run(\n                    Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"say hello\"),\n                    new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n            );\n\n            String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n            Assert.assertEquals(0, exitCode);\n            Assert.assertTrue(output.contains(\"Warning: MCP unavailable: broken (sse transport requires url)\"));\n            Assert.assertTrue(output.contains(\"Warning: MCP unavailable: missing (workspace references undefined MCP server)\"));\n            Assert.assertTrue(output.contains(\"Echo: say hello\"));\n        } finally {\n            restoreUserHome(previousUserHome);\n        }\n    }\n\n    @Test\n    public void test_stream_off_collapses_blank_reasoning_lines_in_main_buffer() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-stream-off-reasoning\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/stream off\\n\"\n                        + \"sparse reasoning\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        String continuationBlankLine = System.lineSeparator() + \"          \" + System.lineSeparator();\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Thinking: First line.\"));\n        Assert.assertTrue(output.contains(\"Second line.\"));\n        Assert.assertFalse(output.contains(continuationBlankLine));\n    }\n\n    @Test\n    public void test_tui_mode_uses_text_commands_in_non_alternate_screen_mode() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-main-buffer-commands\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/commands\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Commands\"));\n        Assert.assertTrue(output.contains(\"(none)\"));\n        Assert.assertFalse(output.contains(\"Exit session\"));\n        Assert.assertFalse(output.contains(\"commands:\"));\n    }\n\n    @Test\n    public void test_append_only_tui_keeps_error_state_when_stream_finishes_with_blank_output() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-stream-error\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                new AppendOnlyPromptTuiFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"stream auth fail\"),\n                new RecordingInteractiveTerminal(out, err)\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Error: Invalid API key\"));\n        Assert.assertFalse(output.contains(\"Done\"));\n    }\n\n    @Test\n    public void test_append_only_tui_slash_enter_applies_selection_without_immediate_dispatch() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-slash-enter\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                new AppendOnlyPromptTuiFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new KeyedRecordingInteractiveTerminal(out, err, Arrays.asList(\n                        TuiKeyStroke.character(\"/\"),\n                        TuiKeyStroke.character(\"s\"),\n                        TuiKeyStroke.of(TuiKeyType.ENTER),\n                        TuiKeyStroke.of(TuiKeyType.ESCAPE),\n                        TuiKeyStroke.character(\"/\"),\n                        TuiKeyStroke.character(\"e\"),\n                        TuiKeyStroke.character(\"x\"),\n                        TuiKeyStroke.character(\"i\"),\n                        TuiKeyStroke.character(\"t\"),\n                        TuiKeyStroke.of(TuiKeyType.ENTER)\n                ))\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name()));\n        Assert.assertEquals(0, exitCode);\n        Assert.assertFalse(output.contains(\"status:\"));\n    }\n\n    @Test\n    public void test_append_only_tui_typing_slash_keeps_a_single_input_render() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-single-slash\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                new AppendOnlyPromptTuiFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new KeyedRecordingInteractiveTerminal(out, err, Collections.singletonList(\n                        TuiKeyStroke.character(\"/\")\n                ))\n        );\n\n        String output = stripAnsi(out.toString(StandardCharsets.UTF_8.name())).replace(\"\\r\", \"\");\n        Assert.assertEquals(0, exitCode);\n        Assert.assertEquals(1, countOccurrences(output, \"> /\"));\n        Assert.assertEquals(1, countOccurrences(output, \"• Commands\"));\n    }\n\n    @Test\n    public void test_replay_command_groups_recent_turns() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-replay\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"first message\\n\"\n                        + \"second message\\n\"\n                        + \"/replay 20\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"replay:\"));\n        Assert.assertTrue(output.contains(\"first message\"));\n        Assert.assertFalse(output.contains(\"you> first message\"));\n        Assert.assertTrue(output.contains(\"Echo: second message\"));\n        Assert.assertFalse(output.contains(\"assistant> Echo: second message\"));\n    }\n\n    @Test\n    public void test_replay_command_hides_reasoning_prefix() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-replay-reasoning\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"reason first\\n\"\n                        + \"/replay 20\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"replay:\"));\n        Assert.assertTrue(output.contains(\"Need to inspect the request first.\"));\n        Assert.assertTrue(output.contains(\"Done reasoning.\"));\n        Assert.assertFalse(output.contains(\"assistant> Done reasoning.\"));\n        Assert.assertFalse(output.contains(\"reasoning> \"));\n    }\n\n    @Test\n    public void test_process_status_and_follow_commands_with_restored_snapshot() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-process-status\");\n        seedStoredSession(workspace, \"process-seeded\", \"proc_demo\", \"[stdout] ready\\n[stdout] waiting\\n\");\n\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"/resume process-seeded\\n\"\n                        + \"/process status proc_demo\\n\"\n                        + \"/process follow proc_demo 200\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"resumed session: process-seeded\"));\n        Assert.assertTrue(output.contains(\"process status:\"));\n        Assert.assertTrue(output.contains(\"id=proc_demo\"));\n        Assert.assertTrue(output.contains(\"mode=metadata-only\"));\n        Assert.assertTrue(output.contains(\"process follow:\"));\n        Assert.assertTrue(output.contains(\"ready\"));\n        Assert.assertTrue(output.contains(\"waiting\"));\n    }\n\n    @Test\n    public void test_tui_mode_uses_replay_command_in_non_alternate_screen_mode() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-tui-replay\");\n        ByteArrayInputStream input = new ByteArrayInputStream(\n                (\"first\\n\"\n                        + \"second\\n\"\n                        + \"/replay 20\\n\"\n                        + \"/exit\\n\").getBytes(StandardCharsets.UTF_8)\n        );\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                new StreamsTerminalIO(input, out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Replay\"));\n        Assert.assertTrue(output.contains(\"first\"));\n        Assert.assertFalse(output.contains(\"history\\n\"));\n        Assert.assertTrue(output.contains(\"Echo: second\"));\n    }\n\n    @Test\n    public void test_default_non_alternate_screen_terminal_uses_main_buffer_mode() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-main-buffer-default\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        RecordingInteractiveTerminal terminal = new RecordingInteractiveTerminal(out, err);\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                terminal\n        );\n\n        String output = out.toString(StandardCharsets.UTF_8.name());\n        Assert.assertEquals(0, exitCode);\n        Assert.assertEquals(1, terminal.getReadLineCalls());\n        Assert.assertTrue(output.contains(\"AI4J  fake-model\"));\n        Assert.assertFalse(output.contains(\"Interactive TUI input is unavailable\"));\n    }\n\n    @Test\n    public void test_main_buffer_mode_defers_next_prompt_until_output_starts() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-main-buffer-async\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        ScriptedInteractiveTerminal terminal = new ScriptedInteractiveTerminal(\n                out,\n                err,\n                Arrays.asList(\"slow hello\", \"/exit\")\n        );\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        ExecutorService executor = Executors.newSingleThreadExecutor();\n        try {\n            Future<Integer> future = executor.submit(new Callable<Integer>() {\n                @Override\n                public Integer call() throws Exception {\n                    return command.run(\n                            Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                            terminal\n                    );\n                }\n            });\n\n            long deadline = System.currentTimeMillis() + 300L;\n            while (System.currentTimeMillis() < deadline && terminal.getReadLineCalls() < 2) {\n                Thread.sleep(20L);\n            }\n\n            Assert.assertEquals(\"expected prompt activation to stay deferred until output actually begins\", 1, terminal.getReadLineCalls());\n            Assert.assertEquals(0, future.get(3L, TimeUnit.SECONDS).intValue());\n        } finally {\n            executor.shutdownNow();\n        }\n    }\n\n    @Test\n    public void test_tui_factory_allows_custom_renderer_and_runtime() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-custom-tui\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        RecordingTuiFactory tuiFactory = new RecordingTuiFactory();\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                tuiFactory,\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString(), \"--prompt\", \"say hello\"),\n                new StreamsTerminalIO(new ByteArrayInputStream(new byte[0]), out, err)\n        );\n\n        String output = new String(out.toByteArray(), StandardCharsets.UTF_8);\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"CUSTOM-TUI\"));\n        Assert.assertTrue(output.contains(\"fake-model\"));\n        Assert.assertTrue(tuiFactory.rendered);\n    }\n\n    @Test\n    public void test_tui_mode_keeps_raw_palette_when_alternate_screen_is_disabled() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-raw-tui\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        RecordingInteractiveTerminal terminal = new RecordingInteractiveTerminal(out, err);\n        RawInteractiveTuiFactory tuiFactory = new RawInteractiveTuiFactory(Arrays.asList(\n                TuiKeyStroke.of(TuiKeyType.CTRL_P),\n                TuiKeyStroke.character(\"e\"),\n                TuiKeyStroke.character(\"x\"),\n                TuiKeyStroke.character(\"i\"),\n                TuiKeyStroke.character(\"t\"),\n                TuiKeyStroke.of(TuiKeyType.ENTER)\n        ));\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                tuiFactory,\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                terminal\n        );\n\n        String output = out.toString(StandardCharsets.UTF_8.name());\n        Assert.assertEquals(0, exitCode);\n        Assert.assertEquals(0, terminal.getReadLineCalls());\n        Assert.assertTrue(output.contains(\"RAW-TUI\"));\n    }\n\n    @Test\n    public void test_raw_tui_escape_interrupts_active_turn() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-raw-tui-interrupt\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        final Deque<TuiKeyStroke> keys = new ArrayDeque<TuiKeyStroke>(Arrays.asList(\n                TuiKeyStroke.character(\"s\"),\n                TuiKeyStroke.character(\"l\"),\n                TuiKeyStroke.character(\"o\"),\n                TuiKeyStroke.character(\"w\"),\n                TuiKeyStroke.character(\" \"),\n                TuiKeyStroke.character(\"h\"),\n                TuiKeyStroke.character(\"e\"),\n                TuiKeyStroke.character(\"l\"),\n                TuiKeyStroke.character(\"l\"),\n                TuiKeyStroke.character(\"o\"),\n                TuiKeyStroke.of(TuiKeyType.ENTER),\n                TuiKeyStroke.of(TuiKeyType.ESCAPE)\n        ));\n        RecordingInteractiveTerminal terminal = new RecordingInteractiveTerminal(out, err) {\n            @Override\n            public boolean isInputClosed() {\n                return keys.isEmpty();\n            }\n        };\n\n        CodingCliTuiFactory tuiFactory = new CodingCliTuiFactory() {\n            @Override\n            public CodingCliTuiSupport create(CodeCommandOptions options,\n                                              TerminalIO terminal,\n                                              TuiConfigManager configManager) {\n                TuiConfig config = new TuiConfig();\n                config.setUseAlternateScreen(false);\n                TuiTheme theme = new TuiTheme();\n                TuiRenderer renderer = new TuiRenderer() {\n                    @Override\n                    public int getMaxEvents() {\n                        return 10;\n                    }\n\n                    @Override\n                    public String getThemeName() {\n                        return \"raw-interrupt\";\n                    }\n\n                    @Override\n                    public void updateTheme(TuiConfig config, TuiTheme theme) {\n                    }\n\n                    @Override\n                    public String render(TuiScreenModel screenModel) {\n                        return \"RAW phase=\"\n                                + (screenModel == null || screenModel.getAssistantViewModel() == null\n                                ? \"\"\n                                : screenModel.getAssistantViewModel().getPhase())\n                                + \" detail=\"\n                                + (screenModel == null || screenModel.getAssistantViewModel() == null\n                                ? \"\"\n                                : String.valueOf(screenModel.getAssistantViewModel().getPhaseDetail()));\n                    }\n                };\n                TuiRuntime runtime = new TuiRuntime() {\n                    @Override\n                    public boolean supportsRawInput() {\n                        return true;\n                    }\n\n                    @Override\n                    public void enter() {\n                    }\n\n                    @Override\n                    public void exit() {\n                    }\n\n                    @Override\n                    public TuiKeyStroke readKeyStroke(long timeoutMs) {\n                        return keys.isEmpty() ? null : keys.removeFirst();\n                    }\n\n                    @Override\n                    public void render(TuiScreenModel screenModel) {\n                        terminal.println(renderer.render(screenModel));\n                    }\n                };\n                return new CodingCliTuiSupport(config, theme, renderer, runtime);\n            }\n        };\n\n        CodeCommand command = new CodeCommand(\n                new FakeCodingCliAgentFactory(),\n                tuiFactory,\n                Collections.<String, String>emptyMap(),\n                new Properties(),\n                workspace\n        );\n\n        int exitCode = command.run(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                terminal\n        );\n\n        String output = out.toString(StandardCharsets.UTF_8.name());\n        Assert.assertEquals(0, exitCode);\n        Assert.assertTrue(output.contains(\"Conversation interrupted by user.\"));\n        Assert.assertFalse(output.contains(\"Slow hello done.\"));\n    }\n\n    @Test\n    public void test_main_buffer_jline_user_interrupt_suppresses_final_output() throws Exception {\n        String previous = System.getProperty(\"ai4j.tui.main-buffer\");\n        System.setProperty(\"ai4j.tui.main-buffer\", \"true\");\n\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-main-buffer-interrupt\");\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        ScriptedLineReaderHandler handler = new ScriptedLineReaderHandler(Arrays.asList(\"slow hello\", \"/exit\"));\n        LineReader lineReader = (LineReader) Proxy.newProxyInstance(\n                LineReader.class.getClassLoader(),\n                new Class<?>[]{LineReader.class},\n                handler\n        );\n        JlineShellContext shellContext = newJlineShellContext(terminal, lineReader);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(shellContext, null);\n\n        Properties properties = new Properties();\n        CodeCommandOptions options = new CodeCommandOptionsParser().parse(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                Collections.<String, String>emptyMap(),\n                properties,\n                workspace\n        );\n        CodingSessionManager sessionManager = new DefaultCodingSessionManager(\n                new InMemoryCodingSessionStore(workspace.resolve(\".ai4j\").resolve(\"sessions\")),\n                new InMemorySessionEventStore()\n        );\n        TuiInteractionState interactionState = new TuiInteractionState();\n        FakeCodingCliAgentFactory agentFactory = new FakeCodingCliAgentFactory();\n        CodingCliAgentFactory.PreparedCodingAgent prepared = agentFactory.prepare(options, terminalIO, interactionState);\n        CodingCliSessionRunner runner = new CodingCliSessionRunner(\n                prepared.getAgent(),\n                prepared.getProtocol(),\n                options,\n                terminalIO,\n                sessionManager,\n                interactionState,\n                new RecordingTuiFactory(),\n                null,\n                agentFactory,\n                Collections.<String, String>emptyMap(),\n                properties\n        );\n\n        ExecutorService executor = Executors.newSingleThreadExecutor();\n        try {\n            Future<Integer> future = executor.submit(new Callable<Integer>() {\n                @Override\n                public Integer call() throws Exception {\n                    return runner.run();\n                }\n            });\n\n            String turnId = null;\n            for (int attempt = 0; attempt < 40; attempt++) {\n                Thread.sleep(50L);\n                turnId = (String) readPrivateField(runner, \"activeMainBufferTurnId\");\n                if (turnId != null) {\n                    break;\n                }\n            }\n            Assert.assertNotNull(turnId);\n            invokePrivateMethod(runner, \"interruptActiveMainBufferTurn\", new Class<?>[]{String.class}, turnId);\n\n            int exitCode = future.get(5, TimeUnit.SECONDS);\n            String rendered = output.toString(StandardCharsets.UTF_8.name());\n            Assert.assertEquals(0, exitCode);\n            Assert.assertTrue(rendered.contains(\"Conversation interrupted by user.\"));\n            Assert.assertFalse(rendered.contains(\"Slow hello done.\"));\n            Assert.assertEquals(2, handler.getReadLineCalls());\n        } finally {\n            executor.shutdownNow();\n            terminalIO.close();\n            shellContext.close();\n            restoreProperty(\"ai4j.tui.main-buffer\", previous);\n        }\n    }\n\n    @Test\n    public void test_main_buffer_jline_user_interrupt_cancels_active_chat_stream() throws Exception {\n        String previous = System.getProperty(\"ai4j.tui.main-buffer\");\n        System.setProperty(\"ai4j.tui.main-buffer\", \"true\");\n\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-main-buffer-chat-cancel\");\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        ScriptedLineReaderHandler handler = new ScriptedLineReaderHandler(Arrays.asList(\"slow chat stream\", \"/exit\"));\n        LineReader lineReader = (LineReader) Proxy.newProxyInstance(\n                LineReader.class.getClassLoader(),\n                new Class<?>[]{LineReader.class},\n                handler\n        );\n        JlineShellContext shellContext = newJlineShellContext(terminal, lineReader);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(shellContext, null);\n\n        Properties properties = new Properties();\n        CodeCommandOptions options = new CodeCommandOptionsParser().parse(\n                Arrays.asList(\"--ui\", \"tui\", \"--model\", \"fake-model\", \"--workspace\", workspace.toString()),\n                Collections.<String, String>emptyMap(),\n                properties,\n                workspace\n        );\n        CodingSessionManager sessionManager = new DefaultCodingSessionManager(\n                new InMemoryCodingSessionStore(workspace.resolve(\".ai4j\").resolve(\"sessions\")),\n                new InMemorySessionEventStore()\n        );\n        TuiInteractionState interactionState = new TuiInteractionState();\n        CountDownLatch cancelled = new CountDownLatch(1);\n        CodingCliAgentFactory agentFactory = new CustomModelCodingCliAgentFactory(\n                new ChatModelClient(new BlockingChatService(cancelled))\n        );\n        CodingCliAgentFactory.PreparedCodingAgent prepared = agentFactory.prepare(options, terminalIO, interactionState);\n        CodingCliSessionRunner runner = new CodingCliSessionRunner(\n                prepared.getAgent(),\n                prepared.getProtocol(),\n                options,\n                terminalIO,\n                sessionManager,\n                interactionState,\n                new RecordingTuiFactory(),\n                null,\n                agentFactory,\n                Collections.<String, String>emptyMap(),\n                properties\n        );\n\n        ExecutorService executor = Executors.newSingleThreadExecutor();\n        try {\n            Future<Integer> future = executor.submit(new Callable<Integer>() {\n                @Override\n                public Integer call() throws Exception {\n                    return runner.run();\n                }\n            });\n\n            String turnId = null;\n            for (int attempt = 0; attempt < 40; attempt++) {\n                Thread.sleep(50L);\n                turnId = (String) readPrivateField(runner, \"activeMainBufferTurnId\");\n                if (turnId != null) {\n                    break;\n                }\n            }\n            Assert.assertNotNull(turnId);\n            invokePrivateMethod(runner, \"interruptActiveMainBufferTurn\", new Class<?>[]{String.class}, turnId);\n\n            int exitCode = future.get(5, TimeUnit.SECONDS);\n            String rendered = output.toString(StandardCharsets.UTF_8.name());\n            Assert.assertEquals(0, exitCode);\n            Assert.assertTrue(cancelled.await(1, TimeUnit.SECONDS));\n            Assert.assertTrue(rendered.contains(\"Conversation interrupted by user.\"));\n            Assert.assertFalse(rendered.contains(\"slow chat stream completed\"));\n            Assert.assertEquals(2, handler.getReadLineCalls());\n        } finally {\n            executor.shutdownNow();\n            terminalIO.close();\n            shellContext.close();\n            restoreProperty(\"ai4j.tui.main-buffer\", previous);\n        }\n    }\n\n    private void seedStoredSession(Path workspace,\n                                   String sessionId,\n                                   String processId,\n                                   String previewLogs) throws Exception {\n        Path sessionsDir = Files.createDirectories(workspace.resolve(\".ai4j\").resolve(\"sessions\"));\n        StoredCodingSession storedSession = StoredCodingSession.builder()\n                .sessionId(sessionId)\n                .rootSessionId(sessionId)\n                .provider(\"zhipu\")\n                .protocol(\"chat\")\n                .model(\"fake-model\")\n                .workspace(workspace.toString())\n                .summary(\"seeded session\")\n                .memoryItemCount(1)\n                .processCount(1)\n                .activeProcessCount(0)\n                .restoredProcessCount(1)\n                .createdAtEpochMs(System.currentTimeMillis())\n                .updatedAtEpochMs(System.currentTimeMillis())\n                .state(CodingSessionState.builder()\n                        .sessionId(sessionId)\n                        .workspaceRoot(workspace.toString())\n                        .processCount(1)\n                        .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder()\n                                .processId(processId)\n                                .command(\"npm run dev\")\n                                .workingDirectory(workspace.toString())\n                                .status(BashProcessStatus.STOPPED)\n                                .startedAt(System.currentTimeMillis())\n                                .endedAt(System.currentTimeMillis())\n                                .lastLogOffset(previewLogs.length())\n                                .lastLogPreview(previewLogs)\n                                .restored(false)\n                                .controlAvailable(true)\n                                .build()))\n                        .build())\n                .build();\n        Files.write(\n                sessionsDir.resolve(sessionId + \".json\"),\n                JSON.toJSONString(storedSession).getBytes(StandardCharsets.UTF_8)\n        );\n    }\n\n    private static final class FakeCodingCliAgentFactory implements CodingCliAgentFactory {\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options) {\n            return prepare(options, null);\n        }\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) {\n            return prepare(options, terminal, null);\n        }\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options,\n                                           TerminalIO terminal,\n                                           TuiInteractionState interactionState) {\n            return new PreparedCodingAgent(\n                    CodingAgents.builder()\n                            .modelClient(new FakeModelClient())\n                            .model(options.getModel())\n                            .workspaceContext(WorkspaceContext.builder().rootPath(options.getWorkspace()).build())\n                            .agentOptions(AgentOptions.builder().stream(options.isStream()).build())\n                            .codingOptions(CodingAgentOptions.builder()\n                                    .toolExecutorDecorator(new CliToolApprovalDecorator(options.getApprovalMode(), terminal, interactionState))\n                                    .build())\n                            .build(),\n                    options.getProtocol() == null ? CliProtocol.CHAT : options.getProtocol()\n            );\n        }\n    }\n\n    private static final class CustomModelCodingCliAgentFactory implements CodingCliAgentFactory {\n\n        private final AgentModelClient modelClient;\n\n        private CustomModelCodingCliAgentFactory(AgentModelClient modelClient) {\n            this.modelClient = modelClient;\n        }\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options) {\n            return prepare(options, null);\n        }\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) {\n            return prepare(options, terminal, null);\n        }\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options,\n                                           TerminalIO terminal,\n                                           TuiInteractionState interactionState) {\n            return new PreparedCodingAgent(\n                    CodingAgents.builder()\n                            .modelClient(modelClient)\n                            .model(options.getModel())\n                            .workspaceContext(WorkspaceContext.builder().rootPath(options.getWorkspace()).build())\n                            .agentOptions(AgentOptions.builder().stream(options.isStream()).build())\n                            .codingOptions(CodingAgentOptions.builder()\n                                    .toolExecutorDecorator(new CliToolApprovalDecorator(options.getApprovalMode(), terminal, interactionState))\n                                    .build())\n                            .build(),\n                    options.getProtocol() == null ? CliProtocol.CHAT : options.getProtocol()\n            );\n        }\n    }\n\n    private static final class ConfiguredRuntimeCodingCliAgentFactory implements CodingCliAgentFactory {\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options) throws Exception {\n            return prepare(options, null);\n        }\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options, TerminalIO terminal) throws Exception {\n            return prepare(options, terminal, null);\n        }\n\n        @Override\n        public PreparedCodingAgent prepare(CodeCommandOptions options,\n                                           TerminalIO terminal,\n                                           TuiInteractionState interactionState) throws Exception {\n            CliMcpRuntimeManager runtimeManager = CliMcpRuntimeManager.initialize(\n                    java.nio.file.Paths.get(options.getWorkspace()),\n                    Collections.<String>emptySet()\n            );\n            return new PreparedCodingAgent(\n                    CodingAgents.builder()\n                            .modelClient(new FakeModelClient())\n                            .model(options.getModel())\n                            .workspaceContext(WorkspaceContext.builder().rootPath(options.getWorkspace()).build())\n                            .agentOptions(AgentOptions.builder().stream(options.isStream()).build())\n                            .codingOptions(CodingAgentOptions.builder()\n                                    .toolExecutorDecorator(new CliToolApprovalDecorator(options.getApprovalMode(), terminal, interactionState))\n                                    .build())\n                            .build(),\n                    options.getProtocol() == null ? CliProtocol.CHAT : options.getProtocol(),\n                    runtimeManager\n            );\n        }\n    }\n\n    private static final class BlockingChatService implements IChatService {\n\n        private final CountDownLatch cancelled;\n\n        private BlockingChatService(CountDownLatch cancelled) {\n            this.cancelled = cancelled;\n        }\n\n        @Override\n        public ChatCompletionResponse chatCompletion(String baseUrl, String apiKey, ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public ChatCompletionResponse chatCompletion(ChatCompletion chatCompletion) {\n            throw new UnsupportedOperationException(\"not used\");\n        }\n\n        @Override\n        public void chatCompletionStream(String baseUrl,\n                                         String apiKey,\n                                         ChatCompletion chatCompletion,\n                                         SseListener eventSourceListener) throws Exception {\n            eventSourceListener.onOpen(new EventSource() {\n                @Override\n                public Request request() {\n                    return new Request.Builder().url(\"http://localhost/test\").build();\n                }\n\n                @Override\n                public void cancel() {\n                    cancelled.countDown();\n                }\n            }, new okhttp3.Response.Builder()\n                    .request(new Request.Builder().url(\"http://localhost/test\").build())\n                    .protocol(Protocol.HTTP_1_1)\n                    .code(200)\n                    .message(\"OK\")\n                    .build());\n\n            if (!eventSourceListener.getCountDownLatch().await(5, TimeUnit.SECONDS)) {\n                throw new AssertionError(\"stream was not released\");\n            }\n        }\n\n        @Override\n        public void chatCompletionStream(ChatCompletion chatCompletion, SseListener eventSourceListener) throws Exception {\n            chatCompletionStream(null, null, chatCompletion, eventSourceListener);\n        }\n    }\n\n    private static final class RecordingTuiFactory implements CodingCliTuiFactory {\n\n        private boolean rendered;\n\n        @Override\n        public CodingCliTuiSupport create(CodeCommandOptions options,\n                                          TerminalIO terminal,\n                                          TuiConfigManager configManager) {\n            TuiConfig config = new TuiConfig();\n            config.setUseAlternateScreen(false);\n            TuiTheme theme = new TuiTheme();\n            TuiRenderer renderer = new TuiRenderer() {\n                @Override\n                public int getMaxEvents() {\n                    return 10;\n                }\n\n                @Override\n                public String getThemeName() {\n                    return \"recording\";\n                }\n\n                @Override\n                public void updateTheme(TuiConfig config, TuiTheme theme) {\n                }\n\n                @Override\n                public String render(TuiScreenModel screenModel) {\n                    rendered = true;\n                    return \"CUSTOM-TUI model=\"\n                            + (screenModel == null || screenModel.getRenderContext() == null\n                            ? \"\"\n                            : screenModel.getRenderContext().getModel());\n                }\n            };\n            TuiRuntime runtime = new TuiRuntime() {\n                @Override\n                public boolean supportsRawInput() {\n                    return false;\n                }\n\n                @Override\n                public void enter() {\n                }\n\n                @Override\n                public void exit() {\n                }\n\n                @Override\n                public io.github.lnyocly.ai4j.tui.TuiKeyStroke readKeyStroke(long timeoutMs) {\n                    return null;\n                }\n\n                @Override\n                public void render(TuiScreenModel screenModel) {\n                    terminal.println(renderer.render(screenModel));\n                }\n            };\n            return new CodingCliTuiSupport(config, theme, renderer, runtime);\n        }\n    }\n\n    private static final class AppendOnlyPromptTuiFactory implements CodingCliTuiFactory {\n\n        @Override\n        public CodingCliTuiSupport create(CodeCommandOptions options,\n                                          TerminalIO terminal,\n                                          TuiConfigManager configManager) {\n            TuiConfig config = new TuiConfig();\n            config.setUseAlternateScreen(false);\n            TuiTheme theme = new TuiTheme();\n            TuiRenderer renderer = new TuiSessionView(config, theme, terminal != null && terminal.supportsAnsi());\n            TuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n            return new CodingCliTuiSupport(config, theme, renderer, runtime);\n        }\n    }\n\n    private static final class RawInteractiveTuiFactory implements CodingCliTuiFactory {\n\n        private final Deque<TuiKeyStroke> keys;\n\n        private RawInteractiveTuiFactory(List<TuiKeyStroke> keys) {\n            this.keys = new ArrayDeque<TuiKeyStroke>(keys == null ? Collections.<TuiKeyStroke>emptyList() : keys);\n        }\n\n        @Override\n        public CodingCliTuiSupport create(CodeCommandOptions options,\n                                          TerminalIO terminal,\n                                          TuiConfigManager configManager) {\n            TuiConfig config = new TuiConfig();\n            config.setUseAlternateScreen(false);\n            TuiTheme theme = new TuiTheme();\n            TuiRenderer renderer = new TuiRenderer() {\n                @Override\n                public int getMaxEvents() {\n                    return 10;\n                }\n\n                @Override\n                public String getThemeName() {\n                    return \"raw-interactive\";\n                }\n\n                @Override\n                public void updateTheme(TuiConfig config, TuiTheme theme) {\n                }\n\n                @Override\n                public String render(TuiScreenModel screenModel) {\n                    return \"RAW-TUI\";\n                }\n            };\n            TuiRuntime runtime = new TuiRuntime() {\n                @Override\n                public boolean supportsRawInput() {\n                    return true;\n                }\n\n                @Override\n                public void enter() {\n                }\n\n                @Override\n                public void exit() {\n                }\n\n                @Override\n                public TuiKeyStroke readKeyStroke(long timeoutMs) {\n                    return keys.isEmpty() ? null : keys.removeFirst();\n                }\n\n                @Override\n                public void render(TuiScreenModel screenModel) {\n                    terminal.println(renderer.render(screenModel));\n                }\n            };\n            return new CodingCliTuiSupport(config, theme, renderer, runtime);\n        }\n    }\n\n    private static class RecordingInteractiveTerminal implements TerminalIO {\n\n        private final ByteArrayOutputStream out;\n        private final ByteArrayOutputStream err;\n        private int readLineCalls;\n\n        private RecordingInteractiveTerminal(ByteArrayOutputStream out, ByteArrayOutputStream err) {\n            this.out = out;\n            this.err = err;\n        }\n\n        @Override\n        public String readLine(String prompt) throws IOException {\n            readLineCalls++;\n            return \"/exit\";\n        }\n\n        @Override\n        public void print(String message) {\n            write(out, message);\n        }\n\n        @Override\n        public void println(String message) {\n            write(out, (message == null ? \"\" : message) + System.lineSeparator());\n        }\n\n        @Override\n        public void errorln(String message) {\n            write(err, (message == null ? \"\" : message) + System.lineSeparator());\n        }\n\n        @Override\n        public boolean supportsAnsi() {\n            return true;\n        }\n\n        @Override\n        public boolean supportsRawInput() {\n            return true;\n        }\n\n        protected int getReadLineCalls() {\n            return readLineCalls;\n        }\n\n        private void write(ByteArrayOutputStream stream, String text) {\n            byte[] bytes = (text == null ? \"\" : text).getBytes(StandardCharsets.UTF_8);\n            stream.write(bytes, 0, bytes.length);\n        }\n    }\n\n    private static final class KeyedRecordingInteractiveTerminal extends RecordingInteractiveTerminal {\n\n        private final Deque<TuiKeyStroke> keys;\n\n        private KeyedRecordingInteractiveTerminal(ByteArrayOutputStream out,\n                                                  ByteArrayOutputStream err,\n                                                  List<TuiKeyStroke> keys) {\n            super(out, err);\n            this.keys = new ArrayDeque<TuiKeyStroke>(keys == null ? Collections.<TuiKeyStroke>emptyList() : keys);\n        }\n\n        @Override\n        public TuiKeyStroke readKeyStroke(long timeoutMs) {\n            return keys.isEmpty() ? null : keys.removeFirst();\n        }\n\n        @Override\n        public boolean isInputClosed() {\n            return keys.isEmpty();\n        }\n    }\n\n    private static final class ScriptedInteractiveTerminal extends RecordingInteractiveTerminal {\n\n        private final Deque<String> lines;\n\n        private ScriptedInteractiveTerminal(ByteArrayOutputStream out,\n                                            ByteArrayOutputStream err,\n                                            List<String> lines) {\n            super(out, err);\n            this.lines = new ArrayDeque<String>(lines == null ? Collections.<String>emptyList() : lines);\n        }\n\n        @Override\n        public synchronized String readLine(String prompt) throws IOException {\n            super.readLine(prompt);\n            return lines.isEmpty() ? null : lines.removeFirst();\n        }\n    }\n\n    private JlineShellContext newJlineShellContext(Terminal terminal, LineReader lineReader) throws Exception {\n        Constructor<JlineShellContext> constructor = JlineShellContext.class\n                .getDeclaredConstructor(Terminal.class, LineReader.class, org.jline.utils.Status.class);\n        constructor.setAccessible(true);\n        return constructor.newInstance(terminal, lineReader, null);\n    }\n\n    private void restoreProperty(String key, String value) {\n        if (value == null) {\n            System.clearProperty(key);\n        } else {\n            System.setProperty(key, value);\n        }\n    }\n\n    private Object readPrivateField(Object target, String fieldName) throws Exception {\n        java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);\n        field.setAccessible(true);\n        return field.get(target);\n    }\n\n    private Object invokePrivateMethod(Object target, String methodName, Class<?>[] parameterTypes, Object... args) throws Exception {\n        Method method = target.getClass().getDeclaredMethod(methodName, parameterTypes);\n        method.setAccessible(true);\n        return method.invoke(target, args);\n    }\n\n    private static final class ScriptedLineReaderHandler implements InvocationHandler {\n\n        private final Deque<String> scriptedLines;\n        private int readLineCalls;\n\n        private ScriptedLineReaderHandler(List<String> scriptedLines) {\n            this.scriptedLines = new ArrayDeque<String>(scriptedLines == null\n                    ? Collections.<String>emptyList()\n                    : scriptedLines);\n        }\n\n        @Override\n        public Object invoke(Object proxy, Method method, Object[] args) {\n            String name = method.getName();\n            if (\"readLine\".equals(name)) {\n                readLineCalls++;\n                return scriptedLines.isEmpty() ? null : scriptedLines.removeFirst();\n            }\n            if (\"isReading\".equals(name)) {\n                return Boolean.FALSE;\n            }\n            if (\"callWidget\".equals(name)) {\n                return Boolean.TRUE;\n            }\n            if (\"hashCode\".equals(name)) {\n                return System.identityHashCode(proxy);\n            }\n            if (\"equals\".equals(name)) {\n                return proxy == (args == null || args.length == 0 ? null : args[0]);\n            }\n            if (\"toString\".equals(name)) {\n                return \"ScriptedLineReader\";\n            }\n            return defaultValue(method.getReturnType());\n        }\n\n        private int getReadLineCalls() {\n            return readLineCalls;\n        }\n\n        private Object defaultValue(Class<?> returnType) {\n            if (returnType == null || Void.TYPE.equals(returnType)) {\n                return null;\n            }\n            if (Boolean.TYPE.equals(returnType)) {\n                return Boolean.FALSE;\n            }\n            if (Character.TYPE.equals(returnType)) {\n                return Character.valueOf('\\0');\n            }\n            if (Byte.TYPE.equals(returnType)) {\n                return Byte.valueOf((byte) 0);\n            }\n            if (Short.TYPE.equals(returnType)) {\n                return Short.valueOf((short) 0);\n            }\n            if (Integer.TYPE.equals(returnType)) {\n                return Integer.valueOf(0);\n            }\n            if (Long.TYPE.equals(returnType)) {\n                return Long.valueOf(0L);\n            }\n            if (Float.TYPE.equals(returnType)) {\n                return Float.valueOf(0F);\n            }\n            if (Double.TYPE.equals(returnType)) {\n                return Double.valueOf(0D);\n            }\n            return null;\n        }\n    }\n\n    private static final class FakeModelClient implements AgentModelClient {\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            if (prompt != null && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                return AgentModelResult.builder()\n                        .outputText(\"## Goal\\nContinue the CLI session.\\n\"\n                                + \"## Constraints & Preferences\\n- Preserve workspace details.\\n\"\n                                + \"## Progress\\n### Done\\n- [x] Session was compacted.\\n\"\n                                + \"### In Progress\\n- [ ] Continue from the latest context.\\n\"\n                                + \"### Blocked\\n- (none)\\n\"\n                                + \"## Key Decisions\\n- **Compaction**: Older messages were summarized.\\n\"\n                                + \"## Next Steps\\n1. Continue the requested coding work.\\n\"\n                                + \"## Critical Context\\n- Keep using the saved session state.\")\n                        .build();\n            }\n            String toolOutput = findLastToolOutput(prompt);\n            if (toolOutput != null) {\n                if (toolOutput.startsWith(\"TOOL_ERROR:\")) {\n                    JSONObject error = parseObject(toolOutput.substring(\"TOOL_ERROR:\".length()).trim());\n                    String message = error == null ? toolOutput : firstNonBlank(error.getString(\"error\"), toolOutput);\n                    return AgentModelResult.builder().outputText(\"Tool error: \" + message).build();\n                }\n                JSONObject json = parseObject(toolOutput);\n                String content = json == null ? \"\" : firstNonBlank(json.getString(\"content\"), json.getString(\"stdout\"));\n                return AgentModelResult.builder().outputText(\"Read result: \" + content).build();\n            }\n\n            String userInput = findLastUserText(prompt);\n            if (userInput != null && userInput.toLowerCase().contains(\"what do you remember\")) {\n                return AgentModelResult.builder().outputText(\"history: \" + findAllUserText(prompt)).build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"reason first\")) {\n                return AgentModelResult.builder()\n                        .reasoningText(\"Need to inspect the request first.\")\n                        .outputText(\"Done reasoning.\")\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"sparse reasoning\")) {\n                return AgentModelResult.builder()\n                        .reasoningText(\"First line.\\n\\n\\n\\nSecond line.\")\n                        .outputText(\"Done sparse reasoning.\")\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"slow hello\")) {\n                return AgentModelResult.builder().outputText(\"Slow hello done.\").build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"chunked hello\")) {\n                return AgentModelResult.builder().outputText(\"Hello world from stream.\").build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"show code block\")) {\n                return AgentModelResult.builder()\n                        .outputText(\"Here is a code sample:\\n```java\\nSystem.out.println(\\\"hi\\\");\\n```\\nDone.\")\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"show split fence\")) {\n                return AgentModelResult.builder()\n                        .outputText(\"Here is python:\\n```python\\nprint(\\\"Hello, World!\\\")\\n```\\nDone.\")\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"rewrite final markdown\")) {\n                return AgentModelResult.builder()\n                        .outputText(\"已经为你创建了 `hello.py` 文件并成功运行！\\n\\n```python\\nprint(\\\"Hello, World!\\\")\\n```\\n\\n运行结果：\\nHello, World!\")\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"final adds code block\")) {\n                return AgentModelResult.builder()\n                        .outputText(\"已经为你创建了一个Python的Hello World程序！文件名为 hello_world.py。\\n\\n你可以通过以下命令运行它：\\n\\n```bash\\npython hello_world.py\\n```\\n\\n这个程序会输出：`Hello, World!`\")\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"record request mode\")) {\n                return AgentModelResult.builder().outputText(\"mode=create\").build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"run bash\")) {\n                return AgentModelResult.builder()\n                        .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                                .callId(\"call-bash\")\n                                .name(\"bash\")\n                                .arguments(\"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"type sample.txt\\\"}\")\n                                .build()))\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"run invalid patch\")) {\n                return AgentModelResult.builder()\n                        .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                                .callId(\"call-invalid-patch\")\n                                .name(\"apply_patch\")\n                                .arguments(\"{\\\"patch\\\":\\\"*** Begin Patch\\\\n*** Unknown: calculator.py\\\\n*** End Patch\\\"}\")\n                                .build()))\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"run recoverable patch\")) {\n                return AgentModelResult.builder()\n                        .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                                .callId(\"call-recoverable-patch\")\n                                .name(\"apply_patch\")\n                                .arguments(\"{\\\"patch\\\":\\\"*** Begin Patch\\\\n*** Update File:/sample.txt\\\\n@@\\\\n-value=1\\\\n+value=2\\\\n*** End Patch\\\"}\")\n                                .build()))\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"run unified diff patch\")) {\n                return AgentModelResult.builder()\n                        .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                                .callId(\"call-unified-diff-patch\")\n                                .name(\"apply_patch\")\n                                .arguments(\"{\\\"patch\\\":\\\"*** Begin Patch\\\\n*** Update File:/sample.txt\\\\n--- a/sample.txt\\\\n+++ b/sample.txt\\\\n@@\\\\n-value=1\\\\n+value=2\\\\n*** End Patch\\\"}\")\n                                .build()))\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"run invalid bash\")) {\n                return AgentModelResult.builder()\n                        .reasoningText(\"Need to inspect the shell call.\")\n                        .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                                .callId(\"call-invalid-bash\")\n                                .name(\"bash\")\n                                .arguments(\"{\\\"action\\\":\\\"exec\\\"}\")\n                                .build()))\n                        .build();\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"sample\")) {\n                return AgentModelResult.builder()\n                        .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                                .callId(\"call-read-file\")\n                                .name(\"read_file\")\n                                .arguments(\"{\\\"path\\\":\\\"sample.txt\\\"}\")\n                                .build()))\n                        .build();\n            }\n            return AgentModelResult.builder().outputText(\"Echo: \" + userInput).build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            String userInput = findLastUserText(prompt);\n            if (userInput != null && userInput.toLowerCase().contains(\"slow hello\")) {\n                AgentModelResult result = AgentModelResult.builder().outputText(\"Slow hello done.\").build();\n                if (listener != null) {\n                    try {\n                        Thread.sleep(450L);\n                    } catch (InterruptedException ex) {\n                        Thread.currentThread().interrupt();\n                        return result;\n                    }\n                    if (Thread.currentThread().isInterrupted()) {\n                        return result;\n                    }\n                    listener.onDeltaText(\"Slow hello done.\");\n                    listener.onComplete(result);\n                }\n                return result;\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"chunked hello\")) {\n                AgentModelResult result = AgentModelResult.builder().outputText(\"Hello world from stream.\").build();\n                if (listener != null) {\n                    listener.onDeltaText(\"Hello \");\n                    listener.onDeltaText(\"world \");\n                    listener.onDeltaText(\"from stream.\");\n                    listener.onComplete(result);\n                }\n                return result;\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"record request mode\")) {\n                AgentModelResult result = AgentModelResult.builder().outputText(\"mode=createStream\").build();\n                if (listener != null) {\n                    listener.onDeltaText(\"mode=createStream\");\n                    listener.onComplete(result);\n                }\n                return result;\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"show code block\")) {\n                String codeBlock = \"Here is a code sample:\\n```java\\nSystem.out.println(\\\"hi\\\");\\n```\\nDone.\";\n                AgentModelResult result = AgentModelResult.builder().outputText(codeBlock).build();\n                if (listener != null) {\n                    listener.onDeltaText(\"Here is a code sample:\\n\");\n                    listener.onDeltaText(\"```\");\n                    listener.onDeltaText(\"java\\nSystem.out.println(\\\"hi\\\");\\n\");\n                    listener.onDeltaText(\"```\\nDone.\");\n                    listener.onComplete(result);\n                }\n                return result;\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"show split fence\")) {\n                String codeBlock = \"Here is python:\\n```python\\nprint(\\\"Hello, World!\\\")\\n```\\nDone.\";\n                AgentModelResult result = AgentModelResult.builder().outputText(codeBlock).build();\n                if (listener != null) {\n                    listener.onDeltaText(\"Here is python:\\n``\");\n                    listener.onDeltaText(\"`python\\nprint(\\\"Hello, World!\\\")\\n\");\n                    listener.onDeltaText(\"```\\nDone.\");\n                    listener.onComplete(result);\n                }\n                return result;\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"rewrite final markdown\")) {\n                String rewritten = \"已经为你创建了 `hello.py` 文件并成功运行！\\n\\n```python\\nprint(\\\"Hello, World!\\\")\\n```\\n\\n运行结果：\\nHello, World!\";\n                AgentModelResult result = AgentModelResult.builder().outputText(rewritten).build();\n                if (listener != null) {\n                    listener.onDeltaText(\"已经为你创建了 hello.py 文件并成功运行！\\n\\n\");\n                    listener.onDeltaText(\"```python\\nprint(\\\"Hello, World!\\\")\\n```\\n\");\n                    listener.onDeltaText(\"\\n运行结果：\\nHello, World!\");\n                    listener.onComplete(result);\n                }\n                return result;\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"final adds code block\")) {\n                String finalText = \"已经为你创建了一个Python的Hello World程序！文件名为 hello_world.py。\\n\\n你可以通过以下命令运行它：\\n\\n```bash\\npython hello_world.py\\n```\\n\\n这个程序会输出：`Hello, World!`\";\n                AgentModelResult result = AgentModelResult.builder().outputText(finalText).build();\n                if (listener != null) {\n                    listener.onDeltaText(\"已经为你创建了一个Python的Hello World程序！文件名为 hello_world.py。\\n\\n\");\n                    listener.onDeltaText(\"你可以通过以下命令运行它：\\n\\n\");\n                    listener.onDeltaText(\"\\n\\n这个程序会输出：Hello, World!\");\n                    listener.onComplete(result);\n                }\n                return result;\n            }\n            if (userInput != null && userInput.toLowerCase().contains(\"stream auth fail\")) {\n                AgentModelResult result = AgentModelResult.builder().outputText(\"\").build();\n                if (listener != null) {\n                    listener.onError(new RuntimeException(\"Invalid API key\"));\n                    listener.onComplete(result);\n                }\n                return result;\n            }\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                if (result.getReasoningText() != null && !result.getReasoningText().isEmpty()) {\n                    listener.onReasoningDelta(result.getReasoningText());\n                }\n                if (result.getOutputText() != null && !result.getOutputText().isEmpty()) {\n                    listener.onDeltaText(result.getOutputText());\n                }\n                if (result.getToolCalls() != null) {\n                    for (AgentToolCall call : result.getToolCalls()) {\n                        listener.onToolCall(call);\n                    }\n                }\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private String findLastUserText(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return \"\";\n            }\n            List<Object> items = prompt.getItems();\n            for (int i = items.size() - 1; i >= 0; i--) {\n                Object item = items.get(i);\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"message\".equals(map.get(\"type\")) || !\"user\".equals(map.get(\"role\"))) {\n                    continue;\n                }\n                Object content = map.get(\"content\");\n                if (!(content instanceof List)) {\n                    continue;\n                }\n                List<?> parts = (List<?>) content;\n                for (Object part : parts) {\n                    if (!(part instanceof Map)) {\n                        continue;\n                    }\n                    Map<?, ?> partMap = (Map<?, ?>) part;\n                    if (\"input_text\".equals(partMap.get(\"type\"))) {\n                        Object text = partMap.get(\"text\");\n                        return text == null ? \"\" : String.valueOf(text);\n                    }\n                }\n            }\n            return \"\";\n        }\n\n        private String findAllUserText(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return \"\";\n            }\n            StringBuilder builder = new StringBuilder();\n            List<Object> items = prompt.getItems();\n            for (Object item : items) {\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"message\".equals(map.get(\"type\")) || !\"user\".equals(map.get(\"role\"))) {\n                    continue;\n                }\n                Object content = map.get(\"content\");\n                if (!(content instanceof List)) {\n                    continue;\n                }\n                List<?> parts = (List<?>) content;\n                for (Object part : parts) {\n                    if (!(part instanceof Map)) {\n                        continue;\n                    }\n                    Map<?, ?> partMap = (Map<?, ?>) part;\n                    if (!\"input_text\".equals(partMap.get(\"type\"))) {\n                        continue;\n                    }\n                    Object text = partMap.get(\"text\");\n                    if (text == null) {\n                        continue;\n                    }\n                    String value = String.valueOf(text);\n                    if (builder.length() > 0) {\n                        builder.append(\" | \");\n                    }\n                    builder.append(value);\n                }\n            }\n            return builder.toString();\n        }\n\n        private String findLastToolOutput(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return null;\n            }\n            List<Object> items = prompt.getItems();\n            for (int i = items.size() - 1; i >= 0; i--) {\n                Object item = items.get(i);\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (\"function_call_output\".equals(map.get(\"type\"))) {\n                    Object output = map.get(\"output\");\n                    return output == null ? null : String.valueOf(output);\n                }\n            }\n            return null;\n        }\n\n        private JSONObject parseObject(String raw) {\n            if (raw == null || raw.trim().isEmpty()) {\n                return null;\n            }\n            try {\n                return JSON.parseObject(raw);\n            } catch (Exception ignored) {\n                return null;\n            }\n        }\n\n        private String firstNonBlank(String... values) {\n            if (values == null) {\n                return \"\";\n            }\n            for (String value : values) {\n                if (value != null && !value.trim().isEmpty()) {\n                    return value;\n                }\n            }\n            return \"\";\n        }\n    }\n\n    private static void seedPersistedTeamState(Path workspace, String teamId) throws Exception {\n        Path teamRoot = workspace.resolve(\".ai4j\").resolve(\"teams\");\n        long now = System.currentTimeMillis();\n\n        AgentTeamTask backendTask = AgentTeamTask.builder()\n                .id(\"backend\")\n                .memberId(\"backend\")\n                .task(\"Define travel destination API\")\n                .build();\n        AgentTeamTaskState backendState = AgentTeamTaskState.builder()\n                .taskId(\"backend\")\n                .task(backendTask)\n                .status(AgentTeamTaskStatus.IN_PROGRESS)\n                .claimedBy(\"backend\")\n                .phase(\"running\")\n                .detail(\"Drafting OpenAPI paths and payloads.\")\n                .percent(Integer.valueOf(40))\n                .heartbeatCount(2)\n                .updatedAtEpochMs(now)\n                .build();\n\n        AgentTeamTask qaTask = AgentTeamTask.builder()\n                .id(\"qa\")\n                .memberId(\"qa\")\n                .task(\"Prepare verification checklist\")\n                .build();\n        AgentTeamTaskState qaState = AgentTeamTaskState.builder()\n                .taskId(\"qa\")\n                .task(qaTask)\n                .status(AgentTeamTaskStatus.PENDING)\n                .phase(\"planned\")\n                .percent(Integer.valueOf(0))\n                .updatedAtEpochMs(now - 1000L)\n                .build();\n\n        AgentTeamMessage message = AgentTeamMessage.builder()\n                .id(\"msg-1\")\n                .fromMemberId(\"architect\")\n                .toMemberId(\"backend\")\n                .type(\"contract.note\")\n                .taskId(\"backend\")\n                .content(\"Define the backend contract first.\")\n                .createdAt(now)\n                .build();\n\n        AgentTeamState state = AgentTeamState.builder()\n                .teamId(teamId)\n                .objective(\"Deliver a travel planner demo.\")\n                .members(Arrays.asList(\n                        AgentTeamMemberSnapshot.builder().id(\"architect\").name(\"Architect\").description(\"System design\").build(),\n                        AgentTeamMemberSnapshot.builder().id(\"backend\").name(\"Backend\").description(\"API implementation\").build(),\n                        AgentTeamMemberSnapshot.builder().id(\"qa\").name(\"QA\").description(\"Verification\").build()\n                ))\n                .taskStates(Arrays.asList(backendState, qaState))\n                .messages(Arrays.asList(message))\n                .lastOutput(\"Architecture and backend work are in progress.\")\n                .lastRounds(3)\n                .lastRunStartedAt(now - 5000L)\n                .lastRunCompletedAt(0L)\n                .updatedAt(now)\n                .runActive(true)\n                .build();\n\n        new FileAgentTeamStateStore(teamRoot.resolve(\"state\")).save(state);\n        new FileAgentTeamMessageBus(teamRoot.resolve(\"mailbox\").resolve(teamId + \".jsonl\"))\n                .restore(Arrays.asList(message));\n    }\n\n    private static int countSpinnerFrames(String output) {\n        int matches = 0;\n        String[] frames = new String[]{\"- thinking...\", \"\\\\ thinking...\", \"| thinking...\", \"/ thinking...\"};\n        for (String frame : frames) {\n            if (output.contains(frame)) {\n                matches++;\n            }\n        }\n        return matches;\n    }\n\n    private static String stripAnsi(String value) {\n        return value == null ? \"\" : value.replaceAll(\"\\\\u001B\\\\[[0-?]*[ -/]*[@-~]\", \"\");\n    }\n\n    private static int countOccurrences(String text, String needle) {\n        if (text == null || needle == null || needle.isEmpty()) {\n            return 0;\n        }\n        int count = 0;\n        int index = 0;\n        while (true) {\n            index = text.indexOf(needle, index);\n            if (index < 0) {\n                return count;\n            }\n            count++;\n            index += needle.length();\n        }\n    }\n\n    private static void restoreUserHome(String value) {\n        if (value == null) {\n            System.clearProperty(\"user.home\");\n        } else {\n            System.setProperty(\"user.home\", value);\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodexStyleBlockFormatterTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\nimport io.github.lnyocly.ai4j.cli.render.CodexStyleBlockFormatter;\nimport io.github.lnyocly.ai4j.tui.TuiAssistantToolView;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertTrue;\n\npublic class CodexStyleBlockFormatterTest {\n\n    private final CodexStyleBlockFormatter formatter = new CodexStyleBlockFormatter(80, 4);\n\n    @Test\n    public void formatAssistantTrimsBlankEdgesButKeepsInnerSpacing() {\n        List<String> lines = formatter.formatAssistant(\"\\n\\nhello\\n\\nworld\\n\\n\");\n\n        assertEquals(Arrays.asList(\"hello\", \"\", \"world\"), lines);\n    }\n\n    @Test\n    public void formatAssistantRendersFencedCodeBlocks() {\n        List<String> lines = formatter.formatAssistant(\"Before\\n```java\\nSystem.out.println(\\\"hi\\\");\\n```\\nAfter\");\n\n        assertEquals(Arrays.asList(\n                \"Before\",\n                \"    System.out.println(\\\"hi\\\");\",\n                \"After\"\n        ), lines);\n    }\n\n    @Test\n    public void formatRunningStatusRemovesBlockBulletPrefix() {\n        TuiAssistantToolView toolView = TuiAssistantToolView.builder()\n                .toolName(\"bash\")\n                .status(\"pending\")\n                .title(\"$ echo hello\")\n                .build();\n\n        assertEquals(\"Running echo hello\", formatter.formatRunningStatus(toolView));\n    }\n\n    @Test\n    public void formatToolBuildsCodexLikeTranscriptBlock() {\n        TuiAssistantToolView toolView = TuiAssistantToolView.builder()\n                .toolName(\"bash\")\n                .status(\"done\")\n                .title(\"$ echo hello\")\n                .previewLines(Arrays.asList(\"stdout> hello\", \"stdout> world\"))\n                .build();\n\n        List<String> lines = formatter.formatTool(toolView);\n\n        assertEquals(\"• Ran echo hello\", lines.get(0));\n        assertEquals(\"  └ hello\", lines.get(1));\n        assertEquals(\"    world\", lines.get(2));\n    }\n\n    @Test\n    public void formatToolKeepsQualifiedMcpToolLabel() {\n        TuiAssistantToolView toolView = TuiAssistantToolView.builder()\n                .toolName(\"fetch\")\n                .status(\"done\")\n                .title(\"fetch.fetch(url=\\\"https://zjuers.com/\\\")\")\n                .previewLines(Arrays.asList(\"out> Contents of https://zjuers.com/\"))\n                .build();\n\n        List<String> lines = formatter.formatTool(toolView);\n\n        assertEquals(\"• Ran fetch.fetch(url=\\\"https://zjuers.com/\\\")\", lines.get(0));\n        assertEquals(\"  └ Contents of https://zjuers.com/\", lines.get(1));\n    }\n\n    @Test\n    public void formatCompactBuildsCompactBlock() {\n        CodingSessionCompactResult result = CodingSessionCompactResult.builder()\n                .automatic(true)\n                .beforeItemCount(12)\n                .afterItemCount(7)\n                .estimatedTokensBefore(3200)\n                .estimatedTokensAfter(1900)\n                .summary(\"Dropped stale history and kept the latest checkpoint.\")\n                .build();\n\n        List<String> lines = formatter.formatCompact(result);\n\n        assertEquals(\"• Auto-compacted session context\", lines.get(0));\n        assertTrue(lines.get(1).contains(\"tokens 3200->1900\"));\n        assertTrue(lines.get(1).contains(\"items 12->7\"));\n        assertTrue(lines.get(2).contains(\"Dropped stale history\"));\n    }\n\n    @Test\n    public void formatOutputParsesStructuredSingleLineStatus() {\n        List<String> lines = formatter.formatOutput(\"process stopped: proc-7 status=stopped\");\n\n        assertEquals(Arrays.asList(\n                \"• Process stopped\",\n                \"  └ proc-7 status=stopped\"\n        ), lines);\n    }\n\n    @Test\n    public void formatInfoBlockKeepsInnerSeparators() {\n        List<String> lines = formatter.formatInfoBlock(\"Replay\", Arrays.asList(\"first\", \"\", \"second\"));\n\n        assertEquals(Arrays.asList(\n                \"• Replay\",\n                \"  └ first\",\n                \"\",\n                \"    second\"\n        ), lines);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/DefaultCodingCliAgentFactoryTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.factory;\n\nimport io.github.lnyocly.ai4j.cli.config.CliWorkspaceConfig;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.CliUiMode;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.provider.CliProviderConfigManager;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class DefaultCodingCliAgentFactoryTest {\n\n    private final DefaultCodingCliAgentFactory factory = new DefaultCodingCliAgentFactory();\n\n    @Test\n    public void test_default_protocol_prefers_chat_for_openai_compatible_base_url() {\n        CodeCommandOptions options = new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.OPENAI,\n                null,\n                \"deepseek-chat\",\n                null,\n                \"https://api.deepseek.com\",\n                \".\",\n                null,\n                null,\n                null,\n                null,\n                12,\n                null,\n                null,\n                null,\n                Boolean.FALSE,\n                false,\n                false\n        );\n\n        Assert.assertEquals(CliProtocol.CHAT, factory.resolveProtocol(options));\n    }\n\n    @Test\n    public void test_default_protocol_prefers_responses_for_official_openai() {\n        CodeCommandOptions options = new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.OPENAI,\n                null,\n                \"gpt-5-mini\",\n                null,\n                \"https://api.openai.com\",\n                \".\",\n                null,\n                null,\n                null,\n                null,\n                12,\n                null,\n                null,\n                null,\n                Boolean.FALSE,\n                false,\n                false\n        );\n\n        Assert.assertEquals(CliProtocol.RESPONSES, factory.resolveProtocol(options));\n    }\n\n    @Test\n    public void test_normalize_zhipu_coding_plan_base_url() {\n        Assert.assertEquals(\n                \"https://open.bigmodel.cn/api/coding/paas/\",\n                factory.normalizeZhipuBaseUrl(\"https://open.bigmodel.cn/api/coding/paas/v4\")\n        );\n        Assert.assertEquals(\n                \"https://open.bigmodel.cn/api/coding/paas/\",\n                factory.normalizeZhipuBaseUrl(\"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions\")\n        );\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void test_explicit_responses_rejected_for_zhipu() {\n        CodeCommandOptions options = new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.ZHIPU,\n                CliProtocol.RESPONSES,\n                \"GLM-4.5-Flash\",\n                null,\n                null,\n                \".\",\n                null,\n                null,\n                null,\n                null,\n                12,\n                null,\n                null,\n                null,\n                Boolean.FALSE,\n                false,\n                false\n        );\n\n        factory.resolveProtocol(options);\n    }\n\n    @Test\n    public void test_build_workspace_context_includes_workspace_skill_directories() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-workspace-skills\");\n        CliProviderConfigManager manager = new CliProviderConfigManager(workspace);\n        manager.saveWorkspaceConfig(CliWorkspaceConfig.builder()\n                .skillDirectories(Arrays.asList(\" .ai4j/skills \", \"C:/skills/team \", \".ai4j/skills\"))\n                .build());\n\n        CodeCommandOptions options = new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.OPENAI,\n                CliProtocol.RESPONSES,\n                \"gpt-5-mini\",\n                null,\n                null,\n                workspace.toString(),\n                \"workspace description\",\n                null,\n                null,\n                null,\n                12,\n                null,\n                null,\n                null,\n                Boolean.FALSE,\n                false,\n                false\n        );\n\n        WorkspaceContext workspaceContext = factory.buildWorkspaceContext(options);\n\n        Assert.assertEquals(workspace.toAbsolutePath().normalize().toString(), workspaceContext.getRoot().toString());\n        Assert.assertEquals(\"workspace description\", workspaceContext.getDescription());\n        Assert.assertEquals(Arrays.asList(\".ai4j/skills\", \"C:/skills/team\"), workspaceContext.getSkillDirectories());\n    }\n\n    @Test\n    public void test_load_definition_registry_merges_built_ins_with_workspace_agents() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-workspace-agents\");\n        Path agentsDirectory = workspace.resolve(\".ai4j\").resolve(\"agents\");\n        Files.createDirectories(agentsDirectory);\n        Files.write(agentsDirectory.resolve(\"reviewer.md\"), (\n                \"---\\n\" +\n                        \"name: reviewer\\n\" +\n                        \"description: Review diffs.\\n\" +\n                        \"tools: read-only\\n\" +\n                        \"---\\n\" +\n                        \"Review code changes for correctness and missing tests.\\n\"\n        ).getBytes(StandardCharsets.UTF_8));\n\n        CodeCommandOptions options = new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.OPENAI,\n                CliProtocol.RESPONSES,\n                \"gpt-5-mini\",\n                null,\n                null,\n                workspace.toString(),\n                null,\n                null,\n                null,\n                null,\n                12,\n                null,\n                null,\n                null,\n                Boolean.FALSE,\n                false,\n                false\n        );\n\n        CodingAgentDefinitionRegistry registry = factory.loadDefinitionRegistry(options);\n\n        Assert.assertNotNull(registry.getDefinition(\"general-purpose\"));\n        Assert.assertNotNull(registry.getDefinition(\"delegate_general_purpose\"));\n        Assert.assertNotNull(registry.getDefinition(\"reviewer\"));\n        Assert.assertNotNull(registry.getDefinition(\"delegate_reviewer\"));\n    }\n\n    @Test\n    public void test_prepare_includes_experimental_subagent_and_team_tools_by_default() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-experimental-default\");\n        TestFactory testFactory = new TestFactory();\n\n        CodeCommandOptions options = new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.OPENAI,\n                CliProtocol.RESPONSES,\n                \"gpt-5-mini\",\n                null,\n                null,\n                workspace.toString(),\n                null,\n                null,\n                null,\n                null,\n                12,\n                null,\n                null,\n                null,\n                Boolean.FALSE,\n                false,\n                false\n        );\n\n        CodingCliAgentFactory.PreparedCodingAgent prepared = testFactory.prepare(options);\n        List<String> toolNames = toolNames(prepared);\n\n        Assert.assertTrue(toolNames.contains(DefaultCodingCliAgentFactory.EXPERIMENTAL_SUBAGENT_TOOL_NAME));\n        Assert.assertTrue(toolNames.contains(DefaultCodingCliAgentFactory.EXPERIMENTAL_TEAM_TOOL_NAME));\n    }\n\n    @Test\n    public void test_prepare_respects_workspace_experimental_toggles() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-experimental-disabled\");\n        new CliProviderConfigManager(workspace).saveWorkspaceConfig(CliWorkspaceConfig.builder()\n                .experimentalSubagentsEnabled(Boolean.FALSE)\n                .experimentalAgentTeamsEnabled(Boolean.FALSE)\n                .build());\n        TestFactory testFactory = new TestFactory();\n\n        CodeCommandOptions options = new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.OPENAI,\n                CliProtocol.RESPONSES,\n                \"gpt-5-mini\",\n                null,\n                null,\n                workspace.toString(),\n                null,\n                null,\n                null,\n                null,\n                12,\n                null,\n                null,\n                null,\n                Boolean.FALSE,\n                false,\n                false\n        );\n\n        CodingCliAgentFactory.PreparedCodingAgent prepared = testFactory.prepare(options);\n        List<String> toolNames = toolNames(prepared);\n\n        Assert.assertFalse(toolNames.contains(DefaultCodingCliAgentFactory.EXPERIMENTAL_SUBAGENT_TOOL_NAME));\n        Assert.assertFalse(toolNames.contains(DefaultCodingCliAgentFactory.EXPERIMENTAL_TEAM_TOOL_NAME));\n    }\n\n    private List<String> toolNames(CodingCliAgentFactory.PreparedCodingAgent prepared) {\n        if (prepared == null || prepared.getAgent() == null) {\n            return Collections.emptyList();\n        }\n        List<String> names = new ArrayList<String>();\n        List<Object> tools = prepared.getAgent().newSession().getDelegate().getContext().getToolRegistry().getTools();\n        if (tools == null) {\n            return names;\n        }\n        for (Object tool : tools) {\n            if (!(tool instanceof Tool)) {\n                continue;\n            }\n            Tool typedTool = (Tool) tool;\n            if (typedTool.getFunction() != null && typedTool.getFunction().getName() != null) {\n                names.add(typedTool.getFunction().getName());\n            }\n        }\n        return names;\n    }\n\n    private static final class TestFactory extends DefaultCodingCliAgentFactory {\n\n        @Override\n        protected AgentModelClient createModelClient(CodeCommandOptions options, CliProtocol protocol) {\n            return new AgentModelClient() {\n                @Override\n                public AgentModelResult create(AgentPrompt prompt) {\n                    return emptyResult();\n                }\n\n                @Override\n                public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n                    return emptyResult();\n                }\n            };\n        }\n\n        private AgentModelResult emptyResult() {\n            return AgentModelResult.builder()\n                    .outputText(\"\")\n                    .toolCalls(new ArrayList<AgentToolCall>())\n                    .memoryItems(new ArrayList<Object>())\n                    .build();\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/DefaultCodingSessionManagerTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.CliUiMode;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.coding.CodingAgentResult;\nimport io.github.lnyocly.ai4j.coding.CodingAgents;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class DefaultCodingSessionManagerTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldCreateSaveResumeAndListSessionEvents() throws Exception {\n        Path workspace = temporaryFolder.newFolder(\"workspace\").toPath();\n        Path sessionDir = temporaryFolder.newFolder(\"sessions\").toPath();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(new EchoModelClient())\n                .model(\"fake-model\")\n                .workspaceContext(WorkspaceContext.builder().rootPath(workspace.toString()).build())\n                .build();\n        DefaultCodingSessionManager manager = new DefaultCodingSessionManager(\n                new FileCodingSessionStore(sessionDir),\n                new FileSessionEventStore(sessionDir.resolve(\"events\"))\n        );\n\n        CodeCommandOptions options = new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.ZHIPU,\n                CliProtocol.CHAT,\n                \"fake-model\",\n                null,\n                null,\n                workspace.toString(),\n                \"workspace\",\n                null,\n                null,\n                null,\n                8,\n                null,\n                null,\n                null,\n                null,\n                false,\n                \"session-alpha\",\n                null,\n                sessionDir.toString(),\n                null,\n                true,\n                false,\n                128000,\n                16384,\n                20000,\n                400,\n                false\n        );\n\n        ManagedCodingSession managed = manager.create(agent, CliProtocol.CHAT, options);\n        CodingAgentResult firstResult = managed.getSession().run(\"remember alpha\");\n        manager.appendEvent(managed.getSessionId(), SessionEvent.builder()\n                .sessionId(managed.getSessionId())\n                .type(SessionEventType.USER_MESSAGE)\n                .summary(\"remember alpha\")\n                .build());\n        manager.save(managed);\n\n        List<CodingSessionDescriptor> descriptors = manager.list();\n        List<SessionEvent> events = manager.listEvents(managed.getSessionId(), 10, null);\n\n        assertEquals(\"Echo: remember alpha\", firstResult.getOutputText());\n        assertEquals(1, descriptors.size());\n        assertEquals(\"session-alpha\", descriptors.get(0).getSessionId());\n        assertEquals(\"session-alpha\", descriptors.get(0).getRootSessionId());\n        assertEquals(null, descriptors.get(0).getParentSessionId());\n        assertFalse(events.isEmpty());\n        assertEquals(SessionEventType.SESSION_CREATED, events.get(0).getType());\n        assertEquals(SessionEventType.SESSION_SAVED, events.get(events.size() - 1).getType());\n\n        ManagedCodingSession resumed = manager.resume(agent, CliProtocol.CHAT, options, managed.getSessionId());\n        CodingAgentResult resumedResult = resumed.getSession().run(\"what do you remember\");\n        List<SessionEvent> resumedEvents = manager.listEvents(resumed.getSessionId(), 20, null);\n\n        assertTrue(resumedResult.getOutputText().contains(\"remember alpha\"));\n        assertEquals(SessionEventType.SESSION_RESUMED, resumedEvents.get(resumedEvents.size() - 1).getType());\n\n        ManagedCodingSession forked = manager.fork(agent, CliProtocol.CHAT, options, managed.getSessionId(), \"session-beta\");\n        manager.save(forked);\n        CodingAgentResult forkedResult = forked.getSession().run(\"what do you remember\");\n        List<CodingSessionDescriptor> afterFork = manager.list();\n        StoredCodingSession storedFork = manager.load(\"session-beta\");\n\n        assertTrue(forkedResult.getOutputText().contains(\"remember alpha\"));\n        assertNotNull(storedFork);\n        assertEquals(\"session-alpha\", storedFork.getRootSessionId());\n        assertEquals(\"session-alpha\", storedFork.getParentSessionId());\n        assertEquals(\"session-beta\", forked.getSessionId());\n        assertEquals(\"session-alpha\", forked.getRootSessionId());\n        assertEquals(\"session-alpha\", forked.getParentSessionId());\n        assertEquals(2, afterFork.size());\n    }\n\n    private static final class EchoModelClient implements AgentModelClient {\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            String userInput = findLastUserText(prompt);\n            if (userInput != null && userInput.toLowerCase().contains(\"what do you remember\")) {\n                return AgentModelResult.builder().outputText(\"history: \" + findAllUserText(prompt)).build();\n            }\n            return AgentModelResult.builder().outputText(\"Echo: \" + userInput).build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private String findLastUserText(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return \"\";\n            }\n            List<Object> items = prompt.getItems();\n            for (int i = items.size() - 1; i >= 0; i--) {\n                Object item = items.get(i);\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"message\".equals(map.get(\"type\")) || !\"user\".equals(map.get(\"role\"))) {\n                    continue;\n                }\n                Object content = map.get(\"content\");\n                if (!(content instanceof List)) {\n                    continue;\n                }\n                List<?> parts = (List<?>) content;\n                for (Object part : parts) {\n                    if (!(part instanceof Map)) {\n                        continue;\n                    }\n                    Map<?, ?> partMap = (Map<?, ?>) part;\n                    if (\"input_text\".equals(partMap.get(\"type\"))) {\n                        Object text = partMap.get(\"text\");\n                        return text == null ? \"\" : String.valueOf(text);\n                    }\n                }\n            }\n            return \"\";\n        }\n\n        private String findAllUserText(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return \"\";\n            }\n            StringBuilder builder = new StringBuilder();\n            for (Object item : prompt.getItems()) {\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"message\".equals(map.get(\"type\")) || !\"user\".equals(map.get(\"role\"))) {\n                    continue;\n                }\n                Object content = map.get(\"content\");\n                if (!(content instanceof List)) {\n                    continue;\n                }\n                for (Object part : (List<?>) content) {\n                    if (!(part instanceof Map)) {\n                        continue;\n                    }\n                    Map<?, ?> partMap = (Map<?, ?>) part;\n                    if (!\"input_text\".equals(partMap.get(\"type\"))) {\n                        continue;\n                    }\n                    Object text = partMap.get(\"text\");\n                    if (text == null) {\n                        continue;\n                    }\n                    if (builder.length() > 0) {\n                        builder.append(\" | \");\n                    }\n                    builder.append(String.valueOf(text));\n                }\n            }\n            return builder.toString();\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/FileCodingSessionStoreTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport io.github.lnyocly.ai4j.agent.memory.MemorySnapshot;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\nimport io.github.lnyocly.ai4j.coding.CodingSessionState;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessStatus;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class FileCodingSessionStoreTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldSaveLoadAndListSessions() throws Exception {\n        Path sessionDir = temporaryFolder.newFolder(\"session-store\").toPath();\n        FileCodingSessionStore store = new FileCodingSessionStore(sessionDir);\n\n        StoredCodingSession saved = store.save(StoredCodingSession.builder()\n                .sessionId(\"session-alpha\")\n                .rootSessionId(\"session-alpha\")\n                .provider(\"zhipu\")\n                .protocol(\"chat\")\n                .model(\"GLM-4.5-Flash\")\n                .workspace(\"workspace-a\")\n                .summary(\"alpha summary\")\n                .memoryItemCount(2)\n                .processCount(1)\n                .activeProcessCount(0)\n                .restoredProcessCount(1)\n                .state(CodingSessionState.builder()\n                        .sessionId(\"session-alpha\")\n                        .workspaceRoot(\"workspace-a\")\n                        .memorySnapshot(MemorySnapshot.from(\n                                Arrays.<Object>asList(\n                                        AgentInputItem.userMessage(\"hello\"),\n                                        AgentInputItem.message(\"assistant\", \"world\")\n                                ),\n                                null\n                        ))\n                        .processCount(1)\n                        .checkpoint(CodingSessionCheckpoint.builder()\n                                .goal(\"Continue session-alpha\")\n                                .doneItems(Collections.singletonList(\"Saved workspace state\"))\n                                .build())\n                        .latestCompactResult(CodingSessionCompactResult.builder()\n                                .sessionId(\"session-alpha\")\n                                .beforeItemCount(12)\n                                .afterItemCount(3)\n                                .summary(\"checkpoint summary\")\n                                .automatic(false)\n                                .strategy(\"checkpoint-delta\")\n                                .deltaItemCount(5)\n                                .checkpointReused(true)\n                                .checkpoint(CodingSessionCheckpoint.builder()\n                                        .goal(\"Continue session-alpha\")\n                                        .nextSteps(Collections.singletonList(\"Resume the next coding step\"))\n                                        .build())\n                                .build())\n                        .autoCompactFailureCount(2)\n                        .autoCompactCircuitBreakerOpen(true)\n                        .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder()\n                                .processId(\"proc_demo\")\n                                .command(\"echo ready\")\n                                .workingDirectory(\"workspace-a\")\n                                .status(BashProcessStatus.STOPPED)\n                                .startedAt(System.currentTimeMillis())\n                                .endedAt(System.currentTimeMillis())\n                                .lastLogOffset(10L)\n                                .lastLogPreview(\"[stdout] ready\")\n                                .restored(true)\n                                .controlAvailable(false)\n                                .build()))\n                        .build())\n                .build());\n\n        StoredCodingSession loaded = store.load(\"session-alpha\");\n        List<StoredCodingSession> sessions = store.list();\n\n        assertNotNull(saved.getStorePath());\n        assertNotNull(loaded);\n        assertEquals(\"session-alpha\", loaded.getSessionId());\n        assertEquals(\"session-alpha\", loaded.getRootSessionId());\n        assertEquals(\"alpha summary\", loaded.getSummary());\n        assertEquals(1, loaded.getProcessCount());\n        assertEquals(1, loaded.getRestoredProcessCount());\n        assertNotNull(loaded.getState().getCheckpoint());\n        assertNotNull(loaded.getState().getLatestCompactResult());\n        assertEquals(\"checkpoint-delta\", loaded.getState().getLatestCompactResult().getStrategy());\n        assertEquals(2, loaded.getState().getAutoCompactFailureCount());\n        assertTrue(loaded.getState().isAutoCompactCircuitBreakerOpen());\n        assertEquals(1, loaded.getState().getProcessSnapshots().size());\n        assertEquals(1, sessions.size());\n        assertEquals(\"session-alpha\", sessions.get(0).getSessionId());\n        assertTrue(sessions.get(0).getUpdatedAtEpochMs() >= sessions.get(0).getCreatedAtEpochMs());\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/FileSessionEventStoreTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.session;\n\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertTrue;\n\npublic class FileSessionEventStoreTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldAppendListAndDeleteSessionEvents() throws Exception {\n        Path eventDir = temporaryFolder.newFolder(\"session-events\").toPath();\n        FileSessionEventStore store = new FileSessionEventStore(eventDir);\n\n        store.append(SessionEvent.builder()\n                .sessionId(\"session-alpha\")\n                .type(SessionEventType.SESSION_CREATED)\n                .timestamp(100L)\n                .summary(\"created\")\n                .build());\n        store.append(SessionEvent.builder()\n                .sessionId(\"session-alpha\")\n                .type(SessionEventType.USER_MESSAGE)\n                .timestamp(200L)\n                .summary(\"user\")\n                .build());\n        store.append(SessionEvent.builder()\n                .sessionId(\"session-alpha\")\n                .type(SessionEventType.ASSISTANT_MESSAGE)\n                .timestamp(300L)\n                .summary(\"assistant\")\n                .build());\n\n        List<SessionEvent> recent = store.list(\"session-alpha\", 2, null);\n        List<SessionEvent> offset = store.list(\"session-alpha\", 1, 1L);\n\n        assertEquals(2, recent.size());\n        assertEquals(SessionEventType.USER_MESSAGE, recent.get(0).getType());\n        assertEquals(SessionEventType.ASSISTANT_MESSAGE, recent.get(1).getType());\n\n        assertEquals(1, offset.size());\n        assertEquals(SessionEventType.USER_MESSAGE, offset.get(0).getType());\n\n        store.delete(\"session-alpha\");\n        assertTrue(store.list(\"session-alpha\", 10, null).isEmpty());\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/JlineShellTerminalIOTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.command.CustomCommandRegistry;\nimport io.github.lnyocly.ai4j.cli.shell.JlineShellContext;\nimport io.github.lnyocly.ai4j.cli.shell.JlineShellTerminalIO;\nimport org.jline.reader.LineReader;\nimport org.jline.reader.LineReaderBuilder;\nimport org.jline.reader.Buffer;\nimport org.jline.terminal.Terminal;\nimport org.jline.terminal.TerminalBuilder;\nimport org.jline.utils.AttributedString;\nimport org.jline.utils.Status;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.lang.reflect.Constructor;\nimport java.lang.reflect.InvocationHandler;\nimport java.lang.reflect.Method;\nimport java.lang.reflect.Proxy;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\n\npublic class JlineShellTerminalIOTest {\n\n    @Test\n    public void test_clear_assistant_block_resets_tracking_and_writes_erase_sequences() throws Exception {\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        LineReader lineReader = LineReaderBuilder.builder()\n                .terminal(terminal)\n                .appName(\"ai4j-cli-test\")\n                .build();\n        Status status = Status.getStatus(terminal, false);\n        if (status != null) {\n            status.setBorder(false);\n        }\n        JlineShellContext context = newContext(terminal, lineReader, status);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.beginAssistantBlockTracking();\n            terminalIO.printTranscriptLine(\"already rendered\", true);\n            Assert.assertTrue(terminalIO.assistantBlockRows() > 0);\n\n            int beforeClear = output.size();\n            terminalIO.clearAssistantBlock();\n\n            String clearOutput = new String(output.toByteArray(), beforeClear, output.size() - beforeClear, StandardCharsets.UTF_8);\n            Assert.assertEquals(0, terminalIO.assistantBlockRows());\n            Assert.assertTrue(clearOutput.contains(\"\\u001b[2K\"));\n            Assert.assertTrue(clearOutput.contains(\"\\u001b[1A\"));\n        } finally {\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_forget_assistant_block_resets_tracking_without_rewriting_terminal_history() throws Exception {\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        LineReader lineReader = LineReaderBuilder.builder()\n                .terminal(terminal)\n                .appName(\"ai4j-cli-test\")\n                .build();\n        Status status = Status.getStatus(terminal, false);\n        if (status != null) {\n            status.setBorder(false);\n        }\n        JlineShellContext context = newContext(terminal, lineReader, status);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.beginAssistantBlockTracking();\n            terminalIO.printTranscriptLine(\"already rendered\", true);\n            Assert.assertTrue(terminalIO.assistantBlockRows() > 0);\n\n            int beforeForget = output.size();\n            terminalIO.forgetAssistantBlock();\n\n            String forgetOutput = new String(output.toByteArray(), beforeForget, output.size() - beforeForget, StandardCharsets.UTF_8);\n            Assert.assertEquals(0, terminalIO.assistantBlockRows());\n            Assert.assertEquals(\"\", forgetOutput);\n        } finally {\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_blank_newline_uses_print_above_while_reading() throws Exception {\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        RecordingLineReaderHandler handler = new RecordingLineReaderHandler();\n        LineReader lineReader = (LineReader) Proxy.newProxyInstance(\n                LineReader.class.getClassLoader(),\n                new Class<?>[]{LineReader.class},\n                handler\n        );\n        JlineShellContext context = newContext(terminal, lineReader, null);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.println(\"\");\n\n            Assert.assertEquals(1, handler.printAboveCalls().size());\n            Assert.assertEquals(\" \", handler.printAboveCalls().get(0));\n            Assert.assertTrue(handler.widgetCalls().isEmpty());\n            Assert.assertEquals(\"\", new String(output.toByteArray(), StandardCharsets.UTF_8));\n        } finally {\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_multiline_transcript_block_uses_print_above_while_reading() throws Exception {\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        RecordingLineReaderHandler handler = new RecordingLineReaderHandler();\n        LineReader lineReader = (LineReader) Proxy.newProxyInstance(\n                LineReader.class.getClassLoader(),\n                new Class<?>[]{LineReader.class},\n                handler\n        );\n        JlineShellContext context = newContext(terminal, lineReader, null);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.printTranscriptBlock(Arrays.asList(\"alpha\", \"\", \"beta\"));\n\n            Assert.assertEquals(1, handler.printAboveCalls().size());\n            Assert.assertEquals(\"alpha\\n \\nbeta\", handler.printAboveCalls().get(0));\n            Assert.assertTrue(handler.widgetCalls().isEmpty());\n            Assert.assertEquals(\"\", new String(output.toByteArray(), StandardCharsets.UTF_8));\n        } finally {\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_assistant_markdown_block_writes_directly_when_not_reading() throws Exception {\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        RecordingLineReaderHandler handler = new RecordingLineReaderHandler(false);\n        LineReader lineReader = (LineReader) Proxy.newProxyInstance(\n                LineReader.class.getClassLoader(),\n                new Class<?>[]{LineReader.class},\n                handler\n        );\n        JlineShellContext context = newContext(terminal, lineReader, null);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.printAssistantMarkdownBlock(\"Hello!\\n\\n- item\");\n\n            Assert.assertTrue(handler.printAboveCalls().isEmpty());\n            String rendered = new String(output.toByteArray(), StandardCharsets.UTF_8);\n            Assert.assertTrue(rendered.contains(\"Hello!\"));\n            Assert.assertTrue(rendered.contains(\"- item\"));\n        } finally {\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_direct_output_window_bypasses_print_above_even_if_line_reader_reports_reading() throws Exception {\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        RecordingLineReaderHandler handler = new RecordingLineReaderHandler(true);\n        LineReader lineReader = (LineReader) Proxy.newProxyInstance(\n                LineReader.class.getClassLoader(),\n                new Class<?>[]{LineReader.class},\n                handler\n        );\n        JlineShellContext context = newContext(terminal, lineReader, null);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.beginDirectOutputWindow();\n            terminalIO.printAssistantMarkdownBlock(\"Hello!\\n\\n- item\");\n\n            Assert.assertTrue(handler.printAboveCalls().isEmpty());\n            String rendered = new String(output.toByteArray(), StandardCharsets.UTF_8);\n            Assert.assertTrue(rendered.contains(\"Hello!\"));\n            Assert.assertTrue(rendered.contains(\"- item\"));\n        } finally {\n            terminalIO.endDirectOutputWindow();\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_repeated_busy_status_does_not_redundantly_redraw_same_line() throws Exception {\n        String previous = System.getProperty(\"ai4j.jline.status\");\n        System.setProperty(\"ai4j.jline.status\", \"true\");\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        LineReader lineReader = LineReaderBuilder.builder()\n                .terminal(terminal)\n                .appName(\"ai4j-cli-test\")\n                .build();\n        CountingStatus status = new CountingStatus(terminal);\n        status.setBorder(false);\n        JlineShellContext context = newContext(terminal, lineReader, status);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.showResponding();\n            terminalIO.showResponding();\n\n            Assert.assertEquals(1, status.updateCount());\n        } finally {\n            terminalIO.close();\n            context.close();\n            restoreProperty(\"ai4j.jline.status\", previous);\n        }\n    }\n\n    @Test\n    public void test_status_is_disabled_by_default() throws Exception {\n        String previous = System.getProperty(\"ai4j.jline.status\");\n        System.clearProperty(\"ai4j.jline.status\");\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        LineReader lineReader = LineReaderBuilder.builder()\n                .terminal(terminal)\n                .appName(\"ai4j-cli-test\")\n                .build();\n        CountingStatus status = new CountingStatus(terminal);\n        status.setBorder(false);\n        JlineShellContext context = newContext(terminal, lineReader, status);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.showResponding();\n\n            Assert.assertEquals(0, status.updateCount());\n        } finally {\n            terminalIO.close();\n            context.close();\n            restoreProperty(\"ai4j.jline.status\", previous);\n        }\n    }\n\n    @Test\n    public void test_inline_slash_palette_renders_when_status_component_is_unavailable() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-inline-slash\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new io.github.lnyocly.ai4j.tui.TuiConfigManager(workspace)\n        );\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        PaletteBuffer buffer = new PaletteBuffer();\n        PaletteLineReaderHandler handler = new PaletteLineReaderHandler(buffer);\n        LineReader lineReader = (LineReader) Proxy.newProxyInstance(\n                LineReader.class.getClassLoader(),\n                new Class<?>[]{LineReader.class},\n                handler\n        );\n        JlineShellContext context = newContext(terminal, lineReader, null);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, controller);\n        try {\n            controller.openSlashMenu(lineReader);\n\n            Assert.assertEquals(1, handler.printAboveCalls().size());\n            String rendered = handler.printAboveCalls().get(0);\n            Assert.assertTrue(rendered.contains(\"commands\"));\n            Assert.assertTrue(rendered.contains(\"/help\"));\n            Assert.assertTrue(rendered.contains(\"/status\"));\n        } finally {\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_turn_interrupt_watch_invokes_handler_on_escape() throws Exception {\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[]{27});\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        LineReader lineReader = LineReaderBuilder.builder()\n                .terminal(terminal)\n                .appName(\"ai4j-cli-test\")\n                .build();\n        JlineShellContext context = newContext(terminal, lineReader, null);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        CountDownLatch interrupted = new CountDownLatch(1);\n        try {\n            terminalIO.beginTurnInterruptWatch(new Runnable() {\n                @Override\n                public void run() {\n                    interrupted.countDown();\n                }\n            });\n\n            Assert.assertTrue(interrupted.await(2, TimeUnit.SECONDS));\n        } finally {\n            terminalIO.endTurnInterruptWatch();\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_turn_interrupt_polling_detects_escape() throws Exception {\n        ByteArrayInputStream input = new ByteArrayInputStream(new byte[]{27});\n        ByteArrayOutputStream output = new ByteArrayOutputStream();\n        Terminal terminal = TerminalBuilder.builder()\n                .system(false)\n                .dumb(true)\n                .streams(input, output)\n                .encoding(StandardCharsets.UTF_8)\n                .build();\n        LineReader lineReader = LineReaderBuilder.builder()\n                .terminal(terminal)\n                .appName(\"ai4j-cli-test\")\n                .build();\n        JlineShellContext context = newContext(terminal, lineReader, null);\n        JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n        try {\n            terminalIO.beginTurnInterruptPolling();\n            Assert.assertTrue(terminalIO.pollTurnInterrupt(500L));\n        } finally {\n            terminalIO.endTurnInterruptPolling();\n            terminalIO.close();\n            context.close();\n        }\n    }\n\n    @Test\n    public void test_connecting_status_escalates_to_stalled_when_no_model_events_arrive() throws Exception {\n        String previousWaiting = System.getProperty(\"ai4j.jline.waiting-ms\");\n        String previousStalled = System.getProperty(\"ai4j.jline.stalled-ms\");\n        try {\n            System.setProperty(\"ai4j.jline.waiting-ms\", \"80\");\n            System.setProperty(\"ai4j.jline.stalled-ms\", \"220\");\n            ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n            ByteArrayOutputStream output = new ByteArrayOutputStream();\n            Terminal terminal = TerminalBuilder.builder()\n                    .system(false)\n                    .dumb(true)\n                    .streams(input, output)\n                    .encoding(StandardCharsets.UTF_8)\n                    .build();\n            LineReader lineReader = LineReaderBuilder.builder()\n                    .terminal(terminal)\n                    .appName(\"ai4j-cli-test\")\n                    .build();\n            JlineShellContext context = newContext(terminal, lineReader, null);\n            JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n            try {\n                terminalIO.showConnecting(\"Connecting to fake-provider/fake-model\");\n                Thread.sleep(140L);\n                String waiting = terminalIO.currentStatusLine();\n                Assert.assertTrue(waiting, waiting.contains(\"Connecting to fake-provider/fake-model (\"));\n\n                Thread.sleep(160L);\n                String stalled = terminalIO.currentStatusLine();\n                Assert.assertTrue(stalled.contains(\"Stalled\"));\n                Assert.assertTrue(stalled.contains(\"No response from model stream\"));\n                Assert.assertTrue(stalled.contains(\"press Esc to interrupt\"));\n            } finally {\n                terminalIO.close();\n                context.close();\n            }\n        } finally {\n            restoreProperty(\"ai4j.jline.waiting-ms\", previousWaiting);\n            restoreProperty(\"ai4j.jline.stalled-ms\", previousStalled);\n        }\n    }\n\n    @Test\n    public void test_responding_status_changes_to_waiting_when_model_stream_goes_quiet() throws Exception {\n        String previousWaiting = System.getProperty(\"ai4j.jline.waiting-ms\");\n        String previousStalled = System.getProperty(\"ai4j.jline.stalled-ms\");\n        try {\n            System.setProperty(\"ai4j.jline.waiting-ms\", \"80\");\n            System.setProperty(\"ai4j.jline.stalled-ms\", \"400\");\n            ByteArrayInputStream input = new ByteArrayInputStream(new byte[0]);\n            ByteArrayOutputStream output = new ByteArrayOutputStream();\n            Terminal terminal = TerminalBuilder.builder()\n                    .system(false)\n                    .dumb(true)\n                    .streams(input, output)\n                    .encoding(StandardCharsets.UTF_8)\n                    .build();\n            LineReader lineReader = LineReaderBuilder.builder()\n                    .terminal(terminal)\n                    .appName(\"ai4j-cli-test\")\n                    .build();\n            JlineShellContext context = newContext(terminal, lineReader, null);\n            JlineShellTerminalIO terminalIO = new JlineShellTerminalIO(context, null);\n            try {\n                terminalIO.showResponding();\n                Thread.sleep(140L);\n                String waiting = terminalIO.currentStatusLine();\n                Assert.assertTrue(waiting, waiting.contains(\"Waiting\"));\n                Assert.assertTrue(waiting, waiting.contains(\"No new model output\"));\n            } finally {\n                terminalIO.close();\n                context.close();\n            }\n        } finally {\n            restoreProperty(\"ai4j.jline.waiting-ms\", previousWaiting);\n            restoreProperty(\"ai4j.jline.stalled-ms\", previousStalled);\n        }\n    }\n\n    private JlineShellContext newContext(Terminal terminal, LineReader lineReader, Status status) throws Exception {\n        Constructor<JlineShellContext> constructor = JlineShellContext.class\n                .getDeclaredConstructor(Terminal.class, LineReader.class, Status.class);\n        constructor.setAccessible(true);\n        return constructor.newInstance(terminal, lineReader, status);\n    }\n\n    private void restoreProperty(String key, String value) {\n        if (value == null) {\n            System.clearProperty(key);\n        } else {\n            System.setProperty(key, value);\n        }\n    }\n\n    private static final class RecordingLineReaderHandler implements InvocationHandler {\n\n        private final List<String> printAboveCalls = new ArrayList<String>();\n        private final List<String> widgetCalls = new ArrayList<String>();\n        private final boolean reading;\n\n        private RecordingLineReaderHandler() {\n            this(true);\n        }\n\n        private RecordingLineReaderHandler(boolean reading) {\n            this.reading = reading;\n        }\n\n        @Override\n        public Object invoke(Object proxy, Method method, Object[] args) {\n            String name = method.getName();\n            if (\"isReading\".equals(name)) {\n                return reading;\n            }\n            if (\"printAbove\".equals(name)) {\n                printAboveCalls.add(args != null && args.length > 0 && args[0] != null ? String.valueOf(args[0]) : null);\n                return null;\n            }\n            if (\"callWidget\".equals(name)) {\n                if (args != null && args.length > 0 && args[0] != null) {\n                    widgetCalls.add(String.valueOf(args[0]));\n                }\n                return Boolean.TRUE;\n            }\n            if (\"hashCode\".equals(name)) {\n                return System.identityHashCode(proxy);\n            }\n            if (\"equals\".equals(name)) {\n                return proxy == (args == null || args.length == 0 ? null : args[0]);\n            }\n            if (\"toString\".equals(name)) {\n                return \"RecordingLineReader\";\n            }\n            return defaultValue(method.getReturnType());\n        }\n\n        private List<String> printAboveCalls() {\n            return printAboveCalls;\n        }\n\n        private List<String> widgetCalls() {\n            return widgetCalls;\n        }\n\n        private Object defaultValue(Class<?> returnType) {\n            if (returnType == null || Void.TYPE.equals(returnType)) {\n                return null;\n            }\n            if (Boolean.TYPE.equals(returnType)) {\n                return Boolean.FALSE;\n            }\n            if (Character.TYPE.equals(returnType)) {\n                return Character.valueOf('\\0');\n            }\n            if (Byte.TYPE.equals(returnType)) {\n                return Byte.valueOf((byte) 0);\n            }\n            if (Short.TYPE.equals(returnType)) {\n                return Short.valueOf((short) 0);\n            }\n            if (Integer.TYPE.equals(returnType)) {\n                return Integer.valueOf(0);\n            }\n            if (Long.TYPE.equals(returnType)) {\n                return Long.valueOf(0L);\n            }\n            if (Float.TYPE.equals(returnType)) {\n                return Float.valueOf(0F);\n            }\n            if (Double.TYPE.equals(returnType)) {\n                return Double.valueOf(0D);\n            }\n            return null;\n        }\n    }\n\n    private static final class CountingStatus extends Status {\n\n        private int updateCount;\n\n        private CountingStatus(Terminal terminal) {\n            super(terminal);\n        }\n\n        @Override\n        public void update(List<AttributedString> lines) {\n            updateCount++;\n        }\n\n        private int updateCount() {\n            return updateCount;\n        }\n    }\n\n    private static final class PaletteLineReaderHandler implements InvocationHandler {\n\n        private final PaletteBuffer buffer;\n        private final List<String> printAboveCalls = new ArrayList<String>();\n\n        private PaletteLineReaderHandler(PaletteBuffer buffer) {\n            this.buffer = buffer;\n        }\n\n        @Override\n        public Object invoke(Object proxy, Method method, Object[] args) {\n            String name = method.getName();\n            if (\"isReading\".equals(name)) {\n                return true;\n            }\n            if (\"getBuffer\".equals(name)) {\n                return buffer.proxy();\n            }\n            if (\"printAbove\".equals(name)) {\n                printAboveCalls.add(args != null && args.length > 0 && args[0] != null ? String.valueOf(args[0]) : null);\n                return null;\n            }\n            if (\"callWidget\".equals(name)) {\n                return Boolean.TRUE;\n            }\n            if (\"hashCode\".equals(name)) {\n                return System.identityHashCode(proxy);\n            }\n            if (\"equals\".equals(name)) {\n                return proxy == (args == null || args.length == 0 ? null : args[0]);\n            }\n            if (\"toString\".equals(name)) {\n                return \"PaletteLineReader\";\n            }\n            return defaultValue(method.getReturnType());\n        }\n\n        private List<String> printAboveCalls() {\n            return printAboveCalls;\n        }\n\n        private Object defaultValue(Class<?> returnType) {\n            if (returnType == null || Void.TYPE.equals(returnType)) {\n                return null;\n            }\n            if (Boolean.TYPE.equals(returnType)) {\n                return Boolean.FALSE;\n            }\n            if (Integer.TYPE.equals(returnType) || Short.TYPE.equals(returnType) || Byte.TYPE.equals(returnType)) {\n                return 0;\n            }\n            if (Long.TYPE.equals(returnType)) {\n                return 0L;\n            }\n            if (Float.TYPE.equals(returnType)) {\n                return 0F;\n            }\n            if (Double.TYPE.equals(returnType)) {\n                return 0D;\n            }\n            if (Character.TYPE.equals(returnType)) {\n                return '\\0';\n            }\n            return null;\n        }\n    }\n\n    private static final class PaletteBuffer {\n\n        private final StringBuilder value = new StringBuilder();\n\n        private Buffer proxy() {\n            InvocationHandler handler = new InvocationHandler() {\n                @Override\n                public Object invoke(Object proxy, Method method, Object[] args) {\n                    String name = method.getName();\n                    if (\"toString\".equals(name)) {\n                        return value.toString();\n                    }\n                    if (\"write\".equals(name)) {\n                        if (args != null && args.length > 0 && args[0] != null) {\n                            value.append(String.valueOf(args[0]));\n                        }\n                        return null;\n                    }\n                    if (\"clear\".equals(name)) {\n                        value.setLength(0);\n                        return null;\n                    }\n                    if (\"cursor\".equals(name) || \"length\".equals(name)) {\n                        return value.length();\n                    }\n                    return defaultValue(method.getReturnType());\n                }\n            };\n            return (Buffer) Proxy.newProxyInstance(\n                    Buffer.class.getClassLoader(),\n                    new Class<?>[]{Buffer.class},\n                    handler\n            );\n        }\n\n        private Object defaultValue(Class<?> returnType) {\n            if (returnType == null || Void.TYPE.equals(returnType)) {\n                return null;\n            }\n            if (Boolean.TYPE.equals(returnType)) {\n                return Boolean.FALSE;\n            }\n            if (Integer.TYPE.equals(returnType) || Short.TYPE.equals(returnType) || Byte.TYPE.equals(returnType)) {\n                return 0;\n            }\n            if (Long.TYPE.equals(returnType)) {\n                return 0L;\n            }\n            if (Float.TYPE.equals(returnType)) {\n                return 0F;\n            }\n            if (Double.TYPE.equals(returnType)) {\n                return 0D;\n            }\n            if (Character.TYPE.equals(returnType)) {\n                return '\\0';\n            }\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/PatchSummaryFormatterTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.cli.render.PatchSummaryFormatter;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\n\npublic class PatchSummaryFormatterTest {\n\n    @Test\n    public void summarizePatchRequestSupportsLongAndShortDirectives() {\n        String patch = \"*** Begin Patch\\n\"\n                + \"*** Add File: notes/todo.txt\\n\"\n                + \"+hello\\n\"\n                + \"*** Update: src/App.java\\n\"\n                + \"@@\\n\"\n                + \"-old\\n\"\n                + \"+new\\n\"\n                + \"*** Delete File: old.txt\\n\"\n                + \"*** End Patch\";\n\n        List<String> lines = PatchSummaryFormatter.summarizePatchRequest(patch, 8);\n\n        assertEquals(Arrays.asList(\n                \"Add notes/todo.txt\",\n                \"Update src/App.java\",\n                \"Delete old.txt\"\n        ), lines);\n    }\n\n    @Test\n    public void summarizePatchResultPrefersStructuredFileChanges() {\n        JSONObject output = new JSONObject();\n        JSONArray fileChanges = new JSONArray();\n        fileChanges.add(new JSONObject()\n                .fluentPut(\"path\", \"notes/todo.txt\")\n                .fluentPut(\"operation\", \"add\")\n                .fluentPut(\"linesAdded\", 3)\n                .fluentPut(\"linesRemoved\", 0));\n        fileChanges.add(new JSONObject()\n                .fluentPut(\"path\", \"src/App.java\")\n                .fluentPut(\"operation\", \"update\")\n                .fluentPut(\"linesAdded\", 8)\n                .fluentPut(\"linesRemoved\", 2));\n        output.put(\"fileChanges\", fileChanges);\n\n        List<String> lines = PatchSummaryFormatter.summarizePatchResult(output, 8);\n\n        assertEquals(Arrays.asList(\n                \"Created notes/todo.txt (+3 -0)\",\n                \"Edited src/App.java (+8 -2)\"\n        ), lines);\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/SlashCommandControllerTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.command.CustomCommandRegistry;\nimport io.github.lnyocly.ai4j.tui.TuiConfigManager;\nimport org.jline.reader.Buffer;\nimport org.jline.reader.Candidate;\nimport org.jline.reader.LineReader;\nimport org.junit.Test;\n\nimport java.lang.reflect.InvocationHandler;\nimport java.lang.reflect.Proxy;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertTrue;\n\npublic class SlashCommandControllerTest {\n\n    @Test\n    public void suggestRootCommandsIncludesBuiltInsAndSpacingRules() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-root\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/\", 1);\n\n        assertContainsValue(candidates, \"/help\");\n        assertContainsValue(candidates, \"/cmd \");\n        assertContainsValue(candidates, \"/theme \");\n        assertContainsValue(candidates, \"/stream \");\n        assertContainsValue(candidates, \"/mcp \");\n        assertContainsValue(candidates, \"/providers\");\n        assertContainsValue(candidates, \"/provider \");\n        assertContainsValue(candidates, \"/model \");\n        assertContainsValue(candidates, \"/experimental \");\n        assertContainsValue(candidates, \"/skills \");\n        assertContainsValue(candidates, \"/agents \");\n        assertContainsValue(candidates, \"/process \");\n        assertContainsValue(candidates, \"/team\");\n    }\n\n    @Test\n    public void suggestCustomCommandNamesFromWorkspaceRegistry() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-cmd\");\n        Path commandsDir = workspace.resolve(\".ai4j\").resolve(\"commands\");\n        Files.createDirectories(commandsDir);\n        Files.write(commandsDir.resolve(\"review.prompt\"),\n                \"# Review auth flow\\nCheck the auth flow.\\n\".getBytes(StandardCharsets.UTF_8));\n\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/cmd re\", \"/cmd re\".length());\n\n        assertContainsValue(candidates, \"review\");\n    }\n\n    @Test\n    public void suggestThemesIncludesBuiltInThemeNames() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-theme\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/theme a\", \"/theme a\".length());\n\n        assertContainsValue(candidates, \"amber\");\n    }\n\n    @Test\n    public void suggestStreamOptionsIncludesOnAndOff() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-stream\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/stream \", \"/stream \".length());\n\n        assertContainsValue(candidates, \"on\");\n        assertContainsValue(candidates, \"off\");\n    }\n\n    @Test\n    public void suggestExperimentalFeaturesIncludesSubagentAndAgentTeams() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-experimental\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/experimental \", \"/experimental \".length());\n\n        assertContainsValue(candidates, \"subagent\");\n        assertContainsValue(candidates, \"agent-teams\");\n    }\n\n    @Test\n    public void suggestExperimentalToggleOptionsIncludesOnAndOff() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-experimental-toggle\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/experimental subagent \", \"/experimental subagent \".length());\n\n        assertContainsValue(candidates, \"on\");\n        assertContainsValue(candidates, \"off\");\n    }\n\n    @Test\n    public void suggestTeamActionsIncludesPersistedStateCommands() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-team\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/team \", \"/team \".length());\n\n        assertContainsValue(candidates, \"list\");\n        assertContainsValue(candidates, \"status \");\n        assertContainsValue(candidates, \"messages \");\n        assertContainsValue(candidates, \"resume \");\n    }\n\n    @Test\n    public void suggestTeamIdsFromSupplierForStatusAndResume() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-team-id\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setTeamCandidateSupplier(() -> Arrays.asList(\"experimental-delivery-team\", \"travel-team\"));\n\n        List<Candidate> statusCandidates = controller.suggest(\"/team status \", \"/team status \".length());\n        List<Candidate> resumeCandidates = controller.suggest(\"/team resume \", \"/team resume \".length());\n\n        assertContainsValue(statusCandidates, \"experimental-delivery-team\");\n        assertContainsValue(statusCandidates, \"travel-team\");\n        assertContainsValue(resumeCandidates, \"experimental-delivery-team\");\n    }\n\n    @Test\n    public void suggestTeamMessageLimitsAfterTeamId() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-team-messages\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setTeamCandidateSupplier(() -> Arrays.asList(\"experimental-delivery-team\"));\n\n        List<Candidate> candidates = controller.suggest(\n                \"/team messages experimental-delivery-team \",\n                \"/team messages experimental-delivery-team \".length()\n        );\n\n        assertContainsValue(candidates, \"10\");\n        assertContainsValue(candidates, \"20\");\n        assertContainsValue(candidates, \"50\");\n    }\n\n    @Test\n    public void suggestSkillNamesFromSupplier() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-skills\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setSkillCandidateSupplier(() -> Arrays.asList(\"repo-review\", \"release-checklist\"));\n\n        List<Candidate> candidates = controller.suggest(\"/skills \", \"/skills \".length());\n\n        assertContainsValue(candidates, \"repo-review\");\n        assertContainsValue(candidates, \"release-checklist\");\n    }\n\n    @Test\n    public void suggestAgentNamesFromSupplier() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-agents\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setAgentCandidateSupplier(() -> Arrays.asList(\"reviewer\", \"planner\"));\n\n        List<Candidate> candidates = controller.suggest(\"/agents \", \"/agents \".length());\n\n        assertContainsValue(candidates, \"reviewer\");\n        assertContainsValue(candidates, \"planner\");\n    }\n\n    @Test\n    public void suggestExactExecutableArgumentCommandKeepsRootCandidateWithoutTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-stream-exact\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/stream\", \"/stream\".length());\n\n        assertContainsValue(candidates, \"/stream \");\n        assertTrue(!containsValue(candidates, \"/stream on\"));\n        assertTrue(!containsValue(candidates, \"/stream off\"));\n    }\n\n    @Test\n    public void suggestExactMcpCommandKeepsRootCandidateWithoutTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-mcp-exact\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/mcp\", \"/mcp\".length());\n\n        assertContainsValue(candidates, \"/mcp \");\n        assertTrue(!containsValue(candidates, \"/mcp add \"));\n        assertTrue(!containsValue(candidates, \"/mcp enable \"));\n    }\n\n    @Test\n    public void suggestMcpActionsAfterTrailingSpaceIncludesManagementCommands() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-mcp-actions\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/mcp \", \"/mcp \".length());\n\n        assertContainsValue(candidates, \"list \");\n        assertContainsValue(candidates, \"add \");\n        assertContainsValue(candidates, \"enable \");\n        assertContainsValue(candidates, \"pause \");\n        assertContainsValue(candidates, \"remove \");\n    }\n\n    @Test\n    public void suggestMcpTransportOptionsAfterTransportFlag() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-mcp-transport\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/mcp add --transport \", \"/mcp add --transport \".length());\n\n        assertContainsValue(candidates, \"stdio\");\n        assertContainsValue(candidates, \"sse\");\n        assertContainsValue(candidates, \"http\");\n    }\n\n    @Test\n    public void suggestMcpServerNamesFromSupplier() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-mcp-server\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setMcpServerCandidateSupplier(() -> Arrays.asList(\"fetch\", \"time\"));\n\n        List<Candidate> candidates = controller.suggest(\"/mcp enable \", \"/mcp enable \".length());\n\n        assertContainsValue(candidates, \"fetch\");\n        assertContainsValue(candidates, \"time\");\n    }\n\n    @Test\n    public void suggestExactProviderCommandKeepsRootCandidateWithoutTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-provider-exact\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/provider\", \"/provider\".length());\n\n        assertContainsValue(candidates, \"/provider \");\n        assertTrue(!containsValue(candidates, \"/provider use \"));\n    }\n\n    @Test\n    public void suggestProviderActionsAfterTrailingSpaceIncludesAddAndEdit() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-provider-actions\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/provider \", \"/provider \".length());\n\n        assertContainsValue(candidates, \"use \");\n        assertContainsValue(candidates, \"save \");\n        assertContainsValue(candidates, \"add \");\n        assertContainsValue(candidates, \"edit \");\n    }\n\n    @Test\n    public void suggestProviderNamesFromProfileSupplier() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-provider-name\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setProfileCandidateSupplier(() -> Arrays.asList(\"openai-main\", \"zhipu-main\"));\n\n        List<Candidate> candidates = controller.suggest(\"/provider use \", \"/provider use \".length());\n\n        assertContainsValue(candidates, \"openai-main\");\n        assertContainsValue(candidates, \"zhipu-main\");\n    }\n\n    @Test\n    public void suggestProviderNamesWhenUseActionIsExactWithoutTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-provider-use-exact\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setProfileCandidateSupplier(() -> Arrays.asList(\"openai-main\", \"zhipu-main\"));\n\n        List<Candidate> candidates = controller.suggest(\"/provider use\", \"/provider use\".length());\n\n        assertContainsValue(candidates, \"/provider use openai-main\");\n        assertContainsValue(candidates, \"/provider use zhipu-main\");\n    }\n\n    @Test\n    public void suggestProviderNamesWhenEditActionIsExactWithoutTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-provider-edit-exact\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setProfileCandidateSupplier(() -> Arrays.asList(\"openai-main\", \"zhipu-main\"));\n\n        List<Candidate> candidates = controller.suggest(\"/provider edit\", \"/provider edit\".length());\n\n        assertContainsValue(candidates, \"/provider edit openai-main\");\n        assertContainsValue(candidates, \"/provider edit zhipu-main\");\n    }\n\n    @Test\n    public void suggestProviderDefaultNamesAndClearFromProfileSupplier() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-provider-default\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setProfileCandidateSupplier(() -> Arrays.asList(\"openai-main\", \"zhipu-main\"));\n\n        List<Candidate> candidates = controller.suggest(\"/provider default \", \"/provider default \".length());\n\n        assertContainsValue(candidates, \"clear\");\n        assertContainsValue(candidates, \"openai-main\");\n        assertContainsValue(candidates, \"zhipu-main\");\n    }\n\n    @Test\n    public void suggestProviderMutationOptionsAfterProfileName() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-provider-mutation-options\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/provider add zhipu-main \", \"/provider add zhipu-main \".length());\n\n        assertContainsValue(candidates, \"--provider \");\n        assertContainsValue(candidates, \"--protocol \");\n        assertContainsValue(candidates, \"--model \");\n        assertContainsValue(candidates, \"--clear-api-key \");\n    }\n\n    @Test\n    public void suggestProviderMutationProtocolValues() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-provider-mutation-protocol\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/provider add zhipu-main --protocol \", \"/provider add zhipu-main --protocol \".length());\n\n        assertContainsValue(candidates, \"chat\");\n        assertContainsValue(candidates, \"responses\");\n    }\n\n    @Test\n    public void suggestExactModelCommandKeepsRootCandidateWithoutTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-model-exact\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setModelCandidateSupplier(() -> Arrays.asList(\n                new SlashCommandController.ModelCompletionCandidate(\"gpt-5-mini\", \"Current effective model\"),\n                new SlashCommandController.ModelCompletionCandidate(\"gpt-4.1\", \"Saved profile openai-main\")\n        ));\n\n        List<Candidate> candidates = controller.suggest(\"/model\", \"/model\".length());\n\n        assertContainsValue(candidates, \"/model \");\n        assertTrue(!containsValue(candidates, \"/model gpt-5-mini\"));\n        assertTrue(!containsValue(candidates, \"/model reset\"));\n    }\n\n    @Test\n    public void suggestModelCandidatesWithTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-model-space\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setModelCandidateSupplier(() -> Arrays.asList(\n                new SlashCommandController.ModelCompletionCandidate(\"glm-4.7\", \"Current effective model\"),\n                new SlashCommandController.ModelCompletionCandidate(\"glm-4.7-plus\", \"Saved profile zhipu-main\")\n        ));\n\n        List<Candidate> candidates = controller.suggest(\"/model \", \"/model \".length());\n\n        assertContainsValue(candidates, \"glm-4.7\");\n        assertContainsValue(candidates, \"glm-4.7-plus\");\n        assertContainsValue(candidates, \"reset\");\n    }\n\n    @Test\n    public void suggestProcessSubcommandsWhenProcessCommandHasTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-process\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        List<Candidate> candidates = controller.suggest(\"/process \", \"/process \".length());\n\n        assertContainsValue(candidates, \"status \");\n        assertContainsValue(candidates, \"follow \");\n    }\n\n    @Test\n    public void suggestProcessIdsFromActiveSessionSupplier() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-process-id\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setProcessCandidateSupplier(() -> Arrays.asList(\n                new SlashCommandController.ProcessCompletionCandidate(\"proc-123\", \"running | live | mvn test\"),\n                new SlashCommandController.ProcessCompletionCandidate(\"proc-456\", \"exited | metadata-only | npm run build\")\n        ));\n\n        List<Candidate> candidates = controller.suggest(\"/process status \", \"/process status \".length());\n\n        assertContainsValue(candidates, \"proc-123\");\n        assertContainsValue(candidates, \"proc-456\");\n    }\n\n    @Test\n    public void suggestProcessLimitsAfterSelectingProcessId() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-process-limit\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setProcessCandidateSupplier(() -> Arrays.asList(\n                new SlashCommandController.ProcessCompletionCandidate(\"proc-123\", \"running | live | mvn test\")\n        ));\n\n        List<Candidate> candidates = controller.suggest(\"/process follow proc-123 \", \"/process follow proc-123 \".length());\n\n        assertContainsValue(candidates, \"800\");\n        assertContainsValue(candidates, \"1600\");\n    }\n\n    @Test\n    public void suggestProcessWriteStopsCompletingAfterProcessId() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-process-write\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setProcessCandidateSupplier(() -> Arrays.asList(\n                new SlashCommandController.ProcessCompletionCandidate(\"proc-123\", \"running | live | mvn test\")\n        ));\n\n        List<Candidate> candidates = controller.suggest(\"/process write proc-123 \", \"/process write proc-123 \".length());\n\n        assertTrue(\"Expected no completion candidates after process write text position\", candidates.isEmpty());\n    }\n\n    @Test\n    public void resolveSlashMenuActionDoesNotInsertDuplicateSlashInsideSlashCommands() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-menu\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        assertTrue(controller.resolveSlashMenuAction(\"\", 0) == SlashCommandController.SlashMenuAction.INSERT_AND_MENU);\n        assertTrue(controller.resolveSlashMenuAction(\"/\", 1) == SlashCommandController.SlashMenuAction.MENU_ONLY);\n        assertTrue(controller.resolveSlashMenuAction(\"/process\", \"/process\".length()) == SlashCommandController.SlashMenuAction.MENU_ONLY);\n        assertTrue(controller.resolveSlashMenuAction(\"/process write proc-123 \", \"/process write proc-123 \".length())\n                == SlashCommandController.SlashMenuAction.INSERT_ONLY);\n    }\n\n    @Test\n    public void resolveAcceptLineActionIgnoresBlankInput() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-enter-action\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        assertTrue(controller.resolveAcceptLineAction(\"\") == SlashCommandController.EnterAction.IGNORE_EMPTY);\n        assertTrue(controller.resolveAcceptLineAction(\"   \") == SlashCommandController.EnterAction.IGNORE_EMPTY);\n        assertTrue(controller.resolveAcceptLineAction(\"/exit\") == SlashCommandController.EnterAction.ACCEPT);\n        assertTrue(controller.resolveAcceptLineAction(\"hello\") == SlashCommandController.EnterAction.ACCEPT);\n    }\n\n    @Test\n    public void openSlashMenuListsChoicesWithoutCompletingFirstCommand() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-open-menu\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        RecordingBuffer buffer = new RecordingBuffer(\"\");\n        List<String> widgetCalls = new ArrayList<String>();\n\n        controller.openSlashMenu(recordingLineReader(buffer, widgetCalls));\n\n        assertEquals(\"/\", buffer.value());\n        assertTrue(!widgetCalls.contains(LineReader.LIST_CHOICES));\n        assertTrue(!widgetCalls.contains(LineReader.MENU_COMPLETE));\n        SlashCommandController.PaletteSnapshot snapshot = controller.getPaletteSnapshot();\n        assertTrue(snapshot.isOpen());\n        assertEquals(\"/\", snapshot.getQuery());\n        assertEquals(\"/help\", snapshot.getItems().get(snapshot.getSelectedIndex()).getValue());\n    }\n\n    @Test\n    public void openSlashMenuKeepsExistingSlashPrefixWhileRefreshingChoices() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-open-existing\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        RecordingBuffer buffer = new RecordingBuffer(\"/re\");\n        List<String> widgetCalls = new ArrayList<String>();\n\n        controller.openSlashMenu(recordingLineReader(buffer, widgetCalls));\n\n        assertEquals(\"/re\", buffer.value());\n        assertTrue(!widgetCalls.contains(LineReader.LIST_CHOICES));\n        assertTrue(!widgetCalls.contains(LineReader.MENU_COMPLETE));\n        SlashCommandController.PaletteSnapshot snapshot = controller.getPaletteSnapshot();\n        assertTrue(snapshot.isOpen());\n        assertEquals(\"/re\", snapshot.getQuery());\n    }\n\n    @Test\n    public void movePaletteSelectionAdvancesWithoutTabBootstrap() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-open-move\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        RecordingBuffer buffer = new RecordingBuffer(\"\");\n        controller.openSlashMenu(recordingLineReader(buffer, new ArrayList<String>()));\n        controller.movePaletteSelection(1);\n\n        SlashCommandController.PaletteSnapshot snapshot = controller.getPaletteSnapshot();\n        assertTrue(snapshot.isOpen());\n        assertEquals(\"/status\", snapshot.getItems().get(snapshot.getSelectedIndex()).getValue());\n    }\n\n    @Test\n    public void acceptSlashSelectionReplacesBufferWithHighlightedCommand() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-accept\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        RecordingBuffer buffer = new RecordingBuffer(\"\");\n        List<String> widgetCalls = new ArrayList<String>();\n        LineReader lineReader = recordingLineReader(buffer, widgetCalls);\n\n        controller.openSlashMenu(lineReader);\n        controller.movePaletteSelection(1);\n\n        assertTrue(controller.acceptSlashSelection(lineReader, true));\n        assertEquals(\"/status\", buffer.value());\n        assertTrue(!controller.getPaletteSnapshot().isOpen());\n    }\n\n    @Test\n    public void acceptSlashSelectionKeepsCommandPrefixForArgumentCandidates() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-stream-accept\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        RecordingBuffer buffer = new RecordingBuffer(\"/stream \");\n        LineReader lineReader = recordingLineReader(buffer, new ArrayList<String>());\n\n        java.lang.reflect.Method updatePalette = SlashCommandController.class\n                .getDeclaredMethod(\"updatePalette\", String.class, List.class);\n        updatePalette.setAccessible(true);\n        updatePalette.invoke(controller, \"/stream \", controller.suggest(\"/stream \", \"/stream \".length()));\n        controller.movePaletteSelection(1);\n\n        assertTrue(controller.acceptSlashSelection(lineReader, true));\n        assertEquals(\"/stream off\", buffer.value());\n        assertTrue(!controller.getPaletteSnapshot().isOpen());\n    }\n\n    @Test\n    public void acceptSlashSelectionFromPartialCommandKeepsPaletteOpenForArgumentChoices() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-slash-stream-chain\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        RecordingBuffer buffer = new RecordingBuffer(\"/stre\");\n        LineReader lineReader = recordingLineReader(buffer, new ArrayList<String>());\n\n        java.lang.reflect.Method updatePalette = SlashCommandController.class\n                .getDeclaredMethod(\"updatePalette\", String.class, List.class);\n        updatePalette.setAccessible(true);\n        updatePalette.invoke(controller, \"/stre\", controller.suggest(\"/stre\", \"/stre\".length()));\n\n        assertTrue(controller.acceptSlashSelection(lineReader, true));\n        assertEquals(\"/stream \", buffer.value());\n        SlashCommandController.PaletteSnapshot snapshot = controller.getPaletteSnapshot();\n        assertTrue(snapshot.isOpen());\n        assertEquals(\"/stream \", snapshot.getQuery());\n        assertTrue(containsPaletteValue(snapshot, \"on\"));\n        assertTrue(containsPaletteValue(snapshot, \"off\"));\n    }\n\n    @Test\n    public void acceptLineExecutesExactOptionalCommandInsteadOfApplyingCandidate() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-enter-model-root\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setModelCandidateSupplier(() -> Arrays.asList(\n                new SlashCommandController.ModelCompletionCandidate(\"glm-4.7\", \"Current effective model\")\n        ));\n\n        RecordingBuffer buffer = new RecordingBuffer(\"/model\");\n        List<String> widgetCalls = new ArrayList<String>();\n        LineReader lineReader = recordingLineReader(buffer, widgetCalls);\n\n        updatePalette(controller, \"/model\");\n\n        assertTrue(invokeAcceptLine(controller, lineReader));\n        assertEquals(\"/model\", buffer.value());\n        assertTrue(widgetCalls.contains(LineReader.ACCEPT_LINE));\n        assertTrue(!controller.getPaletteSnapshot().isOpen());\n    }\n\n    @Test\n    public void acceptLineExecutesExactOptionalCommandWithTrailingSpace() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-enter-model-space\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n        controller.setModelCandidateSupplier(() -> Arrays.asList(\n                new SlashCommandController.ModelCompletionCandidate(\"glm-4.7\", \"Current effective model\")\n        ));\n\n        RecordingBuffer buffer = new RecordingBuffer(\"/model \");\n        List<String> widgetCalls = new ArrayList<String>();\n        LineReader lineReader = recordingLineReader(buffer, widgetCalls);\n\n        updatePalette(controller, \"/model \");\n\n        assertTrue(invokeAcceptLine(controller, lineReader));\n        assertEquals(\"/model \", buffer.value());\n        assertTrue(widgetCalls.contains(LineReader.ACCEPT_LINE));\n        assertTrue(!controller.getPaletteSnapshot().isOpen());\n    }\n\n    @Test\n    public void acceptLineAcceptsSelectionForIncompletePrefix() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-enter-resume-prefix\");\n        SlashCommandController controller = new SlashCommandController(\n                new CustomCommandRegistry(workspace),\n                new TuiConfigManager(workspace)\n        );\n\n        RecordingBuffer buffer = new RecordingBuffer(\"/res\");\n        List<String> widgetCalls = new ArrayList<String>();\n        LineReader lineReader = recordingLineReader(buffer, widgetCalls);\n\n        updatePalette(controller, \"/res\");\n\n        assertTrue(invokeAcceptLine(controller, lineReader));\n        assertEquals(\"/resume \", buffer.value());\n        assertTrue(!widgetCalls.contains(LineReader.ACCEPT_LINE));\n        assertTrue(controller.getPaletteSnapshot().isOpen());\n    }\n\n    private boolean invokeAcceptLine(SlashCommandController controller, LineReader lineReader) throws Exception {\n        java.lang.reflect.Method method = SlashCommandController.class.getDeclaredMethod(\"acceptLine\", LineReader.class);\n        method.setAccessible(true);\n        return Boolean.TRUE.equals(method.invoke(controller, lineReader));\n    }\n\n    private void updatePalette(SlashCommandController controller, String line) throws Exception {\n        java.lang.reflect.Method updatePalette = SlashCommandController.class\n                .getDeclaredMethod(\"updatePalette\", String.class, List.class);\n        updatePalette.setAccessible(true);\n        updatePalette.invoke(controller, line, controller.suggest(line, line.length()));\n    }\n\n    private void assertContainsValue(List<Candidate> candidates, String value) {\n        assertTrue(\"Expected candidate \" + value, containsValue(candidates, value));\n    }\n\n    private boolean containsValue(List<Candidate> candidates, String value) {\n        if (candidates == null) {\n            return false;\n        }\n        for (Candidate candidate : candidates) {\n            if (candidate != null && value.equals(candidate.value())) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private boolean containsPaletteValue(SlashCommandController.PaletteSnapshot snapshot, String value) {\n        if (snapshot == null || snapshot.getItems() == null) {\n            return false;\n        }\n        for (SlashCommandController.PaletteItemSnapshot item : snapshot.getItems()) {\n            if (item != null && value.equals(item.getValue())) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private LineReader recordingLineReader(final RecordingBuffer buffer, final List<String> widgetCalls) {\n        InvocationHandler handler = new InvocationHandler() {\n            @Override\n            public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {\n                String name = method.getName();\n                if (\"getBuffer\".equals(name)) {\n                    return buffer.proxy();\n                }\n                if (\"callWidget\".equals(name)) {\n                    if (args != null && args.length > 0 && args[0] != null) {\n                        widgetCalls.add(String.valueOf(args[0]));\n                    }\n                    return true;\n                }\n                if (\"isSet\".equals(name)) {\n                    return false;\n                }\n                if (\"getVariables\".equals(name)) {\n                    return null;\n                }\n                return null;\n            }\n        };\n        return (LineReader) Proxy.newProxyInstance(\n                LineReader.class.getClassLoader(),\n                new Class[]{LineReader.class},\n                handler\n        );\n    }\n\n    private static final class RecordingBuffer {\n        private final StringBuilder value;\n        private int cursor;\n\n        private RecordingBuffer(String initialValue) {\n            this.value = new StringBuilder(initialValue == null ? \"\" : initialValue);\n            this.cursor = this.value.length();\n        }\n\n        private Buffer proxy() {\n            InvocationHandler handler = new InvocationHandler() {\n                @Override\n                public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {\n                    String name = method.getName();\n                    if (\"toString\".equals(name)) {\n                        return value.toString();\n                    }\n                    if (\"cursor\".equals(name)) {\n                        return cursor;\n                    }\n                    if (\"write\".equals(name)) {\n                        String appended = args != null && args.length > 0 && args[0] != null ? String.valueOf(args[0]) : \"\";\n                        value.insert(cursor, appended);\n                        cursor += appended.length();\n                        return null;\n                    }\n                    if (\"length\".equals(name)) {\n                        return value.length();\n                    }\n                    if (\"clear\".equals(name)) {\n                        value.setLength(0);\n                        cursor = 0;\n                        return true;\n                    }\n                    return null;\n                }\n            };\n            return (Buffer) Proxy.newProxyInstance(\n                    Buffer.class.getClassLoader(),\n                    new Class[]{Buffer.class},\n                    handler\n            );\n        }\n\n        private String value() {\n            return value.toString();\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/TranscriptPrinterTest.java",
    "content": "package io.github.lnyocly.ai4j.cli;\n\nimport io.github.lnyocly.ai4j.cli.render.TranscriptPrinter;\nimport io.github.lnyocly.ai4j.tui.TerminalIO;\nimport org.junit.Test;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\n\npublic class TranscriptPrinterTest {\n\n    @Test\n    public void printsSingleBlankLineBetweenBlocks() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        TranscriptPrinter printer = new TranscriptPrinter(terminal);\n\n        printer.printBlock(Arrays.asList(\"\", \"• Ran echo hello\", \"  └ hello\", \"\"));\n        printer.printBlock(Arrays.asList(\"• Applied patch\"));\n\n        assertEquals(Arrays.asList(\"• Ran echo hello\", \"  └ hello\", \"\", \"• Applied patch\"), terminal.lines());\n    }\n\n    @Test\n    public void sectionBreakResetsSpacingWithoutDoubleBlankLines() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        TranscriptPrinter printer = new TranscriptPrinter(terminal);\n\n        printer.printBlock(Arrays.asList(\"• First block\"));\n        printer.printSectionBreak();\n        printer.printBlock(Arrays.asList(\"• Second block\"));\n\n        assertEquals(Arrays.asList(\"• First block\", \"\", \"• Second block\"), terminal.lines());\n    }\n\n    @Test\n    public void streamingBlockUsesSingleSeparatorBeforeNextBlock() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        TranscriptPrinter printer = new TranscriptPrinter(terminal);\n\n        printer.printBlock(Arrays.asList(\"• First block\"));\n        printer.beginStreamingBlock();\n        terminal.print(\"chunked\");\n        terminal.println(\"\");\n        printer.printBlock(Arrays.asList(\"• Second block\"));\n\n        assertEquals(Arrays.asList(\"• First block\", \"chunked\", \"\", \"\", \"• Second block\"), terminal.lines());\n    }\n\n    @Test\n    public void resetPrintedBlockDropsSeparatorAfterClearedBlock() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        TranscriptPrinter printer = new TranscriptPrinter(terminal);\n\n        printer.beginStreamingBlock();\n        printer.resetPrintedBlock();\n        printer.printBlock(Arrays.asList(\"• Tool block\"));\n\n        assertEquals(Arrays.asList(\"• Tool block\"), terminal.lines());\n    }\n\n    private static final class RecordingTerminalIO implements TerminalIO {\n\n        private final List<String> lines = new ArrayList<String>();\n\n        @Override\n        public String readLine(String prompt) throws IOException {\n            throw new UnsupportedOperationException();\n        }\n\n        @Override\n        public void print(String message) {\n            lines.add(message == null ? \"\" : message);\n        }\n\n        @Override\n        public void println(String message) {\n            lines.add(message == null ? \"\" : message);\n        }\n\n        @Override\n        public void errorln(String message) {\n            lines.add(message == null ? \"\" : message);\n        }\n\n        private List<String> lines() {\n            return lines;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/acp/AcpCommandTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.acp;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptionsParser;\nimport io.github.lnyocly.ai4j.cli.factory.CodingCliAgentFactory;\nimport io.github.lnyocly.ai4j.cli.runtime.CodingTaskSessionEventBridge;\nimport io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.FileCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.FileSessionEventStore;\nimport io.github.lnyocly.ai4j.cli.session.StoredCodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.CodingAgents;\nimport io.github.lnyocly.ai4j.coding.CodingSessionState;\nimport io.github.lnyocly.ai4j.coding.definition.CodingSessionMode;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLink;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskProgress;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskStatus;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.BufferedReader;\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.InputStreamReader;\nimport java.io.PipedInputStream;\nimport java.io.PipedOutputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Properties;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\n\npublic class AcpCommandTest {\n\n    @Test\n    public void test_initialize_new_prompt_and_load_history() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-main\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/new\", params(\n                \"sessionId\", \"acp-session\",\n                \"cwd\", workspace.toString()\n        )))\n                + line(request(3, \"session/prompt\", params(\n                \"sessionId\", \"acp-session\",\n                \"prompt\", textPrompt(\"hello from acp\")\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                provider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        Assert.assertTrue(hasResponse(messages, 1));\n        Assert.assertTrue(hasResponse(messages, 2));\n        Assert.assertTrue(hasResponse(messages, 3));\n        JSONObject newSessionResult = responseResult(messages, 2);\n        Assert.assertEquals(\"acp-session\", newSessionResult.getString(\"sessionId\"));\n        Assert.assertTrue(containsConfigOption(newSessionResult.getJSONArray(\"configOptions\"), \"mode\"));\n        Assert.assertTrue(containsConfigOption(newSessionResult.getJSONArray(\"configOptions\"), \"model\"));\n        Assert.assertEquals(\"auto\", newSessionResult.getJSONObject(\"modes\").getString(\"currentModeId\"));\n        JSONObject availableCommands = findSessionUpdate(messages, \"available_commands_update\");\n        Assert.assertNotNull(availableCommands);\n        JSONArray available = availableCommands.getJSONArray(\"availableCommands\");\n        Assert.assertNotNull(available);\n        Assert.assertFalse(available.isEmpty());\n        Assert.assertTrue(containsAvailableCommand(available, \"status\"));\n        Assert.assertTrue(containsAvailableCommand(available, \"team\"));\n        Assert.assertTrue(containsAvailableCommand(available, \"mcp\"));\n        Assert.assertTrue(hasSessionUpdate(messages, \"user_message_chunk\", \"hello from acp\"));\n        Assert.assertTrue(hasSessionUpdate(messages, \"agent_message_chunk\", \"Echo: hello from acp\"));\n        Assert.assertEquals(\"end_turn\", responseResult(messages, 3).getString(\"stopReason\"));\n\n        ByteArrayOutputStream loadOut = new ByteArrayOutputStream();\n        ByteArrayOutputStream loadErr = new ByteArrayOutputStream();\n        String loadInput = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/list\", params(\"cwd\", workspace.toString())))\n                + line(request(3, \"session/load\", params(\n                \"sessionId\", \"acp-session\",\n                \"cwd\", workspace.toString()\n        )));\n\n        int loadExit = new AcpJsonRpcServer(\n                new ByteArrayInputStream(loadInput.getBytes(StandardCharsets.UTF_8)),\n                loadOut,\n                loadErr,\n                options,\n                provider()\n        ).run();\n\n        Assert.assertEquals(0, loadExit);\n        List<JSONObject> loadMessages = parseLines(loadOut);\n        Assert.assertEquals(\"acp-session\", responseResult(loadMessages, 3).getString(\"sessionId\"));\n        Assert.assertTrue(containsConfigOption(responseResult(loadMessages, 3).getJSONArray(\"configOptions\"), \"mode\"));\n        Assert.assertTrue(containsConfigOption(responseResult(loadMessages, 3).getJSONArray(\"configOptions\"), \"model\"));\n        JSONArray sessions = responseResult(loadMessages, 2).getJSONArray(\"sessions\");\n        Assert.assertNotNull(sessions);\n        Assert.assertFalse(sessions.isEmpty());\n        JSONObject loadAvailableCommands = findSessionUpdate(loadMessages, \"available_commands_update\");\n        Assert.assertNotNull(loadAvailableCommands);\n        Assert.assertTrue(containsAvailableCommand(loadAvailableCommands.getJSONArray(\"availableCommands\"), \"status\"));\n        Assert.assertTrue(hasSessionUpdate(loadMessages, \"user_message_chunk\", \"hello from acp\"));\n        Assert.assertTrue(hasSessionUpdate(loadMessages, \"agent_message_chunk\", \"Echo: hello from acp\"));\n    }\n\n    @Test\n    public void test_slash_command_prompt_is_handled_without_model_round_trip() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-slash\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/new\", params(\n                \"sessionId\", \"slash-session\",\n                \"cwd\", workspace.toString()\n        )))\n                + line(request(3, \"session/prompt\", params(\n                \"sessionId\", \"slash-session\",\n                \"prompt\", textPrompt(\"/status\")\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                provider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        Assert.assertEquals(\"end_turn\", responseResult(messages, 3).getString(\"stopReason\"));\n        Assert.assertTrue(hasSessionUpdate(messages, \"user_message_chunk\", \"/status\"));\n        Assert.assertTrue(containsSessionUpdatePrefix(messages, \"agent_message_chunk\", \"status:\"));\n        Assert.assertFalse(hasSessionUpdate(messages, \"agent_message_chunk\", \"Echo: /status\"));\n    }\n\n    @Test\n    public void test_provider_and_model_slash_commands_rebind_runtime() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-provider-model\");\n        Path fakeHome = Files.createTempDirectory(\"ai4j-acp-provider-home\");\n        String originalUserHome = System.getProperty(\"user.home\");\n        PipedOutputStream clientToServer = new PipedOutputStream();\n        PipedInputStream serverInput = new PipedInputStream(clientToServer);\n        PipedOutputStream serverToClient = new PipedOutputStream();\n        PipedInputStream clientInput = new PipedInputStream(serverToClient);\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        try {\n            System.setProperty(\"user.home\", fakeHome.toString());\n            CodeCommandOptions options = parseOptions(workspace, \"--provider\", \"openai\", \"--model\", \"fake-model\");\n            final AcpJsonRpcServer server = new AcpJsonRpcServer(\n                    serverInput,\n                    serverToClient,\n                    err,\n                    options,\n                    runtimeEchoProvider()\n            );\n            Thread serverThread = new Thread(new Runnable() {\n                @Override\n                public void run() {\n                    server.run();\n                }\n            });\n            serverThread.start();\n\n            BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8));\n            List<JSONObject> messages = new ArrayList<JSONObject>();\n\n            clientToServer.write(line(request(1, \"initialize\", params(\"protocolVersion\", 1))).getBytes(StandardCharsets.UTF_8));\n            clientToServer.write(line(request(2, \"session/new\", params(\n                    \"sessionId\", \"provider-model-session\",\n                    \"cwd\", workspace.toString()\n            ))).getBytes(StandardCharsets.UTF_8));\n            clientToServer.flush();\n            readUntilResponse(reader, messages, 1);\n            readUntilResponse(reader, messages, 2);\n            readUntilSessionUpdate(reader, messages, \"available_commands_update\");\n\n            JSONObject availableCommands = findSessionUpdate(messages, \"available_commands_update\");\n            Assert.assertNotNull(availableCommands);\n            JSONArray available = availableCommands.getJSONArray(\"availableCommands\");\n            Assert.assertTrue(containsAvailableCommand(available, \"providers\"));\n            Assert.assertTrue(containsAvailableCommand(available, \"provider\"));\n            Assert.assertTrue(containsAvailableCommand(available, \"model\"));\n\n            sendPrompt(clientToServer, 3, \"provider-model-session\",\n                    \"/provider add zhipu-main --provider zhipu --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4\");\n            readUntilResponse(reader, messages, 3);\n\n            sendPrompt(clientToServer, 4, \"provider-model-session\", \"/provider use zhipu-main\");\n            readUntilResponse(reader, messages, 4);\n\n            sendPrompt(clientToServer, 5, \"provider-model-session\", \"/model glm-4.7-plus\");\n            readUntilResponse(reader, messages, 5);\n\n            sendPrompt(clientToServer, 6, \"provider-model-session\", \"hello from switched runtime\");\n            readUntilResponse(reader, messages, 6);\n\n            Assert.assertEquals(\"end_turn\", responseResult(messages, 6).getString(\"stopReason\"));\n            Assert.assertTrue(hasSessionUpdate(messages, \"agent_message_chunk\", \"Echo[zhipu/glm-4.7-plus]: hello from switched runtime\"));\n\n            JSONObject workspaceConfig = JSON.parseObject(Files.readAllBytes(workspace.resolve(\".ai4j\").resolve(\"workspace.json\")));\n            Assert.assertEquals(\"zhipu-main\", workspaceConfig.getString(\"activeProfile\"));\n            Assert.assertEquals(\"glm-4.7-plus\", workspaceConfig.getString(\"modelOverride\"));\n\n            JSONObject providersConfig = JSON.parseObject(Files.readAllBytes(fakeHome.resolve(\".ai4j\").resolve(\"providers.json\")));\n            Assert.assertNotNull(providersConfig.getJSONObject(\"profiles\").getJSONObject(\"zhipu-main\"));\n\n            clientToServer.close();\n            serverThread.join(5000L);\n        } finally {\n            System.setProperty(\"user.home\", originalUserHome);\n        }\n    }\n\n    @Test\n    public void test_set_config_option_updates_model_and_emits_config_update() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-set-config\");\n        Path fakeHome = Files.createTempDirectory(\"ai4j-acp-set-config-home\");\n        String originalUserHome = System.getProperty(\"user.home\");\n        try {\n            System.setProperty(\"user.home\", fakeHome.toString());\n            Files.createDirectories(fakeHome.resolve(\".ai4j\"));\n            Files.write(fakeHome.resolve(\".ai4j\").resolve(\"providers.json\"),\n                    (\"{\\n\" +\n                            \"  \\\"profiles\\\": {\\n\" +\n                            \"    \\\"openai-alt\\\": {\\n\" +\n                            \"      \\\"provider\\\": \\\"openai\\\",\\n\" +\n                            \"      \\\"model\\\": \\\"fake-model-2\\\"\\n\" +\n                            \"    }\\n\" +\n                            \"  }\\n\" +\n                            \"}\").getBytes(StandardCharsets.UTF_8));\n\n            CodeCommandOptions options = parseOptions(workspace, \"--provider\", \"openai\", \"--model\", \"fake-model\");\n            ByteArrayOutputStream out = new ByteArrayOutputStream();\n            ByteArrayOutputStream err = new ByteArrayOutputStream();\n            String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                    + line(request(2, \"session/new\", params(\n                    \"sessionId\", \"config-session\",\n                    \"cwd\", workspace.toString()\n            )))\n                    + line(request(3, \"session/set_config_option\", params(\n                    \"sessionId\", \"config-session\",\n                    \"configId\", \"model\",\n                    \"value\", \"fake-model-2\"\n            )))\n                    + line(request(4, \"session/prompt\", params(\n                    \"sessionId\", \"config-session\",\n                    \"prompt\", textPrompt(\"hello from set_config_option\")\n            )));\n\n            int exitCode = new AcpJsonRpcServer(\n                    new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                    out,\n                    err,\n                    options,\n                    runtimeEchoProvider()\n            ).run();\n\n            Assert.assertEquals(0, exitCode);\n\n            List<JSONObject> messages = parseLines(out);\n            Assert.assertTrue(containsConfigOptionValue(responseResult(messages, 3).getJSONArray(\"configOptions\"), \"model\", \"fake-model-2\"));\n            JSONObject configUpdate = findSessionUpdate(messages, \"config_option_update\");\n            Assert.assertNotNull(configUpdate);\n            Assert.assertTrue(containsConfigOptionValue(configUpdate.getJSONArray(\"configOptions\"), \"model\", \"fake-model-2\"));\n            Assert.assertTrue(hasSessionUpdate(messages, \"agent_message_chunk\", \"Echo[openai/fake-model-2]: hello from set_config_option\"));\n        } finally {\n            System.setProperty(\"user.home\", originalUserHome);\n        }\n    }\n\n    @Test\n    public void test_set_config_option_mode_rebinds_permission_behavior_for_factory_bound_decorator() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-mode-config-factory-bound\");\n        PipedOutputStream clientToServer = new PipedOutputStream();\n        PipedInputStream serverInput = new PipedInputStream(clientToServer);\n        PipedOutputStream serverToClient = new PipedOutputStream();\n        PipedInputStream clientInput = new PipedInputStream(serverToClient);\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n        final AcpJsonRpcServer server = new AcpJsonRpcServer(\n                serverInput,\n                serverToClient,\n                err,\n                options,\n                factoryBoundApprovalProvider()\n        );\n\n        Thread serverThread = new Thread(new Runnable() {\n            @Override\n            public void run() {\n                server.run();\n            }\n        });\n        serverThread.start();\n\n        BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8));\n        List<JSONObject> messages = new ArrayList<JSONObject>();\n\n        clientToServer.write(line(request(1, \"initialize\", params(\"protocolVersion\", 1))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.write(line(request(2, \"session/new\", params(\n                \"sessionId\", \"factory-bound-mode-session\",\n                \"cwd\", workspace.toString()\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n        readUntilResponse(reader, messages, 1);\n        readUntilResponse(reader, messages, 2);\n        readUntilSessionUpdate(reader, messages, \"available_commands_update\");\n\n        clientToServer.write(line(request(3, \"session/set_config_option\", params(\n                \"sessionId\", \"factory-bound-mode-session\",\n                \"configId\", \"mode\",\n                \"value\", \"manual\"\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n        readUntilResponse(reader, messages, 3);\n        readUntilSessionUpdate(reader, messages, \"current_mode_update\");\n        readUntilSessionUpdate(reader, messages, \"config_option_update\");\n\n        sendPrompt(clientToServer, 4, \"factory-bound-mode-session\", \"run bash now\");\n        boolean sawPermissionRequest = false;\n        boolean sawPromptResponse = false;\n        for (int i = 0; i < 40; i++) {\n            String messageLine = reader.readLine();\n            Assert.assertNotNull(messageLine);\n            JSONObject message = JSON.parseObject(messageLine);\n            messages.add(message);\n            if (\"session/request_permission\".equals(message.getString(\"method\"))) {\n                sawPermissionRequest = true;\n                Object requestId = message.get(\"id\");\n                clientToServer.write(line(response(requestId, params(\n                        \"outcome\", params(\n                                \"outcome\", \"selected\",\n                                \"optionId\", \"allow_once\"\n                        )\n                ))).getBytes(StandardCharsets.UTF_8));\n                clientToServer.flush();\n                continue;\n            }\n            if (Integer.valueOf(4).equals(message.get(\"id\"))) {\n                sawPromptResponse = true;\n                break;\n            }\n        }\n\n        clientToServer.close();\n        serverThread.join(5000L);\n\n        Assert.assertTrue(sawPermissionRequest);\n        Assert.assertTrue(sawPromptResponse);\n    }\n\n    @Test\n    public void test_set_config_option_rejects_model_not_present_in_select_options() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-set-config-invalid\");\n        CodeCommandOptions options = parseOptions(workspace, \"--provider\", \"openai\", \"--model\", \"fake-model\");\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/new\", params(\n                \"sessionId\", \"invalid-config-session\",\n                \"cwd\", workspace.toString()\n        )))\n                + line(request(3, \"session/set_config_option\", params(\n                \"sessionId\", \"invalid-config-session\",\n                \"configId\", \"model\",\n                \"value\", \"not-listed-model\"\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                runtimeEchoProvider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        JSONObject errorResponse = findError(messages, 3);\n        Assert.assertNotNull(errorResponse);\n        Assert.assertTrue(errorResponse.getJSONObject(\"error\").getString(\"message\").contains(\"Unsupported model\"));\n    }\n\n    @Test\n    public void test_set_mode_switches_permission_behavior_and_emits_mode_updates() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-set-mode\");\n        PipedOutputStream clientToServer = new PipedOutputStream();\n        PipedInputStream serverInput = new PipedInputStream(clientToServer);\n        PipedOutputStream serverToClient = new PipedOutputStream();\n        PipedInputStream clientInput = new PipedInputStream(serverToClient);\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n        final AcpJsonRpcServer server = new AcpJsonRpcServer(\n                serverInput,\n                serverToClient,\n                err,\n                options,\n                provider()\n        );\n\n        Thread serverThread = new Thread(new Runnable() {\n            @Override\n            public void run() {\n                server.run();\n            }\n        });\n        serverThread.start();\n\n        BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8));\n        List<JSONObject> messages = new ArrayList<JSONObject>();\n\n        clientToServer.write(line(request(1, \"initialize\", params(\"protocolVersion\", 1))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.write(line(request(2, \"session/new\", params(\n                \"sessionId\", \"mode-session\",\n                \"cwd\", workspace.toString()\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n        readUntilResponse(reader, messages, 1);\n        readUntilResponse(reader, messages, 2);\n        readUntilSessionUpdate(reader, messages, \"available_commands_update\");\n\n        clientToServer.write(line(request(3, \"session/set_mode\", params(\n                \"sessionId\", \"mode-session\",\n                \"modeId\", \"manual\"\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n        readUntilResponse(reader, messages, 3);\n        readUntilSessionUpdate(reader, messages, \"current_mode_update\");\n        readUntilSessionUpdate(reader, messages, \"config_option_update\");\n\n        clientToServer.write(line(request(4, \"session/prompt\", params(\n                \"sessionId\", \"mode-session\",\n                \"prompt\", textPrompt(\"run bash now\")\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n\n        boolean sawPermissionRequest = false;\n        boolean sawPromptResponse = false;\n        for (int i = 0; i < 30; i++) {\n            String messageLine = reader.readLine();\n            Assert.assertNotNull(messageLine);\n            JSONObject message = JSON.parseObject(messageLine);\n            messages.add(message);\n            if (\"session/request_permission\".equals(message.getString(\"method\"))) {\n                sawPermissionRequest = true;\n                Object requestId = message.get(\"id\");\n                clientToServer.write(line(response(requestId, params(\n                        \"outcome\", params(\n                                \"outcome\", \"selected\",\n                                \"optionId\", \"allow_once\"\n                        )\n                ))).getBytes(StandardCharsets.UTF_8));\n                clientToServer.flush();\n                continue;\n            }\n            if (Integer.valueOf(4).equals(message.get(\"id\"))) {\n                sawPromptResponse = true;\n                break;\n            }\n        }\n\n        clientToServer.close();\n        serverThread.join(5000L);\n\n        Assert.assertTrue(sawPermissionRequest);\n        Assert.assertTrue(sawPromptResponse);\n        JSONObject currentModeUpdate = findSessionUpdate(messages, \"current_mode_update\");\n        Assert.assertNotNull(currentModeUpdate);\n        Assert.assertEquals(\"manual\", currentModeUpdate.getString(\"currentModeId\"));\n        JSONObject configUpdate = findSessionUpdate(messages, \"config_option_update\");\n        Assert.assertNotNull(configUpdate);\n        Assert.assertTrue(containsConfigOptionValue(configUpdate.getJSONArray(\"configOptions\"), \"mode\", \"manual\"));\n    }\n\n    @Test\n    public void test_set_mode_during_active_prompt_applies_to_next_turn() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-set-mode-active\");\n        PipedOutputStream clientToServer = new PipedOutputStream();\n        PipedInputStream serverInput = new PipedInputStream(clientToServer);\n        PipedOutputStream serverToClient = new PipedOutputStream();\n        PipedInputStream clientInput = new PipedInputStream(serverToClient);\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        CountDownLatch promptStarted = new CountDownLatch(1);\n        CountDownLatch releasePrompt = new CountDownLatch(1);\n\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n        final AcpJsonRpcServer server = new AcpJsonRpcServer(\n                serverInput,\n                serverToClient,\n                err,\n                options,\n                blockingProvider(promptStarted, releasePrompt)\n        );\n\n        Thread serverThread = new Thread(new Runnable() {\n            @Override\n            public void run() {\n                server.run();\n            }\n        });\n        serverThread.start();\n\n        BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8));\n        List<JSONObject> messages = new ArrayList<JSONObject>();\n\n        clientToServer.write(line(request(1, \"initialize\", params(\"protocolVersion\", 1))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.write(line(request(2, \"session/new\", params(\n                \"sessionId\", \"mode-active-session\",\n                \"cwd\", workspace.toString()\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n        readUntilResponse(reader, messages, 1);\n        readUntilResponse(reader, messages, 2);\n        readUntilSessionUpdate(reader, messages, \"available_commands_update\");\n\n        sendPrompt(clientToServer, 3, \"mode-active-session\", \"hold mode switch\");\n        Assert.assertTrue(promptStarted.await(5, TimeUnit.SECONDS));\n\n        clientToServer.write(line(request(4, \"session/set_mode\", params(\n                \"sessionId\", \"mode-active-session\",\n                \"modeId\", \"manual\"\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n        readUntilResponse(reader, messages, 4);\n        readUntilSessionUpdate(reader, messages, \"current_mode_update\");\n        readUntilSessionUpdate(reader, messages, \"config_option_update\");\n\n        releasePrompt.countDown();\n        readUntilResponse(reader, messages, 3);\n\n        sendPrompt(clientToServer, 5, \"mode-active-session\", \"run bash now\");\n        boolean sawPermissionRequest = false;\n        boolean sawPromptResponse = false;\n        for (int i = 0; i < 40; i++) {\n            String messageLine = reader.readLine();\n            Assert.assertNotNull(messageLine);\n            JSONObject message = JSON.parseObject(messageLine);\n            messages.add(message);\n            if (\"session/request_permission\".equals(message.getString(\"method\"))) {\n                sawPermissionRequest = true;\n                Object requestId = message.get(\"id\");\n                clientToServer.write(line(response(requestId, params(\n                        \"outcome\", params(\n                                \"outcome\", \"selected\",\n                                \"optionId\", \"allow_once\"\n                        )\n                ))).getBytes(StandardCharsets.UTF_8));\n                clientToServer.flush();\n                continue;\n            }\n            if (Integer.valueOf(5).equals(message.get(\"id\"))) {\n                sawPromptResponse = true;\n                break;\n            }\n        }\n\n        clientToServer.close();\n        serverThread.join(5000L);\n\n        Assert.assertTrue(sawPermissionRequest);\n        Assert.assertTrue(sawPromptResponse);\n        Assert.assertTrue(hasSessionUpdate(messages, \"agent_message_chunk\", \"Echo: hold mode switch\"));\n    }\n\n    @Test\n    public void test_set_config_option_during_active_prompt_applies_to_next_turn() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-set-config-active\");\n        Path fakeHome = Files.createTempDirectory(\"ai4j-acp-set-config-active-home\");\n        String originalUserHome = System.getProperty(\"user.home\");\n        PipedOutputStream clientToServer = new PipedOutputStream();\n        PipedInputStream serverInput = new PipedInputStream(clientToServer);\n        PipedOutputStream serverToClient = new PipedOutputStream();\n        PipedInputStream clientInput = new PipedInputStream(serverToClient);\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        CountDownLatch promptStarted = new CountDownLatch(1);\n        CountDownLatch releasePrompt = new CountDownLatch(1);\n\n        try {\n            System.setProperty(\"user.home\", fakeHome.toString());\n            Files.createDirectories(fakeHome.resolve(\".ai4j\"));\n            Files.write(fakeHome.resolve(\".ai4j\").resolve(\"providers.json\"),\n                    (\"{\\n\" +\n                            \"  \\\"profiles\\\": {\\n\" +\n                            \"    \\\"openai-alt\\\": {\\n\" +\n                            \"      \\\"provider\\\": \\\"openai\\\",\\n\" +\n                            \"      \\\"model\\\": \\\"fake-model-2\\\"\\n\" +\n                            \"    }\\n\" +\n                            \"  }\\n\" +\n                            \"}\").getBytes(StandardCharsets.UTF_8));\n\n            CodeCommandOptions options = parseOptions(workspace, \"--provider\", \"openai\", \"--model\", \"fake-model\");\n            final AcpJsonRpcServer server = new AcpJsonRpcServer(\n                    serverInput,\n                    serverToClient,\n                    err,\n                    options,\n                    blockingRuntimeEchoProvider(promptStarted, releasePrompt)\n            );\n\n            Thread serverThread = new Thread(new Runnable() {\n                @Override\n                public void run() {\n                    server.run();\n                }\n            });\n            serverThread.start();\n\n            BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8));\n            List<JSONObject> messages = new ArrayList<JSONObject>();\n\n            clientToServer.write(line(request(1, \"initialize\", params(\"protocolVersion\", 1))).getBytes(StandardCharsets.UTF_8));\n            clientToServer.write(line(request(2, \"session/new\", params(\n                    \"sessionId\", \"config-active-session\",\n                    \"cwd\", workspace.toString()\n            ))).getBytes(StandardCharsets.UTF_8));\n            clientToServer.flush();\n            readUntilResponse(reader, messages, 1);\n            readUntilResponse(reader, messages, 2);\n            readUntilSessionUpdate(reader, messages, \"available_commands_update\");\n\n            sendPrompt(clientToServer, 3, \"config-active-session\", \"hold config switch\");\n            Assert.assertTrue(promptStarted.await(5, TimeUnit.SECONDS));\n\n            clientToServer.write(line(request(4, \"session/set_config_option\", params(\n                    \"sessionId\", \"config-active-session\",\n                    \"configId\", \"model\",\n                    \"value\", \"fake-model-2\"\n            ))).getBytes(StandardCharsets.UTF_8));\n            clientToServer.flush();\n            readUntilResponse(reader, messages, 4);\n            readUntilSessionUpdate(reader, messages, \"config_option_update\");\n\n            releasePrompt.countDown();\n            readUntilResponse(reader, messages, 3);\n\n            sendPrompt(clientToServer, 5, \"config-active-session\", \"after deferred model switch\");\n            readUntilResponse(reader, messages, 5);\n\n            clientToServer.close();\n            serverThread.join(5000L);\n\n            Assert.assertTrue(containsConfigOptionValue(responseResult(messages, 4).getJSONArray(\"configOptions\"), \"model\", \"fake-model-2\"));\n            Assert.assertTrue(hasSessionUpdate(messages, \"agent_message_chunk\", \"Echo[openai/fake-model]: hold config switch\"));\n            Assert.assertTrue(hasSessionUpdate(messages, \"agent_message_chunk\", \"Echo[openai/fake-model-2]: after deferred model switch\"));\n        } finally {\n            System.setProperty(\"user.home\", originalUserHome);\n        }\n    }\n\n    @Test\n    public void test_load_history_replays_delegate_task_events_as_tool_updates() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-task-history\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n        String sessionId = \"task-history-session\";\n        seedDelegateTaskHistory(workspace, sessionId);\n\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/load\", params(\n                \"sessionId\", sessionId,\n                \"cwd\", workspace.toString()\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                provider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        Assert.assertEquals(sessionId, responseResult(messages, 2).getString(\"sessionId\"));\n\n        JSONObject toolCall = findSessionUpdate(messages, \"tool_call\");\n        Assert.assertNotNull(toolCall);\n        Assert.assertEquals(\"task-1\", toolCall.getString(\"toolCallId\"));\n        Assert.assertEquals(\"Delegate plan\", toolCall.getString(\"title\"));\n        Assert.assertEquals(\"other\", toolCall.getString(\"kind\"));\n        Assert.assertEquals(\"pending\", toolCall.getString(\"status\"));\n        Assert.assertEquals(\"task-1\", toolCall.getJSONObject(\"rawInput\").getString(\"taskId\"));\n        Assert.assertEquals(\"plan\", toolCall.getJSONObject(\"rawInput\").getString(\"definition\"));\n        Assert.assertEquals(\"delegate-session-1\", toolCall.getJSONObject(\"rawInput\").getString(\"childSessionId\"));\n        Assert.assertEquals(\"fork\", toolCall.getJSONObject(\"rawInput\").getString(\"sessionMode\"));\n\n        JSONObject toolCallUpdate = findSessionUpdate(messages, \"tool_call_update\");\n        Assert.assertNotNull(toolCallUpdate);\n        Assert.assertEquals(\"task-1\", toolCallUpdate.getString(\"toolCallId\"));\n        Assert.assertEquals(\"completed\", toolCallUpdate.getString(\"status\"));\n        JSONArray content = toolCallUpdate.getJSONArray(\"content\");\n        Assert.assertNotNull(content);\n        Assert.assertFalse(content.isEmpty());\n        Assert.assertEquals(\"content\", content.getJSONObject(0).getString(\"type\"));\n        Assert.assertEquals(\"delegate plan ready\", content.getJSONObject(0).getJSONObject(\"content\").getString(\"text\"));\n        Assert.assertEquals(\"delegate plan ready\", toolCallUpdate.getJSONObject(\"rawOutput\").getString(\"text\"));\n    }\n\n    @Test\n    public void test_subagent_handoff_is_streamed_as_live_tool_updates() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-subagent-live\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/new\", params(\n                \"sessionId\", \"subagent-session\",\n                \"cwd\", workspace.toString()\n        )))\n                + line(request(3, \"session/prompt\", params(\n                \"sessionId\", \"subagent-session\",\n                \"prompt\", textPrompt(\"run subagent review\")\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                subagentProvider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        Assert.assertEquals(\"end_turn\", responseResult(messages, 3).getString(\"stopReason\"));\n\n        JSONObject handoffCall = findSessionUpdateByToolCallId(messages, \"tool_call\", \"handoff:review-call\");\n        Assert.assertNotNull(handoffCall);\n        Assert.assertEquals(\"Subagent reviewer\", handoffCall.getString(\"title\"));\n        Assert.assertEquals(\"pending\", handoffCall.getString(\"status\"));\n        Assert.assertEquals(\"reviewer\", handoffCall.getJSONObject(\"rawInput\").getString(\"subagent\"));\n\n        JSONObject handoffUpdate = findSessionUpdateByToolCallId(messages, \"tool_call_update\", \"handoff:review-call\");\n        Assert.assertNotNull(handoffUpdate);\n        Assert.assertEquals(\"completed\", handoffUpdate.getString(\"status\"));\n        Assert.assertEquals(\"review-ready\", handoffUpdate.getJSONObject(\"rawOutput\").getString(\"text\"));\n    }\n\n    @Test\n    public void test_team_subagent_tasks_are_streamed_as_live_tool_updates() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-team-live\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/new\", params(\n                \"sessionId\", \"team-session\",\n                \"cwd\", workspace.toString()\n        )))\n                + line(request(3, \"session/prompt\", params(\n                \"sessionId\", \"team-session\",\n                \"prompt\", textPrompt(\"run team review\")\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                teamSubagentProvider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        Assert.assertEquals(\"end_turn\", responseResult(messages, 3).getString(\"stopReason\"));\n\n        JSONObject taskCall = findSessionUpdateByToolCallId(messages, \"tool_call\", \"team-task:review\");\n        Assert.assertNotNull(taskCall);\n        Assert.assertEquals(\"Team task review\", taskCall.getString(\"title\"));\n        Assert.assertEquals(\"pending\", taskCall.getString(\"status\"));\n        Assert.assertEquals(\"reviewer\", taskCall.getJSONObject(\"rawInput\").getString(\"memberId\"));\n        Assert.assertEquals(\"reviewer\", taskCall.getJSONObject(\"rawInput\").getString(\"memberName\"));\n        Assert.assertEquals(\"Review this patch\", taskCall.getJSONObject(\"rawInput\").getString(\"task\"));\n        Assert.assertEquals(Integer.valueOf(0), taskCall.getJSONObject(\"rawInput\").getInteger(\"percent\"));\n\n        JSONObject taskRunningUpdate = findSessionUpdateByToolCallIdAndStatus(messages, \"tool_call_update\", \"team-task:review\", \"in_progress\");\n        Assert.assertNotNull(taskRunningUpdate);\n        Assert.assertEquals(\"Assigned to Reviewer.\", taskRunningUpdate.getJSONObject(\"rawOutput\").getString(\"text\"));\n        Assert.assertEquals(\"running\", taskRunningUpdate.getJSONObject(\"rawOutput\").getString(\"phase\"));\n        Assert.assertEquals(Integer.valueOf(15), taskRunningUpdate.getJSONObject(\"rawOutput\").getInteger(\"percent\"));\n        Assert.assertTrue(taskRunningUpdate.getJSONArray(\"content\").getJSONObject(0).getJSONObject(\"content\").getString(\"text\")\n                .contains(\"running 15%\"));\n\n        JSONObject taskUpdate = findSessionUpdateByToolCallId(messages, \"tool_call_update\", \"team-task:review\");\n        Assert.assertNotNull(taskUpdate);\n        Assert.assertEquals(\"completed\", taskUpdate.getString(\"status\"));\n        Assert.assertEquals(\"team-review-ready\", taskUpdate.getJSONObject(\"rawOutput\").getString(\"text\"));\n        Assert.assertEquals(\"completed\", taskUpdate.getJSONObject(\"rawOutput\").getString(\"phase\"));\n        Assert.assertEquals(Integer.valueOf(100), taskUpdate.getJSONObject(\"rawOutput\").getInteger(\"percent\"));\n\n        JSONObject teamMessage = findSessionUpdateByToolCallIdAndRawOutputType(messages, \"team-task:review\", \"team_message\");\n        Assert.assertNotNull(teamMessage);\n        Assert.assertEquals(\"tool_call_update\", teamMessage.getString(\"sessionUpdate\"));\n        Assert.assertEquals(\"task.assigned\", teamMessage.getJSONObject(\"rawOutput\").getString(\"messageType\"));\n        Assert.assertEquals(\"system\", teamMessage.getJSONObject(\"rawOutput\").getString(\"fromMemberId\"));\n        Assert.assertEquals(\"reviewer\", teamMessage.getJSONObject(\"rawOutput\").getString(\"toMemberId\"));\n        Assert.assertEquals(\"review\", teamMessage.getJSONObject(\"rawOutput\").getString(\"taskId\"));\n        Assert.assertEquals(\"Review this patch\", teamMessage.getJSONObject(\"rawOutput\").getString(\"text\"));\n        JSONArray teamMessageContent = teamMessage.getJSONArray(\"content\");\n        Assert.assertNotNull(teamMessageContent);\n        Assert.assertFalse(teamMessageContent.isEmpty());\n        Assert.assertTrue(teamMessageContent.getJSONObject(0).getJSONObject(\"content\").getString(\"text\")\n                .contains(\"[task.assigned] system -> reviewer\"));\n        Assert.assertEquals(0, countSessionUpdates(messages, \"team_message\"));\n    }\n\n    @Test\n    public void test_permission_request_round_trip() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-permission\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\", \"--approval\", \"manual\");\n\n        PipedOutputStream clientToServer = new PipedOutputStream();\n        PipedInputStream serverInput = new PipedInputStream(clientToServer);\n        PipedOutputStream serverToClient = new PipedOutputStream();\n        PipedInputStream clientInput = new PipedInputStream(serverToClient);\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        final AcpJsonRpcServer server = new AcpJsonRpcServer(\n                serverInput,\n                serverToClient,\n                err,\n                options,\n                provider()\n        );\n\n        Thread serverThread = new Thread(new Runnable() {\n            @Override\n            public void run() {\n                server.run();\n            }\n        });\n        serverThread.start();\n\n        BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8));\n        clientToServer.write(line(request(1, \"initialize\", params(\"protocolVersion\", 1))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.write(line(request(2, \"session/new\", params(\n                \"sessionId\", \"permission-session\",\n                \"cwd\", workspace.toString()\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.write(line(request(3, \"session/prompt\", params(\n                \"sessionId\", \"permission-session\",\n                \"prompt\", textPrompt(\"run bash now\")\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n\n        boolean sawPermissionRequest = false;\n        boolean sawToolCallUpdate = false;\n        boolean sawPromptResponse = false;\n        List<JSONObject> seenMessages = new ArrayList<JSONObject>();\n        for (int i = 0; i < 20; i++) {\n            String line = reader.readLine();\n            Assert.assertNotNull(line);\n            JSONObject message = JSON.parseObject(line);\n            seenMessages.add(message);\n            if (\"session/request_permission\".equals(message.getString(\"method\"))) {\n                sawPermissionRequest = true;\n                Object requestId = message.get(\"id\");\n                clientToServer.write(line(response(requestId, params(\n                        \"outcome\", params(\n                                \"outcome\", \"selected\",\n                                \"optionId\", \"allow_once\"\n                        )\n                ))).getBytes(StandardCharsets.UTF_8));\n                clientToServer.flush();\n                continue;\n            }\n            if (\"session/update\".equals(message.getString(\"method\"))) {\n                JSONObject update = message.getJSONObject(\"params\").getJSONObject(\"update\");\n                if (\"tool_call_update\".equals(update.getString(\"sessionUpdate\"))) {\n                    sawToolCallUpdate = true;\n                }\n                continue;\n            }\n            if (Integer.valueOf(3).equals(message.get(\"id\"))) {\n                sawPromptResponse = true;\n                Assert.assertEquals(\"end_turn\", message.getJSONObject(\"result\").getString(\"stopReason\"));\n                break;\n            }\n        }\n\n        clientToServer.close();\n        serverThread.join(5000L);\n\n        Assert.assertTrue(sawPermissionRequest);\n        Assert.assertTrue(sawToolCallUpdate);\n        Assert.assertTrue(sawPromptResponse);\n        Assert.assertEquals(1, countSessionUpdates(seenMessages, \"tool_call\"));\n\n        JSONObject toolCall = findSessionUpdate(seenMessages, \"tool_call\");\n        Assert.assertNotNull(toolCall);\n        Assert.assertEquals(\"other\", toolCall.getString(\"kind\"));\n        Assert.assertEquals(\"pending\", toolCall.getString(\"status\"));\n\n        JSONObject toolCallUpdate = findSessionUpdate(seenMessages, \"tool_call_update\");\n        Assert.assertNotNull(toolCallUpdate);\n        Assert.assertEquals(\"completed\", toolCallUpdate.getString(\"status\"));\n        JSONArray content = toolCallUpdate.getJSONArray(\"content\");\n        Assert.assertNotNull(content);\n        Assert.assertFalse(content.isEmpty());\n        Assert.assertEquals(\"content\", content.getJSONObject(0).getString(\"type\"));\n        Assert.assertNotNull(content.getJSONObject(0).getJSONObject(\"content\"));\n        Assert.assertTrue(content.getJSONObject(0).getJSONObject(\"content\").getString(\"text\").contains(\"cmd /c echo hi\"));\n    }\n\n    @Test\n    public void test_permission_request_uses_generated_session_id_when_session_new_omits_session_id() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-permission-generated-session\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\", \"--approval\", \"manual\");\n\n        PipedOutputStream clientToServer = new PipedOutputStream();\n        PipedInputStream serverInput = new PipedInputStream(clientToServer);\n        PipedOutputStream serverToClient = new PipedOutputStream();\n        PipedInputStream clientInput = new PipedInputStream(serverToClient);\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n\n        final AcpJsonRpcServer server = new AcpJsonRpcServer(\n                serverInput,\n                serverToClient,\n                err,\n                options,\n                provider()\n        );\n\n        Thread serverThread = new Thread(new Runnable() {\n            @Override\n            public void run() {\n                server.run();\n            }\n        });\n        serverThread.start();\n\n        BufferedReader reader = new BufferedReader(new InputStreamReader(clientInput, StandardCharsets.UTF_8));\n        List<JSONObject> messages = new ArrayList<JSONObject>();\n\n        clientToServer.write(line(request(1, \"initialize\", params(\"protocolVersion\", 1))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.write(line(request(2, \"session/new\", params(\n                \"cwd\", workspace.toString()\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n\n        readUntilResponse(reader, messages, 1);\n        readUntilResponse(reader, messages, 2);\n        readUntilSessionUpdate(reader, messages, \"available_commands_update\");\n\n        JSONObject newSessionResult = responseResult(messages, 2);\n        Assert.assertNotNull(newSessionResult);\n        String generatedSessionId = newSessionResult.getString(\"sessionId\");\n        Assert.assertNotNull(generatedSessionId);\n        Assert.assertFalse(generatedSessionId.trim().isEmpty());\n\n        sendPrompt(clientToServer, 3, generatedSessionId, \"run bash now\");\n\n        boolean sawPermissionRequest = false;\n        boolean sawPromptResponse = false;\n        for (int i = 0; i < 30; i++) {\n            String messageLine = reader.readLine();\n            Assert.assertNotNull(messageLine);\n            JSONObject message = JSON.parseObject(messageLine);\n            messages.add(message);\n            if (\"session/request_permission\".equals(message.getString(\"method\"))) {\n                sawPermissionRequest = true;\n                JSONObject permissionParams = message.getJSONObject(\"params\");\n                Assert.assertNotNull(permissionParams);\n                Assert.assertEquals(generatedSessionId, permissionParams.getString(\"sessionId\"));\n                Object requestId = message.get(\"id\");\n                clientToServer.write(line(response(requestId, params(\n                        \"outcome\", params(\n                                \"outcome\", \"selected\",\n                                \"optionId\", \"allow_once\"\n                        )\n                ))).getBytes(StandardCharsets.UTF_8));\n                clientToServer.flush();\n                continue;\n            }\n            if (Integer.valueOf(3).equals(message.get(\"id\"))) {\n                sawPromptResponse = true;\n                Assert.assertEquals(\"end_turn\", message.getJSONObject(\"result\").getString(\"stopReason\"));\n                break;\n            }\n        }\n\n        clientToServer.close();\n        serverThread.join(5000L);\n\n        Assert.assertTrue(sawPermissionRequest);\n        Assert.assertTrue(sawPromptResponse);\n        Assert.assertEquals(1, countSessionUpdates(messages, \"tool_call\"));\n    }\n\n    @Test\n    public void test_markdown_newlines_are_preserved_in_agent_chunks() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-markdown\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String promptText = \"markdown demo\";\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/new\", params(\n                \"sessionId\", \"markdown-session\",\n                \"cwd\", workspace.toString()\n        )))\n                + line(request(3, \"session/prompt\", params(\n                \"sessionId\", \"markdown-session\",\n                \"prompt\", textPrompt(promptText)\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                provider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        JSONObject update = findSessionUpdate(messages, \"agent_message_chunk\");\n        Assert.assertNotNull(update);\n        Assert.assertEquals(\"# Title\\n\\n- item\", update.getJSONObject(\"content\").getString(\"text\"));\n    }\n\n    @Test\n    public void test_markdown_char_stream_is_forwarded_without_coalescing() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-markdown-stream\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/new\", params(\n                \"sessionId\", \"markdown-stream-session\",\n                \"cwd\", workspace.toString()\n        )))\n                + line(request(3, \"session/prompt\", params(\n                \"sessionId\", \"markdown-stream-session\",\n                \"prompt\", textPrompt(\"markdown stream demo\")\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                provider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        List<String> chunks = sessionUpdateTexts(messages, \"agent_message_chunk\");\n        Assert.assertFalse(chunks.isEmpty());\n        Assert.assertEquals(Arrays.asList(\"##\", \" Heading\\n\\n\", \"1\", \". item\\n\\n\", \"Done\", \".\"), chunks);\n        Assert.assertEquals(\"## Heading\\n\\n1. item\\n\\nDone.\", join(chunks));\n    }\n\n    @Test\n    public void test_whitespace_only_stream_chunks_are_forwarded() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-whitespace-stream\");\n        CodeCommandOptions options = parseOptions(workspace, \"--model\", \"fake-model\");\n\n        ByteArrayOutputStream out = new ByteArrayOutputStream();\n        ByteArrayOutputStream err = new ByteArrayOutputStream();\n        String input = line(request(1, \"initialize\", params(\"protocolVersion\", 1)))\n                + line(request(2, \"session/new\", params(\n                \"sessionId\", \"whitespace-stream-session\",\n                \"cwd\", workspace.toString()\n        )))\n                + line(request(3, \"session/prompt\", params(\n                \"sessionId\", \"whitespace-stream-session\",\n                \"prompt\", textPrompt(\"whitespace stream demo\")\n        )));\n\n        int exitCode = new AcpJsonRpcServer(\n                new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)),\n                out,\n                err,\n                options,\n                provider()\n        ).run();\n\n        Assert.assertEquals(0, exitCode);\n\n        List<JSONObject> messages = parseLines(out);\n        List<String> chunks = sessionUpdateTexts(messages, \"agent_message_chunk\");\n        Assert.assertEquals(Arrays.asList(\"# Heading\", \"\\n\\n\", \"- item\"), chunks);\n        Assert.assertEquals(\"# Heading\\n\\n- item\", join(chunks));\n    }\n\n    private AcpJsonRpcServer.AgentFactoryProvider provider() {\n        return provider(new FakeAcpModelClient());\n    }\n\n    private AcpJsonRpcServer.AgentFactoryProvider provider(final AgentModelClient modelClient) {\n        return new AcpJsonRpcServer.AgentFactoryProvider() {\n            @Override\n            public CodingCliAgentFactory create(final CodeCommandOptions options,\n                                                final AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                                                final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) {\n                return new CodingCliAgentFactory() {\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) {\n                        return prepare(runtimeOptions, null, null, Collections.<String>emptySet());\n                    }\n\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions,\n                                                       io.github.lnyocly.ai4j.tui.TerminalIO terminal,\n                                                       io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState,\n                                                       java.util.Collection<String> pausedMcpServers) {\n                        return new PreparedCodingAgent(\n                                CodingAgents.builder()\n                                        .modelClient(modelClient)\n                                        .model(runtimeOptions.getModel())\n                                        .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build())\n                                        .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build())\n                                        .codingOptions(CodingAgentOptions.builder()\n                                                .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway))\n                                                .build())\n                                        .build(),\n                                runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(),\n                                resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig)\n                        );\n                    }\n                };\n            }\n        };\n    }\n\n    private AcpJsonRpcServer.AgentFactoryProvider blockingProvider(CountDownLatch promptStarted,\n                                                                   CountDownLatch releasePrompt) {\n        return provider(new BlockingAcpModelClient(promptStarted, releasePrompt));\n    }\n\n    private AcpJsonRpcServer.AgentFactoryProvider factoryBoundApprovalProvider() {\n        return new AcpJsonRpcServer.AgentFactoryProvider() {\n            @Override\n            public CodingCliAgentFactory create(final CodeCommandOptions options,\n                                                final AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                                                final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) {\n                final ApprovalMode capturedApprovalMode = options == null ? ApprovalMode.AUTO : options.getApprovalMode();\n                return new CodingCliAgentFactory() {\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) {\n                        return prepare(runtimeOptions, null, null, Collections.<String>emptySet());\n                    }\n\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions,\n                                                       io.github.lnyocly.ai4j.tui.TerminalIO terminal,\n                                                       io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState,\n                                                       java.util.Collection<String> pausedMcpServers) {\n                        return new PreparedCodingAgent(\n                                CodingAgents.builder()\n                                        .modelClient(new FakeAcpModelClient())\n                                        .model(runtimeOptions.getModel())\n                                        .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build())\n                                        .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build())\n                                        .codingOptions(CodingAgentOptions.builder()\n                                                .toolExecutorDecorator(new AcpToolApprovalDecorator(capturedApprovalMode, permissionGateway))\n                                                .build())\n                                        .build(),\n                                runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(),\n                                resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig)\n                        );\n                    }\n                };\n            }\n        };\n    }\n\n    private AcpJsonRpcServer.AgentFactoryProvider subagentProvider() {\n        return new AcpJsonRpcServer.AgentFactoryProvider() {\n            @Override\n            public CodingCliAgentFactory create(final CodeCommandOptions options,\n                                                final AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                                                final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) {\n                return new CodingCliAgentFactory() {\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) {\n                        return prepare(runtimeOptions, null, null, Collections.<String>emptySet());\n                    }\n\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions,\n                                                       io.github.lnyocly.ai4j.tui.TerminalIO terminal,\n                                                       io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState,\n                                                       java.util.Collection<String> pausedMcpServers) {\n                        return new PreparedCodingAgent(\n                                CodingAgents.builder()\n                                        .modelClient(new FakeSubagentRootModelClient())\n                                        .model(runtimeOptions.getModel())\n                                        .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build())\n                                        .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build())\n                                        .codingOptions(CodingAgentOptions.builder()\n                                                .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway))\n                                                .build())\n                                        .subAgent(SubAgentDefinition.builder()\n                                                .name(\"reviewer\")\n                                                .toolName(\"subagent_review\")\n                                                .description(\"Review code changes\")\n                                                .agent(Agents.react()\n                                                        .modelClient(new FakeSubagentWorkerModelClient())\n                                                        .model(runtimeOptions.getModel())\n                                                        .build())\n                                                .build())\n                                        .build(),\n                                runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(),\n                                resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig)\n                        );\n                    }\n                };\n            }\n        };\n    }\n\n    private AcpJsonRpcServer.AgentFactoryProvider teamSubagentProvider() {\n        return new AcpJsonRpcServer.AgentFactoryProvider() {\n            @Override\n            public CodingCliAgentFactory create(final CodeCommandOptions options,\n                                                final AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                                                final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) {\n                return new CodingCliAgentFactory() {\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) {\n                        return prepare(runtimeOptions, null, null, Collections.<String>emptySet());\n                    }\n\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions,\n                                                       io.github.lnyocly.ai4j.tui.TerminalIO terminal,\n                                                       io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState,\n                                                       java.util.Collection<String> pausedMcpServers) {\n                        return new PreparedCodingAgent(\n                                CodingAgents.builder()\n                                        .modelClient(new FakeSubagentRootModelClient())\n                                        .model(runtimeOptions.getModel())\n                                        .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build())\n                                        .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build())\n                                        .codingOptions(CodingAgentOptions.builder()\n                                                .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway))\n                                                .build())\n                                        .subAgent(SubAgentDefinition.builder()\n                                                .name(\"team-reviewer\")\n                                                .toolName(\"subagent_review\")\n                                                .description(\"Review code changes with a team\")\n                                                .agent(Agents.team()\n                                                        .planner((objective, members, teamOptions) -> AgentTeamPlan.builder()\n                                                                .tasks(Arrays.asList(\n                                                                        AgentTeamTask.builder()\n                                                                                .id(\"review\")\n                                                                                .memberId(\"reviewer\")\n                                                                                .task(\"Review this patch\")\n                                                                                .build()\n                                                                ))\n                                                                .build())\n                                                        .synthesizerAgent(Agents.react()\n                                                                .modelClient(new FakeSubagentWorkerModelClient(\"team-synth-complete\"))\n                                                                .model(runtimeOptions.getModel())\n                                                                .build())\n                                                        .member(AgentTeamMember.builder()\n                                                                .id(\"reviewer\")\n                                                                .name(\"Reviewer\")\n                                                                .agent(Agents.react()\n                                                                        .modelClient(new FakeSubagentWorkerModelClient(\"team-review-ready\"))\n                                                                        .model(runtimeOptions.getModel())\n                                                                        .build())\n                                                                .build())\n                                                        .buildAgent())\n                                                .build())\n                                        .build(),\n                                runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(),\n                                resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig)\n                        );\n                    }\n                };\n            }\n        };\n    }\n\n    private AcpJsonRpcServer.AgentFactoryProvider runtimeEchoProvider() {\n        return new AcpJsonRpcServer.AgentFactoryProvider() {\n            @Override\n            public CodingCliAgentFactory create(final CodeCommandOptions options,\n                                                final AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                                                final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) {\n                return new CodingCliAgentFactory() {\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) {\n                        return prepare(runtimeOptions, null, null, Collections.<String>emptySet());\n                    }\n\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions,\n                                                       io.github.lnyocly.ai4j.tui.TerminalIO terminal,\n                                                       io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState,\n                                                       java.util.Collection<String> pausedMcpServers) {\n                        return new PreparedCodingAgent(\n                                CodingAgents.builder()\n                                        .modelClient(new RuntimeEchoModelClient(\n                                                runtimeOptions.getProvider() == null ? null : runtimeOptions.getProvider().getPlatform(),\n                                                runtimeOptions.getModel()))\n                                        .model(runtimeOptions.getModel())\n                                        .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build())\n                                        .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build())\n                                        .codingOptions(CodingAgentOptions.builder()\n                                                .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway))\n                                                .build())\n                                        .build(),\n                                runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(),\n                                resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig)\n                        );\n                    }\n                };\n            }\n        };\n    }\n\n    private AcpJsonRpcServer.AgentFactoryProvider blockingRuntimeEchoProvider(final CountDownLatch promptStarted,\n                                                                              final CountDownLatch releasePrompt) {\n        return new AcpJsonRpcServer.AgentFactoryProvider() {\n            @Override\n            public CodingCliAgentFactory create(final CodeCommandOptions options,\n                                                final AcpToolApprovalDecorator.PermissionGateway permissionGateway,\n                                                final io.github.lnyocly.ai4j.cli.mcp.CliResolvedMcpConfig resolvedMcpConfig) {\n                return new CodingCliAgentFactory() {\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions) {\n                        return prepare(runtimeOptions, null, null, Collections.<String>emptySet());\n                    }\n\n                    @Override\n                    public PreparedCodingAgent prepare(CodeCommandOptions runtimeOptions,\n                                                       io.github.lnyocly.ai4j.tui.TerminalIO terminal,\n                                                       io.github.lnyocly.ai4j.tui.TuiInteractionState interactionState,\n                                                       java.util.Collection<String> pausedMcpServers) {\n                        return new PreparedCodingAgent(\n                                CodingAgents.builder()\n                                        .modelClient(new BlockingRuntimeEchoModelClient(\n                                                runtimeOptions.getProvider() == null ? null : runtimeOptions.getProvider().getPlatform(),\n                                                runtimeOptions.getModel(),\n                                                promptStarted,\n                                                releasePrompt))\n                                        .model(runtimeOptions.getModel())\n                                        .workspaceContext(WorkspaceContext.builder().rootPath(runtimeOptions.getWorkspace()).build())\n                                        .agentOptions(AgentOptions.builder().stream(runtimeOptions.isStream()).build())\n                                        .codingOptions(CodingAgentOptions.builder()\n                                                .toolExecutorDecorator(new AcpToolApprovalDecorator(runtimeOptions.getApprovalMode(), permissionGateway))\n                                                .build())\n                                        .build(),\n                                runtimeOptions.getProtocol() == null ? CliProtocol.CHAT : runtimeOptions.getProtocol(),\n                                resolvedMcpConfig == null ? null : io.github.lnyocly.ai4j.cli.mcp.CliMcpRuntimeManager.initialize(resolvedMcpConfig)\n                        );\n                    }\n                };\n            }\n        };\n    }\n\n    private CodeCommandOptions parseOptions(Path workspace, String... args) {\n        CodeCommandOptionsParser parser = new CodeCommandOptionsParser();\n        List<String> values = new ArrayList<String>(Arrays.asList(args));\n        values.add(\"--workspace\");\n        values.add(workspace.toString());\n        return parser.parse(values, Collections.<String, String>emptyMap(), new Properties(), workspace);\n    }\n\n    private JSONObject request(Object id, String method, Map<String, Object> params) {\n        JSONObject object = new JSONObject();\n        object.put(\"jsonrpc\", \"2.0\");\n        object.put(\"id\", id);\n        object.put(\"method\", method);\n        object.put(\"params\", params);\n        return object;\n    }\n\n    private JSONObject response(Object id, Map<String, Object> result) {\n        JSONObject object = new JSONObject();\n        object.put(\"jsonrpc\", \"2.0\");\n        object.put(\"id\", id);\n        object.put(\"result\", result);\n        return object;\n    }\n\n    private JSONArray textPrompt(String text) {\n        JSONArray prompt = new JSONArray();\n        JSONObject block = new JSONObject();\n        block.put(\"type\", \"text\");\n        block.put(\"text\", text);\n        prompt.add(block);\n        return prompt;\n    }\n\n    private Map<String, Object> params(Object... values) {\n        JSONObject object = new JSONObject();\n        for (int i = 0; i + 1 < values.length; i += 2) {\n            object.put(String.valueOf(values[i]), values[i + 1]);\n        }\n        return object;\n    }\n\n    private String line(JSONObject object) {\n        return object.toJSONString() + \"\\n\";\n    }\n\n    private void sendPrompt(PipedOutputStream clientToServer,\n                            int id,\n                            String sessionId,\n                            String text) throws Exception {\n        clientToServer.write(line(request(id, \"session/prompt\", params(\n                \"sessionId\", sessionId,\n                \"prompt\", textPrompt(text)\n        ))).getBytes(StandardCharsets.UTF_8));\n        clientToServer.flush();\n    }\n\n    private void readUntilResponse(BufferedReader reader,\n                                   List<JSONObject> messages,\n                                   int responseId) throws Exception {\n        for (int i = 0; i < 100; i++) {\n            String messageLine = reader.readLine();\n            Assert.assertNotNull(messageLine);\n            if (messageLine.trim().isEmpty()) {\n                continue;\n            }\n            JSONObject message = JSON.parseObject(messageLine);\n            messages.add(message);\n            if (Integer.valueOf(responseId).equals(message.get(\"id\"))) {\n                return;\n            }\n        }\n        Assert.fail(\"Timed out waiting for ACP response id=\" + responseId);\n    }\n\n    private void readUntilSessionUpdate(BufferedReader reader,\n                                        List<JSONObject> messages,\n                                        String sessionUpdateType) throws Exception {\n        if (findSessionUpdate(messages, sessionUpdateType) != null) {\n            return;\n        }\n        for (int i = 0; i < 100; i++) {\n            String messageLine = reader.readLine();\n            Assert.assertNotNull(messageLine);\n            if (messageLine.trim().isEmpty()) {\n                continue;\n            }\n            JSONObject message = JSON.parseObject(messageLine);\n            messages.add(message);\n            if (\"session/update\".equals(message.getString(\"method\"))) {\n                JSONObject params = message.getJSONObject(\"params\");\n                JSONObject update = params == null ? null : params.getJSONObject(\"update\");\n                if (update != null && sessionUpdateType.equals(update.getString(\"sessionUpdate\"))) {\n                    return;\n                }\n            }\n        }\n        Assert.fail(\"Timed out waiting for ACP session update type=\" + sessionUpdateType);\n    }\n\n    private List<JSONObject> parseLines(ByteArrayOutputStream output) throws Exception {\n        BufferedReader reader = new BufferedReader(new InputStreamReader(\n                new ByteArrayInputStream(output.toByteArray()),\n                StandardCharsets.UTF_8\n        ));\n        List<JSONObject> messages = new ArrayList<JSONObject>();\n        String line;\n        while ((line = reader.readLine()) != null) {\n            if (!line.trim().isEmpty()) {\n                messages.add(JSON.parseObject(line));\n            }\n        }\n        return messages;\n    }\n\n    private boolean hasResponse(List<JSONObject> messages, int id) {\n        for (JSONObject message : messages) {\n            if (Integer.valueOf(id).equals(message.get(\"id\")) && message.containsKey(\"result\")) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private JSONObject responseResult(List<JSONObject> messages, int id) {\n        for (JSONObject message : messages) {\n            if (Integer.valueOf(id).equals(message.get(\"id\")) && message.containsKey(\"result\")) {\n                return message.getJSONObject(\"result\");\n            }\n        }\n        return null;\n    }\n\n    private JSONObject findError(List<JSONObject> messages, int id) {\n        for (JSONObject message : messages) {\n            if (Integer.valueOf(id).equals(message.get(\"id\")) && message.containsKey(\"error\")) {\n                return message;\n            }\n        }\n        return null;\n    }\n\n    private boolean hasSessionUpdate(List<JSONObject> messages, String updateType, String expectedText) {\n        for (JSONObject message : messages) {\n            if (!\"session/update\".equals(message.getString(\"method\"))) {\n                continue;\n            }\n            JSONObject params = message.getJSONObject(\"params\");\n            if (params == null) {\n                continue;\n            }\n            JSONObject update = params.getJSONObject(\"update\");\n            if (update == null || !updateType.equals(update.getString(\"sessionUpdate\"))) {\n                continue;\n            }\n            JSONObject content = update.getJSONObject(\"content\");\n            if (content != null && expectedText.equals(content.getString(\"text\"))) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private int countSessionUpdates(List<JSONObject> messages, String updateType) {\n        int count = 0;\n        for (JSONObject message : messages) {\n            if (!\"session/update\".equals(message.getString(\"method\"))) {\n                continue;\n            }\n            JSONObject params = message.getJSONObject(\"params\");\n            if (params == null) {\n                continue;\n            }\n            JSONObject update = params.getJSONObject(\"update\");\n            if (update != null && updateType.equals(update.getString(\"sessionUpdate\"))) {\n                count++;\n            }\n        }\n        return count;\n    }\n\n    private boolean containsAvailableCommand(JSONArray commands, String name) {\n        if (commands == null || name == null) {\n            return false;\n        }\n        for (int i = 0; i < commands.size(); i++) {\n            JSONObject command = commands.getJSONObject(i);\n            if (command != null && name.equals(command.getString(\"name\"))) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private boolean containsConfigOption(JSONArray configOptions, String id) {\n        if (configOptions == null || id == null) {\n            return false;\n        }\n        for (int i = 0; i < configOptions.size(); i++) {\n            JSONObject option = configOptions.getJSONObject(i);\n            if (option != null && id.equals(option.getString(\"id\"))) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private boolean containsConfigOptionValue(JSONArray configOptions, String id, String expectedValue) {\n        if (configOptions == null || id == null || expectedValue == null) {\n            return false;\n        }\n        for (int i = 0; i < configOptions.size(); i++) {\n            JSONObject option = configOptions.getJSONObject(i);\n            if (option != null\n                    && id.equals(option.getString(\"id\"))\n                    && expectedValue.equals(option.getString(\"currentValue\"))) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private boolean containsSessionUpdatePrefix(List<JSONObject> messages, String updateType, String prefix) {\n        if (prefix == null) {\n            return false;\n        }\n        List<String> texts = sessionUpdateTexts(messages, updateType);\n        for (String text : texts) {\n            if (text != null && text.startsWith(prefix)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private JSONObject findSessionUpdate(List<JSONObject> messages, String updateType) {\n        for (JSONObject message : messages) {\n            if (!\"session/update\".equals(message.getString(\"method\"))) {\n                continue;\n            }\n            JSONObject params = message.getJSONObject(\"params\");\n            if (params == null) {\n                continue;\n            }\n            JSONObject update = params.getJSONObject(\"update\");\n            if (update != null && updateType.equals(update.getString(\"sessionUpdate\"))) {\n                return update;\n            }\n        }\n        return null;\n    }\n\n    private JSONObject findSessionUpdateByToolCallId(List<JSONObject> messages, String updateType, String toolCallId) {\n        JSONObject matched = null;\n        for (JSONObject message : messages) {\n            if (!\"session/update\".equals(message.getString(\"method\"))) {\n                continue;\n            }\n            JSONObject params = message.getJSONObject(\"params\");\n            if (params == null) {\n                continue;\n            }\n            JSONObject update = params.getJSONObject(\"update\");\n            if (update == null || !updateType.equals(update.getString(\"sessionUpdate\"))) {\n                continue;\n            }\n            if (toolCallId.equals(update.getString(\"toolCallId\"))) {\n                matched = update;\n            }\n        }\n        return matched;\n    }\n\n    private JSONObject findSessionUpdateByToolCallIdAndStatus(List<JSONObject> messages,\n                                                              String updateType,\n                                                              String toolCallId,\n                                                              String status) {\n        for (JSONObject message : messages) {\n            if (!\"session/update\".equals(message.getString(\"method\"))) {\n                continue;\n            }\n            JSONObject params = message.getJSONObject(\"params\");\n            if (params == null) {\n                continue;\n            }\n            JSONObject update = params.getJSONObject(\"update\");\n            if (update == null || !updateType.equals(update.getString(\"sessionUpdate\"))) {\n                continue;\n            }\n            if (toolCallId.equals(update.getString(\"toolCallId\")) && status.equals(update.getString(\"status\"))) {\n                return update;\n            }\n        }\n        return null;\n    }\n\n    private JSONObject findSessionUpdateByToolCallIdAndRawOutputType(List<JSONObject> messages,\n                                                                     String toolCallId,\n                                                                     String rawOutputType) {\n        for (JSONObject message : messages) {\n            if (!\"session/update\".equals(message.getString(\"method\"))) {\n                continue;\n            }\n            JSONObject params = message.getJSONObject(\"params\");\n            if (params == null) {\n                continue;\n            }\n            JSONObject update = params.getJSONObject(\"update\");\n            if (update == null || !\"tool_call_update\".equals(update.getString(\"sessionUpdate\"))) {\n                continue;\n            }\n            JSONObject rawOutput = update.getJSONObject(\"rawOutput\");\n            if (toolCallId.equals(update.getString(\"toolCallId\"))\n                    && rawOutput != null\n                    && rawOutputType.equals(rawOutput.getString(\"type\"))) {\n                return update;\n            }\n        }\n        return null;\n    }\n\n    private List<String> sessionUpdateTexts(List<JSONObject> messages, String updateType) {\n        List<String> texts = new ArrayList<String>();\n        for (JSONObject message : messages) {\n            if (!\"session/update\".equals(message.getString(\"method\"))) {\n                continue;\n            }\n            JSONObject params = message.getJSONObject(\"params\");\n            if (params == null) {\n                continue;\n            }\n            JSONObject update = params.getJSONObject(\"update\");\n            if (update == null || !updateType.equals(update.getString(\"sessionUpdate\"))) {\n                continue;\n            }\n            JSONObject content = update.getJSONObject(\"content\");\n            if (content != null) {\n                texts.add(content.getString(\"text\"));\n            }\n        }\n        return texts;\n    }\n\n    private String join(List<String> values) {\n        StringBuilder builder = new StringBuilder();\n        for (String value : values) {\n            if (value != null) {\n                builder.append(value);\n            }\n        }\n        return builder.toString();\n    }\n\n    private void seedDelegateTaskHistory(Path workspace, String sessionId) throws Exception {\n        Path sessionDirectory = workspace.resolve(\".ai4j\").resolve(\"sessions\");\n        long now = System.currentTimeMillis();\n        new FileCodingSessionStore(sessionDirectory).save(StoredCodingSession.builder()\n                .sessionId(sessionId)\n                .rootSessionId(sessionId)\n                .provider(\"openai\")\n                .protocol(\"responses\")\n                .model(\"fake-model\")\n                .workspace(workspace.toString())\n                .summary(\"delegate history\")\n                .createdAtEpochMs(now)\n                .updatedAtEpochMs(now)\n                .state(CodingSessionState.builder()\n                        .sessionId(sessionId)\n                        .workspaceRoot(workspace.toString())\n                        .build())\n                .build());\n\n        DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager(\n                new FileCodingSessionStore(sessionDirectory),\n                new FileSessionEventStore(sessionDirectory.resolve(\"events\"))\n        );\n        CodingTaskSessionEventBridge bridge = new CodingTaskSessionEventBridge(sessionManager);\n\n        CodingTask task = CodingTask.builder()\n                .taskId(\"task-1\")\n                .definitionName(\"plan\")\n                .parentSessionId(sessionId)\n                .childSessionId(\"delegate-session-1\")\n                .background(true)\n                .status(CodingTaskStatus.QUEUED)\n                .progress(CodingTaskProgress.builder()\n                        .phase(\"queued\")\n                        .message(\"Task queued for execution.\")\n                        .percent(0)\n                        .updatedAtEpochMs(now)\n                        .build())\n                .createdAtEpochMs(now)\n                .build();\n        CodingSessionLink link = CodingSessionLink.builder()\n                .linkId(\"link-1\")\n                .taskId(\"task-1\")\n                .definitionName(\"plan\")\n                .parentSessionId(sessionId)\n                .childSessionId(\"delegate-session-1\")\n                .sessionMode(CodingSessionMode.FORK)\n                .background(true)\n                .createdAtEpochMs(now)\n                .build();\n\n        bridge.onTaskCreated(task, link);\n        bridge.onTaskUpdated(task.toBuilder()\n                .status(CodingTaskStatus.COMPLETED)\n                .outputText(\"delegate plan ready\")\n                .progress(task.getProgress().toBuilder()\n                        .phase(\"completed\")\n                        .message(\"Delegated session completed.\")\n                        .percent(100)\n                        .updatedAtEpochMs(now + 1)\n                        .build())\n                .build());\n    }\n\n    private static final class FakeAcpModelClient implements AgentModelClient {\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            String toolOutput = findLastToolOutput(prompt);\n            if (toolOutput != null) {\n                return AgentModelResult.builder().outputText(\"Tool done: \" + toolOutput).build();\n            }\n            String userText = findLastUserText(prompt);\n            if (userText != null && userText.toLowerCase().contains(\"markdown demo\")) {\n                return AgentModelResult.builder().outputText(\"# Title\\n\\n- item\").build();\n            }\n            if (userText != null && userText.toLowerCase().contains(\"markdown stream demo\")) {\n                return AgentModelResult.builder().outputText(\"## Heading\\n\\n1. item\\n\\nDone.\").build();\n            }\n            if (userText != null && userText.toLowerCase().contains(\"whitespace stream demo\")) {\n                return AgentModelResult.builder().outputText(\"# Heading\\n\\n- item\").build();\n            }\n            if (userText != null && userText.toLowerCase().contains(\"run bash\")) {\n                return AgentModelResult.builder()\n                        .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                                .callId(\"bash-call\")\n                                .name(\"bash\")\n                                .arguments(\"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"cmd /c echo hi\\\"}\")\n                                .build()))\n                        .build();\n            }\n            return AgentModelResult.builder().outputText(\"Echo: \" + userText).build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                if (result.getOutputText() != null && !result.getOutputText().isEmpty()) {\n                    String userText = findLastUserText(prompt);\n                    if (userText != null && userText.toLowerCase().contains(\"markdown stream demo\")) {\n                        List<String> tinyChunks = Arrays.asList(\"##\", \" Heading\\n\\n\", \"1\", \". item\\n\\n\", \"Done\", \".\");\n                        for (String chunk : tinyChunks) {\n                            listener.onDeltaText(chunk);\n                        }\n                    } else if (userText != null && userText.toLowerCase().contains(\"whitespace stream demo\")) {\n                        for (String chunk : Arrays.asList(\"# Heading\", \"\\n\\n\", \"- item\")) {\n                            listener.onDeltaText(chunk);\n                        }\n                    } else {\n                        listener.onDeltaText(result.getOutputText());\n                    }\n                }\n                if (result.getToolCalls() != null) {\n                    for (AgentToolCall call : result.getToolCalls()) {\n                        listener.onToolCall(call);\n                    }\n                }\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private String findLastUserText(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return \"\";\n            }\n            List<Object> items = prompt.getItems();\n            for (int i = items.size() - 1; i >= 0; i--) {\n                Object item = items.get(i);\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"message\".equals(map.get(\"type\")) || !\"user\".equals(map.get(\"role\"))) {\n                    continue;\n                }\n                Object content = map.get(\"content\");\n                if (!(content instanceof List)) {\n                    continue;\n                }\n                List<?> parts = (List<?>) content;\n                for (Object part : parts) {\n                    if (!(part instanceof Map)) {\n                        continue;\n                    }\n                    Map<?, ?> partMap = (Map<?, ?>) part;\n                    if (\"input_text\".equals(partMap.get(\"type\"))) {\n                        Object text = partMap.get(\"text\");\n                        return text == null ? \"\" : String.valueOf(text);\n                    }\n                }\n            }\n            return \"\";\n        }\n\n        private String findLastToolOutput(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return null;\n            }\n            List<Object> items = prompt.getItems();\n            for (int i = items.size() - 1; i >= 0; i--) {\n                Object item = items.get(i);\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"function_call_output\".equals(map.get(\"type\"))) {\n                    continue;\n                }\n                Object output = map.get(\"output\");\n                if (output != null) {\n                    return String.valueOf(output);\n                }\n            }\n            return null;\n        }\n    }\n\n    private static final class RuntimeEchoModelClient implements AgentModelClient {\n\n        private final String provider;\n        private final String model;\n\n        private RuntimeEchoModelClient(String provider, String model) {\n            this.provider = provider;\n            this.model = model;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return AgentModelResult.builder()\n                    .outputText(\"Echo[\" + provider + \"/\" + model + \"]: \" + findLastUserText(prompt))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private String findLastUserText(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return \"\";\n            }\n            List<Object> items = prompt.getItems();\n            for (int i = items.size() - 1; i >= 0; i--) {\n                Object item = items.get(i);\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"message\".equals(map.get(\"type\")) || !\"user\".equals(map.get(\"role\"))) {\n                    continue;\n                }\n                Object content = map.get(\"content\");\n                if (!(content instanceof List)) {\n                    continue;\n                }\n                List<?> parts = (List<?>) content;\n                for (Object part : parts) {\n                    if (!(part instanceof Map)) {\n                        continue;\n                    }\n                    Map<?, ?> partMap = (Map<?, ?>) part;\n                    if (\"input_text\".equals(partMap.get(\"type\"))) {\n                        Object text = partMap.get(\"text\");\n                        return text == null ? \"\" : String.valueOf(text);\n                    }\n                }\n            }\n            return \"\";\n        }\n    }\n\n    private static final class BlockingRuntimeEchoModelClient implements AgentModelClient {\n\n        private final String provider;\n        private final String model;\n        private final CountDownLatch promptStarted;\n        private final CountDownLatch releasePrompt;\n\n        private BlockingRuntimeEchoModelClient(String provider,\n                                               String model,\n                                               CountDownLatch promptStarted,\n                                               CountDownLatch releasePrompt) {\n            this.provider = provider;\n            this.model = model;\n            this.promptStarted = promptStarted;\n            this.releasePrompt = releasePrompt;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return AgentModelResult.builder()\n                    .outputText(\"Echo[\" + provider + \"/\" + model + \"]: \" + findLastUserText(prompt))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            String userText = findLastUserText(prompt);\n            if (userText != null && userText.toLowerCase().contains(\"hold config switch\")) {\n                promptStarted.countDown();\n                awaitRelease(releasePrompt);\n            }\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n    }\n\n    private static final class BlockingAcpModelClient implements AgentModelClient {\n\n        private final FakeAcpModelClient delegate = new FakeAcpModelClient();\n        private final CountDownLatch promptStarted;\n        private final CountDownLatch releasePrompt;\n\n        private BlockingAcpModelClient(CountDownLatch promptStarted, CountDownLatch releasePrompt) {\n            this.promptStarted = promptStarted;\n            this.releasePrompt = releasePrompt;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return delegate.create(prompt);\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            String userText = findLastUserText(prompt);\n            if (userText != null && userText.toLowerCase().contains(\"hold mode switch\")) {\n                promptStarted.countDown();\n                awaitRelease(releasePrompt);\n            }\n            return delegate.createStream(prompt, listener);\n        }\n    }\n\n    private static void awaitRelease(CountDownLatch releasePrompt) {\n        try {\n            releasePrompt.await(5, TimeUnit.SECONDS);\n        } catch (InterruptedException ex) {\n            Thread.currentThread().interrupt();\n            throw new IllegalStateException(\"Interrupted while waiting to release prompt\", ex);\n        }\n    }\n\n    private static String findLastUserText(AgentPrompt prompt) {\n        if (prompt == null || prompt.getItems() == null) {\n            return \"\";\n        }\n        List<Object> items = prompt.getItems();\n        for (int i = items.size() - 1; i >= 0; i--) {\n            Object item = items.get(i);\n            if (!(item instanceof Map)) {\n                continue;\n            }\n            Map<?, ?> map = (Map<?, ?>) item;\n            if (!\"message\".equals(map.get(\"type\")) || !\"user\".equals(map.get(\"role\"))) {\n                continue;\n            }\n            Object content = map.get(\"content\");\n            if (!(content instanceof List)) {\n                continue;\n            }\n            List<?> parts = (List<?>) content;\n            for (Object part : parts) {\n                if (!(part instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> partMap = (Map<?, ?>) part;\n                if (\"input_text\".equals(partMap.get(\"type\"))) {\n                    Object text = partMap.get(\"text\");\n                    return text == null ? \"\" : String.valueOf(text);\n                }\n            }\n        }\n        return \"\";\n    }\n\n    private static final class FakeSubagentRootModelClient implements AgentModelClient {\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            String toolOutput = findLastToolOutput(prompt);\n            if (toolOutput != null) {\n                return AgentModelResult.builder().outputText(\"Root completed after \" + toolOutput).build();\n            }\n            return AgentModelResult.builder()\n                    .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                            .callId(\"review-call\")\n                            .name(\"subagent_review\")\n                            .arguments(\"{\\\"task\\\":\\\"Review this patch\\\"}\")\n                            .build()))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                if (result.getToolCalls() != null) {\n                    for (AgentToolCall call : result.getToolCalls()) {\n                        listener.onToolCall(call);\n                    }\n                }\n                if (result.getOutputText() != null) {\n                    listener.onDeltaText(result.getOutputText());\n                }\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private String findLastToolOutput(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null) {\n                return null;\n            }\n            List<Object> items = prompt.getItems();\n            for (int i = items.size() - 1; i >= 0; i--) {\n                Object item = items.get(i);\n                if (!(item instanceof Map)) {\n                    continue;\n                }\n                Map<?, ?> map = (Map<?, ?>) item;\n                if (!\"function_call_output\".equals(map.get(\"type\"))) {\n                    continue;\n                }\n                Object output = map.get(\"output\");\n                if (output != null) {\n                    return String.valueOf(output);\n                }\n            }\n            return null;\n        }\n    }\n\n    private static final class FakeSubagentWorkerModelClient implements AgentModelClient {\n\n        private final String output;\n\n        private FakeSubagentWorkerModelClient() {\n            this(\"review-ready\");\n        }\n\n        private FakeSubagentWorkerModelClient(String output) {\n            this.output = output;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return AgentModelResult.builder().outputText(output).build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/acp/AcpSlashCommandSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.acp;\n\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberSnapshot;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMessage;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.agent.team.FileAgentTeamMessageBus;\nimport io.github.lnyocly.ai4j.agent.team.FileAgentTeamStateStore;\nimport io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.FileCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.FileSessionEventStore;\nimport io.github.lnyocly.ai4j.cli.session.StoredCodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSessionState;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class AcpSlashCommandSupportTest {\n\n    @Test\n    public void availableCommandsShouldIncludeTeam() {\n        Assert.assertTrue(containsCommand(\"team\"));\n        Assert.assertTrue(containsCommand(\"providers\"));\n        Assert.assertTrue(containsCommand(\"provider\"));\n        Assert.assertTrue(containsCommand(\"model\"));\n        Assert.assertTrue(containsCommand(\"experimental\"));\n    }\n\n    @Test\n    public void executeExperimentalShouldDelegateToRuntimeHandler() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-experimental-command\");\n        String sessionId = \"experimental-command-session\";\n        DefaultCodingSessionManager sessionManager = seedTeamHistory(workspace, sessionId);\n\n        ManagedCodingSession session = new ManagedCodingSession(\n                new CodingSession(sessionId, null, null, null, null),\n                \"openai\",\n                \"responses\",\n                \"fake-model\",\n                workspace.toString(),\n                null,\n                null,\n                null,\n                sessionId,\n                null,\n                System.currentTimeMillis(),\n                System.currentTimeMillis()\n        );\n\n        AcpSlashCommandSupport.ExecutionResult result = AcpSlashCommandSupport.execute(\n                new AcpSlashCommandSupport.Context(\n                        session,\n                        sessionManager,\n                        null,\n                        null,\n                        null,\n                        new AcpSlashCommandSupport.RuntimeCommandHandler() {\n                            @Override\n                            public String executeProviders() {\n                                return \"providers\";\n                            }\n\n                            @Override\n                            public String executeProvider(String argument) {\n                                return \"provider \" + argument;\n                            }\n\n                            @Override\n                            public String executeModel(String argument) {\n                                return \"model \" + argument;\n                            }\n\n                            @Override\n                            public String executeExperimental(String argument) {\n                                return \"experimental \" + argument;\n                            }\n                        }\n                ),\n                \"/experimental subagent off\"\n        );\n\n        Assert.assertNotNull(result);\n        Assert.assertEquals(\"experimental subagent off\", result.getOutput());\n    }\n\n    @Test\n    public void executeTeamShouldRenderMemberLaneBoard() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-team-command\");\n        String sessionId = \"team-command-session\";\n        DefaultCodingSessionManager sessionManager = seedTeamHistory(workspace, sessionId);\n\n        ManagedCodingSession session = new ManagedCodingSession(\n                new CodingSession(sessionId, null, null, null, null),\n                \"openai\",\n                \"responses\",\n                \"fake-model\",\n                workspace.toString(),\n                null,\n                null,\n                null,\n                sessionId,\n                null,\n                System.currentTimeMillis(),\n                System.currentTimeMillis()\n        );\n\n        AcpSlashCommandSupport.ExecutionResult result = AcpSlashCommandSupport.execute(\n                new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null),\n                \"/team\"\n        );\n\n        Assert.assertNotNull(result);\n        Assert.assertTrue(result.getOutput().contains(\"team board:\"));\n        Assert.assertTrue(result.getOutput().contains(\"lane Reviewer\"));\n        Assert.assertTrue(result.getOutput().contains(\"Review this patch\"));\n        Assert.assertTrue(result.getOutput().contains(\"[task.assigned] system -> reviewer\"));\n    }\n\n    @Test\n    public void executeTeamSubcommandsShouldRenderPersistedState() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-acp-team-persisted\");\n        String sessionId = \"team-persisted-session\";\n        DefaultCodingSessionManager sessionManager = seedTeamHistory(workspace, sessionId);\n        seedPersistedTeamState(workspace, \"experimental-delivery-team\");\n\n        ManagedCodingSession session = new ManagedCodingSession(\n                new CodingSession(sessionId, null, null, null, null),\n                \"openai\",\n                \"responses\",\n                \"fake-model\",\n                workspace.toString(),\n                null,\n                null,\n                null,\n                sessionId,\n                null,\n                System.currentTimeMillis(),\n                System.currentTimeMillis()\n        );\n\n        AcpSlashCommandSupport.ExecutionResult listResult = AcpSlashCommandSupport.execute(\n                new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null),\n                \"/team list\"\n        );\n        AcpSlashCommandSupport.ExecutionResult statusResult = AcpSlashCommandSupport.execute(\n                new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null),\n                \"/team status experimental-delivery-team\"\n        );\n        AcpSlashCommandSupport.ExecutionResult messageResult = AcpSlashCommandSupport.execute(\n                new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null),\n                \"/team messages experimental-delivery-team 5\"\n        );\n        AcpSlashCommandSupport.ExecutionResult resumeResult = AcpSlashCommandSupport.execute(\n                new AcpSlashCommandSupport.Context(session, sessionManager, null, null, null),\n                \"/team resume experimental-delivery-team\"\n        );\n\n        Assert.assertNotNull(listResult);\n        Assert.assertTrue(listResult.getOutput().contains(\"teams:\"));\n        Assert.assertTrue(listResult.getOutput().contains(\"experimental-delivery-team\"));\n\n        Assert.assertNotNull(statusResult);\n        Assert.assertTrue(statusResult.getOutput().contains(\"team status:\"));\n        Assert.assertTrue(statusResult.getOutput().contains(\"objective=Deliver a travel planner demo.\"));\n\n        Assert.assertNotNull(messageResult);\n        Assert.assertTrue(messageResult.getOutput().contains(\"team messages:\"));\n        Assert.assertTrue(messageResult.getOutput().contains(\"Define the backend contract first.\"));\n\n        Assert.assertNotNull(resumeResult);\n        Assert.assertTrue(resumeResult.getOutput().contains(\"team resumed: experimental-delivery-team\"));\n        Assert.assertTrue(resumeResult.getOutput().contains(\"team board:\"));\n    }\n\n    private DefaultCodingSessionManager seedTeamHistory(Path workspace, String sessionId) throws Exception {\n        Path sessionDirectory = workspace.resolve(\".ai4j\").resolve(\"sessions\");\n        long now = System.currentTimeMillis();\n        new FileCodingSessionStore(sessionDirectory).save(StoredCodingSession.builder()\n                .sessionId(sessionId)\n                .rootSessionId(sessionId)\n                .provider(\"openai\")\n                .protocol(\"responses\")\n                .model(\"fake-model\")\n                .workspace(workspace.toString())\n                .summary(\"team history\")\n                .createdAtEpochMs(now)\n                .updatedAtEpochMs(now)\n                .state(CodingSessionState.builder()\n                        .sessionId(sessionId)\n                        .workspaceRoot(workspace.toString())\n                        .build())\n                .build());\n\n        DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager(\n                new FileCodingSessionStore(sessionDirectory),\n                new FileSessionEventStore(sessionDirectory.resolve(\"events\"))\n        );\n\n        Map<String, Object> createdPayload = new HashMap<String, Object>();\n        createdPayload.put(\"taskId\", \"review\");\n        createdPayload.put(\"callId\", \"team-task:review\");\n        createdPayload.put(\"title\", \"Team task review\");\n        createdPayload.put(\"task\", \"Review this patch\");\n        createdPayload.put(\"status\", \"pending\");\n        createdPayload.put(\"phase\", \"planned\");\n        createdPayload.put(\"percent\", Integer.valueOf(0));\n        createdPayload.put(\"memberId\", \"reviewer\");\n        createdPayload.put(\"memberName\", \"Reviewer\");\n\n        Map<String, Object> updatedPayload = new HashMap<String, Object>(createdPayload);\n        updatedPayload.put(\"status\", \"running\");\n        updatedPayload.put(\"phase\", \"heartbeat\");\n        updatedPayload.put(\"percent\", Integer.valueOf(15));\n        updatedPayload.put(\"detail\", \"Heartbeat from reviewer.\");\n        updatedPayload.put(\"heartbeatCount\", Integer.valueOf(2));\n        updatedPayload.put(\"updatedAtEpochMs\", Long.valueOf(now + 1L));\n\n        Map<String, Object> messagePayload = new HashMap<String, Object>();\n        messagePayload.put(\"messageId\", \"msg-1\");\n        messagePayload.put(\"taskId\", \"review\");\n        messagePayload.put(\"fromMemberId\", \"system\");\n        messagePayload.put(\"toMemberId\", \"reviewer\");\n        messagePayload.put(\"messageType\", \"task.assigned\");\n        messagePayload.put(\"content\", \"Review this patch\");\n        messagePayload.put(\"createdAt\", Long.valueOf(now + 2L));\n\n        sessionManager.appendEvent(sessionId, SessionEvent.builder()\n                .sessionId(sessionId)\n                .type(SessionEventType.TASK_CREATED)\n                .timestamp(now)\n                .summary(\"Team task review [pending]\")\n                .payload(createdPayload)\n                .build());\n        sessionManager.appendEvent(sessionId, SessionEvent.builder()\n                .sessionId(sessionId)\n                .type(SessionEventType.TASK_UPDATED)\n                .timestamp(now + 1L)\n                .summary(\"Team task review [running]\")\n                .payload(updatedPayload)\n                .build());\n        sessionManager.appendEvent(sessionId, SessionEvent.builder()\n                .sessionId(sessionId)\n                .type(SessionEventType.TEAM_MESSAGE)\n                .timestamp(now + 2L)\n                .summary(\"Team message system -> reviewer [task.assigned]\")\n                .payload(messagePayload)\n                .build());\n        return sessionManager;\n    }\n\n    private void seedPersistedTeamState(Path workspace, String teamId) {\n        Path teamRoot = workspace.resolve(\".ai4j\").resolve(\"teams\");\n        long now = System.currentTimeMillis();\n        AgentTeamTask backendTask = AgentTeamTask.builder()\n                .id(\"backend\")\n                .memberId(\"backend\")\n                .task(\"Define travel destination API\")\n                .build();\n        AgentTeamTaskState backendState = AgentTeamTaskState.builder()\n                .taskId(\"backend\")\n                .task(backendTask)\n                .status(AgentTeamTaskStatus.IN_PROGRESS)\n                .claimedBy(\"backend\")\n                .phase(\"running\")\n                .detail(\"Drafting OpenAPI paths and payloads.\")\n                .percent(Integer.valueOf(40))\n                .heartbeatCount(2)\n                .updatedAtEpochMs(now)\n                .build();\n        AgentTeamMessage message = AgentTeamMessage.builder()\n                .id(\"msg-1\")\n                .fromMemberId(\"architect\")\n                .toMemberId(\"backend\")\n                .type(\"contract.note\")\n                .taskId(\"backend\")\n                .content(\"Define the backend contract first.\")\n                .createdAt(now)\n                .build();\n        AgentTeamState state = AgentTeamState.builder()\n                .teamId(teamId)\n                .objective(\"Deliver a travel planner demo.\")\n                .members(java.util.Arrays.asList(\n                        AgentTeamMemberSnapshot.builder().id(\"architect\").name(\"Architect\").description(\"System design\").build(),\n                        AgentTeamMemberSnapshot.builder().id(\"backend\").name(\"Backend\").description(\"API implementation\").build()\n                ))\n                .taskStates(java.util.Collections.singletonList(backendState))\n                .messages(java.util.Collections.singletonList(message))\n                .lastOutput(\"Architecture and backend work are in progress.\")\n                .lastRounds(2)\n                .lastRunStartedAt(now - 3000L)\n                .updatedAt(now)\n                .runActive(true)\n                .build();\n        new FileAgentTeamStateStore(teamRoot.resolve(\"state\")).save(state);\n        new FileAgentTeamMessageBus(teamRoot.resolve(\"mailbox\").resolve(teamId + \".jsonl\"))\n                .restore(java.util.Collections.singletonList(message));\n    }\n\n    private boolean containsCommand(String name) {\n        List<Map<String, Object>> commands = AcpSlashCommandSupport.availableCommands();\n        if (commands == null) {\n            return false;\n        }\n        for (Map<String, Object> command : commands) {\n            if (command != null && name.equals(command.get(\"name\"))) {\n                return true;\n            }\n        }\n        return false;\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/agent/CliCodingAgentRegistryTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.agent;\n\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingIsolationMode;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.List;\n\npublic class CliCodingAgentRegistryTest {\n\n    @Test\n    public void loadRegistryParsesWorkspaceAndConfiguredAgentDirectories() throws Exception {\n        Path workspace = Files.createTempDirectory(\"ai4j-cli-agent-workspace\");\n        Path configuredDirectory = Files.createTempDirectory(\"ai4j-cli-agent-configured\");\n        Path workspaceAgents = workspace.resolve(\".ai4j\").resolve(\"agents\");\n        Files.createDirectories(workspaceAgents);\n\n        Files.write(workspaceAgents.resolve(\"reviewer.md\"), (\n                \"---\\n\" +\n                        \"name: reviewer\\n\" +\n                        \"description: Review code changes for regressions.\\n\" +\n                        \"tools: read-only\\n\" +\n                        \"background: true\\n\" +\n                        \"---\\n\" +\n                        \"Inspect diffs and summarize correctness, risk, and missing tests.\\n\"\n        ).getBytes(StandardCharsets.UTF_8));\n\n        Files.write(configuredDirectory.resolve(\"planner.prompt\"), (\n                \"---\\n\" +\n                        \"name: planner\\n\" +\n                        \"toolName: delegate_planner_custom\\n\" +\n                        \"model: gpt-5-mini\\n\" +\n                        \"---\\n\" +\n                        \"Read the workspace and return a concrete implementation plan.\\n\"\n        ).getBytes(StandardCharsets.UTF_8));\n\n        CliCodingAgentRegistry registry = new CliCodingAgentRegistry(\n                workspace,\n                Arrays.asList(configuredDirectory.toString())\n        );\n\n        List<CodingAgentDefinition> definitions = registry.listDefinitions();\n        CodingAgentDefinition reviewer = registry.loadRegistry().getDefinition(\"reviewer\");\n        CodingAgentDefinition planner = registry.loadRegistry().getDefinition(\"delegate_planner_custom\");\n\n        Assert.assertEquals(2, definitions.size());\n        Assert.assertNotNull(reviewer);\n        Assert.assertEquals(\"delegate_reviewer\", reviewer.getToolName());\n        Assert.assertEquals(CodingToolNames.readOnlyBuiltIn(), reviewer.getAllowedToolNames());\n        Assert.assertEquals(CodingIsolationMode.READ_ONLY, reviewer.getIsolationMode());\n        Assert.assertTrue(reviewer.isBackground());\n        Assert.assertNotNull(planner);\n        Assert.assertEquals(\"planner\", planner.getName());\n        Assert.assertEquals(\"gpt-5-mini\", planner.getModel());\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/AgentHandoffSessionEventSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class AgentHandoffSessionEventSupportTest {\n\n    @Test\n    public void shouldMapHandoffEventsToTaskSessionEvents() {\n        Map<String, Object> startPayload = new LinkedHashMap<String, Object>();\n        startPayload.put(\"handoffId\", \"handoff:call-1\");\n        startPayload.put(\"tool\", \"subagent_review\");\n        startPayload.put(\"subagent\", \"review\");\n        startPayload.put(\"title\", \"Subagent review\");\n        startPayload.put(\"detail\", \"Delegating to subagent review.\");\n        startPayload.put(\"status\", \"starting\");\n        startPayload.put(\"sessionMode\", \"new_session\");\n\n        AgentEvent startEvent = AgentEvent.builder()\n                .type(AgentEventType.HANDOFF_START)\n                .step(0)\n                .message(\"Subagent review\")\n                .payload(startPayload)\n                .build();\n\n        Assert.assertTrue(AgentHandoffSessionEventSupport.supports(startEvent));\n        Assert.assertEquals(SessionEventType.TASK_CREATED, AgentHandoffSessionEventSupport.resolveSessionEventType(startEvent));\n        Assert.assertEquals(\"Subagent review [starting]\", AgentHandoffSessionEventSupport.buildSummary(startEvent));\n\n        Map<String, Object> createdPayload = AgentHandoffSessionEventSupport.buildPayload(startEvent);\n        Assert.assertEquals(\"handoff:call-1\", createdPayload.get(\"taskId\"));\n        Assert.assertEquals(\"handoff:call-1\", createdPayload.get(\"callId\"));\n        Assert.assertEquals(\"Subagent review\", createdPayload.get(\"title\"));\n        Assert.assertEquals(\"starting\", createdPayload.get(\"status\"));\n        Assert.assertEquals(\"new_session\", createdPayload.get(\"sessionMode\"));\n\n        Map<String, Object> endPayload = new LinkedHashMap<String, Object>();\n        endPayload.put(\"handoffId\", \"handoff:call-1\");\n        endPayload.put(\"tool\", \"subagent_review\");\n        endPayload.put(\"subagent\", \"review\");\n        endPayload.put(\"title\", \"Subagent review\");\n        endPayload.put(\"detail\", \"Subagent completed.\");\n        endPayload.put(\"status\", \"completed\");\n        endPayload.put(\"output\", \"review line 1\\nreview line 2\");\n        endPayload.put(\"attempts\", Integer.valueOf(1));\n        endPayload.put(\"durationMillis\", Long.valueOf(42L));\n\n        AgentEvent endEvent = AgentEvent.builder()\n                .type(AgentEventType.HANDOFF_END)\n                .step(0)\n                .message(\"Subagent review\")\n                .payload(endPayload)\n                .build();\n\n        Assert.assertEquals(SessionEventType.TASK_UPDATED, AgentHandoffSessionEventSupport.resolveSessionEventType(endEvent));\n        Map<String, Object> updatedPayload = AgentHandoffSessionEventSupport.buildPayload(endEvent);\n        Assert.assertEquals(\"completed\", updatedPayload.get(\"status\"));\n        Assert.assertEquals(\"review line 1\\nreview line 2\", updatedPayload.get(\"output\"));\n        Assert.assertEquals(Arrays.asList(\"review line 1\", \"review line 2\"), updatedPayload.get(\"previewLines\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/AgentTeamMessageSessionEventSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class AgentTeamMessageSessionEventSupportTest {\n\n    @Test\n    public void shouldMapTeamMessageEventToSessionEvent() {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"messageId\", \"msg-1\");\n        payload.put(\"fromMemberId\", \"reviewer\");\n        payload.put(\"toMemberId\", \"lead\");\n        payload.put(\"taskId\", \"review\");\n        payload.put(\"type\", \"task.result\");\n        payload.put(\"content\", \"Patch looks good.\\nNo blocking issue found.\");\n\n        AgentEvent event = AgentEvent.builder()\n                .type(AgentEventType.TEAM_MESSAGE)\n                .step(2)\n                .message(\"Patch looks good.\")\n                .payload(payload)\n                .build();\n\n        Assert.assertTrue(AgentTeamMessageSessionEventSupport.supports(event));\n        Assert.assertEquals(\"Team message reviewer -> lead [task.result]\",\n                AgentTeamMessageSessionEventSupport.buildSummary(event));\n\n        SessionEvent sessionEvent = AgentTeamMessageSessionEventSupport.toSessionEvent(\"session-1\", \"turn-1\", event);\n        Assert.assertNotNull(sessionEvent);\n        Assert.assertEquals(SessionEventType.TEAM_MESSAGE, sessionEvent.getType());\n        Assert.assertEquals(\"msg-1\", sessionEvent.getPayload().get(\"messageId\"));\n        Assert.assertEquals(\"team-task:review\", sessionEvent.getPayload().get(\"callId\"));\n        Assert.assertEquals(\"task.result\", sessionEvent.getPayload().get(\"messageType\"));\n        Assert.assertEquals(Arrays.asList(\"Patch looks good.\", \"No blocking issue found.\"),\n                sessionEvent.getPayload().get(\"previewLines\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/AgentTeamSessionEventSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentEventType;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class AgentTeamSessionEventSupportTest {\n\n    @Test\n    public void shouldMapTeamTaskEventsToTaskSessionEvents() {\n        Map<String, Object> createdPayload = new LinkedHashMap<String, Object>();\n        createdPayload.put(\"taskId\", \"team-task:collect\");\n        createdPayload.put(\"callId\", \"team-task:collect\");\n        createdPayload.put(\"title\", \"Team task collect\");\n        createdPayload.put(\"status\", \"planned\");\n        createdPayload.put(\"memberId\", \"researcher\");\n        createdPayload.put(\"memberName\", \"Researcher\");\n        createdPayload.put(\"task\", \"Collect requirements\");\n        createdPayload.put(\"dependsOn\", Arrays.asList(\"seed\"));\n        createdPayload.put(\"detail\", \"Task planned.\");\n        createdPayload.put(\"phase\", \"planned\");\n        createdPayload.put(\"percent\", Integer.valueOf(0));\n\n        AgentEvent createdEvent = AgentEvent.builder()\n                .type(AgentEventType.TEAM_TASK_CREATED)\n                .step(1)\n                .message(\"Team task collect\")\n                .payload(createdPayload)\n                .build();\n\n        Assert.assertTrue(AgentTeamSessionEventSupport.supports(createdEvent));\n        Assert.assertEquals(SessionEventType.TASK_CREATED, AgentTeamSessionEventSupport.resolveSessionEventType(createdEvent));\n        Assert.assertEquals(\"Team task collect [planned]\", AgentTeamSessionEventSupport.buildSummary(createdEvent));\n\n        Map<String, Object> createdSessionPayload = AgentTeamSessionEventSupport.buildPayload(createdEvent);\n        Assert.assertEquals(\"team-task:collect\", createdSessionPayload.get(\"taskId\"));\n        Assert.assertEquals(\"researcher\", createdSessionPayload.get(\"memberId\"));\n        Assert.assertEquals(\"Researcher\", createdSessionPayload.get(\"memberName\"));\n        Assert.assertEquals(\"Collect requirements\", createdSessionPayload.get(\"task\"));\n        Assert.assertEquals(\"planned\", createdSessionPayload.get(\"phase\"));\n        Assert.assertEquals(Integer.valueOf(0), createdSessionPayload.get(\"percent\"));\n\n        Map<String, Object> updatedPayload = new LinkedHashMap<String, Object>();\n        updatedPayload.put(\"taskId\", \"team-task:collect\");\n        updatedPayload.put(\"callId\", \"team-task:collect\");\n        updatedPayload.put(\"title\", \"Team task collect\");\n        updatedPayload.put(\"status\", \"completed\");\n        updatedPayload.put(\"phase\", \"completed\");\n        updatedPayload.put(\"percent\", Integer.valueOf(100));\n        updatedPayload.put(\"heartbeatCount\", Integer.valueOf(2));\n        updatedPayload.put(\"output\", \"Collected requirement list\\nCaptured risks\");\n        updatedPayload.put(\"durationMillis\", Long.valueOf(33L));\n\n        AgentEvent updatedEvent = AgentEvent.builder()\n                .type(AgentEventType.TEAM_TASK_UPDATED)\n                .step(1)\n                .message(\"Team task collect\")\n                .payload(updatedPayload)\n                .build();\n\n        Assert.assertEquals(SessionEventType.TASK_UPDATED, AgentTeamSessionEventSupport.resolveSessionEventType(updatedEvent));\n        Map<String, Object> updatedSessionPayload = AgentTeamSessionEventSupport.buildPayload(updatedEvent);\n        Assert.assertEquals(\"completed\", updatedSessionPayload.get(\"status\"));\n        Assert.assertEquals(\"Collected requirement list\\nCaptured risks\", updatedSessionPayload.get(\"output\"));\n        Assert.assertEquals(\"completed\", updatedSessionPayload.get(\"phase\"));\n        Assert.assertEquals(Integer.valueOf(100), updatedSessionPayload.get(\"percent\"));\n        Assert.assertEquals(Integer.valueOf(2), updatedSessionPayload.get(\"heartbeatCount\"));\n        Assert.assertEquals(Arrays.asList(\"Collected requirement list\", \"Captured risks\"), updatedSessionPayload.get(\"previewLines\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/CodingTaskSessionEventBridgeTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore;\nimport io.github.lnyocly.ai4j.coding.definition.CodingSessionMode;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLink;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskProgress;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskStatus;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.nio.file.Paths;\nimport java.util.List;\n\npublic class CodingTaskSessionEventBridgeTest {\n\n    @Test\n    public void shouldAppendCreatedAndUpdatedTaskEvents() throws Exception {\n        DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager(\n                new InMemoryCodingSessionStore(Paths.get(\"(memory-sessions)\")),\n                new InMemorySessionEventStore()\n        );\n        CodingTaskSessionEventBridge bridge = new CodingTaskSessionEventBridge(sessionManager);\n\n        CodingTask task = CodingTask.builder()\n                .taskId(\"task-1\")\n                .definitionName(\"explore\")\n                .parentSessionId(\"session-1\")\n                .childSessionId(\"child-1\")\n                .background(true)\n                .status(CodingTaskStatus.QUEUED)\n                .progress(CodingTaskProgress.builder()\n                        .phase(\"queued\")\n                        .message(\"Task queued for execution.\")\n                        .percent(0)\n                        .updatedAtEpochMs(System.currentTimeMillis())\n                        .build())\n                .createdAtEpochMs(System.currentTimeMillis())\n                .build();\n        CodingSessionLink link = CodingSessionLink.builder()\n                .linkId(\"link-1\")\n                .taskId(\"task-1\")\n                .definitionName(\"explore\")\n                .parentSessionId(\"session-1\")\n                .childSessionId(\"child-1\")\n                .sessionMode(CodingSessionMode.FORK)\n                .background(true)\n                .createdAtEpochMs(System.currentTimeMillis())\n                .build();\n\n        bridge.onTaskCreated(task, link);\n        bridge.onTaskUpdated(task.toBuilder()\n                .status(CodingTaskStatus.COMPLETED)\n                .outputText(\"delegate complete\")\n                .progress(task.getProgress().toBuilder()\n                        .phase(\"completed\")\n                        .message(\"Delegated session completed.\")\n                        .percent(100)\n                        .build())\n                .build());\n\n        List<SessionEvent> events = sessionManager.listEvents(\"session-1\", null, null);\n        Assert.assertEquals(2, events.size());\n        Assert.assertEquals(SessionEventType.TASK_CREATED, events.get(0).getType());\n        Assert.assertEquals(SessionEventType.TASK_UPDATED, events.get(1).getType());\n        Assert.assertEquals(\"task-1\", events.get(0).getPayload().get(\"taskId\"));\n        Assert.assertEquals(\"fork\", events.get(0).getPayload().get(\"sessionMode\"));\n        Assert.assertEquals(\"completed\", events.get(1).getPayload().get(\"status\"));\n        Assert.assertEquals(\"delegate complete\", events.get(1).getPayload().get(\"output\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/runtime/HeadlessCodingSessionRuntimeTest.java",
    "content": "package io.github.lnyocly.ai4j.cli.runtime;\n\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport io.github.lnyocly.ai4j.cli.ApprovalMode;\nimport io.github.lnyocly.ai4j.cli.CliProtocol;\nimport io.github.lnyocly.ai4j.cli.CliUiMode;\nimport io.github.lnyocly.ai4j.cli.command.CodeCommandOptions;\nimport io.github.lnyocly.ai4j.cli.session.DefaultCodingSessionManager;\nimport io.github.lnyocly.ai4j.cli.session.InMemoryCodingSessionStore;\nimport io.github.lnyocly.ai4j.cli.session.InMemorySessionEventStore;\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.CodingAgents;\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.loop.CodingStopReason;\nimport io.github.lnyocly.ai4j.coding.session.ManagedCodingSession;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Deque;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\n\npublic class HeadlessCodingSessionRuntimeTest {\n\n    private static final String STUB_TOOL = \"stub_tool\";\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldMapApprovalBlockToBlockedStopReasonAndAppendBlockedEvent() throws Exception {\n        DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager(\n                new InMemoryCodingSessionStore(Paths.get(\"(memory-sessions)\")),\n                new InMemorySessionEventStore()\n        );\n        CodeCommandOptions options = testOptions();\n        HeadlessCodingSessionRuntime runtime = new HeadlessCodingSessionRuntime(options, sessionManager);\n        CollectingObserver observer = new CollectingObserver();\n\n        InspectableQueueModelClient modelClient = new InspectableQueueModelClient();\n        modelClient.enqueue(toolCallResult(STUB_TOOL, \"call-1\"));\n        modelClient.enqueue(assistantResult(\"Approval required before proceeding.\"));\n\n        try (ManagedCodingSession session = managedSession(newAgent(modelClient, approvalRejectedToolExecutor()), options)) {\n            HeadlessCodingSessionRuntime.PromptResult result = runtime.runPrompt(session, \"Run the protected operation.\", null, observer);\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), null, null);\n\n            assertEquals(\"blocked\", result.getStopReason());\n            assertEquals(CodingStopReason.BLOCKED_BY_APPROVAL, result.getCodingStopReason());\n            assertTrue(hasEvent(events, SessionEventType.BLOCKED));\n            assertTrue(hasEvent(observer.sessionEvents, SessionEventType.BLOCKED));\n            assertFalse(hasEvent(events, SessionEventType.AUTO_CONTINUE));\n        }\n    }\n\n    @Test\n    public void shouldAppendAutoContinueAndAutoStopEventsForLoopedPrompt() throws Exception {\n        DefaultCodingSessionManager sessionManager = new DefaultCodingSessionManager(\n                new InMemoryCodingSessionStore(Paths.get(\"(memory-sessions)\")),\n                new InMemorySessionEventStore()\n        );\n        CodeCommandOptions options = testOptions();\n        HeadlessCodingSessionRuntime runtime = new HeadlessCodingSessionRuntime(options, sessionManager);\n        CollectingObserver observer = new CollectingObserver();\n\n        InspectableQueueModelClient modelClient = new InspectableQueueModelClient();\n        modelClient.enqueue(toolCallResult(STUB_TOOL, \"call-1\"));\n        modelClient.enqueue(assistantResult(\"Continuing with remaining work.\"));\n        modelClient.enqueue(assistantResult(\"Completed the requested change.\"));\n\n        try (ManagedCodingSession session = managedSession(newAgent(modelClient, okToolExecutor()), options)) {\n            HeadlessCodingSessionRuntime.PromptResult result = runtime.runPrompt(session, \"Implement the requested change.\", null, observer);\n            List<SessionEvent> events = sessionManager.listEvents(session.getSessionId(), null, null);\n\n            assertEquals(\"end_turn\", result.getStopReason());\n            assertEquals(CodingStopReason.COMPLETED, result.getCodingStopReason());\n            assertTrue(hasEvent(events, SessionEventType.AUTO_CONTINUE));\n            assertTrue(hasEvent(events, SessionEventType.AUTO_STOP));\n            assertTrue(hasEvent(observer.sessionEvents, SessionEventType.AUTO_CONTINUE));\n            assertTrue(hasEvent(observer.sessionEvents, SessionEventType.AUTO_STOP));\n        }\n    }\n\n    private CodeCommandOptions testOptions() {\n        return new CodeCommandOptions(\n                false,\n                CliUiMode.CLI,\n                PlatformType.OPENAI,\n                CliProtocol.CHAT,\n                \"glm-4.5-flash\",\n                null,\n                null,\n                \"(workspace)\",\n                \"JUnit workspace\",\n                null,\n                null,\n                null,\n                0,\n                null,\n                null,\n                null,\n                null,\n                false,\n                null,\n                null,\n                null,\n                null,\n                null,\n                ApprovalMode.AUTO,\n                true,\n                false,\n                false,\n                128000,\n                16384,\n                20000,\n                400,\n                false\n        );\n    }\n\n    private ManagedCodingSession managedSession(CodingAgent agent, CodeCommandOptions options) throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"headless-runtime-workspace\").toPath();\n        CodingSession session = agent.newSession();\n        long now = System.currentTimeMillis();\n        return new ManagedCodingSession(\n                session,\n                options.getProvider().getPlatform(),\n                options.getProtocol().getValue(),\n                options.getModel(),\n                workspaceRoot.toString(),\n                options.getWorkspaceDescription(),\n                options.getSystemPrompt(),\n                options.getInstructions(),\n                session.getSessionId(),\n                null,\n                now,\n                now\n        );\n    }\n\n    private CodingAgent newAgent(InspectableQueueModelClient modelClient, ToolExecutor toolExecutor) throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"headless-agent-workspace\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit headless runtime workspace\")\n                .build();\n        return CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .codingOptions(CodingAgentOptions.builder()\n                        .autoCompactEnabled(false)\n                        .autoContinueEnabled(true)\n                        .maxAutoFollowUps(2)\n                        .maxTotalTurns(6)\n                        .build())\n                .toolRegistry(singleToolRegistry(STUB_TOOL))\n                .toolExecutor(toolExecutor)\n                .build();\n    }\n\n    private AgentToolRegistry singleToolRegistry(String toolName) {\n        Tool.Function function = new Tool.Function();\n        function.setName(toolName);\n        function.setDescription(\"Stub tool for testing\");\n        return new StaticToolRegistry(Collections.<Object>singletonList(new Tool(\"function\", function)));\n    }\n\n    private ToolExecutor okToolExecutor() {\n        return new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) {\n                return \"{\\\"ok\\\":true}\";\n            }\n        };\n    }\n\n    private ToolExecutor approvalRejectedToolExecutor() {\n        return new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) {\n                return \"[approval-rejected] protected action\";\n            }\n        };\n    }\n\n    private boolean hasEvent(List<SessionEvent> events, SessionEventType type) {\n        if (events == null) {\n            return false;\n        }\n        for (SessionEvent event : events) {\n            if (event != null && event.getType() == type) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private AgentModelResult toolCallResult(String toolName, String callId) {\n        return AgentModelResult.builder()\n                .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                        .name(toolName)\n                        .arguments(\"{}\")\n                        .callId(callId)\n                        .type(\"function\")\n                        .build()))\n                .memoryItems(Collections.<Object>emptyList())\n                .build();\n    }\n\n    private AgentModelResult assistantResult(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", text)))\n                .build();\n    }\n\n    private static final class CollectingObserver extends HeadlessTurnObserver.Adapter {\n\n        private final List<SessionEvent> sessionEvents = new ArrayList<SessionEvent>();\n\n        @Override\n        public void onSessionEvent(ManagedCodingSession session, SessionEvent event) {\n            if (event != null) {\n                sessionEvents.add(event);\n            }\n        }\n    }\n\n    private static final class InspectableQueueModelClient implements AgentModelClient {\n\n        private final Deque<AgentModelResult> results = new ArrayDeque<AgentModelResult>();\n        private final List<AgentPrompt> prompts = new ArrayList<AgentPrompt>();\n\n        private void enqueue(AgentModelResult result) {\n            results.addLast(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            prompts.add(prompt == null ? null : prompt.toBuilder().build());\n            AgentModelResult result = results.removeFirst();\n            return result == null ? AgentModelResult.builder().build() : result;\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/AnsiTuiRuntimeTest.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.IOException;\n\npublic class AnsiTuiRuntimeTest {\n\n    @Test\n    public void shouldPrintFramesWithoutTrailingNewlineAndSkipDuplicateRenders() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        SequenceRenderer renderer = new SequenceRenderer(\"frame-1\", \"frame-1\", \"frame-2\");\n        AnsiTuiRuntime runtime = new AnsiTuiRuntime(terminal, renderer, false);\n\n        runtime.render(TuiScreenModel.builder().build());\n        runtime.render(TuiScreenModel.builder().build());\n        runtime.render(TuiScreenModel.builder().build());\n\n        Assert.assertEquals(0, terminal.clearCount);\n        Assert.assertEquals(2, terminal.moveHomeCount);\n        Assert.assertEquals(4, terminal.printCount);\n        Assert.assertEquals(0, terminal.printlnCount);\n        Assert.assertEquals(\"frame-1\\u001b[Jframe-2\\u001b[J\", terminal.printed.toString());\n    }\n\n    private static final class SequenceRenderer implements TuiRenderer {\n\n        private final String[] frames;\n        private int index;\n\n        private SequenceRenderer(String... frames) {\n            this.frames = frames == null ? new String[0] : frames;\n        }\n\n        @Override\n        public int getMaxEvents() {\n            return 0;\n        }\n\n        @Override\n        public String getThemeName() {\n            return \"test\";\n        }\n\n        @Override\n        public void updateTheme(TuiConfig config, TuiTheme theme) {\n        }\n\n        @Override\n        public String render(TuiScreenModel screenModel) {\n            if (frames.length == 0) {\n                return \"\";\n            }\n            int current = Math.min(index, frames.length - 1);\n            index++;\n            return frames[current];\n        }\n    }\n\n    private static final class RecordingTerminalIO implements TerminalIO {\n\n        private final StringBuilder printed = new StringBuilder();\n        private int printCount;\n        private int printlnCount;\n        private int clearCount;\n        private int moveHomeCount;\n\n        @Override\n        public String readLine(String prompt) throws IOException {\n            return null;\n        }\n\n        @Override\n        public void print(String message) {\n            printCount++;\n            printed.append(message == null ? \"\" : message);\n        }\n\n        @Override\n        public void println(String message) {\n            printlnCount++;\n            printed.append(message == null ? \"\" : message).append('\\n');\n        }\n\n        @Override\n        public void errorln(String message) {\n        }\n\n        @Override\n        public void clearScreen() {\n            clearCount++;\n        }\n\n        @Override\n        public void moveCursorHome() {\n            moveHomeCount++;\n        }\n\n        @Override\n        public boolean supportsAnsi() {\n            return true;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/AppendOnlyTuiRuntimeTest.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.IOException;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class AppendOnlyTuiRuntimeTest {\n\n    @Test\n    public void shouldNotDuplicateLiveReasoningWhenAssistantEventArrives() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n\n        runtime.render(screenModel(\n                TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.THINKING)\n                        .reasoningText(\"Inspecting the workspace\")\n                        .build()\n        ));\n\n        runtime.render(TuiScreenModel.builder()\n                .cachedEvents(Arrays.asList(SessionEvent.builder()\n                        .eventId(\"evt-1\")\n                        .type(SessionEventType.ASSISTANT_MESSAGE)\n                        .timestamp(1L)\n                        .payload(payload(\"kind\", \"reasoning\", \"output\", \"Inspecting the workspace\"))\n                        .build()))\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.COMPLETE)\n                        .build())\n                .build());\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertEquals(1, countOccurrences(output, \"Inspecting the workspace\"));\n        Assert.assertTrue(output.contains(\"Thinking: Inspecting the workspace\"));\n    }\n\n    @Test\n    public void shouldAppendMissingAssistantSuffixWithoutPrintingANewBlock() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n\n        runtime.render(screenModel(\n                TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.GENERATING)\n                        .text(\"Hello\")\n                        .build()\n        ));\n\n        runtime.render(TuiScreenModel.builder()\n                .cachedEvents(Arrays.asList(SessionEvent.builder()\n                        .eventId(\"evt-2\")\n                        .type(SessionEventType.ASSISTANT_MESSAGE)\n                        .timestamp(2L)\n                        .payload(payload(\"kind\", \"assistant\", \"output\", \"Hello world\"))\n                        .build()))\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.COMPLETE)\n                        .build())\n                .build());\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertTrue(output.contains(\"Hello world\"));\n        Assert.assertFalse(output.contains(\"Hello\\n\\n• world\"));\n    }\n\n    @Test\n    public void shouldNotRepeatLocalCommandOutputInFooterPreview() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        runtime.render(TuiScreenModel.builder()\n                .assistantOutput(\"No saved sessions found in ./.ai4j/memory-sessions\")\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.IDLE)\n                        .build())\n                .build());\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertEquals(1, countOccurrences(output, \"No saved sessions found in ./.ai4j/memory-sessions\"));\n    }\n\n    @Test\n    public void shouldKeepReasoningTextAndToolResultInTranscriptOrder() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n\n        runtime.render(screenModel(\n                TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.THINKING)\n                        .reasoningText(\"Inspecting project files\")\n                        .build()\n        ));\n\n        runtime.render(TuiScreenModel.builder()\n                .cachedEvents(Arrays.asList(\n                        SessionEvent.builder()\n                                .eventId(\"evt-r\")\n                                .type(SessionEventType.ASSISTANT_MESSAGE)\n                                .timestamp(1L)\n                                .payload(payload(\"kind\", \"reasoning\", \"output\", \"Inspecting project files\"))\n                                .build(),\n                        SessionEvent.builder()\n                                .eventId(\"evt-a\")\n                                .type(SessionEventType.ASSISTANT_MESSAGE)\n                                .timestamp(2L)\n                                .payload(payload(\"kind\", \"assistant\", \"output\", \"I will run the tests first.\"))\n                                .build(),\n                        SessionEvent.builder()\n                                .eventId(\"evt-t\")\n                                .type(SessionEventType.TOOL_RESULT)\n                                .timestamp(3L)\n                                .payload(payload(\n                                        \"tool\", \"bash\",\n                                        \"title\", \"mvn test\",\n                                        \"detail\", \"exit=0\",\n                                        \"previewLines\", Arrays.asList(\"BUILD SUCCESS\")))\n                                .build()))\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.COMPLETE)\n                        .build())\n                .build());\n\n        String output = stripAnsi(terminal.printed.toString());\n        int reasoningIndex = output.indexOf(\"Thinking: Inspecting project files\");\n        int textIndex = output.indexOf(\"I will run the tests first.\");\n        int toolIndex = output.indexOf(\"Ran mvn test\");\n        Assert.assertTrue(reasoningIndex >= 0);\n        Assert.assertTrue(textIndex > reasoningIndex);\n        Assert.assertTrue(toolIndex > textIndex);\n    }\n\n    @Test\n    public void shouldNotRedrawFooterWhenOnlyTheSameAppendOnlyStatusIsRenderedAgain() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        TuiScreenModel model = screenModel(TuiAssistantViewModel.builder()\n                .phase(TuiAssistantPhase.THINKING)\n                .phaseDetail(\"Streaming reasoning...\")\n                .updatedAtEpochMs(1000L)\n                .build());\n\n        runtime.render(model);\n        String firstRender = terminal.printed.toString();\n\n        runtime.render(model);\n\n        Assert.assertEquals(firstRender, terminal.printed.toString());\n    }\n\n    @Test\n    public void shouldRenderStableErrorFooterWithoutSpinnerPrefix() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        runtime.render(screenModel(TuiAssistantViewModel.builder()\n                .phase(TuiAssistantPhase.ERROR)\n                .phaseDetail(\"Authentication failed\")\n                .updatedAtEpochMs(2000L)\n                .build()));\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertTrue(output.contains(\"Error\"));\n        Assert.assertFalse(output.contains(\"- Error\"));\n        Assert.assertFalse(output.contains(\"/ Error\"));\n        Assert.assertFalse(output.contains(\"| Error\"));\n        Assert.assertFalse(output.contains(\"\\\\ Error\"));\n    }\n\n    @Test\n    public void shouldRenderStableThinkingFooterWithBulletPrefix() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        runtime.render(screenModel(TuiAssistantViewModel.builder()\n                .phase(TuiAssistantPhase.THINKING)\n                .phaseDetail(\"Thinking about: hello\")\n                .animationTick(2)\n                .updatedAtEpochMs(2000L)\n                .build()));\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertTrue(output.contains(\"• Thinking\"));\n        Assert.assertTrue(output.contains(\": hello\"));\n        Assert.assertTrue(containsSpinnerFrame(output));\n        Assert.assertFalse(output.contains(\"- Thinking\"));\n        Assert.assertFalse(output.contains(\"/ Thinking\"));\n        Assert.assertFalse(output.contains(\"| Thinking\"));\n        Assert.assertFalse(output.contains(\"\\\\ Thinking\"));\n        Assert.assertFalse(output.contains(\"Thinking: Thinking about: hello\"));\n    }\n\n    @Test\n    public void shouldRenderBulletHeaderHintAndReadyFooter() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        runtime.render(TuiScreenModel.builder()\n                .assistantOutput(\"Ask AI4J to inspect this repository\\nOpen the command palette with /\\nReplay recent history with Ctrl+R\")\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.IDLE)\n                        .build())\n                .build());\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertTrue(output.contains(\"• Type / for commands, Enter to send\"));\n        Assert.assertTrue(output.contains(\"• Ask AI4J to inspect this repository\"));\n        Assert.assertTrue(output.contains(\"• Open the command palette with /\"));\n        Assert.assertTrue(output.contains(\"• Replay recent history with Ctrl+R\"));\n        Assert.assertTrue(output.contains(\"• Ready\"));\n    }\n\n    @Test\n    public void shouldKeepInitialHintAsBulletLinesEvenWhenSessionEventsAlreadyExist() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        runtime.render(TuiScreenModel.builder()\n                .cachedEvents(Collections.singletonList(SessionEvent.builder()\n                        .eventId(\"evt-session-created\")\n                        .type(SessionEventType.SESSION_CREATED)\n                        .timestamp(1L)\n                        .summary(\"session created\")\n                        .build()))\n                .assistantOutput(\"Ask AI4J to inspect this repository\\nOpen the command palette with /\\nReplay recent history with Ctrl+R\")\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.IDLE)\n                        .build())\n                .build());\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertTrue(output.contains(\"• Ask AI4J to inspect this repository\"));\n        Assert.assertTrue(output.contains(\"• Open the command palette with /\"));\n        Assert.assertTrue(output.contains(\"• Replay recent history with Ctrl+R\"));\n        Assert.assertFalse(output.contains(\"  Open the command palette with /\"));\n        Assert.assertFalse(output.contains(\"  Replay recent history with Ctrl+R\"));\n    }\n\n    @Test\n    public void shouldRenderPaletteWithBulletItemsAndHeader() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        TuiInteractionState interaction = new TuiInteractionState();\n        interaction.openSlashPalette(Arrays.asList(\n                new TuiPaletteItem(\"status\", \"command\", \"/status\", \"Show current session status\", \"/status\"),\n                new TuiPaletteItem(\"session\", \"command\", \"/session\", \"Show current session metadata\", \"/session\")\n        ), \"/s\");\n\n        runtime.render(TuiScreenModel.builder()\n                .interactionState(interaction)\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.IDLE)\n                        .build())\n                .build());\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertTrue(output.contains(\"• Commands: /s\"));\n        Assert.assertTrue(output.contains(\"• /status  Show current session status\"));\n        Assert.assertTrue(output.contains(\"• /session  Show current session metadata\"));\n        Assert.assertFalse(output.contains(\"> /status\"));\n    }\n\n    @Test\n    public void shouldRenderProcessEventsAsStructuredTranscriptBlocks() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n\n        runtime.render(TuiScreenModel.builder()\n                .cachedEvents(Collections.singletonList(SessionEvent.builder()\n                        .eventId(\"proc-1\")\n                        .type(SessionEventType.PROCESS_UPDATED)\n                        .timestamp(1L)\n                        .summary(\"process updated: proc_demo (RUNNING)\")\n                        .payload(payload(\n                                \"processId\", \"proc_demo\",\n                                \"status\", \"RUNNING\",\n                                \"command\", \"python demo.py\",\n                                \"workingDirectory\", \"workspace\"\n                        ))\n                        .build()))\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.IDLE)\n                        .build())\n                .build());\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertTrue(output.contains(\"• Process: proc_demo (RUNNING)\"));\n        Assert.assertTrue(output.contains(\"└ python demo.py\"));\n        Assert.assertTrue(output.contains(\"cwd workspace\"));\n        Assert.assertFalse(output.contains(\"• Process: process updated: proc_demo\"));\n    }\n\n    @Test\n    public void shouldReturnToColumnOneBeforeRedrawingFooterAfterLiveOutput() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        TuiInteractionState interaction = new TuiInteractionState();\n        interaction.replaceInputBuffer(\"status\");\n\n        runtime.render(TuiScreenModel.builder()\n                .interactionState(interaction)\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.IDLE)\n                        .build())\n                .build());\n\n        runtime.render(TuiScreenModel.builder()\n                .interactionState(interaction)\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.GENERATING)\n                        .text(\"Hello\")\n                        .build())\n                .build());\n\n        String raw = terminal.printed.toString();\n        Assert.assertTrue(raw.contains(\"Hello\\r\\u001b[2K\"));\n    }\n\n    @Test\n    public void shouldRenderWorkingSpinnerForPendingToolStatus() {\n        RecordingTerminalIO terminal = new RecordingTerminalIO();\n        AppendOnlyTuiRuntime runtime = new AppendOnlyTuiRuntime(terminal);\n        runtime.enter();\n\n        runtime.render(screenModel(TuiAssistantViewModel.builder()\n                .phase(TuiAssistantPhase.WAITING_TOOL_RESULT)\n                .animationTick(4)\n                .tools(Collections.singletonList(TuiAssistantToolView.builder()\n                        .toolName(\"bash\")\n                        .title(\"mvn test\")\n                        .status(\"pending\")\n                        .build()))\n                .build()));\n\n        String output = stripAnsi(terminal.printed.toString());\n        Assert.assertTrue(output.contains(\"• Working\"));\n        Assert.assertTrue(output.contains(\"Running mvn test\"));\n        Assert.assertTrue(containsSpinnerFrame(output));\n    }\n\n    private static TuiScreenModel screenModel(TuiAssistantViewModel assistantViewModel) {\n        return TuiScreenModel.builder()\n                .assistantViewModel(assistantViewModel)\n                .build();\n    }\n\n    private static Map<String, Object> payload(Object... pairs) {\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        if (pairs == null) {\n            return payload;\n        }\n        for (int i = 0; i + 1 < pairs.length; i += 2) {\n            payload.put(String.valueOf(pairs[i]), pairs[i + 1]);\n        }\n        return payload;\n    }\n\n    private static String stripAnsi(String value) {\n        if (value == null) {\n            return \"\";\n        }\n        return value.replaceAll(\"\\\\u001B\\\\[[;\\\\d]*[ -/]*[@-~]\", \"\")\n                .replace(\"\\r\", \"\");\n    }\n\n    private static int countOccurrences(String text, String needle) {\n        if (text == null || needle == null || needle.isEmpty()) {\n            return 0;\n        }\n        int count = 0;\n        int index = 0;\n        while (true) {\n            index = text.indexOf(needle, index);\n            if (index < 0) {\n                return count;\n            }\n            count++;\n            index += needle.length();\n        }\n    }\n\n    private static boolean containsSpinnerFrame(String text) {\n        return text != null && (text.contains(\"⠋\")\n                || text.contains(\"⠙\")\n                || text.contains(\"⠹\")\n                || text.contains(\"⠸\")\n                || text.contains(\"⠼\")\n                || text.contains(\"⠴\")\n                || text.contains(\"⠦\")\n                || text.contains(\"⠧\")\n                || text.contains(\"⠇\")\n                || text.contains(\"⠏\"));\n    }\n\n    private static final class RecordingTerminalIO implements TerminalIO {\n\n        private final StringBuilder printed = new StringBuilder();\n\n        @Override\n        public String readLine(String prompt) throws IOException {\n            return null;\n        }\n\n        @Override\n        public void print(String message) {\n            printed.append(message == null ? \"\" : message);\n        }\n\n        @Override\n        public void println(String message) {\n            printed.append(message == null ? \"\" : message).append('\\n');\n        }\n\n        @Override\n        public void errorln(String message) {\n        }\n\n        @Override\n        public boolean supportsAnsi() {\n            return true;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/StreamsTerminalIOTest.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport io.github.lnyocly.ai4j.tui.io.DefaultStreamsTerminalIO;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.lang.reflect.Method;\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\n\npublic class StreamsTerminalIOTest {\n\n    @Test\n    public void shouldUseConfiguredCharsetForChineseInputAndOutput() throws Exception {\n        String original = System.getProperty(\"ai4j.terminal.encoding\");\n        System.setProperty(\"ai4j.terminal.encoding\", \"UTF-8\");\n        try {\n            ByteArrayInputStream in = new ByteArrayInputStream(\"中文输入\\n\".getBytes(StandardCharsets.UTF_8));\n            ByteArrayOutputStream out = new ByteArrayOutputStream();\n            ByteArrayOutputStream err = new ByteArrayOutputStream();\n            StreamsTerminalIO terminal = new StreamsTerminalIO(in, out, err, false);\n\n            String line = terminal.readLine(\"> \");\n            terminal.println(\"中文输出\");\n            terminal.errorln(\"错误输出\");\n\n            String stdout = new String(out.toByteArray(), StandardCharsets.UTF_8);\n            String stderr = new String(err.toByteArray(), StandardCharsets.UTF_8);\n\n            Assert.assertEquals(\"中文输入\", line);\n            Assert.assertTrue(stdout.contains(\"> \"));\n            Assert.assertTrue(stdout.contains(\"中文输出\"));\n            Assert.assertTrue(stderr.contains(\"错误输出\"));\n        } finally {\n            if (original == null) {\n                System.clearProperty(\"ai4j.terminal.encoding\");\n            } else {\n                System.setProperty(\"ai4j.terminal.encoding\", original);\n            }\n        }\n    }\n\n    @Test\n    public void shouldPreferUtf8HintsBeforeLegacyPlatformFallback() throws Exception {\n        Method resolveCharset = DefaultStreamsTerminalIO.class.getDeclaredMethod(\n                \"resolveTerminalCharset\",\n                String[].class,\n                String[].class,\n                String[].class,\n                boolean.class\n        );\n        resolveCharset.setAccessible(true);\n        Charset charset = (Charset) resolveCharset.invoke(\n                null,\n                new Object[]{\n                        new String[0],\n                        new String[0],\n                        new String[]{\"GBK\", \"GBK\"},\n                        true\n                }\n        );\n\n        Assert.assertEquals(StandardCharsets.UTF_8, charset);\n    }\n\n    @Test\n    public void shouldDrainBufferedScriptedKeyStrokesWithTimeoutReads() throws Exception {\n        ByteArrayInputStream in = new ByteArrayInputStream(new byte[]{16, 'e', 'x', 'i', 't', '\\n'});\n        StreamsTerminalIO terminal = new StreamsTerminalIO(\n                in,\n                new ByteArrayOutputStream(),\n                new ByteArrayOutputStream(),\n                false\n        );\n\n        Assert.assertEquals(TuiKeyType.CTRL_P, terminal.readKeyStroke(150L).getType());\n        Assert.assertEquals(\"e\", terminal.readKeyStroke(150L).getText());\n        Assert.assertEquals(\"x\", terminal.readKeyStroke(150L).getText());\n        Assert.assertEquals(\"i\", terminal.readKeyStroke(150L).getText());\n        Assert.assertEquals(\"t\", terminal.readKeyStroke(150L).getText());\n        Assert.assertEquals(TuiKeyType.ENTER, terminal.readKeyStroke(150L).getType());\n        Assert.assertNull(terminal.readKeyStroke(10L));\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/TuiConfigManagerTest.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class TuiConfigManagerTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldLoadBuiltInThemesAndPersistWorkspaceConfig() throws Exception {\n        Path workspace = temporaryFolder.newFolder(\"workspace\").toPath();\n        TuiConfigManager manager = new TuiConfigManager(workspace);\n\n        List<String> themeNames = manager.listThemeNames();\n        TuiTheme ocean = manager.resolveTheme(\"ocean\");\n        TuiTheme defaults = manager.resolveTheme(\"default\");\n        TuiTheme githubDark = manager.resolveTheme(\"github-dark\");\n        TuiConfig saved = manager.switchTheme(\"matrix\");\n        TuiConfig loaded = manager.load(null);\n\n        assertTrue(themeNames.contains(\"default\"));\n        assertTrue(themeNames.contains(\"ocean\"));\n        assertTrue(themeNames.contains(\"github-dark\"));\n        assertTrue(themeNames.contains(\"github-light\"));\n        assertNotNull(ocean);\n        assertEquals(\"ocean\", ocean.getName());\n        assertEquals(\"#161b22\", defaults.getCodeBackground());\n        assertEquals(\"#30363d\", defaults.getCodeBorder());\n        assertEquals(\"#c9d1d9\", defaults.getCodeText());\n        assertEquals(\"#ff7b72\", defaults.getCodeKeyword());\n        assertEquals(\"#a5d6ff\", defaults.getCodeString());\n        assertEquals(\"#8b949e\", defaults.getCodeComment());\n        assertEquals(\"#79c0ff\", defaults.getCodeNumber());\n        assertEquals(\"github-dark\", githubDark.getName());\n        assertEquals(\"#161b22\", githubDark.getCodeBackground());\n        assertEquals(\"matrix\", saved.getTheme());\n        assertEquals(\"matrix\", loaded.getTheme());\n        assertTrue(Files.exists(workspace.resolve(\".ai4j\").resolve(\"tui.json\")));\n    }\n\n    @Test\n    public void shouldPreferWorkspaceThemeOverrides() throws Exception {\n        Path workspace = temporaryFolder.newFolder(\"workspace-override\").toPath();\n        Path themesDir = workspace.resolve(\".ai4j\").resolve(\"themes\");\n        Files.createDirectories(themesDir);\n        Files.write(themesDir.resolve(\"custom.json\"),\n                (\"{\\n\"\n                        + \"  \\\"name\\\": \\\"custom\\\",\\n\"\n                        + \"  \\\"brand\\\": \\\"#112233\\\",\\n\"\n                        + \"  \\\"accent\\\": \\\"#445566\\\"\\n\"\n                        + \"}\").getBytes(StandardCharsets.UTF_8));\n\n        TuiConfigManager manager = new TuiConfigManager(workspace);\n        TuiTheme custom = manager.resolveTheme(\"custom\");\n\n        assertEquals(\"custom\", custom.getName());\n        assertEquals(\"#112233\", custom.getBrand());\n        assertEquals(\"#161b22\", custom.getCodeBackground());\n        assertEquals(\"#ff7b72\", custom.getCodeKeyword());\n        assertEquals(\"#79c0ff\", custom.getCodeNumber());\n        assertTrue(manager.listThemeNames().contains(\"custom\"));\n    }\n}\n\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/TuiInteractionStateTest.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.concurrent.atomic.AtomicInteger;\n\npublic class TuiInteractionStateTest {\n\n    @Test\n    public void shouldConsumeInputSilentlyWithoutTriggeringRender() {\n        TuiInteractionState state = new TuiInteractionState();\n        AtomicInteger renders = new AtomicInteger();\n        state.setRenderCallback(new Runnable() {\n            @Override\n            public void run() {\n                renders.incrementAndGet();\n            }\n        });\n\n        state.appendInput(\"hello\");\n        Assert.assertEquals(1, renders.get());\n\n        String value = state.consumeInputBufferSilently();\n        Assert.assertEquals(\"hello\", value);\n        Assert.assertEquals(1, renders.get());\n        Assert.assertEquals(\"\", state.getInputBuffer());\n    }\n\n    @Test\n    public void shouldSkipRenderWhenTranscriptScrollAlreadyAtZero() {\n        TuiInteractionState state = new TuiInteractionState();\n        AtomicInteger renders = new AtomicInteger();\n        state.setRenderCallback(new Runnable() {\n            @Override\n            public void run() {\n                renders.incrementAndGet();\n            }\n        });\n\n        state.resetTranscriptScroll();\n        Assert.assertEquals(0, renders.get());\n\n        state.moveTranscriptScroll(1);\n        Assert.assertEquals(1, renders.get());\n\n        state.resetTranscriptScroll();\n        Assert.assertEquals(2, renders.get());\n    }\n\n    @Test\n    public void shouldNormalizeSlashPaletteQueryWithoutLeadingSlash() {\n        TuiInteractionState state = new TuiInteractionState();\n        state.openSlashPalette(Arrays.asList(\n                new TuiPaletteItem(\"status\", \"command\", \"/status\", \"Show current session status\", \"/status\"),\n                new TuiPaletteItem(\"session\", \"command\", \"/session\", \"Show current session metadata\", \"/session\")\n        ), \"/s\");\n\n        Assert.assertEquals(\"s\", state.getPaletteQuery());\n        Assert.assertEquals(2, state.getPaletteItems().size());\n    }\n\n    @Test\n    public void shouldAppendSlashInputAndOpenPaletteInSingleRender() {\n        TuiInteractionState state = new TuiInteractionState();\n        AtomicInteger renders = new AtomicInteger();\n        state.setRenderCallback(new Runnable() {\n            @Override\n            public void run() {\n                renders.incrementAndGet();\n            }\n        });\n\n        state.appendInputAndSyncSlashPalette(\"/\", Arrays.asList(\n                new TuiPaletteItem(\"help\", \"command\", \"/help\", \"Show help\", \"/help\"),\n                new TuiPaletteItem(\"status\", \"command\", \"/status\", \"Show current session status\", \"/status\")\n        ));\n\n        Assert.assertEquals(1, renders.get());\n        Assert.assertEquals(\"/\", state.getInputBuffer());\n        Assert.assertTrue(state.isPaletteOpen());\n        Assert.assertEquals(TuiInteractionState.PaletteMode.SLASH, state.getPaletteMode());\n        Assert.assertEquals(\"\", state.getPaletteQuery());\n    }\n\n    @Test\n    public void shouldReplaceSlashSelectionAndClosePaletteInSingleRender() {\n        TuiInteractionState state = new TuiInteractionState();\n        AtomicInteger renders = new AtomicInteger();\n        state.setRenderCallback(new Runnable() {\n            @Override\n            public void run() {\n                renders.incrementAndGet();\n            }\n        });\n        state.openSlashPalette(Arrays.asList(\n                new TuiPaletteItem(\"status\", \"command\", \"/status\", \"Show current session status\", \"/status\")\n        ), \"/s\");\n        renders.set(0);\n\n        state.replaceInputBufferAndClosePalette(\"/status\");\n\n        Assert.assertEquals(1, renders.get());\n        Assert.assertEquals(\"/status\", state.getInputBuffer());\n        Assert.assertFalse(state.isPaletteOpen());\n    }\n}\n"
  },
  {
    "path": "ai4j-cli/src/test/java/io/github/lnyocly/ai4j/tui/TuiSessionViewTest.java",
    "content": "package io.github.lnyocly.ai4j.tui;\n\nimport io.github.lnyocly.ai4j.coding.CodingSessionSnapshot;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessInfo;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessStatus;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionDescriptor;\nimport io.github.lnyocly.ai4j.coding.session.SessionEvent;\nimport io.github.lnyocly.ai4j.coding.session.SessionEventType;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class TuiSessionViewTest {\n\n    @Test\n    public void shouldRenderReplayViewerOverlay() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        TuiInteractionState state = new TuiInteractionState();\n        view.setCachedReplay(Arrays.asList(\n                \"you> first\",\n                \"assistant> second\",\n                \"\",\n                \"you> third\"\n        ));\n        state.openReplayViewer();\n        state.moveReplayScroll(2);\n\n        String rendered = view.render(null, TuiRenderContext.builder().model(\"glm-4.5-flash\").build(), state);\n        Assert.assertTrue(rendered.contains(\"History\"));\n        Assert.assertTrue(rendered.contains(\"• third\"));\n        Assert.assertTrue(rendered.contains(\"↑/↓ scroll  Esc close\"));\n        Assert.assertFalse(rendered.contains(\"USER_MESSAGE\"));\n        Assert.assertFalse(rendered.contains(\"you>\"));\n        Assert.assertFalse(rendered.contains(\"assistant>\"));\n        Assert.assertTrue(rendered.contains(\"third\"));\n    }\n\n    @Test\n    public void shouldRenderTeamBoardOverlay() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        TuiInteractionState state = new TuiInteractionState();\n        java.util.List<String> boardLines = Arrays.asList(\n                \"summary tasks=2 running=1 completed=1 failed=0 blocked=0 members=2\",\n                \"\",\n                \"lane reviewer\",\n                \"  [running/heartbeat 15%] Review this patch (review)\",\n                \"    Heartbeat from reviewer.\",\n                \"    heartbeats: 2\",\n                \"  messages:\",\n                \"    - [task.assigned] system -> reviewer | task=review | Review this patch\",\n                \"\",\n                \"lane planner\",\n                \"  [completed/completed 100%] Build final summary (plan)\"\n        );\n        state.openTeamBoard();\n        state.moveTeamBoardScroll(1);\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .interactionState(state)\n                .cachedTeamBoard(boardLines)\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"Team Board\"));\n        Assert.assertTrue(rendered.contains(\"lane reviewer\"));\n        Assert.assertTrue(rendered.contains(\"running/heartbeat 15%\"));\n        Assert.assertTrue(rendered.contains(\"task.assigned\"));\n        Assert.assertTrue(rendered.contains(\"↑/↓ scroll  Esc close\"));\n    }\n\n    @Test\n    public void shouldRenderProcessInspectorOverlay() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        TuiInteractionState state = new TuiInteractionState();\n        state.openProcessInspector(\"proc_demo\");\n        state.appendProcessInput(\"npm run dev\");\n        view.setProcessInspector(\n                BashProcessInfo.builder()\n                        .processId(\"proc_demo\")\n                        .command(\"npm run dev\")\n                        .workingDirectory(\".\")\n                        .status(BashProcessStatus.RUNNING)\n                        .controlAvailable(true)\n                        .build(),\n                BashProcessLogChunk.builder()\n                        .processId(\"proc_demo\")\n                        .offset(0L)\n                        .nextOffset(20L)\n                        .content(\"[stdout] ready\\n\")\n                        .status(BashProcessStatus.RUNNING)\n                        .build()\n        );\n\n        String rendered = view.render(null, TuiRenderContext.builder().model(\"glm-4.5-flash\").build(), state);\n        Assert.assertTrue(rendered.contains(\"Process proc_demo\"));\n        Assert.assertTrue(rendered.contains(\"status running\"));\n        Assert.assertTrue(rendered.contains(\"proc_demo\"));\n        Assert.assertTrue(rendered.contains(\"stdin> npm run dev\"));\n        Assert.assertTrue(rendered.contains(\"ready\"));\n    }\n\n    @Test\n    public void shouldRenderSlashCommandOverlay() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        TuiInteractionState state = new TuiInteractionState();\n        state.replaceInputBuffer(\"/re\");\n        state.openSlashPalette(Arrays.asList(\n                new TuiPaletteItem(\"replay\", \"command\", \"/replay\", \"Replay recent turns\", \"/replay 20\"),\n                new TuiPaletteItem(\"resume\", \"command\", \"/resume\", \"Resume a saved session\", \"/resume\")\n        ), \"/re\");\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .interactionState(state)\n                .build());\n\n        int composerIndex = rendered.indexOf(\"> /re\");\n        int replayIndex = rendered.lastIndexOf(\"/replay 20\");\n\n        Assert.assertTrue(rendered.contains(\"> /replay 20\"));\n        Assert.assertTrue(rendered.contains(\"/replay 20\"));\n        Assert.assertTrue(rendered.contains(\"/resume\"));\n        Assert.assertTrue(replayIndex > composerIndex);\n    }\n\n    @Test\n    public void shouldScrollSlashCommandWindowWithSelection() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        TuiInteractionState state = new TuiInteractionState();\n        state.replaceInputBuffer(\"/\");\n        state.openSlashPalette(Arrays.asList(\n                new TuiPaletteItem(\"one\", \"command\", \"/one\", \"One\", \"/one\"),\n                new TuiPaletteItem(\"two\", \"command\", \"/two\", \"Two\", \"/two\"),\n                new TuiPaletteItem(\"three\", \"command\", \"/three\", \"Three\", \"/three\"),\n                new TuiPaletteItem(\"four\", \"command\", \"/four\", \"Four\", \"/four\"),\n                new TuiPaletteItem(\"five\", \"command\", \"/five\", \"Five\", \"/five\"),\n                new TuiPaletteItem(\"six\", \"command\", \"/six\", \"Six\", \"/six\"),\n                new TuiPaletteItem(\"seven\", \"command\", \"/seven\", \"Seven\", \"/seven\"),\n                new TuiPaletteItem(\"eight\", \"command\", \"/eight\", \"Eight\", \"/eight\"),\n                new TuiPaletteItem(\"nine\", \"command\", \"/nine\", \"Nine\", \"/nine\"),\n                new TuiPaletteItem(\"ten\", \"command\", \"/ten\", \"Ten\", \"/ten\")\n        ), \"/\");\n        for (int i = 0; i < 8; i++) {\n            state.movePaletteSelection(1);\n        }\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .interactionState(state)\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"> /nine\"));\n        Assert.assertTrue(rendered.contains(\"/ten\"));\n        Assert.assertTrue(rendered.contains(\"...\"));\n        Assert.assertFalse(rendered.contains(\"> /one\"));\n    }\n\n    @Test\n    public void shouldRenderStructuredAssistantTrace() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.WAITING_TOOL_RESULT)\n                        .step(1)\n                        .phaseDetail(\"Running command...\")\n                        .reasoningText(\"Thinking through the workspace.\")\n                        .text(\"Scanning the workspace.\\nPreparing the next action.\")\n                        .tools(Arrays.asList(\n                                TuiAssistantToolView.builder()\n                                        .toolName(\"bash\")\n                                        .status(\"done\")\n                                        .title(\"$ type sample.txt\")\n                                        .detail(\"exit=0 | cwd=workspace\")\n                                        .previewLines(Arrays.asList(\"hello-cli\", \"done\"))\n                                        .build()))\n                        .build())\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"running command...\"));\n        Assert.assertTrue(rendered.contains(\"Thinking through the workspace.\"));\n        Assert.assertTrue(rendered.contains(\"Scanning the workspace.\"));\n        Assert.assertTrue(rendered.contains(\"• Ran type sample.txt\"));\n        Assert.assertTrue(rendered.contains(\"└ hello-cli\"));\n        Assert.assertFalse(rendered.contains(\"exit=0 | cwd=workspace\"));\n    }\n\n    @Test\n    public void shouldRenderMinimalHeader() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        TuiInteractionState state = new TuiInteractionState();\n        state.openReplayViewer();\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .descriptor(CodingSessionDescriptor.builder()\n                        .sessionId(\"session-alpha\")\n                        .rootSessionId(\"session-alpha\")\n                        .provider(\"zhipu\")\n                        .protocol(\"chat\")\n                        .model(\"glm-4.5-flash\")\n                        .workspace(\"workspace\")\n                        .build())\n                .snapshot(CodingSessionSnapshot.builder()\n                        .sessionId(\"session-alpha\")\n                        .estimatedContextTokens(512)\n                        .lastCompactMode(\"manual\")\n                        .build())\n                .cachedReplay(Arrays.asList(\n                        \"you> first\",\n                        \"assistant> second\"\n                ))\n                .interactionState(state)\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"AI4J\"));\n        Assert.assertTrue(rendered.contains(\"glm-4.5-flash\"));\n        Assert.assertTrue(rendered.contains(\"workspace\"));\n        Assert.assertFalse(rendered.contains(\"focus=\"));\n        Assert.assertFalse(rendered.contains(\"overlay=\"));\n    }\n\n    @Test\n    public void shouldRenderStartupTipsWhenIdle() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"Ask AI4J to inspect this repository\"));\n        Assert.assertTrue(rendered.contains(\"Type `/` for commands\"));\n        Assert.assertTrue(rendered.contains(\"Ctrl+R\"));\n        Assert.assertFalse(rendered.contains(\"SYSTEM\"));\n    }\n\n    @Test\n    public void shouldNotDuplicateAssistantOutputWhenLatestEventMatchesLiveText() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        Map<String, Object> payload = new HashMap<String, Object>();\n        payload.put(\"output\", \"Line one\\nLine two\");\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Collections.singletonList(SessionEvent.builder()\n                        .type(SessionEventType.ASSISTANT_MESSAGE)\n                        .summary(\"Line one Line two\")\n                        .payload(payload)\n                        .build()))\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.COMPLETE)\n                        .text(\"Line one\\nLine two\")\n                        .build())\n                .build());\n\n        Assert.assertFalse(rendered.contains(\"assistant>\"));\n        Assert.assertEquals(1, countOccurrences(rendered, \"Line one\"));\n        Assert.assertTrue(rendered.contains(\"Line two\"));\n    }\n\n    @Test\n    public void shouldRenderAssistantMarkdownCodeBlocks() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.COMPLETE)\n                        .text(\"## Files\\n- src/main/java\\n> note\\nUse `rg` and **fast path**\\n```java\\nSystem.out.println(\\\"hi\\\");\\n```\")\n                        .build())\n                .build());\n\n        Assert.assertFalse(rendered.contains(\"assistant>\"));\n        Assert.assertTrue(rendered.contains(\"## Files\"));\n        Assert.assertTrue(rendered.contains(\"- src/main/java\"));\n        Assert.assertTrue(rendered.contains(\"> note\"));\n        Assert.assertTrue(rendered.contains(\"Use rg and fast path\"));\n        Assert.assertTrue(rendered.contains(\"System.out.println(\\\"hi\\\");\"));\n        Assert.assertFalse(rendered.contains(\"+-- code: java\"));\n        Assert.assertFalse(rendered.contains(\"| System.out.println(\\\"hi\\\");\"));\n    }\n\n    @Test\n    public void shouldRenderTranscriptInEventOrderWithPersistentToolEntries() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        Map<String, Object> toolCall = new HashMap<String, Object>();\n        toolCall.put(\"tool\", \"bash\");\n        toolCall.put(\"callId\", \"call-bash\");\n        toolCall.put(\"arguments\", \"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"type sample.txt\\\"}\");\n\n        Map<String, Object> toolResult = new HashMap<String, Object>();\n        toolResult.put(\"tool\", \"bash\");\n        toolResult.put(\"callId\", \"call-bash\");\n        toolResult.put(\"arguments\", \"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"type sample.txt\\\"}\");\n        toolResult.put(\"output\", \"{\\\"exitCode\\\":0,\\\"workingDirectory\\\":\\\"workspace\\\",\\\"stdout\\\":\\\"hello-cli\\\"}\");\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Arrays.asList(\n                        SessionEvent.builder()\n                                .type(SessionEventType.USER_MESSAGE)\n                                .payload(Collections.<String, Object>singletonMap(\"input\", \"inspect sample\"))\n                                .build(),\n                        SessionEvent.builder()\n                                .type(SessionEventType.ASSISTANT_MESSAGE)\n                                .payload(Collections.<String, Object>singletonMap(\"output\", \"I will inspect the file first.\"))\n                                .build(),\n                        SessionEvent.builder()\n                                .type(SessionEventType.TOOL_CALL)\n                                .payload(toolCall)\n                                .build(),\n                        SessionEvent.builder()\n                                .type(SessionEventType.TOOL_RESULT)\n                                .payload(toolResult)\n                                .build(),\n                        SessionEvent.builder()\n                                .type(SessionEventType.ASSISTANT_MESSAGE)\n                                .payload(Collections.<String, Object>singletonMap(\"output\", \"The file contains hello-cli.\"))\n                                .build()))\n                .build());\n\n        int firstAssistant = rendered.indexOf(\"I will inspect the file first.\");\n        int toolResultIndex = rendered.indexOf(\"• Ran type sample.txt\");\n        int secondAssistant = rendered.indexOf(\"The file contains hello-cli.\");\n\n        Assert.assertTrue(firstAssistant >= 0);\n        Assert.assertTrue(rendered.contains(\"• inspect sample\\n\\n• I will inspect the file first.\"));\n        Assert.assertFalse(rendered.contains(\"you> inspect sample\"));\n        Assert.assertFalse(rendered.contains(\"• Running type sample.txt\"));\n        Assert.assertTrue(toolResultIndex > firstAssistant);\n        Assert.assertTrue(secondAssistant > toolResultIndex);\n        Assert.assertTrue(rendered.contains(\"└ hello-cli\"));\n        Assert.assertFalse(rendered.contains(\"exit=0\"));\n    }\n\n    @Test\n    public void shouldPreferPersistedToolMetadataWhenArgumentsAreClipped() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        Map<String, Object> toolCall = new HashMap<String, Object>();\n        toolCall.put(\"tool\", \"bash\");\n        toolCall.put(\"callId\", \"call-python\");\n        toolCall.put(\"arguments\", \"{\\\"action\\\":\\\"exec\\\"\");\n        toolCall.put(\"title\", \"$ python -c \\\"print(1)\\\"\");\n        toolCall.put(\"detail\", \"Running command...\");\n        toolCall.put(\"previewLines\", Collections.singletonList(\"cwd> workspace\"));\n\n        Map<String, Object> toolResult = new HashMap<String, Object>();\n        toolResult.put(\"tool\", \"bash\");\n        toolResult.put(\"callId\", \"call-python\");\n        toolResult.put(\"arguments\", \"{\\\"action\\\":\\\"exec\\\"\");\n        toolResult.put(\"output\", \"{\\\"exitCode\\\":0\");\n        toolResult.put(\"title\", \"$ python -c \\\"print(1)\\\"\");\n        toolResult.put(\"detail\", \"exit=0 | cwd=workspace\");\n        toolResult.put(\"previewLines\", Collections.singletonList(\"1\"));\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Arrays.asList(\n                        SessionEvent.builder()\n                                .type(SessionEventType.TOOL_CALL)\n                                .payload(toolCall)\n                                .build(),\n                        SessionEvent.builder()\n                                .type(SessionEventType.TOOL_RESULT)\n                                .payload(toolResult)\n                                .build()))\n                .build());\n\n        Assert.assertFalse(rendered.contains(\"• Running python -c \\\"print(1)\\\"\"));\n        Assert.assertTrue(rendered.contains(\"• Ran python -c \\\"print(1)\\\"\"));\n        Assert.assertFalse(rendered.contains(\"exit=0 | cwd=workspace\"));\n        Assert.assertTrue(rendered.contains(\"└ 1\"));\n        Assert.assertFalse(rendered.contains(\"(empty command)\"));\n    }\n\n    @Test\n    public void shouldIndentMultilineToolPreviewOutput() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        Map<String, Object> toolResult = new HashMap<String, Object>();\n        toolResult.put(\"tool\", \"bash\");\n        toolResult.put(\"callId\", \"call-date\");\n        toolResult.put(\"title\", \"$ date /t && time /t\");\n        toolResult.put(\"previewLines\", Collections.singletonList(\"2026/03/21 Sat\\n21:18\"));\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Collections.singletonList(SessionEvent.builder()\n                        .type(SessionEventType.TOOL_RESULT)\n                        .payload(toolResult)\n                        .build()))\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"• Ran date /t && time /t\"));\n        Assert.assertTrue(rendered.contains(\"└ 2026/03/21 Sat\"));\n        Assert.assertTrue(rendered.contains(\"\\n    21:18\"));\n    }\n\n    @Test\n    public void shouldAllowScrollingFullTranscriptWithoutTruncatingAssistantLines() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        TuiInteractionState state = new TuiInteractionState();\n        StringBuilder assistantText = new StringBuilder();\n        for (int i = 1; i <= 30; i++) {\n            if (assistantText.length() > 0) {\n                assistantText.append('\\n');\n            }\n            assistantText.append(\"line \").append(i);\n        }\n\n        ArrayList<SessionEvent> events = new ArrayList<SessionEvent>();\n        events.add(SessionEvent.builder()\n                .type(SessionEventType.ASSISTANT_MESSAGE)\n                .payload(Collections.<String, Object>singletonMap(\"output\", assistantText.toString()))\n                .build());\n\n        String bottomRendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(events)\n                .interactionState(state)\n                .build());\n        Assert.assertTrue(bottomRendered.contains(\"line 30\"));\n        Assert.assertFalse(bottomRendered.contains(\"          ...\"));\n\n        state.moveTranscriptScroll(6);\n        String scrolledRendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(events)\n                .interactionState(state)\n                .build());\n\n        Assert.assertTrue(scrolledRendered.contains(\"line 1\"));\n        Assert.assertTrue(scrolledRendered.contains(\"line 24\"));\n        Assert.assertFalse(scrolledRendered.contains(\"line 30\"));\n        Assert.assertFalse(scrolledRendered.contains(\"assistant>\"));\n    }\n\n    @Test\n    public void shouldUseTerminalHeightToSizeTranscriptViewport() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        TuiInteractionState state = new TuiInteractionState();\n        StringBuilder assistantText = new StringBuilder();\n        for (int i = 1; i <= 30; i++) {\n            if (assistantText.length() > 0) {\n                assistantText.append('\\n');\n            }\n            assistantText.append(\"line \").append(i);\n        }\n\n        ArrayList<SessionEvent> events = new ArrayList<SessionEvent>();\n        events.add(SessionEvent.builder()\n                .type(SessionEventType.ASSISTANT_MESSAGE)\n                .payload(Collections.<String, Object>singletonMap(\"output\", assistantText.toString()))\n                .build());\n\n        TuiRenderContext context = TuiRenderContext.builder()\n                .model(\"glm-4.5-flash\")\n                .terminalRows(14)\n                .build();\n\n        String bottomRendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(events)\n                .interactionState(state)\n                .renderContext(context)\n                .build());\n\n        Assert.assertTrue(bottomRendered.contains(\"line 23\"));\n        Assert.assertTrue(bottomRendered.contains(\"line 30\"));\n        Assert.assertFalse(bottomRendered.contains(\"line 22\"));\n\n        state.moveTranscriptScroll(3);\n        String scrolledRendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(events)\n                .interactionState(state)\n                .renderContext(context)\n                .build());\n\n        Assert.assertTrue(scrolledRendered.contains(\"line 20\"));\n        Assert.assertTrue(scrolledRendered.contains(\"line 27\"));\n        Assert.assertFalse(scrolledRendered.contains(\"line 19\"));\n        Assert.assertFalse(scrolledRendered.contains(\"line 28\"));\n    }\n\n    @Test\n    public void shouldRenderLiveToolImmediatelyEvenWhenHistoryContainsOlderToolEvents() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        Map<String, Object> oldToolCall = new HashMap<String, Object>();\n        oldToolCall.put(\"tool\", \"bash\");\n        oldToolCall.put(\"callId\", \"old-call\");\n        oldToolCall.put(\"arguments\", \"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"pwd\\\"}\");\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Collections.singletonList(SessionEvent.builder()\n                        .type(SessionEventType.TOOL_CALL)\n                        .payload(oldToolCall)\n                        .build()))\n                        .assistantViewModel(TuiAssistantViewModel.builder()\n                                .phase(TuiAssistantPhase.WAITING_TOOL_RESULT)\n                                .tools(Collections.singletonList(TuiAssistantToolView.builder()\n                                        .callId(\"current-call\")\n                                        .toolName(\"bash\")\n                                        .status(\"pending\")\n                                        .title(\"$ date\")\n                                        .detail(\"Running command...\")\n                                        .build()))\n                        .build())\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"• Running date\"));\n        Assert.assertFalse(rendered.contains(\"running command `date`\"));\n    }\n\n    @Test\n    public void shouldShowInvalidBashCallWithoutEmptyCommandFallback() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        Map<String, Object> toolResult = new HashMap<String, Object>();\n        toolResult.put(\"tool\", \"bash\");\n        toolResult.put(\"callId\", \"call-bash-invalid\");\n        toolResult.put(\"arguments\", \"{\\\"action\\\":\\\"exec\\\"}\");\n        toolResult.put(\"output\", \"TOOL_ERROR: {\\\"error\\\":\\\"bash exec requires a non-empty command\\\"}\");\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Collections.singletonList(SessionEvent.builder()\n                        .type(SessionEventType.TOOL_RESULT)\n                        .payload(toolResult)\n                        .build()))\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"• Command failed bash exec\"));\n        Assert.assertTrue(rendered.contains(\"bash exec requires a non-empty command\"));\n        Assert.assertFalse(rendered.contains(\"(empty command)\"));\n    }\n\n    @Test\n    public void shouldHighlightAnsiCodeBlocks() {\n        TuiTheme theme = new TuiTheme();\n        theme.setText(\"#eeeeee\");\n        theme.setMuted(\"#778899\");\n        theme.setBrand(\"#3366ff\");\n        theme.setAccent(\"#ff9900\");\n        theme.setSuccess(\"#33cc99\");\n        theme.setPanelBorder(\"#445566\");\n        theme.setCodeBackground(\"#111827\");\n        theme.setCodeBorder(\"#445566\");\n        theme.setCodeText(\"#eeeeee\");\n        theme.setCodeKeyword(\"#3366ff\");\n        theme.setCodeString(\"#33cc99\");\n        theme.setCodeComment(\"#778899\");\n        theme.setCodeNumber(\"#ff9900\");\n\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), theme, true);\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .assistantViewModel(TuiAssistantViewModel.builder()\n                        .phase(TuiAssistantPhase.COMPLETE)\n                        .text(\"```java\\npublic class Demo { String value = \\\"hi\\\"; int count = 42; // note\\n}\\n```\")\n                        .build())\n                .build());\n\n        Assert.assertFalse(rendered.contains(\"+-- code: java\"));\n        Assert.assertTrue(rendered.contains(\"\\u001b[1;38;2;51;102;255;48;2;17;24;39mpublic\"));\n        Assert.assertTrue(rendered.contains(\"\\u001b[38;2;51;204;153;48;2;17;24;39m\\\"hi\\\"\"));\n        Assert.assertTrue(rendered.contains(\"\\u001b[38;2;255;153;0;48;2;17;24;39m42\"));\n        Assert.assertTrue(rendered.contains(\"\\u001b[38;2;119;136;153;48;2;17;24;39m// note\"));\n    }\n\n    @Test\n    public void shouldRenderCodexStyleToolLabelsWithoutMetadataNoise() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n\n        Map<String, Object> statusResult = new HashMap<String, Object>();\n        statusResult.put(\"tool\", \"bash\");\n        statusResult.put(\"callId\", \"call-status\");\n        statusResult.put(\"arguments\", \"{\\\"action\\\":\\\"status\\\",\\\"processId\\\":\\\"proc_demo\\\"}\");\n        statusResult.put(\"output\", \"{\\\"processId\\\":\\\"proc_demo\\\",\\\"status\\\":\\\"RUNNING\\\",\\\"command\\\":\\\"python demo.py\\\",\\\"workingDirectory\\\":\\\"workspace\\\"}\");\n\n        Map<String, Object> logsResult = new HashMap<String, Object>();\n        logsResult.put(\"tool\", \"bash\");\n        logsResult.put(\"callId\", \"call-logs\");\n        logsResult.put(\"arguments\", \"{\\\"action\\\":\\\"logs\\\",\\\"processId\\\":\\\"proc_demo\\\"}\");\n        logsResult.put(\"output\", \"{\\\"content\\\":\\\"line 1\\\\nline 2\\\",\\\"status\\\":\\\"RUNNING\\\"}\");\n\n        Map<String, Object> writeResult = new HashMap<String, Object>();\n        writeResult.put(\"tool\", \"bash\");\n        writeResult.put(\"callId\", \"call-write\");\n        writeResult.put(\"arguments\", \"{\\\"action\\\":\\\"write\\\",\\\"processId\\\":\\\"proc_demo\\\",\\\"input\\\":\\\"status\\\\n\\\"}\");\n        writeResult.put(\"output\", \"{\\\"bytesWritten\\\":7,\\\"process\\\":{\\\"processId\\\":\\\"proc_demo\\\",\\\"status\\\":\\\"RUNNING\\\"}}\");\n\n        Map<String, Object> patchResult = new HashMap<String, Object>();\n        patchResult.put(\"tool\", \"apply_patch\");\n        patchResult.put(\"callId\", \"call-patch\");\n        patchResult.put(\"arguments\", \"{\\\"patch\\\":\\\"*** Begin Patch\\\\n*** End Patch\\\\n\\\"}\");\n        patchResult.put(\"output\", \"{\\\"filesChanged\\\":0,\\\"operationsApplied\\\":0,\\\"changedFiles\\\":[]}\");\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Arrays.asList(\n                        SessionEvent.builder().type(SessionEventType.TOOL_RESULT).payload(statusResult).build(),\n                        SessionEvent.builder().type(SessionEventType.TOOL_RESULT).payload(logsResult).build(),\n                        SessionEvent.builder().type(SessionEventType.TOOL_RESULT).payload(writeResult).build(),\n                        SessionEvent.builder().type(SessionEventType.TOOL_RESULT).payload(patchResult).build()))\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"• Checked proc_demo\"));\n        Assert.assertTrue(rendered.contains(\"└ python demo.py\"));\n        Assert.assertTrue(rendered.contains(\"• Read logs proc_demo\"));\n        Assert.assertTrue(rendered.contains(\"└ line 1\"));\n        Assert.assertTrue(rendered.contains(\"• Wrote to proc_demo\"));\n        Assert.assertTrue(rendered.contains(\"• Applied patch\"));\n        Assert.assertFalse(rendered.contains(\"cwd>\"));\n        Assert.assertFalse(rendered.contains(\"process=\"));\n        Assert.assertFalse(rendered.contains(\"bytes=7\"));\n        Assert.assertFalse(rendered.contains(\"files=0 | ops=0\"));\n        Assert.assertFalse(rendered.contains(\"(no changed files)\"));\n    }\n\n    @Test\n    public void shouldRenderTeamMessageEventsAsNotes() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        Map<String, Object> payload = new HashMap<String, Object>();\n        payload.put(\"taskId\", \"review\");\n        payload.put(\"content\", \"Please double-check the auth diff.\");\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Collections.singletonList(SessionEvent.builder()\n                        .type(SessionEventType.TEAM_MESSAGE)\n                        .summary(\"Team message reviewer -> lead [peer.ask]\")\n                        .payload(payload)\n                        .build()))\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"Team message reviewer -> lead [peer.ask]\"));\n        Assert.assertTrue(rendered.contains(\"task: review\"));\n        Assert.assertTrue(rendered.contains(\"Please double-check the auth diff.\"));\n    }\n\n    @Test\n    public void shouldRenderTeamTaskProgressMetadata() {\n        TuiSessionView view = new TuiSessionView(new TuiConfig(), new TuiTheme(), false);\n        Map<String, Object> payload = new HashMap<String, Object>();\n        payload.put(\"title\", \"Team task review\");\n        payload.put(\"detail\", \"Heartbeat from reviewer.\");\n        payload.put(\"memberName\", \"Reviewer\");\n        payload.put(\"status\", \"running\");\n        payload.put(\"phase\", \"heartbeat\");\n        payload.put(\"percent\", Integer.valueOf(15));\n        payload.put(\"heartbeatCount\", Integer.valueOf(2));\n\n        String rendered = view.render(TuiScreenModel.builder()\n                .cachedEvents(Collections.singletonList(SessionEvent.builder()\n                        .type(SessionEventType.TASK_UPDATED)\n                        .summary(\"Team task review [running]\")\n                        .payload(payload)\n                        .build()))\n                .build());\n\n        Assert.assertTrue(rendered.contains(\"Team task review [running]\"));\n        Assert.assertTrue(rendered.contains(\"Heartbeat from reviewer.\"));\n        Assert.assertTrue(rendered.contains(\"member: Reviewer\"));\n        Assert.assertTrue(rendered.contains(\"status: running | phase: heartbeat | progress: 15%\"));\n        Assert.assertTrue(rendered.contains(\"heartbeats: 2\"));\n    }\n\n    private int countOccurrences(String text, String needle) {\n        int count = 0;\n        int index = 0;\n        while (text != null && needle != null && (index = text.indexOf(needle, index)) >= 0) {\n            count++;\n            index += needle.length();\n        }\n        return count;\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <parent>\n        <groupId>io.github.lnyo-cly</groupId>\n        <artifactId>ai4j-sdk</artifactId>\n        <version>2.3.0</version>\n    </parent>\n\n    <artifactId>ai4j-coding</artifactId>\n    <packaging>jar</packaging>\n\n    <name>ai4j-coding</name>\n    <description>ai4j Coding Agent 运行时模块，提供工作区感知工具、outer loop 与 compaction。 Coding agent runtime for workspace-aware tools, outer loops, and compaction.</description>\n\n    <licenses>\n        <license>\n            <name>The Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n        </license>\n    </licenses>\n\n    <issueManagement>\n        <system>GitHub</system>\n        <url>https://github.com/LnYo-Cly/ai4j/issues</url>\n    </issueManagement>\n    <url>https://github.com/LnYo-Cly/ai4j</url>\n\n    <developers>\n        <developer>\n            <id>LnYo-Cly</id>\n            <name>LnYo-Cly</name>\n            <email>lnyocly@gmail.com</email>\n            <url>https://github.com/LnYo-Cly/ai4j</url>\n            <timezone>+8</timezone>\n        </developer>\n    </developers>\n\n    <scm>\n        <url>https://github.com/LnYo-Cly/ai4j</url>\n        <connection>scm:git:https://github.com/LnYo-Cly/ai4j.git</connection>\n        <developerConnection>scm:git:https://github.com/LnYo-Cly/ai4j.git</developerConnection>\n    </scm>\n\n    <dependencies>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j-agent</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>com.alibaba.fastjson2</groupId>\n            <artifactId>fastjson2</artifactId>\n            <version>2.0.43</version>\n        </dependency>\n        <dependency>\n            <groupId>junit</groupId>\n            <artifactId>junit</artifactId>\n            <version>4.13.2</version>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n\n    <profiles>\n        <profile>\n            <id>release</id>\n            <build>\n                <plugins>\n                    <plugin>\n                        <groupId>org.codehaus.mojo</groupId>\n                        <artifactId>flatten-maven-plugin</artifactId>\n                        <version>${flatten-maven-plugin.version}</version>\n                        <configuration>\n                            <flattenMode>ossrh</flattenMode>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>flatten</id>\n                                <phase>process-resources</phase>\n                                <goals>\n                                    <goal>flatten</goal>\n                                </goals>\n                            </execution>\n                            <execution>\n                                <id>flatten-clean</id>\n                                <phase>clean</phase>\n                                <goals>\n                                    <goal>clean</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-source-plugin</artifactId>\n                        <version>3.3.1</version>\n                        <executions>\n                            <execution>\n                                <id>attach-sources</id>\n                                <goals>\n                                    <goal>jar-no-fork</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-javadoc-plugin</artifactId>\n                        <version>3.6.3</version>\n                        <executions>\n                            <execution>\n                                <id>attach-javadocs</id>\n                                <goals>\n                                    <goal>jar</goal>\n                                </goals>\n                                <configuration>\n                                    <doclint>none</doclint>\n                                    <failOnError>false</failOnError>\n                                    <tags>\n                                        <tag>\n                                            <name>Author</name>\n                                            <placement>a</placement>\n                                            <head>Author:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Description</name>\n                                            <placement>a</placement>\n                                            <head>Description:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Date</name>\n                                            <placement>a</placement>\n                                            <head>Date:</head>\n                                        </tag>\n                                    </tags>\n                                </configuration>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-gpg-plugin</artifactId>\n                        <version>1.6</version>\n                        <configuration>\n                            <executable>D:\\Develop\\DevelopEnv\\GnuPG\\bin\\gpg.exe</executable>\n                            <keyname>cly</keyname>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>sign-artifacts</id>\n                                <phase>verify</phase>\n                                <goals>\n                                    <goal>sign</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.sonatype.central</groupId>\n                        <artifactId>central-publishing-maven-plugin</artifactId>\n                        <version>0.4.0</version>\n                        <extensions>true</extensions>\n                        <configuration>\n                            <publishingServerId>LnYo-Cly</publishingServerId>\n                            <tokenAuth>true</tokenAuth>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n</project>\n\n\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgent.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult;\nimport io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry;\nimport io.github.lnyocly.ai4j.coding.runtime.CodingRuntime;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLink;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.util.List;\nimport java.util.UUID;\n\npublic class CodingAgent {\n\n    private final Agent delegate;\n    private final WorkspaceContext workspaceContext;\n    private final CodingAgentOptions options;\n    private final AgentToolRegistry customToolRegistry;\n    private final ToolExecutor customToolExecutor;\n    private final CodingRuntime runtime;\n    private final SubAgentRegistry subAgentRegistry;\n    private final HandoffPolicy handoffPolicy;\n\n    public CodingAgent(Agent delegate,\n                       WorkspaceContext workspaceContext,\n                       CodingAgentOptions options,\n                       AgentToolRegistry customToolRegistry,\n                       ToolExecutor customToolExecutor,\n                       CodingRuntime runtime,\n                       SubAgentRegistry subAgentRegistry,\n                       HandoffPolicy handoffPolicy) {\n        this.delegate = delegate;\n        this.workspaceContext = workspaceContext;\n        this.options = options;\n        this.customToolRegistry = customToolRegistry;\n        this.customToolExecutor = customToolExecutor;\n        this.runtime = runtime;\n        this.subAgentRegistry = subAgentRegistry;\n        this.handoffPolicy = handoffPolicy;\n    }\n\n    public CodingAgentResult run(String input) throws Exception {\n        try (CodingSession session = newSession()) {\n            return session.run(input);\n        }\n    }\n\n    public CodingAgentResult run(CodingAgentRequest request) throws Exception {\n        try (CodingSession session = newSession()) {\n            return session.run(request);\n        }\n    }\n\n    public CodingAgentResult runStream(CodingAgentRequest request, AgentListener listener) throws Exception {\n        try (CodingSession session = newSession()) {\n            return session.runStream(request, listener);\n        }\n    }\n\n    public CodingSession newSession() {\n        return newSession((String) null, null);\n    }\n\n    public CodingSession newSession(CodingSessionState state) {\n        return newSession(state == null ? null : state.getSessionId(), state);\n    }\n\n    public CodingSession newSession(String sessionId, CodingSessionState state) {\n        AgentSession rawSession = delegate.newSession();\n        SessionProcessRegistry processRegistry = new SessionProcessRegistry(workspaceContext, options);\n        CodingAgentDefinitionRegistry definitionRegistry = getDefinitionRegistry();\n        AgentToolRegistry builtInRegistry = CodingAgentBuilder.createBuiltInRegistry(options, definitionRegistry);\n        ToolExecutor builtInExecutor = CodingAgentBuilder.createBuiltInToolExecutor(\n                workspaceContext,\n                options,\n                processRegistry,\n                runtime,\n                definitionRegistry\n        );\n        AgentToolRegistry mergedBaseRegistry = CodingAgentBuilder.mergeToolRegistry(\n                builtInRegistry,\n                customToolRegistry\n        );\n        ToolExecutor mergedBaseExecutor = CodingAgentBuilder.mergeToolExecutor(\n                builtInRegistry,\n                builtInExecutor,\n                customToolRegistry,\n                customToolExecutor\n        );\n        AgentToolRegistry mergedRegistry = CodingAgentBuilder.mergeSubAgentToolRegistry(\n                mergedBaseRegistry,\n                subAgentRegistry\n        );\n        ToolExecutor mergedExecutor = CodingAgentBuilder.mergeSubAgentToolExecutor(\n                mergedBaseExecutor,\n                subAgentRegistry,\n                handoffPolicy\n        );\n        AgentSession session = new AgentSession(\n                rawSession.getRuntime(),\n                rawSession.getContext().toBuilder()\n                        .toolRegistry(mergedRegistry)\n                        .toolExecutor(mergedExecutor)\n                        .build()\n        );\n        CodingSession codingSession = new CodingSession(\n                isBlank(sessionId) ? UUID.randomUUID().toString() : sessionId,\n                session,\n                workspaceContext,\n                options,\n                processRegistry,\n                runtime\n        );\n        if (state != null) {\n            codingSession.restore(state);\n        }\n        return codingSession;\n    }\n\n    public Agent getDelegate() {\n        return delegate;\n    }\n\n    public WorkspaceContext getWorkspaceContext() {\n        return workspaceContext;\n    }\n\n    public CodingAgentOptions getOptions() {\n        return options;\n    }\n\n    public CodingRuntime getRuntime() {\n        return runtime;\n    }\n\n    public CodingAgentDefinitionRegistry getDefinitionRegistry() {\n        return runtime == null ? null : runtime.getDefinitionRegistry();\n    }\n\n    public CodingDelegateResult delegate(CodingDelegateRequest request) throws Exception {\n        try (CodingSession session = newSession()) {\n            return session.delegate(request);\n        }\n    }\n\n    public CodingTask getTask(String taskId) {\n        return runtime == null ? null : runtime.getTask(taskId);\n    }\n\n    public List<CodingTask> listTasks() {\n        return runtime == null ? java.util.Collections.<CodingTask>emptyList() : runtime.listTasks();\n    }\n\n    public List<CodingTask> listTasks(String parentSessionId) {\n        return runtime == null\n                ? java.util.Collections.<CodingTask>emptyList()\n                : runtime.listTasksByParentSessionId(parentSessionId);\n    }\n\n    public List<CodingSessionLink> listSessionLinks(String parentSessionId) {\n        return runtime == null\n                ? java.util.Collections.<CodingSessionLink>emptyList()\n                : runtime.listSessionLinks(parentSessionId);\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentBuilder.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentBuilder;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRuntime;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy;\nimport io.github.lnyocly.ai4j.agent.subagent.StaticSubAgentRegistry;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentToolExecutor;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.CompositeToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.definition.BuiltInCodingAgentDefinitions;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateToolExecutor;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateToolRegistry;\nimport io.github.lnyocly.ai4j.coding.policy.CodingToolPolicyResolver;\nimport io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry;\nimport io.github.lnyocly.ai4j.coding.prompt.CodingContextPromptAssembler;\nimport io.github.lnyocly.ai4j.coding.runtime.CodingRuntime;\nimport io.github.lnyocly.ai4j.coding.runtime.DefaultCodingRuntime;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLinkStore;\nimport io.github.lnyocly.ai4j.coding.session.InMemoryCodingSessionLinkStore;\nimport io.github.lnyocly.ai4j.coding.skill.CodingSkillDiscovery;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskManager;\nimport io.github.lnyocly.ai4j.coding.task.InMemoryCodingTaskManager;\nimport io.github.lnyocly.ai4j.coding.tool.ApplyPatchToolExecutor;\nimport io.github.lnyocly.ai4j.coding.tool.BashToolExecutor;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolRegistryFactory;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.tool.ReadFileToolExecutor;\nimport io.github.lnyocly.ai4j.coding.tool.RoutingToolExecutor;\nimport io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator;\nimport io.github.lnyocly.ai4j.coding.tool.WriteFileToolExecutor;\nimport io.github.lnyocly.ai4j.coding.workspace.LocalWorkspaceFileService;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceFileService;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class CodingAgentBuilder {\n\n    private AgentRuntime runtime;\n    private AgentModelClient modelClient;\n    private WorkspaceContext workspaceContext;\n    private AgentOptions agentOptions;\n    private CodingAgentOptions codingOptions;\n    private AgentToolRegistry toolRegistry;\n    private ToolExecutor toolExecutor;\n    private CodingAgentDefinitionRegistry definitionRegistry;\n    private SubAgentRegistry subAgentRegistry;\n    private HandoffPolicy handoffPolicy;\n    private final List<SubAgentDefinition> subAgentDefinitions = new ArrayList<SubAgentDefinition>();\n    private CodingTaskManager taskManager;\n    private CodingSessionLinkStore sessionLinkStore;\n    private CodingToolPolicyResolver toolPolicyResolver;\n    private CodingRuntime codingRuntime;\n    private String model;\n    private String instructions;\n    private String systemPrompt;\n    private Double temperature;\n    private Double topP;\n    private Integer maxOutputTokens;\n    private Object reasoning;\n    private Object toolChoice;\n    private Boolean parallelToolCalls;\n    private Boolean store;\n    private String user;\n    private Map<String, Object> extraBody;\n\n    public CodingAgentBuilder runtime(AgentRuntime runtime) {\n        this.runtime = runtime;\n        return this;\n    }\n\n    public CodingAgentBuilder modelClient(AgentModelClient modelClient) {\n        this.modelClient = modelClient;\n        return this;\n    }\n\n    public CodingAgentBuilder workspaceContext(WorkspaceContext workspaceContext) {\n        this.workspaceContext = workspaceContext;\n        return this;\n    }\n\n    public CodingAgentBuilder agentOptions(AgentOptions agentOptions) {\n        this.agentOptions = agentOptions;\n        return this;\n    }\n\n    public CodingAgentBuilder codingOptions(CodingAgentOptions codingOptions) {\n        this.codingOptions = codingOptions;\n        return this;\n    }\n\n    public CodingAgentBuilder toolRegistry(AgentToolRegistry toolRegistry) {\n        this.toolRegistry = toolRegistry;\n        return this;\n    }\n\n    public CodingAgentBuilder toolExecutor(ToolExecutor toolExecutor) {\n        this.toolExecutor = toolExecutor;\n        return this;\n    }\n\n    public CodingAgentBuilder definitionRegistry(CodingAgentDefinitionRegistry definitionRegistry) {\n        this.definitionRegistry = definitionRegistry;\n        return this;\n    }\n\n    public CodingAgentBuilder subAgentRegistry(SubAgentRegistry subAgentRegistry) {\n        this.subAgentRegistry = subAgentRegistry;\n        return this;\n    }\n\n    public CodingAgentBuilder handoffPolicy(HandoffPolicy handoffPolicy) {\n        this.handoffPolicy = handoffPolicy;\n        return this;\n    }\n\n    public CodingAgentBuilder subAgent(SubAgentDefinition definition) {\n        if (definition != null) {\n            this.subAgentDefinitions.add(definition);\n        }\n        return this;\n    }\n\n    public CodingAgentBuilder subAgents(List<SubAgentDefinition> definitions) {\n        if (definitions != null && !definitions.isEmpty()) {\n            this.subAgentDefinitions.addAll(definitions);\n        }\n        return this;\n    }\n\n    public CodingAgentBuilder taskManager(CodingTaskManager taskManager) {\n        this.taskManager = taskManager;\n        return this;\n    }\n\n    public CodingAgentBuilder sessionLinkStore(CodingSessionLinkStore sessionLinkStore) {\n        this.sessionLinkStore = sessionLinkStore;\n        return this;\n    }\n\n    public CodingAgentBuilder toolPolicyResolver(CodingToolPolicyResolver toolPolicyResolver) {\n        this.toolPolicyResolver = toolPolicyResolver;\n        return this;\n    }\n\n    public CodingAgentBuilder codingRuntime(CodingRuntime codingRuntime) {\n        this.codingRuntime = codingRuntime;\n        return this;\n    }\n\n    public CodingAgentBuilder model(String model) {\n        this.model = model;\n        return this;\n    }\n\n    public CodingAgentBuilder instructions(String instructions) {\n        this.instructions = instructions;\n        return this;\n    }\n\n    public CodingAgentBuilder systemPrompt(String systemPrompt) {\n        this.systemPrompt = systemPrompt;\n        return this;\n    }\n\n    public CodingAgentBuilder temperature(Double temperature) {\n        this.temperature = temperature;\n        return this;\n    }\n\n    public CodingAgentBuilder topP(Double topP) {\n        this.topP = topP;\n        return this;\n    }\n\n    public CodingAgentBuilder maxOutputTokens(Integer maxOutputTokens) {\n        this.maxOutputTokens = maxOutputTokens;\n        return this;\n    }\n\n    public CodingAgentBuilder reasoning(Object reasoning) {\n        this.reasoning = reasoning;\n        return this;\n    }\n\n    public CodingAgentBuilder toolChoice(Object toolChoice) {\n        this.toolChoice = toolChoice;\n        return this;\n    }\n\n    public CodingAgentBuilder parallelToolCalls(Boolean parallelToolCalls) {\n        this.parallelToolCalls = parallelToolCalls;\n        return this;\n    }\n\n    public CodingAgentBuilder store(Boolean store) {\n        this.store = store;\n        return this;\n    }\n\n    public CodingAgentBuilder user(String user) {\n        this.user = user;\n        return this;\n    }\n\n    public CodingAgentBuilder extraBody(Map<String, Object> extraBody) {\n        this.extraBody = extraBody;\n        return this;\n    }\n\n    public CodingAgent build() {\n        if (modelClient == null) {\n            throw new IllegalStateException(\"modelClient is required\");\n        }\n        if (isBlank(model)) {\n            throw new IllegalStateException(\"model is required\");\n        }\n\n        WorkspaceContext resolvedWorkspaceContext = workspaceContext == null\n                ? WorkspaceContext.builder().build()\n                : workspaceContext;\n        resolvedWorkspaceContext = CodingSkillDiscovery.enrich(resolvedWorkspaceContext);\n        CodingAgentOptions resolvedCodingOptions = codingOptions == null\n                ? CodingAgentOptions.builder().build()\n                : codingOptions;\n        AgentOptions resolvedAgentOptions = agentOptions == null\n                ? AgentOptions.builder().maxSteps(0).build()\n                : agentOptions;\n        CodingAgentDefinitionRegistry resolvedDefinitionRegistry = definitionRegistry == null\n                ? BuiltInCodingAgentDefinitions.registry()\n                : definitionRegistry;\n        SubAgentRegistry resolvedSubAgentRegistry = resolveSubAgentRegistry();\n        HandoffPolicy resolvedHandoffPolicy = handoffPolicy == null ? HandoffPolicy.builder().build() : handoffPolicy;\n        CodingTaskManager resolvedTaskManager = taskManager == null ? new InMemoryCodingTaskManager() : taskManager;\n        CodingSessionLinkStore resolvedSessionLinkStore = sessionLinkStore == null ? new InMemoryCodingSessionLinkStore() : sessionLinkStore;\n        CodingToolPolicyResolver resolvedToolPolicyResolver = toolPolicyResolver == null ? new CodingToolPolicyResolver() : toolPolicyResolver;\n        CodingRuntime resolvedCodingRuntime = codingRuntime == null\n                ? new DefaultCodingRuntime(\n                resolvedWorkspaceContext,\n                resolvedCodingOptions,\n                toolRegistry,\n                toolExecutor,\n                resolvedDefinitionRegistry,\n                resolvedTaskManager,\n                resolvedSessionLinkStore,\n                resolvedToolPolicyResolver,\n                resolvedSubAgentRegistry,\n                resolvedHandoffPolicy\n        )\n                : codingRuntime;\n\n        AgentToolRegistry builtInRegistry = createBuiltInRegistry(resolvedCodingOptions, resolvedDefinitionRegistry);\n        ToolExecutor builtInExecutor = createBuiltInToolExecutor(\n                resolvedWorkspaceContext,\n                resolvedCodingOptions,\n                new SessionProcessRegistry(resolvedWorkspaceContext, resolvedCodingOptions),\n                resolvedCodingRuntime,\n                resolvedDefinitionRegistry\n        );\n\n        AgentToolRegistry resolvedToolRegistry = mergeToolRegistry(builtInRegistry, toolRegistry);\n        ToolExecutor resolvedToolExecutor = mergeToolExecutor(builtInRegistry, builtInExecutor, toolRegistry, toolExecutor);\n        resolvedToolRegistry = mergeSubAgentToolRegistry(resolvedToolRegistry, resolvedSubAgentRegistry);\n        resolvedToolExecutor = mergeSubAgentToolExecutor(resolvedToolExecutor, resolvedSubAgentRegistry, resolvedHandoffPolicy);\n        String resolvedSystemPrompt = resolvedCodingOptions.isPrependWorkspaceInstructions()\n                ? CodingContextPromptAssembler.mergeSystemPrompt(systemPrompt, resolvedWorkspaceContext)\n                : systemPrompt;\n        AgentBuilder delegate = new AgentBuilder();\n        if (runtime != null) {\n            delegate.runtime(runtime);\n        }\n        Agent agent = delegate\n                .modelClient(modelClient)\n                .model(model)\n                .instructions(instructions)\n                .systemPrompt(resolvedSystemPrompt)\n                .options(resolvedAgentOptions)\n                .toolRegistry(resolvedToolRegistry)\n                .toolExecutor(resolvedToolExecutor)\n                .temperature(temperature)\n                .topP(topP)\n                .maxOutputTokens(maxOutputTokens)\n                .reasoning(reasoning)\n                .toolChoice(toolChoice)\n                .parallelToolCalls(parallelToolCalls)\n                .store(store)\n                .user(user)\n                .extraBody(extraBody)\n                .build();\n\n        return new CodingAgent(\n                agent,\n                resolvedWorkspaceContext,\n                resolvedCodingOptions,\n                toolRegistry,\n                toolExecutor,\n                resolvedCodingRuntime,\n                resolvedSubAgentRegistry,\n                resolvedHandoffPolicy\n        );\n    }\n\n    public static AgentToolRegistry createBuiltInRegistry(CodingAgentOptions options) {\n        return createBuiltInRegistry(options, null);\n    }\n\n    public static AgentToolRegistry createBuiltInRegistry(CodingAgentOptions options,\n                                                          CodingAgentDefinitionRegistry definitionRegistry) {\n        if (options == null || !options.isIncludeBuiltInTools()) {\n            return StaticToolRegistry.empty();\n        }\n        AgentToolRegistry builtInRegistry = CodingToolRegistryFactory.createBuiltInRegistry();\n        AgentToolRegistry delegateRegistry = new CodingDelegateToolRegistry(definitionRegistry);\n        if (delegateRegistry.getTools().isEmpty()) {\n            return builtInRegistry;\n        }\n        return new CompositeToolRegistry(builtInRegistry, delegateRegistry);\n    }\n\n    public static ToolExecutor createBuiltInToolExecutor(WorkspaceContext workspaceContext,\n                                                         CodingAgentOptions options,\n                                                         SessionProcessRegistry processRegistry) {\n        return createBuiltInToolExecutor(workspaceContext, options, processRegistry, null, null);\n    }\n\n    public static ToolExecutor createBuiltInToolExecutor(WorkspaceContext workspaceContext,\n                                                         CodingAgentOptions options,\n                                                         SessionProcessRegistry processRegistry,\n                                                         CodingRuntime codingRuntime,\n                                                         CodingAgentDefinitionRegistry definitionRegistry) {\n        if (options == null || !options.isIncludeBuiltInTools()) {\n            return null;\n        }\n        WorkspaceFileService workspaceFileService = new LocalWorkspaceFileService(workspaceContext);\n        ToolExecutorDecorator decorator = options.getToolExecutorDecorator();\n        List<RoutingToolExecutor.Route> routes = new ArrayList<RoutingToolExecutor.Route>();\n        routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.READ_FILE),\n                decorate(CodingToolNames.READ_FILE, new ReadFileToolExecutor(workspaceFileService, options), decorator)));\n        routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.WRITE_FILE),\n                decorate(CodingToolNames.WRITE_FILE, new WriteFileToolExecutor(workspaceContext), decorator)));\n        routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.APPLY_PATCH),\n                decorate(CodingToolNames.APPLY_PATCH, new ApplyPatchToolExecutor(workspaceContext), decorator)));\n        routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.BASH),\n                decorate(CodingToolNames.BASH, new BashToolExecutor(workspaceContext, options, processRegistry), decorator)));\n        if (codingRuntime != null && definitionRegistry != null) {\n            ToolExecutor delegateExecutor = new CodingDelegateToolExecutor(codingRuntime, definitionRegistry);\n            routes.add(RoutingToolExecutor.route(resolveToolNames(new CodingDelegateToolRegistry(definitionRegistry)), delegateExecutor));\n        }\n        return new RoutingToolExecutor(routes, null);\n    }\n\n    public static AgentToolRegistry mergeToolRegistry(AgentToolRegistry builtInRegistry, AgentToolRegistry customRegistry) {\n        if (customRegistry == null) {\n            return builtInRegistry;\n        }\n        return new CompositeToolRegistry(builtInRegistry, customRegistry);\n    }\n\n    public static ToolExecutor mergeToolExecutor(AgentToolRegistry builtInRegistry,\n                                                 ToolExecutor builtInExecutor,\n                                                 AgentToolRegistry customRegistry,\n                                                 ToolExecutor customExecutor) {\n        if (customRegistry != null && customExecutor == null) {\n            throw new IllegalStateException(\"toolExecutor is required when custom toolRegistry is provided\");\n        }\n        if (builtInExecutor == null) {\n            return customExecutor;\n        }\n        if (customExecutor == null) {\n            return builtInExecutor;\n        }\n\n        List<RoutingToolExecutor.Route> routes = new ArrayList<>();\n        routes.add(RoutingToolExecutor.route(resolveToolNames(builtInRegistry), builtInExecutor));\n        routes.add(RoutingToolExecutor.route(resolveToolNames(customRegistry), customExecutor));\n        return new RoutingToolExecutor(routes, null);\n    }\n\n    public static AgentToolRegistry mergeSubAgentToolRegistry(AgentToolRegistry baseRegistry,\n                                                              SubAgentRegistry subAgentRegistry) {\n        if (subAgentRegistry == null || subAgentRegistry.getTools() == null || subAgentRegistry.getTools().isEmpty()) {\n            return baseRegistry == null ? StaticToolRegistry.empty() : baseRegistry;\n        }\n        AgentToolRegistry safeBaseRegistry = baseRegistry == null ? StaticToolRegistry.empty() : baseRegistry;\n        return new CompositeToolRegistry(safeBaseRegistry, new StaticToolRegistry(subAgentRegistry.getTools()));\n    }\n\n    public static ToolExecutor mergeSubAgentToolExecutor(ToolExecutor baseExecutor,\n                                                         SubAgentRegistry subAgentRegistry,\n                                                         HandoffPolicy handoffPolicy) {\n        if (subAgentRegistry == null) {\n            return baseExecutor;\n        }\n        return new SubAgentToolExecutor(\n                subAgentRegistry,\n                baseExecutor,\n                handoffPolicy == null ? HandoffPolicy.builder().build() : handoffPolicy\n        );\n    }\n\n    public static Set<String> resolveToolNames(AgentToolRegistry registry) {\n        if (registry == null || registry.getTools() == null) {\n            return Collections.emptySet();\n        }\n        Set<String> toolNames = new HashSet<>();\n        for (Object tool : registry.getTools()) {\n            if (tool instanceof Tool) {\n                Tool.Function function = ((Tool) tool).getFunction();\n                if (function != null && !isBlank(function.getName())) {\n                    toolNames.add(function.getName());\n                }\n            }\n        }\n        return toolNames;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static ToolExecutor decorate(String toolName, ToolExecutor executor, ToolExecutorDecorator decorator) {\n        if (decorator == null || executor == null) {\n            return executor;\n        }\n        return decorator.decorate(toolName, executor);\n    }\n\n    private SubAgentRegistry resolveSubAgentRegistry() {\n        if (subAgentRegistry != null) {\n            return subAgentRegistry;\n        }\n        if (!subAgentDefinitions.isEmpty()) {\n            return new StaticSubAgentRegistry(subAgentDefinitions);\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentOptions.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.coding.tool.ToolExecutorDecorator;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingAgentOptions {\n\n    @Builder.Default\n    private boolean includeBuiltInTools = true;\n\n    @Builder.Default\n    private boolean prependWorkspaceInstructions = true;\n\n    @Builder.Default\n    private int defaultFileListMaxDepth = 4;\n\n    @Builder.Default\n    private int defaultFileListMaxEntries = 200;\n\n    @Builder.Default\n    private int defaultReadMaxChars = 12000;\n\n    @Builder.Default\n    private long defaultCommandTimeoutMs = 30000L;\n\n    @Builder.Default\n    private int defaultBashLogChars = 12000;\n\n    @Builder.Default\n    private int maxProcessOutputChars = 120000;\n\n    @Builder.Default\n    private long processStopGraceMs = 1000L;\n\n    @Builder.Default\n    private boolean autoCompactEnabled = true;\n\n    @Builder.Default\n    private int compactContextWindowTokens = 128000;\n\n    @Builder.Default\n    private int compactReserveTokens = 16384;\n\n    @Builder.Default\n    private int compactKeepRecentTokens = 20000;\n\n    @Builder.Default\n    private int compactSummaryMaxOutputTokens = 400;\n\n    @Builder.Default\n    private boolean toolResultMicroCompactEnabled = true;\n\n    @Builder.Default\n    private int toolResultMicroCompactKeepRecent = 3;\n\n    @Builder.Default\n    private int toolResultMicroCompactMaxTokens = 1200;\n\n    @Builder.Default\n    private int autoCompactMaxConsecutiveFailures = 3;\n\n    @Builder.Default\n    private boolean autoContinueEnabled = true;\n\n    @Builder.Default\n    private int maxAutoFollowUps = 2;\n\n    @Builder.Default\n    private int maxTotalTurns = 6;\n\n    @Builder.Default\n    private boolean continueAfterCompact = true;\n\n    @Builder.Default\n    private boolean stopOnApprovalBlock = true;\n\n    @Builder.Default\n    private boolean stopOnExplicitQuestion = true;\n\n    private ToolExecutorDecorator toolExecutorDecorator;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentRequest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class CodingAgentRequest {\n\n    private String input;\n\n    private Map<String, Object> metadata;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentResult.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.coding.loop.CodingStopReason;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class CodingAgentResult {\n\n    private String sessionId;\n\n    private String outputText;\n\n    private Object rawResponse;\n\n    private List<AgentToolCall> toolCalls;\n\n    private List<AgentToolResult> toolResults;\n\n    private int steps;\n\n    @Builder.Default\n    private int turns = 1;\n\n    private CodingStopReason stopReason;\n\n    private boolean autoContinued;\n\n    private int autoFollowUpCount;\n\n    private boolean lastCompactApplied;\n\n    public static CodingAgentResult from(String sessionId, AgentResult result) {\n        if (result == null) {\n            return CodingAgentResult.builder().sessionId(sessionId).build();\n        }\n        return CodingAgentResult.builder()\n                .sessionId(sessionId)\n                .outputText(result.getOutputText())\n                .rawResponse(result.getRawResponse())\n                .toolCalls(result.getToolCalls())\n                .toolResults(result.getToolResults())\n                .steps(result.getSteps())\n                .turns(1)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgents.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\npublic final class CodingAgents {\n\n    private CodingAgents() {\n    }\n\n    public static CodingAgentBuilder builder() {\n        return new CodingAgentBuilder();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSession.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.memory.AgentMemory;\nimport io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory;\nimport io.github.lnyocly.ai4j.agent.memory.MemorySnapshot;\nimport io.github.lnyocly.ai4j.coding.loop.CodingAgentLoopController;\nimport io.github.lnyocly.ai4j.coding.loop.CodingLoopDecision;\nimport io.github.lnyocly.ai4j.coding.compact.CodingSessionCompactor;\nimport io.github.lnyocly.ai4j.coding.compact.CodingToolResultMicroCompactResult;\nimport io.github.lnyocly.ai4j.coding.compact.CodingToolResultMicroCompactor;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessInfo;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\nimport io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry;\nimport io.github.lnyocly.ai4j.coding.runtime.CodingRuntime;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.UUID;\n\npublic class CodingSession implements AutoCloseable {\n\n    private static final CodingSessionCompactor COMPACTOR = new CodingSessionCompactor();\n    private static final CodingToolResultMicroCompactor TOOL_RESULT_MICRO_COMPACTOR = new CodingToolResultMicroCompactor();\n    private static final CodingAgentLoopController LOOP_CONTROLLER = new CodingAgentLoopController();\n\n    private final String sessionId;\n    private final AgentSession delegate;\n    private final WorkspaceContext workspaceContext;\n    private final CodingAgentOptions options;\n    private final SessionProcessRegistry processRegistry;\n    private final CodingRuntime runtime;\n    private CodingSessionCheckpoint checkpoint;\n    private CodingSessionCompactResult lastAutoCompactResult;\n    private CodingSessionCompactResult latestCompactResult;\n    private Exception lastAutoCompactError;\n    private final List<CodingSessionCompactResult> pendingAutoCompactResults = new ArrayList<CodingSessionCompactResult>();\n    private final List<Exception> pendingAutoCompactErrors = new ArrayList<Exception>();\n    private final List<CodingLoopDecision> pendingLoopDecisions = new ArrayList<CodingLoopDecision>();\n    private int consecutiveAutoCompactFailures;\n    private boolean autoCompactCircuitBreakerOpen;\n\n    public CodingSession(AgentSession delegate,\n                         WorkspaceContext workspaceContext,\n                         CodingAgentOptions options,\n                         SessionProcessRegistry processRegistry) {\n        this(UUID.randomUUID().toString(), delegate, workspaceContext, options, processRegistry, null);\n    }\n\n    public CodingSession(String sessionId,\n                         AgentSession delegate,\n                         WorkspaceContext workspaceContext,\n                         CodingAgentOptions options,\n                         SessionProcessRegistry processRegistry) {\n        this(sessionId, delegate, workspaceContext, options, processRegistry, null);\n    }\n\n    public CodingSession(AgentSession delegate,\n                         WorkspaceContext workspaceContext,\n                         CodingAgentOptions options,\n                         SessionProcessRegistry processRegistry,\n                         CodingRuntime runtime) {\n        this(UUID.randomUUID().toString(), delegate, workspaceContext, options, processRegistry, runtime);\n    }\n\n    public CodingSession(String sessionId,\n                         AgentSession delegate,\n                         WorkspaceContext workspaceContext,\n                         CodingAgentOptions options,\n                         SessionProcessRegistry processRegistry,\n                         CodingRuntime runtime) {\n        this.sessionId = sessionId;\n        this.delegate = delegate;\n        this.workspaceContext = workspaceContext;\n        this.options = options;\n        this.processRegistry = processRegistry;\n        this.runtime = runtime;\n    }\n\n    public CodingAgentResult run(String input) throws Exception {\n        return run(CodingAgentRequest.builder().input(input).build());\n    }\n\n    public CodingAgentResult run(CodingAgentRequest request) throws Exception {\n        clearPendingLoopArtifacts();\n        return LOOP_CONTROLLER.run(this, request);\n    }\n\n    public CodingAgentResult runStream(CodingAgentRequest request, AgentListener listener) throws Exception {\n        clearPendingLoopArtifacts();\n        return LOOP_CONTROLLER.runStream(this, request, listener);\n    }\n\n    public String getSessionId() {\n        return sessionId;\n    }\n\n    public AgentSession getDelegate() {\n        return delegate;\n    }\n\n    public WorkspaceContext getWorkspaceContext() {\n        return workspaceContext;\n    }\n\n    public CodingAgentOptions getOptions() {\n        return options;\n    }\n\n    public CodingRuntime getRuntime() {\n        return runtime;\n    }\n\n    public CodingDelegateResult delegate(CodingDelegateRequest request) throws Exception {\n        if (runtime == null) {\n            throw new IllegalStateException(\"coding runtime is unavailable for this session\");\n        }\n        return runtime.delegate(this, request);\n    }\n\n    public CodingSessionSnapshot snapshot() {\n        MemorySnapshot snapshot = exportMemory();\n        List<Object> items = snapshot == null || snapshot.getItems() == null\n                ? Collections.<Object>emptyList()\n                : snapshot.getItems();\n        List<BashProcessInfo> processes = listProcessInfos();\n        List<StoredProcessSnapshot> processSnapshots = exportProcessSnapshots();\n        CodingSessionCheckpoint effectiveCheckpoint = resolveCheckpoint(snapshot, processSnapshots, items.size(), false);\n        String renderedSummary = CodingSessionCheckpointFormatter.render(effectiveCheckpoint);\n        return CodingSessionSnapshot.builder()\n                .sessionId(sessionId)\n                .workspaceRoot(workspaceContext == null ? null : workspaceContext.getRootPath())\n                .memoryItemCount(items == null ? 0 : items.size())\n                .summary(renderedSummary)\n                .checkpointGoal(effectiveCheckpoint == null ? null : effectiveCheckpoint.getGoal())\n                .checkpointGeneratedAtEpochMs(effectiveCheckpoint == null ? 0L : effectiveCheckpoint.getGeneratedAtEpochMs())\n                .checkpointSplitTurn(effectiveCheckpoint != null && effectiveCheckpoint.isSplitTurn())\n                .processCount(processes.size())\n                .activeProcessCount(processRegistry == null ? 0 : processRegistry.activeCount())\n                .restoredProcessCount(processRegistry == null ? 0 : processRegistry.restoredCount())\n                .estimatedContextTokens(COMPACTOR.estimateContextTokens(items, renderedSummary))\n                .lastCompactMode(resolveCompactMode(latestCompactResult))\n                .lastCompactBeforeItemCount(latestCompactResult == null ? 0 : latestCompactResult.getBeforeItemCount())\n                .lastCompactAfterItemCount(latestCompactResult == null ? 0 : latestCompactResult.getAfterItemCount())\n                .lastCompactTokensBefore(latestCompactResult == null ? 0 : latestCompactResult.getEstimatedTokensBefore())\n                .lastCompactTokensAfter(latestCompactResult == null ? 0 : latestCompactResult.getEstimatedTokensAfter())\n                .lastCompactStrategy(latestCompactResult == null ? null : latestCompactResult.getStrategy())\n                .lastCompactSummary(latestCompactResult == null ? null : latestCompactResult.getSummary())\n                .autoCompactFailureCount(consecutiveAutoCompactFailures)\n                .autoCompactCircuitBreakerOpen(autoCompactCircuitBreakerOpen)\n                .processes(processes)\n                .build();\n    }\n\n    public CodingSessionState exportState() {\n        MemorySnapshot snapshot = exportMemory();\n        List<Object> items = snapshot == null || snapshot.getItems() == null\n                ? Collections.<Object>emptyList()\n                : snapshot.getItems();\n        List<StoredProcessSnapshot> processSnapshots = exportProcessSnapshots();\n        return CodingSessionState.builder()\n                .sessionId(sessionId)\n                .workspaceRoot(workspaceContext == null ? null : workspaceContext.getRootPath())\n                .memorySnapshot(snapshot)\n                .processCount(processSnapshots.size())\n                .checkpoint(resolveCheckpoint(snapshot, processSnapshots, items.size(), false))\n                .latestCompactResult(copyCompactResult(latestCompactResult))\n                .autoCompactFailureCount(consecutiveAutoCompactFailures)\n                .autoCompactCircuitBreakerOpen(autoCompactCircuitBreakerOpen)\n                .processSnapshots(processSnapshots)\n                .build();\n    }\n\n    public void restore(CodingSessionState state) {\n        if (state == null) {\n            return;\n        }\n        AgentMemory memory = resolveMemory();\n        if (!(memory instanceof InMemoryAgentMemory)) {\n            throw new IllegalStateException(\"restore is only supported for InMemoryAgentMemory\");\n        }\n        ((InMemoryAgentMemory) memory).restore(state.getMemorySnapshot());\n        if (processRegistry != null) {\n            processRegistry.restoreSnapshots(state.getProcessSnapshots());\n        }\n        clearPendingLoopArtifacts();\n        checkpoint = copyCheckpoint(state.getCheckpoint());\n        latestCompactResult = copyCompactResult(state.getLatestCompactResult());\n        if (checkpoint == null) {\n            checkpoint = latestCompactResult == null ? null : copyCheckpoint(latestCompactResult.getCheckpoint());\n        }\n        if (checkpoint == null) {\n            checkpoint = resolveCheckpoint(state.getMemorySnapshot(), state.getProcessSnapshots(), 0, false);\n        }\n        consecutiveAutoCompactFailures = Math.max(0, state.getAutoCompactFailureCount());\n        autoCompactCircuitBreakerOpen = state.isAutoCompactCircuitBreakerOpen();\n    }\n\n    public CodingSessionCompactResult compact() {\n        return compact(null);\n    }\n\n    public CodingSessionCompactResult compact(String summary) {\n        InMemoryAgentMemory inMemoryMemory = requireInMemoryMemory();\n        try {\n            CodingSessionCompactResult result = COMPACTOR.compact(\n                    sessionId,\n                    delegate == null ? null : delegate.getContext(),\n                    inMemoryMemory,\n                    options,\n                    summary,\n                    exportProcessSnapshots(),\n                    checkpoint,\n                    true\n            );\n            checkpoint = result == null ? null : result.getCheckpoint();\n            latestCompactResult = copyCompactResult(result);\n            clearPendingLoopArtifacts();\n            resetAutoCompactFailureTracking();\n            return result;\n        } catch (Exception ex) {\n            throw propagateCompactException(ex);\n        }\n    }\n\n    public List<BashProcessInfo> listProcesses() {\n        return listProcessInfos();\n    }\n\n    public BashProcessInfo processStatus(String processId) {\n        requireProcessRegistry();\n        return processRegistry.status(processId);\n    }\n\n    public BashProcessLogChunk processLogs(String processId, Long offset, Integer limit) {\n        requireProcessRegistry();\n        return processRegistry.logs(processId, offset, limit);\n    }\n\n    public int writeProcess(String processId, String input) throws IOException {\n        requireProcessRegistry();\n        return processRegistry.write(processId, input);\n    }\n\n    public BashProcessInfo stopProcess(String processId) {\n        requireProcessRegistry();\n        return processRegistry.stop(processId);\n    }\n\n    public CodingSessionCompactResult drainLastAutoCompactResult() {\n        CodingSessionCompactResult result = lastAutoCompactResult;\n        lastAutoCompactResult = null;\n        if (!pendingAutoCompactResults.isEmpty()) {\n            pendingAutoCompactResults.remove(pendingAutoCompactResults.size() - 1);\n        }\n        return result;\n    }\n\n    public Exception drainLastAutoCompactError() {\n        Exception error = lastAutoCompactError;\n        lastAutoCompactError = null;\n        if (!pendingAutoCompactErrors.isEmpty()) {\n            pendingAutoCompactErrors.remove(pendingAutoCompactErrors.size() - 1);\n        }\n        return error;\n    }\n\n    public void clearAutoCompactState() {\n        lastAutoCompactResult = null;\n        lastAutoCompactError = null;\n        pendingAutoCompactResults.clear();\n        pendingAutoCompactErrors.clear();\n    }\n\n    public CodingSessionCompactResult getLastAutoCompactResult() {\n        return copyCompactResult(lastAutoCompactResult);\n    }\n\n    public Exception getLastAutoCompactError() {\n        return lastAutoCompactError;\n    }\n\n    public List<CodingSessionCompactResult> drainAutoCompactResults() {\n        if (pendingAutoCompactResults.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<CodingSessionCompactResult> drained = new ArrayList<CodingSessionCompactResult>();\n        for (CodingSessionCompactResult result : pendingAutoCompactResults) {\n            drained.add(copyCompactResult(result));\n        }\n        pendingAutoCompactResults.clear();\n        lastAutoCompactResult = null;\n        return drained;\n    }\n\n    public List<Exception> drainAutoCompactErrors() {\n        if (pendingAutoCompactErrors.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Exception> drained = new ArrayList<Exception>(pendingAutoCompactErrors);\n        pendingAutoCompactErrors.clear();\n        lastAutoCompactError = null;\n        return drained;\n    }\n\n    public void recordLoopDecision(CodingLoopDecision decision) {\n        if (decision != null) {\n            pendingLoopDecisions.add(decision.toBuilder().build());\n        }\n    }\n\n    public List<CodingLoopDecision> drainLoopDecisions() {\n        if (pendingLoopDecisions.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<CodingLoopDecision> drained = new ArrayList<CodingLoopDecision>();\n        for (CodingLoopDecision decision : pendingLoopDecisions) {\n            drained.add(decision == null ? null : decision.toBuilder().build());\n        }\n        pendingLoopDecisions.clear();\n        return drained;\n    }\n\n    public void clearPendingLoopArtifacts() {\n        clearAutoCompactState();\n        pendingLoopDecisions.clear();\n    }\n\n    private void maybeAutoCompactAfterTurn() {\n        lastAutoCompactResult = null;\n        lastAutoCompactError = null;\n        if (options == null || !options.isAutoCompactEnabled()) {\n            return;\n        }\n        InMemoryAgentMemory inMemoryMemory;\n        try {\n            inMemoryMemory = requireInMemoryMemory();\n        } catch (Exception ex) {\n            lastAutoCompactError = ex;\n            pendingAutoCompactErrors.add(ex);\n            return;\n        }\n        if (autoCompactCircuitBreakerOpen) {\n            lastAutoCompactError = new IllegalStateException(\n                    \"Automatic compaction is paused after \" + consecutiveAutoCompactFailures\n                            + \" consecutive failures. Run a manual compact or reduce context to reset.\"\n            );\n            pendingAutoCompactErrors.add(lastAutoCompactError);\n            return;\n        }\n        try {\n            MemorySnapshot snapshot = inMemoryMemory.snapshot();\n            CodingToolResultMicroCompactResult microCompactResult = TOOL_RESULT_MICRO_COMPACTOR.compact(\n                    snapshot,\n                    options,\n                    resolveAutoCompactTargetTokens()\n            );\n            if (microCompactResult != null\n                    && microCompactResult.getAfterTokens() <= resolveAutoCompactTargetTokens()) {\n                inMemoryMemory.restore(MemorySnapshot.from(\n                        microCompactResult.getItems(),\n                        snapshot == null ? null : snapshot.getSummary()\n                ));\n                lastAutoCompactResult = buildMicroCompactResult(snapshot, microCompactResult);\n                latestCompactResult = copyCompactResult(lastAutoCompactResult);\n                pendingAutoCompactResults.add(copyCompactResult(lastAutoCompactResult));\n                resetAutoCompactFailureTracking();\n                return;\n            }\n            lastAutoCompactResult = COMPACTOR.compact(\n                    sessionId,\n                    delegate == null ? null : delegate.getContext(),\n                    inMemoryMemory,\n                    options,\n                    null,\n                    exportProcessSnapshots(),\n                    checkpoint,\n                    false\n            );\n            checkpoint = lastAutoCompactResult == null ? checkpoint : lastAutoCompactResult.getCheckpoint();\n            latestCompactResult = copyCompactResult(lastAutoCompactResult);\n            if (lastAutoCompactResult != null) {\n                pendingAutoCompactResults.add(copyCompactResult(lastAutoCompactResult));\n            }\n            resetAutoCompactFailureTracking();\n        } catch (Exception ex) {\n            recordAutoCompactFailure(ex);\n        }\n    }\n\n    private InMemoryAgentMemory requireInMemoryMemory() {\n        AgentMemory memory = resolveMemory();\n        if (!(memory instanceof InMemoryAgentMemory)) {\n            throw new IllegalStateException(\"compact is only supported for InMemoryAgentMemory\");\n        }\n        return (InMemoryAgentMemory) memory;\n    }\n\n    private void requireProcessRegistry() {\n        if (processRegistry == null) {\n            throw new IllegalStateException(\"process registry is unavailable\");\n        }\n    }\n\n    private List<BashProcessInfo> listProcessInfos() {\n        if (processRegistry == null) {\n            return Collections.emptyList();\n        }\n        return new ArrayList<BashProcessInfo>(processRegistry.list());\n    }\n\n    private List<StoredProcessSnapshot> exportProcessSnapshots() {\n        if (processRegistry == null) {\n            return Collections.emptyList();\n        }\n        return processRegistry.exportSnapshots();\n    }\n\n    @Override\n    public void close() {\n        if (processRegistry != null) {\n            processRegistry.close();\n        }\n    }\n\n    private AgentMemory resolveMemory() {\n        return delegate == null || delegate.getContext() == null ? null : delegate.getContext().getMemory();\n    }\n\n    private MemorySnapshot exportMemory() {\n        AgentMemory memory = resolveMemory();\n        if (memory == null) {\n            return MemorySnapshot.from(Collections.emptyList(), null);\n        }\n        if (memory instanceof InMemoryAgentMemory) {\n            return ((InMemoryAgentMemory) memory).snapshot();\n        }\n        return MemorySnapshot.from(memory.getItems(), memory.getSummary());\n    }\n\n    private CodingSessionCheckpoint resolveCheckpoint(MemorySnapshot snapshot,\n                                                      List<StoredProcessSnapshot> processSnapshots,\n                                                      int sourceItemCount,\n                                                      boolean splitTurn) {\n        if (checkpoint != null) {\n            checkpoint = checkpoint.toBuilder()\n                    .processSnapshots(copyProcesses(processSnapshots))\n                    .sourceItemCount(sourceItemCount)\n                    .splitTurn(splitTurn)\n                    .build();\n            return checkpoint;\n        }\n        String summary = snapshot == null ? null : snapshot.getSummary();\n        checkpoint = CodingSessionCheckpointFormatter.create(summary, processSnapshots, sourceItemCount, splitTurn);\n        return checkpoint;\n    }\n\n    private List<StoredProcessSnapshot> copyProcesses(List<StoredProcessSnapshot> processSnapshots) {\n        if (processSnapshots == null || processSnapshots.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<StoredProcessSnapshot> copies = new ArrayList<StoredProcessSnapshot>();\n        for (StoredProcessSnapshot snapshot : processSnapshots) {\n            if (snapshot != null) {\n                copies.add(snapshot.toBuilder().build());\n            }\n        }\n        return copies;\n    }\n\n    private RuntimeException propagateCompactException(Exception ex) {\n        if (ex instanceof RuntimeException) {\n            return (RuntimeException) ex;\n        }\n        return new IllegalStateException(\"Failed to compact coding session\", ex);\n    }\n\n    private String resolveCompactMode(CodingSessionCompactResult result) {\n        if (result == null) {\n            return null;\n        }\n        return result.isAutomatic() ? \"auto\" : \"manual\";\n    }\n\n    private int resolveAutoCompactTargetTokens() {\n        if (options == null) {\n            return 0;\n        }\n        return Math.max(0, options.getCompactContextWindowTokens() - options.getCompactReserveTokens());\n    }\n\n    private void resetAutoCompactFailureTracking() {\n        consecutiveAutoCompactFailures = 0;\n        autoCompactCircuitBreakerOpen = false;\n    }\n\n    private void recordAutoCompactFailure(Exception ex) {\n        consecutiveAutoCompactFailures += 1;\n        int threshold = options == null ? 0 : Math.max(0, options.getAutoCompactMaxConsecutiveFailures());\n        if (threshold > 0 && consecutiveAutoCompactFailures >= threshold) {\n            autoCompactCircuitBreakerOpen = true;\n            lastAutoCompactError = new IllegalStateException(\n                    \"Automatic compaction failed \" + consecutiveAutoCompactFailures\n                            + \" consecutive times; circuit breaker opened.\",\n                    ex\n            );\n        } else {\n            lastAutoCompactError = ex;\n        }\n        pendingAutoCompactErrors.add(lastAutoCompactError);\n    }\n\n    private CodingSessionCompactResult buildMicroCompactResult(MemorySnapshot snapshot,\n                                                               CodingToolResultMicroCompactResult microCompactResult) {\n        List<Object> rawItems = snapshot == null || snapshot.getItems() == null\n                ? Collections.<Object>emptyList()\n                : snapshot.getItems();\n        return CodingSessionCompactResult.builder()\n                .sessionId(sessionId)\n                .beforeItemCount(rawItems.size())\n                .afterItemCount(microCompactResult.getItems() == null ? 0 : microCompactResult.getItems().size())\n                .summary(microCompactResult.getSummary())\n                .automatic(true)\n                .splitTurn(false)\n                .estimatedTokensBefore(microCompactResult.getBeforeTokens())\n                .estimatedTokensAfter(microCompactResult.getAfterTokens())\n                .strategy(\"tool-result-micro\")\n                .compactedToolResultCount(microCompactResult.getCompactedToolResultCount())\n                .checkpoint(checkpoint == null ? null : checkpoint.toBuilder().build())\n                .build();\n    }\n\n    private CodingSessionCompactResult copyCompactResult(CodingSessionCompactResult result) {\n        if (result == null) {\n            return null;\n        }\n        return result.toBuilder()\n                .checkpoint(copyCheckpoint(result.getCheckpoint()))\n                .build();\n    }\n\n    private CodingSessionCheckpoint copyCheckpoint(CodingSessionCheckpoint source) {\n        if (source == null) {\n            return null;\n        }\n        return source.toBuilder()\n                .constraints(source.getConstraints() == null ? new ArrayList<String>() : new ArrayList<String>(source.getConstraints()))\n                .doneItems(source.getDoneItems() == null ? new ArrayList<String>() : new ArrayList<String>(source.getDoneItems()))\n                .inProgressItems(source.getInProgressItems() == null ? new ArrayList<String>() : new ArrayList<String>(source.getInProgressItems()))\n                .blockedItems(source.getBlockedItems() == null ? new ArrayList<String>() : new ArrayList<String>(source.getBlockedItems()))\n                .keyDecisions(source.getKeyDecisions() == null ? new ArrayList<String>() : new ArrayList<String>(source.getKeyDecisions()))\n                .nextSteps(source.getNextSteps() == null ? new ArrayList<String>() : new ArrayList<String>(source.getNextSteps()))\n                .criticalContext(source.getCriticalContext() == null ? new ArrayList<String>() : new ArrayList<String>(source.getCriticalContext()))\n                .processSnapshots(copyProcesses(source.getProcessSnapshots()))\n                .build();\n    }\n\n    public CodingAgentResult runSingleTurn(CodingAgentRequest request, String hiddenInstructions) throws Exception {\n        AgentResult result = CodingSessionScope.runWithSession(this, new CodingSessionScope.SessionCallable<AgentResult>() {\n            @Override\n            public AgentResult call() throws Exception {\n                AgentSession executionSession = resolveExecutionSession(hiddenInstructions);\n                Object input = request == null ? null : request.getInput();\n                return executionSession.run(AgentRequest.builder().input(input).build());\n            }\n        });\n        maybeAutoCompactAfterTurn();\n        return CodingAgentResult.from(sessionId, result);\n    }\n\n    public CodingAgentResult runSingleTurnStream(CodingAgentRequest request,\n                                                 AgentListener listener,\n                                                 String hiddenInstructions) throws Exception {\n        AgentResult result = CodingSessionScope.runWithSession(this, new CodingSessionScope.SessionCallable<AgentResult>() {\n            @Override\n            public AgentResult call() throws Exception {\n                AgentSession executionSession = resolveExecutionSession(hiddenInstructions);\n                Object input = request == null ? null : request.getInput();\n                return executionSession.runStreamResult(AgentRequest.builder().input(input).build(), listener);\n            }\n        });\n        maybeAutoCompactAfterTurn();\n        return CodingAgentResult.from(sessionId, result);\n    }\n\n    private AgentSession resolveExecutionSession(String hiddenInstructions) {\n        if (delegate == null || delegate.getContext() == null || isBlank(hiddenInstructions)) {\n            return delegate;\n        }\n        return new AgentSession(\n                delegate.getRuntime(),\n                delegate.getContext().toBuilder()\n                        .instructions(mergeText(delegate.getContext().getInstructions(), hiddenInstructions))\n                        .build()\n        );\n    }\n\n    private String mergeText(String base, String extra) {\n        if (isBlank(base)) {\n            return extra;\n        }\n        if (isBlank(extra)) {\n            return base;\n        }\n        return base + \"\\n\\n\" + extra;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionCheckpoint.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class CodingSessionCheckpoint {\n\n    private String goal;\n\n    @Builder.Default\n    private List<String> constraints = new ArrayList<String>();\n\n    @Builder.Default\n    private List<String> doneItems = new ArrayList<String>();\n\n    @Builder.Default\n    private List<String> inProgressItems = new ArrayList<String>();\n\n    @Builder.Default\n    private List<String> blockedItems = new ArrayList<String>();\n\n    @Builder.Default\n    private List<String> keyDecisions = new ArrayList<String>();\n\n    @Builder.Default\n    private List<String> nextSteps = new ArrayList<String>();\n\n    @Builder.Default\n    private List<String> criticalContext = new ArrayList<String>();\n\n    @Builder.Default\n    private List<StoredProcessSnapshot> processSnapshots = new ArrayList<StoredProcessSnapshot>();\n\n    private long generatedAtEpochMs;\n\n    private int sourceItemCount;\n\n    private boolean splitTurn;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionCheckpointFormatter.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic final class CodingSessionCheckpointFormatter {\n\n    private CodingSessionCheckpointFormatter() {\n    }\n\n    public static CodingSessionCheckpoint parse(String summary) {\n        CodingSessionCheckpoint checkpoint = CodingSessionCheckpoint.builder().build();\n        if (isBlank(summary)) {\n            return checkpoint;\n        }\n        CodingSessionCheckpoint structured = parseStructured(summary);\n        if (structured != null) {\n            return structured;\n        }\n\n        String[] lines = summary.replace(\"\\r\", \"\").split(\"\\n\");\n        Section section = null;\n        ProgressSection progressSection = null;\n        StringBuilder goal = new StringBuilder();\n\n        for (String rawLine : lines) {\n            String line = rawLine == null ? \"\" : rawLine.trim();\n            if (line.isEmpty()) {\n                continue;\n            }\n            if (\"## Goal\".equals(line)) {\n                section = Section.GOAL;\n                progressSection = null;\n                continue;\n            }\n            if (\"## Constraints & Preferences\".equals(line)) {\n                section = Section.CONSTRAINTS;\n                progressSection = null;\n                continue;\n            }\n            if (\"## Progress\".equals(line)) {\n                section = Section.PROGRESS;\n                progressSection = null;\n                continue;\n            }\n            if (\"### Done\".equals(line)) {\n                section = Section.PROGRESS;\n                progressSection = ProgressSection.DONE;\n                continue;\n            }\n            if (\"### In Progress\".equals(line)) {\n                section = Section.PROGRESS;\n                progressSection = ProgressSection.IN_PROGRESS;\n                continue;\n            }\n            if (\"### Blocked\".equals(line)) {\n                section = Section.PROGRESS;\n                progressSection = ProgressSection.BLOCKED;\n                continue;\n            }\n            if (\"## Key Decisions\".equals(line)) {\n                section = Section.KEY_DECISIONS;\n                progressSection = null;\n                continue;\n            }\n            if (\"## Next Steps\".equals(line)) {\n                section = Section.NEXT_STEPS;\n                progressSection = null;\n                continue;\n            }\n            if (\"## Critical Context\".equals(line)) {\n                section = Section.CRITICAL_CONTEXT;\n                progressSection = null;\n                continue;\n            }\n            if (\"## Process Snapshots\".equals(line)) {\n                section = Section.PROCESS_SNAPSHOTS;\n                progressSection = null;\n                continue;\n            }\n            if (section == null) {\n                continue;\n            }\n\n            switch (section) {\n                case GOAL:\n                    if (goal.length() > 0) {\n                        goal.append('\\n');\n                    }\n                    goal.append(stripListPrefix(line));\n                    break;\n                case CONSTRAINTS:\n                    addIfPresent(checkpoint.getConstraints(), stripListPrefix(line));\n                    break;\n                case PROGRESS:\n                    addProgressLine(checkpoint, progressSection, line);\n                    break;\n                case KEY_DECISIONS:\n                    addIfPresent(checkpoint.getKeyDecisions(), stripListPrefix(line));\n                    break;\n                case NEXT_STEPS:\n                    addIfPresent(checkpoint.getNextSteps(), stripNumberPrefix(stripListPrefix(line)));\n                    break;\n                case CRITICAL_CONTEXT:\n                    addIfPresent(checkpoint.getCriticalContext(), stripListPrefix(line));\n                    break;\n                case PROCESS_SNAPSHOTS:\n                    // 进程快照以结构化 state 为准，文本渲染仅做人类可读展示。\n                    break;\n                default:\n                    break;\n            }\n        }\n\n        if (goal.length() > 0) {\n            checkpoint.setGoal(goal.toString().trim());\n        }\n        return checkpoint;\n    }\n\n    public static String renderStructuredJson(CodingSessionCheckpoint checkpoint) {\n        return toStructuredObject(checkpoint).toJSONString();\n    }\n\n    public static CodingSessionCheckpoint create(String summary,\n                                                 List<StoredProcessSnapshot> processSnapshots,\n                                                 int sourceItemCount,\n                                                 boolean splitTurn) {\n        CodingSessionCheckpoint checkpoint = parse(summary);\n        checkpoint.setProcessSnapshots(copyProcesses(processSnapshots));\n        checkpoint.setSourceItemCount(sourceItemCount);\n        checkpoint.setSplitTurn(splitTurn);\n        checkpoint.setGeneratedAtEpochMs(System.currentTimeMillis());\n        if (isBlank(checkpoint.getGoal())) {\n            checkpoint.setGoal(\"Continue the current coding session.\");\n        }\n        return checkpoint;\n    }\n\n    public static String render(CodingSessionCheckpoint checkpoint) {\n        if (checkpoint == null) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"## Goal\\n\");\n        builder.append(defaultText(checkpoint.getGoal(), \"Continue the current coding session.\")).append('\\n');\n        builder.append(\"## Constraints & Preferences\\n\");\n        appendBulletSection(builder, checkpoint.getConstraints(), \"(none)\");\n        builder.append(\"## Progress\\n\");\n        builder.append(\"### Done\\n\");\n        appendProgressSection(builder, checkpoint.getDoneItems(), \"x\");\n        builder.append(\"### In Progress\\n\");\n        appendProgressSection(builder, checkpoint.getInProgressItems(), \" \");\n        builder.append(\"### Blocked\\n\");\n        appendBulletSection(builder, checkpoint.getBlockedItems(), \"(none)\");\n        builder.append(\"## Key Decisions\\n\");\n        appendBulletSection(builder, checkpoint.getKeyDecisions(), \"(none)\");\n        builder.append(\"## Next Steps\\n\");\n        appendNumberSection(builder, checkpoint.getNextSteps(), \"Resume from the latest workspace state.\");\n        builder.append(\"## Critical Context\\n\");\n        appendBulletSection(builder, checkpoint.getCriticalContext(), \"(none)\");\n        if (checkpoint.getProcessSnapshots() != null && !checkpoint.getProcessSnapshots().isEmpty()) {\n            builder.append(\"## Process Snapshots\\n\");\n            for (StoredProcessSnapshot snapshot : checkpoint.getProcessSnapshots()) {\n                builder.append(\"- \").append(renderProcessSnapshot(snapshot)).append('\\n');\n            }\n        }\n        return builder.toString().trim();\n    }\n\n    private static CodingSessionCheckpoint parseStructured(String summary) {\n        String json = extractJsonObject(summary);\n        if (isBlank(json)) {\n            return null;\n        }\n        try {\n            JSONObject object = JSON.parseObject(json);\n            if (object == null || object.isEmpty()) {\n                return null;\n            }\n            return fromStructuredObject(object);\n        } catch (Exception ignore) {\n            return null;\n        }\n    }\n\n    private static CodingSessionCheckpoint fromStructuredObject(JSONObject object) {\n        if (object == null) {\n            return null;\n        }\n        JSONObject progress = object.getJSONObject(\"progress\");\n        CodingSessionCheckpoint checkpoint = CodingSessionCheckpoint.builder()\n                .goal(safeTrim(object.getString(\"goal\")))\n                .constraints(readStringList(object.getJSONArray(\"constraints\")))\n                .doneItems(readStringList(progress == null ? object.getJSONArray(\"doneItems\") : progress.getJSONArray(\"done\")))\n                .inProgressItems(readStringList(progress == null ? object.getJSONArray(\"inProgressItems\") : progress.getJSONArray(\"inProgress\")))\n                .blockedItems(readStringList(progress == null ? object.getJSONArray(\"blockedItems\") : progress.getJSONArray(\"blocked\")))\n                .keyDecisions(readStringList(object.getJSONArray(\"keyDecisions\")))\n                .nextSteps(readStringList(object.getJSONArray(\"nextSteps\")))\n                .criticalContext(readStringList(object.getJSONArray(\"criticalContext\")))\n                .build();\n        if (checkpoint.getGoal() == null\n                && checkpoint.getConstraints().isEmpty()\n                && checkpoint.getDoneItems().isEmpty()\n                && checkpoint.getInProgressItems().isEmpty()\n                && checkpoint.getBlockedItems().isEmpty()\n                && checkpoint.getKeyDecisions().isEmpty()\n                && checkpoint.getNextSteps().isEmpty()\n                && checkpoint.getCriticalContext().isEmpty()) {\n            return null;\n        }\n        return checkpoint;\n    }\n\n    private static JSONObject toStructuredObject(CodingSessionCheckpoint checkpoint) {\n        JSONObject object = new JSONObject();\n        CodingSessionCheckpoint effective = checkpoint == null ? CodingSessionCheckpoint.builder().build() : checkpoint;\n        object.put(\"goal\", defaultText(effective.getGoal(), \"Continue the current coding session.\"));\n        object.put(\"constraints\", new JSONArray(normalize(effective.getConstraints())));\n        JSONObject progress = new JSONObject();\n        progress.put(\"done\", new JSONArray(normalize(effective.getDoneItems())));\n        progress.put(\"inProgress\", new JSONArray(normalize(effective.getInProgressItems())));\n        progress.put(\"blocked\", new JSONArray(normalize(effective.getBlockedItems())));\n        object.put(\"progress\", progress);\n        object.put(\"keyDecisions\", new JSONArray(normalize(effective.getKeyDecisions())));\n        object.put(\"nextSteps\", new JSONArray(normalize(effective.getNextSteps())));\n        object.put(\"criticalContext\", new JSONArray(normalize(effective.getCriticalContext())));\n        return object;\n    }\n\n    private static void addProgressLine(CodingSessionCheckpoint checkpoint, ProgressSection progressSection, String line) {\n        String normalized = stripCheckboxPrefix(stripListPrefix(line));\n        if (isBlank(normalized)) {\n            return;\n        }\n        if (progressSection == ProgressSection.DONE) {\n            checkpoint.getDoneItems().add(normalized);\n            return;\n        }\n        if (progressSection == ProgressSection.IN_PROGRESS) {\n            checkpoint.getInProgressItems().add(normalized);\n            return;\n        }\n        if (progressSection == ProgressSection.BLOCKED) {\n            checkpoint.getBlockedItems().add(normalized);\n        }\n    }\n\n    private static void appendBulletSection(StringBuilder builder, List<String> values, String emptyValue) {\n        List<String> normalized = normalize(values);\n        if (normalized.isEmpty()) {\n            builder.append(\"- \").append(emptyValue).append('\\n');\n            return;\n        }\n        for (String value : normalized) {\n            builder.append(\"- \").append(value).append('\\n');\n        }\n    }\n\n    private static void appendProgressSection(StringBuilder builder, List<String> values, String marker) {\n        List<String> normalized = normalize(values);\n        if (normalized.isEmpty()) {\n            builder.append(\"- [\").append(marker).append(\"] \").append(\"(none)\").append('\\n');\n            return;\n        }\n        for (String value : normalized) {\n            builder.append(\"- [\").append(marker).append(\"] \").append(value).append('\\n');\n        }\n    }\n\n    private static void appendNumberSection(StringBuilder builder, List<String> values, String emptyValue) {\n        List<String> normalized = normalize(values);\n        if (normalized.isEmpty()) {\n            builder.append(\"1. \").append(emptyValue).append('\\n');\n            return;\n        }\n        for (int i = 0; i < normalized.size(); i++) {\n            builder.append(i + 1).append(\". \").append(normalized.get(i)).append('\\n');\n        }\n    }\n\n    private static String renderProcessSnapshot(StoredProcessSnapshot snapshot) {\n        if (snapshot == null) {\n            return \"(unknown)\";\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(defaultText(snapshot.getProcessId(), \"(unknown)\")).append(\" | \");\n        builder.append(snapshot.isControlAvailable() ? \"live\" : \"metadata-only\").append(\" | \");\n        builder.append(\"status=\").append(snapshot.getStatus()).append(\" | \");\n        builder.append(\"cwd=\").append(defaultText(snapshot.getWorkingDirectory(), \"(unknown)\")).append(\" | \");\n        builder.append(\"command=\").append(defaultText(snapshot.getCommand(), \"(none)\"));\n        if (snapshot.getLastLogOffset() > 0) {\n            builder.append(\" | lastLogOffset=\").append(snapshot.getLastLogOffset());\n        }\n        if (!isBlank(snapshot.getLastLogPreview())) {\n            builder.append(\" | preview=\").append(clip(snapshot.getLastLogPreview(), 160));\n        }\n        return builder.toString();\n    }\n\n    private static List<String> normalize(List<String> values) {\n        if (values == null || values.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<String> result = new ArrayList<String>();\n        for (String value : values) {\n            if (!isBlank(value) && !\"(none)\".equalsIgnoreCase(value.trim())) {\n                result.add(value.trim());\n            }\n        }\n        return result;\n    }\n\n    private static void addIfPresent(List<String> target, String value) {\n        if (target == null || isBlank(value) || \"(none)\".equalsIgnoreCase(value.trim())) {\n            return;\n        }\n        target.add(value.trim());\n    }\n\n    private static List<StoredProcessSnapshot> copyProcesses(List<StoredProcessSnapshot> processSnapshots) {\n        if (processSnapshots == null || processSnapshots.isEmpty()) {\n            return new ArrayList<StoredProcessSnapshot>();\n        }\n        List<StoredProcessSnapshot> copies = new ArrayList<StoredProcessSnapshot>();\n        for (StoredProcessSnapshot snapshot : processSnapshots) {\n            if (snapshot != null) {\n                copies.add(snapshot.toBuilder().build());\n            }\n        }\n        return copies;\n    }\n\n    private static String stripListPrefix(String value) {\n        if (isBlank(value)) {\n            return \"\";\n        }\n        String trimmed = value.trim();\n        if (trimmed.startsWith(\"- \")) {\n            return trimmed.substring(2).trim();\n        }\n        return trimmed;\n    }\n\n    private static String stripCheckboxPrefix(String value) {\n        if (isBlank(value)) {\n            return \"\";\n        }\n        String trimmed = value.trim();\n        if (trimmed.startsWith(\"[x] \") || trimmed.startsWith(\"[X] \") || trimmed.startsWith(\"[ ] \")) {\n            return trimmed.substring(4).trim();\n        }\n        return trimmed;\n    }\n\n    private static String stripNumberPrefix(String value) {\n        if (isBlank(value)) {\n            return \"\";\n        }\n        int index = value.indexOf('.');\n        if (index > 0) {\n            boolean digitsOnly = true;\n            for (int i = 0; i < index; i++) {\n                if (!Character.isDigit(value.charAt(i))) {\n                    digitsOnly = false;\n                    break;\n                }\n            }\n            if (digitsOnly && index + 1 < value.length()) {\n                return value.substring(index + 1).trim();\n            }\n        }\n        return value.trim();\n    }\n\n    private static List<String> readStringList(JSONArray values) {\n        if (values == null || values.isEmpty()) {\n            return new ArrayList<String>();\n        }\n        List<String> result = new ArrayList<String>();\n        for (int i = 0; i < values.size(); i++) {\n            String value = safeTrim(values.getString(i));\n            if (!isBlank(value) && !\"(none)\".equalsIgnoreCase(value)) {\n                result.add(value);\n            }\n        }\n        return result;\n    }\n\n    private static String extractJsonObject(String summary) {\n        if (isBlank(summary)) {\n            return null;\n        }\n        String trimmed = stripCodeFence(summary.trim());\n        int start = trimmed.indexOf('{');\n        int end = trimmed.lastIndexOf('}');\n        if (start < 0 || end <= start) {\n            return null;\n        }\n        return trimmed.substring(start, end + 1);\n    }\n\n    private static String stripCodeFence(String text) {\n        if (isBlank(text) || !text.startsWith(\"```\")) {\n            return text;\n        }\n        int firstNewline = text.indexOf('\\n');\n        if (firstNewline < 0) {\n            return text;\n        }\n        String body = text.substring(firstNewline + 1);\n        int lastFence = body.lastIndexOf(\"```\");\n        if (lastFence >= 0) {\n            body = body.substring(0, lastFence);\n        }\n        return body.trim();\n    }\n\n    private static String safeTrim(String value) {\n        return isBlank(value) ? null : value.trim();\n    }\n\n    private static String defaultText(String value, String fallback) {\n        return isBlank(value) ? fallback : value.trim();\n    }\n\n    private static String clip(String value, int maxChars) {\n        if (value == null) {\n            return \"\";\n        }\n        String normalized = value.replace('\\r', ' ').replace('\\n', ' ').trim();\n        if (normalized.length() <= maxChars) {\n            return normalized;\n        }\n        return normalized.substring(0, maxChars) + \"...\";\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private enum Section {\n        GOAL,\n        CONSTRAINTS,\n        PROGRESS,\n        KEY_DECISIONS,\n        NEXT_STEPS,\n        CRITICAL_CONTEXT,\n        PROCESS_SNAPSHOTS\n    }\n\n    private enum ProgressSection {\n        DONE,\n        IN_PROGRESS,\n        BLOCKED\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionCompactResult.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingSessionCompactResult {\n\n    private String sessionId;\n\n    private int beforeItemCount;\n\n    private int afterItemCount;\n\n    private String summary;\n\n    private boolean automatic;\n\n    private boolean splitTurn;\n\n    private int estimatedTokensBefore;\n\n    private int estimatedTokensAfter;\n\n    private String strategy;\n\n    private int compactedToolResultCount;\n\n    private int deltaItemCount;\n\n    private boolean checkpointReused;\n\n    private boolean fallbackSummary;\n\n    private CodingSessionCheckpoint checkpoint;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionScope.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\npublic final class CodingSessionScope {\n\n    private static final ThreadLocal<CodingSession> CURRENT = new ThreadLocal<CodingSession>();\n\n    private CodingSessionScope() {\n    }\n\n    public interface SessionCallable<T> {\n        T call() throws Exception;\n    }\n\n    public interface SessionRunnable {\n        void run() throws Exception;\n    }\n\n    public static CodingSession currentSession() {\n        return CURRENT.get();\n    }\n\n    public static <T> T runWithSession(CodingSession session, SessionCallable<T> callable) throws Exception {\n        if (callable == null) {\n            throw new IllegalArgumentException(\"callable is required\");\n        }\n        CodingSession previous = CURRENT.get();\n        CURRENT.set(session);\n        try {\n            return callable.call();\n        } finally {\n            restore(previous);\n        }\n    }\n\n    public static void runWithSession(CodingSession session, SessionRunnable runnable) throws Exception {\n        if (runnable == null) {\n            throw new IllegalArgumentException(\"runnable is required\");\n        }\n        runWithSession(session, new SessionCallable<Void>() {\n            @Override\n            public Void call() throws Exception {\n                runnable.run();\n                return null;\n            }\n        });\n    }\n\n    private static void restore(CodingSession previous) {\n        if (previous == null) {\n            CURRENT.remove();\n            return;\n        }\n        CURRENT.set(previous);\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionSnapshot.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.coding.process.BashProcessInfo;\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@Builder\npublic class CodingSessionSnapshot {\n\n    private String sessionId;\n\n    private String workspaceRoot;\n\n    private int memoryItemCount;\n\n    private String summary;\n\n    private String checkpointGoal;\n\n    private long checkpointGeneratedAtEpochMs;\n\n    private boolean checkpointSplitTurn;\n\n    private int processCount;\n\n    private int activeProcessCount;\n\n    private int restoredProcessCount;\n\n    private int estimatedContextTokens;\n\n    private String lastCompactMode;\n\n    private int lastCompactBeforeItemCount;\n\n    private int lastCompactAfterItemCount;\n\n    private int lastCompactTokensBefore;\n\n    private int lastCompactTokensAfter;\n\n    private String lastCompactStrategy;\n\n    private String lastCompactSummary;\n\n    private int autoCompactFailureCount;\n\n    private boolean autoCompactCircuitBreakerOpen;\n\n    @Builder.Default\n    private List<BashProcessInfo> processes = new ArrayList<BashProcessInfo>();\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingSessionState.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.memory.MemorySnapshot;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class CodingSessionState {\n\n    private String sessionId;\n\n    private String workspaceRoot;\n\n    private MemorySnapshot memorySnapshot;\n\n    private int processCount;\n\n    private CodingSessionCheckpoint checkpoint;\n\n    private CodingSessionCompactResult latestCompactResult;\n\n    private int autoCompactFailureCount;\n\n    private boolean autoCompactCircuitBreakerOpen;\n\n    @Builder.Default\n    private List<StoredProcessSnapshot> processSnapshots = new ArrayList<StoredProcessSnapshot>();\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/compact/CodingCompactionPreparation.java",
    "content": "package io.github.lnyocly.ai4j.coding.compact;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\n@Builder\npublic class CodingCompactionPreparation {\n\n    private List<Object> rawItems;\n\n    private List<Object> itemsToSummarize;\n\n    private List<Object> turnPrefixItems;\n\n    private List<Object> keptItems;\n\n    private String previousSummary;\n\n    private boolean splitTurn;\n\n    private int firstKeptItemIndex;\n\n    private int estimatedTokensBefore;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/compact/CodingSessionCompactor.java",
    "content": "package io.github.lnyocly.ai4j.coding.compact;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory;\nimport io.github.lnyocly.ai4j.agent.memory.MemorySnapshot;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpointFormatter;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\npublic class CodingSessionCompactor {\n\n    private static final int MAX_SUMMARIZATION_PROMPT_TOO_LONG_RETRIES = 3;\n    private static final double PROMPT_TOO_LONG_RETRY_DROP_RATIO = 0.25d;\n    private static final int FALLBACK_RECENT_CONTEXT_ITEMS = 6;\n    private static final String COMPACTION_FALLBACK_MARKER = \"**Compaction fallback**\";\n    private static final String SESSION_MEMORY_FALLBACK_MARKER = \"**Session-memory fallback**\";\n\n    private static final String SUMMARIZATION_SYSTEM_PROMPT =\n            \"You create context checkpoint summaries for coding sessions. \"\n                    + \"Return only the structured JSON object requested by the user prompt.\";\n\n    private static final String SUMMARIZATION_PROMPT =\n            \"The messages above are a conversation to summarize.\\n\"\n                    + \"Create a structured context checkpoint summary that another LLM will use to continue the work.\\n\"\n                    + \"Return ONLY a JSON object with this exact schema:\\n\"\n                    + \"{\\n\"\n                    + \"  \\\"goal\\\": \\\"string\\\",\\n\"\n                    + \"  \\\"constraints\\\": [\\\"string\\\"],\\n\"\n                    + \"  \\\"progress\\\": {\\n\"\n                    + \"    \\\"done\\\": [\\\"string\\\"],\\n\"\n                    + \"    \\\"inProgress\\\": [\\\"string\\\"],\\n\"\n                    + \"    \\\"blocked\\\": [\\\"string\\\"]\\n\"\n                    + \"  },\\n\"\n                    + \"  \\\"keyDecisions\\\": [\\\"string\\\"],\\n\"\n                    + \"  \\\"nextSteps\\\": [\\\"string\\\"],\\n\"\n                    + \"  \\\"criticalContext\\\": [\\\"string\\\"]\\n\"\n                    + \"}\\n\"\n                    + \"Keep fields concise. Preserve exact file paths, function names, commands, and error messages.\";\n\n    private static final String UPDATE_SUMMARIZATION_PROMPT =\n            \"The messages above are NEW conversation messages to incorporate into the existing checkpoint provided in <existing_checkpoint_json> tags.\\n\"\n                    + \"Update the existing structured checkpoint with new information.\\n\"\n                    + \"RULES:\\n\"\n                    + \"- PRESERVE all existing information from the previous summary\\n\"\n                    + \"- ADD new progress, decisions, and context from the new messages\\n\"\n                    + \"- UPDATE the Progress section: move items from \\\"In Progress\\\" to \\\"Done\\\" when completed\\n\"\n                    + \"- UPDATE \\\"Next Steps\\\" based on what was accomplished\\n\"\n                    + \"- PRESERVE exact file paths, function names, and error messages\\n\"\n                    + \"- If something is no longer relevant, you may remove it\\n\"\n                    + \"Return ONLY the updated JSON object using the same schema as the initial checkpoint.\\n\"\n                    + \"Keep fields concise. Preserve exact file paths, function names, commands, and error messages.\";\n\n    private static final String TURN_PREFIX_SUMMARIZATION_PROMPT =\n            \"The messages above are the EARLY PART of an in-progress turn that was too large to keep in full.\\n\"\n                    + \"Summarize only the critical context from this partial turn so the later kept messages can still be understood.\\n\"\n                    + \"Focus on:\\n\"\n                    + \"- the user request for this turn\\n\"\n                    + \"- key assistant decisions already made\\n\"\n                    + \"- tool calls/results that changed the workspace or revealed critical facts\\n\"\n                    + \"- exact file paths, function names, commands, and errors\\n\"\n                    + \"Return ONLY the same checkpoint JSON schema, keeping fields compact.\";\n\n    public CodingCompactionPreparation prepare(MemorySnapshot snapshot,\n                                               CodingAgentOptions options,\n                                               boolean force) {\n        List<Object> rawItems = snapshot == null || snapshot.getItems() == null\n                ? Collections.<Object>emptyList()\n                : new ArrayList<Object>(snapshot.getItems());\n        String previousSummary = snapshot == null ? null : snapshot.getSummary();\n        int estimatedTokensBefore = estimateContextTokens(rawItems, previousSummary);\n        if (!force && !shouldCompact(estimatedTokensBefore, options)) {\n            return null;\n        }\n\n        if (rawItems.isEmpty()) {\n            return CodingCompactionPreparation.builder()\n                    .rawItems(rawItems)\n                    .itemsToSummarize(Collections.emptyList())\n                    .turnPrefixItems(Collections.emptyList())\n                    .keptItems(Collections.emptyList())\n                    .previousSummary(previousSummary)\n                    .splitTurn(false)\n                    .firstKeptItemIndex(0)\n                    .estimatedTokensBefore(estimatedTokensBefore)\n                    .build();\n        }\n\n        CutPoint cutPoint = findCutPoint(rawItems, options.getCompactKeepRecentTokens());\n        int firstKeptItemIndex = cutPoint.firstKeptItemIndex;\n        int historyEnd = cutPoint.splitTurn ? cutPoint.turnStartItemIndex : firstKeptItemIndex;\n\n        List<Object> itemsToSummarize = copySlice(rawItems, 0, historyEnd);\n        List<Object> turnPrefixItems = cutPoint.splitTurn\n                ? copySlice(rawItems, cutPoint.turnStartItemIndex, firstKeptItemIndex)\n                : Collections.<Object>emptyList();\n        List<Object> keptItems = copySlice(rawItems, firstKeptItemIndex, rawItems.size());\n\n        if (force && itemsToSummarize.isEmpty() && turnPrefixItems.isEmpty()) {\n            itemsToSummarize = new ArrayList<Object>(rawItems);\n            keptItems = Collections.emptyList();\n            firstKeptItemIndex = rawItems.size();\n            cutPoint = new CutPoint(firstKeptItemIndex, -1, false);\n        }\n\n        return CodingCompactionPreparation.builder()\n                .rawItems(rawItems)\n                .itemsToSummarize(itemsToSummarize)\n                .turnPrefixItems(turnPrefixItems)\n                .keptItems(keptItems)\n                .previousSummary(previousSummary)\n                .splitTurn(cutPoint.splitTurn)\n                .firstKeptItemIndex(firstKeptItemIndex)\n                .estimatedTokensBefore(estimatedTokensBefore)\n                .build();\n    }\n\n    public CodingSessionCompactResult compact(String sessionId,\n                                              AgentContext context,\n                                              InMemoryAgentMemory memory,\n                                              CodingAgentOptions options,\n                                              String customInstructions,\n                                              List<StoredProcessSnapshot> processSnapshots,\n                                              CodingSessionCheckpoint previousCheckpoint,\n                                              boolean force) throws Exception {\n        MemorySnapshot snapshot = memory.snapshot();\n        CodingCompactionPreparation preparation = prepare(snapshot, options, force);\n        if (preparation == null) {\n            return null;\n        }\n\n        CodingSessionCheckpoint existingCheckpoint = copyCheckpoint(previousCheckpoint);\n        if (!hasCheckpointContent(existingCheckpoint) && !isBlank(preparation.getPreviousSummary())) {\n            existingCheckpoint = CodingSessionCheckpointFormatter.parse(preparation.getPreviousSummary());\n        }\n        boolean checkpointReused = hasCheckpointContent(existingCheckpoint)\n                || !isBlank(preparation.getPreviousSummary());\n        int deltaItemCount = safeSize(preparation.getItemsToSummarize()) + safeSize(preparation.getTurnPrefixItems());\n\n        CodingSessionCheckpoint checkpoint = buildCheckpoint(\n                context,\n                preparation,\n                options,\n                customInstructions,\n                existingCheckpoint\n        );\n        boolean aggressiveCompactionApplied = false;\n        List<Object> keptItems = preparation.getKeptItems() == null\n                ? Collections.<Object>emptyList()\n                : preparation.getKeptItems();\n        String renderedSummary = CodingSessionCheckpointFormatter.render(checkpoint);\n        int afterTokens = estimateContextTokens(keptItems, renderedSummary);\n        if (needsAggressiveCompaction(afterTokens, preparation.getEstimatedTokensBefore(), options)\n                && preparation.getRawItems() != null\n                && !preparation.getRawItems().isEmpty()\n                && !keptItems.isEmpty()) {\n            aggressiveCompactionApplied = true;\n            checkpoint = summarize(\n                    context,\n                    preparation.getRawItems(),\n                    options,\n                    appendInstructions(customInstructions, \"Aggressively compact the full conversation into a single checkpoint.\"),\n                    existingCheckpoint,\n                    false\n            );\n            if (checkpoint == null) {\n                checkpoint = buildFallbackCheckpoint(preparation.getRawItems(), existingCheckpoint);\n            }\n            keptItems = Collections.emptyList();\n            renderedSummary = CodingSessionCheckpointFormatter.render(checkpoint);\n            afterTokens = estimateContextTokens(keptItems, renderedSummary);\n        }\n\n        checkpoint = attachCheckpointMetadata(\n                checkpoint,\n                processSnapshots,\n                preparation.getRawItems() == null ? 0 : preparation.getRawItems().size(),\n                preparation.isSplitTurn()\n        );\n        renderedSummary = CodingSessionCheckpointFormatter.render(checkpoint);\n        memory.restore(MemorySnapshot.from(keptItems, renderedSummary));\n        afterTokens = estimateContextTokens(keptItems, renderedSummary);\n\n        return CodingSessionCompactResult.builder()\n                .sessionId(sessionId)\n                .beforeItemCount(preparation.getRawItems() == null ? 0 : preparation.getRawItems().size())\n                .afterItemCount(keptItems.size())\n                .summary(renderedSummary)\n                .automatic(!force)\n                .splitTurn(preparation.isSplitTurn())\n                .estimatedTokensBefore(preparation.getEstimatedTokensBefore())\n                .estimatedTokensAfter(afterTokens)\n                .strategy(resolveCheckpointStrategy(aggressiveCompactionApplied, checkpointReused))\n                .deltaItemCount(deltaItemCount)\n                .checkpointReused(checkpointReused)\n                .fallbackSummary(detectFallbackSummary(checkpoint))\n                .checkpoint(checkpoint)\n                .build();\n    }\n\n    public int estimateContextTokens(List<Object> rawItems, String summary) {\n        int tokens = isBlank(summary) ? 0 : estimateTextTokens(summary);\n        if (rawItems != null) {\n            for (Object rawItem : rawItems) {\n                tokens += estimateItemTokens(rawItem);\n            }\n        }\n        return tokens;\n    }\n\n    private boolean shouldCompact(int estimatedTokens, CodingAgentOptions options) {\n        if (options == null || !options.isAutoCompactEnabled()) {\n            return false;\n        }\n        return estimatedTokens > options.getCompactContextWindowTokens() - options.getCompactReserveTokens();\n    }\n\n    private boolean needsAggressiveCompaction(int afterTokens, int beforeTokens, CodingAgentOptions options) {\n        if (afterTokens >= beforeTokens) {\n            return true;\n        }\n        if (options == null) {\n            return false;\n        }\n        return afterTokens > options.getCompactContextWindowTokens() - options.getCompactReserveTokens();\n    }\n\n    private CutPoint findCutPoint(List<Object> rawItems, int keepRecentTokens) {\n        List<Integer> cutPoints = new ArrayList<Integer>();\n        for (int i = 0; i < rawItems.size(); i++) {\n            if (isValidCutPoint(rawItems.get(i))) {\n                cutPoints.add(i);\n            }\n        }\n        if (cutPoints.isEmpty()) {\n            return new CutPoint(0, -1, false);\n        }\n\n        int accumulatedTokens = 0;\n        int cutIndex = cutPoints.get(0);\n        for (int i = rawItems.size() - 1; i >= 0; i--) {\n            accumulatedTokens += estimateItemTokens(rawItems.get(i));\n            if (accumulatedTokens >= keepRecentTokens) {\n                cutIndex = findNearestCutPointAtOrAfter(cutPoints, i);\n                break;\n            }\n        }\n\n        boolean cutAtUserMessage = isUserMessage(rawItems.get(cutIndex));\n        int turnStartItemIndex = cutAtUserMessage ? -1 : findTurnStartIndex(rawItems, cutIndex);\n        return new CutPoint(cutIndex, turnStartItemIndex, !cutAtUserMessage && turnStartItemIndex >= 0);\n    }\n\n    private int findNearestCutPointAtOrAfter(List<Integer> cutPoints, int index) {\n        for (Integer cutPoint : cutPoints) {\n            if (cutPoint >= index) {\n                return cutPoint;\n            }\n        }\n        return cutPoints.get(cutPoints.size() - 1);\n    }\n\n    private int findTurnStartIndex(List<Object> rawItems, int index) {\n        for (int i = index; i >= 0; i--) {\n            if (isUserMessage(rawItems.get(i))) {\n                return i;\n            }\n        }\n        return -1;\n    }\n\n    private boolean isValidCutPoint(Object item) {\n        JSONObject object = toJSONObject(item);\n        return \"message\".equals(object.getString(\"type\"));\n    }\n\n    private boolean isUserMessage(Object item) {\n        JSONObject object = toJSONObject(item);\n        return \"message\".equals(object.getString(\"type\")) && \"user\".equals(object.getString(\"role\"));\n    }\n\n    private int estimateItemTokens(Object item) {\n        JSONObject object = toJSONObject(item);\n        String type = object.getString(\"type\");\n        if (\"function_call_output\".equals(type)) {\n            return estimateTextTokens(object.getString(\"output\"));\n        }\n        if (\"message\".equals(type)) {\n            JSONArray content = object.getJSONArray(\"content\");\n            if (content == null) {\n                return estimateTextTokens(object.toJSONString());\n            }\n            int chars = 0;\n            for (int i = 0; i < content.size(); i++) {\n                JSONObject part = content.getJSONObject(i);\n                if (part == null) {\n                    continue;\n                }\n                String partType = part.getString(\"type\");\n                if (\"input_text\".equals(partType) || \"output_text\".equals(partType)) {\n                    chars += safeText(part.getString(\"text\")).length();\n                } else if (\"input_image\".equals(partType)) {\n                    chars += 4800;\n                } else {\n                    chars += part.toJSONString().length();\n                }\n            }\n            return estimateChars(chars);\n        }\n        return estimateTextTokens(object.toJSONString());\n    }\n\n    private CodingSessionCheckpoint buildCheckpoint(AgentContext context,\n                                                    CodingCompactionPreparation preparation,\n                                                    CodingAgentOptions options,\n                                                    String customInstructions,\n                                                    CodingSessionCheckpoint previousCheckpoint) throws Exception {\n        CodingSessionCheckpoint historyCheckpoint = copyCheckpoint(previousCheckpoint);\n        if (!hasCheckpointContent(historyCheckpoint) && !isBlank(preparation.getPreviousSummary())) {\n            historyCheckpoint = CodingSessionCheckpointFormatter.parse(preparation.getPreviousSummary());\n        }\n\n        if (preparation.getItemsToSummarize() != null && !preparation.getItemsToSummarize().isEmpty()) {\n            historyCheckpoint = summarize(\n                    context,\n                    preparation.getItemsToSummarize(),\n                    options,\n                    customInstructions,\n                    historyCheckpoint,\n                    false\n            );\n        }\n\n        if (preparation.getTurnPrefixItems() != null && !preparation.getTurnPrefixItems().isEmpty()) {\n            String turnPrefixInstructions = appendInstructions(\n                    customInstructions,\n                    \"These messages are the early part of an in-progress turn that precedes newer kept messages.\"\n            );\n            historyCheckpoint = summarize(\n                    context,\n                    preparation.getTurnPrefixItems(),\n                    options,\n                    turnPrefixInstructions,\n                    hasCheckpointContent(historyCheckpoint) ? historyCheckpoint : null,\n                    !hasCheckpointContent(historyCheckpoint)\n            );\n        }\n\n        if (!hasCheckpointContent(historyCheckpoint)) {\n            historyCheckpoint = buildFallbackCheckpoint(preparation.getRawItems(), previousCheckpoint);\n        }\n        return historyCheckpoint;\n    }\n\n    private CodingSessionCheckpoint summarize(AgentContext context,\n                                              List<Object> items,\n                                              CodingAgentOptions options,\n                                              String customInstructions,\n                                              CodingSessionCheckpoint previousCheckpoint,\n                                              boolean turnPrefix) throws Exception {\n        if (items == null || items.isEmpty()) {\n            return copyCheckpoint(previousCheckpoint);\n        }\n\n        AgentModelClient modelClient = context == null ? null : context.getModelClient();\n        if (modelClient == null) {\n            return buildFallbackCheckpoint(items, previousCheckpoint);\n        }\n\n        List<Object> attemptItems = new ArrayList<Object>(items);\n        int retryCount = 0;\n        int droppedItemCount = 0;\n        while (true) {\n            String conversationText = serializeConversation(attemptItems);\n            if (isBlank(conversationText)) {\n                return annotatePromptTooLongRetry(\n                        buildFallbackCheckpoint(items, previousCheckpoint),\n                        retryCount,\n                        droppedItemCount,\n                        retryCount > 0\n                );\n            }\n\n            AgentPrompt summarizationPrompt = AgentPrompt.builder()\n                    .model(context.getModel())\n                    .systemPrompt(SUMMARIZATION_SYSTEM_PROMPT)\n                    .items(Collections.<Object>singletonList(AgentInputItem.userMessage(\n                            buildSummarizationPromptText(conversationText, customInstructions, previousCheckpoint, turnPrefix)\n                    )))\n                    .maxOutputTokens(options == null ? null : options.getCompactSummaryMaxOutputTokens())\n                    .store(Boolean.FALSE)\n                    .build();\n            AgentModelResult result;\n            try {\n                result = modelClient.create(summarizationPrompt);\n            } catch (Exception ex) {\n                if (!isPromptTooLongError(ex)) {\n                    if (hasCheckpointContent(previousCheckpoint)) {\n                        return buildFallbackCheckpoint(items, previousCheckpoint);\n                    }\n                    throw ex;\n                }\n                PromptTooLongRetrySlice retrySlice = truncateForPromptTooLongRetry(attemptItems);\n                if (retryCount >= MAX_SUMMARIZATION_PROMPT_TOO_LONG_RETRIES || retrySlice == null) {\n                    return annotatePromptTooLongRetry(\n                            buildFallbackCheckpoint(items, previousCheckpoint),\n                            retryCount + 1,\n                            droppedItemCount,\n                            true\n                    );\n                }\n                retryCount += 1;\n                droppedItemCount += retrySlice.droppedItemCount;\n                attemptItems = retrySlice.items;\n                continue;\n            }\n\n            String outputText = result == null ? null : result.getOutputText();\n            CodingSessionCheckpoint parsed = isBlank(outputText)\n                    ? buildFallbackCheckpoint(items, previousCheckpoint)\n                    : CodingSessionCheckpointFormatter.parse(outputText);\n            if (!hasCheckpointContent(parsed)) {\n                parsed = buildFallbackCheckpoint(items, previousCheckpoint);\n            }\n            return annotatePromptTooLongRetry(parsed, retryCount, droppedItemCount, false);\n        }\n    }\n\n    private String buildSummarizationPromptText(String conversationText,\n                                                String customInstructions,\n                                                CodingSessionCheckpoint previousCheckpoint,\n                                                boolean turnPrefix) {\n        StringBuilder prompt = new StringBuilder();\n        prompt.append(\"<conversation>\\n\").append(conversationText).append(\"\\n</conversation>\\n\");\n        if (hasCheckpointContent(previousCheckpoint)) {\n            prompt.append(\"\\n<existing_checkpoint_json>\\n\")\n                    .append(CodingSessionCheckpointFormatter.renderStructuredJson(previousCheckpoint))\n                    .append(\"\\n</existing_checkpoint_json>\\n\");\n        }\n        prompt.append(\"\\n\");\n        prompt.append(turnPrefix\n                ? TURN_PREFIX_SUMMARIZATION_PROMPT\n                : (hasCheckpointContent(previousCheckpoint) ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT));\n        if (!isBlank(customInstructions)) {\n            prompt.append(\"\\nAdditional focus: \").append(customInstructions.trim());\n        }\n        return prompt.toString();\n    }\n\n    private String serializeConversation(List<Object> items) {\n        StringBuilder builder = new StringBuilder();\n        if (items == null) {\n            return \"\";\n        }\n        for (Object item : items) {\n            String line = serializeItem(item);\n            if (!isBlank(line)) {\n                if (builder.length() > 0) {\n                    builder.append(\"\\n\\n\");\n                }\n                builder.append(line);\n            }\n        }\n        return builder.toString();\n    }\n\n    private String serializeItem(Object item) {\n        JSONObject object = toJSONObject(item);\n        String type = object.getString(\"type\");\n        if (\"function_call_output\".equals(type)) {\n            return \"[Tool Result]: \" + truncateForSummary(object.getString(\"output\"));\n        }\n        if (!\"message\".equals(type)) {\n            return \"[Context]: \" + truncateForSummary(object.toJSONString());\n        }\n\n        String role = object.getString(\"role\");\n        String text = extractMessageText(object.getJSONArray(\"content\"));\n        if (\"user\".equals(role)) {\n            return \"[User]: \" + truncateForSummary(text);\n        }\n        if (\"assistant\".equals(role)) {\n            return \"[Assistant]: \" + truncateForSummary(text);\n        }\n        if (\"system\".equals(role)) {\n            return \"[System]: \" + truncateForSummary(text);\n        }\n        return \"[\" + safeText(role) + \"]: \" + truncateForSummary(text);\n    }\n\n    private String extractMessageText(JSONArray content) {\n        if (content == null || content.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < content.size(); i++) {\n            JSONObject part = content.getJSONObject(i);\n            if (part == null) {\n                continue;\n            }\n            String partType = part.getString(\"type\");\n            if (\"input_text\".equals(partType) || \"output_text\".equals(partType)) {\n                if (builder.length() > 0) {\n                    builder.append(' ');\n                }\n                builder.append(safeText(part.getString(\"text\")));\n            } else if (\"input_image\".equals(partType)) {\n                if (builder.length() > 0) {\n                    builder.append(' ');\n                }\n                builder.append(\"[image]\");\n            } else {\n                if (builder.length() > 0) {\n                    builder.append(' ');\n                }\n                builder.append(part.toJSONString());\n            }\n        }\n        return builder.toString().trim();\n    }\n\n    private String truncateForSummary(String text) {\n        String value = safeText(text);\n        int maxChars = 2000;\n        if (value.length() <= maxChars) {\n            return value;\n        }\n        int truncatedChars = value.length() - maxChars;\n        return value.substring(0, maxChars) + \"\\n\\n[... \" + truncatedChars + \" more characters truncated]\";\n    }\n\n    private CodingSessionCheckpoint buildFallbackCheckpoint(List<Object> items,\n                                                            CodingSessionCheckpoint previousCheckpoint) {\n        if (hasCheckpointContent(previousCheckpoint)) {\n            return buildSessionMemoryFallbackCheckpoint(items, previousCheckpoint);\n        }\n        CodingSessionCheckpoint checkpoint = copyCheckpoint(previousCheckpoint);\n        if (checkpoint == null) {\n            checkpoint = CodingSessionCheckpoint.builder().build();\n        }\n\n        if (isBlank(checkpoint.getGoal())) {\n            checkpoint.setGoal(\"Continue the current coding session.\");\n        }\n        if (hasCheckpointContent(previousCheckpoint)) {\n            addUnique(checkpoint.getDoneItems(), \"Previous compacted context retained.\");\n            addUnique(checkpoint.getCriticalContext(), \"Previous summary exists and should be preserved.\");\n        } else {\n            addUnique(checkpoint.getDoneItems(), \"Conversation summarized from existing context.\");\n        }\n        addUnique(checkpoint.getInProgressItems(), \"Resume from the latest workspace state.\");\n        addUnique(checkpoint.getKeyDecisions(), COMPACTION_FALLBACK_MARKER + \": Used local summary because the model summary was unavailable.\");\n        addUnique(checkpoint.getNextSteps(), \"Inspect the latest files and continue from the kept recent context.\");\n        mergeRecentContextIntoCheckpoint(checkpoint, items, false);\n        return checkpoint;\n    }\n\n    private CodingSessionCheckpoint buildSessionMemoryFallbackCheckpoint(List<Object> items,\n                                                                        CodingSessionCheckpoint previousCheckpoint) {\n        CodingSessionCheckpoint checkpoint = copyCheckpoint(previousCheckpoint);\n        if (checkpoint == null) {\n            checkpoint = CodingSessionCheckpoint.builder().build();\n        }\n        if (isBlank(checkpoint.getGoal())) {\n            checkpoint.setGoal(resolveLatestUserGoal(items));\n        }\n        if (isBlank(checkpoint.getGoal())) {\n            checkpoint.setGoal(\"Continue the current coding session.\");\n        }\n        addUnique(checkpoint.getDoneItems(), \"Previous compacted context retained.\");\n        addUnique(checkpoint.getInProgressItems(), \"Continue from the latest kept context and recent delta.\");\n        addUnique(checkpoint.getKeyDecisions(),\n                SESSION_MEMORY_FALLBACK_MARKER + \": Reused the existing checkpoint and merged recent delta locally because the model summary was unavailable.\");\n        addUnique(checkpoint.getNextSteps(), \"Continue from the latest kept context, recent delta, and current workspace state.\");\n        mergeRecentContextIntoCheckpoint(checkpoint, items, true);\n        String latestUserGoal = resolveLatestUserGoal(items);\n        if (!isBlank(latestUserGoal)) {\n            addUnique(checkpoint.getCriticalContext(), \"Latest user delta: \" + latestUserGoal);\n        }\n        return checkpoint;\n    }\n\n    private void mergeRecentContextIntoCheckpoint(CodingSessionCheckpoint checkpoint,\n                                                  List<Object> items,\n                                                  boolean sessionMemoryFallback) {\n        if (checkpoint == null || items == null || items.isEmpty()) {\n            return;\n        }\n        int start = Math.max(0, items.size() - FALLBACK_RECENT_CONTEXT_ITEMS);\n        for (int i = start; i < items.size(); i++) {\n            Object item = items.get(i);\n            String line = serializeItem(item);\n            if (!isBlank(line)) {\n                addUnique(checkpoint.getCriticalContext(), line.replace('\\n', ' '));\n            }\n            mergeFallbackSignals(checkpoint, item, sessionMemoryFallback);\n        }\n    }\n\n    private void mergeFallbackSignals(CodingSessionCheckpoint checkpoint,\n                                      Object item,\n                                      boolean sessionMemoryFallback) {\n        if (checkpoint == null || item == null) {\n            return;\n        }\n        JSONObject object = toJSONObject(item);\n        String type = object.getString(\"type\");\n        if (\"function_call_output\".equals(type)) {\n            String output = singleLine(object.getString(\"output\"));\n            if (isBlank(output)) {\n                return;\n            }\n            String lower = output.toLowerCase(Locale.ROOT);\n            if (lower.contains(\"[approval-rejected]\")) {\n                addUnique(checkpoint.getBlockedItems(), \"Approval was rejected in recent delta: \" + clip(output, 220));\n            } else if (lower.contains(\"tool_error:\") || lower.contains(\"tool error\")) {\n                addUnique(checkpoint.getBlockedItems(), \"Tool error preserved from recent delta: \" + clip(output, 220));\n            } else if (sessionMemoryFallback) {\n                addUnique(checkpoint.getCriticalContext(), \"Recent tool delta: \" + clip(output, 220));\n            }\n            return;\n        }\n        if (!\"message\".equals(type)) {\n            return;\n        }\n        String role = object.getString(\"role\");\n        String text = singleLine(extractMessageText(object.getJSONArray(\"content\")));\n        if (isBlank(text)) {\n            return;\n        }\n        if (\"assistant\".equals(role) && sessionMemoryFallback) {\n            addUnique(checkpoint.getCriticalContext(), \"Recent assistant delta: \" + clip(text, 220));\n        }\n        if (\"system\".equals(role) && sessionMemoryFallback) {\n            addUnique(checkpoint.getCriticalContext(), \"Recent system delta: \" + clip(text, 220));\n        }\n    }\n\n    private CodingSessionCheckpoint annotatePromptTooLongRetry(CodingSessionCheckpoint checkpoint,\n                                                               int retryCount,\n                                                               int droppedItemCount,\n                                                               boolean fallbackAfterRetry) {\n        if (retryCount <= 0 && !fallbackAfterRetry) {\n            return checkpoint;\n        }\n        CodingSessionCheckpoint effective = copyCheckpoint(checkpoint);\n        if (effective == null) {\n            effective = CodingSessionCheckpoint.builder().build();\n        }\n        StringBuilder keyDecision = new StringBuilder();\n        keyDecision.append(\"**Compaction retry**: Summary prompt exceeded model context\");\n        if (fallbackAfterRetry) {\n            keyDecision.append(\" and fell back to the local checkpoint after \")\n                    .append(Math.max(1, retryCount))\n                    .append(\" retry attempt\");\n        } else {\n            keyDecision.append(\"; dropped \").append(Math.max(1, droppedItemCount))\n                    .append(\" oldest item\");\n            if (droppedItemCount != 1) {\n                keyDecision.append(\"s\");\n            }\n            keyDecision.append(\" across \").append(retryCount).append(\" retry attempt\");\n        }\n        if (retryCount != 1) {\n            keyDecision.append(\"s\");\n        }\n        keyDecision.append(\".\");\n        addUnique(effective.getKeyDecisions(), keyDecision.toString());\n        addUnique(effective.getCriticalContext(),\n                \"Oldest summarized context may be partially omitted because the compaction summary request exceeded model context.\");\n        return effective;\n    }\n\n    private String resolveLatestUserGoal(List<Object> items) {\n        if (items == null || items.isEmpty()) {\n            return null;\n        }\n        for (int i = items.size() - 1; i >= 0; i--) {\n            JSONObject object = toJSONObject(items.get(i));\n            if (!\"message\".equals(object.getString(\"type\")) || !\"user\".equals(object.getString(\"role\"))) {\n                continue;\n            }\n            String text = singleLine(extractMessageText(object.getJSONArray(\"content\")));\n            if (!isBlank(text)) {\n                return clip(text, 220);\n            }\n        }\n        return null;\n    }\n\n    private PromptTooLongRetrySlice truncateForPromptTooLongRetry(List<Object> items) {\n        if (items == null || items.size() <= 1) {\n            return null;\n        }\n        int totalTokens = 0;\n        for (Object item : items) {\n            totalTokens += estimateItemTokens(item);\n        }\n        int targetTokensToDrop = Math.max(1, (int) Math.ceil(totalTokens * PROMPT_TOO_LONG_RETRY_DROP_RATIO));\n        int accumulatedTokens = 0;\n        int candidateDropIndex = 1;\n        for (int i = 0; i < items.size() - 1; i++) {\n            accumulatedTokens += estimateItemTokens(items.get(i));\n            candidateDropIndex = i + 1;\n            if (accumulatedTokens >= targetTokensToDrop) {\n                break;\n            }\n        }\n        int adjustedDropIndex = adjustPromptTooLongRetryDropIndex(items, candidateDropIndex);\n        adjustedDropIndex = Math.min(items.size() - 1, Math.max(1, adjustedDropIndex));\n        return new PromptTooLongRetrySlice(copySlice(items, adjustedDropIndex, items.size()), adjustedDropIndex);\n    }\n\n    private int adjustPromptTooLongRetryDropIndex(List<Object> items, int candidateDropIndex) {\n        if (items == null || items.isEmpty()) {\n            return candidateDropIndex;\n        }\n        List<Integer> cutPoints = new ArrayList<Integer>();\n        for (int i = 1; i < items.size(); i++) {\n            if (isValidCutPoint(items.get(i))) {\n                cutPoints.add(i);\n            }\n        }\n        if (cutPoints.isEmpty()) {\n            return candidateDropIndex;\n        }\n        for (Integer cutPoint : cutPoints) {\n            if (cutPoint >= candidateDropIndex) {\n                return cutPoint;\n            }\n        }\n        return candidateDropIndex;\n    }\n\n    private boolean isPromptTooLongError(Throwable throwable) {\n        Throwable current = throwable;\n        while (current != null) {\n            String message = safeText(current.getMessage()).toLowerCase(Locale.ROOT);\n            if (message.contains(\"prompt too long\")\n                    || message.contains(\"prompt_too_long\")\n                    || message.contains(\"context length\")\n                    || message.contains(\"maximum context\")\n                    || message.contains(\"maximum context length\")\n                    || message.contains(\"too many tokens\")\n                    || message.contains(\"token limit\")\n                    || message.contains(\"context window\")\n                    || message.contains(\"request too large\")\n                    || message.contains(\"payload too large\")\n                    || message.contains(\"request entity too large\")\n                    || message.contains(\"input is too long\")\n                    || message.contains(\"status code: 413\")\n                    || message.contains(\"error code: 413\")) {\n                return true;\n            }\n            current = current.getCause();\n        }\n        return false;\n    }\n\n    private CodingSessionCheckpoint attachCheckpointMetadata(CodingSessionCheckpoint checkpoint,\n                                                             List<StoredProcessSnapshot> processSnapshots,\n                                                             int sourceItemCount,\n                                                             boolean splitTurn) {\n        CodingSessionCheckpoint effective = copyCheckpoint(checkpoint);\n        if (effective == null) {\n            effective = CodingSessionCheckpoint.builder().build();\n        }\n        if (isBlank(effective.getGoal())) {\n            effective.setGoal(\"Continue the current coding session.\");\n        }\n        effective.setProcessSnapshots(copyProcesses(processSnapshots));\n        effective.setSourceItemCount(sourceItemCount);\n        effective.setSplitTurn(splitTurn);\n        effective.setGeneratedAtEpochMs(System.currentTimeMillis());\n        return effective;\n    }\n\n    private CodingSessionCheckpoint copyCheckpoint(CodingSessionCheckpoint checkpoint) {\n        if (checkpoint == null) {\n            return null;\n        }\n        return CodingSessionCheckpoint.builder()\n                .goal(checkpoint.getGoal())\n                .constraints(copyStrings(checkpoint.getConstraints()))\n                .doneItems(copyStrings(checkpoint.getDoneItems()))\n                .inProgressItems(copyStrings(checkpoint.getInProgressItems()))\n                .blockedItems(copyStrings(checkpoint.getBlockedItems()))\n                .keyDecisions(copyStrings(checkpoint.getKeyDecisions()))\n                .nextSteps(copyStrings(checkpoint.getNextSteps()))\n                .criticalContext(copyStrings(checkpoint.getCriticalContext()))\n                .processSnapshots(copyProcesses(checkpoint.getProcessSnapshots()))\n                .generatedAtEpochMs(checkpoint.getGeneratedAtEpochMs())\n                .sourceItemCount(checkpoint.getSourceItemCount())\n                .splitTurn(checkpoint.isSplitTurn())\n                .build();\n    }\n\n    private List<String> copyStrings(List<String> values) {\n        if (values == null || values.isEmpty()) {\n            return new ArrayList<String>();\n        }\n        return new ArrayList<String>(values);\n    }\n\n    private List<StoredProcessSnapshot> copyProcesses(List<StoredProcessSnapshot> processSnapshots) {\n        if (processSnapshots == null || processSnapshots.isEmpty()) {\n            return new ArrayList<StoredProcessSnapshot>();\n        }\n        List<StoredProcessSnapshot> copies = new ArrayList<StoredProcessSnapshot>();\n        for (StoredProcessSnapshot snapshot : processSnapshots) {\n            if (snapshot != null) {\n                copies.add(snapshot.toBuilder().build());\n            }\n        }\n        return copies;\n    }\n\n    private boolean hasCheckpointContent(CodingSessionCheckpoint checkpoint) {\n        if (checkpoint == null) {\n            return false;\n        }\n        return !isBlank(checkpoint.getGoal())\n                || !copyStrings(checkpoint.getConstraints()).isEmpty()\n                || !copyStrings(checkpoint.getDoneItems()).isEmpty()\n                || !copyStrings(checkpoint.getInProgressItems()).isEmpty()\n                || !copyStrings(checkpoint.getBlockedItems()).isEmpty()\n                || !copyStrings(checkpoint.getKeyDecisions()).isEmpty()\n                || !copyStrings(checkpoint.getNextSteps()).isEmpty()\n                || !copyStrings(checkpoint.getCriticalContext()).isEmpty();\n    }\n\n    private boolean detectFallbackSummary(CodingSessionCheckpoint checkpoint) {\n        if (checkpoint == null || checkpoint.getKeyDecisions() == null) {\n            return false;\n        }\n        for (String keyDecision : checkpoint.getKeyDecisions()) {\n            String normalized = safeText(keyDecision);\n            if (normalized.contains(COMPACTION_FALLBACK_MARKER)\n                    || normalized.contains(SESSION_MEMORY_FALLBACK_MARKER)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private void addUnique(List<String> target, String value) {\n        if (target == null || isBlank(value)) {\n            return;\n        }\n        String normalized = value.trim();\n        for (String existing : target) {\n            if (normalized.equals(existing == null ? null : existing.trim())) {\n                return;\n            }\n        }\n        target.add(normalized);\n    }\n\n    private JSONObject toJSONObject(Object item) {\n        if (item == null) {\n            return new JSONObject();\n        }\n        if (item instanceof JSONObject) {\n            return (JSONObject) item;\n        }\n        if (item instanceof Map<?, ?>) {\n            JSONObject object = new JSONObject();\n            for (Map.Entry<?, ?> entry : ((Map<?, ?>) item).entrySet()) {\n                if (entry.getKey() != null) {\n                    object.put(String.valueOf(entry.getKey()), entry.getValue());\n                }\n            }\n            return object;\n        }\n        return JSON.parseObject(JSON.toJSONString(item));\n    }\n\n    private List<Object> copySlice(List<Object> rawItems, int from, int to) {\n        if (rawItems == null || rawItems.isEmpty() || from >= to) {\n            return Collections.emptyList();\n        }\n        return new ArrayList<Object>(rawItems.subList(Math.max(0, from), Math.min(rawItems.size(), to)));\n    }\n\n    private int estimateTextTokens(String text) {\n        return estimateChars(safeText(text).length());\n    }\n\n    private String resolveCheckpointStrategy(boolean aggressiveCompactionApplied, boolean checkpointReused) {\n        if (aggressiveCompactionApplied) {\n            return checkpointReused ? \"aggressive-checkpoint-delta\" : \"aggressive-checkpoint\";\n        }\n        return checkpointReused ? \"checkpoint-delta\" : \"checkpoint\";\n    }\n\n    private int safeSize(List<?> values) {\n        return values == null ? 0 : values.size();\n    }\n\n    private int estimateChars(int chars) {\n        return (chars + 3) / 4;\n    }\n\n    private String safeText(String value) {\n        return value == null ? \"\" : value;\n    }\n\n    private String singleLine(String value) {\n        return safeText(value).replace('\\r', ' ').replace('\\n', ' ').trim();\n    }\n\n    private String clip(String value, int maxChars) {\n        String text = safeText(value).trim();\n        if (text.length() <= maxChars) {\n            return text;\n        }\n        return text.substring(0, Math.max(0, maxChars - 3)) + \"...\";\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private String appendInstructions(String base, String extra) {\n        if (isBlank(base)) {\n            return extra;\n        }\n        if (isBlank(extra)) {\n            return base;\n        }\n        return base.trim() + \" \" + extra.trim();\n    }\n\n    private static final class CutPoint {\n\n        private final int firstKeptItemIndex;\n        private final int turnStartItemIndex;\n        private final boolean splitTurn;\n\n        private CutPoint(int firstKeptItemIndex, int turnStartItemIndex, boolean splitTurn) {\n            this.firstKeptItemIndex = firstKeptItemIndex;\n            this.turnStartItemIndex = turnStartItemIndex;\n            this.splitTurn = splitTurn;\n        }\n    }\n\n    private static final class PromptTooLongRetrySlice {\n\n        private final List<Object> items;\n        private final int droppedItemCount;\n\n        private PromptTooLongRetrySlice(List<Object> items, int droppedItemCount) {\n            this.items = items;\n            this.droppedItemCount = droppedItemCount;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/compact/CodingToolResultMicroCompactResult.java",
    "content": "package io.github.lnyocly.ai4j.coding.compact;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.List;\n\n@Data\n@Builder\npublic class CodingToolResultMicroCompactResult {\n\n    private List<Object> items;\n\n    private int beforeTokens;\n\n    private int afterTokens;\n\n    private int compactedToolResultCount;\n\n    private String summary;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/compact/CodingToolResultMicroCompactor.java",
    "content": "package io.github.lnyocly.ai4j.coding.compact;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.memory.MemorySnapshot;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\n\nimport java.util.ArrayList;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class CodingToolResultMicroCompactor {\n\n    private static final String COMPACTED_PREFIX = \"[tool result compacted to save context]\";\n    private static final int PREVIEW_CHARS = 240;\n\n    public CodingToolResultMicroCompactResult compact(MemorySnapshot snapshot,\n                                                      CodingAgentOptions options,\n                                                      int targetTokens) {\n        if (snapshot == null || options == null || !options.isToolResultMicroCompactEnabled()) {\n            return null;\n        }\n        List<Object> rawItems = snapshot.getItems() == null\n                ? new ArrayList<Object>()\n                : new ArrayList<Object>(snapshot.getItems());\n        if (rawItems.isEmpty()) {\n            return null;\n        }\n\n        int beforeTokens = estimateContextTokens(rawItems, snapshot.getSummary());\n        if (beforeTokens <= targetTokens) {\n            return null;\n        }\n\n        List<Integer> toolResultIndexes = collectToolResultIndexes(rawItems);\n        int keepRecent = Math.max(0, options.getToolResultMicroCompactKeepRecent());\n        if (toolResultIndexes.size() <= keepRecent) {\n            return null;\n        }\n        Set<Integer> protectedIndexes = new HashSet<Integer>();\n        for (int i = Math.max(0, toolResultIndexes.size() - keepRecent); i < toolResultIndexes.size(); i++) {\n            protectedIndexes.add(toolResultIndexes.get(i));\n        }\n\n        int maxToolResultTokens = Math.max(1, options.getToolResultMicroCompactMaxTokens());\n        List<Object> compactedItems = new ArrayList<Object>(rawItems.size());\n        int compactedCount = 0;\n        for (int i = 0; i < rawItems.size(); i++) {\n            Object item = rawItems.get(i);\n            if (shouldCompactToolResult(item, i, protectedIndexes, maxToolResultTokens)) {\n                compactedItems.add(compactToolResult(item));\n                compactedCount += 1;\n            } else {\n                compactedItems.add(copyItem(item));\n            }\n        }\n        if (compactedCount == 0) {\n            return null;\n        }\n\n        int afterTokens = estimateContextTokens(compactedItems, snapshot.getSummary());\n        return CodingToolResultMicroCompactResult.builder()\n                .items(compactedItems)\n                .beforeTokens(beforeTokens)\n                .afterTokens(afterTokens)\n                .compactedToolResultCount(compactedCount)\n                .summary(buildSummary(compactedCount, beforeTokens, afterTokens))\n                .build();\n    }\n\n    private List<Integer> collectToolResultIndexes(List<Object> rawItems) {\n        List<Integer> indexes = new ArrayList<Integer>();\n        for (int i = 0; i < rawItems.size(); i++) {\n            JSONObject object = toJSONObject(rawItems.get(i));\n            if (\"function_call_output\".equals(object.getString(\"type\"))) {\n                indexes.add(i);\n            }\n        }\n        return indexes;\n    }\n\n    private boolean shouldCompactToolResult(Object item,\n                                            int index,\n                                            Set<Integer> protectedIndexes,\n                                            int maxToolResultTokens) {\n        if (protectedIndexes.contains(index)) {\n            return false;\n        }\n        JSONObject object = toJSONObject(item);\n        if (!\"function_call_output\".equals(object.getString(\"type\"))) {\n            return false;\n        }\n        String output = safeText(object.getString(\"output\"));\n        if (output.isEmpty() || output.startsWith(COMPACTED_PREFIX)) {\n            return false;\n        }\n        return estimateTextTokens(output) > maxToolResultTokens;\n    }\n\n    private Object compactToolResult(Object item) {\n        JSONObject object = toJSONObject(copyItem(item));\n        String callId = safeText(object.getString(\"call_id\"));\n        String output = safeText(object.getString(\"output\"));\n        String preview = output.replace('\\r', ' ').replace('\\n', ' ').trim();\n        if (preview.length() > PREVIEW_CHARS) {\n            preview = preview.substring(0, PREVIEW_CHARS).trim() + \"...\";\n        }\n        StringBuilder compacted = new StringBuilder(COMPACTED_PREFIX);\n        if (!callId.isEmpty()) {\n            compacted.append(\" call_id=\").append(callId).append(\".\");\n        }\n        if (!preview.isEmpty()) {\n            compacted.append(\" Preview: \").append(preview);\n        }\n        object.put(\"output\", compacted.toString());\n        return object;\n    }\n\n    private String buildSummary(int compactedCount, int beforeTokens, int afterTokens) {\n        StringBuilder summary = new StringBuilder();\n        summary.append(\"Micro-compacted \").append(compactedCount).append(\" older tool result\");\n        if (compactedCount != 1) {\n            summary.append(\"s\");\n        }\n        summary.append(\" to reduce context pressure (\");\n        summary.append(beforeTokens).append(\"->\").append(afterTokens).append(\" tokens).\");\n        return summary.toString();\n    }\n\n    private int estimateContextTokens(List<Object> rawItems, String summary) {\n        int tokens = isBlank(summary) ? 0 : estimateTextTokens(summary);\n        if (rawItems != null) {\n            for (Object rawItem : rawItems) {\n                tokens += estimateItemTokens(rawItem);\n            }\n        }\n        return tokens;\n    }\n\n    private int estimateItemTokens(Object item) {\n        JSONObject object = toJSONObject(item);\n        String type = object.getString(\"type\");\n        if (\"function_call_output\".equals(type)) {\n            return estimateTextTokens(object.getString(\"output\"));\n        }\n        if (\"message\".equals(type)) {\n            JSONArray content = object.getJSONArray(\"content\");\n            if (content == null) {\n                return estimateTextTokens(object.toJSONString());\n            }\n            int chars = 0;\n            for (int i = 0; i < content.size(); i++) {\n                JSONObject part = content.getJSONObject(i);\n                if (part == null) {\n                    continue;\n                }\n                String partType = part.getString(\"type\");\n                if (\"input_text\".equals(partType) || \"output_text\".equals(partType)) {\n                    chars += safeText(part.getString(\"text\")).length();\n                } else if (\"input_image\".equals(partType)) {\n                    chars += 4800;\n                } else {\n                    chars += part.toJSONString().length();\n                }\n            }\n            return estimateChars(chars);\n        }\n        return estimateTextTokens(object.toJSONString());\n    }\n\n    private int estimateTextTokens(String text) {\n        return estimateChars(safeText(text).length());\n    }\n\n    private int estimateChars(int chars) {\n        return (chars + 3) / 4;\n    }\n\n    private Object copyItem(Object item) {\n        if (item == null) {\n            return null;\n        }\n        return JSON.parse(JSON.toJSONString(item));\n    }\n\n    private JSONObject toJSONObject(Object item) {\n        if (item == null) {\n            return new JSONObject();\n        }\n        if (item instanceof JSONObject) {\n            return (JSONObject) item;\n        }\n        if (item instanceof Map<?, ?>) {\n            JSONObject object = new JSONObject();\n            for (Map.Entry<?, ?> entry : ((Map<?, ?>) item).entrySet()) {\n                if (entry.getKey() != null) {\n                    object.put(String.valueOf(entry.getKey()), entry.getValue());\n                }\n            }\n            return object;\n        }\n        return JSON.parseObject(JSON.toJSONString(item));\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private String safeText(String value) {\n        return value == null ? \"\" : value;\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/BuiltInCodingAgentDefinitions.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\n\nimport java.util.Arrays;\nimport java.util.List;\n\npublic final class BuiltInCodingAgentDefinitions {\n\n    public static final String GENERAL_PURPOSE = \"general-purpose\";\n    public static final String EXPLORE = \"explore\";\n    public static final String PLAN = \"plan\";\n    public static final String VERIFICATION = \"verification\";\n\n    private static final List<CodingAgentDefinition> DEFINITIONS = Arrays.asList(\n            CodingAgentDefinition.builder()\n                    .name(GENERAL_PURPOSE)\n                    .toolName(\"delegate_general_purpose\")\n                    .description(\"General coding worker with read, write, patch, and shell access.\")\n                    .instructions(\"You are the default coding worker. Inspect the workspace, make focused edits when needed, and finish the assigned coding task directly.\")\n                    .allowedToolNames(CodingToolNames.allBuiltIn())\n                    .sessionMode(CodingSessionMode.FORK)\n                    .isolationMode(CodingIsolationMode.WRITE_ENABLED)\n                    .memoryScope(CodingMemoryScope.FORK)\n                    .background(false)\n                    .build(),\n            CodingAgentDefinition.builder()\n                    .name(EXPLORE)\n                    .toolName(\"delegate_explore\")\n                    .description(\"Read-only codebase explorer for answering concrete repository questions.\")\n                    .instructions(\"You are a read-only exploration worker. Search, read files, and inspect the workspace, but do not modify files.\")\n                    .allowedToolNames(CodingToolNames.readOnlyBuiltIn())\n                    .sessionMode(CodingSessionMode.FORK)\n                    .isolationMode(CodingIsolationMode.READ_ONLY)\n                    .memoryScope(CodingMemoryScope.FORK)\n                    .background(false)\n                    .build(),\n            CodingAgentDefinition.builder()\n                    .name(PLAN)\n                    .toolName(\"delegate_plan\")\n                    .description(\"Read-only planner for design, decomposition, and implementation planning.\")\n                    .instructions(\"You are a planning worker. Read the codebase, identify constraints, and produce a concrete implementation plan without editing files.\")\n                    .allowedToolNames(CodingToolNames.readOnlyBuiltIn())\n                    .sessionMode(CodingSessionMode.FORK)\n                    .isolationMode(CodingIsolationMode.READ_ONLY)\n                    .memoryScope(CodingMemoryScope.FORK)\n                    .background(false)\n                    .build(),\n            CodingAgentDefinition.builder()\n                    .name(VERIFICATION)\n                    .toolName(\"delegate_verification\")\n                    .description(\"Read-only verification worker for checks, builds, and validation.\")\n                    .instructions(\"You are a verification worker. Use read-only inspection and shell validation to verify changes, summarize risks, and report findings clearly.\")\n                    .allowedToolNames(CodingToolNames.readOnlyBuiltIn())\n                    .sessionMode(CodingSessionMode.FORK)\n                    .isolationMode(CodingIsolationMode.READ_ONLY)\n                    .memoryScope(CodingMemoryScope.FORK)\n                    .background(true)\n                    .build()\n    );\n\n    private BuiltInCodingAgentDefinitions() {\n    }\n\n    public static List<CodingAgentDefinition> list() {\n        return DEFINITIONS;\n    }\n\n    public static CodingAgentDefinitionRegistry registry() {\n        return new StaticCodingAgentDefinitionRegistry(DEFINITIONS);\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingAgentDefinition.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.Set;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingAgentDefinition {\n\n    private String name;\n\n    private String description;\n\n    private String toolName;\n\n    private String model;\n\n    private String instructions;\n\n    private String systemPrompt;\n\n    private Set<String> allowedToolNames;\n\n    @Builder.Default\n    private CodingSessionMode sessionMode = CodingSessionMode.FORK;\n\n    @Builder.Default\n    private CodingIsolationMode isolationMode = CodingIsolationMode.INHERIT;\n\n    @Builder.Default\n    private CodingMemoryScope memoryScope = CodingMemoryScope.INHERIT;\n\n    @Builder.Default\n    private CodingApprovalMode approvalMode = CodingApprovalMode.INHERIT;\n\n    @Builder.Default\n    private boolean background = false;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingAgentDefinitionRegistry.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\nimport java.util.List;\n\npublic interface CodingAgentDefinitionRegistry {\n\n    CodingAgentDefinition getDefinition(String nameOrToolName);\n\n    List<CodingAgentDefinition> listDefinitions();\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingApprovalMode.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\npublic enum CodingApprovalMode {\n    INHERIT,\n    AUTO,\n    MANUAL\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingIsolationMode.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\npublic enum CodingIsolationMode {\n    INHERIT,\n    READ_ONLY,\n    WRITE_ENABLED\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingMemoryScope.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\npublic enum CodingMemoryScope {\n    INHERIT,\n    FRESH,\n    FORK,\n    SHARED\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CodingSessionMode.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\npublic enum CodingSessionMode {\n    NEW,\n    FORK,\n    SHARED\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/CompositeCodingAgentDefinitionRegistry.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.Locale;\n\npublic class CompositeCodingAgentDefinitionRegistry implements CodingAgentDefinitionRegistry {\n\n    private final List<CodingAgentDefinitionRegistry> registries;\n\n    public CompositeCodingAgentDefinitionRegistry(CodingAgentDefinitionRegistry... registries) {\n        this(registries == null ? Collections.<CodingAgentDefinitionRegistry>emptyList() : Arrays.asList(registries));\n    }\n\n    public CompositeCodingAgentDefinitionRegistry(List<CodingAgentDefinitionRegistry> registries) {\n        if (registries == null || registries.isEmpty()) {\n            this.registries = Collections.emptyList();\n            return;\n        }\n        List<CodingAgentDefinitionRegistry> ordered = new ArrayList<CodingAgentDefinitionRegistry>();\n        for (CodingAgentDefinitionRegistry registry : registries) {\n            if (registry != null) {\n                ordered.add(registry);\n            }\n        }\n        this.registries = ordered.isEmpty()\n                ? Collections.<CodingAgentDefinitionRegistry>emptyList()\n                : Collections.unmodifiableList(ordered);\n    }\n\n    @Override\n    public CodingAgentDefinition getDefinition(String nameOrToolName) {\n        if (isBlank(nameOrToolName)) {\n            return null;\n        }\n        for (int i = registries.size() - 1; i >= 0; i--) {\n            CodingAgentDefinitionRegistry registry = registries.get(i);\n            CodingAgentDefinition definition = registry == null ? null : registry.getDefinition(nameOrToolName);\n            if (definition != null) {\n                return definition;\n            }\n        }\n        return null;\n    }\n\n    @Override\n    public List<CodingAgentDefinition> listDefinitions() {\n        if (registries.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<CodingAgentDefinition> ordered = new ArrayList<CodingAgentDefinition>();\n        for (CodingAgentDefinitionRegistry registry : registries) {\n            if (registry == null || registry.listDefinitions() == null) {\n                continue;\n            }\n            for (CodingAgentDefinition definition : registry.listDefinitions()) {\n                mergeDefinition(ordered, definition);\n            }\n        }\n        return ordered.isEmpty()\n                ? Collections.<CodingAgentDefinition>emptyList()\n                : Collections.unmodifiableList(ordered);\n    }\n\n    private void mergeDefinition(List<CodingAgentDefinition> ordered, CodingAgentDefinition candidate) {\n        if (ordered == null || candidate == null || isBlank(candidate.getName())) {\n            return;\n        }\n        String candidateName = normalize(candidate.getName());\n        String candidateToolName = normalize(candidate.getToolName());\n        for (Iterator<CodingAgentDefinition> iterator = ordered.iterator(); iterator.hasNext(); ) {\n            CodingAgentDefinition existing = iterator.next();\n            if (existing == null) {\n                iterator.remove();\n                continue;\n            }\n            if (sameKey(candidateName, existing.getName())\n                    || sameKey(candidateToolName, existing.getToolName())\n                    || sameKey(candidateName, existing.getToolName())\n                    || sameKey(candidateToolName, existing.getName())) {\n                iterator.remove();\n            }\n        }\n        ordered.add(candidate);\n    }\n\n    private boolean sameKey(String left, String right) {\n        String normalizedRight = normalize(right);\n        return left != null && normalizedRight != null && left.equals(normalizedRight);\n    }\n\n    private String normalize(String value) {\n        return isBlank(value) ? null : value.trim().toLowerCase(Locale.ROOT);\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/definition/StaticCodingAgentDefinitionRegistry.java",
    "content": "package io.github.lnyocly.ai4j.coding.definition;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\npublic class StaticCodingAgentDefinitionRegistry implements CodingAgentDefinitionRegistry {\n\n    private final Map<String, CodingAgentDefinition> definitions;\n    private final List<CodingAgentDefinition> orderedDefinitions;\n\n    public StaticCodingAgentDefinitionRegistry(List<CodingAgentDefinition> definitions) {\n        if (definitions == null || definitions.isEmpty()) {\n            this.definitions = Collections.emptyMap();\n            this.orderedDefinitions = Collections.emptyList();\n            return;\n        }\n        Map<String, CodingAgentDefinition> map = new LinkedHashMap<String, CodingAgentDefinition>();\n        List<CodingAgentDefinition> ordered = new ArrayList<CodingAgentDefinition>();\n        for (CodingAgentDefinition definition : definitions) {\n            if (definition == null || isBlank(definition.getName())) {\n                continue;\n            }\n            CodingAgentDefinition normalized = definition.toBuilder().build();\n            register(map, normalized.getName(), normalized);\n            if (!isBlank(normalized.getToolName())) {\n                register(map, normalized.getToolName(), normalized);\n            }\n            ordered.add(normalized);\n        }\n        this.definitions = Collections.unmodifiableMap(map);\n        this.orderedDefinitions = Collections.unmodifiableList(ordered);\n    }\n\n    @Override\n    public CodingAgentDefinition getDefinition(String nameOrToolName) {\n        if (isBlank(nameOrToolName)) {\n            return null;\n        }\n        return definitions.get(normalize(nameOrToolName));\n    }\n\n    @Override\n    public List<CodingAgentDefinition> listDefinitions() {\n        return orderedDefinitions;\n    }\n\n    private void register(Map<String, CodingAgentDefinition> map, String key, CodingAgentDefinition definition) {\n        String normalized = normalize(key);\n        if (map.containsKey(normalized)) {\n            throw new IllegalArgumentException(\"Duplicate coding agent definition: \" + key);\n        }\n        map.put(normalized, definition);\n    }\n\n    private String normalize(String value) {\n        return value == null ? null : value.trim().toLowerCase(Locale.ROOT);\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/delegate/CodingDelegateRequest.java",
    "content": "package io.github.lnyocly.ai4j.coding.delegate;\n\nimport io.github.lnyocly.ai4j.coding.definition.CodingSessionMode;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingDelegateRequest {\n\n    private String definitionName;\n\n    private String input;\n\n    private String context;\n\n    private String childSessionId;\n\n    private Boolean background;\n\n    private CodingSessionMode sessionMode;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/delegate/CodingDelegateResult.java",
    "content": "package io.github.lnyocly.ai4j.coding.delegate;\n\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskStatus;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingDelegateResult {\n\n    private String taskId;\n\n    private String definitionName;\n\n    private String parentSessionId;\n\n    private String childSessionId;\n\n    private boolean background;\n\n    private CodingTaskStatus status;\n\n    private String outputText;\n\n    private String error;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/delegate/CodingDelegateToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.coding.delegate;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSessionScope;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.definition.CodingSessionMode;\nimport io.github.lnyocly.ai4j.coding.runtime.CodingRuntime;\n\nimport java.util.Locale;\n\npublic class CodingDelegateToolExecutor implements ToolExecutor {\n\n    private final CodingRuntime runtime;\n    private final CodingAgentDefinitionRegistry definitionRegistry;\n\n    public CodingDelegateToolExecutor(CodingRuntime runtime, CodingAgentDefinitionRegistry definitionRegistry) {\n        this.runtime = runtime;\n        this.definitionRegistry = definitionRegistry;\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        if (call == null || isBlank(call.getName())) {\n            throw new IllegalArgumentException(\"delegate tool call is invalid\");\n        }\n        if (runtime == null) {\n            throw new IllegalStateException(\"coding runtime is required for delegate tools\");\n        }\n        CodingSession session = CodingSessionScope.currentSession();\n        if (session == null) {\n            throw new IllegalStateException(\"delegate tools require an active coding session\");\n        }\n\n        CodingAgentDefinition definition = requireDefinition(call.getName());\n        JSONObject arguments = parseArguments(call.getArguments());\n        CodingDelegateRequest request = CodingDelegateRequest.builder()\n                .definitionName(definition.getName())\n                .input(firstNonBlank(arguments.getString(\"task\"), arguments.getString(\"input\")))\n                .context(arguments.getString(\"context\"))\n                .childSessionId(arguments.getString(\"childSessionId\"))\n                .background(parseBoolean(arguments, \"background\"))\n                .sessionMode(parseSessionMode(arguments.getString(\"sessionMode\")))\n                .build();\n\n        CodingDelegateResult result = runtime.delegate(session, request);\n        JSONObject payload = new JSONObject();\n        payload.put(\"definitionName\", result == null ? definition.getName() : result.getDefinitionName());\n        payload.put(\"toolName\", definition.getToolName());\n        payload.put(\"taskId\", result == null ? null : result.getTaskId());\n        payload.put(\"parentSessionId\", result == null ? session.getSessionId() : result.getParentSessionId());\n        payload.put(\"childSessionId\", result == null ? null : result.getChildSessionId());\n        payload.put(\"background\", result != null && result.isBackground());\n        payload.put(\"status\", result == null || result.getStatus() == null ? null : result.getStatus().name().toLowerCase(Locale.ROOT));\n        payload.put(\"output\", result == null ? null : result.getOutputText());\n        payload.put(\"error\", result == null ? null : result.getError());\n        return payload.toJSONString();\n    }\n\n    private CodingAgentDefinition requireDefinition(String toolName) {\n        CodingAgentDefinition definition = definitionRegistry == null ? null : definitionRegistry.getDefinition(toolName);\n        if (definition == null) {\n            throw new IllegalArgumentException(\"Unknown delegate tool: \" + toolName);\n        }\n        return definition;\n    }\n\n    private JSONObject parseArguments(String raw) {\n        if (isBlank(raw)) {\n            return new JSONObject();\n        }\n        try {\n            JSONObject object = JSON.parseObject(raw);\n            return object == null ? new JSONObject() : object;\n        } catch (Exception ignored) {\n            JSONObject object = new JSONObject();\n            object.put(\"task\", raw);\n            return object;\n        }\n    }\n\n    private Boolean parseBoolean(JSONObject arguments, String key) {\n        if (arguments == null || key == null || !arguments.containsKey(key)) {\n            return null;\n        }\n        return arguments.getBoolean(key);\n    }\n\n    private CodingSessionMode parseSessionMode(String raw) {\n        if (isBlank(raw)) {\n            return null;\n        }\n        try {\n            return CodingSessionMode.valueOf(raw.trim().toUpperCase(Locale.ROOT));\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/delegate/CodingDelegateToolRegistry.java",
    "content": "package io.github.lnyocly.ai4j.coding.delegate;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class CodingDelegateToolRegistry implements AgentToolRegistry {\n\n    private final List<Object> tools;\n\n    public CodingDelegateToolRegistry(CodingAgentDefinitionRegistry definitionRegistry) {\n        List<CodingAgentDefinition> definitions = definitionRegistry == null\n                ? Collections.<CodingAgentDefinition>emptyList()\n                : definitionRegistry.listDefinitions();\n        if (definitions == null || definitions.isEmpty()) {\n            this.tools = Collections.emptyList();\n            return;\n        }\n        List<Object> resolved = new ArrayList<Object>();\n        for (CodingAgentDefinition definition : definitions) {\n            Tool tool = createTool(definition);\n            if (tool != null) {\n                resolved.add(tool);\n            }\n        }\n        this.tools = Collections.unmodifiableList(resolved);\n    }\n\n    @Override\n    public List<Object> getTools() {\n        return new ArrayList<Object>(tools);\n    }\n\n    private Tool createTool(CodingAgentDefinition definition) {\n        if (definition == null || isBlank(definition.getToolName())) {\n            return null;\n        }\n        Map<String, Tool.Function.Property> properties = new LinkedHashMap<String, Tool.Function.Property>();\n        properties.put(\"task\", property(\"string\", \"Task to delegate to this coding worker.\"));\n        properties.put(\"context\", property(\"string\", \"Optional extra context for the delegated task.\"));\n        properties.put(\"background\", property(\"boolean\", \"Whether to run the delegated task in the background.\"));\n        properties.put(\"sessionMode\", property(\"string\", \"Optional session mode override: new or fork.\"));\n        properties.put(\"childSessionId\", property(\"string\", \"Optional child session id override.\"));\n\n        Tool.Function.Parameter parameter = new Tool.Function.Parameter(\"object\", properties, Arrays.asList(\"task\"));\n        Tool.Function function = new Tool.Function(\n                definition.getToolName(),\n                resolveDescription(definition),\n                parameter\n        );\n        Tool tool = new Tool();\n        tool.setType(\"function\");\n        tool.setFunction(function);\n        return tool;\n    }\n\n    private String resolveDescription(CodingAgentDefinition definition) {\n        String description = definition.getDescription();\n        if (!isBlank(description)) {\n            return description;\n        }\n        return \"Delegate a coding task to worker \" + definition.getName();\n    }\n\n    private Tool.Function.Property property(String type, String description) {\n        Tool.Function.Property property = new Tool.Function.Property();\n        property.setType(type);\n        property.setDescription(description);\n        return property;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingAgentLoopController.java",
    "content": "package io.github.lnyocly.ai4j.coding.loop;\n\nimport io.github.lnyocly.ai4j.agent.event.AgentEvent;\nimport io.github.lnyocly.ai4j.agent.event.AgentListener;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolResult;\nimport io.github.lnyocly.ai4j.coding.CodingAgentRequest;\nimport io.github.lnyocly.ai4j.coding.CodingAgentResult;\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.regex.Pattern;\n\npublic class CodingAgentLoopController {\n\n    private static final String TOOL_ERROR_PREFIX = \"TOOL_ERROR:\";\n    private static final String APPROVAL_REJECTED_PREFIX = \"[approval-rejected]\";\n    private static final Pattern QUESTION_PATTERN = Pattern.compile(\n            \"(\\\\?|？|\\\\b(could you|can you|would you like|do you want|which|what|please confirm|need your input|need approval)\\\\b|请确认|请提供|你希望|是否需要)\",\n            Pattern.CASE_INSENSITIVE\n    );\n    private static final Pattern COMPLETION_PATTERN = Pattern.compile(\n            \"^(done|completed|finished|implemented|updated|fixed|resolved|summary:|here('| i)s|已完成|完成了|已经|已实现|已修复|已更新|总结)\",\n            Pattern.CASE_INSENSITIVE\n    );\n    private static final Pattern CONTINUE_PATTERN = Pattern.compile(\n            \"\\\\b(next|continue|continuing|proceed|then I will|I'll next|still need to|remaining work)\\\\b|继续|下一步|接下来|仍需\",\n            Pattern.CASE_INSENSITIVE\n    );\n\n    public CodingAgentResult run(CodingSession session, CodingAgentRequest request) throws Exception {\n        return execute(session, request, null, false);\n    }\n\n    public CodingAgentResult runStream(CodingSession session,\n                                       CodingAgentRequest request,\n                                       AgentListener listener) throws Exception {\n        return execute(session, request, listener, true);\n    }\n\n    private CodingAgentResult execute(CodingSession session,\n                                      CodingAgentRequest request,\n                                      AgentListener listener,\n                                      boolean stream) throws Exception {\n        if (session == null) {\n            throw new IllegalArgumentException(\"session is required\");\n        }\n        CodingLoopPolicy policy = CodingLoopPolicy.from(session.getOptions());\n        List<io.github.lnyocly.ai4j.agent.tool.AgentToolCall> aggregatedCalls = new ArrayList<io.github.lnyocly.ai4j.agent.tool.AgentToolCall>();\n        List<AgentToolResult> aggregatedResults = new ArrayList<AgentToolResult>();\n        CodingAgentResult lastResult = null;\n        int totalSteps = 0;\n        int turns = 0;\n        int autoFollowUps = 0;\n        String continuationPrompt = null;\n\n        while (true) {\n            throwIfInterrupted();\n            if (hasPositiveLimit(policy.getMaxTotalTurns()) && turns >= policy.getMaxTotalTurns()) {\n                CodingLoopDecision forcedStop = stopDecision(turns, CodingStopReason.MAX_TOTAL_TURNS_REACHED,\n                        \"Stopped after reaching the total turn limit.\");\n                session.recordLoopDecision(forcedStop);\n                return aggregate(session, lastResult, aggregatedCalls, aggregatedResults, totalSteps, turns, autoFollowUps, forcedStop);\n            }\n\n            AgentListener effectiveListener = listener == null ? null : new StepOffsetAgentListener(listener, totalSteps);\n            CodingAgentResult turnResult = stream\n                    ? session.runSingleTurnStream(turnRequest(request, continuationPrompt), effectiveListener, continuationPrompt)\n                    : session.runSingleTurn(turnRequest(request, continuationPrompt), continuationPrompt);\n            turns += 1;\n            totalSteps += turnResult == null ? 0 : turnResult.getSteps();\n            if (turnResult != null && turnResult.getToolCalls() != null) {\n                aggregatedCalls.addAll(turnResult.getToolCalls());\n            }\n            if (turnResult != null && turnResult.getToolResults() != null) {\n                aggregatedResults.addAll(turnResult.getToolResults());\n            }\n            lastResult = turnResult;\n\n            CodingLoopDecision decision = decide(\n                    policy,\n                    turnResult,\n                    session.getLastAutoCompactResult(),\n                    session.getLastAutoCompactError(),\n                    turns,\n                    autoFollowUps\n            );\n            session.recordLoopDecision(decision);\n\n            if (!decision.isContinueLoop()) {\n                return aggregate(session, lastResult, aggregatedCalls, aggregatedResults, totalSteps, turns, autoFollowUps, decision);\n            }\n\n            autoFollowUps += 1;\n            continuationPrompt = decision.getContinuationPrompt();\n            request = null;\n        }\n    }\n\n    private CodingAgentRequest turnRequest(CodingAgentRequest request, String continuationPrompt) {\n        if (continuationPrompt == null || continuationPrompt.trim().isEmpty()) {\n            return request;\n        }\n        return CodingAgentRequest.builder().build();\n    }\n\n    private CodingLoopDecision decide(CodingLoopPolicy policy,\n                                      CodingAgentResult result,\n                                      CodingSessionCompactResult compactResult,\n                                      Exception compactError,\n                                      int turnNumber,\n                                      int autoFollowUpsSoFar) {\n        String outputText = result == null || result.getOutputText() == null ? \"\" : result.getOutputText().trim();\n        boolean compactApplied = compactResult != null;\n        boolean approvalBlocked = hasApprovalBlockedResult(result);\n        boolean toolError = hasToolError(result);\n        boolean explicitQuestion = policy.isStopOnExplicitQuestion() && looksLikeQuestion(outputText);\n        boolean candidateContinue = shouldContinue(policy, result, compactApplied, outputText);\n\n        if (approvalBlocked && policy.isStopOnApprovalBlock()) {\n            return stopDecision(turnNumber, CodingStopReason.BLOCKED_BY_APPROVAL, \"Stopped because tool approval was rejected.\")\n                    .toBuilder()\n                    .compactApplied(compactApplied)\n                    .build();\n        }\n        if (explicitQuestion) {\n            return stopDecision(turnNumber, CodingStopReason.NEEDS_USER_INPUT, \"Stopped because the assistant asked for user input.\")\n                    .toBuilder()\n                    .compactApplied(compactApplied)\n                    .build();\n        }\n        if (toolError && !looksLikeCompleted(outputText)) {\n            return stopDecision(turnNumber, CodingStopReason.BLOCKED_BY_TOOL_ERROR, \"Stopped because a tool failed and the task could not continue safely.\")\n                    .toBuilder()\n                    .compactApplied(compactApplied)\n                    .build();\n        }\n        if (compactError != null && candidateContinue) {\n            return stopDecision(turnNumber, CodingStopReason.ERROR, \"Stopped because automatic compaction failed before continuation.\")\n                    .toBuilder()\n                    .compactApplied(compactApplied)\n                    .build();\n        }\n        if (!candidateContinue || !policy.isAutoContinueEnabled()) {\n            return stopDecision(turnNumber, CodingStopReason.COMPLETED, \"Stopped after the assistant completed the current task turn.\")\n                    .toBuilder()\n                    .compactApplied(compactApplied)\n                    .build();\n        }\n        if (hasPositiveLimit(policy.getMaxAutoFollowUps()) && autoFollowUpsSoFar >= policy.getMaxAutoFollowUps()) {\n            return stopDecision(turnNumber, CodingStopReason.MAX_AUTO_FOLLOWUPS_REACHED,\n                    \"Stopped after reaching the auto-follow-up limit.\")\n                    .toBuilder()\n                    .compactApplied(compactApplied)\n                    .build();\n        }\n        if (hasPositiveLimit(policy.getMaxTotalTurns()) && turnNumber >= policy.getMaxTotalTurns()) {\n            return stopDecision(turnNumber, CodingStopReason.MAX_TOTAL_TURNS_REACHED,\n                    \"Stopped after reaching the total turn limit.\")\n                    .toBuilder()\n                    .compactApplied(compactApplied)\n                    .build();\n        }\n        String continueReason = resolveContinueReason(result, compactApplied, outputText);\n        CodingLoopDecision seedDecision = CodingLoopDecision.builder()\n                .turnNumber(turnNumber)\n                .continueLoop(true)\n                .continueReason(continueReason)\n                .summary(buildContinueSummary(turnNumber, continueReason, compactApplied))\n                .compactApplied(compactApplied)\n                .build();\n        return seedDecision.toBuilder()\n                .continuationPrompt(CodingContinuationPrompt.build(seedDecision, result, compactResult, turnNumber + 1))\n                .build();\n    }\n\n    private boolean shouldContinue(CodingLoopPolicy policy,\n                                   CodingAgentResult result,\n                                   boolean compactApplied,\n                                   String outputText) {\n        if (!policy.isAutoContinueEnabled()) {\n            return false;\n        }\n        if (looksLikeCompleted(outputText)) {\n            return false;\n        }\n        if (result != null && result.getToolCalls() != null && !result.getToolCalls().isEmpty()) {\n            return outputText.isEmpty() || looksTransitional(outputText);\n        }\n        if (compactApplied && policy.isContinueAfterCompact()) {\n            return outputText.isEmpty() || looksTransitional(outputText);\n        }\n        return outputText.isEmpty() || looksTransitional(outputText);\n    }\n\n    private String resolveContinueReason(CodingAgentResult result, boolean compactApplied, String outputText) {\n        if (compactApplied) {\n            return CodingLoopDecision.CONTINUE_AFTER_COMPACTION;\n        }\n        if (result != null && result.getToolCalls() != null && !result.getToolCalls().isEmpty()) {\n            return CodingLoopDecision.CONTINUE_AFTER_TOOL_WORK;\n        }\n        if (outputText.isEmpty() || looksTransitional(outputText)) {\n            return CodingLoopDecision.CONTINUE_AUTONOMOUS_WORK;\n        }\n        return CodingLoopDecision.CONTINUE_AFTER_TOOL_WORK;\n    }\n\n    private String buildContinueSummary(int turnNumber, String continueReason, boolean compactApplied) {\n        StringBuilder summary = new StringBuilder();\n        summary.append(\"Auto-continue after turn \").append(turnNumber);\n        if (continueReason != null && !continueReason.trim().isEmpty()) {\n            summary.append(\" (\").append(continueReason).append(\")\");\n        }\n        if (compactApplied) {\n            summary.append(\" with compacted context\");\n        }\n        summary.append(\".\");\n        return summary.toString();\n    }\n\n    private CodingLoopDecision stopDecision(int turnNumber, CodingStopReason stopReason, String summary) {\n        return CodingLoopDecision.builder()\n                .turnNumber(turnNumber)\n                .continueLoop(false)\n                .stopReason(stopReason)\n                .summary(summary)\n                .build();\n    }\n\n    private CodingAgentResult aggregate(CodingSession session,\n                                        CodingAgentResult lastResult,\n                                        List<io.github.lnyocly.ai4j.agent.tool.AgentToolCall> aggregatedCalls,\n                                        List<AgentToolResult> aggregatedResults,\n                                        int totalSteps,\n                                        int turns,\n                                        int autoFollowUps,\n                                        CodingLoopDecision decision) {\n        return CodingAgentResult.builder()\n                .sessionId(session == null ? null : session.getSessionId())\n                .outputText(lastResult == null ? null : lastResult.getOutputText())\n                .rawResponse(lastResult == null ? null : lastResult.getRawResponse())\n                .toolCalls(aggregatedCalls.isEmpty() ? Collections.<io.github.lnyocly.ai4j.agent.tool.AgentToolCall>emptyList() : aggregatedCalls)\n                .toolResults(aggregatedResults.isEmpty() ? Collections.<AgentToolResult>emptyList() : aggregatedResults)\n                .steps(totalSteps)\n                .turns(turns)\n                .stopReason(decision == null ? null : decision.getStopReason())\n                .autoContinued(autoFollowUps > 0)\n                .autoFollowUpCount(autoFollowUps)\n                .lastCompactApplied(decision != null && decision.isCompactApplied())\n                .build();\n    }\n\n    private boolean hasApprovalBlockedResult(CodingAgentResult result) {\n        if (result == null || result.getToolResults() == null) {\n            return false;\n        }\n        for (AgentToolResult toolResult : result.getToolResults()) {\n            if (toolResult != null\n                    && toolResult.getOutput() != null\n                    && toolResult.getOutput().startsWith(APPROVAL_REJECTED_PREFIX)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private boolean hasToolError(CodingAgentResult result) {\n        if (result == null || result.getToolResults() == null) {\n            return false;\n        }\n        for (AgentToolResult toolResult : result.getToolResults()) {\n            if (toolResult != null\n                    && toolResult.getOutput() != null\n                    && toolResult.getOutput().startsWith(TOOL_ERROR_PREFIX)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private boolean looksLikeQuestion(String text) {\n        return text != null && !text.trim().isEmpty() && QUESTION_PATTERN.matcher(text).find();\n    }\n\n    private boolean looksLikeCompleted(String text) {\n        return text != null && !text.trim().isEmpty() && COMPLETION_PATTERN.matcher(text.trim()).find();\n    }\n\n    private boolean looksTransitional(String text) {\n        if (text == null || text.trim().isEmpty()) {\n            return false;\n        }\n        String normalized = text.trim().toLowerCase(Locale.ROOT);\n        return CONTINUE_PATTERN.matcher(normalized).find();\n    }\n\n    private boolean hasPositiveLimit(int limit) {\n        return limit > 0;\n    }\n\n    private void throwIfInterrupted() throws InterruptedException {\n        if (Thread.currentThread().isInterrupted()) {\n            throw new InterruptedException(\"Coding agent loop interrupted\");\n        }\n    }\n\n    private static final class StepOffsetAgentListener implements AgentListener {\n\n        private final AgentListener delegate;\n        private final int stepOffset;\n\n        private StepOffsetAgentListener(AgentListener delegate, int stepOffset) {\n            this.delegate = delegate;\n            this.stepOffset = Math.max(0, stepOffset);\n        }\n\n        @Override\n        public void onEvent(AgentEvent event) {\n            if (delegate == null) {\n                return;\n            }\n            if (event == null) {\n                delegate.onEvent(null);\n                return;\n            }\n            Integer originalStep = event.getStep();\n            int adjustedStep = originalStep == null ? stepOffset : originalStep + stepOffset;\n            delegate.onEvent(AgentEvent.builder()\n                    .type(event.getType())\n                    .step(adjustedStep)\n                    .message(event.getMessage())\n                    .payload(event.getPayload())\n                    .build());\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingContinuationPrompt.java",
    "content": "package io.github.lnyocly.ai4j.coding.loop;\n\nimport io.github.lnyocly.ai4j.coding.CodingAgentResult;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\n\nimport java.util.List;\n\npublic final class CodingContinuationPrompt {\n\n    private CodingContinuationPrompt() {\n    }\n\n    public static String build(CodingLoopDecision decision,\n                               CodingAgentResult previousResult,\n                               CodingSessionCompactResult compactResult,\n                               int nextTurnNumber) {\n        StringBuilder prompt = new StringBuilder();\n        prompt.append(\"Internal continuation. This is not a new user message. \");\n        prompt.append(\"Continue the same coding task using the existing workspace and session context.\\n\");\n        prompt.append(\"Do not restate prior work or ask the user to repeat information already present.\\n\");\n        prompt.append(\"If the task is already complete, respond with a concise completion summary and stop.\\n\");\n        prompt.append(\"If user clarification or approval is required, state that clearly and stop.\\n\");\n        prompt.append(\"Otherwise continue directly with the next concrete step, using tools when useful.\\n\");\n        if (decision != null && decision.getContinueReason() != null) {\n            prompt.append(\"Continuation reason: \").append(decision.getContinueReason()).append(\".\\n\");\n        }\n        if (decision != null && decision.isCompactApplied()) {\n            prompt.append(\"A context compaction was just applied. Re-anchor yourself from the retained checkpoint and recent messages.\\n\");\n            appendCompactReanchor(prompt, compactResult);\n        }\n        if (previousResult != null && previousResult.getOutputText() != null && !previousResult.getOutputText().trim().isEmpty()) {\n            prompt.append(\"Latest assistant output:\\n\");\n            prompt.append(previousResult.getOutputText().trim()).append(\"\\n\");\n        }\n        prompt.append(\"Continuation turn #\").append(nextTurnNumber).append(\".\");\n        return prompt.toString();\n    }\n\n    private static void appendCompactReanchor(StringBuilder prompt, CodingSessionCompactResult compactResult) {\n        if (prompt == null || compactResult == null) {\n            return;\n        }\n        if (!isBlank(compactResult.getStrategy())) {\n            prompt.append(\"Compaction strategy: \").append(compactResult.getStrategy()).append(\".\\n\");\n        }\n        if (compactResult.isCheckpointReused()) {\n            prompt.append(\"The existing checkpoint was updated with new delta context.\\n\");\n        }\n        CodingSessionCheckpoint checkpoint = compactResult.getCheckpoint();\n        if (checkpoint != null) {\n            if (!isBlank(checkpoint.getGoal())) {\n                prompt.append(\"Checkpoint goal: \").append(clip(checkpoint.getGoal(), 220)).append(\"\\n\");\n            }\n            if (checkpoint.isSplitTurn()) {\n                prompt.append(\"This checkpoint came from a split-turn compaction. Use the kept recent messages as the latest turn tail.\\n\");\n            }\n            appendList(prompt, \"Checkpoint constraints\", checkpoint.getConstraints(), 3);\n            appendList(prompt, \"Checkpoint blocked items\", checkpoint.getBlockedItems(), 2);\n            appendList(prompt, \"Checkpoint next steps\", checkpoint.getNextSteps(), 3);\n            appendList(prompt, \"Checkpoint critical context\", checkpoint.getCriticalContext(), 3);\n            appendList(prompt, \"Checkpoint in-progress items\", checkpoint.getInProgressItems(), 2);\n            appendProcesses(prompt, checkpoint.getProcessSnapshots(), 2);\n            return;\n        }\n        if (!isBlank(compactResult.getSummary())) {\n            prompt.append(\"Compact summary excerpt: \")\n                    .append(clip(singleLine(compactResult.getSummary()), 280))\n                    .append(\"\\n\");\n        }\n    }\n\n    private static void appendList(StringBuilder prompt, String label, List<String> values, int maxItems) {\n        if (prompt == null || values == null || values.isEmpty() || maxItems <= 0) {\n            return;\n        }\n        prompt.append(label).append(\":\\n\");\n        int written = 0;\n        for (String value : values) {\n            if (isBlank(value)) {\n                continue;\n            }\n            prompt.append(\"- \").append(clip(value.trim(), 220)).append(\"\\n\");\n            written += 1;\n            if (written >= maxItems) {\n                break;\n            }\n        }\n    }\n\n    private static void appendProcesses(StringBuilder prompt, List<StoredProcessSnapshot> snapshots, int maxItems) {\n        if (prompt == null || snapshots == null || snapshots.isEmpty() || maxItems <= 0) {\n            return;\n        }\n        prompt.append(\"Checkpoint process snapshots:\\n\");\n        int written = 0;\n        for (StoredProcessSnapshot snapshot : snapshots) {\n            if (snapshot == null || isBlank(snapshot.getProcessId())) {\n                continue;\n            }\n            StringBuilder line = new StringBuilder();\n            line.append(\"- \").append(snapshot.getProcessId());\n            if (snapshot.getStatus() != null) {\n                line.append(\" [\").append(snapshot.getStatus()).append(\"]\");\n            }\n            if (!isBlank(snapshot.getCommand())) {\n                line.append(\" \").append(clip(snapshot.getCommand().trim(), 140));\n            }\n            if (snapshot.isRestored()) {\n                line.append(\" (restored snapshot)\");\n            }\n            prompt.append(line).append(\"\\n\");\n            written += 1;\n            if (written >= maxItems) {\n                break;\n            }\n        }\n    }\n\n    private static String singleLine(String value) {\n        return isBlank(value) ? \"\" : value.replace('\\r', ' ').replace('\\n', ' ').trim();\n    }\n\n    private static String clip(String value, int maxChars) {\n        if (isBlank(value) || value.length() <= maxChars) {\n            return value;\n        }\n        return value.substring(0, Math.max(0, maxChars - 3)) + \"...\";\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingLoopDecision.java",
    "content": "package io.github.lnyocly.ai4j.coding.loop;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingLoopDecision {\n\n    public static final String CONTINUE_AFTER_TOOL_WORK = \"CONTINUE_AFTER_TOOL_WORK\";\n    public static final String CONTINUE_AFTER_COMPACTION = \"CONTINUE_AFTER_COMPACTION\";\n    public static final String CONTINUE_AUTONOMOUS_WORK = \"CONTINUE_AUTONOMOUS_WORK\";\n\n    private int turnNumber;\n\n    private boolean continueLoop;\n\n    private String continueReason;\n\n    private CodingStopReason stopReason;\n\n    private String summary;\n\n    private String continuationPrompt;\n\n    private boolean compactApplied;\n\n    public boolean isBlocked() {\n        return stopReason == CodingStopReason.BLOCKED_BY_APPROVAL\n                || stopReason == CodingStopReason.BLOCKED_BY_TOOL_ERROR;\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingLoopPolicy.java",
    "content": "package io.github.lnyocly.ai4j.coding.loop;\n\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingLoopPolicy {\n\n    @Builder.Default\n    private boolean autoContinueEnabled = true;\n\n    @Builder.Default\n    private int maxAutoFollowUps = 2;\n\n    @Builder.Default\n    private int maxTotalTurns = 6;\n\n    @Builder.Default\n    private boolean continueAfterCompact = true;\n\n    @Builder.Default\n    private boolean stopOnApprovalBlock = true;\n\n    @Builder.Default\n    private boolean stopOnExplicitQuestion = true;\n\n    public static CodingLoopPolicy from(CodingAgentOptions options) {\n        if (options == null) {\n            return CodingLoopPolicy.builder().build();\n        }\n        return CodingLoopPolicy.builder()\n                .autoContinueEnabled(options.isAutoContinueEnabled())\n                .maxAutoFollowUps(options.getMaxAutoFollowUps())\n                .maxTotalTurns(options.getMaxTotalTurns())\n                .continueAfterCompact(options.isContinueAfterCompact())\n                .stopOnApprovalBlock(options.isStopOnApprovalBlock())\n                .stopOnExplicitQuestion(options.isStopOnExplicitQuestion())\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingStopReason.java",
    "content": "package io.github.lnyocly.ai4j.coding.loop;\n\npublic enum CodingStopReason {\n    COMPLETED,\n    NEEDS_USER_INPUT,\n    BLOCKED_BY_APPROVAL,\n    BLOCKED_BY_TOOL_ERROR,\n    MAX_AUTO_FOLLOWUPS_REACHED,\n    MAX_TOTAL_TURNS_REACHED,\n    INTERRUPTED,\n    ERROR\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/patch/ApplyPatchFileChange.java",
    "content": "package io.github.lnyocly.ai4j.coding.patch;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ApplyPatchFileChange {\n\n    private String path;\n\n    private String operation;\n\n    private int linesAdded;\n\n    private int linesRemoved;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/patch/ApplyPatchResult.java",
    "content": "package io.github.lnyocly.ai4j.coding.patch;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ApplyPatchResult {\n\n    private int filesChanged;\n\n    private int operationsApplied;\n\n    private List<String> changedFiles;\n\n    private List<ApplyPatchFileChange> fileChanges;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/policy/CodingToolContextPolicy.java",
    "content": "package io.github.lnyocly.ai4j.coding.policy;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport lombok.Builder;\nimport lombok.Data;\n\nimport java.util.Set;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingToolContextPolicy {\n\n    private AgentToolRegistry toolRegistry;\n\n    private ToolExecutor toolExecutor;\n\n    private Set<String> allowedToolNames;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/policy/CodingToolPolicyResolver.java",
    "content": "package io.github.lnyocly.ai4j.coding.policy;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Set;\n\npublic class CodingToolPolicyResolver {\n\n    public CodingToolContextPolicy resolve(AgentToolRegistry baseRegistry,\n                                           ToolExecutor baseExecutor,\n                                           CodingAgentDefinition definition) {\n        Set<String> allowedToolNames = normalize(definition == null ? null : definition.getAllowedToolNames());\n        if (allowedToolNames.isEmpty()) {\n            return CodingToolContextPolicy.builder()\n                    .toolRegistry(baseRegistry == null ? StaticToolRegistry.empty() : baseRegistry)\n                    .toolExecutor(baseExecutor)\n                    .allowedToolNames(Collections.<String>emptySet())\n                    .build();\n        }\n        return CodingToolContextPolicy.builder()\n                .toolRegistry(filterRegistry(baseRegistry, allowedToolNames))\n                .toolExecutor(wrapExecutor(baseExecutor, allowedToolNames))\n                .allowedToolNames(allowedToolNames)\n                .build();\n    }\n\n    private AgentToolRegistry filterRegistry(AgentToolRegistry baseRegistry, Set<String> allowedToolNames) {\n        if (baseRegistry == null) {\n            return StaticToolRegistry.empty();\n        }\n        List<Object> filtered = new ArrayList<Object>();\n        List<Object> tools = baseRegistry.getTools();\n        if (tools != null) {\n            for (Object tool : tools) {\n                if (supports(tool, allowedToolNames)) {\n                    filtered.add(tool);\n                }\n            }\n        }\n        return new StaticToolRegistry(filtered);\n    }\n\n    private boolean supports(Object tool, Set<String> allowedToolNames) {\n        String toolName = extractToolName(tool);\n        return toolName != null && allowedToolNames.contains(normalize(toolName));\n    }\n\n    private String extractToolName(Object tool) {\n        if (!(tool instanceof Tool)) {\n            return null;\n        }\n        Tool.Function function = ((Tool) tool).getFunction();\n        return function == null ? null : function.getName();\n    }\n\n    private ToolExecutor wrapExecutor(final ToolExecutor baseExecutor, final Set<String> allowedToolNames) {\n        return new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) throws Exception {\n                String toolName = call == null ? null : call.getName();\n                if (toolName == null || !allowedToolNames.contains(normalize(toolName))) {\n                    throw new IllegalArgumentException(\"Tool is not allowed in this delegated coding session: \" + toolName);\n                }\n                if (baseExecutor == null) {\n                    throw new IllegalStateException(\"No tool executor available for delegated coding session\");\n                }\n                return baseExecutor.execute(call);\n            }\n        };\n    }\n\n    private Set<String> normalize(Set<String> toolNames) {\n        if (toolNames == null || toolNames.isEmpty()) {\n            return Collections.emptySet();\n        }\n        Set<String> normalized = new LinkedHashSet<String>();\n        for (String toolName : toolNames) {\n            String value = normalize(toolName);\n            if (value != null) {\n                normalized.add(value);\n            }\n        }\n        return normalized.isEmpty() ? Collections.<String>emptySet() : Collections.unmodifiableSet(normalized);\n    }\n\n    private String normalize(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed.toLowerCase(Locale.ROOT);\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/BashProcessInfo.java",
    "content": "package io.github.lnyocly.ai4j.coding.process;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class BashProcessInfo {\n\n    private String processId;\n\n    private String command;\n\n    private String workingDirectory;\n\n    private BashProcessStatus status;\n\n    private Long pid;\n\n    private Integer exitCode;\n\n    private long startedAt;\n\n    private Long endedAt;\n\n    private boolean restored;\n\n    private boolean controlAvailable;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/BashProcessLogChunk.java",
    "content": "package io.github.lnyocly.ai4j.coding.process;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class BashProcessLogChunk {\n\n    private String processId;\n\n    private long offset;\n\n    private long nextOffset;\n\n    private boolean truncated;\n\n    private String content;\n\n    private BashProcessStatus status;\n\n    private Integer exitCode;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/BashProcessStatus.java",
    "content": "package io.github.lnyocly.ai4j.coding.process;\n\npublic enum BashProcessStatus {\n    RUNNING,\n    EXITED,\n    STOPPED\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/SessionProcessRegistry.java",
    "content": "package io.github.lnyocly.ai4j.coding.process;\n\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.shell.ShellCommandSupport;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.nio.charset.Charset;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Comparator;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.TimeUnit;\n\npublic class SessionProcessRegistry implements AutoCloseable {\n\n    private final WorkspaceContext workspaceContext;\n    private final CodingAgentOptions options;\n    private final Map<String, ManagedProcess> processes = new ConcurrentHashMap<String, ManagedProcess>();\n    private final Map<String, StoredProcessSnapshot> restoredSnapshots = new ConcurrentHashMap<String, StoredProcessSnapshot>();\n\n    public SessionProcessRegistry(WorkspaceContext workspaceContext, CodingAgentOptions options) {\n        this.workspaceContext = workspaceContext;\n        this.options = options;\n    }\n\n    public BashProcessInfo start(String command, String cwd) throws IOException {\n        if (isBlank(command)) {\n            throw new IllegalArgumentException(\"command is required\");\n        }\n        Path workingDirectory = workspaceContext.resolveWorkspacePath(cwd);\n        ProcessBuilder processBuilder = new ProcessBuilder(ShellCommandSupport.buildShellCommand(command));\n        processBuilder.directory(workingDirectory.toFile());\n        Process process = processBuilder.start();\n        Charset shellCharset = ShellCommandSupport.resolveShellCharset();\n\n        String processId = \"proc_\" + UUID.randomUUID().toString().replace(\"-\", \"\");\n        ManagedProcess managed = new ManagedProcess(processId,\n                command,\n                workingDirectory.toString(),\n                process,\n                options.getMaxProcessOutputChars(),\n                shellCharset);\n        restoredSnapshots.remove(processId);\n        processes.put(processId, managed);\n        managed.startReaders();\n        managed.startWatcher();\n        return managed.snapshot();\n    }\n\n    public BashProcessInfo status(String processId) {\n        ManagedProcess managed = processes.get(processId);\n        if (managed != null) {\n            return managed.snapshot();\n        }\n        StoredProcessSnapshot restored = restoredSnapshots.get(processId);\n        if (restored != null) {\n            return toProcessInfo(restored);\n        }\n        throw new IllegalArgumentException(\"Unknown processId: \" + processId);\n    }\n\n    public List<BashProcessInfo> list() {\n        List<BashProcessInfo> result = new ArrayList<BashProcessInfo>();\n        for (ManagedProcess managed : processes.values()) {\n            result.add(managed.snapshot());\n        }\n        for (StoredProcessSnapshot restored : restoredSnapshots.values()) {\n            result.add(toProcessInfo(restored));\n        }\n        result.sort(Comparator.comparingLong(BashProcessInfo::getStartedAt));\n        return result;\n    }\n\n    public List<StoredProcessSnapshot> exportSnapshots() {\n        Map<String, StoredProcessSnapshot> snapshots = new LinkedHashMap<String, StoredProcessSnapshot>();\n        for (StoredProcessSnapshot restored : restoredSnapshots.values()) {\n            if (restored != null && !isBlank(restored.getProcessId())) {\n                snapshots.put(restored.getProcessId(), restored.toBuilder().build());\n            }\n        }\n        int previewChars = Math.max(256, options.getDefaultBashLogChars());\n        for (ManagedProcess managed : processes.values()) {\n            StoredProcessSnapshot snapshot = managed.metadataSnapshot(previewChars);\n            snapshots.put(snapshot.getProcessId(), snapshot);\n        }\n        List<StoredProcessSnapshot> result = new ArrayList<StoredProcessSnapshot>(snapshots.values());\n        result.sort(Comparator.comparingLong(StoredProcessSnapshot::getStartedAt));\n        return result;\n    }\n\n    public void restoreSnapshots(List<StoredProcessSnapshot> snapshots) {\n        restoredSnapshots.clear();\n        if (snapshots == null) {\n            return;\n        }\n        for (StoredProcessSnapshot snapshot : snapshots) {\n            if (snapshot == null || isBlank(snapshot.getProcessId()) || processes.containsKey(snapshot.getProcessId())) {\n                continue;\n            }\n            restoredSnapshots.put(snapshot.getProcessId(), snapshot.toBuilder()\n                    .restored(true)\n                    .controlAvailable(false)\n                    .build());\n        }\n    }\n\n    public int activeCount() {\n        int count = 0;\n        for (ManagedProcess managed : processes.values()) {\n            if (managed.status == BashProcessStatus.RUNNING) {\n                count++;\n            }\n        }\n        return count;\n    }\n\n    public int restoredCount() {\n        return restoredSnapshots.size();\n    }\n\n    public BashProcessLogChunk logs(String processId, Long offset, Integer limit) {\n        ManagedProcess managed = processes.get(processId);\n        if (managed != null) {\n            long effectiveOffset = offset == null || offset < 0 ? 0L : offset.longValue();\n            int effectiveLimit = limit == null || limit <= 0 ? options.getDefaultBashLogChars() : limit.intValue();\n            return managed.readLogs(effectiveOffset, effectiveLimit);\n        }\n        StoredProcessSnapshot restored = restoredSnapshots.get(processId);\n        if (restored != null) {\n            return restoredLogs(restored, offset, limit);\n        }\n        throw new IllegalArgumentException(\"Unknown processId: \" + processId);\n    }\n\n    public int write(String processId, String input) throws IOException {\n        if (input == null) {\n            input = \"\";\n        }\n        ManagedProcess managed = getLiveProcess(processId);\n        OutputStream outputStream = managed.process.getOutputStream();\n        byte[] bytes = input.getBytes(managed.charset);\n        outputStream.write(bytes);\n        outputStream.flush();\n        return bytes.length;\n    }\n\n    public BashProcessInfo stop(String processId) {\n        ManagedProcess managed = getLiveProcess(processId);\n        managed.stop(options.getProcessStopGraceMs());\n        return managed.snapshot();\n    }\n\n    @Override\n    public void close() {\n        for (ManagedProcess managed : processes.values()) {\n            managed.stop(options.getProcessStopGraceMs());\n        }\n    }\n\n    private ManagedProcess getLiveProcess(String processId) {\n        ManagedProcess managed = processes.get(processId);\n        if (managed != null) {\n            return managed;\n        }\n        if (restoredSnapshots.containsKey(processId)) {\n            throw new IllegalStateException(\"Process \" + processId + \" was restored as metadata only; live control is unavailable\");\n        }\n        throw new IllegalArgumentException(\"Unknown processId: \" + processId);\n    }\n\n    private BashProcessInfo toProcessInfo(StoredProcessSnapshot snapshot) {\n        return BashProcessInfo.builder()\n                .processId(snapshot.getProcessId())\n                .command(snapshot.getCommand())\n                .workingDirectory(snapshot.getWorkingDirectory())\n                .status(snapshot.getStatus())\n                .pid(snapshot.getPid())\n                .exitCode(snapshot.getExitCode())\n                .startedAt(snapshot.getStartedAt())\n                .endedAt(snapshot.getEndedAt())\n                .restored(true)\n                .controlAvailable(false)\n                .build();\n    }\n\n    private BashProcessLogChunk restoredLogs(StoredProcessSnapshot snapshot, Long offset, Integer limit) {\n        String preview = snapshot.getLastLogPreview() == null ? \"\" : snapshot.getLastLogPreview();\n        long previewOffset = Math.max(0L, snapshot.getLastLogOffset() - preview.length());\n        long requestedOffset = offset == null || offset < 0 ? previewOffset : Math.max(previewOffset, offset.longValue());\n        int from = (int) Math.max(0L, requestedOffset - previewOffset);\n        int maxChars = limit == null || limit <= 0 ? preview.length() : Math.max(1, limit.intValue());\n        int to = Math.min(preview.length(), from + maxChars);\n        return BashProcessLogChunk.builder()\n                .processId(snapshot.getProcessId())\n                .offset(requestedOffset)\n                .nextOffset(previewOffset + to)\n                .truncated(offset != null && offset.longValue() < previewOffset)\n                .content(preview.substring(Math.min(from, preview.length()), Math.min(to, preview.length())))\n                .status(snapshot.getStatus())\n                .exitCode(snapshot.getExitCode())\n                .build();\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static class ManagedProcess {\n\n        private final String processId;\n        private final String command;\n        private final String workingDirectory;\n        private final Process process;\n        private final ProcessOutputBuffer outputBuffer;\n        private final long startedAt;\n        private final Long pid;\n        private final Charset charset;\n\n        private volatile BashProcessStatus status;\n        private volatile Integer exitCode;\n        private volatile Long endedAt;\n\n        private ManagedProcess(String processId,\n                               String command,\n                               String workingDirectory,\n                               Process process,\n                               int maxOutputChars,\n                               Charset charset) {\n            this.processId = processId;\n            this.command = command;\n            this.workingDirectory = workingDirectory;\n            this.process = process;\n            this.outputBuffer = new ProcessOutputBuffer(maxOutputChars);\n            this.startedAt = System.currentTimeMillis();\n            this.pid = safePid(process);\n            this.charset = charset;\n            this.status = BashProcessStatus.RUNNING;\n        }\n\n        private void startReaders() {\n            Thread stdoutThread = new Thread(new StreamCollector(process.getInputStream(), outputBuffer, \"[stdout] \", charset), processId + \"-stdout\");\n            Thread stderrThread = new Thread(new StreamCollector(process.getErrorStream(), outputBuffer, \"[stderr] \", charset), processId + \"-stderr\");\n            stdoutThread.setDaemon(true);\n            stderrThread.setDaemon(true);\n            stdoutThread.start();\n            stderrThread.start();\n        }\n\n        private void startWatcher() {\n            Thread watcher = new Thread(() -> {\n                try {\n                    int code = process.waitFor();\n                    exitCode = code;\n                    if (status == BashProcessStatus.RUNNING) {\n                        status = BashProcessStatus.EXITED;\n                    }\n                    endedAt = System.currentTimeMillis();\n                } catch (InterruptedException ignored) {\n                    Thread.currentThread().interrupt();\n                }\n            }, processId + \"-watcher\");\n            watcher.setDaemon(true);\n            watcher.start();\n        }\n\n        private BashProcessLogChunk readLogs(long offset, int limit) {\n            ProcessOutputBuffer.Chunk chunk = outputBuffer.read(offset, limit);\n            return BashProcessLogChunk.builder()\n                    .processId(processId)\n                    .offset(chunk.getOffset())\n                    .nextOffset(chunk.getNextOffset())\n                    .truncated(chunk.isTruncated())\n                    .content(chunk.getContent())\n                    .status(status)\n                    .exitCode(exitCode)\n                    .build();\n        }\n\n        private BashProcessInfo snapshot() {\n            return BashProcessInfo.builder()\n                    .processId(processId)\n                    .command(command)\n                    .workingDirectory(workingDirectory)\n                    .status(status)\n                    .pid(pid)\n                    .exitCode(exitCode)\n                    .startedAt(startedAt)\n                    .endedAt(endedAt)\n                    .restored(false)\n                    .controlAvailable(true)\n                    .build();\n        }\n\n        private StoredProcessSnapshot metadataSnapshot(int previewChars) {\n            return StoredProcessSnapshot.builder()\n                    .processId(processId)\n                    .command(command)\n                    .workingDirectory(workingDirectory)\n                    .status(status)\n                    .pid(pid)\n                    .exitCode(exitCode)\n                    .startedAt(startedAt)\n                    .endedAt(endedAt)\n                    .lastLogOffset(outputBuffer.nextOffset())\n                    .lastLogPreview(outputBuffer.tail(previewChars))\n                    .restored(false)\n                    .controlAvailable(true)\n                    .build();\n        }\n\n        private void stop(long graceMs) {\n            if (!process.isAlive()) {\n                if (status == BashProcessStatus.RUNNING) {\n                    status = BashProcessStatus.EXITED;\n                    endedAt = System.currentTimeMillis();\n                    try {\n                        exitCode = process.exitValue();\n                    } catch (IllegalThreadStateException ignored) {\n                    }\n                }\n                return;\n            }\n            process.destroy();\n            try {\n                if (!process.waitFor(graceMs, TimeUnit.MILLISECONDS)) {\n                    process.destroyForcibly();\n                    process.waitFor(graceMs, TimeUnit.MILLISECONDS);\n                }\n            } catch (InterruptedException ignored) {\n                Thread.currentThread().interrupt();\n            }\n            status = BashProcessStatus.STOPPED;\n            endedAt = System.currentTimeMillis();\n            try {\n                exitCode = process.exitValue();\n            } catch (IllegalThreadStateException ignored) {\n                exitCode = -1;\n            }\n        }\n\n        private static Long safePid(Process process) {\n            try {\n                return (Long) process.getClass().getMethod(\"pid\").invoke(process);\n            } catch (Exception ignored) {\n                return null;\n            }\n        }\n    }\n\n    private static class StreamCollector implements Runnable {\n\n        private final InputStream inputStream;\n        private final ProcessOutputBuffer outputBuffer;\n        private final String prefix;\n        private final Charset charset;\n\n        private StreamCollector(InputStream inputStream, ProcessOutputBuffer outputBuffer, String prefix, Charset charset) {\n            this.inputStream = inputStream;\n            this.outputBuffer = outputBuffer;\n            this.prefix = prefix;\n            this.charset = charset;\n        }\n\n        @Override\n        public void run() {\n            try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) {\n                String line;\n                while ((line = reader.readLine()) != null) {\n                    outputBuffer.append(prefix + line + \"\\n\");\n                }\n            } catch (IOException ignored) {\n            }\n        }\n    }\n\n    private static class ProcessOutputBuffer {\n\n        private final int maxChars;\n        private final StringBuilder buffer = new StringBuilder();\n        private long startOffset;\n\n        private ProcessOutputBuffer(int maxChars) {\n            this.maxChars = Math.max(1024, maxChars);\n        }\n\n        private synchronized void append(String value) {\n            if (value == null || value.isEmpty()) {\n                return;\n            }\n            buffer.append(value);\n            trim();\n        }\n\n        private synchronized Chunk read(long offset, int limit) {\n            long safeOffset = Math.max(offset, startOffset);\n            int from = (int) (safeOffset - startOffset);\n            int safeLimit = Math.max(1, limit);\n            int to = Math.min(buffer.length(), from + safeLimit);\n            String content = buffer.substring(Math.min(from, buffer.length()), Math.min(to, buffer.length()));\n            long nextOffset = startOffset + to;\n            return new Chunk(safeOffset, nextOffset, offset < startOffset, content);\n        }\n\n        private synchronized long nextOffset() {\n            return startOffset + buffer.length();\n        }\n\n        private synchronized String tail(int maxChars) {\n            int safeMax = Math.max(1, maxChars);\n            if (buffer.length() <= safeMax) {\n                return buffer.toString();\n            }\n            return buffer.substring(buffer.length() - safeMax);\n        }\n\n        private void trim() {\n            if (buffer.length() <= maxChars) {\n                return;\n            }\n            int overflow = buffer.length() - maxChars;\n            buffer.delete(0, overflow);\n            startOffset += overflow;\n        }\n\n        private static class Chunk {\n\n            private final long offset;\n            private final long nextOffset;\n            private final boolean truncated;\n            private final String content;\n\n            private Chunk(long offset, long nextOffset, boolean truncated, String content) {\n                this.offset = offset;\n                this.nextOffset = nextOffset;\n                this.truncated = truncated;\n                this.content = content;\n            }\n\n            private long getOffset() {\n                return offset;\n            }\n\n            private long getNextOffset() {\n                return nextOffset;\n            }\n\n            private boolean isTruncated() {\n                return truncated;\n            }\n\n            private String getContent() {\n                return content;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/StoredProcessSnapshot.java",
    "content": "package io.github.lnyocly.ai4j.coding.process;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class StoredProcessSnapshot {\n\n    private String processId;\n\n    private String command;\n\n    private String workingDirectory;\n\n    private BashProcessStatus status;\n\n    private Long pid;\n\n    private Integer exitCode;\n\n    private long startedAt;\n\n    private Long endedAt;\n\n    private long lastLogOffset;\n\n    private String lastLogPreview;\n\n    private boolean restored;\n\n    private boolean controlAvailable;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/prompt/CodingContextPromptAssembler.java",
    "content": "package io.github.lnyocly.ai4j.coding.prompt;\n\nimport io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor;\nimport io.github.lnyocly.ai4j.coding.shell.ShellCommandSupport;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.skill.SkillDescriptor;\nimport io.github.lnyocly.ai4j.skill.Skills;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic final class CodingContextPromptAssembler {\n\n    private CodingContextPromptAssembler() {\n    }\n\n    public static String mergeSystemPrompt(String basePrompt, WorkspaceContext workspaceContext) {\n        String workspacePrompt = buildWorkspacePrompt(workspaceContext);\n        if (isBlank(basePrompt)) {\n            return workspacePrompt;\n        }\n        if (isBlank(workspacePrompt)) {\n            return basePrompt;\n        }\n        return basePrompt + \"\\n\\n\" + workspacePrompt;\n    }\n\n    private static String buildWorkspacePrompt(WorkspaceContext workspaceContext) {\n        if (workspaceContext == null) {\n            return null;\n        }\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"You are a coding agent operating inside a local workspace.\\n\");\n        builder.append(\"Workspace root: \").append(workspaceContext.getRoot().toString()).append(\"\\n\");\n        if (!isBlank(workspaceContext.getDescription())) {\n            builder.append(\"Workspace description: \").append(workspaceContext.getDescription()).append(\"\\n\");\n        }\n        builder.append(\"Available built-in tools: bash, read_file, write_file, apply_patch.\\n\");\n        builder.append(\"Use bash for search, git, build, test, and process management. Use read_file before making changes. Use write_file for full-file create/overwrite/append operations, especially for new files. Use apply_patch for structured diffs.\\n\");\n        builder.append(\"Tool-call rules: only call a tool when you have a complete payload. \")\n                .append(\"For bash, always send a JSON object like {\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"...\\\"} and never omit command for exec/start. \")\n                .append(\"Use bash action=exec only for non-interactive commands that will exit by themselves. If a command may wait for stdin, open a REPL, start a server, tail logs, or keep running, use bash action=start and then bash action=logs/status/write/stop. \")\n                .append(\"For read_file, include path. For write_file, include path and content, plus optional mode=create|overwrite|append. Relative paths resolve from the workspace root and absolute paths are allowed. For apply_patch, include patch.\\n\");\n        builder.append(\"apply_patch must use the exact grammar: *** Begin Patch, then *** Add File:/*** Update File:/*** Delete File:, and end with *** End Patch.\\n\");\n        builder.append(ShellCommandSupport.buildShellUsageGuidance()).append(\"\\n\");\n        if (!workspaceContext.isAllowOutsideWorkspace()) {\n            builder.append(\"Do not rely on files outside the workspace root unless the user explicitly allows it.\\n\");\n        }\n        appendSkillGuidance(builder, workspaceContext.getAvailableSkills());\n        return builder.toString().trim();\n    }\n\n    private static void appendSkillGuidance(StringBuilder builder, List<CodingSkillDescriptor> availableSkills) {\n        String skillPrompt = Skills.buildAvailableSkillsPrompt(toSkillDescriptors(availableSkills));\n        if (isBlank(skillPrompt)) {\n            return;\n        }\n        builder.append(skillPrompt).append(\"\\n\");\n    }\n\n    private static List<SkillDescriptor> toSkillDescriptors(List<CodingSkillDescriptor> availableSkills) {\n        if (availableSkills == null || availableSkills.isEmpty()) {\n            return null;\n        }\n        List<SkillDescriptor> descriptors = new ArrayList<SkillDescriptor>();\n        for (CodingSkillDescriptor skill : availableSkills) {\n            if (skill == null) {\n                continue;\n            }\n            descriptors.add(SkillDescriptor.builder()\n                    .name(skill.getName())\n                    .description(skill.getDescription())\n                    .skillFilePath(skill.getSkillFilePath())\n                    .source(skill.getSource())\n                    .build());\n        }\n        return descriptors;\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private static boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/runtime/CodingRuntime.java",
    "content": "package io.github.lnyocly.ai4j.coding.runtime;\n\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLink;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\n\nimport java.util.List;\n\npublic interface CodingRuntime {\n\n    CodingDelegateResult delegate(CodingSession parentSession, CodingDelegateRequest request) throws Exception;\n\n    void addListener(CodingRuntimeListener listener);\n\n    void removeListener(CodingRuntimeListener listener);\n\n    CodingTask getTask(String taskId);\n\n    List<CodingTask> listTasks();\n\n    List<CodingTask> listTasksByParentSessionId(String parentSessionId);\n\n    List<CodingSessionLink> listSessionLinks(String parentSessionId);\n\n    CodingAgentDefinitionRegistry getDefinitionRegistry();\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/runtime/CodingRuntimeListener.java",
    "content": "package io.github.lnyocly.ai4j.coding.runtime;\n\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLink;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\n\npublic interface CodingRuntimeListener {\n\n    void onTaskCreated(CodingTask task, CodingSessionLink link);\n\n    void onTaskUpdated(CodingTask task);\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/runtime/DefaultCodingRuntime.java",
    "content": "package io.github.lnyocly.ai4j.coding.runtime;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.AgentContext;\nimport io.github.lnyocly.ai4j.agent.AgentSession;\nimport io.github.lnyocly.ai4j.agent.memory.MemorySnapshot;\nimport io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.CodingAgentBuilder;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.CodingAgentResult;\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSessionState;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.definition.CodingSessionMode;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult;\nimport io.github.lnyocly.ai4j.coding.policy.CodingToolContextPolicy;\nimport io.github.lnyocly.ai4j.coding.policy.CodingToolPolicyResolver;\nimport io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLink;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLinkStore;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskManager;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskProgress;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskStatus;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CopyOnWriteArrayList;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ThreadFactory;\n\npublic class DefaultCodingRuntime implements CodingRuntime {\n\n    private final WorkspaceContext workspaceContext;\n    private final CodingAgentOptions options;\n    private final AgentToolRegistry customToolRegistry;\n    private final ToolExecutor customToolExecutor;\n    private final CodingAgentDefinitionRegistry definitionRegistry;\n    private final CodingTaskManager taskManager;\n    private final CodingSessionLinkStore sessionLinkStore;\n    private final CodingToolPolicyResolver toolPolicyResolver;\n    private final SubAgentRegistry subAgentRegistry;\n    private final HandoffPolicy handoffPolicy;\n    private final ExecutorService executorService;\n    private final CopyOnWriteArrayList<CodingRuntimeListener> listeners = new CopyOnWriteArrayList<CodingRuntimeListener>();\n\n    public DefaultCodingRuntime(WorkspaceContext workspaceContext,\n                                CodingAgentOptions options,\n                                AgentToolRegistry customToolRegistry,\n                                ToolExecutor customToolExecutor,\n                                CodingAgentDefinitionRegistry definitionRegistry,\n                                CodingTaskManager taskManager,\n                                CodingSessionLinkStore sessionLinkStore,\n                                CodingToolPolicyResolver toolPolicyResolver) {\n        this(workspaceContext, options, customToolRegistry, customToolExecutor, definitionRegistry, taskManager, sessionLinkStore, toolPolicyResolver, null, null);\n    }\n\n    public DefaultCodingRuntime(WorkspaceContext workspaceContext,\n                                CodingAgentOptions options,\n                                AgentToolRegistry customToolRegistry,\n                                ToolExecutor customToolExecutor,\n                                CodingAgentDefinitionRegistry definitionRegistry,\n                                CodingTaskManager taskManager,\n                                CodingSessionLinkStore sessionLinkStore,\n                                CodingToolPolicyResolver toolPolicyResolver,\n                                SubAgentRegistry subAgentRegistry,\n                                HandoffPolicy handoffPolicy) {\n        this(workspaceContext, options, customToolRegistry, customToolExecutor, definitionRegistry, taskManager, sessionLinkStore, toolPolicyResolver,\n                Executors.newCachedThreadPool(new ThreadFactory() {\n                    @Override\n                    public Thread newThread(Runnable runnable) {\n                        Thread thread = new Thread(runnable, \"ai4j-coding-runtime\");\n                        thread.setDaemon(true);\n                        return thread;\n                    }\n                }),\n                subAgentRegistry,\n                handoffPolicy);\n    }\n\n    public DefaultCodingRuntime(WorkspaceContext workspaceContext,\n                                CodingAgentOptions options,\n                                AgentToolRegistry customToolRegistry,\n                                ToolExecutor customToolExecutor,\n                                CodingAgentDefinitionRegistry definitionRegistry,\n                                CodingTaskManager taskManager,\n                                CodingSessionLinkStore sessionLinkStore,\n                                CodingToolPolicyResolver toolPolicyResolver,\n                                ExecutorService executorService) {\n        this(workspaceContext, options, customToolRegistry, customToolExecutor, definitionRegistry, taskManager, sessionLinkStore, toolPolicyResolver, executorService, null, null);\n    }\n\n    public DefaultCodingRuntime(WorkspaceContext workspaceContext,\n                                CodingAgentOptions options,\n                                AgentToolRegistry customToolRegistry,\n                                ToolExecutor customToolExecutor,\n                                CodingAgentDefinitionRegistry definitionRegistry,\n                                CodingTaskManager taskManager,\n                                CodingSessionLinkStore sessionLinkStore,\n                                CodingToolPolicyResolver toolPolicyResolver,\n                                ExecutorService executorService,\n                                SubAgentRegistry subAgentRegistry,\n                                HandoffPolicy handoffPolicy) {\n        this.workspaceContext = workspaceContext;\n        this.options = options == null ? CodingAgentOptions.builder().build() : options;\n        this.customToolRegistry = customToolRegistry;\n        this.customToolExecutor = customToolExecutor;\n        this.definitionRegistry = definitionRegistry;\n        this.taskManager = taskManager;\n        this.sessionLinkStore = sessionLinkStore;\n        this.toolPolicyResolver = toolPolicyResolver == null ? new CodingToolPolicyResolver() : toolPolicyResolver;\n        this.subAgentRegistry = subAgentRegistry;\n        this.handoffPolicy = handoffPolicy;\n        this.executorService = executorService;\n    }\n\n    @Override\n    public CodingDelegateResult delegate(final CodingSession parentSession, final CodingDelegateRequest request) throws Exception {\n        if (parentSession == null) {\n            throw new IllegalArgumentException(\"parentSession is required\");\n        }\n        CodingAgentDefinition definition = requireDefinition(request);\n        boolean background = request != null && request.getBackground() != null\n                ? request.getBackground().booleanValue()\n                : definition.isBackground();\n        CodingSessionMode sessionMode = request != null && request.getSessionMode() != null\n                ? request.getSessionMode()\n                : defaultSessionMode(definition);\n        String taskId = UUID.randomUUID().toString();\n        String childSessionId = firstNonBlank(\n                request == null ? null : request.getChildSessionId(),\n                parentSession.getSessionId() + \"-delegate-\" + UUID.randomUUID().toString().substring(0, 8)\n        );\n        long now = System.currentTimeMillis();\n        CodingSessionState seedState = resolveSeedState(parentSession, sessionMode);\n\n        CodingTask queuedTask = persistInitialTask(CodingTask.builder()\n                .taskId(taskId)\n                .definitionName(definition.getName())\n                .parentSessionId(parentSession.getSessionId())\n                .childSessionId(childSessionId)\n                .input(composeInput(request))\n                .background(background)\n                .status(CodingTaskStatus.QUEUED)\n                .progress(progress(\"queued\", \"Task queued for execution.\", 0, now))\n                .createdAtEpochMs(now)\n                .build());\n        CodingSessionLink link = saveLink(CodingSessionLink.builder()\n                .linkId(UUID.randomUUID().toString())\n                .taskId(taskId)\n                .definitionName(definition.getName())\n                .parentSessionId(parentSession.getSessionId())\n                .childSessionId(childSessionId)\n                .sessionMode(sessionMode)\n                .background(background)\n                .createdAtEpochMs(now)\n                .build());\n        notifyTaskCreated(queuedTask, link);\n\n        if (background) {\n            executorService.submit(new Runnable() {\n                @Override\n                public void run() {\n                    runTask(parentSession, request, definition, taskId, childSessionId, seedState);\n                }\n            });\n            CodingTask task = taskManager.getTask(taskId);\n            return buildDelegateResult(task, null);\n        }\n\n        return runTask(parentSession, request, definition, taskId, childSessionId, seedState);\n    }\n\n    @Override\n    public CodingTask getTask(String taskId) {\n        return taskManager == null ? null : taskManager.getTask(taskId);\n    }\n\n    @Override\n    public void addListener(CodingRuntimeListener listener) {\n        if (listener != null) {\n            listeners.addIfAbsent(listener);\n        }\n    }\n\n    @Override\n    public void removeListener(CodingRuntimeListener listener) {\n        if (listener != null) {\n            listeners.remove(listener);\n        }\n    }\n\n    @Override\n    public List<CodingTask> listTasks() {\n        return taskManager == null ? Collections.<CodingTask>emptyList() : taskManager.listTasks();\n    }\n\n    @Override\n    public List<CodingTask> listTasksByParentSessionId(String parentSessionId) {\n        return taskManager == null ? Collections.<CodingTask>emptyList() : taskManager.listTasksByParentSessionId(parentSessionId);\n    }\n\n    @Override\n    public List<CodingSessionLink> listSessionLinks(String parentSessionId) {\n        return sessionLinkStore == null\n                ? Collections.<CodingSessionLink>emptyList()\n                : sessionLinkStore.listLinksByParentSessionId(parentSessionId);\n    }\n\n    @Override\n    public CodingAgentDefinitionRegistry getDefinitionRegistry() {\n        return definitionRegistry;\n    }\n\n    private CodingDelegateResult runTask(CodingSession parentSession,\n                                         CodingDelegateRequest request,\n                                         CodingAgentDefinition definition,\n                                         String taskId,\n                                         String childSessionId,\n                                         CodingSessionState seedState) {\n        long startedAt = System.currentTimeMillis();\n        saveTask(updateTask(taskId, CodingTaskStatus.STARTING, progress(\"starting\", \"Preparing delegated session.\", 10, startedAt), startedAt, 0L, null, null));\n        CodingSession childSession = null;\n        try {\n            childSession = createChildSession(parentSession, definition, childSessionId, seedState);\n            saveTask(updateTask(taskId, CodingTaskStatus.RUNNING, progress(\"running\", \"Delegated session is running.\", 50, System.currentTimeMillis()), startedAt, 0L, null, null));\n            CodingAgentResult result = childSession.run(composeInput(request));\n            String outputText = resolveDelegateOutputText(result, childSession);\n            long endedAt = System.currentTimeMillis();\n            CodingTask completed = saveTask(updateTask(taskId, CodingTaskStatus.COMPLETED,\n                    progress(\"completed\", \"Delegated session completed.\", 100, endedAt), startedAt, endedAt,\n                    outputText, null));\n            return buildDelegateResult(completed, null);\n        } catch (Exception ex) {\n            long endedAt = System.currentTimeMillis();\n            CodingTask failed = saveTask(updateTask(taskId, CodingTaskStatus.FAILED,\n                    progress(\"failed\", safeMessage(ex), 100, endedAt), startedAt, endedAt,\n                    null, safeMessage(ex)));\n            return buildDelegateResult(failed, ex);\n        } finally {\n            if (childSession != null) {\n                childSession.close();\n            }\n        }\n    }\n\n    private CodingSession createChildSession(CodingSession parentSession,\n                                             CodingAgentDefinition definition,\n                                             String childSessionId,\n                                             CodingSessionState seedState) {\n        AgentSession parentAgentSession = parentSession.getDelegate();\n        if (parentAgentSession == null || parentAgentSession.getContext() == null) {\n            throw new IllegalStateException(\"parent agent session context is unavailable\");\n        }\n        SessionProcessRegistry processRegistry = new SessionProcessRegistry(workspaceContext, options);\n        AgentToolRegistry builtInRegistry = CodingAgentBuilder.createBuiltInRegistry(options, definitionRegistry);\n        ToolExecutor builtInExecutor = CodingAgentBuilder.createBuiltInToolExecutor(workspaceContext, options, processRegistry, this, definitionRegistry);\n        AgentToolRegistry mergedRegistry = CodingAgentBuilder.mergeToolRegistry(builtInRegistry, customToolRegistry);\n        ToolExecutor mergedExecutor = CodingAgentBuilder.mergeToolExecutor(\n                builtInRegistry,\n                builtInExecutor,\n                customToolRegistry,\n                customToolExecutor\n        );\n        mergedRegistry = CodingAgentBuilder.mergeSubAgentToolRegistry(mergedRegistry, subAgentRegistry);\n        mergedExecutor = CodingAgentBuilder.mergeSubAgentToolExecutor(mergedExecutor, subAgentRegistry, handoffPolicy);\n        CodingToolContextPolicy toolPolicy = toolPolicyResolver.resolve(mergedRegistry, mergedExecutor, definition);\n        AgentContext parentContext = parentAgentSession.getContext();\n        AgentContext childContext = parentContext.toBuilder()\n                .memory(new InMemoryAgentMemory())\n                .toolRegistry(toolPolicy.getToolRegistry())\n                .toolExecutor(toolPolicy.getToolExecutor())\n                .model(firstNonBlank(definition.getModel(), parentContext.getModel()))\n                .instructions(mergeText(parentContext.getInstructions(), definition.getInstructions()))\n                .systemPrompt(mergeText(parentContext.getSystemPrompt(), definition.getSystemPrompt()))\n                .build();\n        CodingSession childSession = new CodingSession(\n                childSessionId,\n                new AgentSession(parentAgentSession.getRuntime(), childContext),\n                workspaceContext,\n                options,\n                processRegistry,\n                this\n        );\n        if (seedState != null) {\n            childSession.restore(seedState);\n        }\n        return childSession;\n    }\n\n    private CodingSessionState resolveSeedState(CodingSession parentSession, CodingSessionMode sessionMode) {\n        if (parentSession == null || sessionMode == null || sessionMode == CodingSessionMode.NEW) {\n            return null;\n        }\n        return parentSession.exportState();\n    }\n\n    private CodingAgentDefinition requireDefinition(CodingDelegateRequest request) {\n        String name = request == null ? null : request.getDefinitionName();\n        CodingAgentDefinition definition = definitionRegistry == null ? null : definitionRegistry.getDefinition(firstNonBlank(name, \"general-purpose\"));\n        if (definition == null) {\n            throw new IllegalArgumentException(\"Unknown coding agent definition: \" + firstNonBlank(name, \"general-purpose\"));\n        }\n        return definition;\n    }\n\n    private CodingSessionMode defaultSessionMode(CodingAgentDefinition definition) {\n        return definition == null || definition.getSessionMode() == null\n                ? CodingSessionMode.FORK\n                : definition.getSessionMode();\n    }\n\n    private String composeInput(CodingDelegateRequest request) {\n        if (request == null) {\n            return \"\";\n        }\n        String input = trimToNull(request.getInput());\n        String context = trimToNull(request.getContext());\n        if (input == null) {\n            return context == null ? \"\" : context;\n        }\n        if (context == null) {\n            return input;\n        }\n        return input + \"\\n\\nContext:\\n\" + context;\n    }\n\n    private CodingTask saveTask(CodingTask task) {\n        if (taskManager == null || task == null) {\n            return task;\n        }\n        CodingTask saved = taskManager.save(task);\n        notifyTaskUpdated(saved);\n        return saved;\n    }\n\n    private CodingTask persistInitialTask(CodingTask task) {\n        if (taskManager == null || task == null) {\n            return task;\n        }\n        return taskManager.save(task);\n    }\n\n    private CodingSessionLink saveLink(CodingSessionLink link) {\n        if (sessionLinkStore == null || link == null) {\n            return link;\n        }\n        return sessionLinkStore.save(link);\n    }\n\n    private CodingTask updateTask(String taskId,\n                                  CodingTaskStatus status,\n                                  CodingTaskProgress progress,\n                                  long startedAt,\n                                  long endedAt,\n                                  String outputText,\n                                  String error) {\n        CodingTask current = taskManager == null ? null : taskManager.getTask(taskId);\n        if (current == null) {\n            throw new IllegalStateException(\"Coding task not found: \" + taskId);\n        }\n        return current.toBuilder()\n                .status(status)\n                .progress(progress)\n                .startedAtEpochMs(startedAt > 0 ? startedAt : current.getStartedAtEpochMs())\n                .endedAtEpochMs(endedAt > 0 ? endedAt : current.getEndedAtEpochMs())\n                .outputText(outputText == null ? current.getOutputText() : outputText)\n                .error(error == null ? current.getError() : error)\n                .build();\n    }\n\n    private CodingTaskProgress progress(String phase, String message, Integer percent, long now) {\n        return CodingTaskProgress.builder()\n                .phase(phase)\n                .message(message)\n                .percent(percent)\n                .updatedAtEpochMs(now)\n                .build();\n    }\n\n    private CodingDelegateResult buildDelegateResult(CodingTask task, Exception error) {\n        return CodingDelegateResult.builder()\n                .taskId(task == null ? null : task.getTaskId())\n                .definitionName(task == null ? null : task.getDefinitionName())\n                .parentSessionId(task == null ? null : task.getParentSessionId())\n                .childSessionId(task == null ? null : task.getChildSessionId())\n                .background(task != null && task.isBackground())\n                .status(task == null ? null : task.getStatus())\n                .outputText(task == null ? null : task.getOutputText())\n                .error(error == null ? (task == null ? null : task.getError()) : safeMessage(error))\n                .build();\n    }\n\n    private String mergeText(String base, String extra) {\n        if (isBlank(base)) {\n            return extra;\n        }\n        if (isBlank(extra)) {\n            return base;\n        }\n        return base + \"\\n\\n\" + extra;\n    }\n\n    private String resolveDelegateOutputText(CodingAgentResult result, CodingSession childSession) {\n        String direct = trimToNull(result == null ? null : result.getOutputText());\n        if (direct != null) {\n            return direct;\n        }\n        if (childSession == null) {\n            return null;\n        }\n        try {\n            return resolveLatestAssistantMessage(childSession.exportState() == null ? null : childSession.exportState().getMemorySnapshot());\n        } catch (Exception ignored) {\n            return null;\n        }\n    }\n\n    private String resolveLatestAssistantMessage(MemorySnapshot snapshot) {\n        if (snapshot == null || snapshot.getItems() == null || snapshot.getItems().isEmpty()) {\n            return null;\n        }\n        List<Object> items = snapshot.getItems();\n        for (int i = items.size() - 1; i >= 0; i--) {\n            JSONObject object = toJSONObject(items.get(i));\n            if (!\"message\".equals(object.getString(\"type\")) || !\"assistant\".equals(object.getString(\"role\"))) {\n                continue;\n            }\n            String text = trimToNull(extractMessageText(object.getJSONArray(\"content\")));\n            if (text != null) {\n                return text;\n            }\n        }\n        return null;\n    }\n\n    private String extractMessageText(JSONArray content) {\n        if (content == null || content.isEmpty()) {\n            return null;\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < content.size(); i++) {\n            JSONObject part = content.getJSONObject(i);\n            if (part == null) {\n                continue;\n            }\n            String partType = part.getString(\"type\");\n            if (\"input_text\".equals(partType) || \"output_text\".equals(partType)) {\n                String text = trimToNull(part.getString(\"text\"));\n                if (text != null) {\n                    if (builder.length() > 0) {\n                        builder.append(' ');\n                    }\n                    builder.append(text);\n                }\n            }\n        }\n        return builder.toString();\n    }\n\n    private JSONObject toJSONObject(Object raw) {\n        if (raw instanceof JSONObject) {\n            return (JSONObject) raw;\n        }\n        if (raw instanceof Map) {\n            return new JSONObject((Map<?, ?>) raw);\n        }\n        if (raw == null) {\n            return new JSONObject();\n        }\n        try {\n            return JSON.parseObject(JSON.toJSONString(raw));\n        } catch (Exception ignored) {\n            return new JSONObject();\n        }\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            String trimmed = trimToNull(value);\n            if (trimmed != null) {\n                return trimmed;\n            }\n        }\n        return null;\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private String safeMessage(Throwable throwable) {\n        if (throwable == null) {\n            return null;\n        }\n        Throwable current = throwable;\n        String message = null;\n        while (current != null) {\n            if (!isBlank(current.getMessage())) {\n                message = current.getMessage().trim();\n            }\n            current = current.getCause();\n        }\n        return isBlank(message) ? throwable.getClass().getSimpleName() : message;\n    }\n\n    private void notifyTaskCreated(CodingTask task, CodingSessionLink link) {\n        for (CodingRuntimeListener listener : listeners) {\n            try {\n                listener.onTaskCreated(copyTask(task), copyLink(link));\n            } catch (Exception ignored) {\n            }\n        }\n    }\n\n    private void notifyTaskUpdated(CodingTask task) {\n        for (CodingRuntimeListener listener : listeners) {\n            try {\n                listener.onTaskUpdated(copyTask(task));\n            } catch (Exception ignored) {\n            }\n        }\n    }\n\n    private CodingTask copyTask(CodingTask task) {\n        return task == null ? null : task.toBuilder().build();\n    }\n\n    private CodingSessionLink copyLink(CodingSessionLink link) {\n        return link == null ? null : link.toBuilder().build();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/CodingSessionDescriptor.java",
    "content": "package io.github.lnyocly.ai4j.coding.session;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class CodingSessionDescriptor {\n\n    private String sessionId;\n\n    private String rootSessionId;\n\n    private String parentSessionId;\n\n    private String provider;\n\n    private String protocol;\n\n    private String model;\n\n    private String workspace;\n\n    private String summary;\n\n    private int memoryItemCount;\n\n    private int processCount;\n\n    private int activeProcessCount;\n\n    private int restoredProcessCount;\n\n    private long createdAtEpochMs;\n\n    private long updatedAtEpochMs;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/CodingSessionLink.java",
    "content": "package io.github.lnyocly.ai4j.coding.session;\n\nimport io.github.lnyocly.ai4j.coding.definition.CodingSessionMode;\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingSessionLink {\n\n    private String linkId;\n\n    private String taskId;\n\n    private String definitionName;\n\n    private String parentSessionId;\n\n    private String childSessionId;\n\n    private CodingSessionMode sessionMode;\n\n    private boolean background;\n\n    private long createdAtEpochMs;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/CodingSessionLinkStore.java",
    "content": "package io.github.lnyocly.ai4j.coding.session;\n\nimport java.util.List;\n\npublic interface CodingSessionLinkStore {\n\n    CodingSessionLink save(CodingSessionLink link);\n\n    List<CodingSessionLink> listLinks();\n\n    List<CodingSessionLink> listLinksByParentSessionId(String parentSessionId);\n\n    CodingSessionLink findByChildSessionId(String childSessionId);\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/InMemoryCodingSessionLinkStore.java",
    "content": "package io.github.lnyocly.ai4j.coding.session;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class InMemoryCodingSessionLinkStore implements CodingSessionLinkStore {\n\n    private final Map<String, CodingSessionLink> linksByChildSessionId = new ConcurrentHashMap<String, CodingSessionLink>();\n\n    @Override\n    public CodingSessionLink save(CodingSessionLink link) {\n        if (link == null || isBlank(link.getChildSessionId())) {\n            throw new IllegalArgumentException(\"childSessionId is required\");\n        }\n        CodingSessionLink stored = link.toBuilder().build();\n        linksByChildSessionId.put(stored.getChildSessionId(), stored);\n        return stored.toBuilder().build();\n    }\n\n    @Override\n    public List<CodingSessionLink> listLinks() {\n        return sort(linksByChildSessionId.values());\n    }\n\n    @Override\n    public List<CodingSessionLink> listLinksByParentSessionId(String parentSessionId) {\n        if (isBlank(parentSessionId)) {\n            return Collections.emptyList();\n        }\n        List<CodingSessionLink> items = new ArrayList<CodingSessionLink>();\n        for (CodingSessionLink link : linksByChildSessionId.values()) {\n            if (link != null && parentSessionId.equals(link.getParentSessionId())) {\n                items.add(link.toBuilder().build());\n            }\n        }\n        sortInPlace(items);\n        return items;\n    }\n\n    @Override\n    public CodingSessionLink findByChildSessionId(String childSessionId) {\n        CodingSessionLink link = childSessionId == null ? null : linksByChildSessionId.get(childSessionId);\n        return link == null ? null : link.toBuilder().build();\n    }\n\n    private List<CodingSessionLink> sort(Iterable<CodingSessionLink> values) {\n        List<CodingSessionLink> items = new ArrayList<CodingSessionLink>();\n        for (CodingSessionLink value : values) {\n            if (value != null) {\n                items.add(value.toBuilder().build());\n            }\n        }\n        sortInPlace(items);\n        return items;\n    }\n\n    private void sortInPlace(List<CodingSessionLink> items) {\n        Collections.sort(items, new Comparator<CodingSessionLink>() {\n            @Override\n            public int compare(CodingSessionLink left, CodingSessionLink right) {\n                long leftTime = left == null ? 0L : left.getCreatedAtEpochMs();\n                long rightTime = right == null ? 0L : right.getCreatedAtEpochMs();\n                if (leftTime == rightTime) {\n                    String leftId = left == null ? null : left.getChildSessionId();\n                    String rightId = right == null ? null : right.getChildSessionId();\n                    if (leftId == null) {\n                        return rightId == null ? 0 : -1;\n                    }\n                    if (rightId == null) {\n                        return 1;\n                    }\n                    return leftId.compareTo(rightId);\n                }\n                return leftTime < rightTime ? -1 : 1;\n            }\n        });\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/ManagedCodingSession.java",
    "content": "package io.github.lnyocly.ai4j.coding.session;\n\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.CodingSessionSnapshot;\n\npublic class ManagedCodingSession implements AutoCloseable {\n\n    private final CodingSession session;\n    private final String provider;\n    private final String protocol;\n    private final String model;\n    private final String workspace;\n    private final String workspaceDescription;\n    private final String systemPrompt;\n    private final String instructions;\n    private final String rootSessionId;\n    private final String parentSessionId;\n    private final long createdAtEpochMs;\n    private long updatedAtEpochMs;\n\n    public ManagedCodingSession(CodingSession session,\n                                String provider,\n                                String protocol,\n                                String model,\n                                String workspace,\n                                String workspaceDescription,\n                                String systemPrompt,\n                                String instructions,\n                                String rootSessionId,\n                                String parentSessionId,\n                                long createdAtEpochMs,\n                                long updatedAtEpochMs) {\n        this.session = session;\n        this.provider = provider;\n        this.protocol = protocol;\n        this.model = model;\n        this.workspace = workspace;\n        this.workspaceDescription = workspaceDescription;\n        this.systemPrompt = systemPrompt;\n        this.instructions = instructions;\n        this.rootSessionId = rootSessionId;\n        this.parentSessionId = parentSessionId;\n        this.createdAtEpochMs = createdAtEpochMs;\n        this.updatedAtEpochMs = updatedAtEpochMs;\n    }\n\n    public CodingSession getSession() {\n        return session;\n    }\n\n    public String getSessionId() {\n        return session == null ? null : session.getSessionId();\n    }\n\n    public String getProvider() {\n        return provider;\n    }\n\n    public String getProtocol() {\n        return protocol;\n    }\n\n    public String getModel() {\n        return model;\n    }\n\n    public String getWorkspace() {\n        return workspace;\n    }\n\n    public String getWorkspaceDescription() {\n        return workspaceDescription;\n    }\n\n    public String getSystemPrompt() {\n        return systemPrompt;\n    }\n\n    public String getInstructions() {\n        return instructions;\n    }\n\n    public String getRootSessionId() {\n        return rootSessionId;\n    }\n\n    public String getParentSessionId() {\n        return parentSessionId;\n    }\n\n    public long getCreatedAtEpochMs() {\n        return createdAtEpochMs;\n    }\n\n    public long getUpdatedAtEpochMs() {\n        return updatedAtEpochMs;\n    }\n\n    public void touch(long updatedAtEpochMs) {\n        this.updatedAtEpochMs = updatedAtEpochMs;\n    }\n\n    public CodingSessionDescriptor toDescriptor() {\n        CodingSessionSnapshot snapshot = session == null ? null : session.snapshot();\n        return CodingSessionDescriptor.builder()\n                .sessionId(getSessionId())\n                .rootSessionId(rootSessionId)\n                .parentSessionId(parentSessionId)\n                .provider(provider)\n                .protocol(protocol)\n                .model(model)\n                .workspace(workspace)\n                .summary(snapshot == null ? null : snapshot.getSummary())\n                .memoryItemCount(snapshot == null ? 0 : snapshot.getMemoryItemCount())\n                .processCount(snapshot == null ? 0 : snapshot.getProcessCount())\n                .activeProcessCount(snapshot == null ? 0 : snapshot.getActiveProcessCount())\n                .restoredProcessCount(snapshot == null ? 0 : snapshot.getRestoredProcessCount())\n                .createdAtEpochMs(createdAtEpochMs)\n                .updatedAtEpochMs(updatedAtEpochMs)\n                .build();\n    }\n\n    @Override\n    public void close() {\n        if (session != null) {\n            session.close();\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/SessionEvent.java",
    "content": "package io.github.lnyocly.ai4j.coding.session;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class SessionEvent {\n\n    private String eventId;\n\n    private String sessionId;\n\n    private SessionEventType type;\n\n    private long timestamp;\n\n    private String turnId;\n\n    private Integer step;\n\n    private String summary;\n\n    private Map<String, Object> payload;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/session/SessionEventType.java",
    "content": "package io.github.lnyocly.ai4j.coding.session;\n\npublic enum SessionEventType {\n    SESSION_CREATED,\n    SESSION_SAVED,\n    SESSION_RESUMED,\n    SESSION_FORKED,\n    USER_MESSAGE,\n    ASSISTANT_MESSAGE,\n    TOOL_CALL,\n    TOOL_RESULT,\n    TASK_CREATED,\n    TASK_UPDATED,\n    TEAM_MESSAGE,\n    COMPACT,\n    AUTO_CONTINUE,\n    AUTO_STOP,\n    BLOCKED,\n    PROCESS_STARTED,\n    PROCESS_UPDATED,\n    PROCESS_STOPPED,\n    ERROR\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/LocalShellCommandExecutor.java",
    "content": "package io.github.lnyocly.ai4j.coding.shell;\n\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.nio.charset.Charset;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\n\npublic class LocalShellCommandExecutor implements ShellCommandExecutor {\n\n    private final WorkspaceContext workspaceContext;\n    private final long defaultTimeoutMs;\n\n    public LocalShellCommandExecutor(WorkspaceContext workspaceContext, long defaultTimeoutMs) {\n        this.workspaceContext = workspaceContext;\n        this.defaultTimeoutMs = defaultTimeoutMs;\n    }\n\n    @Override\n    public ShellCommandResult execute(ShellCommandRequest request) throws Exception {\n        if (request == null || isBlank(request.getCommand())) {\n            throw new IllegalArgumentException(\"command is required\");\n        }\n\n        Path workingDirectory = workspaceContext.resolveWorkspacePath(request.getWorkingDirectory());\n        long timeoutMs = request.getTimeoutMs() == null || request.getTimeoutMs() <= 0\n                ? defaultTimeoutMs\n                : request.getTimeoutMs();\n\n        ProcessBuilder processBuilder = new ProcessBuilder(buildShellCommand(request.getCommand()));\n        processBuilder.directory(workingDirectory.toFile());\n        Process process = processBuilder.start();\n        Charset shellCharset = ShellCommandSupport.resolveShellCharset();\n\n        StringBuilder stdout = new StringBuilder();\n        StringBuilder stderr = new StringBuilder();\n        Thread stdoutThread = new Thread(new StreamCollector(process.getInputStream(), stdout, shellCharset), \"ai4j-coding-stdout\");\n        Thread stderrThread = new Thread(new StreamCollector(process.getErrorStream(), stderr, shellCharset), \"ai4j-coding-stderr\");\n        stdoutThread.start();\n        stderrThread.start();\n\n        boolean finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS);\n        int exitCode;\n        if (finished) {\n            exitCode = process.exitValue();\n        } else {\n            process.destroyForcibly();\n            process.waitFor(5L, TimeUnit.SECONDS);\n            exitCode = -1;\n            appendTimeoutHint(stderr);\n        }\n\n        stdoutThread.join();\n        stderrThread.join();\n\n        return ShellCommandResult.builder()\n                .command(request.getCommand())\n                .workingDirectory(workingDirectory.toString())\n                .stdout(stdout.toString())\n                .stderr(stderr.toString())\n                .exitCode(exitCode)\n                .timedOut(!finished)\n                .build();\n    }\n\n    private List<String> buildShellCommand(String command) {\n        return ShellCommandSupport.buildShellCommand(command);\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private void appendTimeoutHint(StringBuilder stderr) {\n        if (stderr == null) {\n            return;\n        }\n        if (stderr.length() > 0) {\n            stderr.append('\\n');\n        }\n        stderr.append(\"Command timed out before exit. If it is interactive or long-running, use bash action=start and then bash action=logs/status/write/stop instead of bash action=exec.\");\n    }\n\n    private static class StreamCollector implements Runnable {\n\n        private final InputStream inputStream;\n        private final StringBuilder target;\n        private final Charset charset;\n\n        private StreamCollector(InputStream inputStream, StringBuilder target, Charset charset) {\n            this.inputStream = inputStream;\n            this.target = target;\n            this.charset = charset;\n        }\n\n        @Override\n        public void run() {\n            try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) {\n                String line;\n                while ((line = reader.readLine()) != null) {\n                    if (target.length() > 0) {\n                        target.append('\\n');\n                    }\n                    target.append(line);\n                }\n            } catch (IOException ignored) {\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandExecutor.java",
    "content": "package io.github.lnyocly.ai4j.coding.shell;\n\npublic interface ShellCommandExecutor {\n\n    ShellCommandResult execute(ShellCommandRequest request) throws Exception;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandRequest.java",
    "content": "package io.github.lnyocly.ai4j.coding.shell;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ShellCommandRequest {\n\n    private String command;\n\n    private String workingDirectory;\n\n    private Long timeoutMs;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandResult.java",
    "content": "package io.github.lnyocly.ai4j.coding.shell;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class ShellCommandResult {\n\n    private String command;\n\n    private String workingDirectory;\n\n    private String stdout;\n\n    private String stderr;\n\n    private int exitCode;\n\n    private boolean timedOut;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandSupport.java",
    "content": "package io.github.lnyocly.ai4j.coding.shell;\n\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Locale;\n\npublic final class ShellCommandSupport {\n\n    private static final String SHELL_ENCODING_PROPERTY = \"ai4j.shell.encoding\";\n    private static final String SHELL_ENCODING_ENV = \"AI4J_SHELL_ENCODING\";\n\n    private ShellCommandSupport() {\n    }\n\n    public static List<String> buildShellCommand(String command) {\n        return buildShellCommand(command, System.getProperty(\"os.name\", \"\"));\n    }\n\n    static List<String> buildShellCommand(String command, String osName) {\n        if (isWindows(osName)) {\n            return Arrays.asList(\"cmd.exe\", \"/c\", command);\n        }\n        return Arrays.asList(\"sh\", \"-lc\", command);\n    }\n\n    public static String buildShellUsageGuidance() {\n        return buildShellUsageGuidance(System.getProperty(\"os.name\", \"\"));\n    }\n\n    static String buildShellUsageGuidance(String osName) {\n        if (isWindows(osName)) {\n            return \"The bash tool runs through cmd.exe /c on this machine. Use Windows command syntax and redirection; avoid Unix-only shell features such as cat <<EOF or other POSIX heredocs. Use action=exec only for commands that finish on their own. For interactive or long-running commands, use action=start and then action=logs/status/write/stop.\";\n        }\n        return \"The bash tool runs through sh -lc on this machine. Use POSIX shell syntax; avoid cmd.exe or PowerShell-only commands such as type nul > file or Get-Content. Use action=exec only for commands that finish on their own. For interactive or long-running commands, use action=start and then action=logs/status/write/stop.\";\n    }\n\n    public static Charset resolveShellCharset() {\n        return resolveShellCharset(\n                System.getProperty(\"os.name\", \"\"),\n                new String[]{\n                        System.getProperty(SHELL_ENCODING_PROPERTY),\n                        System.getenv(SHELL_ENCODING_ENV)\n                },\n                new String[]{\n                        System.getProperty(\"native.encoding\"),\n                        System.getProperty(\"sun.jnu.encoding\"),\n                        System.getProperty(\"file.encoding\"),\n                        Charset.defaultCharset().name()\n                }\n        );\n    }\n\n    static Charset resolveShellCharset(String osName, String[] explicitCandidates, String[] systemCandidates) {\n        Charset explicit = firstSupportedCharset(explicitCandidates);\n        if (explicit != null) {\n            return explicit;\n        }\n        if (!isWindows(osName)) {\n            return StandardCharsets.UTF_8;\n        }\n        Charset platform = firstSupportedCharset(systemCandidates);\n        return platform == null ? Charset.defaultCharset() : platform;\n    }\n\n    private static boolean isWindows(String osName) {\n        return osName != null && osName.toLowerCase(Locale.ROOT).contains(\"win\");\n    }\n\n    private static Charset firstSupportedCharset(String[] candidates) {\n        if (candidates == null) {\n            return null;\n        }\n        for (String candidate : candidates) {\n            if (candidate == null || candidate.trim().isEmpty()) {\n                continue;\n            }\n            try {\n                return Charset.forName(candidate.trim());\n            } catch (Exception ignored) {\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/skill/CodingSkillDescriptor.java",
    "content": "package io.github.lnyocly.ai4j.coding.skill;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class CodingSkillDescriptor {\n\n    private String name;\n    private String description;\n    private String skillFilePath;\n    private String source;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/skill/CodingSkillDiscovery.java",
    "content": "package io.github.lnyocly.ai4j.coding.skill;\n\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.skill.SkillDescriptor;\nimport io.github.lnyocly.ai4j.skill.Skills;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\n\npublic final class CodingSkillDiscovery {\n\n    private CodingSkillDiscovery() {\n    }\n\n    public static WorkspaceContext enrich(WorkspaceContext workspaceContext) {\n        WorkspaceContext source = workspaceContext == null\n                ? WorkspaceContext.builder().build()\n                : workspaceContext;\n        DiscoveryResult result = discover(source);\n        return source.toBuilder()\n                .allowedReadRoots(result.allowedReadRoots)\n                .availableSkills(result.skills)\n                .build();\n    }\n\n    public static DiscoveryResult discover(WorkspaceContext workspaceContext) {\n        WorkspaceContext source = workspaceContext == null\n                ? WorkspaceContext.builder().build()\n                : workspaceContext;\n        Skills.DiscoveryResult result = Skills.discoverDefault(source.getRoot(), source.getSkillDirectories());\n        return new DiscoveryResult(\n                toCodingDescriptors(result.getSkills()),\n                result.getAllowedReadRoots()\n        );\n    }\n\n    private static List<CodingSkillDescriptor> toCodingDescriptors(List<SkillDescriptor> skills) {\n        if (skills == null || skills.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<CodingSkillDescriptor> descriptors = new ArrayList<CodingSkillDescriptor>();\n        for (SkillDescriptor skill : skills) {\n            if (skill == null) {\n                continue;\n            }\n            descriptors.add(CodingSkillDescriptor.builder()\n                    .name(skill.getName())\n                    .description(skill.getDescription())\n                    .skillFilePath(skill.getSkillFilePath())\n                    .source(skill.getSource())\n                    .build());\n        }\n        return descriptors;\n    }\n\n    public static final class DiscoveryResult {\n\n        private final List<CodingSkillDescriptor> skills;\n        private final List<String> allowedReadRoots;\n\n        public DiscoveryResult(List<CodingSkillDescriptor> skills, List<String> allowedReadRoots) {\n            this.skills = skills == null ? Collections.<CodingSkillDescriptor>emptyList() : skills;\n            this.allowedReadRoots = allowedReadRoots == null ? Collections.<String>emptyList() : allowedReadRoots;\n        }\n\n        public List<CodingSkillDescriptor> getSkills() {\n            return skills;\n        }\n\n        public List<String> getAllowedReadRoots() {\n            return allowedReadRoots;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/CodingTask.java",
    "content": "package io.github.lnyocly.ai4j.coding.task;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingTask {\n\n    private String taskId;\n\n    private String definitionName;\n\n    private String parentSessionId;\n\n    private String childSessionId;\n\n    private String input;\n\n    private boolean background;\n\n    private CodingTaskStatus status;\n\n    private CodingTaskProgress progress;\n\n    private long createdAtEpochMs;\n\n    private long startedAtEpochMs;\n\n    private long endedAtEpochMs;\n\n    private String outputText;\n\n    private String error;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/CodingTaskManager.java",
    "content": "package io.github.lnyocly.ai4j.coding.task;\n\nimport java.util.List;\n\npublic interface CodingTaskManager {\n\n    CodingTask save(CodingTask task);\n\n    CodingTask getTask(String taskId);\n\n    List<CodingTask> listTasks();\n\n    List<CodingTask> listTasksByParentSessionId(String parentSessionId);\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/CodingTaskProgress.java",
    "content": "package io.github.lnyocly.ai4j.coding.task;\n\nimport lombok.Builder;\nimport lombok.Data;\n\n@Data\n@Builder(toBuilder = true)\npublic class CodingTaskProgress {\n\n    private String phase;\n\n    private String message;\n\n    private Integer percent;\n\n    private long updatedAtEpochMs;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/CodingTaskStatus.java",
    "content": "package io.github.lnyocly.ai4j.coding.task;\n\npublic enum CodingTaskStatus {\n    QUEUED,\n    STARTING,\n    RUNNING,\n    COMPLETED,\n    FAILED,\n    CANCELLED;\n\n    public boolean isTerminal() {\n        return this == COMPLETED || this == FAILED || this == CANCELLED;\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/task/InMemoryCodingTaskManager.java",
    "content": "package io.github.lnyocly.ai4j.coding.task;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic class InMemoryCodingTaskManager implements CodingTaskManager {\n\n    private final Map<String, CodingTask> tasks = new ConcurrentHashMap<String, CodingTask>();\n\n    @Override\n    public CodingTask save(CodingTask task) {\n        if (task == null || isBlank(task.getTaskId())) {\n            throw new IllegalArgumentException(\"taskId is required\");\n        }\n        CodingTask stored = task.toBuilder().build();\n        tasks.put(stored.getTaskId(), stored);\n        return stored.toBuilder().build();\n    }\n\n    @Override\n    public CodingTask getTask(String taskId) {\n        CodingTask task = taskId == null ? null : tasks.get(taskId);\n        return task == null ? null : task.toBuilder().build();\n    }\n\n    @Override\n    public List<CodingTask> listTasks() {\n        return sort(tasks.values());\n    }\n\n    @Override\n    public List<CodingTask> listTasksByParentSessionId(String parentSessionId) {\n        if (isBlank(parentSessionId)) {\n            return Collections.emptyList();\n        }\n        List<CodingTask> matches = new ArrayList<CodingTask>();\n        for (CodingTask task : tasks.values()) {\n            if (task != null && parentSessionId.equals(task.getParentSessionId())) {\n                matches.add(task.toBuilder().build());\n            }\n        }\n        sortInPlace(matches);\n        return matches;\n    }\n\n    private List<CodingTask> sort(Iterable<CodingTask> values) {\n        List<CodingTask> items = new ArrayList<CodingTask>();\n        for (CodingTask task : values) {\n            if (task != null) {\n                items.add(task.toBuilder().build());\n            }\n        }\n        sortInPlace(items);\n        return items;\n    }\n\n    private void sortInPlace(List<CodingTask> items) {\n        Collections.sort(items, new Comparator<CodingTask>() {\n            @Override\n            public int compare(CodingTask left, CodingTask right) {\n                long leftTime = left == null ? 0L : left.getCreatedAtEpochMs();\n                long rightTime = right == null ? 0L : right.getCreatedAtEpochMs();\n                if (leftTime == rightTime) {\n                    String leftId = left == null ? null : left.getTaskId();\n                    String rightId = right == null ? null : right.getTaskId();\n                    if (leftId == null) {\n                        return rightId == null ? 0 : -1;\n                    }\n                    if (rightId == null) {\n                        return 1;\n                    }\n                    return leftId.compareTo(rightId);\n                }\n                return leftTime < rightTime ? -1 : 1;\n            }\n        });\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ApplyPatchToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.coding.tool;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.patch.ApplyPatchFileChange;\nimport io.github.lnyocly.ai4j.coding.patch.ApplyPatchResult;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.LinkedHashSet;\nimport java.util.List;\nimport java.util.Set;\n\npublic class ApplyPatchToolExecutor implements ToolExecutor {\n\n    private static final String BEGIN_PATCH = \"*** Begin Patch\";\n    private static final String END_PATCH = \"*** End Patch\";\n    private static final String ADD_FILE = \"*** Add File:\";\n    private static final String ADD_FILE_ALIAS = \"*** Add:\";\n    private static final String UPDATE_FILE = \"*** Update File:\";\n    private static final String UPDATE_FILE_ALIAS = \"*** Update:\";\n    private static final String DELETE_FILE = \"*** Delete File:\";\n    private static final String DELETE_FILE_ALIAS = \"*** Delete:\";\n\n    private final WorkspaceContext workspaceContext;\n\n    public ApplyPatchToolExecutor(WorkspaceContext workspaceContext) {\n        this.workspaceContext = workspaceContext;\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        JSONObject arguments = parseArguments(call == null ? null : call.getArguments());\n        String patch = arguments.getString(\"patch\");\n        if (patch == null || patch.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"patch is required\");\n        }\n        ApplyPatchResult result = apply(patch);\n        return JSON.toJSONString(result);\n    }\n\n    private ApplyPatchResult apply(String patchText) throws IOException {\n        List<String> lines = normalizeLines(patchText);\n        if (lines.size() < 2 || !BEGIN_PATCH.equals(lines.get(0)) || !END_PATCH.equals(lines.get(lines.size() - 1))) {\n            throw new IllegalArgumentException(\"Invalid patch envelope\");\n        }\n\n        int index = 1;\n        int operationsApplied = 0;\n        Set<String> changedFiles = new LinkedHashSet<String>();\n        List<ApplyPatchFileChange> fileChanges = new ArrayList<ApplyPatchFileChange>();\n        while (index < lines.size() - 1) {\n            String line = lines.get(index);\n            PatchDirective directive = parseDirective(line);\n            if (directive == null) {\n                if (line.trim().isEmpty()) {\n                    index++;\n                    continue;\n                }\n                throw new IllegalArgumentException(\"Unsupported patch line: \" + line);\n            }\n            if (\"add\".equals(directive.operation)) {\n                String path = directive.path;\n                PatchOperation operation = applyAddFile(lines, index + 1, path);\n                index = operation.nextIndex;\n                operationsApplied++;\n                changedFiles.add(operation.fileChange.getPath());\n                fileChanges.add(operation.fileChange);\n                continue;\n            }\n            if (\"update\".equals(directive.operation)) {\n                String path = directive.path;\n                PatchOperation operation = applyUpdateFile(lines, index + 1, path);\n                index = operation.nextIndex;\n                operationsApplied++;\n                changedFiles.add(operation.fileChange.getPath());\n                fileChanges.add(operation.fileChange);\n                continue;\n            }\n            if (\"delete\".equals(directive.operation)) {\n                String path = directive.path;\n                PatchOperation operation = applyDeleteFile(path, index + 1);\n                index = operation.nextIndex;\n                operationsApplied++;\n                changedFiles.add(operation.fileChange.getPath());\n                fileChanges.add(operation.fileChange);\n                continue;\n            }\n        }\n\n        return ApplyPatchResult.builder()\n                .filesChanged(changedFiles.size())\n                .operationsApplied(operationsApplied)\n                .changedFiles(new ArrayList<String>(changedFiles))\n                .fileChanges(fileChanges)\n                .build();\n    }\n\n    private PatchOperation applyAddFile(List<String> lines, int startIndex, String path) throws IOException {\n        Path file = workspaceContext.resolveWorkspacePath(path);\n        if (Files.exists(file)) {\n            throw new IllegalArgumentException(\"File already exists: \" + path);\n        }\n        List<String> contentLines = new ArrayList<String>();\n        int index = startIndex;\n        while (index < lines.size() - 1 && !lines.get(index).startsWith(\"*** \")) {\n            String line = lines.get(index);\n            if (!line.startsWith(\"+\")) {\n                throw new IllegalArgumentException(\"Add file lines must start with '+': \" + line);\n            }\n            contentLines.add(line.substring(1));\n            index++;\n        }\n        writeFile(file, joinLines(contentLines));\n        return new PatchOperation(index, ApplyPatchFileChange.builder()\n                .path(normalizeRelativePath(path))\n                .operation(\"add\")\n                .linesAdded(contentLines.size())\n                .linesRemoved(0)\n                .build());\n    }\n\n    private PatchOperation applyUpdateFile(List<String> lines, int startIndex, String path) throws IOException {\n        Path file = workspaceContext.resolveWorkspacePath(path);\n        if (!Files.exists(file) || Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"File does not exist: \" + path);\n        }\n\n        List<String> body = new ArrayList<String>();\n        int index = startIndex;\n        while (index < lines.size() - 1 && !lines.get(index).startsWith(\"*** \")) {\n            body.add(lines.get(index));\n            index++;\n        }\n\n        List<String> normalizedBody = normalizeUpdateBody(body);\n        String original = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);\n        String updated = applyUpdateBody(original, normalizedBody, path);\n        writeFile(file, updated);\n        return new PatchOperation(index, ApplyPatchFileChange.builder()\n                .path(normalizeRelativePath(path))\n                .operation(\"update\")\n                .linesAdded(countPrefixedLines(normalizedBody, '+'))\n                .linesRemoved(countPrefixedLines(normalizedBody, '-'))\n                .build());\n    }\n\n    private PatchOperation applyDeleteFile(String path, int startIndex) throws IOException {\n        Path file = workspaceContext.resolveWorkspacePath(path);\n        if (!Files.exists(file) || Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"File does not exist: \" + path);\n        }\n        String original = new String(Files.readAllBytes(file), StandardCharsets.UTF_8);\n        int removed = splitContentLines(original).size();\n        Files.delete(file);\n        return new PatchOperation(startIndex, ApplyPatchFileChange.builder()\n                .path(normalizeRelativePath(path))\n                .operation(\"delete\")\n                .linesAdded(0)\n                .linesRemoved(removed)\n                .build());\n    }\n\n    private String applyUpdateBody(String original, List<String> body, String path) {\n        List<String> originalLines = splitContentLines(original);\n        List<List<String>> hunks = parseHunks(body);\n\n        List<String> output = new ArrayList<String>();\n        int cursor = 0;\n        for (List<String> hunk : hunks) {\n            List<String> anchor = resolveAnchor(hunk);\n            int matchIndex = findAnchor(originalLines, cursor, anchor);\n            if (matchIndex < 0) {\n                throw new IllegalArgumentException(\"Failed to locate patch hunk in file: \" + path);\n            }\n\n            appendRange(output, originalLines, cursor, matchIndex);\n            int current = matchIndex;\n            for (String line : hunk) {\n                if (line.startsWith(\"@@\")) {\n                    continue;\n                }\n                if (line.isEmpty()) {\n                    throw new IllegalArgumentException(\"Invalid empty patch line in update body\");\n                }\n                char prefix = line.charAt(0);\n                String content = line.substring(1);\n                switch (prefix) {\n                    case ' ':\n                        ensureMatch(originalLines, current, content, path);\n                        output.add(originalLines.get(current));\n                        current++;\n                        break;\n                    case '-':\n                        ensureMatch(originalLines, current, content, path);\n                        current++;\n                        break;\n                    case '+':\n                        output.add(content);\n                        break;\n                    default:\n                        throw new IllegalArgumentException(\"Unsupported update line: \" + line);\n                }\n            }\n            cursor = current;\n        }\n\n        appendRange(output, originalLines, cursor, originalLines.size());\n        return joinLines(output);\n    }\n\n    private List<List<String>> parseHunks(List<String> body) {\n        List<List<String>> hunks = new ArrayList<List<String>>();\n        List<String> current = new ArrayList<String>();\n        for (String line : body) {\n            if (line.startsWith(\"@@\")) {\n                if (!current.isEmpty()) {\n                    hunks.add(current);\n                    current = new ArrayList<String>();\n                }\n                current.add(line);\n                continue;\n            }\n            if (line.startsWith(\" \") || line.startsWith(\"+\") || line.startsWith(\"-\")) {\n                current.add(line);\n                continue;\n            }\n            if (line.trim().isEmpty()) {\n                current.add(\" \");\n                continue;\n            }\n            throw new IllegalArgumentException(\"Unsupported update body line: \" + line);\n        }\n        if (!current.isEmpty()) {\n            hunks.add(current);\n        }\n        if (hunks.isEmpty()) {\n            throw new IllegalArgumentException(\"Update file patch must contain at least one hunk\");\n        }\n        return hunks;\n    }\n\n    private List<String> resolveAnchor(List<String> hunk) {\n        List<String> leading = new ArrayList<String>();\n        for (String line : hunk) {\n            if (line.startsWith(\"@@\")) {\n                continue;\n            }\n            if (line.startsWith(\" \") || line.startsWith(\"-\")) {\n                leading.add(line.substring(1));\n            } else if (line.startsWith(\"+\")) {\n                break;\n            }\n        }\n        if (!leading.isEmpty()) {\n            return leading;\n        }\n\n        for (String line : hunk) {\n            if (line.startsWith(\"@@\") || line.startsWith(\"+\")) {\n                continue;\n            }\n            leading.add(line.substring(1));\n        }\n        return leading;\n    }\n\n    private int findAnchor(List<String> originalLines, int fromIndex, List<String> anchor) {\n        if (anchor.isEmpty()) {\n            return fromIndex;\n        }\n        int max = originalLines.size() - anchor.size();\n        for (int i = Math.max(0, fromIndex); i <= max; i++) {\n            boolean matched = true;\n            for (int j = 0; j < anchor.size(); j++) {\n                if (!originalLines.get(i + j).equals(anchor.get(j))) {\n                    matched = false;\n                    break;\n                }\n            }\n            if (matched) {\n                return i;\n            }\n        }\n        return -1;\n    }\n\n    private void ensureMatch(List<String> originalLines, int current, String expected, String path) {\n        if (current >= originalLines.size()) {\n            throw new IllegalArgumentException(\"Patch exceeds file length: \" + path);\n        }\n        String actual = originalLines.get(current);\n        if (!actual.equals(expected)) {\n            throw new IllegalArgumentException(\"Patch context mismatch for file \" + path + \": expected '\" + expected + \"' but found '\" + actual + \"'\");\n        }\n    }\n\n    private void appendRange(List<String> output, List<String> originalLines, int from, int to) {\n        for (int i = from; i < to; i++) {\n            output.add(originalLines.get(i));\n        }\n    }\n\n    private void writeFile(Path file, String content) throws IOException {\n        Path parent = file.getParent();\n        if (parent != null) {\n            Files.createDirectories(parent);\n        }\n        Files.write(file, content.getBytes(StandardCharsets.UTF_8));\n    }\n\n    private List<String> splitContentLines(String content) {\n        String normalized = content.replace(\"\\r\\n\", \"\\n\").replace('\\r', '\\n');\n        if (normalized.isEmpty()) {\n            return new ArrayList<String>();\n        }\n        String[] parts = normalized.split(\"\\n\", -1);\n        List<String> lines = new ArrayList<String>();\n        int length = parts.length;\n        if (length > 0 && parts[length - 1].isEmpty()) {\n            length--;\n        }\n        for (int i = 0; i < length; i++) {\n            lines.add(parts[i]);\n        }\n        return lines;\n    }\n\n    private String joinLines(List<String> lines) {\n        if (lines == null || lines.isEmpty()) {\n            return \"\";\n        }\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < lines.size(); i++) {\n            if (i > 0) {\n                builder.append('\\n');\n            }\n            builder.append(lines.get(i));\n        }\n        return builder.toString();\n    }\n\n    private String normalizeRelativePath(String path) {\n        Path resolved = workspaceContext.resolveWorkspacePath(path);\n        Path root = workspaceContext.getRoot();\n        if (resolved.equals(root)) {\n            return \".\";\n        }\n        return root.relativize(resolved).toString().replace('\\\\', '/');\n    }\n\n    private JSONObject parseArguments(String rawArguments) {\n        if (rawArguments == null || rawArguments.trim().isEmpty()) {\n            return new JSONObject();\n        }\n        return JSON.parseObject(rawArguments);\n    }\n\n    private List<String> normalizeLines(String content) {\n        String normalized = content.replace(\"\\r\\n\", \"\\n\").replace('\\r', '\\n');\n        String[] parts = normalized.split(\"\\n\", -1);\n        List<String> lines = new ArrayList<String>();\n        int length = parts.length;\n        if (length > 0 && parts[length - 1].isEmpty()) {\n            length--;\n        }\n        for (int i = 0; i < length; i++) {\n            lines.add(parts[i]);\n        }\n        return lines;\n    }\n\n    private int countPrefixedLines(List<String> lines, char prefix) {\n        if (lines == null || lines.isEmpty()) {\n            return 0;\n        }\n        int count = 0;\n        for (String line : lines) {\n            if (line != null && !line.isEmpty() && line.charAt(0) == prefix) {\n                count++;\n            }\n        }\n        return count;\n    }\n\n    private List<String> normalizeUpdateBody(List<String> body) {\n        if (body == null || body.isEmpty()) {\n            return new ArrayList<String>();\n        }\n        List<String> normalized = new ArrayList<String>();\n        boolean sawPatchContent = false;\n        for (String line : body) {\n            if (!sawPatchContent && isUnifiedDiffMetadataLine(line)) {\n                continue;\n            }\n            normalized.add(line);\n            if (line != null\n                    && (line.startsWith(\"@@\")\n                    || line.startsWith(\" \")\n                    || line.startsWith(\"+\")\n                    || line.startsWith(\"-\"))) {\n                sawPatchContent = true;\n            }\n        }\n        return normalized;\n    }\n\n    private boolean isUnifiedDiffMetadataLine(String line) {\n        if (line == null) {\n            return false;\n        }\n        return line.startsWith(\"--- \")\n                || line.startsWith(\"+++ \")\n                || line.startsWith(\"diff --git \")\n                || line.startsWith(\"index \");\n    }\n\n    private PatchDirective parseDirective(String line) {\n        String path = directivePath(line, ADD_FILE);\n        if (path != null) {\n            return new PatchDirective(\"add\", path);\n        }\n        path = directivePath(line, ADD_FILE_ALIAS);\n        if (path != null) {\n            return new PatchDirective(\"add\", path);\n        }\n        path = directivePath(line, UPDATE_FILE);\n        if (path != null) {\n            return new PatchDirective(\"update\", path);\n        }\n        path = directivePath(line, UPDATE_FILE_ALIAS);\n        if (path != null) {\n            return new PatchDirective(\"update\", path);\n        }\n        path = directivePath(line, DELETE_FILE);\n        if (path != null) {\n            return new PatchDirective(\"delete\", path);\n        }\n        path = directivePath(line, DELETE_FILE_ALIAS);\n        if (path != null) {\n            return new PatchDirective(\"delete\", path);\n        }\n        return null;\n    }\n\n    private String directivePath(String line, String directivePrefix) {\n        if (line == null || directivePrefix == null || !line.startsWith(directivePrefix)) {\n            return null;\n        }\n        String rawPath = line.substring(directivePrefix.length()).trim();\n        String normalized = normalizePatchPath(rawPath);\n        if (normalized.isEmpty()) {\n            throw new IllegalArgumentException(\"Patch directive is missing a file path: \" + line);\n        }\n        return normalized;\n    }\n\n    private String normalizePatchPath(String rawPath) {\n        String path = rawPath == null ? \"\" : rawPath.trim();\n        while ((path.startsWith(\"/\") || path.startsWith(\"\\\\\")) && !looksLikeAbsolutePath(path)) {\n            path = path.substring(1).trim();\n        }\n        return path;\n    }\n\n    private boolean looksLikeAbsolutePath(String path) {\n        if (path == null || path.isEmpty()) {\n            return false;\n        }\n        if (path.startsWith(\"\\\\\\\\\") || path.startsWith(\"//\")) {\n            return true;\n        }\n        return path.length() >= 3\n                && Character.isLetter(path.charAt(0))\n                && path.charAt(1) == ':'\n                && (path.charAt(2) == '\\\\' || path.charAt(2) == '/');\n    }\n\n    private static final class PatchOperation {\n\n        private final int nextIndex;\n        private final ApplyPatchFileChange fileChange;\n\n        private PatchOperation(int nextIndex, ApplyPatchFileChange fileChange) {\n            this.nextIndex = nextIndex;\n            this.fileChange = fileChange;\n        }\n    }\n\n    private static final class PatchDirective {\n\n        private final String operation;\n        private final String path;\n\n        private PatchDirective(String operation, String path) {\n            this.operation = operation;\n            this.path = path;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/BashToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.coding.tool;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessInfo;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk;\nimport io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry;\nimport io.github.lnyocly.ai4j.coding.shell.LocalShellCommandExecutor;\nimport io.github.lnyocly.ai4j.coding.shell.ShellCommandRequest;\nimport io.github.lnyocly.ai4j.coding.shell.ShellCommandResult;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class BashToolExecutor implements ToolExecutor {\n\n    private final WorkspaceContext workspaceContext;\n    private final CodingAgentOptions options;\n    private final SessionProcessRegistry processRegistry;\n    private final LocalShellCommandExecutor shellCommandExecutor;\n\n    public BashToolExecutor(WorkspaceContext workspaceContext,\n                            CodingAgentOptions options,\n                            SessionProcessRegistry processRegistry) {\n        this.workspaceContext = workspaceContext;\n        this.options = options;\n        this.processRegistry = processRegistry;\n        this.shellCommandExecutor = new LocalShellCommandExecutor(workspaceContext, options.getDefaultCommandTimeoutMs());\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        JSONObject arguments = parseArguments(call == null ? null : call.getArguments());\n        String action = arguments.getString(\"action\");\n        if (action == null || action.trim().isEmpty()) {\n            action = \"exec\";\n        }\n        switch (action) {\n            case \"exec\":\n                return exec(arguments);\n            case \"start\":\n                return start(arguments);\n            case \"status\":\n                return status(arguments);\n            case \"logs\":\n                return logs(arguments);\n            case \"write\":\n                return write(arguments);\n            case \"stop\":\n                return stop(arguments);\n            case \"list\":\n                return list();\n            default:\n                throw new IllegalArgumentException(\"Unsupported bash action: \" + action);\n        }\n    }\n\n    private String exec(JSONObject arguments) throws Exception {\n        ShellCommandResult result = shellCommandExecutor.execute(ShellCommandRequest.builder()\n                .command(arguments.getString(\"command\"))\n                .workingDirectory(arguments.getString(\"cwd\"))\n                .timeoutMs(arguments.getLong(\"timeoutMs\"))\n                .build());\n        return JSON.toJSONString(result);\n    }\n\n    private String start(JSONObject arguments) throws Exception {\n        BashProcessInfo result = processRegistry.start(arguments.getString(\"command\"), arguments.getString(\"cwd\"));\n        return JSON.toJSONString(result);\n    }\n\n    private String status(JSONObject arguments) {\n        return JSON.toJSONString(processRegistry.status(arguments.getString(\"processId\")));\n    }\n\n    private String logs(JSONObject arguments) {\n        BashProcessLogChunk result = processRegistry.logs(\n                arguments.getString(\"processId\"),\n                arguments.getLong(\"offset\"),\n                arguments.getInteger(\"limit\")\n        );\n        return JSON.toJSONString(result);\n    }\n\n    private String write(JSONObject arguments) throws Exception {\n        String processId = arguments.getString(\"processId\");\n        int bytesWritten = processRegistry.write(processId, arguments.getString(\"input\"));\n        Map<String, Object> result = new LinkedHashMap<String, Object>();\n        result.put(\"process\", processRegistry.status(processId));\n        result.put(\"bytesWritten\", bytesWritten);\n        return JSON.toJSONString(result);\n    }\n\n    private String stop(JSONObject arguments) {\n        return JSON.toJSONString(processRegistry.stop(arguments.getString(\"processId\")));\n    }\n\n    private String list() {\n        return JSON.toJSONString(processRegistry.list());\n    }\n\n    private JSONObject parseArguments(String rawArguments) {\n        if (rawArguments == null || rawArguments.trim().isEmpty()) {\n            return new JSONObject();\n        }\n        return JSON.parseObject(rawArguments);\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/CodingToolNames.java",
    "content": "package io.github.lnyocly.ai4j.coding.tool;\n\nimport io.github.lnyocly.ai4j.tool.BuiltInTools;\n\nimport java.util.Set;\n\npublic final class CodingToolNames {\n\n    public static final String BASH = BuiltInTools.BASH;\n    public static final String READ_FILE = BuiltInTools.READ_FILE;\n    public static final String WRITE_FILE = BuiltInTools.WRITE_FILE;\n    public static final String APPLY_PATCH = BuiltInTools.APPLY_PATCH;\n\n    private CodingToolNames() {\n    }\n\n    public static Set<String> allBuiltIn() {\n        return BuiltInTools.allCodingToolNames();\n    }\n\n    public static Set<String> readOnlyBuiltIn() {\n        return BuiltInTools.readOnlyCodingToolNames();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/CodingToolRegistryFactory.java",
    "content": "package io.github.lnyocly.ai4j.coding.tool;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry;\nimport io.github.lnyocly.ai4j.tool.BuiltInTools;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic final class CodingToolRegistryFactory {\n\n    private CodingToolRegistryFactory() {\n    }\n\n    public static AgentToolRegistry createBuiltInRegistry() {\n        List<Object> tools = new ArrayList<Object>();\n        tools.addAll(BuiltInTools.codingTools());\n        return new StaticToolRegistry(tools);\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ReadFileToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.coding.tool;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceFileReadResult;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceFileService;\n\npublic class ReadFileToolExecutor implements ToolExecutor {\n\n    private final WorkspaceFileService workspaceFileService;\n    private final CodingAgentOptions options;\n\n    public ReadFileToolExecutor(WorkspaceFileService workspaceFileService, CodingAgentOptions options) {\n        this.workspaceFileService = workspaceFileService;\n        this.options = options;\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        JSONObject arguments = parseArguments(call == null ? null : call.getArguments());\n        WorkspaceFileReadResult result = workspaceFileService.readFile(\n                arguments.getString(\"path\"),\n                arguments.getInteger(\"startLine\"),\n                arguments.getInteger(\"endLine\"),\n                arguments.getInteger(\"maxChars\") == null ? options.getDefaultReadMaxChars() : arguments.getInteger(\"maxChars\")\n        );\n        return JSON.toJSONString(result);\n    }\n\n    private JSONObject parseArguments(String rawArguments) {\n        if (rawArguments == null || rawArguments.trim().isEmpty()) {\n            return new JSONObject();\n        }\n        return JSON.parseObject(rawArguments);\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/RoutingToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.coding.tool;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Set;\n\npublic class RoutingToolExecutor implements ToolExecutor {\n\n    private final List<Route> routes;\n    private final ToolExecutor fallbackExecutor;\n\n    public RoutingToolExecutor(List<Route> routes, ToolExecutor fallbackExecutor) {\n        if (routes == null) {\n            this.routes = Collections.emptyList();\n        } else {\n            this.routes = new ArrayList<>(routes);\n        }\n        this.fallbackExecutor = fallbackExecutor;\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        String toolName = call == null ? null : call.getName();\n        for (Route route : routes) {\n            if (route.supports(toolName)) {\n                return route.getExecutor().execute(call);\n            }\n        }\n        if (fallbackExecutor != null) {\n            return fallbackExecutor.execute(call);\n        }\n        throw new IllegalArgumentException(\"No tool executor found for tool: \" + toolName);\n    }\n\n    public static Route route(Set<String> toolNames, ToolExecutor executor) {\n        return new Route(toolNames, executor);\n    }\n\n    public static class Route {\n\n        private final Set<String> toolNames;\n        private final ToolExecutor executor;\n\n        public Route(Set<String> toolNames, ToolExecutor executor) {\n            if (toolNames == null) {\n                this.toolNames = Collections.emptySet();\n            } else {\n                this.toolNames = new HashSet<>(toolNames);\n            }\n            this.executor = executor;\n        }\n\n        public boolean supports(String toolName) {\n            return toolName != null && toolNames.contains(toolName);\n        }\n\n        public ToolExecutor getExecutor() {\n            return executor;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ToolExecutorDecorator.java",
    "content": "package io.github.lnyocly.ai4j.coding.tool;\n\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\n\npublic interface ToolExecutorDecorator {\n\n    ToolExecutor decorate(String toolName, ToolExecutor delegate);\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/WriteFileToolExecutor.java",
    "content": "package io.github.lnyocly.ai4j.coding.tool;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.OpenOption;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.nio.file.StandardOpenOption;\nimport java.util.Locale;\n\npublic class WriteFileToolExecutor implements ToolExecutor {\n\n    private final WorkspaceContext workspaceContext;\n\n    public WriteFileToolExecutor(WorkspaceContext workspaceContext) {\n        this.workspaceContext = workspaceContext;\n    }\n\n    @Override\n    public String execute(AgentToolCall call) throws Exception {\n        JSONObject arguments = parseArguments(call == null ? null : call.getArguments());\n        String path = safeTrim(arguments.getString(\"path\"));\n        if (isBlank(path)) {\n            throw new IllegalArgumentException(\"path is required\");\n        }\n        String content = arguments.containsKey(\"content\") && arguments.get(\"content\") != null\n                ? arguments.getString(\"content\")\n                : \"\";\n        String mode = firstNonBlank(safeTrim(arguments.getString(\"mode\")), \"overwrite\").toLowerCase(Locale.ROOT);\n        Path file = resolvePath(path);\n        if (Files.exists(file) && Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"Target is a directory: \" + path);\n        }\n\n        boolean existed = Files.exists(file);\n        boolean appended = false;\n        boolean created;\n        byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8);\n\n        Path parent = file.getParent();\n        if (parent != null) {\n            Files.createDirectories(parent);\n        }\n\n        if (\"create\".equals(mode)) {\n            if (existed) {\n                throw new IllegalArgumentException(\"File already exists: \" + path);\n            }\n            Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE});\n            created = true;\n        } else if (\"overwrite\".equals(mode)) {\n            Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE});\n            created = !existed;\n        } else if (\"append\".equals(mode)) {\n            Files.write(file, bytes, new OpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE});\n            created = !existed;\n            appended = true;\n        } else {\n            throw new IllegalArgumentException(\"Unsupported write mode: \" + mode);\n        }\n\n        JSONObject result = new JSONObject();\n        result.put(\"path\", path);\n        result.put(\"resolvedPath\", file.toString());\n        result.put(\"mode\", mode);\n        result.put(\"created\", created);\n        result.put(\"appended\", appended);\n        result.put(\"bytesWritten\", bytes.length);\n        return JSON.toJSONString(result);\n    }\n\n    private Path resolvePath(String path) {\n        Path candidate = Paths.get(path);\n        if (candidate.isAbsolute()) {\n            return candidate.toAbsolutePath().normalize();\n        }\n        Path root = workspaceContext == null ? Paths.get(\".\").toAbsolutePath().normalize() : workspaceContext.getRoot();\n        return root.resolve(path).toAbsolutePath().normalize();\n    }\n\n    private JSONObject parseArguments(String rawArguments) {\n        if (rawArguments == null || rawArguments.trim().isEmpty()) {\n            return new JSONObject();\n        }\n        return JSON.parseObject(rawArguments);\n    }\n\n    private String safeTrim(String value) {\n        return value == null ? null : value.trim();\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (!isBlank(value)) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/LocalWorkspaceFileService.java",
    "content": "package io.github.lnyocly.ai4j.coding.workspace;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.stream.Stream;\n\npublic class LocalWorkspaceFileService implements WorkspaceFileService {\n\n    private final WorkspaceContext workspaceContext;\n\n    public LocalWorkspaceFileService(WorkspaceContext workspaceContext) {\n        this.workspaceContext = workspaceContext;\n    }\n\n    @Override\n    public List<WorkspaceEntry> listFiles(String path, int maxDepth, int maxEntries) throws IOException {\n        Path start = workspaceContext.resolveReadablePath(path);\n        if (!Files.exists(start)) {\n            return Collections.emptyList();\n        }\n        if (!Files.isDirectory(start)) {\n            throw new IllegalArgumentException(\"Path is not a directory: \" + path);\n        }\n\n        int effectiveMaxDepth = maxDepth <= 0 ? 4 : maxDepth;\n        int effectiveMaxEntries = maxEntries <= 0 ? 200 : maxEntries;\n        List<WorkspaceEntry> entries = new ArrayList<>();\n\n        try (Stream<Path> stream = Files.walk(start, effectiveMaxDepth)) {\n            Iterator<Path> iterator = stream\n                    .filter(candidate -> !candidate.equals(start))\n                    .filter(candidate -> !workspaceContext.isExcluded(candidate))\n                    .iterator();\n            while (iterator.hasNext() && entries.size() < effectiveMaxEntries) {\n                Path candidate = iterator.next();\n                entries.add(WorkspaceEntry.builder()\n                        .path(toRelativePath(candidate))\n                        .directory(Files.isDirectory(candidate))\n                        .size(safeSize(candidate))\n                        .build());\n            }\n        }\n\n        return entries;\n    }\n\n    @Override\n    public WorkspaceFileReadResult readFile(String path, Integer startLine, Integer endLine, Integer maxChars) throws IOException {\n        Path file = workspaceContext.resolveReadablePath(path);\n        if (!Files.exists(file)) {\n            throw new IllegalArgumentException(\"File does not exist: \" + path);\n        }\n        if (Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"Path is a directory: \" + path);\n        }\n\n        List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);\n        int effectiveStartLine = startLine == null || startLine < 1 ? 1 : startLine;\n        int effectiveEndLine = endLine == null || endLine > lines.size() ? lines.size() : endLine;\n        if (effectiveEndLine < effectiveStartLine) {\n            effectiveEndLine = effectiveStartLine - 1;\n        }\n\n        StringBuilder contentBuilder = new StringBuilder();\n        for (int i = effectiveStartLine; i <= effectiveEndLine; i++) {\n            if (i > lines.size()) {\n                break;\n            }\n            if (contentBuilder.length() > 0) {\n                contentBuilder.append('\\n');\n            }\n            contentBuilder.append(lines.get(i - 1));\n        }\n\n        int effectiveMaxChars = maxChars == null || maxChars <= 0 ? 12000 : maxChars;\n        String content = contentBuilder.toString();\n        boolean truncated = false;\n        if (content.length() > effectiveMaxChars) {\n            content = content.substring(0, effectiveMaxChars);\n            truncated = true;\n        }\n\n        return WorkspaceFileReadResult.builder()\n                .path(toRelativePath(file))\n                .content(content)\n                .startLine(effectiveStartLine)\n                .endLine(effectiveEndLine)\n                .truncated(truncated)\n                .build();\n    }\n\n    @Override\n    public WorkspaceWriteResult writeFile(String path, String content, boolean append) throws IOException {\n        Path file = workspaceContext.resolveWorkspacePath(path);\n        if (Files.exists(file) && Files.isDirectory(file)) {\n            throw new IllegalArgumentException(\"Path is a directory: \" + path);\n        }\n        boolean created = !Files.exists(file);\n        Path parent = file.getParent();\n        if (parent != null) {\n            Files.createDirectories(parent);\n        }\n\n        byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8);\n        if (append) {\n            Files.write(file, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND);\n        } else {\n            Files.write(file, bytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);\n        }\n\n        return WorkspaceWriteResult.builder()\n                .path(toRelativePath(file))\n                .bytesWritten(bytes.length)\n                .created(created)\n                .appended(append)\n                .build();\n    }\n\n    private long safeSize(Path path) {\n        if (Files.isDirectory(path)) {\n            return 0L;\n        }\n        try {\n            return Files.size(path);\n        } catch (IOException ignored) {\n            return 0L;\n        }\n    }\n\n    private String toRelativePath(Path path) {\n        Path root = workspaceContext.getRoot();\n        if (path == null) {\n            return \"\";\n        }\n        if (!path.startsWith(root)) {\n            return path.toString().replace('\\\\', '/');\n        }\n        if (path.equals(root)) {\n            return \".\";\n        }\n        return root.relativize(path).toString().replace('\\\\', '/');\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceContext.java",
    "content": "package io.github.lnyocly.ai4j.coding.workspace;\n\nimport io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor;\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class WorkspaceContext {\n\n    @Builder.Default\n    private String rootPath = Paths.get(\".\").toAbsolutePath().normalize().toString();\n\n    @Builder.Default\n    private List<String> excludedPaths = defaultExcludedPaths();\n\n    @Builder.Default\n    private boolean allowOutsideWorkspace = false;\n\n    private String description;\n\n    @Builder.Default\n    private List<String> skillDirectories = new ArrayList<String>();\n\n    @Builder.Default\n    private List<String> allowedReadRoots = new ArrayList<String>();\n\n    @Builder.Default\n    private List<CodingSkillDescriptor> availableSkills = new ArrayList<CodingSkillDescriptor>();\n\n    public Path getRoot() {\n        return Paths.get(rootPath).toAbsolutePath().normalize();\n    }\n\n    public Path resolveWorkspacePath(String path) {\n        Path root = getRoot();\n        if (isBlank(path)) {\n            return root;\n        }\n        Path candidate = Paths.get(path);\n        if (!candidate.isAbsolute()) {\n            candidate = root.resolve(path);\n        }\n        candidate = candidate.toAbsolutePath().normalize();\n        if (!allowOutsideWorkspace && !candidate.startsWith(root)) {\n            throw new IllegalArgumentException(\"Path escapes workspace root: \" + path);\n        }\n        return candidate;\n    }\n\n    public Path resolveReadablePath(String path) {\n        Path root = getRoot();\n        if (isBlank(path)) {\n            return root;\n        }\n        Path candidate = Paths.get(path);\n        if (!candidate.isAbsolute()) {\n            candidate = root.resolve(path);\n        }\n        candidate = candidate.toAbsolutePath().normalize();\n        if (allowOutsideWorkspace || candidate.startsWith(root)) {\n            return candidate;\n        }\n        for (Path allowedRoot : getAllowedReadRootPaths()) {\n            if (candidate.startsWith(allowedRoot)) {\n                return candidate;\n            }\n        }\n        throw new IllegalArgumentException(\"Path escapes workspace root: \" + path);\n    }\n\n    public List<Path> getAllowedReadRootPaths() {\n        if (allowedReadRoots == null || allowedReadRoots.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Path> paths = new ArrayList<Path>();\n        for (String allowedReadRoot : allowedReadRoots) {\n            if (isBlank(allowedReadRoot)) {\n                continue;\n            }\n            paths.add(Paths.get(allowedReadRoot).toAbsolutePath().normalize());\n        }\n        return paths;\n    }\n\n    public boolean isExcluded(Path absolutePath) {\n        Path root = getRoot();\n        if (absolutePath == null || !absolutePath.startsWith(root)) {\n            return false;\n        }\n        Path relative = root.relativize(absolutePath);\n        for (Path part : relative) {\n            if (excludedPaths.contains(part.toString())) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private static List<String> defaultExcludedPaths() {\n        return new ArrayList<>(Arrays.asList(\".git\", \"target\", \"node_modules\", \".idea\"));\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceEntry.java",
    "content": "package io.github.lnyocly.ai4j.coding.workspace;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class WorkspaceEntry {\n\n    private String path;\n\n    private boolean directory;\n\n    private long size;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceFileReadResult.java",
    "content": "package io.github.lnyocly.ai4j.coding.workspace;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class WorkspaceFileReadResult {\n\n    private String path;\n\n    private String content;\n\n    private int startLine;\n\n    private int endLine;\n\n    private boolean truncated;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceFileService.java",
    "content": "package io.github.lnyocly.ai4j.coding.workspace;\n\nimport java.io.IOException;\nimport java.util.List;\n\npublic interface WorkspaceFileService {\n\n    List<WorkspaceEntry> listFiles(String path, int maxDepth, int maxEntries) throws IOException;\n\n    WorkspaceFileReadResult readFile(String path, Integer startLine, Integer endLine, Integer maxChars) throws IOException;\n\n    WorkspaceWriteResult writeFile(String path, String content, boolean append) throws IOException;\n}\n"
  },
  {
    "path": "ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceWriteResult.java",
    "content": "package io.github.lnyocly.ai4j.coding.workspace;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder\n@NoArgsConstructor\n@AllArgsConstructor\npublic class WorkspaceWriteResult {\n\n    private String path;\n\n    private long bytesWritten;\n\n    private boolean created;\n\n    private boolean appended;\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/ApplyPatchToolExecutorTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.coding.tool.ApplyPatchToolExecutor;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\n\npublic class ApplyPatchToolExecutorTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldAddUpdateAndDeleteFiles() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-patch\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .build();\n        ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(workspaceContext);\n\n        String addResult = executor.execute(call(patch(\n                \"*** Begin Patch\",\n                \"*** Add File: notes/todo.txt\",\n                \"+alpha\",\n                \"+beta\",\n                \"*** End Patch\"\n        )));\n        JSONObject add = JSON.parseObject(addResult);\n        assertEquals(1, add.getIntValue(\"filesChanged\"));\n        assertEquals(\"notes/todo.txt\", add.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"path\"));\n        assertEquals(\"add\", add.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"operation\"));\n        assertEquals(2, add.getJSONArray(\"fileChanges\").getJSONObject(0).getIntValue(\"linesAdded\"));\n        assertTrue(Files.exists(workspaceRoot.resolve(\"notes/todo.txt\")));\n        assertEquals(\"alpha\\nbeta\", new String(Files.readAllBytes(workspaceRoot.resolve(\"notes/todo.txt\")), StandardCharsets.UTF_8));\n\n        Files.write(workspaceRoot.resolve(\"demo.txt\"), \"first\\nsecond\\nthird\".getBytes(StandardCharsets.UTF_8));\n        String updateResult = executor.execute(call(patch(\n                \"*** Begin Patch\",\n                \"*** Update File: demo.txt\",\n                \"@@\",\n                \" first\",\n                \"-second\",\n                \"+updated-second\",\n                \" third\",\n                \"*** End Patch\"\n        )));\n        JSONObject update = JSON.parseObject(updateResult);\n        assertEquals(1, update.getIntValue(\"operationsApplied\"));\n        assertEquals(\"demo.txt\", update.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"path\"));\n        assertEquals(\"update\", update.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"operation\"));\n        assertEquals(1, update.getJSONArray(\"fileChanges\").getJSONObject(0).getIntValue(\"linesAdded\"));\n        assertEquals(1, update.getJSONArray(\"fileChanges\").getJSONObject(0).getIntValue(\"linesRemoved\"));\n        assertEquals(\"first\\nupdated-second\\nthird\",\n                new String(Files.readAllBytes(workspaceRoot.resolve(\"demo.txt\")), StandardCharsets.UTF_8));\n\n        String deleteResult = executor.execute(call(patch(\n                \"*** Begin Patch\",\n                \"*** Delete File: demo.txt\",\n                \"*** End Patch\"\n        )));\n        JSONObject delete = JSON.parseObject(deleteResult);\n        assertEquals(1, delete.getIntValue(\"filesChanged\"));\n        assertEquals(\"demo.txt\", delete.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"path\"));\n        assertEquals(\"delete\", delete.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"operation\"));\n        assertEquals(3, delete.getJSONArray(\"fileChanges\").getJSONObject(0).getIntValue(\"linesRemoved\"));\n        assertFalse(Files.exists(workspaceRoot.resolve(\"demo.txt\")));\n    }\n\n    @Test\n    public void shouldAcceptShortDirectiveAliases() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-patch-short-directives\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .build();\n        ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(workspaceContext);\n\n        String addResult = executor.execute(call(patch(\n                \"*** Begin Patch\",\n                \"*** Add: calculator.py\",\n                \"+print('ok')\",\n                \"*** End Patch\"\n        )));\n        JSONObject add = JSON.parseObject(addResult);\n        assertEquals(1, add.getIntValue(\"filesChanged\"));\n        assertEquals(\"calculator.py\", add.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"path\"));\n        assertTrue(Files.exists(workspaceRoot.resolve(\"calculator.py\")));\n        assertEquals(\"print('ok')\", new String(Files.readAllBytes(workspaceRoot.resolve(\"calculator.py\")), StandardCharsets.UTF_8));\n    }\n\n    @Test\n    public void shouldAcceptDirectiveWithoutSpaceAfterColonAndWorkspaceRootPath() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-patch-relaxed-directive\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .build();\n        ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(workspaceContext);\n\n        Files.write(workspaceRoot.resolve(\"sample.txt\"), \"value=1\".getBytes(StandardCharsets.UTF_8));\n\n        String updateResult = executor.execute(call(patch(\n                \"*** Begin Patch\",\n                \"*** Update File:/sample.txt\",\n                \"@@\",\n                \"-value=1\",\n                \"+value=2\",\n                \"*** End Patch\"\n        )));\n        JSONObject update = JSON.parseObject(updateResult);\n        assertEquals(1, update.getIntValue(\"operationsApplied\"));\n        assertEquals(\"sample.txt\", update.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"path\"));\n        assertEquals(\"value=2\", new String(Files.readAllBytes(workspaceRoot.resolve(\"sample.txt\")), StandardCharsets.UTF_8));\n    }\n\n    @Test\n    public void shouldAcceptUnifiedDiffMetadataBeforePatchHunk() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-patch-unified-diff\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .build();\n        ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(workspaceContext);\n\n        Files.write(workspaceRoot.resolve(\"sample.txt\"), \"value=1\".getBytes(StandardCharsets.UTF_8));\n\n        String updateResult = executor.execute(call(patch(\n                \"*** Begin Patch\",\n                \"*** Update File:/sample.txt\",\n                \"--- a/sample.txt\",\n                \"+++ b/sample.txt\",\n                \"@@\",\n                \"-value=1\",\n                \"+value=2\",\n                \"*** End Patch\"\n        )));\n        JSONObject update = JSON.parseObject(updateResult);\n        assertEquals(1, update.getIntValue(\"operationsApplied\"));\n        assertEquals(\"sample.txt\", update.getJSONArray(\"fileChanges\").getJSONObject(0).getString(\"path\"));\n        assertEquals(1, update.getJSONArray(\"fileChanges\").getJSONObject(0).getIntValue(\"linesAdded\"));\n        assertEquals(1, update.getJSONArray(\"fileChanges\").getJSONObject(0).getIntValue(\"linesRemoved\"));\n        assertEquals(\"value=2\", new String(Files.readAllBytes(workspaceRoot.resolve(\"sample.txt\")), StandardCharsets.UTF_8));\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void shouldRejectEscapingWorkspace() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-patch-escape\").toPath();\n        ApplyPatchToolExecutor executor = new ApplyPatchToolExecutor(\n                WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build()\n        );\n\n        executor.execute(call(patch(\n                \"*** Begin Patch\",\n                \"*** Add File: ../escape.txt\",\n                \"+blocked\",\n                \"*** End Patch\"\n        )));\n    }\n\n    private AgentToolCall call(String patch) {\n        JSONObject arguments = new JSONObject();\n        arguments.put(\"patch\", patch);\n        return AgentToolCall.builder()\n                .name(CodingToolNames.APPLY_PATCH)\n                .arguments(arguments.toJSONString())\n                .build();\n    }\n\n    private String patch(String... lines) {\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < lines.length; i++) {\n            if (i > 0) {\n                builder.append('\\n');\n            }\n            builder.append(lines[i]);\n        }\n        return builder.toString();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/BashToolExecutorTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry;\nimport io.github.lnyocly.ai4j.coding.tool.BashToolExecutor;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\nimport static org.junit.Assert.fail;\n\npublic class BashToolExecutorTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldExecuteForegroundCommand() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-bash-exec\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build();\n\n        BashToolExecutor executor = new BashToolExecutor(\n                workspaceContext,\n                CodingAgentOptions.builder().build(),\n                new SessionProcessRegistry(workspaceContext, CodingAgentOptions.builder().build())\n        );\n\n        String raw = executor.execute(call(json(\"action\", \"exec\", \"command\", \"echo hello-ai4j\")));\n        JSONObject result = JSON.parseObject(raw);\n\n        assertFalse(result.getBooleanValue(\"timedOut\"));\n        assertEquals(0, result.getIntValue(\"exitCode\"));\n        assertTrue(result.getString(\"stdout\").toLowerCase().contains(\"hello-ai4j\"));\n    }\n\n    @Test\n    public void shouldManageBackgroundProcessLifecycle() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-bash-bg\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build();\n        CodingAgentOptions options = CodingAgentOptions.builder().build();\n        SessionProcessRegistry registry = new SessionProcessRegistry(workspaceContext, options);\n        BashToolExecutor executor = new BashToolExecutor(workspaceContext, options, registry);\n\n        String startRaw = executor.execute(call(json(\"action\", \"start\", \"command\", backgroundCommand())));\n        JSONObject start = JSON.parseObject(startRaw);\n        String processId = start.getString(\"processId\");\n        assertEquals(\"RUNNING\", start.getString(\"status\"));\n\n        JSONObject logs = awaitLogs(executor, processId, 3000L);\n        assertTrue(logs.getLongValue(\"nextOffset\") > 0L);\n\n        String stopRaw = executor.execute(call(json(\"action\", \"stop\", \"processId\", processId)));\n        JSONObject stop = JSON.parseObject(stopRaw);\n        assertTrue(\"STOPPED\".equals(stop.getString(\"status\")) || \"EXITED\".equals(stop.getString(\"status\")));\n\n        String listRaw = executor.execute(call(json(\"action\", \"list\")));\n        JSONArray list = JSON.parseArray(listRaw);\n        assertEquals(1, list.size());\n    }\n\n    @Test\n    public void shouldWriteToBackgroundProcess() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-bash-write\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build();\n        CodingAgentOptions options = CodingAgentOptions.builder().build();\n        SessionProcessRegistry registry = new SessionProcessRegistry(workspaceContext, options);\n        BashToolExecutor executor = new BashToolExecutor(workspaceContext, options, registry);\n\n        String startRaw = executor.execute(call(json(\"action\", \"start\", \"command\", stdinEchoCommand())));\n        String processId = JSON.parseObject(startRaw).getString(\"processId\");\n\n        executor.execute(call(json(\"action\", \"write\", \"processId\", processId, \"input\", \"hello-stdin\\n\")));\n        JSONObject logs = awaitLogs(executor, processId, 3000L);\n        assertTrue(logs.getString(\"content\").toLowerCase().contains(\"hello-stdin\"));\n\n        executor.execute(call(json(\"action\", \"stop\", \"processId\", processId)));\n    }\n\n    @Test\n    public void shouldExposeRestoredProcessesAsMetadataOnly() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-bash-restored\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build();\n        CodingAgentOptions options = CodingAgentOptions.builder().build();\n\n        SessionProcessRegistry liveRegistry = new SessionProcessRegistry(workspaceContext, options);\n        BashToolExecutor liveExecutor = new BashToolExecutor(workspaceContext, options, liveRegistry);\n        String startRaw = liveExecutor.execute(call(json(\"action\", \"start\", \"command\", backgroundCommand())));\n        String processId = JSON.parseObject(startRaw).getString(\"processId\");\n        assertTrue(processId != null && !processId.isEmpty());\n        JSONObject liveLogs = awaitLogs(liveExecutor, processId, 3000L);\n        assertTrue(liveLogs.getString(\"content\").toLowerCase().contains(\"ready\"));\n        liveRegistry.close();\n\n        SessionProcessRegistry restoredRegistry = new SessionProcessRegistry(workspaceContext, options);\n        restoredRegistry.restoreSnapshots(liveRegistry.exportSnapshots());\n        BashToolExecutor restoredExecutor = new BashToolExecutor(workspaceContext, options, restoredRegistry);\n\n        JSONObject status = JSON.parseObject(restoredExecutor.execute(call(json(\"action\", \"status\", \"processId\", processId))));\n        assertTrue(status.getBooleanValue(\"restored\"));\n        assertFalse(status.getBooleanValue(\"controlAvailable\"));\n\n        JSONObject logs = JSON.parseObject(restoredExecutor.execute(call(json(\"action\", \"logs\", \"processId\", processId))));\n        assertEquals(processId, logs.getString(\"processId\"));\n        assertTrue(logs.getString(\"content\").toLowerCase().contains(\"ready\"));\n        assertTrue(logs.getLongValue(\"nextOffset\") > 0L);\n\n        try {\n            restoredExecutor.execute(call(json(\"action\", \"stop\", \"processId\", processId)));\n            fail(\"Expected metadata-only process stop to be rejected\");\n        } catch (IllegalStateException expected) {\n            assertTrue(expected.getMessage().contains(\"metadata only\"));\n        }\n    }\n\n    private AgentToolCall call(String arguments) {\n        return AgentToolCall.builder()\n                .name(CodingToolNames.BASH)\n                .arguments(arguments)\n                .build();\n    }\n\n    private String json(Object... pairs) {\n        JSONObject object = new JSONObject();\n        for (int i = 0; i < pairs.length; i += 2) {\n            object.put(String.valueOf(pairs[i]), pairs[i + 1]);\n        }\n        return JSON.toJSONString(object);\n    }\n\n    private JSONObject awaitLogs(BashToolExecutor executor, String processId, long timeoutMs) throws Exception {\n        long deadline = System.currentTimeMillis() + timeoutMs;\n        JSONObject last = null;\n        while (System.currentTimeMillis() < deadline) {\n            String logsRaw = executor.execute(call(json(\"action\", \"logs\", \"processId\", processId, \"limit\", 8000)));\n            last = JSON.parseObject(logsRaw);\n            String content = last.getString(\"content\");\n            if (content != null && !content.trim().isEmpty()) {\n                return last;\n            }\n            Thread.sleep(150L);\n        }\n        return last == null ? new JSONObject() : last;\n    }\n\n    private String backgroundCommand() {\n        if (isWindows()) {\n            return \"powershell -NoProfile -Command \\\"Write-Output ready; Start-Sleep -Seconds 10\\\"\";\n        }\n        return \"echo ready && sleep 10\";\n    }\n\n    private String stdinEchoCommand() {\n        if (isWindows()) {\n            return \"more\";\n        }\n        return \"cat\";\n    }\n\n    private boolean isWindows() {\n        return System.getProperty(\"os.name\", \"\").toLowerCase().contains(\"win\");\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingAgentBuilderTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.subagent.HandoffPolicy;\nimport io.github.lnyocly.ai4j.agent.subagent.SubAgentDefinition;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingSessionMode;\nimport io.github.lnyocly.ai4j.coding.definition.StaticCodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskManager;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskStatus;\nimport io.github.lnyocly.ai4j.coding.task.InMemoryCodingTaskManager;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayDeque;\nimport java.util.Arrays;\nimport java.util.Deque;\nimport java.util.LinkedHashSet;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class CodingAgentBuilderTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldRunBuiltInCodingToolWithinAgentLoop() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-agent\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding workspace\")\n                .build();\n\n        QueueModelClient modelClient = new QueueModelClient();\n        modelClient.enqueue(AgentModelResult.builder()\n                .toolCalls(Arrays.asList(AgentToolCall.builder()\n                        .name(CodingToolNames.BASH)\n                        .arguments(\"{\\\"action\\\":\\\"exec\\\",\\\"command\\\":\\\"echo session-ready\\\"}\")\n                        .callId(\"call-1\")\n                        .build()))\n                .rawResponse(\"tool-call\")\n                .build());\n        modelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"done\")\n                .rawResponse(\"final\")\n                .build());\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        CodingSession session = agent.newSession();\n        CodingAgentResult result;\n        try {\n            result = session.run(\"Run a quick shell check.\");\n        } finally {\n            session.close();\n        }\n\n        assertNotNull(session.getSessionId());\n        assertEquals(\"done\", result.getOutputText());\n        assertEquals(1, result.getToolResults().size());\n        assertTrue(result.getToolResults().get(0).getOutput().toLowerCase().contains(\"session-ready\"));\n    }\n\n    @Test\n    public void shouldApplyPatchWithinAgentLoop() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-agent-patch\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit patch workspace\")\n                .build();\n\n        QueueModelClient modelClient = new QueueModelClient();\n        modelClient.enqueue(AgentModelResult.builder()\n                .toolCalls(Arrays.asList(AgentToolCall.builder()\n                        .name(CodingToolNames.APPLY_PATCH)\n                        .arguments(\"{\\\"patch\\\":\\\"*** Begin Patch\\\\n*** Add File: notes/patch.txt\\\\n+created-by-agent\\\\n*** End Patch\\\"}\")\n                        .callId(\"call-2\")\n                        .build()))\n                .rawResponse(\"tool-call\")\n                .build());\n        modelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"patch-done\")\n                .rawResponse(\"final\")\n                .build());\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        CodingSession session = agent.newSession();\n        CodingAgentResult result;\n        try {\n            result = session.run(\"Create a file by patch.\");\n        } finally {\n            session.close();\n        }\n\n        assertEquals(\"patch-done\", result.getOutputText());\n        assertEquals(1, result.getToolResults().size());\n        assertTrue(Files.exists(workspaceRoot.resolve(\"notes/patch.txt\")));\n        assertEquals(\"created-by-agent\",\n                new String(Files.readAllBytes(workspaceRoot.resolve(\"notes/patch.txt\")), StandardCharsets.UTF_8));\n    }\n\n    @Test\n    public void shouldReturnToolErrorInsteadOfAbortingSession() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-agent-invalid-patch\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit invalid patch workspace\")\n                .build();\n\n        QueueModelClient modelClient = new QueueModelClient();\n        modelClient.enqueue(AgentModelResult.builder()\n                .toolCalls(Arrays.asList(AgentToolCall.builder()\n                        .name(CodingToolNames.APPLY_PATCH)\n                        .arguments(\"{\\\"patch\\\":\\\"*** Begin Patch\\\\n*** Unknown: calculator.py\\\\n*** End Patch\\\"}\")\n                        .callId(\"call-invalid-patch\")\n                        .build()))\n                .rawResponse(\"tool-call\")\n                .build());\n        modelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"tool-error-handled\")\n                .rawResponse(\"final\")\n                .build());\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        CodingSession session = agent.newSession();\n        CodingAgentResult result;\n        try {\n            result = session.run(\"Trigger an invalid patch.\");\n        } finally {\n            session.close();\n        }\n\n        assertEquals(\"tool-error-handled\", result.getOutputText());\n        assertEquals(1, result.getToolResults().size());\n        assertTrue(result.getToolResults().get(0).getOutput().contains(\"TOOL_ERROR:\"));\n        assertTrue(result.getToolResults().get(0).getOutput().contains(\"Unsupported patch line\"));\n        assertTrue(Files.notExists(workspaceRoot.resolve(\"calculator.py\")));\n    }\n\n    @Test\n    public void shouldAllowModelToInvokeDelegateTool() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-agent-delegate\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit delegate workspace\")\n                .build();\n\n        QueueModelClient modelClient = new QueueModelClient();\n        modelClient.enqueue(AgentModelResult.builder()\n                .toolCalls(Arrays.asList(AgentToolCall.builder()\n                        .name(\"delegate_plan\")\n                        .arguments(\"{\\\"task\\\":\\\"Draft a short implementation plan\\\",\\\"context\\\":\\\"Focus on tests first\\\"}\")\n                        .callId(\"delegate-call-1\")\n                        .build()))\n                .rawResponse(\"delegate-tool-call\")\n                .build());\n        modelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"delegate plan ready\")\n                .rawResponse(\"delegate-child\")\n                .build());\n        modelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"root-complete\")\n                .rawResponse(\"root-final\")\n                .build());\n\n        CodingTaskManager taskManager = new InMemoryCodingTaskManager();\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .codingOptions(CodingAgentOptions.builder()\n                        .autoContinueEnabled(false)\n                        .build())\n                .taskManager(taskManager)\n                .build();\n\n        CodingSession session = agent.newSession();\n        CodingAgentResult result;\n        try {\n            result = session.run(\"Delegate planning to a worker.\");\n        } finally {\n            session.close();\n        }\n\n        assertNotNull(result.getOutputText());\n        assertTrue(!result.getOutputText().trim().isEmpty());\n        assertEquals(1, result.getToolResults().size());\n        String toolOutput = result.getToolResults().get(0).getOutput();\n        assertTrue(toolOutput, toolOutput.contains(\"\\\"definitionName\\\":\\\"plan\\\"\"));\n        assertTrue(toolOutput, toolOutput.contains(\"\\\"status\\\":\\\"completed\\\"\"));\n        assertTrue(toolOutput, toolOutput.contains(\"\\\"output\\\":\\\"delegate plan ready\\\"\"));\n\n        assertEquals(1, taskManager.listTasksByParentSessionId(session.getSessionId()).size());\n        CodingTask task = taskManager.listTasksByParentSessionId(session.getSessionId()).get(0);\n        assertEquals(CodingTaskStatus.COMPLETED, task.getStatus());\n        assertEquals(\"delegate plan ready\", task.getOutputText());\n    }\n\n    @Test\n    public void shouldAllowModelToInvokeGenericSubAgentToolWithinCodingSession() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-agent-subagent\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit subagent workspace\")\n                .build();\n\n        QueueModelClient reviewModelClient = new QueueModelClient();\n        reviewModelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"review-ready\")\n                .rawResponse(\"review-final\")\n                .build());\n\n        QueueModelClient rootModelClient = new QueueModelClient();\n        rootModelClient.enqueue(AgentModelResult.builder()\n                .toolCalls(Arrays.asList(AgentToolCall.builder()\n                        .name(\"subagent_review\")\n                        .arguments(\"{\\\"task\\\":\\\"Review the proposed patch\\\",\\\"context\\\":\\\"Focus on correctness risks\\\"}\")\n                        .callId(\"subagent-call-1\")\n                        .build()))\n                .rawResponse(\"subagent-tool-call\")\n                .build());\n        rootModelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"root-finished\")\n                .rawResponse(\"root-final\")\n                .build());\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(rootModelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .subAgent(SubAgentDefinition.builder()\n                        .name(\"review\")\n                        .toolName(\"subagent_review\")\n                        .description(\"Review coding changes and report risks.\")\n                        .agent(Agents.react()\n                                .modelClient(reviewModelClient)\n                                .model(\"glm-4.5-flash\")\n                                .build())\n                        .build())\n                .handoffPolicy(HandoffPolicy.builder().maxDepth(1).build())\n                .build();\n\n        CodingAgentResult result;\n        try (CodingSession session = agent.newSession()) {\n            result = session.run(\"Ask the reviewer for a focused review.\");\n        }\n\n        assertEquals(\"root-finished\", result.getOutputText());\n        assertEquals(1, result.getToolResults().size());\n        String toolOutput = result.getToolResults().get(0).getOutput();\n        assertTrue(toolOutput, toolOutput.contains(\"\\\"subagent\\\":\\\"review\\\"\"));\n        assertTrue(toolOutput, toolOutput.contains(\"\\\"toolName\\\":\\\"subagent_review\\\"\"));\n        assertTrue(toolOutput, toolOutput.contains(\"\\\"output\\\":\\\"review-ready\\\"\"));\n    }\n\n    @Test\n    public void shouldAllowDelegatedCodingWorkerToInvokeConfiguredSubAgent() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-agent-delegate-subagent\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit delegate subagent workspace\")\n                .build();\n\n        QueueModelClient reviewModelClient = new QueueModelClient();\n        reviewModelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"review-ready\")\n                .rawResponse(\"review-final\")\n                .build());\n\n        QueueModelClient rootAndDelegateModelClient = new QueueModelClient();\n        rootAndDelegateModelClient.enqueue(AgentModelResult.builder()\n                .toolCalls(Arrays.asList(AgentToolCall.builder()\n                        .name(\"delegate_review_worker\")\n                        .arguments(\"{\\\"task\\\":\\\"Delegate a review step\\\",\\\"context\\\":\\\"Use the review specialist\\\"}\")\n                        .callId(\"delegate-review-call-1\")\n                        .build()))\n                .rawResponse(\"delegate-worker-call\")\n                .build());\n        rootAndDelegateModelClient.enqueue(AgentModelResult.builder()\n                .toolCalls(Arrays.asList(AgentToolCall.builder()\n                        .name(\"subagent_review\")\n                        .arguments(\"{\\\"task\\\":\\\"Review the delegated change\\\"}\")\n                        .callId(\"delegate-subagent-call-1\")\n                        .build()))\n                .rawResponse(\"delegate-subagent-call\")\n                .build());\n        rootAndDelegateModelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"delegate-worker-finished\")\n                .rawResponse(\"delegate-worker-final\")\n                .build());\n        rootAndDelegateModelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"root-finished\")\n                .rawResponse(\"root-final\")\n                .build());\n\n        CodingTaskManager taskManager = new InMemoryCodingTaskManager();\n        StaticCodingAgentDefinitionRegistry definitionRegistry = new StaticCodingAgentDefinitionRegistry(\n                Arrays.asList(CodingAgentDefinition.builder()\n                        .name(\"review-worker\")\n                        .toolName(\"delegate_review_worker\")\n                        .description(\"Delegated worker that can call the review subagent.\")\n                        .allowedToolNames(new LinkedHashSet<String>(Arrays.asList(\"subagent_review\")))\n                        .sessionMode(CodingSessionMode.FORK)\n                        .build())\n        );\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(rootAndDelegateModelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .definitionRegistry(definitionRegistry)\n                .taskManager(taskManager)\n                .subAgent(SubAgentDefinition.builder()\n                        .name(\"review\")\n                        .toolName(\"subagent_review\")\n                        .description(\"Review coding changes and report risks.\")\n                        .agent(Agents.react()\n                                .modelClient(reviewModelClient)\n                                .model(\"glm-4.5-flash\")\n                                .build())\n                        .build())\n                .handoffPolicy(HandoffPolicy.builder().maxDepth(2).build())\n                .build();\n\n        CodingAgentResult result;\n        try (CodingSession session = agent.newSession()) {\n            result = session.run(\"Delegate review work to a specialized worker.\");\n\n            assertEquals(\"root-finished\", result.getOutputText());\n            assertEquals(1, result.getToolResults().size());\n            String toolOutput = result.getToolResults().get(0).getOutput();\n            assertTrue(toolOutput, toolOutput.contains(\"\\\"definitionName\\\":\\\"review-worker\\\"\"));\n            assertTrue(toolOutput, toolOutput.contains(\"\\\"status\\\":\\\"completed\\\"\"));\n            assertTrue(toolOutput, toolOutput.contains(\"\\\"output\\\":\\\"delegate-worker-finished\\\"\"));\n\n            assertEquals(1, taskManager.listTasksByParentSessionId(session.getSessionId()).size());\n            CodingTask task = taskManager.listTasksByParentSessionId(session.getSessionId()).get(0);\n            assertEquals(CodingTaskStatus.COMPLETED, task.getStatus());\n            assertEquals(\"delegate-worker-finished\", task.getOutputText());\n        }\n    }\n\n    private static class QueueModelClient implements AgentModelClient {\n\n        private final Deque<AgentModelResult> results = new ArrayDeque<>();\n\n        private void enqueue(AgentModelResult result) {\n            results.addLast(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            return results.removeFirst();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = results.removeFirst();\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n            }\n            return result;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingRuntimeTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.definition.BuiltInCodingAgentDefinitions;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinition;\nimport io.github.lnyocly.ai4j.coding.definition.CodingAgentDefinitionRegistry;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateRequest;\nimport io.github.lnyocly.ai4j.coding.delegate.CodingDelegateResult;\nimport io.github.lnyocly.ai4j.coding.policy.CodingToolContextPolicy;\nimport io.github.lnyocly.ai4j.coding.policy.CodingToolPolicyResolver;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLink;\nimport io.github.lnyocly.ai4j.coding.session.CodingSessionLinkStore;\nimport io.github.lnyocly.ai4j.coding.session.InMemoryCodingSessionLinkStore;\nimport io.github.lnyocly.ai4j.coding.task.CodingTask;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskManager;\nimport io.github.lnyocly.ai4j.coding.task.CodingTaskStatus;\nimport io.github.lnyocly.ai4j.coding.task.InMemoryCodingTaskManager;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolRegistryFactory;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.LinkedBlockingQueue;\nimport java.util.concurrent.TimeUnit;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\nimport static org.junit.Assert.fail;\n\npublic class CodingRuntimeTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldResolveBuiltInDefinitionsByNameAndToolName() {\n        CodingAgentDefinitionRegistry registry = BuiltInCodingAgentDefinitions.registry();\n\n        assertEquals(4, registry.listDefinitions().size());\n        assertNotNull(registry.getDefinition(BuiltInCodingAgentDefinitions.GENERAL_PURPOSE));\n        assertNotNull(registry.getDefinition(\"delegate_explore\"));\n        assertTrue(registry.getDefinition(\"explore\").getAllowedToolNames().contains(CodingToolNames.READ_FILE));\n        assertFalse(registry.getDefinition(\"explore\").getAllowedToolNames().contains(CodingToolNames.WRITE_FILE));\n    }\n\n    @Test\n    public void shouldExposeDelegateToolsInBuiltInRegistry() {\n        AgentToolRegistry registry = CodingAgentBuilder.createBuiltInRegistry(\n                CodingAgentOptions.builder().build(),\n                BuiltInCodingAgentDefinitions.registry()\n        );\n\n        List<String> toolNames = collectToolNames(registry);\n        assertTrue(toolNames.contains(\"delegate_general_purpose\"));\n        assertTrue(toolNames.contains(\"delegate_explore\"));\n        assertTrue(toolNames.contains(\"delegate_plan\"));\n        assertTrue(toolNames.contains(\"delegate_verification\"));\n    }\n\n    @Test\n    public void shouldFilterToolsForReadOnlyDefinitions() throws Exception {\n        AgentToolRegistry registry = CodingToolRegistryFactory.createBuiltInRegistry();\n        ToolExecutor executor = new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) {\n                return call == null ? null : call.getName();\n            }\n        };\n        CodingAgentDefinition definition = BuiltInCodingAgentDefinitions.registry().getDefinition(\"explore\");\n\n        CodingToolContextPolicy policy = new CodingToolPolicyResolver().resolve(registry, executor, definition);\n\n        assertEquals(2, policy.getToolRegistry().getTools().size());\n        assertEquals(CodingToolNames.READ_FILE, policy.getToolExecutor().execute(AgentToolCall.builder().name(CodingToolNames.READ_FILE).build()));\n        try {\n            policy.getToolExecutor().execute(AgentToolCall.builder().name(CodingToolNames.WRITE_FILE).build());\n            fail(\"Expected disallowed tool execution to fail\");\n        } catch (IllegalArgumentException expected) {\n            assertTrue(expected.getMessage().contains(\"not allowed\"));\n        }\n    }\n\n    @Test\n    public void shouldDelegateSynchronouslyAndTrackTaskAndLinks() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"runtime-sync\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit runtime sync workspace\")\n                .build();\n        QueueModelClient modelClient = new QueueModelClient();\n        modelClient.enqueue(AgentModelResult.builder().outputText(\"sync-child-done\").build());\n        CodingTaskManager taskManager = new InMemoryCodingTaskManager();\n        CodingSessionLinkStore linkStore = new InMemoryCodingSessionLinkStore();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .taskManager(taskManager)\n                .sessionLinkStore(linkStore)\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            CodingDelegateResult result = session.delegate(CodingDelegateRequest.builder()\n                    .definitionName(\"explore\")\n                    .input(\"Inspect the workspace and summarize what matters.\")\n                    .build());\n\n            assertEquals(CodingTaskStatus.COMPLETED, result.getStatus());\n            assertEquals(\"sync-child-done\", result.getOutputText());\n            CodingTask task = taskManager.getTask(result.getTaskId());\n            assertNotNull(task);\n            assertEquals(CodingTaskStatus.COMPLETED, task.getStatus());\n            assertEquals(session.getSessionId(), task.getParentSessionId());\n            List<CodingSessionLink> links = linkStore.listLinksByParentSessionId(session.getSessionId());\n            assertEquals(1, links.size());\n            assertEquals(result.getTaskId(), links.get(0).getTaskId());\n        }\n    }\n\n    @Test\n    public void shouldFallbackToAssistantMemoryWhenDelegateOutputTextIsBlank() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"runtime-sync-memory-fallback\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit runtime sync workspace fallback\")\n                .build();\n        QueueModelClient modelClient = new QueueModelClient();\n        modelClient.enqueue(AgentModelResult.builder()\n                .outputText(\"\")\n                .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", \"delegate plan ready from memory\")))\n                .build());\n        CodingTaskManager taskManager = new InMemoryCodingTaskManager();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .codingOptions(CodingAgentOptions.builder()\n                        .autoContinueEnabled(false)\n                        .build())\n                .taskManager(taskManager)\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            CodingDelegateResult result = session.delegate(CodingDelegateRequest.builder()\n                    .definitionName(\"plan\")\n                    .input(\"Draft a short implementation plan.\")\n                    .build());\n\n            assertEquals(CodingTaskStatus.COMPLETED, result.getStatus());\n            assertEquals(\"delegate plan ready from memory\", result.getOutputText());\n            CodingTask task = taskManager.getTask(result.getTaskId());\n            assertNotNull(task);\n            assertEquals(\"delegate plan ready from memory\", task.getOutputText());\n        }\n    }\n\n    @Test\n    public void shouldDelegateInBackgroundAndEventuallyComplete() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"runtime-background\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit runtime background workspace\")\n                .build();\n        BlockingModelClient modelClient = new BlockingModelClient(\"background-child-done\");\n        CodingTaskManager taskManager = new InMemoryCodingTaskManager();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .taskManager(taskManager)\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            CodingDelegateResult result = session.delegate(CodingDelegateRequest.builder()\n                    .definitionName(\"verification\")\n                    .input(\"Run verification in the background.\")\n                    .build());\n\n            assertTrue(result.isBackground());\n            assertTrue(modelClient.awaitStarted(3, TimeUnit.SECONDS));\n\n            CodingTask runningTask = taskManager.getTask(result.getTaskId());\n            assertNotNull(runningTask);\n            assertTrue(Arrays.asList(CodingTaskStatus.QUEUED, CodingTaskStatus.STARTING, CodingTaskStatus.RUNNING, CodingTaskStatus.COMPLETED)\n                    .contains(runningTask.getStatus()));\n\n            modelClient.release();\n            CodingTask completed = waitForTask(taskManager, result.getTaskId(), 5000L);\n            assertNotNull(completed);\n            assertEquals(CodingTaskStatus.COMPLETED, completed.getStatus());\n            assertEquals(\"background-child-done\", completed.getOutputText());\n        }\n    }\n\n    private CodingTask waitForTask(CodingTaskManager taskManager, String taskId, long timeoutMs) throws Exception {\n        long deadline = System.currentTimeMillis() + timeoutMs;\n        while (System.currentTimeMillis() < deadline) {\n            CodingTask task = taskManager.getTask(taskId);\n            if (task != null && task.getStatus() != null && task.getStatus().isTerminal()) {\n                return task;\n            }\n            Thread.sleep(50L);\n        }\n        return taskManager.getTask(taskId);\n    }\n\n    private List<String> collectToolNames(AgentToolRegistry registry) {\n        List<String> toolNames = new java.util.ArrayList<String>();\n        if (registry == null || registry.getTools() == null) {\n            return toolNames;\n        }\n        for (Object tool : registry.getTools()) {\n            if (tool instanceof io.github.lnyocly.ai4j.platform.openai.tool.Tool) {\n                io.github.lnyocly.ai4j.platform.openai.tool.Tool.Function function =\n                        ((io.github.lnyocly.ai4j.platform.openai.tool.Tool) tool).getFunction();\n                if (function != null && function.getName() != null) {\n                    toolNames.add(function.getName());\n                }\n            }\n        }\n        return toolNames;\n    }\n\n    private static class QueueModelClient implements AgentModelClient {\n\n        private final LinkedBlockingQueue<AgentModelResult> results = new LinkedBlockingQueue<AgentModelResult>();\n\n        private void enqueue(AgentModelResult result) {\n            results.offer(result);\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) throws Exception {\n            return results.poll(3, TimeUnit.SECONDS);\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n    }\n\n    private static class BlockingModelClient implements AgentModelClient {\n\n        private final String outputText;\n        private final CountDownLatch started = new CountDownLatch(1);\n        private final CountDownLatch release = new CountDownLatch(1);\n\n        private BlockingModelClient(String outputText) {\n            this.outputText = outputText;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) throws Exception {\n            started.countDown();\n            release.await(3, TimeUnit.SECONDS);\n            return AgentModelResult.builder()\n                    .outputText(outputText)\n                    .memoryItems(Collections.<Object>emptyList())\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception {\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private boolean awaitStarted(long timeout, TimeUnit unit) throws InterruptedException {\n            return started.await(timeout, unit);\n        }\n\n        private void release() {\n            release.countDown();\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingSessionCheckpointFormatterTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport org.junit.Test;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertTrue;\n\npublic class CodingSessionCheckpointFormatterTest {\n\n    @Test\n    public void shouldIgnoreLeadingProseBeforeMarkdownSections() {\n        String summary = \"Please let me know how you would like to proceed!\\n\"\n                + \"\\n\"\n                + \"## Goal\\n\"\n                + \"Fix auto compact.\\n\"\n                + \"## Constraints & Preferences\\n\"\n                + \"- Preserve session state.\\n\"\n                + \"## Progress\\n\"\n                + \"### Done\\n\"\n                + \"- [x] Reproduced the issue.\\n\"\n                + \"### In Progress\\n\"\n                + \"- [ ] Patch the parser.\\n\"\n                + \"### Blocked\\n\"\n                + \"- (none)\\n\";\n\n        CodingSessionCheckpoint checkpoint = CodingSessionCheckpointFormatter.parse(summary);\n\n        assertEquals(\"Fix auto compact.\", checkpoint.getGoal());\n        assertTrue(checkpoint.getConstraints().contains(\"Preserve session state.\"));\n        assertTrue(checkpoint.getDoneItems().contains(\"Reproduced the issue.\"));\n        assertTrue(checkpoint.getInProgressItems().contains(\"Patch the parser.\"));\n    }\n\n    @Test\n    public void shouldParseStructuredJsonCheckpoint() {\n        String summary = \"{\\n\"\n                + \"  \\\"goal\\\": \\\"Resume the coding task.\\\",\\n\"\n                + \"  \\\"constraints\\\": [\\\"Keep exact file paths.\\\"],\\n\"\n                + \"  \\\"progress\\\": {\\n\"\n                + \"    \\\"done\\\": [\\\"Updated CodingSession.java.\\\"],\\n\"\n                + \"    \\\"inProgress\\\": [\\\"Finish CodingSessionCompactor.java.\\\"],\\n\"\n                + \"    \\\"blocked\\\": [\\\"Need compile validation.\\\"]\\n\"\n                + \"  },\\n\"\n                + \"  \\\"keyDecisions\\\": [\\\"Use CodingSessionCheckpoint as the canonical state.\\\"],\\n\"\n                + \"  \\\"nextSteps\\\": [\\\"Run mvn -pl ai4j-coding -am -DskipTests compile.\\\"],\\n\"\n                + \"  \\\"criticalContext\\\": [\\\"Markdown parsing remains compatibility-only.\\\"]\\n\"\n                + \"}\";\n\n        CodingSessionCheckpoint checkpoint = CodingSessionCheckpointFormatter.parse(summary);\n\n        assertEquals(\"Resume the coding task.\", checkpoint.getGoal());\n        assertTrue(checkpoint.getConstraints().contains(\"Keep exact file paths.\"));\n        assertTrue(checkpoint.getDoneItems().contains(\"Updated CodingSession.java.\"));\n        assertTrue(checkpoint.getInProgressItems().contains(\"Finish CodingSessionCompactor.java.\"));\n        assertTrue(checkpoint.getBlockedItems().contains(\"Need compile validation.\"));\n        assertTrue(checkpoint.getKeyDecisions().contains(\"Use CodingSessionCheckpoint as the canonical state.\"));\n        assertTrue(checkpoint.getNextSteps().contains(\"Run mvn -pl ai4j-coding -am -DskipTests compile.\"));\n        assertTrue(checkpoint.getCriticalContext().contains(\"Markdown parsing remains compatibility-only.\"));\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingSessionTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.memory.MemorySnapshot;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessLogChunk;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessStatus;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.util.ArrayDeque;\nimport java.util.Collections;\nimport java.util.Deque;\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class CodingSessionTest {\n\n    private static final String STUB_TOOL = \"stub_tool\";\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldSnapshotAndCompactSessionMemory() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session workspace\")\n                .build();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(new CompactionAwareModelClient())\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        CodingSession session = agent.newSession();\n        try {\n            session.run(\"Inspect the repository layout.\");\n            session.run(\"Now summarize the current progress.\");\n\n            CodingSessionSnapshot before = session.snapshot();\n            CodingSessionCompactResult compactResult = session.compact();\n            CodingSessionSnapshot after = session.snapshot();\n\n            assertTrue(before.getMemoryItemCount() >= 2);\n            assertNotNull(compactResult.getSummary());\n            assertNotNull(compactResult.getCheckpoint());\n            assertTrue(compactResult.getSummary().contains(\"## Goal\"));\n            assertEquals(before.getMemoryItemCount(), compactResult.getBeforeItemCount());\n            assertEquals(after.getMemoryItemCount(), compactResult.getAfterItemCount());\n            assertTrue(after.getMemoryItemCount() <= before.getMemoryItemCount());\n            assertTrue(compactResult.getEstimatedTokensBefore() > 0);\n            assertEquals(workspaceRoot.toString(), after.getWorkspaceRoot());\n            assertEquals(\"Continue the coding task.\", after.getCheckpointGoal());\n            assertEquals(\"manual\", after.getLastCompactMode());\n            assertTrue(compactResult.getStrategy().contains(\"checkpoint\"));\n            assertEquals(compactResult.getEstimatedTokensBefore(), after.getLastCompactTokensBefore());\n            assertEquals(compactResult.getEstimatedTokensAfter(), after.getLastCompactTokensAfter());\n        } finally {\n            session.close();\n        }\n    }\n\n    @Test\n    public void shouldExportAndRestoreSessionState() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-restore\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session restore workspace\")\n                .build();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(new CompactionAwareModelClient())\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        CodingSession first = agent.newSession(\"resume-session\", null);\n        CodingSessionState state;\n        try {\n            first.run(\"Inspect the repository layout.\");\n            first.run(\"Now summarize the current progress.\");\n            state = first.exportState();\n        } finally {\n            first.close();\n        }\n\n        CodingSession resumed = agent.newSession(state);\n        try {\n            CodingSessionSnapshot snapshot = resumed.snapshot();\n            assertEquals(\"resume-session\", resumed.getSessionId());\n            assertEquals(workspaceRoot.toString(), snapshot.getWorkspaceRoot());\n            assertTrue(snapshot.getMemoryItemCount() >= 2);\n            resumed.run(\"Continue from previous context.\");\n            assertTrue(resumed.snapshot().getMemoryItemCount() > snapshot.getMemoryItemCount());\n        } finally {\n            resumed.close();\n        }\n    }\n\n    @Test\n    public void shouldRestoreProcessMetadataAsReadOnlySnapshots() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-process-restore\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session process restore workspace\")\n                .build();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(new CompactionAwareModelClient())\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        CodingSessionState seededState = CodingSessionState.builder()\n                .sessionId(\"process-seeded\")\n                .workspaceRoot(workspaceRoot.toString())\n                .memorySnapshot(firstMemorySnapshot())\n                .processCount(1)\n                .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder()\n                        .processId(\"proc_demo\")\n                        .command(\"echo ready && sleep 10\")\n                        .workingDirectory(workspaceRoot.toString())\n                        .status(BashProcessStatus.STOPPED)\n                        .startedAt(System.currentTimeMillis())\n                        .endedAt(System.currentTimeMillis())\n                        .lastLogOffset(42L)\n                        .lastLogPreview(\"[stdout] ready\")\n                        .restored(false)\n                        .controlAvailable(true)\n                        .build()))\n                .build();\n\n        try (CodingSession resumed = agent.newSession(seededState)) {\n            CodingSessionSnapshot snapshot = resumed.snapshot();\n            assertEquals(1, snapshot.getProcessCount());\n            assertEquals(0, snapshot.getActiveProcessCount());\n            assertEquals(1, snapshot.getRestoredProcessCount());\n            assertTrue(snapshot.getProcesses().get(0).isRestored());\n            assertFalse(snapshot.getProcesses().get(0).isControlAvailable());\n        }\n    }\n\n    @Test\n    public void shouldReadPreviewLogsFromRestoredProcessSnapshots() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-process-logs\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session restored process logs workspace\")\n                .build();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(new CompactionAwareModelClient())\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        CodingSessionState seededState = CodingSessionState.builder()\n                .sessionId(\"process-log-seeded\")\n                .workspaceRoot(workspaceRoot.toString())\n                .memorySnapshot(firstMemorySnapshot())\n                .processCount(1)\n                .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder()\n                        .processId(\"proc_demo\")\n                        .command(\"npm run dev\")\n                        .workingDirectory(workspaceRoot.toString())\n                        .status(BashProcessStatus.STOPPED)\n                        .startedAt(System.currentTimeMillis())\n                        .endedAt(System.currentTimeMillis())\n                        .lastLogOffset(30L)\n                        .lastLogPreview(\"[stdout] server ready\\n\")\n                        .restored(false)\n                        .controlAvailable(true)\n                        .build()))\n                .build();\n\n        try (CodingSession resumed = agent.newSession(seededState)) {\n            BashProcessLogChunk logs = resumed.processLogs(\"proc_demo\", null, 200);\n            assertNotNull(logs);\n            assertEquals(\"proc_demo\", logs.getProcessId());\n            assertTrue(logs.getContent().contains(\"server ready\"));\n            assertEquals(BashProcessStatus.STOPPED, logs.getStatus());\n        }\n    }\n\n    @Test\n    public void shouldAutoCompactWhenTokenBudgetExceeded() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-auto\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session auto compact workspace\")\n                .build();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(new CompactionAwareModelClient())\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .codingOptions(CodingAgentOptions.builder()\n                        .autoCompactEnabled(true)\n                        .compactContextWindowTokens(120)\n                        .compactReserveTokens(20)\n                        .compactKeepRecentTokens(40)\n                        .compactSummaryMaxOutputTokens(120)\n                        .build())\n                .build();\n\n        CodingSession session = agent.newSession();\n        try {\n            session.run(\"Please write a very long analysis about the workspace state and repeat details many times.\");\n            CodingSessionCompactResult autoResult = session.drainLastAutoCompactResult();\n            CodingSessionSnapshot snapshot = session.snapshot();\n\n            assertNotNull(autoResult);\n            assertTrue(autoResult.isAutomatic());\n            assertTrue(autoResult.getSummary().contains(\"## Goal\"));\n            assertTrue(snapshot.getEstimatedContextTokens() <= autoResult.getEstimatedTokensBefore());\n            assertNull(session.drainLastAutoCompactError());\n            assertEquals(\"auto\", snapshot.getLastCompactMode());\n            assertTrue(autoResult.getStrategy().contains(\"checkpoint\"));\n            assertEquals(\"Continue the coding task.\", snapshot.getCheckpointGoal());\n        } finally {\n            session.close();\n        }\n    }\n\n    @Test\n    public void shouldMicroCompactOversizedToolResultsBeforeFullCheckpointCompaction() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-micro\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session micro compact workspace\")\n                .build();\n\n        QueueModelClient modelClient = new QueueModelClient();\n        modelClient.enqueue(toolCallResult(STUB_TOOL, \"call-1\"));\n        modelClient.enqueue(assistantResult(\"Tool work finished for this step.\"));\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .toolRegistry(singleToolRegistry(STUB_TOOL))\n                .toolExecutor(largeToolExecutor())\n                .codingOptions(CodingAgentOptions.builder()\n                        .autoCompactEnabled(true)\n                        .compactContextWindowTokens(700)\n                        .compactReserveTokens(120)\n                        .compactKeepRecentTokens(200)\n                        .toolResultMicroCompactEnabled(true)\n                        .toolResultMicroCompactKeepRecent(0)\n                        .toolResultMicroCompactMaxTokens(120)\n                        .build())\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"Run the stub tool and continue.\");\n\n            CodingSessionCompactResult autoResult = session.drainLastAutoCompactResult();\n            MemorySnapshot memorySnapshot = session.exportState().getMemorySnapshot();\n\n            assertNotNull(autoResult);\n            assertEquals(\"tool-result-micro\", autoResult.getStrategy());\n            assertTrue(autoResult.getCompactedToolResultCount() > 0);\n            assertTrue(autoResult.getEstimatedTokensAfter() < autoResult.getEstimatedTokensBefore());\n            assertTrue(String.valueOf(memorySnapshot.getItems()).contains(\"tool result compacted to save context\"));\n            assertEquals(0, modelClient.getSummaryPromptCount());\n        }\n    }\n\n    @Test\n    public void shouldOpenAutoCompactCircuitBreakerAfterRepeatedFailures() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-breaker\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session auto compact breaker workspace\")\n                .build();\n\n        FailingCompactionModelClient modelClient = new FailingCompactionModelClient();\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .codingOptions(CodingAgentOptions.builder()\n                        .autoCompactEnabled(true)\n                        .compactContextWindowTokens(160)\n                        .compactReserveTokens(40)\n                        .compactKeepRecentTokens(40)\n                        .compactSummaryMaxOutputTokens(80)\n                        .autoCompactMaxConsecutiveFailures(3)\n                        .build())\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"First prompt.\");\n            assertNotNull(session.drainLastAutoCompactError());\n            session.run(\"Second prompt.\");\n            assertNotNull(session.drainLastAutoCompactError());\n            session.run(\"Third prompt.\");\n            Exception thirdError = session.drainLastAutoCompactError();\n\n            CodingSessionSnapshot afterFailures = session.snapshot();\n            assertNotNull(thirdError);\n            assertTrue(thirdError.getMessage().contains(\"circuit breaker opened\"));\n            assertEquals(3, afterFailures.getAutoCompactFailureCount());\n            assertTrue(afterFailures.isAutoCompactCircuitBreakerOpen());\n            assertEquals(3, modelClient.getSummaryPromptCount());\n\n            session.run(\"Fourth prompt.\");\n            Exception pausedError = session.drainLastAutoCompactError();\n\n            assertNotNull(pausedError);\n            assertTrue(pausedError.getMessage().contains(\"paused after 3 consecutive failures\"));\n            assertEquals(3, modelClient.getSummaryPromptCount());\n        }\n    }\n\n    @Test\n    public void shouldRetryCompactSummaryAfterPromptTooLong() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-ptl-retry\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session prompt-too-long retry workspace\")\n                .build();\n\n        PromptTooLongOnceCompactionModelClient modelClient = new PromptTooLongOnceCompactionModelClient(3200);\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"Turn one.\");\n            session.run(\"Turn two.\");\n            session.run(\"Turn three.\");\n\n            CodingSessionCompactResult compactResult = session.compact();\n\n            assertNotNull(compactResult);\n            assertEquals(2, modelClient.getSummaryPromptCount());\n            assertEquals(1, modelClient.getPromptTooLongFailures());\n            assertTrue(compactResult.getSummary().contains(\"Compaction retry\"));\n            assertFalse(compactResult.getSummary().contains(\"Compaction fallback\"));\n        }\n    }\n\n    @Test\n    public void shouldFallbackWhenPromptTooLongRetriesAreExhausted() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-ptl-fallback\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session prompt-too-long fallback workspace\")\n                .build();\n\n        AlwaysPromptTooLongCompactionModelClient modelClient = new AlwaysPromptTooLongCompactionModelClient();\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"Turn one.\");\n            session.run(\"Turn two.\");\n            session.run(\"Turn three.\");\n\n            CodingSessionCompactResult compactResult = session.compact();\n\n            assertNotNull(compactResult);\n            assertEquals(4, modelClient.getSummaryPromptCount());\n            assertTrue(compactResult.isFallbackSummary());\n            assertTrue(compactResult.getSummary().contains(\"Compaction fallback\"));\n            assertTrue(compactResult.getSummary().contains(\"Compaction retry\"));\n        }\n    }\n\n    @Test\n    public void shouldUseSessionMemoryFallbackWhenCheckpointAlreadyExists() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-memory-fallback\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session memory fallback workspace\")\n                .build();\n\n        FirstSummaryThenFailingCompactionModelClient modelClient = new FirstSummaryThenFailingCompactionModelClient();\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"Create the initial checkpoint.\");\n            CodingSessionCompactResult first = session.compact();\n\n            session.run(\"Follow-up change after the checkpoint already exists.\");\n            CodingSessionCompactResult second = session.compact();\n\n            assertNotNull(first);\n            assertNotNull(second);\n            assertFalse(first.isFallbackSummary());\n            assertTrue(second.isFallbackSummary());\n            assertEquals(\"checkpoint-delta\", second.getStrategy());\n            assertTrue(second.isCheckpointReused());\n            assertTrue(second.getSummary().contains(\"Session-memory fallback\"));\n            assertTrue(second.getSummary().contains(\"Previous compacted context retained.\"));\n            assertTrue(second.getSummary().contains(\"Latest user delta: Follow-up change after the checkpoint already exists.\"));\n        }\n    }\n\n    @Test\n    public void shouldReuseExistingCheckpointForAggressiveFallback() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-aggressive-memory-fallback\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session aggressive memory fallback workspace\")\n                .build();\n\n        FirstSummaryThenAggressiveFailingCompactionModelClient modelClient = new FirstSummaryThenAggressiveFailingCompactionModelClient();\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .codingOptions(CodingAgentOptions.builder()\n                        .autoCompactEnabled(false)\n                        .compactContextWindowTokens(260)\n                        .compactReserveTokens(80)\n                        .compactKeepRecentTokens(800)\n                        .compactSummaryMaxOutputTokens(120)\n                        .build())\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"Create the initial checkpoint.\");\n            CodingSessionCompactResult first = session.compact();\n\n            session.run(\"Make a very large follow-up change that should trigger aggressive compaction.\");\n            CodingSessionCompactResult second = session.compact();\n\n            assertNotNull(first);\n            assertNotNull(second);\n            assertTrue(second.isFallbackSummary());\n            assertEquals(\"aggressive-checkpoint-delta\", second.getStrategy());\n            assertTrue(second.isCheckpointReused());\n            assertTrue(second.getSummary().contains(\"Session-memory fallback\"));\n            assertTrue(second.getSummary().contains(\"Created the initial checkpoint.\"));\n            assertTrue(second.getSummary().contains(\"Latest user delta: Make a very large follow-up change that should trigger aggressive compaction.\"));\n            assertEquals(3, modelClient.getSummaryPromptCount());\n        }\n    }\n\n    @Test\n    public void shouldExposeCheckpointDeltaStrategyWhenReusingPreviousCheckpoint() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-delta\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session delta compact workspace\")\n                .build();\n\n        CompactionAwareModelClient modelClient = new CompactionAwareModelClient();\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"Initial workspace analysis.\");\n            CodingSessionCompactResult first = session.compact();\n\n            session.run(\"Apply another change after the first compact.\");\n            CodingSessionCompactResult second = session.compact();\n\n            assertNotNull(first);\n            assertNotNull(second);\n            assertEquals(\"checkpoint\", first.getStrategy());\n            assertEquals(\"checkpoint-delta\", second.getStrategy());\n            assertFalse(first.isCheckpointReused());\n            assertTrue(second.isCheckpointReused());\n            assertTrue(second.getDeltaItemCount() > 0);\n        }\n    }\n\n    @Test\n    public void shouldPreserveLatestCompactMetadataAcrossExportAndRestore() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-compact-restore\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session compact restore workspace\")\n                .build();\n\n        CompactionAwareModelClient modelClient = new CompactionAwareModelClient();\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        CodingSessionState exportedState;\n        CodingSessionCompactResult compactResult;\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"Inspect the repository layout.\");\n            session.run(\"Summarize the current progress.\");\n            compactResult = session.compact();\n            exportedState = session.exportState();\n        }\n\n        assertNotNull(exportedState);\n        assertNotNull(exportedState.getLatestCompactResult());\n        assertEquals(compactResult.getStrategy(), exportedState.getLatestCompactResult().getStrategy());\n\n        try (CodingSession resumed = agent.newSession(exportedState)) {\n            CodingSessionSnapshot snapshot = resumed.snapshot();\n\n            assertEquals(\"manual\", snapshot.getLastCompactMode());\n            assertEquals(compactResult.getStrategy(), snapshot.getLastCompactStrategy());\n            assertEquals(compactResult.getEstimatedTokensBefore(), snapshot.getLastCompactTokensBefore());\n            assertEquals(compactResult.getEstimatedTokensAfter(), snapshot.getLastCompactTokensAfter());\n            assertTrue(snapshot.getLastCompactSummary().contains(\"## Goal\"));\n        }\n    }\n\n    @Test\n    public void shouldPreserveAutoCompactBreakerAcrossExportAndRestore() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-breaker-restore\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session breaker restore workspace\")\n                .build();\n\n        FailingCompactionModelClient modelClient = new FailingCompactionModelClient();\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .codingOptions(CodingAgentOptions.builder()\n                        .autoCompactEnabled(true)\n                        .compactContextWindowTokens(160)\n                        .compactReserveTokens(40)\n                        .compactKeepRecentTokens(40)\n                        .compactSummaryMaxOutputTokens(80)\n                        .autoCompactMaxConsecutiveFailures(3)\n                        .build())\n                .build();\n\n        CodingSessionState exportedState;\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"First prompt.\");\n            assertNotNull(session.drainLastAutoCompactError());\n            session.run(\"Second prompt.\");\n            assertNotNull(session.drainLastAutoCompactError());\n            session.run(\"Third prompt.\");\n            assertNotNull(session.drainLastAutoCompactError());\n\n            exportedState = session.exportState();\n            assertEquals(3, exportedState.getAutoCompactFailureCount());\n            assertTrue(exportedState.isAutoCompactCircuitBreakerOpen());\n        }\n\n        try (CodingSession resumed = agent.newSession(exportedState)) {\n            CodingSessionSnapshot snapshot = resumed.snapshot();\n\n            assertEquals(3, snapshot.getAutoCompactFailureCount());\n            assertTrue(snapshot.isAutoCompactCircuitBreakerOpen());\n\n            resumed.run(\"Fourth prompt.\");\n            Exception pausedError = resumed.drainLastAutoCompactError();\n\n            assertNotNull(pausedError);\n            assertTrue(pausedError.getMessage().contains(\"paused after 3 consecutive failures\"));\n            assertEquals(3, modelClient.getSummaryPromptCount());\n        }\n    }\n\n    @Test\n    public void shouldClearPendingLoopArtifactsAfterManualCompact() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-session-manual-compact-cleanup\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit coding session manual compact cleanup workspace\")\n                .build();\n\n        CodingAgent agent = CodingAgents.builder()\n                .modelClient(new CompactionAwareModelClient())\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .build();\n\n        try (CodingSession session = agent.newSession()) {\n            session.run(\"Inspect the repository layout.\");\n            session.recordLoopDecision(io.github.lnyocly.ai4j.coding.loop.CodingLoopDecision.builder()\n                    .turnNumber(1)\n                    .continueLoop(false)\n                    .summary(\"stale loop state\")\n                    .build());\n\n            session.compact();\n\n            assertTrue(session.drainLoopDecisions().isEmpty());\n            assertTrue(session.drainAutoCompactResults().isEmpty());\n            assertTrue(session.drainAutoCompactErrors().isEmpty());\n        }\n    }\n\n    private static final class CompactionAwareModelClient implements AgentModelClient {\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            if (prompt != null && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                return AgentModelResult.builder()\n                        .outputText(\"{\\n\"\n                                + \"  \\\"goal\\\": \\\"Continue the coding task.\\\",\\n\"\n                                + \"  \\\"constraints\\\": [\\\"Preserve exact file paths.\\\"],\\n\"\n                                + \"  \\\"progress\\\": {\\n\"\n                                + \"    \\\"done\\\": [\\\"Compacted old messages.\\\"],\\n\"\n                                + \"    \\\"inProgress\\\": [\\\"Continue with the latest kept context.\\\"],\\n\"\n                                + \"    \\\"blocked\\\": []\\n\"\n                                + \"  },\\n\"\n                                + \"  \\\"keyDecisions\\\": [\\\"**Compaction**: Summarized older messages into a checkpoint.\\\"],\\n\"\n                                + \"  \\\"nextSteps\\\": [\\\"Continue the coding task from the latest workspace state.\\\"],\\n\"\n                                + \"  \\\"criticalContext\\\": [\\\"Keep using the same workspace and session.\\\"]\\n\"\n                                + \"}\")\n                        .build();\n            }\n            String text = findLastUserText(prompt);\n            if (text.contains(\"very long analysis\")) {\n                String output = repeat(\"long-output-\", 80);\n                return AgentModelResult.builder()\n                        .outputText(output)\n                        .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", output)))\n                        .build();\n            }\n            return AgentModelResult.builder()\n                    .outputText(\"echo\")\n                    .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", \"echo\")))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private String findLastUserText(AgentPrompt prompt) {\n            if (prompt == null || prompt.getItems() == null || prompt.getItems().isEmpty()) {\n                return \"\";\n            }\n            Object last = prompt.getItems().get(prompt.getItems().size() - 1);\n            return last == null ? \"\" : String.valueOf(last);\n        }\n\n        private String repeat(String text, int times) {\n            StringBuilder builder = new StringBuilder();\n            for (int i = 0; i < times; i++) {\n                builder.append(text);\n            }\n            return builder.toString();\n        }\n    }\n\n    private MemorySnapshot firstMemorySnapshot() {\n        return MemorySnapshot.from(Collections.<Object>singletonList(AgentInputItem.userMessage(\"hello\")), null);\n    }\n\n    private AgentToolRegistry singleToolRegistry(String toolName) {\n        Tool.Function function = new Tool.Function();\n        function.setName(toolName);\n        function.setDescription(\"Stub tool for testing\");\n        return new StaticToolRegistry(Collections.<Object>singletonList(new Tool(\"function\", function)));\n    }\n\n    private ToolExecutor largeToolExecutor() {\n        return new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) {\n                return repeat(\"tool-output-\", 260);\n            }\n        };\n    }\n\n    private AgentModelResult toolCallResult(String toolName, String callId) {\n        return AgentModelResult.builder()\n                .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                        .name(toolName)\n                        .arguments(\"{}\")\n                        .callId(callId)\n                        .type(\"function\")\n                        .build()))\n                .memoryItems(Collections.<Object>emptyList())\n                .build();\n    }\n\n    private AgentModelResult assistantResult(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", text)))\n                .build();\n    }\n\n    private String repeat(String text, int times) {\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < times; i++) {\n            builder.append(text);\n        }\n        return builder.toString();\n    }\n\n    private String findLastUserText(AgentPrompt prompt) {\n        if (prompt == null || prompt.getItems() == null || prompt.getItems().isEmpty()) {\n            return \"\";\n        }\n        Object last = prompt.getItems().get(prompt.getItems().size() - 1);\n        return last == null ? \"\" : String.valueOf(last);\n    }\n\n    private static final class QueueModelClient implements AgentModelClient {\n\n        private final Deque<AgentModelResult> results = new ArrayDeque<AgentModelResult>();\n        private int summaryPromptCount;\n\n        private void enqueue(AgentModelResult result) {\n            results.addLast(result);\n        }\n\n        private int getSummaryPromptCount() {\n            return summaryPromptCount;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            if (prompt != null && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                summaryPromptCount += 1;\n            }\n            AgentModelResult result = results.removeFirst();\n            return result == null ? AgentModelResult.builder().build() : result;\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n    }\n\n    private final class FailingCompactionModelClient implements AgentModelClient {\n\n        private int summaryPromptCount;\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            if (prompt != null && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                summaryPromptCount += 1;\n                throw new IllegalStateException(\"summary model unavailable\");\n            }\n            String output = repeat(\"long-output-\", 40);\n            return AgentModelResult.builder()\n                    .outputText(output)\n                    .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", output)))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private int getSummaryPromptCount() {\n            return summaryPromptCount;\n        }\n    }\n\n    private final class PromptTooLongOnceCompactionModelClient implements AgentModelClient {\n\n        private final int promptTooLongThreshold;\n        private int summaryPromptCount;\n        private int promptTooLongFailures;\n\n        private PromptTooLongOnceCompactionModelClient(int promptTooLongThreshold) {\n            this.promptTooLongThreshold = promptTooLongThreshold;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            if (prompt != null && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                summaryPromptCount += 1;\n                String text = findLastUserText(prompt);\n                if (promptTooLongFailures == 0 && text.length() >= promptTooLongThreshold) {\n                    promptTooLongFailures += 1;\n                    throw new IllegalStateException(\"Prompt too long for compact summary.\");\n                }\n                return AgentModelResult.builder()\n                        .outputText(\"{\\n\"\n                                + \"  \\\"goal\\\": \\\"Continue the coding task.\\\",\\n\"\n                                + \"  \\\"constraints\\\": [\\\"Retry summary when needed.\\\"],\\n\"\n                                + \"  \\\"progress\\\": {\\n\"\n                                + \"    \\\"done\\\": [\\\"Recovered compaction after a prompt-too-long retry.\\\"],\\n\"\n                                + \"    \\\"inProgress\\\": [\\\"Continue from compacted context.\\\"],\\n\"\n                                + \"    \\\"blocked\\\": []\\n\"\n                                + \"  },\\n\"\n                                + \"  \\\"keyDecisions\\\": [\\\"Use retry slicing when the summary prompt exceeds context.\\\"],\\n\"\n                                + \"  \\\"nextSteps\\\": [\\\"Continue from the compacted checkpoint.\\\"],\\n\"\n                                + \"  \\\"criticalContext\\\": [\\\"Retry path succeeded.\\\"]\\n\"\n                                + \"}\")\n                        .build();\n            }\n            String output = repeat(\"session-output-\", 90);\n            return AgentModelResult.builder()\n                    .outputText(output)\n                    .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", output)))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private int getSummaryPromptCount() {\n            return summaryPromptCount;\n        }\n\n        private int getPromptTooLongFailures() {\n            return promptTooLongFailures;\n        }\n    }\n\n    private final class AlwaysPromptTooLongCompactionModelClient implements AgentModelClient {\n\n        private int summaryPromptCount;\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            if (prompt != null && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                summaryPromptCount += 1;\n                throw new IllegalStateException(\"Maximum context length exceeded for compact summary.\");\n            }\n            String output = repeat(\"session-output-\", 90);\n            return AgentModelResult.builder()\n                    .outputText(output)\n                    .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", output)))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private int getSummaryPromptCount() {\n            return summaryPromptCount;\n        }\n    }\n\n    private final class FirstSummaryThenFailingCompactionModelClient implements AgentModelClient {\n\n        private int summaryPromptCount;\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            if (prompt != null && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                summaryPromptCount += 1;\n                if (summaryPromptCount == 1) {\n                    return AgentModelResult.builder()\n                            .outputText(\"{\\n\"\n                                    + \"  \\\"goal\\\": \\\"Continue the coding task.\\\",\\n\"\n                                    + \"  \\\"constraints\\\": [\\\"Preserve exact file paths.\\\"],\\n\"\n                                    + \"  \\\"progress\\\": {\\n\"\n                                    + \"    \\\"done\\\": [\\\"Created the initial checkpoint.\\\"],\\n\"\n                                    + \"    \\\"inProgress\\\": [\\\"Wait for the next delta.\\\"],\\n\"\n                                    + \"    \\\"blocked\\\": []\\n\"\n                                    + \"  },\\n\"\n                                    + \"  \\\"keyDecisions\\\": [\\\"Use checkpoint + delta updates.\\\"],\\n\"\n                                    + \"  \\\"nextSteps\\\": [\\\"Continue from the next user change.\\\"],\\n\"\n                                    + \"  \\\"criticalContext\\\": [\\\"Checkpoint already exists before the next compact.\\\"]\\n\"\n                                    + \"}\")\n                            .build();\n                }\n                throw new IllegalStateException(\"summary model unavailable\");\n            }\n            return AgentModelResult.builder()\n                    .outputText(\"echo\")\n                    .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", \"echo\")))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n    }\n\n    private final class FirstSummaryThenAggressiveFailingCompactionModelClient implements AgentModelClient {\n\n        private int summaryPromptCount;\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            if (prompt != null && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                summaryPromptCount += 1;\n                if (summaryPromptCount == 1 || summaryPromptCount == 2) {\n                    return AgentModelResult.builder()\n                            .outputText(\"{\\n\"\n                                    + \"  \\\"goal\\\": \\\"Continue the coding task.\\\",\\n\"\n                                    + \"  \\\"constraints\\\": [\\\"Preserve exact file paths.\\\"],\\n\"\n                                    + \"  \\\"progress\\\": {\\n\"\n                                    + \"    \\\"done\\\": [\\\"Created the initial checkpoint.\\\"],\\n\"\n                                    + \"    \\\"inProgress\\\": [\\\"Handle the recent large delta.\\\"],\\n\"\n                                    + \"    \\\"blocked\\\": []\\n\"\n                                    + \"  },\\n\"\n                                    + \"  \\\"keyDecisions\\\": [\\\"Use checkpoint + delta updates.\\\"],\\n\"\n                                    + \"  \\\"nextSteps\\\": [\\\"Continue from the next user change.\\\"],\\n\"\n                                    + \"  \\\"criticalContext\\\": [\\\"Checkpoint already exists before aggressive compact.\\\"]\\n\"\n                                    + \"}\")\n                            .build();\n                }\n                throw new IllegalStateException(\"summary model unavailable\");\n            }\n            String text = findLastUserText(prompt);\n            if (text.contains(\"very large follow-up change\")) {\n                String output = repeat(\"large-follow-up-output-\", 240);\n                return AgentModelResult.builder()\n                        .outputText(output)\n                        .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", output)))\n                        .build();\n            }\n            return AgentModelResult.builder()\n                    .outputText(\"echo\")\n                    .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", \"echo\")))\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n\n        private int getSummaryPromptCount() {\n            return summaryPromptCount;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingSkillSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.coding.skill.CodingSkillDescriptor;\nimport io.github.lnyocly.ai4j.coding.skill.CodingSkillDiscovery;\nimport io.github.lnyocly.ai4j.coding.workspace.LocalWorkspaceFileService;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceFileReadResult;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\nimport static org.junit.Assert.fail;\n\npublic class CodingSkillSupportTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldDiscoverSkillsAndInjectAvailableSkillsPrompt() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-skills\").toPath();\n        Path workspaceSkillFile = writeSkill(\n                workspaceRoot.resolve(\".ai4j\").resolve(\"skills\").resolve(\"reviewer\").resolve(\"SKILL.md\"),\n                \"---\\nname: reviewer\\ndescription: Review code changes for risks.\\n---\\n\"\n        );\n        Path fakeHome = temporaryFolder.newFolder(\"fake-home\").toPath();\n        Path globalSkillFile = writeSkill(\n                fakeHome.resolve(\".ai4j\").resolve(\"skills\").resolve(\"refactorer\").resolve(\"SKILL.md\"),\n                \"# refactorer\\nSimplify recently modified code without changing behavior.\\n\"\n        );\n\n        String originalUserHome = System.getProperty(\"user.home\");\n        System.setProperty(\"user.home\", fakeHome.toString());\n        try {\n            WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                    .rootPath(workspaceRoot.toString())\n                    .description(\"Skill-aware workspace\")\n                    .build();\n\n            WorkspaceContext enriched = CodingSkillDiscovery.enrich(workspaceContext);\n            List<CodingSkillDescriptor> skills = enriched.getAvailableSkills();\n            assertEquals(2, skills.size());\n            assertEquals(\"reviewer\", skills.get(0).getName());\n            assertEquals(\"workspace\", skills.get(0).getSource());\n            assertEquals(workspaceSkillFile.toAbsolutePath().normalize().toString(), skills.get(0).getSkillFilePath());\n            assertEquals(\"refactorer\", skills.get(1).getName());\n            assertEquals(\"global\", skills.get(1).getSource());\n            assertEquals(globalSkillFile.toAbsolutePath().normalize().toString(), skills.get(1).getSkillFilePath());\n            assertTrue(enriched.getAllowedReadRoots().contains(workspaceSkillFile.getParent().getParent().toString()));\n            assertTrue(enriched.getAllowedReadRoots().contains(globalSkillFile.getParent().getParent().toString()));\n\n            CapturingModelClient modelClient = new CapturingModelClient();\n            CodingAgent agent = CodingAgents.builder()\n                    .modelClient(modelClient)\n                    .model(\"glm-4.5-flash\")\n                    .workspaceContext(workspaceContext)\n                    .systemPrompt(\"Base prompt.\")\n                    .build();\n\n            CodingSession session = agent.newSession();\n            try {\n                session.run(\"Use the most relevant skill.\");\n            } finally {\n                session.close();\n            }\n\n            AgentPrompt prompt = modelClient.getLastPrompt();\n            assertNotNull(prompt);\n            assertTrue(prompt.getSystemPrompt().contains(\"<available_skills>\"));\n            assertTrue(prompt.getSystemPrompt().contains(\"name: reviewer\"));\n            assertTrue(prompt.getSystemPrompt().contains(workspaceSkillFile.toAbsolutePath().normalize().toString()));\n            assertTrue(prompt.getSystemPrompt().contains(\"name: refactorer\"));\n            assertTrue(prompt.getSystemPrompt().contains(globalSkillFile.toAbsolutePath().normalize().toString()));\n        } finally {\n            restoreProperty(\"user.home\", originalUserHome);\n        }\n    }\n\n    @Test\n    public void shouldAllowReadOnlySkillFilesOutsideWorkspace() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-read-skill\").toPath();\n        Path fakeHome = temporaryFolder.newFolder(\"fake-home-read\").toPath();\n        Path globalSkillFile = writeSkill(\n                fakeHome.resolve(\".ai4j\").resolve(\"skills\").resolve(\"planner\").resolve(\"SKILL.md\"),\n                \"---\\nname: planner\\ndescription: Plan implementation steps.\\n---\\n\"\n        );\n\n        String originalUserHome = System.getProperty(\"user.home\");\n        System.setProperty(\"user.home\", fakeHome.toString());\n        try {\n            WorkspaceContext workspaceContext = CodingSkillDiscovery.enrich(WorkspaceContext.builder()\n                    .rootPath(workspaceRoot.toString())\n                    .build());\n\n            LocalWorkspaceFileService fileService = new LocalWorkspaceFileService(workspaceContext);\n            WorkspaceFileReadResult result = fileService.readFile(globalSkillFile.toString(), 1, 2, 4000);\n            assertTrue(result.getContent().contains(\"planner\"));\n            assertEquals(globalSkillFile.toAbsolutePath().normalize().toString().replace('\\\\', '/'), result.getPath());\n\n            try {\n                fileService.writeFile(globalSkillFile.toString(), \"mutated\", false);\n                fail(\"writeFile should not allow writes outside the workspace root\");\n            } catch (IllegalArgumentException expected) {\n                assertTrue(expected.getMessage().contains(\"escapes workspace root\"));\n            }\n        } finally {\n            restoreProperty(\"user.home\", originalUserHome);\n        }\n    }\n\n    private static Path writeSkill(Path skillFile, String content) throws Exception {\n        Files.createDirectories(skillFile.getParent());\n        Files.write(skillFile, content.getBytes(StandardCharsets.UTF_8));\n        return skillFile;\n    }\n\n    private static void restoreProperty(String key, String value) {\n        if (value == null) {\n            System.clearProperty(key);\n            return;\n        }\n        System.setProperty(key, value);\n    }\n\n    private static final class CapturingModelClient implements AgentModelClient {\n\n        private AgentPrompt lastPrompt;\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            lastPrompt = prompt;\n            return AgentModelResult.builder()\n                    .outputText(\"ok\")\n                    .rawResponse(\"ok\")\n                    .build();\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            return create(prompt);\n        }\n\n        public AgentPrompt getLastPrompt() {\n            return lastPrompt;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/LocalShellCommandExecutorTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.coding.shell.LocalShellCommandExecutor;\nimport io.github.lnyocly.ai4j.coding.shell.ShellCommandRequest;\nimport io.github.lnyocly.ai4j.coding.shell.ShellCommandResult;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\n\npublic class LocalShellCommandExecutorTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldRunCommandInsideWorkspace() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-shell\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .build();\n\n        LocalShellCommandExecutor executor = new LocalShellCommandExecutor(workspaceContext, 10000L);\n        ShellCommandResult result = executor.execute(ShellCommandRequest.builder()\n                .command(\"echo hello-ai4j\")\n                .build());\n\n        assertFalse(result.isTimedOut());\n        assertEquals(0, result.getExitCode());\n        assertTrue(result.getStdout().toLowerCase().contains(\"hello-ai4j\"));\n    }\n\n    @Test\n    public void shouldExplainHowToHandleTimedOutInteractiveOrLongRunningCommands() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-shell-timeout\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .build();\n\n        LocalShellCommandExecutor executor = new LocalShellCommandExecutor(workspaceContext, 200L);\n        ShellCommandResult result = executor.execute(ShellCommandRequest.builder()\n                .command(timeoutCommand())\n                .build());\n\n        assertTrue(result.isTimedOut());\n        assertEquals(-1, result.getExitCode());\n        assertTrue(result.getStderr().contains(\"bash action=start\"));\n    }\n\n    private String timeoutCommand() {\n        String osName = System.getProperty(\"os.name\", \"\").toLowerCase();\n        if (osName.contains(\"win\")) {\n            return \"ping 127.0.0.1 -n 5 > nul\";\n        }\n        return \"sleep 5\";\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/MinimaxCodingAgentTeamWorkspaceUsageTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport io.github.lnyocly.ai4j.agent.Agent;\nimport io.github.lnyocly.ai4j.agent.AgentOptions;\nimport io.github.lnyocly.ai4j.agent.AgentRequest;\nimport io.github.lnyocly.ai4j.agent.AgentResult;\nimport io.github.lnyocly.ai4j.agent.Agents;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeam;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMember;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamMemberResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamOptions;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamPlan;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamResult;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTask;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState;\nimport io.github.lnyocly.ai4j.agent.team.AgentTeamTaskStatus;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.coding.process.SessionProcessRegistry;\nimport io.github.lnyocly.ai4j.coding.prompt.CodingContextPromptAssembler;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.config.MinimaxConfig;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.service.Configuration;\nimport io.github.lnyocly.ai4j.service.PlatformType;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Before;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\n\npublic class MinimaxCodingAgentTeamWorkspaceUsageTest {\n\n    private static final String DEFAULT_MODEL = \"MiniMax-M2.7\";\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    private ChatModelClient modelClient;\n    private String model;\n\n    @Before\n    public void setupMinimaxClient() {\n        String apiKey = readValue(\"MINIMAX_API_KEY\", \"minimax.api.key\");\n        Assume.assumeTrue(\"Skip because MiniMax API key is not configured\", !isBlank(apiKey));\n\n        model = readValue(\"MINIMAX_MODEL\", \"minimax.model\");\n        if (isBlank(model)) {\n            model = DEFAULT_MODEL;\n        }\n\n        Configuration configuration = new Configuration();\n        MinimaxConfig minimaxConfig = new MinimaxConfig();\n        minimaxConfig.setApiKey(apiKey);\n        configuration.setMinimaxConfig(minimaxConfig);\n        configuration.setOkHttpClient(createHttpClient());\n\n        AiService aiService = new AiService(configuration);\n        modelClient = new ChatModelClient(aiService.getChatService(PlatformType.MINIMAX));\n    }\n\n    @Test\n    public void test_travel_demo_team_delivers_workspace_artifacts() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"minimax-travel-team-workspace\").toPath();\n        seedWorkspace(workspaceRoot);\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"Temporary travel demo workspace for MiniMax team delivery verification\")\n                .build();\n        CodingAgentOptions codingOptions = CodingAgentOptions.builder()\n                .autoCompactEnabled(false)\n                .build();\n\n        AgentTeam team = Agents.team()\n                .planner((objective, members, options) -> AgentTeamPlan.builder()\n                        .rawPlanText(\"travel-workspace-delivery-plan\")\n                        .tasks(Arrays.asList(\n                                AgentTeamTask.builder()\n                                        .id(\"product\")\n                                        .memberId(\"product\")\n                                        .task(\"Fill docs/product/prd.md with a concise travel app PRD. Replace TODO_PRODUCT only.\")\n                                        .context(\"Read README.md first. Keep the product scope MVP-level and implementation-ready.\")\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"architecture\")\n                                        .memberId(\"architect\")\n                                        .task(\"Fill docs/architecture/system-design.md with the architecture and file ownership plan. Replace TODO_ARCHITECTURE only.\")\n                                        .context(\"Read README.md and docs/product/prd.md first. Tell backend/frontend exactly which files they own.\")\n                                        .dependsOn(Arrays.asList(\"product\"))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"backend\")\n                                        .memberId(\"backend\")\n                                        .task(\"Replace TODO_BACKEND_OPENAPI in backend/openapi.yaml and TODO_BACKEND_DATA in backend/mock-destinations.json.\")\n                                        .context(\"Read the product and architecture docs first. Produce a small but valid travel API contract and destination dataset.\")\n                                        .dependsOn(Arrays.asList(\"product\", \"architecture\"))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"frontend\")\n                                        .memberId(\"frontend\")\n                                        .task(\"Replace TODO_FRONTEND_HTML in frontend/index.html, TODO_FRONTEND_CSS in frontend/styles.css, and TODO_FRONTEND_JS in frontend/app.js.\")\n                                        .context(\"Read the product doc, architecture doc, and backend files first. Produce a minimal static travel planner demo page that uses the mock destination data shape.\")\n                                        .dependsOn(Arrays.asList(\"product\", \"architecture\", \"backend\"))\n                                        .build(),\n                                AgentTeamTask.builder()\n                                        .id(\"qa\")\n                                        .memberId(\"qa\")\n                                        .task(\"Replace TODO_QA in qa/test-plan.md with a practical QA plan for the generated travel demo workspace.\")\n                                        .context(\"Read all generated docs and app files first. Cover smoke, API-contract, UI, and regression checks.\")\n                                        .dependsOn(Arrays.asList(\"product\", \"architecture\", \"backend\", \"frontend\"))\n                                        .build()\n                        ))\n                        .build())\n                .synthesizer((objective, plan, memberResults, options) -> AgentResult.builder()\n                        .outputText(renderSummary(objective, memberResults))\n                        .build())\n                .member(member(\n                        workspaceContext,\n                        codingOptions,\n                        \"product\",\n                        \"Product Manager\",\n                        \"Owns the product scope and acceptance criteria.\",\n                        \"You are the product manager for a travel planning demo app.\\n\"\n                                + \"You must read README.md, then update only docs/product/prd.md.\\n\"\n                                + \"Replace TODO_PRODUCT with a compact PRD that covers target users, user stories, MVP scope, non-goals, and acceptance criteria.\\n\"\n                                + \"Do not modify any other file.\"\n                ))\n                .member(member(\n                        workspaceContext,\n                        codingOptions,\n                        \"architect\",\n                        \"Architecture Analyst\",\n                        \"Owns architecture and delivery boundaries.\",\n                        \"You are the architecture analyst for a travel planning demo app.\\n\"\n                                + \"You must read README.md and docs/product/prd.md, then update only docs/architecture/system-design.md.\\n\"\n                                + \"Replace TODO_ARCHITECTURE with the system design, module boundaries, and exact file ownership for backend/frontend.\\n\"\n                                + \"Do not modify any other file.\"\n                ))\n                .member(member(\n                        workspaceContext,\n                        codingOptions,\n                        \"backend\",\n                        \"Backend Engineer\",\n                        \"Owns the mock API contract and seed data.\",\n                        \"You are the backend engineer for a travel planning demo app.\\n\"\n                                + \"You must read README.md, docs/product/prd.md, and docs/architecture/system-design.md.\\n\"\n                                + \"Update only backend/openapi.yaml and backend/mock-destinations.json.\\n\"\n                                + \"Replace the TODO markers with a valid OpenAPI skeleton and a small JSON destination dataset for a travel planner.\"\n                ))\n                .member(member(\n                        workspaceContext,\n                        codingOptions,\n                        \"frontend\",\n                        \"Frontend Engineer\",\n                        \"Owns the static demo UI.\",\n                        \"You are the frontend engineer for a travel planning demo app.\\n\"\n                                + \"You must read README.md, docs/product/prd.md, docs/architecture/system-design.md, backend/openapi.yaml, and backend/mock-destinations.json.\\n\"\n                                + \"Update only frontend/index.html, frontend/styles.css, and frontend/app.js.\\n\"\n                                + \"Replace the TODO markers with a minimal static travel planner demo that matches the mock data shape and references app.js/styles.css correctly.\"\n                ))\n                .member(member(\n                        workspaceContext,\n                        codingOptions,\n                        \"qa\",\n                        \"QA Engineer\",\n                        \"Owns verification strategy for the generated workspace.\",\n                        \"You are the QA engineer for a travel planning demo app.\\n\"\n                                + \"You must read all generated docs and app files, then update only qa/test-plan.md.\\n\"\n                                + \"Replace TODO_QA with a practical test plan covering smoke, API contract checks, UI behavior, and regression scope.\\n\"\n                                + \"Do not modify any other file.\"\n                ))\n                .options(AgentTeamOptions.builder()\n                        .parallelDispatch(true)\n                        .maxConcurrency(3)\n                        .enableMessageBus(true)\n                        .includeMessageHistoryInDispatch(true)\n                        .enableMemberTeamTools(true)\n                        .maxRounds(16)\n                        .build())\n                .build();\n\n        AgentTeamResult result = callWithProviderGuard(new ThrowingSupplier<AgentTeamResult>() {\n            @Override\n            public AgentTeamResult get() throws Exception {\n                return team.run(AgentRequest.builder()\n                        .input(\"Collaboratively deliver the files for the travel demo workspace without changing files outside the assigned ownership.\")\n                        .build());\n            }\n        });\n\n        printResult(result, workspaceRoot);\n\n        Assert.assertNotNull(result);\n        Assert.assertEquals(5, result.getTaskStates().size());\n        Assert.assertTrue(\"Team tasks did not all complete: \" + describeTaskStates(result.getTaskStates()),\n                allTasksCompleted(result.getTaskStates()));\n\n        assertFileContains(workspaceRoot.resolve(\"docs/product/prd.md\"), \"MVP\");\n        assertFileContains(workspaceRoot.resolve(\"docs/architecture/system-design.md\"), \"backend/openapi.yaml\");\n        assertFileContains(workspaceRoot.resolve(\"backend/openapi.yaml\"), \"openapi:\");\n        assertFileContains(workspaceRoot.resolve(\"backend/mock-destinations.json\"), \"\\\"destinations\\\"\");\n        assertFileContains(workspaceRoot.resolve(\"frontend/index.html\"), \"app.js\");\n        assertFileContainsAny(workspaceRoot.resolve(\"frontend/styles.css\"),\n                \".grid\",\n                \".destinations-grid\",\n                \"grid-template-columns\");\n        assertFileContainsIgnoreCase(workspaceRoot.resolve(\"frontend/app.js\"), \"destination\");\n        assertFileContainsIgnoreCase(workspaceRoot.resolve(\"qa/test-plan.md\"), \"smoke\");\n\n        assertTodoRemoved(workspaceRoot.resolve(\"docs/product/prd.md\"), \"TODO_PRODUCT\");\n        assertTodoRemoved(workspaceRoot.resolve(\"docs/architecture/system-design.md\"), \"TODO_ARCHITECTURE\");\n        assertTodoRemoved(workspaceRoot.resolve(\"backend/openapi.yaml\"), \"TODO_BACKEND_OPENAPI\");\n        assertTodoRemoved(workspaceRoot.resolve(\"backend/mock-destinations.json\"), \"TODO_BACKEND_DATA\");\n        assertTodoRemoved(workspaceRoot.resolve(\"frontend/index.html\"), \"TODO_FRONTEND_HTML\");\n        assertTodoRemoved(workspaceRoot.resolve(\"frontend/styles.css\"), \"TODO_FRONTEND_CSS\");\n        assertTodoRemoved(workspaceRoot.resolve(\"frontend/app.js\"), \"TODO_FRONTEND_JS\");\n        assertTodoRemoved(workspaceRoot.resolve(\"qa/test-plan.md\"), \"TODO_QA\");\n    }\n\n    private AgentTeamMember member(WorkspaceContext workspaceContext,\n                                   CodingAgentOptions codingOptions,\n                                   String id,\n                                   String name,\n                                   String description,\n                                   String systemPrompt) {\n        return AgentTeamMember.builder()\n                .id(id)\n                .name(name)\n                .description(description)\n                .agent(buildWorkspaceCodingAgent(workspaceContext, codingOptions, systemPrompt))\n                .build();\n    }\n\n    private Agent buildWorkspaceCodingAgent(WorkspaceContext workspaceContext,\n                                            CodingAgentOptions codingOptions,\n                                            String systemPrompt) {\n        SessionProcessRegistry processRegistry = new SessionProcessRegistry(workspaceContext, codingOptions);\n        AgentToolRegistry toolRegistry = CodingAgentBuilder.createBuiltInRegistry(codingOptions);\n        ToolExecutor toolExecutor = CodingAgentBuilder.createBuiltInToolExecutor(\n                workspaceContext,\n                codingOptions,\n                processRegistry\n        );\n        return Agents.react()\n                .modelClient(modelClient)\n                .model(model)\n                .temperature(0.2)\n                .systemPrompt(CodingContextPromptAssembler.mergeSystemPrompt(systemPrompt, workspaceContext))\n                .toolRegistry(toolRegistry)\n                .toolExecutor(toolExecutor)\n                .options(AgentOptions.builder()\n                        .maxSteps(10)\n                        .stream(false)\n                        .build())\n                .build();\n    }\n\n    private void seedWorkspace(Path workspaceRoot) throws IOException {\n        Files.createDirectories(workspaceRoot.resolve(\"docs/product\"));\n        Files.createDirectories(workspaceRoot.resolve(\"docs/architecture\"));\n        Files.createDirectories(workspaceRoot.resolve(\"backend\"));\n        Files.createDirectories(workspaceRoot.resolve(\"frontend\"));\n        Files.createDirectories(workspaceRoot.resolve(\"qa\"));\n\n        write(workspaceRoot.resolve(\"README.md\"),\n                \"# Travel Demo Workspace\\n\"\n                        + \"\\n\"\n                        + \"Goal: deliver a small travel planning demo workspace.\\n\"\n                        + \"\\n\"\n                        + \"Required outputs:\\n\"\n                        + \"- docs/product/prd.md\\n\"\n                        + \"- docs/architecture/system-design.md\\n\"\n                        + \"- backend/openapi.yaml\\n\"\n                        + \"- backend/mock-destinations.json\\n\"\n                        + \"- frontend/index.html\\n\"\n                        + \"- frontend/styles.css\\n\"\n                        + \"- frontend/app.js\\n\"\n                        + \"- qa/test-plan.md\\n\"\n                        + \"\\n\"\n                        + \"Rules:\\n\"\n                        + \"- Only modify the files you own.\\n\"\n                        + \"- Replace TODO markers rather than inventing new paths.\\n\"\n                        + \"- Keep the demo small, concrete, and internally consistent.\\n\");\n\n        write(workspaceRoot.resolve(\"docs/product/prd.md\"), \"# Travel Demo PRD\\n\\nTODO_PRODUCT\\n\");\n        write(workspaceRoot.resolve(\"docs/architecture/system-design.md\"), \"# Travel Demo Architecture\\n\\nTODO_ARCHITECTURE\\n\");\n        write(workspaceRoot.resolve(\"backend/openapi.yaml\"), \"TODO_BACKEND_OPENAPI\\n\");\n        write(workspaceRoot.resolve(\"backend/mock-destinations.json\"), \"{\\n  \\\"destinations\\\": TODO_BACKEND_DATA\\n}\\n\");\n        write(workspaceRoot.resolve(\"frontend/index.html\"),\n                \"<!DOCTYPE html>\\n<html>\\n<head>\\n  <meta charset=\\\"UTF-8\\\" />\\n  <title>Travel Demo</title>\\n  <link rel=\\\"stylesheet\\\" href=\\\"styles.css\\\" />\\n</head>\\n<body>\\nTODO_FRONTEND_HTML\\n<script src=\\\"app.js\\\"></script>\\n</body>\\n</html>\\n\");\n        write(workspaceRoot.resolve(\"frontend/styles.css\"), \"TODO_FRONTEND_CSS\\n\");\n        write(workspaceRoot.resolve(\"frontend/app.js\"), \"TODO_FRONTEND_JS\\n\");\n        write(workspaceRoot.resolve(\"qa/test-plan.md\"), \"# QA Test Plan\\n\\nTODO_QA\\n\");\n    }\n\n    private void write(Path path, String content) throws IOException {\n        Files.write(path, content.getBytes(StandardCharsets.UTF_8));\n    }\n\n    private void assertFileContains(Path path, String expected) throws IOException {\n        Assert.assertTrue(\"Expected file to exist: \" + path, Files.exists(path));\n        String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);\n        Assert.assertTrue(\"Expected [\" + expected + \"] in \" + path + \" but content was:\\n\" + content,\n                content.contains(expected));\n    }\n\n    private void assertFileContainsAny(Path path, String... expectedValues) throws IOException {\n        Assert.assertTrue(\"Expected file to exist: \" + path, Files.exists(path));\n        String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);\n        if (expectedValues != null) {\n            for (String expected : expectedValues) {\n                if (expected != null && content.contains(expected)) {\n                    return;\n                }\n            }\n        }\n        Assert.fail(\"Expected one of \" + Arrays.toString(expectedValues) + \" in \" + path + \" but content was:\\n\" + content);\n    }\n\n    private void assertFileContainsIgnoreCase(Path path, String expected) throws IOException {\n        Assert.assertTrue(\"Expected file to exist: \" + path, Files.exists(path));\n        String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);\n        Assert.assertTrue(\"Expected [\" + expected + \"] in \" + path + \" but content was:\\n\" + content,\n                content.toLowerCase().contains(expected.toLowerCase()));\n    }\n\n    private void assertTodoRemoved(Path path, String todoMarker) throws IOException {\n        String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);\n        Assert.assertFalse(\"Expected TODO marker to be removed from \" + path + \": \" + todoMarker,\n                content.contains(todoMarker));\n    }\n\n    private String renderSummary(String objective, List<AgentTeamMemberResult> memberResults) {\n        Map<String, String> outputs = new LinkedHashMap<String, String>();\n        if (memberResults != null) {\n            for (AgentTeamMemberResult item : memberResults) {\n                if (item == null || isBlank(item.getMemberId())) {\n                    continue;\n                }\n                outputs.put(item.getMemberId(), item.isSuccess() ? safe(item.getOutput()) : \"FAILED: \" + safe(item.getError()));\n            }\n        }\n\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"Travel Workspace Delivery Summary\\n\");\n        builder.append(\"Objective: \").append(safe(objective)).append(\"\\n\\n\");\n        appendSection(builder, \"Product\", outputs.get(\"product\"));\n        appendSection(builder, \"Architect\", outputs.get(\"architect\"));\n        appendSection(builder, \"Backend\", outputs.get(\"backend\"));\n        appendSection(builder, \"Frontend\", outputs.get(\"frontend\"));\n        appendSection(builder, \"QA\", outputs.get(\"qa\"));\n        return builder.toString().trim();\n    }\n\n    private void appendSection(StringBuilder builder, String title, String output) {\n        builder.append('[').append(title).append(\"]\\n\");\n        builder.append(safe(output)).append(\"\\n\\n\");\n    }\n\n    private void printResult(AgentTeamResult result, Path workspaceRoot) throws IOException {\n        System.out.println(\"==== TASK STATES ====\");\n        if (result != null && result.getTaskStates() != null) {\n            for (AgentTeamTaskState state : result.getTaskStates()) {\n                System.out.println(state.getTaskId() + \" => \" + state.getStatus() + \" by \" + state.getClaimedBy());\n            }\n        }\n\n        System.out.println(\"==== GENERATED FILES ====\");\n        Files.walk(workspaceRoot)\n                .filter(Files::isRegularFile)\n                .forEach(path -> System.out.println(workspaceRoot.relativize(path).toString()));\n\n        System.out.println(\"==== FINAL OUTPUT ====\");\n        System.out.println(result == null ? \"\" : result.getOutput());\n    }\n\n    private boolean allTasksCompleted(List<AgentTeamTaskState> states) {\n        if (states == null || states.isEmpty()) {\n            return false;\n        }\n        for (AgentTeamTaskState state : states) {\n            if (state == null || state.getStatus() != AgentTeamTaskStatus.COMPLETED) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n    private String describeTaskStates(List<AgentTeamTaskState> states) {\n        if (states == null || states.isEmpty()) {\n            return \"[]\";\n        }\n        StringBuilder sb = new StringBuilder(\"[\");\n        for (int i = 0; i < states.size(); i++) {\n            AgentTeamTaskState state = states.get(i);\n            if (i > 0) {\n                sb.append(\", \");\n            }\n            if (state == null) {\n                sb.append(\"null\");\n                continue;\n            }\n            sb.append(state.getTaskId()).append(\":\").append(state.getStatus());\n            if (!isBlank(state.getClaimedBy())) {\n                sb.append(\"@\").append(state.getClaimedBy());\n            }\n            if (!isBlank(state.getError())) {\n                sb.append(\"(error=\").append(state.getError()).append(\")\");\n            }\n        }\n        sb.append(\"]\");\n        return sb.toString();\n    }\n\n    private <T> T callWithProviderGuard(ThrowingSupplier<T> supplier) throws Exception {\n        try {\n            return supplier.get();\n        } catch (Exception ex) {\n            skipIfProviderUnavailable(ex);\n            throw ex;\n        }\n    }\n\n    private void skipIfProviderUnavailable(Throwable throwable) {\n        if (isProviderUnavailable(throwable)) {\n            Assume.assumeTrue(\"Skip due provider limit/unavailable: \" + extractRootMessage(throwable), false);\n        }\n    }\n\n    private boolean isProviderUnavailable(Throwable throwable) {\n        Throwable current = throwable;\n        while (current != null) {\n            String message = current.getMessage();\n            if (!isBlank(message)) {\n                String lower = message.toLowerCase();\n                if (lower.contains(\"timeout\")\n                        || lower.contains(\"rate limit\")\n                        || lower.contains(\"too many requests\")\n                        || lower.contains(\"quota\")\n                        || lower.contains(\"tool arguments must be a json object\")\n                        || message.contains(\"频次\")\n                        || message.contains(\"限流\")\n                        || message.contains(\"额度\")\n                        || message.contains(\"配额\")\n                        || message.contains(\"账户已达到\")) {\n                    return true;\n                }\n            }\n            current = current.getCause();\n        }\n        return false;\n    }\n\n    private String extractRootMessage(Throwable throwable) {\n        Throwable current = throwable;\n        Throwable last = throwable;\n        while (current != null) {\n            last = current;\n            current = current.getCause();\n        }\n        return last == null || isBlank(last.getMessage()) ? \"unknown error\" : last.getMessage();\n    }\n\n    private OkHttpClient createHttpClient() {\n        HttpLoggingInterceptor logging = new HttpLoggingInterceptor();\n        logging.setLevel(HttpLoggingInterceptor.Level.BASIC);\n        return new OkHttpClient.Builder()\n                .addInterceptor(logging)\n                .addInterceptor(new ErrorInterceptor())\n                .connectTimeout(300, TimeUnit.SECONDS)\n                .writeTimeout(300, TimeUnit.SECONDS)\n                .readTimeout(300, TimeUnit.SECONDS)\n                .build();\n    }\n\n    private String readValue(String envKey, String propertyKey) {\n        String value = envKey == null ? null : System.getenv(envKey);\n        if (isBlank(value) && propertyKey != null) {\n            value = System.getProperty(propertyKey);\n        }\n        return value;\n    }\n\n    private String safe(String value) {\n        return isBlank(value) ? \"\" : value.trim();\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    @FunctionalInterface\n    private interface ThrowingSupplier<T> {\n        T get() throws Exception;\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/ReadFileToolExecutorTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.tool.ReadFileToolExecutor;\nimport io.github.lnyocly.ai4j.coding.workspace.LocalWorkspaceFileService;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\n\npublic class ReadFileToolExecutorTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldReadFileInsideWorkspace() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-read\").toPath();\n        Files.write(workspaceRoot.resolve(\"demo.txt\"), \"alpha\\nbeta\".getBytes(StandardCharsets.UTF_8));\n\n        ReadFileToolExecutor executor = new ReadFileToolExecutor(\n                new LocalWorkspaceFileService(WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build()),\n                CodingAgentOptions.builder().build()\n        );\n\n        String raw = executor.execute(AgentToolCall.builder()\n                .name(CodingToolNames.READ_FILE)\n                .arguments(\"{\\\"path\\\":\\\"demo.txt\\\"}\")\n                .build());\n\n        JSONObject result = JSON.parseObject(raw);\n        assertEquals(\"demo.txt\", result.getString(\"path\"));\n        assertEquals(\"alpha\\nbeta\", result.getString(\"content\"));\n        assertFalse(result.getBooleanValue(\"truncated\"));\n    }\n\n    @Test(expected = IllegalArgumentException.class)\n    public void shouldRejectPathOutsideWorkspace() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-read-escape\").toPath();\n        ReadFileToolExecutor executor = new ReadFileToolExecutor(\n                new LocalWorkspaceFileService(WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build()),\n                CodingAgentOptions.builder().build()\n        );\n\n        executor.execute(AgentToolCall.builder()\n                .name(CodingToolNames.READ_FILE)\n                .arguments(\"{\\\"path\\\":\\\"../escape.txt\\\"}\")\n                .build());\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/WriteFileToolExecutorTest.java",
    "content": "package io.github.lnyocly.ai4j.coding;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.coding.tool.CodingToolNames;\nimport io.github.lnyocly.ai4j.coding.tool.WriteFileToolExecutor;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\n\npublic class WriteFileToolExecutorTest {\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldCreateOverwriteAndAppendFiles() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-write\").toPath();\n        WriteFileToolExecutor executor = new WriteFileToolExecutor(\n                WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build()\n        );\n\n        JSONObject created = JSON.parseObject(executor.execute(call(\"notes/todo.txt\", \"alpha\", \"create\")));\n        assertTrue(created.getBooleanValue(\"created\"));\n        assertFalse(created.getBooleanValue(\"appended\"));\n        assertEquals(\"alpha\", new String(Files.readAllBytes(workspaceRoot.resolve(\"notes/todo.txt\")), StandardCharsets.UTF_8));\n\n        JSONObject overwritten = JSON.parseObject(executor.execute(call(\"notes/todo.txt\", \"beta\", \"overwrite\")));\n        assertFalse(overwritten.getBooleanValue(\"created\"));\n        assertFalse(overwritten.getBooleanValue(\"appended\"));\n        assertEquals(\"beta\", new String(Files.readAllBytes(workspaceRoot.resolve(\"notes/todo.txt\")), StandardCharsets.UTF_8));\n\n        JSONObject appended = JSON.parseObject(executor.execute(call(\"notes/todo.txt\", \"\\ngamma\", \"append\")));\n        assertFalse(appended.getBooleanValue(\"created\"));\n        assertTrue(appended.getBooleanValue(\"appended\"));\n        assertEquals(\"beta\\ngamma\", new String(Files.readAllBytes(workspaceRoot.resolve(\"notes/todo.txt\")), StandardCharsets.UTF_8));\n    }\n\n    @Test\n    public void shouldAllowWritingAbsolutePathOutsideWorkspace() throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"workspace-write-outside\").toPath();\n        Path outsideFile = temporaryFolder.newFolder(\"outside-root\").toPath().resolve(\"outside.txt\");\n        WriteFileToolExecutor executor = new WriteFileToolExecutor(\n                WorkspaceContext.builder().rootPath(workspaceRoot.toString()).build()\n        );\n\n        JSONObject result = JSON.parseObject(executor.execute(call(outsideFile.toString(), \"outside\", \"overwrite\")));\n        assertTrue(Files.exists(outsideFile));\n        assertEquals(\"outside\", new String(Files.readAllBytes(outsideFile), StandardCharsets.UTF_8));\n        assertEquals(outsideFile.toAbsolutePath().normalize().toString(), result.getString(\"resolvedPath\"));\n    }\n\n    private AgentToolCall call(String path, String content, String mode) {\n        JSONObject arguments = new JSONObject();\n        arguments.put(\"path\", path);\n        arguments.put(\"content\", content);\n        arguments.put(\"mode\", mode);\n        return AgentToolCall.builder()\n                .name(CodingToolNames.WRITE_FILE)\n                .arguments(arguments.toJSONString())\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/loop/CodingAgentLoopControllerTest.java",
    "content": "package io.github.lnyocly.ai4j.coding.loop;\n\nimport io.github.lnyocly.ai4j.agent.memory.MemorySnapshot;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelResult;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener;\nimport io.github.lnyocly.ai4j.agent.model.AgentPrompt;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.agent.util.AgentInputItem;\nimport io.github.lnyocly.ai4j.coding.CodingAgent;\nimport io.github.lnyocly.ai4j.coding.CodingAgentOptions;\nimport io.github.lnyocly.ai4j.coding.CodingAgentRequest;\nimport io.github.lnyocly.ai4j.coding.CodingAgentResult;\nimport io.github.lnyocly.ai4j.coding.CodingAgents;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCheckpoint;\nimport io.github.lnyocly.ai4j.coding.CodingSessionCompactResult;\nimport io.github.lnyocly.ai4j.coding.CodingSession;\nimport io.github.lnyocly.ai4j.coding.process.BashProcessStatus;\nimport io.github.lnyocly.ai4j.coding.process.StoredProcessSnapshot;\nimport io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext;\nimport io.github.lnyocly.ai4j.platform.openai.tool.Tool;\nimport org.junit.Rule;\nimport org.junit.Test;\nimport org.junit.rules.TemporaryFolder;\n\nimport java.nio.file.Path;\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Deque;\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertTrue;\n\npublic class CodingAgentLoopControllerTest {\n\n    private static final String STUB_TOOL = \"stub_tool\";\n\n    @Rule\n    public TemporaryFolder temporaryFolder = new TemporaryFolder();\n\n    @Test\n    public void shouldContinueWithHiddenInstructionsWithoutAddingExtraUserMessage() throws Exception {\n        InspectableQueueModelClient modelClient = new InspectableQueueModelClient();\n        modelClient.enqueue(toolCallResult(STUB_TOOL, \"call-1\"));\n        modelClient.enqueue(assistantResult(\"Continuing with remaining work.\"));\n        modelClient.enqueue(assistantResult(\"Completed the requested change.\"));\n\n        try (CodingSession session = newAgent(modelClient, okToolExecutor(), defaultOptions()).newSession()) {\n            CodingAgentResult result = session.run(CodingAgentRequest.builder().input(\"Implement the requested change.\").build());\n\n            assertEquals(CodingStopReason.COMPLETED, result.getStopReason());\n            assertEquals(2, result.getTurns());\n            assertTrue(result.isAutoContinued());\n            assertEquals(1, result.getAutoFollowUpCount());\n            assertEquals(1, countUserMessages(session.exportState().getMemorySnapshot()));\n            assertTrue(modelClient.getPrompts().size() >= 3);\n            assertTrue(modelClient.getPrompts().get(2).getInstructions().contains(\"Internal continuation. This is not a new user message.\"));\n            assertTrue(modelClient.getPrompts().get(2).getInstructions().contains(\"Continuation reason: CONTINUE_AFTER_TOOL_WORK.\"));\n        }\n    }\n\n    @Test\n    public void shouldStopWhenMaxAutoFollowUpsIsReached() throws Exception {\n        InspectableQueueModelClient modelClient = new InspectableQueueModelClient();\n        modelClient.enqueue(toolCallResult(STUB_TOOL, \"call-1\"));\n        modelClient.enqueue(assistantResult(\"Continuing with remaining work.\"));\n        modelClient.enqueue(toolCallResult(STUB_TOOL, \"call-2\"));\n        modelClient.enqueue(assistantResult(\"Continuing with remaining work.\"));\n\n        CodingAgentOptions options = defaultOptions().toBuilder()\n                .maxAutoFollowUps(1)\n                .build();\n\n        try (CodingSession session = newAgent(modelClient, okToolExecutor(), options).newSession()) {\n            CodingAgentResult result = session.run(\"Keep working until you are done.\");\n            List<CodingLoopDecision> decisions = session.drainLoopDecisions();\n\n            assertEquals(CodingStopReason.MAX_AUTO_FOLLOWUPS_REACHED, result.getStopReason());\n            assertEquals(2, result.getTurns());\n            assertTrue(result.isAutoContinued());\n            assertEquals(1, result.getAutoFollowUpCount());\n            assertEquals(2, result.getToolResults().size());\n            assertEquals(2, decisions.size());\n            assertTrue(decisions.get(0).isContinueLoop());\n            assertEquals(CodingLoopDecision.CONTINUE_AFTER_TOOL_WORK, decisions.get(0).getContinueReason());\n            assertFalse(decisions.get(1).isContinueLoop());\n            assertEquals(CodingStopReason.MAX_AUTO_FOLLOWUPS_REACHED, decisions.get(1).getStopReason());\n        }\n    }\n\n    @Test\n    public void shouldStopWhenApprovalIsRejected() throws Exception {\n        InspectableQueueModelClient modelClient = new InspectableQueueModelClient();\n        modelClient.enqueue(toolCallResult(STUB_TOOL, \"call-1\"));\n        modelClient.enqueue(assistantResult(\"Approval required before proceeding.\"));\n\n        try (CodingSession session = newAgent(modelClient, approvalRejectedToolExecutor(), defaultOptions()).newSession()) {\n            CodingAgentResult result = session.run(\"Run the protected operation.\");\n            List<CodingLoopDecision> decisions = session.drainLoopDecisions();\n\n            assertEquals(CodingStopReason.BLOCKED_BY_APPROVAL, result.getStopReason());\n            assertFalse(result.isAutoContinued());\n            assertEquals(1, result.getTurns());\n            assertEquals(1, decisions.size());\n            assertTrue(decisions.get(0).isBlocked());\n            assertEquals(CodingStopReason.BLOCKED_BY_APPROVAL, decisions.get(0).getStopReason());\n        }\n    }\n\n    @Test\n    public void shouldStopAfterToolWorkWhenAssistantAlreadyReturnedFinalAnswer() throws Exception {\n        InspectableQueueModelClient modelClient = new InspectableQueueModelClient();\n        modelClient.enqueue(toolCallResult(STUB_TOOL, \"call-1\"));\n        modelClient.enqueue(assistantResult(\"# ai4j\"));\n\n        try (CodingSession session = newAgent(modelClient, okToolExecutor(), defaultOptions()).newSession()) {\n            CodingAgentResult result = session.run(\"Read README.md and answer with the first heading.\");\n            List<CodingLoopDecision> decisions = session.drainLoopDecisions();\n\n            assertEquals(CodingStopReason.COMPLETED, result.getStopReason());\n            assertFalse(result.isAutoContinued());\n            assertEquals(1, result.getTurns());\n            assertEquals(\"# ai4j\", result.getOutputText());\n            assertEquals(1, decisions.size());\n            assertFalse(decisions.get(0).isContinueLoop());\n            assertEquals(CodingStopReason.COMPLETED, decisions.get(0).getStopReason());\n        }\n    }\n\n    @Test\n    public void shouldReanchorContinuationPromptFromCheckpointAfterCompaction() throws Exception {\n        InspectableQueueModelClient modelClient = new InspectableQueueModelClient();\n        modelClient.enqueue(assistantResult(\"Continuing with remaining work. \" + repeat(\"checkpoint-pressure-\", 80)));\n        modelClient.enqueue(assistantResult(\"Completed the requested change.\"));\n\n        CodingAgentOptions options = defaultOptions().toBuilder()\n                .autoCompactEnabled(true)\n                .compactContextWindowTokens(220)\n                .compactReserveTokens(60)\n                .compactKeepRecentTokens(60)\n                .compactSummaryMaxOutputTokens(120)\n                .continueAfterCompact(true)\n                .build();\n\n        try (CodingSession session = newAgent(modelClient, okToolExecutor(), options).newSession()) {\n            CodingAgentResult result = session.run(\"Keep working until the task is done.\");\n\n            assertEquals(CodingStopReason.COMPLETED, result.getStopReason());\n            assertEquals(2, result.getTurns());\n            assertTrue(result.isAutoContinued());\n            assertTrue(modelClient.getPrompts().size() >= 3);\n            String continuationInstructions = findContinuationInstructions(modelClient.getPrompts());\n\n            assertTrue(continuationInstructions.contains(\"A context compaction was just applied.\"));\n            assertTrue(continuationInstructions.contains(\"Compaction strategy:\"));\n            assertTrue(continuationInstructions.contains(\"checkpoint\"));\n            assertTrue(continuationInstructions.contains(\"Checkpoint goal: Continue the coding task.\"));\n            assertTrue(continuationInstructions.contains(\"Checkpoint next steps:\"));\n        }\n    }\n\n    @Test\n    public void shouldInjectConstraintsBlockedAndProcessesIntoContinuationPrompt() {\n        CodingLoopDecision decision = CodingLoopDecision.builder()\n                .turnNumber(2)\n                .continueLoop(true)\n                .continueReason(CodingLoopDecision.CONTINUE_AFTER_COMPACTION)\n                .compactApplied(true)\n                .build();\n        CodingSessionCompactResult compactResult = CodingSessionCompactResult.builder()\n                .strategy(\"checkpoint-delta\")\n                .checkpointReused(true)\n                .checkpoint(CodingSessionCheckpoint.builder()\n                        .goal(\"Finish the current implementation.\")\n                        .splitTurn(true)\n                        .constraints(Collections.singletonList(\"Keep exact file paths.\"))\n                        .blockedItems(Collections.singletonList(\"Wait for approval before deleting files.\"))\n                        .nextSteps(Collections.singletonList(\"Run the next code change step.\"))\n                        .criticalContext(Collections.singletonList(\"The workspace already contains partial edits.\"))\n                        .inProgressItems(Collections.singletonList(\"Refine the compact continuation path.\"))\n                        .processSnapshots(Collections.singletonList(StoredProcessSnapshot.builder()\n                                .processId(\"proc_demo\")\n                                .command(\"npm run dev\")\n                                .status(BashProcessStatus.RUNNING)\n                                .restored(true)\n                                .build()))\n                        .build())\n                .build();\n\n        String prompt = CodingContinuationPrompt.build(\n                decision,\n                CodingAgentResult.builder()\n                        .outputText(\"Continue from the checkpoint.\")\n                        .build(),\n                compactResult,\n                3\n        );\n\n        assertTrue(prompt.contains(\"Compaction strategy: checkpoint-delta.\"));\n        assertTrue(prompt.contains(\"The existing checkpoint was updated with new delta context.\"));\n        assertTrue(prompt.contains(\"This checkpoint came from a split-turn compaction.\"));\n        assertTrue(prompt.contains(\"Checkpoint constraints:\"));\n        assertTrue(prompt.contains(\"Keep exact file paths.\"));\n        assertTrue(prompt.contains(\"Checkpoint blocked items:\"));\n        assertTrue(prompt.contains(\"Wait for approval before deleting files.\"));\n        assertTrue(prompt.contains(\"Checkpoint process snapshots:\"));\n        assertTrue(prompt.contains(\"proc_demo [RUNNING] npm run dev (restored snapshot)\"));\n    }\n\n    private CodingAgent newAgent(InspectableQueueModelClient modelClient,\n                                 ToolExecutor toolExecutor,\n                                 CodingAgentOptions options) throws Exception {\n        Path workspaceRoot = temporaryFolder.newFolder(\"loop-controller-workspace\").toPath();\n        WorkspaceContext workspaceContext = WorkspaceContext.builder()\n                .rootPath(workspaceRoot.toString())\n                .description(\"JUnit loop controller workspace\")\n                .build();\n        return CodingAgents.builder()\n                .modelClient(modelClient)\n                .model(\"glm-4.5-flash\")\n                .workspaceContext(workspaceContext)\n                .codingOptions(options)\n                .toolRegistry(singleToolRegistry(STUB_TOOL))\n                .toolExecutor(toolExecutor)\n                .build();\n    }\n\n    private CodingAgentOptions defaultOptions() {\n        return CodingAgentOptions.builder()\n                .autoCompactEnabled(false)\n                .autoContinueEnabled(true)\n                .maxAutoFollowUps(2)\n                .maxTotalTurns(6)\n                .build();\n    }\n\n    private AgentToolRegistry singleToolRegistry(String toolName) {\n        Tool.Function function = new Tool.Function();\n        function.setName(toolName);\n        function.setDescription(\"Stub tool for testing\");\n        return new StaticToolRegistry(Collections.<Object>singletonList(new Tool(\"function\", function)));\n    }\n\n    private ToolExecutor okToolExecutor() {\n        return new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) {\n                return \"{\\\"ok\\\":true}\";\n            }\n        };\n    }\n\n    private ToolExecutor approvalRejectedToolExecutor() {\n        return new ToolExecutor() {\n            @Override\n            public String execute(AgentToolCall call) {\n                return \"[approval-rejected] protected action\";\n            }\n        };\n    }\n\n    private AgentModelResult toolCallResult(String toolName, String callId) {\n        return AgentModelResult.builder()\n                .toolCalls(Collections.singletonList(AgentToolCall.builder()\n                        .name(toolName)\n                        .arguments(\"{}\")\n                        .callId(callId)\n                        .type(\"function\")\n                        .build()))\n                .memoryItems(Collections.<Object>emptyList())\n                .build();\n    }\n\n    private AgentModelResult assistantResult(String text) {\n        return AgentModelResult.builder()\n                .outputText(text)\n                .memoryItems(Collections.<Object>singletonList(AgentInputItem.message(\"assistant\", text)))\n                .build();\n    }\n\n    private String repeat(String text, int times) {\n        StringBuilder builder = new StringBuilder();\n        for (int i = 0; i < times; i++) {\n            builder.append(text);\n        }\n        return builder.toString();\n    }\n\n    private static AgentModelResult compactSummaryResult() {\n        return AgentModelResult.builder()\n                .outputText(\"{\\n\"\n                        + \"  \\\"goal\\\": \\\"Continue the coding task.\\\",\\n\"\n                        + \"  \\\"constraints\\\": [\\\"Preserve exact file paths.\\\"],\\n\"\n                        + \"  \\\"progress\\\": {\\n\"\n                        + \"    \\\"done\\\": [\\\"Compacted older context.\\\"],\\n\"\n                        + \"    \\\"inProgress\\\": [\\\"Resume from the checkpoint.\\\"],\\n\"\n                        + \"    \\\"blocked\\\": []\\n\"\n                        + \"  },\\n\"\n                        + \"  \\\"keyDecisions\\\": [\\\"Use the compacted checkpoint for continuation.\\\"],\\n\"\n                        + \"  \\\"nextSteps\\\": [\\\"Continue with the next concrete implementation step.\\\"],\\n\"\n                        + \"  \\\"criticalContext\\\": [\\\"Recent messages were compacted before auto-continuation.\\\"]\\n\"\n                        + \"}\")\n                .build();\n    }\n\n    private String findContinuationInstructions(List<AgentPrompt> prompts) {\n        if (prompts == null) {\n            return \"\";\n        }\n        for (AgentPrompt prompt : prompts) {\n            if (prompt != null\n                    && prompt.getInstructions() != null\n                    && prompt.getInstructions().contains(\"Internal continuation.\")) {\n                return prompt.getInstructions();\n            }\n        }\n        return \"\";\n    }\n\n    private int countUserMessages(MemorySnapshot snapshot) {\n        if (snapshot == null || snapshot.getItems() == null) {\n            return 0;\n        }\n        int count = 0;\n        for (Object item : snapshot.getItems()) {\n            if (!(item instanceof Map)) {\n                continue;\n            }\n            Object role = ((Map<?, ?>) item).get(\"role\");\n            if (\"user\".equals(role)) {\n                count += 1;\n            }\n        }\n        return count;\n    }\n\n    private static final class InspectableQueueModelClient implements AgentModelClient {\n\n        private final Deque<AgentModelResult> results = new ArrayDeque<AgentModelResult>();\n        private final List<AgentPrompt> prompts = new ArrayList<AgentPrompt>();\n\n        private void enqueue(AgentModelResult result) {\n            results.addLast(result);\n        }\n\n        private List<AgentPrompt> getPrompts() {\n            return prompts;\n        }\n\n        @Override\n        public AgentModelResult create(AgentPrompt prompt) {\n            prompts.add(prompt == null ? null : prompt.toBuilder().build());\n            if (prompt != null\n                    && prompt.getSystemPrompt() != null\n                    && prompt.getSystemPrompt().contains(\"context checkpoint summaries\")) {\n                return compactSummaryResult();\n            }\n            AgentModelResult result = results.removeFirst();\n            return result == null ? AgentModelResult.builder().build() : result;\n        }\n\n        @Override\n        public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n            AgentModelResult result = create(prompt);\n            if (listener != null && result != null && result.getOutputText() != null) {\n                listener.onDeltaText(result.getOutputText());\n                listener.onComplete(result);\n            }\n            return result;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/shell/ShellCommandSupportTest.java",
    "content": "package io.github.lnyocly.ai4j.coding.shell;\n\nimport org.junit.Test;\n\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertTrue;\n\npublic class ShellCommandSupportTest {\n\n    @Test\n    public void shouldBuildWindowsShellCommandAndGuidance() {\n        assertEquals(Arrays.asList(\"cmd.exe\", \"/c\", \"dir\"),\n                ShellCommandSupport.buildShellCommand(\"dir\", \"Windows 11\"));\n\n        String guidance = ShellCommandSupport.buildShellUsageGuidance(\"Windows 11\");\n        assertTrue(guidance.contains(\"cmd.exe /c\"));\n        assertTrue(guidance.contains(\"cat <<EOF\"));\n        assertTrue(guidance.contains(\"action=start\"));\n    }\n\n    @Test\n    public void shouldBuildPosixShellCommandAndGuidance() {\n        assertEquals(Arrays.asList(\"sh\", \"-lc\", \"pwd\"),\n                ShellCommandSupport.buildShellCommand(\"pwd\", \"Linux\"));\n\n        String guidance = ShellCommandSupport.buildShellUsageGuidance(\"Linux\");\n        assertTrue(guidance.contains(\"sh -lc\"));\n        assertTrue(guidance.contains(\"type nul > file\"));\n        assertTrue(guidance.contains(\"Get-Content\"));\n        assertTrue(guidance.contains(\"action=start\"));\n    }\n\n    @Test\n    public void shouldResolveWindowsShellCharsetFromNativeEncoding() {\n        Charset charset = ShellCommandSupport.resolveShellCharset(\n                \"Windows 11\",\n                new String[]{null, \"\"},\n                new String[]{\"GBK\", \"UTF-8\"}\n        );\n\n        assertEquals(Charset.forName(\"GBK\"), charset);\n    }\n\n    @Test\n    public void shouldPreferExplicitShellCharsetOverride() {\n        Charset charset = ShellCommandSupport.resolveShellCharset(\n                \"Windows 11\",\n                new String[]{\"UTF-8\"},\n                new String[]{\"GBK\"}\n        );\n\n        assertEquals(StandardCharsets.UTF_8, charset);\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-demo/README.md",
    "content": "# ai4j FlowGram Demo\n\n## Run\n\n```powershell\n$env:ZHIPU_API_KEY=\"your-key\"\ncmd /c \"mvn -pl ai4j-flowgram-demo -am -DskipTests package\"\njava -jar ai4j-flowgram-demo/target/ai4j-flowgram-demo-2.1.0.jar\n```\n\nThe demo exposes FlowGram REST APIs under `/flowgram`.\n\n## Quick Check\n\n```powershell\n$body = @{\n  schema = @{\n    nodes = @(\n      @{\n        id = \"start_0\"\n        type = \"Start\"\n        name = \"start_0\"\n        data = @{\n          outputs = @{\n            type = \"object\"\n            required = @(\"message\")\n            properties = @{\n              message = @{ type = \"string\" }\n            }\n          }\n        }\n      },\n      @{\n        id = \"end_0\"\n        type = \"End\"\n        name = \"end_0\"\n        data = @{\n          inputs = @{\n            type = \"object\"\n            required = @(\"result\")\n            properties = @{\n              result = @{ type = \"string\" }\n            }\n          }\n          inputsValues = @{\n            result = @{\n              type = \"ref\"\n              content = @(\"start_0\", \"message\")\n            }\n          }\n        }\n      }\n    )\n    edges = @(\n      @{\n        sourceNodeID = \"start_0\"\n        targetNodeID = \"end_0\"\n      }\n    )\n  }\n  inputs = @{\n    message = \"hello-flowgram\"\n  }\n} | ConvertTo-Json -Depth 10\n\nInvoke-RestMethod -Method Post -Uri \"http://127.0.0.1:18080/flowgram/tasks/run\" -ContentType \"application/json\" -Body $body\n```\n\n## Real LLM Example\n\n```powershell\n$body = @{\n  schema = @{\n    nodes = @(\n      @{\n        id = \"start_0\"\n        type = \"Start\"\n        name = \"start_0\"\n        data = @{\n          outputs = @{\n            type = \"object\"\n            required = @(\"message\")\n            properties = @{\n              message = @{ type = \"string\" }\n            }\n          }\n        }\n      },\n      @{\n        id = \"llm_0\"\n        type = \"LLM\"\n        name = \"llm_0\"\n        data = @{\n          inputs = @{\n            type = \"object\"\n            required = @(\"modelName\", \"prompt\")\n            properties = @{\n              modelName = @{ type = \"string\" }\n              prompt = @{ type = \"string\" }\n            }\n          }\n          outputs = @{\n            type = \"object\"\n            required = @(\"result\")\n            properties = @{\n              result = @{ type = \"string\" }\n            }\n          }\n          inputsValues = @{\n            modelName = @{\n              type = \"constant\"\n              content = \"glm-4.7\"\n            }\n            prompt = @{\n              type = \"ref\"\n              content = @(\"start_0\", \"message\")\n            }\n          }\n        }\n      },\n      @{\n        id = \"end_0\"\n        type = \"End\"\n        name = \"end_0\"\n        data = @{\n          inputs = @{\n            type = \"object\"\n            required = @(\"result\")\n            properties = @{\n              result = @{ type = \"string\" }\n            }\n          }\n          inputsValues = @{\n            result = @{\n              type = \"ref\"\n              content = @(\"llm_0\", \"result\")\n            }\n          }\n        }\n      }\n    )\n    edges = @(\n      @{\n        sourceNodeID = \"start_0\"\n        targetNodeID = \"llm_0\"\n      },\n      @{\n        sourceNodeID = \"llm_0\"\n        targetNodeID = \"end_0\"\n      }\n    )\n  }\n  inputs = @{\n    message = \"Please answer with exactly three words: FlowGram spring boot.\"\n  }\n} | ConvertTo-Json -Depth 12\n\n$run = Invoke-RestMethod -Method Post -Uri \"http://127.0.0.1:18080/flowgram/tasks/run\" -ContentType \"application/json\" -Body $body\n$result = Invoke-RestMethod -Method Get -Uri (\"http://127.0.0.1:18080/flowgram/tasks/\" + $run.taskId + \"/result\")\n```\n\nOn a verified local run with `glm-4.7`, the workflow completed with:\n\n```json\n{\n  \"status\": \"success\",\n  \"result\": \"FlowGram spring boot\"\n}\n```\n\n"
  },
  {
    "path": "ai4j-flowgram-demo/backend-18080-run.log",
    "content": "[INFO] Scanning for projects...\n[INFO] \n[INFO] ---------------< io.github.lnyo-cly:ai4j-flowgram-demo >----------------\n[INFO] Building ai4j-flowgram-demo 2.0.0\n[INFO]   from pom.xml\n[INFO] --------------------------------[ jar ]---------------------------------\n[INFO] \n[INFO] >>> spring-boot:2.3.12.RELEASE:run (default-cli) > test-compile @ ai4j-flowgram-demo >>>\n[INFO] \n[INFO] --- resources:3.3.1:resources (default-resources) @ ai4j-flowgram-demo ---\n[INFO] Copying 1 resource from src\\main\\resources to target\\classes\n[INFO] \n[INFO] --- compiler:3.8.1:compile (default-compile) @ ai4j-flowgram-demo ---\n[INFO] Nothing to compile - all classes are up to date\n[INFO] \n[INFO] --- resources:3.3.1:testResources (default-testResources) @ ai4j-flowgram-demo ---\n[INFO] skip non existing resourceDirectory G:\\My_Project\\java\\ai4j-sdk\\ai4j-flowgram-demo\\src\\test\\resources\n[INFO] \n[INFO] --- compiler:3.8.1:testCompile (default-testCompile) @ ai4j-flowgram-demo ---\n[INFO] No sources to compile\n[INFO] \n[INFO] <<< spring-boot:2.3.12.RELEASE:run (default-cli) < test-compile @ ai4j-flowgram-demo <<<\n[INFO] \n[INFO] \n[INFO] --- spring-boot:2.3.12.RELEASE:run (default-cli) @ ai4j-flowgram-demo ---\n[INFO] Attaching agents: []\n\n  .   ____          _            __ _ _\n /\\\\ / ___'_ __ _ _(_)_ __  __ _ \\ \\ \\ \\\n( ( )\\___ | '_ | '_| | '_ \\/ _` | \\ \\ \\ \\\n \\\\/  ___)| |_)| | | | | || (_| |  ) ) ) )\n  '  |____| .__|_| |_|_| |_\\__, | / / / /\n =========|_|==============|___/=/_/_/_/\n :: Spring Boot ::       (v2.3.12.RELEASE)\n\n2026-03-28 17:20:27.251  INFO 51532 --- [           main] i.g.l.a.f.demo.FlowGramDemoApplication   : Starting FlowGramDemoApplication on DESKTOP-F1VDEPJ with PID 51532 (G:\\My_Project\\java\\ai4j-sdk\\ai4j-flowgram-demo\\target\\classes started by 1 in G:\\My_Project\\java\\ai4j-sdk\\ai4j-flowgram-demo)\n2026-03-28 17:20:27.253  INFO 51532 --- [           main] i.g.l.a.f.demo.FlowGramDemoApplication   : No active profile set, falling back to default profiles: default\n2026-03-28 17:20:27.348  WARN 51532 --- [kground-preinit] o.s.h.c.j.Jackson2ObjectMapperBuilder    : For Jackson Kotlin classes support please add \"com.fasterxml.jackson.module:jackson-module-kotlin\" to the classpath\n2026-03-28 17:20:28.119  INFO 51532 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 18080 (http)\n2026-03-28 17:20:28.130  INFO 51532 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]\n2026-03-28 17:20:28.130  INFO 51532 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.46]\n2026-03-28 17:20:28.274  INFO 51532 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext\n2026-03-28 17:20:28.274  INFO 51532 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 980 ms\n2026-03-28 17:20:28.502  INFO 51532 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'\n2026-03-28 17:20:28.644  INFO 51532 --- [           main] i.g.l.ai4j.utils.ServiceLoaderUtil       : Loaded SPI implementation: DefaultDispatcherProvider\n2026-03-28 17:20:28.646  INFO 51532 --- [           main] i.g.l.ai4j.utils.ServiceLoaderUtil       : Loaded SPI implementation: DefaultConnectionPoolProvider\n2026-03-28 17:20:28.947  INFO 51532 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 18080 (http) with context path ''\n2026-03-28 17:20:28.953  INFO 51532 --- [           main] i.g.l.a.f.demo.FlowGramDemoApplication   : Started FlowGramDemoApplication in 2.019 seconds (JVM running for 2.555)\n2026-03-28 17:20:35.313  INFO 51532 --- [io-18080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'\n2026-03-28 17:20:35.313  INFO 51532 --- [io-18080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'\n2026-03-28 17:20:35.316  INFO 51532 --- [io-18080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 3 ms\n[INFO] ------------------------------------------------------------------------\n[INFO] BUILD FAILURE\n[INFO] ------------------------------------------------------------------------\n[INFO] Total time:  01:08 h\n[INFO] Finished at: 2026-03-28T18:28:45+08:00\n[INFO] ------------------------------------------------------------------------\n[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:2.3.12.RELEASE:run (default-cli) on project ai4j-flowgram-demo: Application finished with exit code: -1 -> [Help 1]\n[ERROR] \n[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.\n[ERROR] Re-run Maven using the -X switch to enable full debug logging.\n[ERROR] \n[ERROR] For more information about the errors and possible solutions, please read the following articles:\n[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException\n"
  },
  {
    "path": "ai4j-flowgram-demo/backend-18080.log",
    "content": "[INFO] Scanning for projects...\n[INFO] ------------------------------------------------------------------------\n[INFO] Reactor Build Order:\n[INFO] \n[INFO] ai4j-sdk                                                           [pom]\n[INFO] ai4j-core                                                          [jar]\n[INFO] ai4j-model                                                         [jar]\n[INFO] ai4j-agent                                                         [jar]\n[INFO] ai4j                                                               [jar]\n[INFO] ai4j-spring-boot-starter                                           [jar]\n[INFO] ai4j-flowgram-spring-boot-starter                                  [jar]\n[INFO] ai4j-flowgram-demo                                                 [jar]\n[INFO] \n[INFO] --------------------< io.github.lnyo-cly:ai4j-sdk >---------------------\n[INFO] Building ai4j-sdk 2.0.0                                            [1/8]\n[INFO]   from pom.xml\n[INFO] --------------------------------[ pom ]---------------------------------\n[INFO] \n[INFO] >>> spring-boot:2.3.12.RELEASE:run (default-cli) > test-compile @ ai4j-sdk >>>\n[INFO] \n[INFO] <<< spring-boot:2.3.12.RELEASE:run (default-cli) < test-compile @ ai4j-sdk <<<\n[INFO] \n[INFO] \n[INFO] --- spring-boot:2.3.12.RELEASE:run (default-cli) @ ai4j-sdk ---\n[INFO] ------------------------------------------------------------------------\n[INFO] Reactor Summary for ai4j-sdk 2.0.0:\n[INFO] \n[INFO] ai4j-sdk ........................................... FAILURE [  1.107 s]\n[INFO] ai4j-core .......................................... SKIPPED\n[INFO] ai4j-model ......................................... SKIPPED\n[INFO] ai4j-agent ......................................... SKIPPED\n[INFO] ai4j ............................................... SKIPPED\n[INFO] ai4j-spring-boot-starter ........................... SKIPPED\n[INFO] ai4j-flowgram-spring-boot-starter .................. SKIPPED\n[INFO] ai4j-flowgram-demo ................................. SKIPPED\n[INFO] ------------------------------------------------------------------------\n[INFO] BUILD FAILURE\n[INFO] ------------------------------------------------------------------------\n[INFO] Total time:  1.351 s\n[INFO] Finished at: 2026-03-28T17:19:49+08:00\n[INFO] ------------------------------------------------------------------------\n[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:2.3.12.RELEASE:run (default-cli) on project ai4j-sdk: Unable to find a suitable main class, please add a 'mainClass' property -> [Help 1]\n[ERROR] \n[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.\n[ERROR] Re-run Maven using the -X switch to enable full debug logging.\n[ERROR] \n[ERROR] For more information about the errors and possible solutions, please read the following articles:\n[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException\n"
  },
  {
    "path": "ai4j-flowgram-demo/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <parent>\n        <groupId>io.github.lnyo-cly</groupId>\n        <artifactId>ai4j-sdk</artifactId>\n        <version>2.3.0</version>\n    </parent>\n\n    <artifactId>ai4j-flowgram-demo</artifactId>\n    <packaging>jar</packaging>\n\n    <name>ai4j-flowgram-demo</name>\n    <description>Demo application for ai4j FlowGram Spring Boot integration.</description>\n\n    <properties>\n        <java.version>1.8</java.version>\n        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>\n        <spring-boot.version>2.3.12.RELEASE</spring-boot.version>\n    </properties>\n\n    <dependencies>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j-flowgram-spring-boot-starter</artifactId>\n            <version>${project.version}</version>\n        </dependency>\n    </dependencies>\n\n    <build>\n        <plugins>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-deploy-plugin</artifactId>\n                <version>3.1.1</version>\n                <configuration>\n                    <skip>true</skip>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-install-plugin</artifactId>\n                <version>3.1.1</version>\n                <configuration>\n                    <skip>true</skip>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-compiler-plugin</artifactId>\n                <version>3.8.1</version>\n                <configuration>\n                    <source>1.8</source>\n                    <target>1.8</target>\n                    <encoding>UTF8</encoding>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.springframework.boot</groupId>\n                <artifactId>spring-boot-maven-plugin</artifactId>\n                <version>${spring-boot.version}</version>\n                <configuration>\n                    <mainClass>io.github.lnyocly.ai4j.flowgram.demo.FlowGramDemoApplication</mainClass>\n                </configuration>\n                <executions>\n                    <execution>\n                        <goals>\n                            <goal>repackage</goal>\n                        </goals>\n                    </execution>\n                </executions>\n            </plugin>\n        </plugins>\n    </build>\n\n</project>\n\n"
  },
  {
    "path": "ai4j-flowgram-demo/src/main/java/io/github/lnyocly/ai4j/flowgram/demo/FlowGramDemoApplication.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.demo;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\n\n@SpringBootApplication\npublic class FlowGramDemoApplication {\n\n    public static void main(String[] args) {\n        SpringApplication.run(FlowGramDemoApplication.class, args);\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-demo/src/main/java/io/github/lnyocly/ai4j/flowgram/demo/FlowGramDemoMockController.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.demo;\n\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\n@RestController\n@RequestMapping(\"/flowgram/demo/mock\")\npublic class FlowGramDemoMockController {\n\n    @GetMapping(\"/weather\")\n    public Map<String, Object> weather(@RequestParam(value = \"city\", required = false) String city,\n                                       @RequestParam(value = \"date\", required = false) String date) {\n        String safeCity = isBlank(city) ? \"杭州\" : city.trim();\n        String safeDate = isBlank(date) ? \"2026-04-02\" : date.trim();\n        int seed = Math.abs((safeCity + \"|\" + safeDate).hashCode());\n\n        Map<String, Object> payload = new LinkedHashMap<String, Object>();\n        payload.put(\"city\", safeCity);\n        payload.put(\"date\", safeDate);\n        payload.put(\"weather\", pickWeather(seed));\n        payload.put(\"temperature\", pickTemperature(seed));\n        payload.put(\"advice\", pickAdvice(seed));\n        return payload;\n    }\n\n    private String pickWeather(int seed) {\n        String[] values = new String[]{\"晴\", \"多云\", \"小雨\", \"阴\"};\n        return values[seed % values.length];\n    }\n\n    private String pickTemperature(int seed) {\n        int min = 12 + (seed % 7);\n        int max = min + 5 + (seed % 4);\n        return min + \"-\" + max + \"C\";\n    }\n\n    private String pickAdvice(int seed) {\n        String[] values = new String[]{\n                \"适合按计划出行\",\n                \"建议提前十分钟进站\",\n                \"请留意沿途降雨变化\",\n                \"建议准备一件薄外套\"\n        };\n        return values[seed % values.length];\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-demo/src/main/resources/application.yml",
    "content": "server:\n  port: 18080\n\nai:\n  platforms:\n    - id: minimax-coding\n      platform: minimax\n      api-key: ${MINIMAX_API_KEY:}\n      api-host: https://api.minimax.chat/\n      chat-completion-url: v1/text/chatcompletion_v2\n    - id: glm-coding\n      platform: zhipu\n      api-key: ${ZHIPU_API_KEY:}\n      api-host: https://open.bigmodel.cn/api/paas/\n      chat-completion-url: v4/chat/completions\n\nai4j:\n  flowgram:\n    default-service-id: minimax-coding\n    api:\n      base-path: /flowgram\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-flowgram-spring-boot-starter</artifactId>\n    <packaging>jar</packaging>\n    <version>2.3.0</version>\n\n    <name>ai4j-flowgram-spring-boot-starter</name>\n    <description>ai4j FlowGram 的 Spring Boot Starter，支持流程应用与 trace 集成。 Spring Boot starter for ai4j FlowGram workflow applications and trace integration.</description>\n\n    <licenses>\n        <license>\n            <name>The Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n        </license>\n    </licenses>\n\n    <issueManagement>\n        <system>GitHub</system>\n        <url>https://github.com/LnYo-Cly/ai4j/issues</url>\n    </issueManagement>\n    <url>https://github.com/LnYo-Cly/ai4j</url>\n\n    <developers>\n        <developer>\n            <id>LnYo-Cly</id>\n            <name>LnYo-Cly</name>\n            <email>lnyocly@gmail.com</email>\n            <url>https://github.com/LnYo-Cly/ai4j</url>\n            <timezone>+8</timezone>\n        </developer>\n    </developers>\n\n    <scm>\n        <url>https://github.com/LnYo-Cly/ai4j</url>\n        <connection>scm:git:https://github.com/LnYo-Cly/ai4j.git</connection>\n        <developerConnection>scm:git:https://github.com/LnYo-Cly/ai4j.git</developerConnection>\n    </scm>\n\n    <properties>\n        <java.version>1.8</java.version>\n        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>\n        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>\n        <spring-boot.version>2.3.12.RELEASE</spring-boot.version>\n        <skipTests>true</skipTests>\n    </properties>\n\n    <dependencies>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j-agent</artifactId>\n            <version>${project.version}</version>\n            <exclusions>\n                <exclusion>\n                    <groupId>org.slf4j</groupId>\n                    <artifactId>slf4j-simple</artifactId>\n                </exclusion>\n            </exclusions>\n        </dependency>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j-spring-boot-starter</artifactId>\n            <version>${project.version}</version>\n            <exclusions>\n                <exclusion>\n                    <groupId>org.slf4j</groupId>\n                    <artifactId>slf4j-simple</artifactId>\n                </exclusion>\n            </exclusions>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-web</artifactId>\n            <version>${spring-boot.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-configuration-processor</artifactId>\n            <version>${spring-boot.version}</version>\n            <optional>true</optional>\n        </dependency>\n        <dependency>\n            <groupId>javax.annotation</groupId>\n            <artifactId>javax.annotation-api</artifactId>\n            <version>1.3.2</version>\n        </dependency>\n        <dependency>\n            <groupId>junit</groupId>\n            <artifactId>junit</artifactId>\n            <version>4.13.2</version>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>com.h2database</groupId>\n            <artifactId>h2</artifactId>\n            <version>2.2.224</version>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-starter-test</artifactId>\n            <version>${spring-boot.version}</version>\n            <scope>test</scope>\n        </dependency>\n    </dependencies>\n\n    <build>\n        <finalName>${project.name}-${project.version}</finalName>\n        <plugins>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-surefire-plugin</artifactId>\n                <version>2.12.4</version>\n                <configuration>\n                    <skipTests>${skipTests}</skipTests>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-compiler-plugin</artifactId>\n                <version>3.8.1</version>\n                <configuration>\n                    <source>1.8</source>\n                    <target>1.8</target>\n                    <encoding>UTF8</encoding>\n                    <compilerArgs>\n                        <arg>-parameters</arg>\n                    </compilerArgs>\n                    <parameters>true</parameters>\n                    <annotationProcessorPaths>\n                        <path>\n                            <groupId>org.projectlombok</groupId>\n                            <artifactId>lombok</artifactId>\n                            <version>1.18.30</version>\n                        </path>\n                    </annotationProcessorPaths>\n                </configuration>\n            </plugin>\n        </plugins>\n    </build>\n\n    <profiles>\n        <profile>\n            <id>release</id>\n            <build>\n                <plugins>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-source-plugin</artifactId>\n                        <version>3.3.1</version>\n                        <executions>\n                            <execution>\n                                <id>attach-sources</id>\n                                <goals>\n                                    <goal>jar-no-fork</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-javadoc-plugin</artifactId>\n                        <version>3.6.3</version>\n                        <executions>\n                            <execution>\n                                <id>attach-javadocs</id>\n                                <goals>\n                                    <goal>jar</goal>\n                                </goals>\n                                <configuration>\n                                    <doclint>none</doclint>\n                                    <failOnError>false</failOnError>\n                                    <tags>\n                                        <tag>\n                                            <name>Author</name>\n                                            <placement>a</placement>\n                                            <head>Author:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Description</name>\n                                            <placement>a</placement>\n                                            <head>Description:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Date</name>\n                                            <placement>a</placement>\n                                            <head>Date:</head>\n                                        </tag>\n                                    </tags>\n                                </configuration>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-gpg-plugin</artifactId>\n                        <version>1.6</version>\n                        <configuration>\n                            <executable>D:\\Develop\\DevelopEnv\\GnuPG\\bin\\gpg.exe</executable>\n                            <keyname>cly</keyname>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>sign-artifacts</id>\n                                <phase>verify</phase>\n                                <goals>\n                                    <goal>sign</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.sonatype.central</groupId>\n                        <artifactId>central-publishing-maven-plugin</artifactId>\n                        <version>0.4.0</version>\n                        <extensions>true</extensions>\n                        <configuration>\n                            <publishingServerId>LnYo-Cly</publishingServerId>\n                            <tokenAuth>true</tokenAuth>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n</project>\n\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/adapter/FlowGramProtocolAdapter.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.adapter;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskResultOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunInput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskValidateOutput;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskCancelResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskReportResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskResultResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunRequest;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateRequest;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateResponse;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class FlowGramProtocolAdapter {\n\n    public FlowGramTaskRunInput toTaskRunInput(FlowGramTaskRunRequest request) {\n        return FlowGramTaskRunInput.builder()\n                .schema(schemaToJson(request == null ? null : request.getSchema()))\n                .inputs(safeMap(request == null ? null : request.getInputs()))\n                .build();\n    }\n\n    public FlowGramTaskRunInput toTaskRunInput(FlowGramTaskValidateRequest request) {\n        return FlowGramTaskRunInput.builder()\n                .schema(schemaToJson(request == null ? null : request.getSchema()))\n                .inputs(safeMap(request == null ? null : request.getInputs()))\n                .build();\n    }\n\n    public FlowGramTaskRunResponse toRunResponse(FlowGramTaskRunOutput output) {\n        return FlowGramTaskRunResponse.builder()\n                .taskId(output == null ? null : output.getTaskID())\n                .build();\n    }\n\n    public FlowGramTaskValidateResponse toValidateResponse(FlowGramTaskValidateOutput output) {\n        return FlowGramTaskValidateResponse.builder()\n                .valid(output != null && output.isValid())\n                .errors(output == null || output.getErrors() == null\n                        ? Collections.<String>emptyList()\n                        : output.getErrors())\n                .build();\n    }\n\n    public FlowGramTaskReportResponse toReportResponse(String taskId,\n                                                       FlowGramTaskReportOutput output,\n                                                       boolean includeNodeDetails,\n                                                       FlowGramTraceView trace) {\n        Map<String, FlowGramTaskReportResponse.NodeStatus> nodes = null;\n        if (includeNodeDetails && output != null && output.getNodes() != null) {\n            nodes = new LinkedHashMap<String, FlowGramTaskReportResponse.NodeStatus>();\n            for (Map.Entry<String, FlowGramTaskReportOutput.NodeStatus> entry : output.getNodes().entrySet()) {\n                FlowGramTaskReportOutput.NodeStatus value = entry.getValue();\n                nodes.put(entry.getKey(), FlowGramTaskReportResponse.NodeStatus.builder()\n                        .status(value == null ? null : value.getStatus())\n                        .terminated(value != null && value.isTerminated())\n                        .startTime(value == null ? null : value.getStartTime())\n                        .endTime(value == null ? null : value.getEndTime())\n                        .error(value == null ? null : value.getError())\n                        .inputs(value == null ? Collections.<String, Object>emptyMap() : safeMap(value.getInputs()))\n                        .outputs(value == null ? Collections.<String, Object>emptyMap() : safeMap(value.getOutputs()))\n                        .build());\n            }\n        }\n        FlowGramTaskReportOutput.WorkflowStatus workflow = output == null ? null : output.getWorkflow();\n        return FlowGramTaskReportResponse.builder()\n                .taskId(taskId)\n                .inputs(output == null ? Collections.<String, Object>emptyMap() : safeMap(output.getInputs()))\n                .outputs(output == null ? Collections.<String, Object>emptyMap() : safeMap(output.getOutputs()))\n                .workflow(FlowGramTaskReportResponse.WorkflowStatus.builder()\n                        .status(workflow == null ? null : workflow.getStatus())\n                        .terminated(workflow != null && workflow.isTerminated())\n                        .startTime(workflow == null ? null : workflow.getStartTime())\n                        .endTime(workflow == null ? null : workflow.getEndTime())\n                        .error(workflow == null ? null : workflow.getError())\n                        .build())\n                .nodes(nodes)\n                .trace(trace)\n                .build();\n    }\n\n    public FlowGramTaskResultResponse toResultResponse(String taskId,\n                                                       FlowGramTaskResultOutput output,\n                                                       FlowGramTraceView trace) {\n        return FlowGramTaskResultResponse.builder()\n                .taskId(taskId)\n                .status(output == null ? null : output.getStatus())\n                .terminated(output != null && output.isTerminated())\n                .error(output == null ? null : output.getError())\n                .result(output == null ? Collections.<String, Object>emptyMap() : safeMap(output.getResult()))\n                .trace(trace)\n                .build();\n    }\n\n    public FlowGramTaskCancelResponse toCancelResponse(boolean success) {\n        return FlowGramTaskCancelResponse.builder()\n                .success(success)\n                .build();\n    }\n\n    private String schemaToJson(Object schema) {\n        if (schema == null) {\n            return null;\n        }\n        if (schema instanceof String) {\n            return (String) schema;\n        }\n        return JSON.toJSONString(schema);\n    }\n\n    private Map<String, Object> safeMap(Map<String, Object> source) {\n        Map<String, Object> target = new LinkedHashMap<String, Object>();\n        if (source == null) {\n            return target;\n        }\n        for (Map.Entry<String, Object> entry : source.entrySet()) {\n            target.put(entry.getKey(), copyValue(entry.getValue()));\n        }\n        return target;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private Object copyValue(Object value) {\n        if (value instanceof Map) {\n            Map<String, Object> copy = new LinkedHashMap<String, Object>();\n            Map<?, ?> source = (Map<?, ?>) value;\n            for (Map.Entry<?, ?> entry : source.entrySet()) {\n                copy.put(String.valueOf(entry.getKey()), copyValue(entry.getValue()));\n            }\n            return copy;\n        }\n        if (value instanceof List) {\n            java.util.List<Object> copy = new java.util.ArrayList<Object>();\n            for (Object item : (List<Object>) value) {\n                copy.add(copyValue(item));\n            }\n            return copy;\n        }\n        return value;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/autoconfigure/FlowGramAutoConfiguration.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.autoconfigure;\n\nimport io.github.lnyocly.ai4j.AiConfigAutoConfiguration;\nimport io.github.lnyocly.ai4j.agent.flowgram.Ai4jFlowGramLlmNodeRunner;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramLlmNodeRunner;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeListener;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeService;\nimport io.github.lnyocly.ai4j.agent.trace.TracePricingResolver;\nimport io.github.lnyocly.ai4j.flowgram.springboot.adapter.FlowGramProtocolAdapter;\nimport io.github.lnyocly.ai4j.flowgram.springboot.config.FlowGramProperties;\nimport io.github.lnyocly.ai4j.flowgram.springboot.controller.FlowGramTaskController;\nimport io.github.lnyocly.ai4j.flowgram.springboot.exception.FlowGramExceptionHandler;\nimport io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramCodeNodeExecutor;\nimport io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramHttpNodeExecutor;\nimport io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramKnowledgeRetrieveNodeExecutor;\nimport io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramToolNodeExecutor;\nimport io.github.lnyocly.ai4j.flowgram.springboot.node.FlowGramVariableNodeExecutor;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.DefaultFlowGramAccessChecker;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.DefaultFlowGramCallerResolver;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.DefaultFlowGramTaskOwnershipStrategy;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramAccessChecker;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramCallerResolver;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramTaskOwnershipStrategy;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramRuntimeFacade;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramRuntimeTraceCollector;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramTaskStore;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.InMemoryFlowGramTaskStore;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.JdbcFlowGramTaskStore;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.RegistryBackedFlowGramModelClientResolver;\nimport io.github.lnyocly.ai4j.rag.RagContextAssembler;\nimport io.github.lnyocly.ai4j.rag.Reranker;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceRegistry;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport org.springframework.beans.factory.ObjectProvider;\nimport org.springframework.beans.factory.SmartInitializingSingleton;\nimport org.springframework.boot.autoconfigure.AutoConfigureAfter;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnClass;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnBean;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;\nimport org.springframework.boot.context.properties.EnableConfigurationProperties;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.servlet.DispatcherServlet;\n\nimport javax.sql.DataSource;\n\n@Configuration\n@ConditionalOnClass(DispatcherServlet.class)\n@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)\n@ConditionalOnProperty(prefix = \"ai4j.flowgram\", name = \"enabled\", havingValue = \"true\", matchIfMissing = true)\n@AutoConfigureAfter(AiConfigAutoConfiguration.class)\n@EnableConfigurationProperties(FlowGramProperties.class)\npublic class FlowGramAutoConfiguration {\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramTaskStore flowGramTaskStore(FlowGramProperties properties,\n                                               ObjectProvider<DataSource> dataSourceProvider) {\n        String type = properties == null || properties.getTaskStore() == null\n                ? \"memory\"\n                : properties.getTaskStore().getType();\n        if (type == null || \"memory\".equalsIgnoreCase(type.trim())) {\n            return new InMemoryFlowGramTaskStore();\n        }\n        if (\"jdbc\".equalsIgnoreCase(type.trim())) {\n            DataSource dataSource = dataSourceProvider.getIfAvailable();\n            if (dataSource == null) {\n                throw new IllegalStateException(\"FlowGram jdbc task store requires a DataSource bean\");\n            }\n            FlowGramProperties.TaskStoreProperties taskStore = properties.getTaskStore();\n            return new JdbcFlowGramTaskStore(\n                    dataSource,\n                    taskStore == null ? null : taskStore.getTableName(),\n                    taskStore == null || taskStore.isInitializeSchema()\n            );\n        }\n        throw new IllegalStateException(\"Unsupported FlowGram task store type: \" + type);\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramCallerResolver flowGramCallerResolver(FlowGramProperties properties) {\n        return new DefaultFlowGramCallerResolver(properties);\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramAccessChecker flowGramAccessChecker() {\n        return new DefaultFlowGramAccessChecker();\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramTaskOwnershipStrategy flowGramTaskOwnershipStrategy(FlowGramProperties properties) {\n        return new DefaultFlowGramTaskOwnershipStrategy(properties == null ? null : properties.getTaskRetention());\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramProtocolAdapter flowGramProtocolAdapter() {\n        return new FlowGramProtocolAdapter();\n    }\n\n    @Bean\n    @ConditionalOnMissingBean(FlowGramLlmNodeRunner.class)\n    public FlowGramLlmNodeRunner flowGramLlmNodeRunner(AiServiceRegistry aiServiceRegistry,\n                                                       FlowGramProperties properties,\n                                                       ObjectProvider<TracePricingResolver> pricingResolverProvider) {\n        return new Ai4jFlowGramLlmNodeRunner(\n                new RegistryBackedFlowGramModelClientResolver(aiServiceRegistry, properties),\n                pricingResolverProvider.getIfAvailable()\n        );\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramRuntimeService flowGramRuntimeService(FlowGramLlmNodeRunner flowGramLlmNodeRunner,\n                                                         ObjectProvider<FlowGramNodeExecutor> executors) {\n        FlowGramRuntimeService runtimeService = new FlowGramRuntimeService(flowGramLlmNodeRunner);\n        for (FlowGramNodeExecutor executor : executors) {\n            runtimeService.registerNodeExecutor(executor);\n        }\n        return runtimeService;\n    }\n\n    @Bean\n    @ConditionalOnMissingBean(name = \"flowGramHttpNodeExecutor\")\n    public FlowGramNodeExecutor flowGramHttpNodeExecutor() {\n        return new FlowGramHttpNodeExecutor();\n    }\n\n    @Bean\n    @ConditionalOnMissingBean(name = \"flowGramVariableNodeExecutor\")\n    public FlowGramNodeExecutor flowGramVariableNodeExecutor() {\n        return new FlowGramVariableNodeExecutor();\n    }\n\n    @Bean\n    @ConditionalOnMissingBean(name = \"flowGramCodeNodeExecutor\")\n    public FlowGramNodeExecutor flowGramCodeNodeExecutor() {\n        return new FlowGramCodeNodeExecutor();\n    }\n\n    @Bean\n    @ConditionalOnMissingBean(name = \"flowGramToolNodeExecutor\")\n    public FlowGramNodeExecutor flowGramToolNodeExecutor() {\n        return new FlowGramToolNodeExecutor();\n    }\n\n    @Bean\n    @ConditionalOnBean(AiServiceRegistry.class)\n    @ConditionalOnSingleCandidate(VectorStore.class)\n    @ConditionalOnMissingBean(name = \"flowGramKnowledgeRetrieveNodeExecutor\")\n    public FlowGramNodeExecutor flowGramKnowledgeRetrieveNodeExecutor(AiServiceRegistry aiServiceRegistry,\n                                                                      VectorStore vectorStore,\n                                                                      Reranker ragReranker,\n                                                                      RagContextAssembler ragContextAssembler) {\n        return new FlowGramKnowledgeRetrieveNodeExecutor(\n                aiServiceRegistry,\n                vectorStore,\n                ragReranker,\n                ragContextAssembler\n        );\n    }\n\n    @Bean\n    public SmartInitializingSingleton flowGramNodeExecutorRegistrar(FlowGramRuntimeService runtimeService,\n                                                                    ObjectProvider<FlowGramNodeExecutor> executors) {\n        return new SmartInitializingSingleton() {\n            @Override\n            public void afterSingletonsInstantiated() {\n                for (FlowGramNodeExecutor executor : executors) {\n                    runtimeService.registerNodeExecutor(executor);\n                }\n            }\n        };\n    }\n\n    @Bean\n    public SmartInitializingSingleton flowGramRuntimeListenerRegistrar(FlowGramRuntimeService runtimeService,\n                                                                       ObjectProvider<FlowGramRuntimeListener> listeners) {\n        return new SmartInitializingSingleton() {\n            @Override\n            public void afterSingletonsInstantiated() {\n                for (FlowGramRuntimeListener listener : listeners) {\n                    runtimeService.registerListener(listener);\n                }\n            }\n        };\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramRuntimeTraceCollector flowGramRuntimeTraceCollector() {\n        return new FlowGramRuntimeTraceCollector();\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramRuntimeFacade flowGramRuntimeFacade(FlowGramRuntimeService runtimeService,\n                                                       FlowGramProtocolAdapter protocolAdapter,\n                                                       FlowGramTaskStore taskStore,\n                                                       FlowGramCallerResolver callerResolver,\n                                                       FlowGramAccessChecker accessChecker,\n                                                       FlowGramTaskOwnershipStrategy ownershipStrategy,\n                                                       FlowGramProperties properties,\n                                                       FlowGramRuntimeTraceCollector traceCollector) {\n        return new FlowGramRuntimeFacade(runtimeService, protocolAdapter, taskStore, callerResolver, accessChecker, ownershipStrategy, properties, traceCollector);\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramTaskController flowGramTaskController(FlowGramRuntimeFacade runtimeFacade) {\n        return new FlowGramTaskController(runtimeFacade);\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public FlowGramExceptionHandler flowGramExceptionHandler() {\n        return new FlowGramExceptionHandler();\n    }\n}\n\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/config/FlowGramProperties.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.config;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\n\n@Data\n@ConfigurationProperties(prefix = \"ai4j.flowgram\")\npublic class FlowGramProperties {\n\n    private boolean enabled = true;\n    private String defaultServiceId;\n    private boolean streamProgress = false;\n    private Duration taskRetention = Duration.ofHours(1);\n    private boolean reportNodeDetails = true;\n    private boolean traceEnabled = true;\n    private final ApiProperties api = new ApiProperties();\n    private final TaskStoreProperties taskStore = new TaskStoreProperties();\n    private final CorsProperties cors = new CorsProperties();\n    private final AuthProperties auth = new AuthProperties();\n\n    @Data\n    public static class ApiProperties {\n        private String basePath = \"/flowgram\";\n    }\n\n    @Data\n    public static class TaskStoreProperties {\n        private String type = \"memory\";\n        private String tableName = \"ai4j_flowgram_task\";\n        private boolean initializeSchema = true;\n    }\n\n    @Data\n    public static class CorsProperties {\n        private List<String> allowedOrigins = new ArrayList<String>();\n    }\n\n    @Data\n    public static class AuthProperties {\n        private boolean enabled = false;\n        private String headerName = \"Authorization\";\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/controller/FlowGramTaskController.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.controller;\n\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskCancelResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskReportResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskResultResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunRequest;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateRequest;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramRuntimeFacade;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport javax.servlet.http.HttpServletRequest;\n\n@RestController\n@RequestMapping(\"${ai4j.flowgram.api.base-path:/flowgram}\")\npublic class FlowGramTaskController {\n\n    private final FlowGramRuntimeFacade runtimeFacade;\n\n    public FlowGramTaskController(FlowGramRuntimeFacade runtimeFacade) {\n        this.runtimeFacade = runtimeFacade;\n    }\n\n    @PostMapping(\"/tasks/run\")\n    public FlowGramTaskRunResponse run(@RequestBody FlowGramTaskRunRequest request,\n                                       HttpServletRequest servletRequest) {\n        return runtimeFacade.run(request, servletRequest);\n    }\n\n    @PostMapping(\"/tasks/validate\")\n    public FlowGramTaskValidateResponse validate(@RequestBody FlowGramTaskValidateRequest request,\n                                                 HttpServletRequest servletRequest) {\n        return runtimeFacade.validate(request, servletRequest);\n    }\n\n    @GetMapping(\"/tasks/{taskId}/report\")\n    public FlowGramTaskReportResponse report(@PathVariable(\"taskId\") String taskId,\n                                             HttpServletRequest servletRequest) {\n        return runtimeFacade.report(taskId, servletRequest);\n    }\n\n    @GetMapping(\"/tasks/{taskId}/result\")\n    public FlowGramTaskResultResponse result(@PathVariable(\"taskId\") String taskId,\n                                             HttpServletRequest servletRequest) {\n        return runtimeFacade.result(taskId, servletRequest);\n    }\n\n    @PostMapping(\"/tasks/{taskId}/cancel\")\n    public FlowGramTaskCancelResponse cancel(@PathVariable(\"taskId\") String taskId,\n                                             HttpServletRequest servletRequest) {\n        return runtimeFacade.cancel(taskId, servletRequest);\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramErrorResponse.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramErrorResponse {\n\n    private String code;\n    private String message;\n    private Object details;\n    private long timestamp;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskCancelResponse.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskCancelResponse {\n\n    private boolean success;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskReportResponse.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskReportResponse {\n\n    private String taskId;\n    private Map<String, Object> inputs;\n    private Map<String, Object> outputs;\n    private WorkflowStatus workflow;\n    private Map<String, NodeStatus> nodes;\n    private FlowGramTraceView trace;\n\n    @Data\n    @Builder(toBuilder = true)\n    @NoArgsConstructor\n    @AllArgsConstructor\n    public static class WorkflowStatus {\n        private String status;\n        private boolean terminated;\n        private Long startTime;\n        private Long endTime;\n        private String error;\n    }\n\n    @Data\n    @Builder(toBuilder = true)\n    @NoArgsConstructor\n    @AllArgsConstructor\n    public static class NodeStatus {\n        private String status;\n        private boolean terminated;\n        private Long startTime;\n        private Long endTime;\n        private String error;\n        private Map<String, Object> inputs;\n        private Map<String, Object> outputs;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskResultResponse.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskResultResponse {\n\n    private String taskId;\n    private String status;\n    private boolean terminated;\n    private String error;\n    private Map<String, Object> result;\n    private FlowGramTraceView trace;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskRunRequest.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskRunRequest {\n\n    private Object schema;\n    private Map<String, Object> inputs;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskRunResponse.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskRunResponse {\n\n    private String taskId;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskValidateRequest.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskValidateRequest {\n\n    private Object schema;\n    private Map<String, Object> inputs;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTaskValidateResponse.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskValidateResponse {\n\n    private boolean valid;\n    private List<String> errors;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/dto/FlowGramTraceView.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.dto;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTraceView {\n\n    private String taskId;\n    private String status;\n    private Long startedAt;\n    private Long endedAt;\n    private SummaryView summary;\n    private List<EventView> events;\n    private Map<String, NodeView> nodes;\n\n    @Data\n    @Builder(toBuilder = true)\n    @NoArgsConstructor\n    @AllArgsConstructor\n    public static class EventView {\n        private String type;\n        private Long timestamp;\n        private String nodeId;\n        private String status;\n        private String error;\n    }\n\n    @Data\n    @Builder(toBuilder = true)\n    @NoArgsConstructor\n    @AllArgsConstructor\n    public static class NodeView {\n        private String nodeId;\n        private String status;\n        private boolean terminated;\n        private Long startedAt;\n        private Long endedAt;\n        private Long durationMillis;\n        private String error;\n        private Integer eventCount;\n        private String model;\n        private MetricsView metrics;\n    }\n\n    @Data\n    @Builder(toBuilder = true)\n    @NoArgsConstructor\n    @AllArgsConstructor\n    public static class SummaryView {\n        private Long durationMillis;\n        private Integer eventCount;\n        private Integer nodeCount;\n        private Integer terminatedNodeCount;\n        private Integer successNodeCount;\n        private Integer failedNodeCount;\n        private Integer llmNodeCount;\n        private MetricsView metrics;\n    }\n\n    @Data\n    @Builder(toBuilder = true)\n    @NoArgsConstructor\n    @AllArgsConstructor\n    public static class MetricsView {\n        private Long promptTokens;\n        private Long completionTokens;\n        private Long totalTokens;\n        private Double inputCost;\n        private Double outputCost;\n        private Double totalCost;\n        private String currency;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/exception/FlowGramAccessDeniedException.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.exception;\n\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramAction;\nimport org.springframework.http.HttpStatus;\n\npublic class FlowGramAccessDeniedException extends FlowGramApiException {\n\n    public FlowGramAccessDeniedException(FlowGramAction action) {\n        super(HttpStatus.FORBIDDEN,\n                \"FLOWGRAM_ACCESS_DENIED\",\n                \"Access denied for FlowGram action: \" + action);\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/exception/FlowGramApiException.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.exception;\n\nimport org.springframework.http.HttpStatus;\n\npublic class FlowGramApiException extends RuntimeException {\n\n    private final HttpStatus status;\n    private final String code;\n    private final Object details;\n\n    public FlowGramApiException(HttpStatus status, String code, String message) {\n        this(status, code, message, null);\n    }\n\n    public FlowGramApiException(HttpStatus status, String code, String message, Object details) {\n        super(message);\n        this.status = status;\n        this.code = code;\n        this.details = details;\n    }\n\n    public HttpStatus getStatus() {\n        return status;\n    }\n\n    public String getCode() {\n        return code;\n    }\n\n    public Object getDetails() {\n        return details;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/exception/FlowGramExceptionHandler.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.exception;\n\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramErrorResponse;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\nimport org.springframework.web.bind.annotation.RestControllerAdvice;\n\n@RestControllerAdvice\npublic class FlowGramExceptionHandler {\n\n    @ExceptionHandler(FlowGramApiException.class)\n    public ResponseEntity<FlowGramErrorResponse> handleFlowGramApiException(FlowGramApiException ex) {\n        return ResponseEntity.status(ex.getStatus())\n                .body(error(ex.getCode(), ex.getMessage(), ex.getDetails()));\n    }\n\n    @ExceptionHandler(IllegalArgumentException.class)\n    public ResponseEntity<FlowGramErrorResponse> handleIllegalArgumentException(IllegalArgumentException ex) {\n        return ResponseEntity.status(HttpStatus.BAD_REQUEST)\n                .body(error(\"FLOWGRAM_BAD_REQUEST\", ex.getMessage(), null));\n    }\n\n    @ExceptionHandler(Exception.class)\n    public ResponseEntity<FlowGramErrorResponse> handleException(Exception ex) {\n        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)\n                .body(error(\"FLOWGRAM_RUNTIME_ERROR\", ex.getMessage(), null));\n    }\n\n    private FlowGramErrorResponse error(String code, String message, Object details) {\n        return FlowGramErrorResponse.builder()\n                .code(code)\n                .message(message)\n                .details(details)\n                .timestamp(System.currentTimeMillis())\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/exception/FlowGramTaskNotFoundException.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.exception;\n\nimport org.springframework.http.HttpStatus;\n\npublic class FlowGramTaskNotFoundException extends FlowGramApiException {\n\n    public FlowGramTaskNotFoundException(String taskId) {\n        super(HttpStatus.NOT_FOUND,\n                \"FLOWGRAM_TASK_NOT_FOUND\",\n                \"FlowGram task not found: \" + taskId);\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramCodeNodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.node;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionRequest;\nimport io.github.lnyocly.ai4j.agent.codeact.CodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class FlowGramCodeNodeExecutor implements FlowGramNodeExecutor {\n\n    private final NashornCodeExecutor codeExecutor = new NashornCodeExecutor();\n\n    @Override\n    public String getType() {\n        return \"CODE\";\n    }\n\n    @Override\n    public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) {\n        Map<String, Object> nodeData = context == null || context.getNode() == null\n                ? new LinkedHashMap<String, Object>()\n                : safeMap(context.getNode().getData());\n        Map<String, Object> script = mapValue(nodeData.get(\"script\"));\n        String language = valueAsString(script == null ? null : script.get(\"language\"));\n        if (isBlank(language)) {\n            language = \"javascript\";\n        }\n        String content = valueAsString(script == null ? null : script.get(\"content\"));\n        if (isBlank(content)) {\n            throw new IllegalArgumentException(\"Code node requires script.content\");\n        }\n\n        CodeExecutionResult executionResult = codeExecutor.execute(CodeExecutionRequest.builder()\n                .language(language)\n                .code(buildScript(content, context == null ? null : context.getInputs()))\n                .timeoutMs(8000L)\n                .build());\n        if (executionResult == null) {\n            throw new IllegalStateException(\"Code node executor returned no result\");\n        }\n        if (!executionResult.isSuccess()) {\n            throw new IllegalStateException(executionResult.getError());\n        }\n\n        Map<String, Object> outputs = parseOutputs(executionResult.getResult());\n        if (executionResult.getStdout() != null && !executionResult.getStdout().trim().isEmpty()) {\n            outputs.put(\"stdout\", executionResult.getStdout());\n        }\n        return FlowGramNodeExecutionResult.builder()\n                .outputs(outputs)\n                .build();\n    }\n\n    private String buildScript(String userCode, Map<String, Object> params) {\n        String paramsJson = JSON.toJSONString(params == null ? new LinkedHashMap<String, Object>() : params);\n        String paramsLiteral = JSON.toJSONString(paramsJson);\n        StringBuilder builder = new StringBuilder();\n        builder.append(\"var params = JSON.parse(\").append(paramsLiteral).append(\");\\n\");\n        builder.append(\"var __flowgram_input = { params: params };\\n\");\n        builder.append(userCode).append(\"\\n\");\n        builder.append(\"if (typeof main === 'function') {\\n\");\n        builder.append(\"  var __flowgram_result = main(__flowgram_input);\\n\");\n        builder.append(\"  if (__flowgram_result != null && typeof __flowgram_result.then === 'function') {\\n\");\n        builder.append(\"    throw new Error('async main() is not supported in FlowGram Code node yet');\\n\");\n        builder.append(\"  }\\n\");\n        builder.append(\"  return JSON.stringify(__flowgram_result == null ? {} : __flowgram_result);\\n\");\n        builder.append(\"} else if (typeof ret !== 'undefined') {\\n\");\n        builder.append(\"  return JSON.stringify(ret == null ? {} : ret);\\n\");\n        builder.append(\"} else {\\n\");\n        builder.append(\"  return JSON.stringify({});\\n\");\n        builder.append(\"}\\n\");\n        return builder.toString();\n    }\n\n    private Map<String, Object> parseOutputs(String rawResult) {\n        if (isBlank(rawResult)) {\n            return new LinkedHashMap<String, Object>();\n        }\n        Object parsed = tryParse(rawResult);\n        if (parsed instanceof JSONObject) {\n            return new LinkedHashMap<String, Object>(((JSONObject) parsed));\n        }\n        if (parsed instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> map = (Map<String, Object>) parsed;\n            return new LinkedHashMap<String, Object>(map);\n        }\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        outputs.put(\"result\", parsed == null ? rawResult : parsed);\n        return outputs;\n    }\n\n    private Object tryParse(String raw) {\n        try {\n            return JSON.parse(raw);\n        } catch (Exception ex) {\n            return raw;\n        }\n    }\n\n    private Map<String, Object> mapValue(Object value) {\n        if (!(value instanceof Map)) {\n            return null;\n        }\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> map = (Map<String, Object>) value;\n        return map;\n    }\n\n    private Map<String, Object> safeMap(Map<String, Object> value) {\n        return value == null ? new LinkedHashMap<String, Object>() : value;\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramHttpNodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.node;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.HttpURLConnection;\nimport java.net.URL;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\npublic class FlowGramHttpNodeExecutor implements FlowGramNodeExecutor {\n\n    private final FlowGramNodeValueResolver valueResolver = new FlowGramNodeValueResolver();\n\n    @Override\n    public String getType() {\n        return \"HTTP\";\n    }\n\n    @Override\n    public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) throws Exception {\n        Map<String, Object> nodeData = context == null || context.getNode() == null\n                ? new LinkedHashMap<String, Object>()\n                : safeMap(context.getNode().getData());\n\n        Map<String, Object> api = valueResolver.resolveMap(mapValue(nodeData.get(\"api\")), context);\n        Map<String, Object> headers = valueResolver.resolveMap(mapValue(nodeData.get(\"headersValues\")), context);\n        Map<String, Object> params = valueResolver.resolveMap(mapValue(nodeData.get(\"paramsValues\")), context);\n        Map<String, Object> timeout = valueResolver.resolveMap(mapValue(nodeData.get(\"timeout\")), context);\n        Map<String, Object> body = safeMap(mapValue(nodeData.get(\"body\")));\n\n        String method = firstNonBlank(valueAsString(api.get(\"method\")), \"GET\").toUpperCase(Locale.ROOT);\n        String url = valueAsString(api.get(\"url\"));\n        if (isBlank(url)) {\n            throw new IllegalArgumentException(\"HTTP node requires api.url\");\n        }\n\n        String fullUrl = appendQueryParams(url, params);\n        int timeoutMs = intValue(timeout.get(\"timeout\"), 10000);\n        int retryTimes = Math.max(1, intValue(timeout.get(\"retryTimes\"), 1));\n\n        Exception lastError = null;\n        for (int attempt = 0; attempt < retryTimes; attempt++) {\n            try {\n                return FlowGramNodeExecutionResult.builder()\n                        .outputs(executeRequest(fullUrl, method, headers, body, timeoutMs, context))\n                        .build();\n            } catch (Exception ex) {\n                lastError = ex;\n            }\n        }\n        throw lastError == null ? new IllegalStateException(\"HTTP node execution failed\") : lastError;\n    }\n\n    private Map<String, Object> executeRequest(String fullUrl,\n                                               String method,\n                                               Map<String, Object> headers,\n                                               Map<String, Object> body,\n                                               int timeoutMs,\n                                               FlowGramNodeExecutionContext context) throws Exception {\n        HttpURLConnection connection = (HttpURLConnection) new URL(fullUrl).openConnection();\n        connection.setRequestMethod(method);\n        connection.setConnectTimeout(timeoutMs);\n        connection.setReadTimeout(timeoutMs);\n        connection.setUseCaches(false);\n        connection.setDoInput(true);\n\n        for (Map.Entry<String, Object> entry : headers.entrySet()) {\n            if (isBlank(entry.getKey()) || entry.getValue() == null) {\n                continue;\n            }\n            connection.setRequestProperty(entry.getKey(), String.valueOf(entry.getValue()));\n        }\n\n        String requestBody = buildRequestBody(body, context);\n        if (requestBody != null && allowsRequestBody(method)) {\n            connection.setDoOutput(true);\n            if (isBlank(connection.getRequestProperty(\"Content-Type\"))) {\n                String bodyType = valueAsString(body.get(\"bodyType\"));\n                if (\"JSON\".equalsIgnoreCase(bodyType)) {\n                    connection.setRequestProperty(\"Content-Type\", \"application/json; charset=UTF-8\");\n                } else if (\"raw-text\".equalsIgnoreCase(bodyType)) {\n                    connection.setRequestProperty(\"Content-Type\", \"text/plain; charset=UTF-8\");\n                }\n            }\n            byte[] payload = requestBody.getBytes(StandardCharsets.UTF_8);\n            connection.setRequestProperty(\"Content-Length\", String.valueOf(payload.length));\n            OutputStream outputStream = connection.getOutputStream();\n            try {\n                outputStream.write(payload);\n            } finally {\n                outputStream.close();\n            }\n        }\n\n        int statusCode = connection.getResponseCode();\n        String responseBody = readBody(connection, statusCode);\n        Map<String, Object> responseHeaders = new LinkedHashMap<String, Object>();\n        for (Map.Entry<String, List<String>> entry : connection.getHeaderFields().entrySet()) {\n            if (entry.getKey() == null || entry.getValue() == null) {\n                continue;\n            }\n            List<String> values = entry.getValue();\n            if (values.size() == 1) {\n                responseHeaders.put(entry.getKey(), values.get(0));\n            } else {\n                responseHeaders.put(entry.getKey(), new ArrayList<String>(values));\n            }\n        }\n\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        outputs.put(\"statusCode\", statusCode);\n        outputs.put(\"body\", responseBody);\n        outputs.put(\"headers\", responseHeaders);\n        outputs.put(\"contentType\", connection.getContentType());\n        return outputs;\n    }\n\n    private String buildRequestBody(Map<String, Object> body, FlowGramNodeExecutionContext context) {\n        if (body == null || body.isEmpty()) {\n            return null;\n        }\n        String bodyType = valueAsString(body.get(\"bodyType\"));\n        if (isBlank(bodyType) || \"none\".equalsIgnoreCase(bodyType)) {\n            return null;\n        }\n        if (\"JSON\".equalsIgnoreCase(bodyType)) {\n            Object jsonValue = valueResolver.resolve(body.get(\"json\"), context);\n            if (jsonValue == null) {\n                return null;\n            }\n            return jsonValue instanceof String ? String.valueOf(jsonValue) : JSON.toJSONString(jsonValue);\n        }\n        if (\"raw-text\".equalsIgnoreCase(bodyType)) {\n            Object rawText = valueResolver.resolve(body.get(\"rawText\"), context);\n            return rawText == null ? null : String.valueOf(rawText);\n        }\n        Object resolved = valueResolver.resolve(body, context);\n        return resolved == null ? null : JSON.toJSONString(resolved);\n    }\n\n    private String readBody(HttpURLConnection connection, int statusCode) throws Exception {\n        InputStream inputStream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream();\n        if (inputStream == null) {\n            return null;\n        }\n        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n        byte[] buffer = new byte[2048];\n        try {\n            int read;\n            while ((read = inputStream.read(buffer)) >= 0) {\n                outputStream.write(buffer, 0, read);\n            }\n        } finally {\n            inputStream.close();\n        }\n        return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);\n    }\n\n    private String appendQueryParams(String rawUrl, Map<String, Object> params) throws Exception {\n        if (params == null || params.isEmpty()) {\n            return rawUrl;\n        }\n        StringBuilder builder = new StringBuilder(rawUrl);\n        builder.append(rawUrl.contains(\"?\") ? '&' : '?');\n        boolean first = true;\n        for (Map.Entry<String, Object> entry : params.entrySet()) {\n            if (isBlank(entry.getKey()) || entry.getValue() == null) {\n                continue;\n            }\n            if (!first) {\n                builder.append('&');\n            }\n            builder.append(encode(entry.getKey()))\n                    .append('=')\n                    .append(encode(String.valueOf(entry.getValue())));\n            first = false;\n        }\n        return builder.toString();\n    }\n\n    private String encode(String value) throws Exception {\n        return URLEncoder.encode(value, \"UTF-8\").replace(\"+\", \"%20\");\n    }\n\n    private boolean allowsRequestBody(String method) {\n        return !\"GET\".equals(method) && !\"HEAD\".equals(method);\n    }\n\n    private Map<String, Object> mapValue(Object value) {\n        if (!(value instanceof Map)) {\n            return null;\n        }\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> map = (Map<String, Object>) value;\n        return map;\n    }\n\n    private Map<String, Object> safeMap(Map<String, Object> value) {\n        return value == null ? new LinkedHashMap<String, Object>() : value;\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private int intValue(Object value, int defaultValue) {\n        if (value == null) {\n            return defaultValue;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).intValue();\n        }\n        try {\n            return Integer.parseInt(String.valueOf(value).trim());\n        } catch (Exception ex) {\n            return defaultValue;\n        }\n    }\n\n    private String firstNonBlank(String first, String fallback) {\n        return isBlank(first) ? fallback : first;\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramKnowledgeRetrieveNodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.node;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor;\nimport io.github.lnyocly.ai4j.rag.DefaultRagService;\nimport io.github.lnyocly.ai4j.rag.DenseRetriever;\nimport io.github.lnyocly.ai4j.rag.RagCitation;\nimport io.github.lnyocly.ai4j.rag.RagContextAssembler;\nimport io.github.lnyocly.ai4j.rag.RagHit;\nimport io.github.lnyocly.ai4j.rag.RagQuery;\nimport io.github.lnyocly.ai4j.rag.RagResult;\nimport io.github.lnyocly.ai4j.rag.RagService;\nimport io.github.lnyocly.ai4j.rag.RagScoreDetail;\nimport io.github.lnyocly.ai4j.rag.RagTrace;\nimport io.github.lnyocly.ai4j.rag.Reranker;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceRegistry;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\n\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class FlowGramKnowledgeRetrieveNodeExecutor implements FlowGramNodeExecutor {\n\n    private final AiServiceRegistry aiServiceRegistry;\n    private final VectorStore vectorStore;\n    private final Reranker reranker;\n    private final RagContextAssembler contextAssembler;\n\n    public FlowGramKnowledgeRetrieveNodeExecutor(AiServiceRegistry aiServiceRegistry,\n                                                 VectorStore vectorStore,\n                                                 Reranker reranker,\n                                                 RagContextAssembler contextAssembler) {\n        this.aiServiceRegistry = aiServiceRegistry;\n        this.vectorStore = vectorStore;\n        this.reranker = reranker;\n        this.contextAssembler = contextAssembler;\n    }\n\n    @Override\n    public String getType() {\n        return \"KNOWLEDGE\";\n    }\n\n    @Override\n    public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) throws Exception {\n        Map<String, Object> inputs = context == null || context.getInputs() == null\n                ? new LinkedHashMap<String, Object>()\n                : context.getInputs();\n\n        String serviceId = requiredString(inputs, \"serviceId\");\n        String embeddingModel = requiredString(inputs, \"embeddingModel\");\n        String dataset = firstNonBlank(valueAsString(inputs.get(\"dataset\")), valueAsString(inputs.get(\"namespace\")));\n        if (dataset == null || dataset.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"Knowledge node requires dataset or namespace\");\n        }\n        String query = requiredString(inputs, \"query\");\n        int topK = intValue(inputs.get(\"topK\"), 5);\n        int finalTopK = intValue(inputs.get(\"finalTopK\"), topK);\n        String delimiter = valueAsString(inputs.get(\"delimiter\"));\n        if (delimiter == null) {\n            delimiter = \"\\n\\n\";\n        }\n        Map<String, Object> filter = mapValue(inputs.get(\"filter\"));\n\n        IEmbeddingService embeddingService = aiServiceRegistry.getEmbeddingService(serviceId);\n        RagService ragService = new DefaultRagService(\n                new DenseRetriever(embeddingService, vectorStore),\n                reranker,\n                contextAssembler\n        );\n        RagResult ragResult = ragService.search(RagQuery.builder()\n                .query(query)\n                .embeddingModel(embeddingModel)\n                .dataset(dataset)\n                .topK(topK)\n                .finalTopK(finalTopK)\n                .filter(filter)\n                .delimiter(delimiter)\n                .build());\n        List<Map<String, Object>> matches = mapHits(ragResult == null ? null : ragResult.getHits());\n        List<Map<String, Object>> citations = mapCitations(ragResult == null ? null : ragResult.getCitations());\n        Map<String, Object> trace = mapTrace(ragResult == null ? null : ragResult.getTrace());\n\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        outputs.put(\"matches\", matches);\n        outputs.put(\"hits\", matches);\n        outputs.put(\"context\", ragResult == null ? \"\" : ragResult.getContext());\n        outputs.put(\"citations\", citations);\n        outputs.put(\"sources\", citations);\n        outputs.put(\"trace\", trace);\n        outputs.put(\"retrievedHits\", trace.get(\"retrievedHits\"));\n        outputs.put(\"rerankedHits\", trace.get(\"rerankedHits\"));\n        outputs.put(\"count\", matches.size());\n        return FlowGramNodeExecutionResult.builder()\n                .outputs(outputs)\n                .build();\n    }\n\n    private String requiredString(Map<String, Object> inputs, String key) {\n        String value = valueAsString(inputs.get(key));\n        if (value == null || value.trim().isEmpty()) {\n            throw new IllegalArgumentException(\"Knowledge node requires \" + key);\n        }\n        return value.trim();\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private int intValue(Object value, int defaultValue) {\n        if (value == null) {\n            return defaultValue;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).intValue();\n        }\n        try {\n            return Integer.parseInt(String.valueOf(value).trim());\n        } catch (Exception ex) {\n            return defaultValue;\n        }\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private Map<String, Object> mapValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Map) {\n            return new LinkedHashMap<String, Object>((Map<String, Object>) value);\n        }\n        String text = valueAsString(value);\n        if (text == null || text.trim().isEmpty()) {\n            return null;\n        }\n        try {\n            return JSON.parseObject(text, LinkedHashMap.class);\n        } catch (Exception ignore) {\n            return null;\n        }\n    }\n\n    private List<Map<String, Object>> mapHits(List<RagHit> hits) {\n        if (hits == null || hits.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Map<String, Object>> results = new ArrayList<Map<String, Object>>();\n        for (RagHit hit : hits) {\n            if (hit == null) {\n                continue;\n            }\n            Map<String, Object> item = new LinkedHashMap<String, Object>();\n            item.put(\"id\", hit.getId());\n            item.put(\"score\", hit.getScore());\n            item.put(\"rank\", hit.getRank());\n            item.put(\"retrieverSource\", hit.getRetrieverSource());\n            item.put(\"retrievalScore\", hit.getRetrievalScore());\n            item.put(\"fusionScore\", hit.getFusionScore());\n            item.put(\"rerankScore\", hit.getRerankScore());\n            item.put(\"content\", hit.getContent());\n            item.put(\"metadata\", hit.getMetadata());\n            item.put(\"documentId\", hit.getDocumentId());\n            item.put(\"sourceName\", hit.getSourceName());\n            item.put(\"sourcePath\", hit.getSourcePath());\n            item.put(\"sourceUri\", hit.getSourceUri());\n            item.put(\"pageNumber\", hit.getPageNumber());\n            item.put(\"sectionTitle\", hit.getSectionTitle());\n            item.put(\"chunkIndex\", hit.getChunkIndex());\n            item.put(\"scoreDetails\", mapScoreDetails(hit.getScoreDetails()));\n            results.add(item);\n        }\n        return results;\n    }\n\n    private List<Map<String, Object>> mapCitations(List<RagCitation> citations) {\n        if (citations == null || citations.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Map<String, Object>> results = new ArrayList<Map<String, Object>>();\n        for (RagCitation citation : citations) {\n            if (citation == null) {\n                continue;\n            }\n            Map<String, Object> item = new LinkedHashMap<String, Object>();\n            item.put(\"citationId\", citation.getCitationId());\n            item.put(\"sourceName\", citation.getSourceName());\n            item.put(\"sourcePath\", citation.getSourcePath());\n            item.put(\"sourceUri\", citation.getSourceUri());\n            item.put(\"pageNumber\", citation.getPageNumber());\n            item.put(\"sectionTitle\", citation.getSectionTitle());\n            item.put(\"snippet\", citation.getSnippet());\n            results.add(item);\n        }\n        return results;\n    }\n\n    private Map<String, Object> mapTrace(RagTrace trace) {\n        Map<String, Object> result = new LinkedHashMap<String, Object>();\n        if (trace == null) {\n            result.put(\"retrievedHits\", Collections.emptyList());\n            result.put(\"rerankedHits\", Collections.emptyList());\n            return result;\n        }\n        result.put(\"retrievedHits\", mapHits(trace.getRetrievedHits()));\n        result.put(\"rerankedHits\", mapHits(trace.getRerankedHits()));\n        return result;\n    }\n\n    private List<Map<String, Object>> mapScoreDetails(List<RagScoreDetail> details) {\n        if (details == null || details.isEmpty()) {\n            return Collections.emptyList();\n        }\n        List<Map<String, Object>> results = new ArrayList<Map<String, Object>>();\n        for (RagScoreDetail detail : details) {\n            if (detail == null) {\n                continue;\n            }\n            Map<String, Object> item = new LinkedHashMap<String, Object>();\n            item.put(\"source\", detail.getSource());\n            item.put(\"rank\", detail.getRank());\n            item.put(\"retrievalScore\", detail.getRetrievalScore());\n            item.put(\"fusionContribution\", detail.getFusionContribution());\n            results.add(item);\n        }\n        return results;\n    }\n}\n\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramNodeValueResolver.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.node;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\nclass FlowGramNodeValueResolver {\n\n    public Object resolve(Object value, FlowGramNodeExecutionContext context) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof List) {\n            return resolveList((List<?>) value, context);\n        }\n        if (!(value instanceof Map)) {\n            return copyValue(value);\n        }\n        Map<String, Object> valueMap = mapValue(value);\n        if (valueMap == null) {\n            return copyValue(value);\n        }\n        String type = normalizeType(valueAsString(valueMap.get(\"type\")));\n        if (\"REF\".equals(type)) {\n            return resolveReference(valueMap.get(\"content\"), context);\n        }\n        if (\"CONSTANT\".equals(type)) {\n            return copyValue(valueMap.get(\"content\"));\n        }\n        if (\"TEMPLATE\".equals(type)) {\n            Object content = valueMap.get(\"content\");\n            if (content instanceof String) {\n                return renderTemplate((String) content, context);\n            }\n            return resolve(content, context);\n        }\n        if (\"EXPRESSION\".equals(type)) {\n            return evaluateExpression(valueAsString(valueMap.get(\"content\")), context);\n        }\n        return resolveMap(valueMap, context);\n    }\n\n    public Map<String, Object> resolveMap(Map<String, Object> raw, FlowGramNodeExecutionContext context) {\n        Map<String, Object> resolved = new LinkedHashMap<String, Object>();\n        if (raw == null) {\n            return resolved;\n        }\n        for (Map.Entry<String, Object> entry : raw.entrySet()) {\n            resolved.put(entry.getKey(), resolve(entry.getValue(), context));\n        }\n        return resolved;\n    }\n\n    public List<Object> resolveList(List<?> raw, FlowGramNodeExecutionContext context) {\n        List<Object> resolved = new ArrayList<Object>();\n        if (raw == null) {\n            return resolved;\n        }\n        for (Object item : raw) {\n            resolved.add(resolve(item, context));\n        }\n        return resolved;\n    }\n\n    public String renderTemplate(String template, FlowGramNodeExecutionContext context) {\n        if (template == null) {\n            return null;\n        }\n        String rendered = template;\n        rendered = replaceTemplatePattern(rendered, \"${\", \"}\", context);\n        rendered = replaceTemplatePattern(rendered, \"{{\", \"}}\", context);\n        return rendered;\n    }\n\n    private String replaceTemplatePattern(String template,\n                                          String prefix,\n                                          String suffix,\n                                          FlowGramNodeExecutionContext context) {\n        String rendered = template;\n        int start = rendered.indexOf(prefix);\n        while (start >= 0) {\n            int end = rendered.indexOf(suffix, start + prefix.length());\n            if (end < 0) {\n                break;\n            }\n            String expression = rendered.substring(start + prefix.length(), end).trim();\n            Object value = resolvePathExpression(expression, context);\n            rendered = rendered.substring(0, start)\n                    + (value == null ? \"\" : String.valueOf(value))\n                    + rendered.substring(end + suffix.length());\n            start = rendered.indexOf(prefix, start);\n        }\n        return rendered;\n    }\n\n    private Object evaluateExpression(String expression, FlowGramNodeExecutionContext context) {\n        if (isBlank(expression)) {\n            return expression;\n        }\n        String trimmed = expression.trim();\n        if (trimmed.startsWith(\"${\") && trimmed.endsWith(\"}\")) {\n            return resolvePathExpression(trimmed.substring(2, trimmed.length() - 1), context);\n        }\n        if (trimmed.startsWith(\"{{\") && trimmed.endsWith(\"}}\")) {\n            return resolvePathExpression(trimmed.substring(2, trimmed.length() - 2), context);\n        }\n        if (\"true\".equalsIgnoreCase(trimmed) || \"false\".equalsIgnoreCase(trimmed)) {\n            return Boolean.parseBoolean(trimmed);\n        }\n        Double number = valueAsDouble(trimmed);\n        if (number != null) {\n            return trimmed.contains(\".\") ? number : Integer.valueOf(number.intValue());\n        }\n        return renderTemplate(trimmed, context);\n    }\n\n    private Object resolvePathExpression(String expression, FlowGramNodeExecutionContext context) {\n        if (isBlank(expression)) {\n            return null;\n        }\n        List<Object> path = new ArrayList<Object>();\n        for (String segment : expression.split(\"\\\\.\")) {\n            String trimmed = segment.trim();\n            if (!trimmed.isEmpty()) {\n                path.add(trimmed);\n            }\n        }\n        return resolveReference(path, context);\n    }\n\n    private Object resolveReference(Object content, FlowGramNodeExecutionContext context) {\n        List<Object> path = objectList(content);\n        if (path.isEmpty()) {\n            return null;\n        }\n        Object current = resolveRootReference(path.get(0), context);\n        for (int i = 1; i < path.size(); i++) {\n            current = descend(current, path.get(i));\n            if (current == null) {\n                return null;\n            }\n        }\n        return current;\n    }\n\n    private Object resolveRootReference(Object segment, FlowGramNodeExecutionContext context) {\n        String key = valueAsString(segment);\n        if (isBlank(key)) {\n            return null;\n        }\n        if (\"locals\".equals(key)) {\n            return context == null ? null : context.getLocals();\n        }\n        if (context != null && context.getLocals() != null && context.getLocals().containsKey(key)) {\n            return context.getLocals().get(key);\n        }\n        if (\"inputs\".equals(key) || \"taskInputs\".equals(key) || \"$inputs\".equals(key)) {\n            return context == null ? null : context.getTaskInputs();\n        }\n        if (context != null && context.getInputs() != null && context.getInputs().containsKey(key)) {\n            return context.getInputs().get(key);\n        }\n        return context == null ? null : safeMap(context.getNodeOutputs()).get(key);\n    }\n\n    private Object descend(Object current, Object segment) {\n        if (current == null || segment == null) {\n            return null;\n        }\n        if (current instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> map = (Map<String, Object>) current;\n            return map.get(String.valueOf(segment));\n        }\n        if (current instanceof List) {\n            Integer index = valueAsInteger(segment);\n            if (index == null) {\n                return null;\n            }\n            List<?> list = (List<?>) current;\n            return index >= 0 && index < list.size() ? list.get(index) : null;\n        }\n        return null;\n    }\n\n    private List<Object> objectList(Object value) {\n        List<Object> result = new ArrayList<Object>();\n        if (value instanceof List) {\n            result.addAll((List<?>) value);\n            return result;\n        }\n        String text = valueAsString(value);\n        if (!isBlank(text)) {\n            for (String segment : text.split(\"\\\\.\")) {\n                String trimmed = segment.trim();\n                if (!trimmed.isEmpty()) {\n                    result.add(trimmed);\n                }\n            }\n        }\n        return result;\n    }\n\n    private Map<String, Object> mapValue(Object value) {\n        if (!(value instanceof Map)) {\n            return null;\n        }\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> cast = (Map<String, Object>) value;\n        return cast;\n    }\n\n    private Map<String, Object> safeMap(Map<String, Object> value) {\n        return value == null ? new LinkedHashMap<String, Object>() : value;\n    }\n\n    private Object copyValue(Object value) {\n        if (value instanceof Map) {\n            Map<String, Object> copy = new LinkedHashMap<String, Object>();\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> source = (Map<String, Object>) value;\n            for (Map.Entry<String, Object> entry : source.entrySet()) {\n                copy.put(entry.getKey(), copyValue(entry.getValue()));\n            }\n            return copy;\n        }\n        if (value instanceof List) {\n            List<Object> copy = new ArrayList<Object>();\n            for (Object item : (List<?>) value) {\n                copy.add(copyValue(item));\n            }\n            return copy;\n        }\n        return value;\n    }\n\n    private String normalizeType(String type) {\n        return type == null ? \"\" : type.trim().toUpperCase();\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private Integer valueAsInteger(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).intValue();\n        }\n        String text = String.valueOf(value).trim();\n        if (text.isEmpty()) {\n            return null;\n        }\n        try {\n            return Integer.parseInt(text);\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    private Double valueAsDouble(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return ((Number) value).doubleValue();\n        }\n        String text = String.valueOf(value).trim();\n        if (text.isEmpty()) {\n            return null;\n        }\n        try {\n            return Double.parseDouble(text);\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramToolNodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.node;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor;\nimport io.github.lnyocly.ai4j.agent.tool.AgentToolCall;\nimport io.github.lnyocly.ai4j.agent.tool.ToolExecutor;\nimport io.github.lnyocly.ai4j.agent.tool.ToolUtilExecutor;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class FlowGramToolNodeExecutor implements FlowGramNodeExecutor {\n\n    @Override\n    public String getType() {\n        return \"TOOL\";\n    }\n\n    @Override\n    public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) throws Exception {\n        Map<String, Object> inputs = context == null || context.getInputs() == null\n                ? new LinkedHashMap<String, Object>()\n                : new LinkedHashMap<String, Object>(context.getInputs());\n\n        String toolName = trimToNull(valueAsString(inputs.remove(\"toolName\")));\n        if (toolName == null) {\n            throw new IllegalArgumentException(\"Tool node requires toolName\");\n        }\n\n        Object argumentsValue = inputs.remove(\"argumentsJson\");\n        String argumentsJson;\n        if (argumentsValue == null) {\n            argumentsJson = JSON.toJSONString(inputs);\n        } else if (argumentsValue instanceof String) {\n            String text = ((String) argumentsValue).trim();\n            argumentsJson = text.isEmpty() ? \"{}\" : text;\n        } else {\n            argumentsJson = JSON.toJSONString(argumentsValue);\n        }\n\n        String rawOutput = tryExecuteBuiltinDemoTool(toolName, argumentsJson);\n        if (rawOutput == null) {\n            ToolExecutor executor = new ToolUtilExecutor(Collections.singleton(toolName));\n            rawOutput = executor.execute(AgentToolCall.builder()\n                    .name(toolName)\n                    .arguments(argumentsJson)\n                    .type(\"function\")\n                    .callId(context == null ? null : context.getTaskId())\n                    .build());\n        }\n\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        outputs.put(\"toolName\", toolName);\n        outputs.put(\"rawOutput\", rawOutput);\n\n        Object parsed = tryParse(rawOutput);\n        if (parsed instanceof Map) {\n            @SuppressWarnings(\"unchecked\")\n            Map<String, Object> parsedMap = (Map<String, Object>) parsed;\n            outputs.put(\"data\", parsedMap);\n            if (!parsedMap.containsKey(\"result\")) {\n                outputs.put(\"result\", rawOutput);\n            }\n            outputs.putAll(parsedMap);\n        } else {\n            outputs.put(\"result\", parsed == null ? rawOutput : parsed);\n        }\n        return FlowGramNodeExecutionResult.builder()\n                .outputs(outputs)\n                .build();\n    }\n\n    private String tryExecuteBuiltinDemoTool(String toolName, String argumentsJson) {\n        if (\"queryTrainInfo\".equals(toolName)) {\n            Integer type = extractIntegerArgument(argumentsJson, \"type\");\n            return type != null && type.intValue() > 35\n                    ? \"天气情况正常，允许发车\"\n                    : \"天气情况较差，不允许发车\";\n        }\n        return null;\n    }\n\n    private Object tryParse(String raw) {\n        if (raw == null) {\n            return null;\n        }\n        try {\n            return JSON.parse(raw);\n        } catch (Exception ex) {\n            return raw;\n        }\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n\n    private Integer extractIntegerArgument(String argumentsJson, String key) {\n        try {\n            Map<String, Object> map = JSON.parseObject(argumentsJson);\n            if (map == null) {\n                return null;\n            }\n            Object value = map.get(key);\n            if (value instanceof Number) {\n                return ((Number) value).intValue();\n            }\n            if (value == null) {\n                return null;\n            }\n            return Integer.valueOf(String.valueOf(value));\n        } catch (Exception ex) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramVariableNodeExecutor.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.node;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor;\n\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class FlowGramVariableNodeExecutor implements FlowGramNodeExecutor {\n\n    private final FlowGramNodeValueResolver valueResolver = new FlowGramNodeValueResolver();\n\n    @Override\n    public String getType() {\n        return \"VARIABLE\";\n    }\n\n    @Override\n    public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) {\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        List<Object> assigns = valueResolver.resolveList(listValue(dataValue(context, \"assign\")), context);\n        for (Object assignObject : assigns) {\n            Map<String, Object> assign = mapValue(assignObject);\n            if (assign == null) {\n                continue;\n            }\n            String left = valueAsString(assign.get(\"left\"));\n            if (isBlank(left)) {\n                continue;\n            }\n            outputs.put(left, assign.get(\"right\"));\n        }\n        if (outputs.isEmpty() && context != null && context.getInputs() != null) {\n            outputs.putAll(context.getInputs());\n        }\n        return FlowGramNodeExecutionResult.builder()\n                .outputs(outputs)\n                .build();\n    }\n\n    private Object dataValue(FlowGramNodeExecutionContext context, String key) {\n        Map<String, Object> data = context == null || context.getNode() == null ? null : context.getNode().getData();\n        return data == null ? null : data.get(key);\n    }\n\n    private List<Object> listValue(Object value) {\n        if (value instanceof List) {\n            @SuppressWarnings(\"unchecked\")\n            List<Object> list = (List<Object>) value;\n            return list;\n        }\n        return null;\n    }\n\n    private Map<String, Object> mapValue(Object value) {\n        if (!(value instanceof Map)) {\n            return null;\n        }\n        @SuppressWarnings(\"unchecked\")\n        Map<String, Object> map = (Map<String, Object>) value;\n        return map;\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/DefaultFlowGramAccessChecker.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramStoredTask;\n\npublic class DefaultFlowGramAccessChecker implements FlowGramAccessChecker {\n\n    @Override\n    public boolean isAllowed(FlowGramAction action, FlowGramCaller caller, FlowGramStoredTask task) {\n        return true;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/DefaultFlowGramCallerResolver.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\nimport io.github.lnyocly.ai4j.flowgram.springboot.config.FlowGramProperties;\n\nimport javax.servlet.http.HttpServletRequest;\nimport java.util.Collections;\n\npublic class DefaultFlowGramCallerResolver implements FlowGramCallerResolver {\n\n    private static final String DEFAULT_TENANT_HEADER = \"X-Tenant-Id\";\n\n    private final FlowGramProperties properties;\n\n    public DefaultFlowGramCallerResolver(FlowGramProperties properties) {\n        this.properties = properties;\n    }\n\n    @Override\n    public FlowGramCaller resolve(HttpServletRequest request) {\n        if (request == null || properties == null || properties.getAuth() == null || !properties.getAuth().isEnabled()) {\n            return anonymousCaller();\n        }\n        String callerId = trimToNull(request.getHeader(properties.getAuth().getHeaderName()));\n        String tenantId = trimToNull(request.getHeader(DEFAULT_TENANT_HEADER));\n        if (callerId == null) {\n            return anonymousCaller();\n        }\n        return FlowGramCaller.builder()\n                .callerId(callerId)\n                .tenantId(tenantId)\n                .anonymous(false)\n                .attributes(Collections.<String, Object>emptyMap())\n                .build();\n    }\n\n    private FlowGramCaller anonymousCaller() {\n        return FlowGramCaller.builder()\n                .callerId(\"anonymous\")\n                .anonymous(true)\n                .attributes(Collections.<String, Object>emptyMap())\n                .build();\n    }\n\n    private String trimToNull(String value) {\n        if (value == null) {\n            return null;\n        }\n        String trimmed = value.trim();\n        return trimmed.isEmpty() ? null : trimmed;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/DefaultFlowGramTaskOwnershipStrategy.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\nimport java.time.Duration;\n\npublic class DefaultFlowGramTaskOwnershipStrategy implements FlowGramTaskOwnershipStrategy {\n\n    private final Duration retention;\n\n    public DefaultFlowGramTaskOwnershipStrategy(Duration retention) {\n        this.retention = retention;\n    }\n\n    @Override\n    public FlowGramTaskOwnership createOwnership(String taskId, FlowGramCaller caller) {\n        long createdAt = System.currentTimeMillis();\n        long expiresAt = createdAt + Math.max(retention == null ? 0L : retention.toMillis(), 0L);\n        return FlowGramTaskOwnership.builder()\n                .creatorId(caller == null ? null : caller.getCallerId())\n                .tenantId(caller == null ? null : caller.getTenantId())\n                .createdAt(createdAt)\n                .expiresAt(expiresAt)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramAccessChecker.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramStoredTask;\n\npublic interface FlowGramAccessChecker {\n\n    boolean isAllowed(FlowGramAction action, FlowGramCaller caller, FlowGramStoredTask task);\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramAction.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\npublic enum FlowGramAction {\n    RUN,\n    VALIDATE,\n    REPORT,\n    RESULT,\n    CANCEL\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramCaller.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramCaller {\n\n    private String callerId;\n    private String tenantId;\n    private boolean anonymous;\n    private Map<String, Object> attributes;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramCallerResolver.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\nimport javax.servlet.http.HttpServletRequest;\n\npublic interface FlowGramCallerResolver {\n\n    FlowGramCaller resolve(HttpServletRequest request);\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramTaskOwnership.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramTaskOwnership {\n\n    private String creatorId;\n    private String tenantId;\n    private Long createdAt;\n    private Long expiresAt;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/security/FlowGramTaskOwnershipStrategy.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.security;\n\npublic interface FlowGramTaskOwnershipStrategy {\n\n    FlowGramTaskOwnership createOwnership(String taskId, FlowGramCaller caller);\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramRuntimeFacade.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeService;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskCancelOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskResultOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskRunOutput;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskValidateOutput;\nimport io.github.lnyocly.ai4j.flowgram.springboot.adapter.FlowGramProtocolAdapter;\nimport io.github.lnyocly.ai4j.flowgram.springboot.config.FlowGramProperties;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskCancelResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskReportResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskResultResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunRequest;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateRequest;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView;\nimport io.github.lnyocly.ai4j.flowgram.springboot.exception.FlowGramAccessDeniedException;\nimport io.github.lnyocly.ai4j.flowgram.springboot.exception.FlowGramTaskNotFoundException;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramAccessChecker;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramAction;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramCaller;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramCallerResolver;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramTaskOwnership;\nimport io.github.lnyocly.ai4j.flowgram.springboot.security.FlowGramTaskOwnershipStrategy;\n\nimport javax.servlet.http.HttpServletRequest;\nimport java.util.Collections;\n\npublic class FlowGramRuntimeFacade {\n\n    private final FlowGramRuntimeService runtimeService;\n    private final FlowGramProtocolAdapter protocolAdapter;\n    private final FlowGramTaskStore taskStore;\n    private final FlowGramCallerResolver callerResolver;\n    private final FlowGramAccessChecker accessChecker;\n    private final FlowGramTaskOwnershipStrategy ownershipStrategy;\n    private final FlowGramProperties properties;\n    private final FlowGramRuntimeTraceCollector traceCollector;\n    private final FlowGramTraceResponseEnricher traceResponseEnricher = new FlowGramTraceResponseEnricher();\n\n    public FlowGramRuntimeFacade(FlowGramRuntimeService runtimeService,\n                                 FlowGramProtocolAdapter protocolAdapter,\n                                 FlowGramTaskStore taskStore,\n                                 FlowGramCallerResolver callerResolver,\n                                 FlowGramAccessChecker accessChecker,\n                                 FlowGramTaskOwnershipStrategy ownershipStrategy,\n                                 FlowGramProperties properties,\n                                 FlowGramRuntimeTraceCollector traceCollector) {\n        this.runtimeService = runtimeService;\n        this.protocolAdapter = protocolAdapter;\n        this.taskStore = taskStore;\n        this.callerResolver = callerResolver;\n        this.accessChecker = accessChecker;\n        this.ownershipStrategy = ownershipStrategy;\n        this.properties = properties;\n        this.traceCollector = traceCollector;\n    }\n\n    public FlowGramTaskRunResponse run(FlowGramTaskRunRequest request, HttpServletRequest servletRequest) {\n        FlowGramCaller caller = resolveCaller(servletRequest);\n        ensureAllowed(FlowGramAction.RUN, caller, null);\n        FlowGramTaskRunOutput output = runtimeService.runTask(protocolAdapter.toTaskRunInput(request));\n        FlowGramTaskOwnership ownership = ownershipStrategy.createOwnership(output.getTaskID(), caller);\n        taskStore.save(FlowGramStoredTask.builder()\n                .taskId(output.getTaskID())\n                .creatorId(ownership == null ? null : ownership.getCreatorId())\n                .tenantId(ownership == null ? null : ownership.getTenantId())\n                .createdAt(ownership == null ? null : ownership.getCreatedAt())\n                .expiresAt(ownership == null ? null : ownership.getExpiresAt())\n                .status(\"pending\")\n                .terminated(false)\n                .resultSnapshot(Collections.<String, Object>emptyMap())\n                .build());\n        return protocolAdapter.toRunResponse(output);\n    }\n\n    public FlowGramTaskValidateResponse validate(FlowGramTaskValidateRequest request, HttpServletRequest servletRequest) {\n        FlowGramCaller caller = resolveCaller(servletRequest);\n        ensureAllowed(FlowGramAction.VALIDATE, caller, null);\n        FlowGramTaskValidateOutput output = runtimeService.validateTask(protocolAdapter.toTaskRunInput(request));\n        return protocolAdapter.toValidateResponse(output);\n    }\n\n    public FlowGramTaskReportResponse report(String taskId, HttpServletRequest servletRequest) {\n        FlowGramTaskReportOutput report = runtimeService.getTaskReport(taskId);\n        if (report == null) {\n            throw new FlowGramTaskNotFoundException(taskId);\n        }\n        FlowGramStoredTask task = loadTask(taskId);\n        FlowGramCaller caller = resolveCaller(servletRequest);\n        ensureAllowed(FlowGramAction.REPORT, caller, task);\n        if (report.getWorkflow() != null) {\n            taskStore.updateState(taskId,\n                    report.getWorkflow().getStatus(),\n                    report.getWorkflow().isTerminated(),\n                    report.getWorkflow().getError(),\n                    null);\n        }\n        return traceResponseEnricher.enrichReportResponse(protocolAdapter.toReportResponse(taskId,\n                report,\n                properties == null || properties.isReportNodeDetails(),\n                resolveTrace(taskId, report)));\n    }\n\n    public FlowGramTaskResultResponse result(String taskId, HttpServletRequest servletRequest) {\n        FlowGramTaskResultOutput result = runtimeService.getTaskResult(taskId);\n        if (result == null) {\n            throw new FlowGramTaskNotFoundException(taskId);\n        }\n        FlowGramStoredTask task = loadTask(taskId);\n        FlowGramCaller caller = resolveCaller(servletRequest);\n        ensureAllowed(FlowGramAction.RESULT, caller, task);\n        taskStore.updateState(taskId, result.getStatus(), result.isTerminated(), result.getError(), result.getResult());\n        FlowGramTaskReportOutput report = runtimeService.getTaskReport(taskId);\n        FlowGramTraceView trace = resolveTrace(taskId, report);\n        FlowGramTaskReportResponse reportResponse = traceResponseEnricher.enrichReportResponse(\n                protocolAdapter.toReportResponse(taskId, report, true, trace));\n        return protocolAdapter.toResultResponse(taskId, result, reportResponse == null ? trace : reportResponse.getTrace());\n    }\n\n    public FlowGramTaskCancelResponse cancel(String taskId, HttpServletRequest servletRequest) {\n        FlowGramStoredTask task = loadTask(taskId);\n        FlowGramCaller caller = resolveCaller(servletRequest);\n        ensureAllowed(FlowGramAction.CANCEL, caller, task);\n        FlowGramTaskCancelOutput output = runtimeService.cancelTask(taskId);\n        if (output == null || !output.isSuccess()) {\n            throw new FlowGramTaskNotFoundException(taskId);\n        }\n        return protocolAdapter.toCancelResponse(true);\n    }\n\n    private FlowGramCaller resolveCaller(HttpServletRequest servletRequest) {\n        FlowGramCaller caller = callerResolver.resolve(servletRequest);\n        return caller == null ? FlowGramCaller.builder().callerId(\"anonymous\").anonymous(true).build() : caller;\n    }\n\n    private FlowGramStoredTask loadTask(String taskId) {\n        FlowGramStoredTask task = taskStore.find(taskId);\n        if (task != null) {\n            return task;\n        }\n        return FlowGramStoredTask.builder().taskId(taskId).build();\n    }\n\n    private void ensureAllowed(FlowGramAction action, FlowGramCaller caller, FlowGramStoredTask task) {\n        if (!accessChecker.isAllowed(action, caller, task)) {\n            throw new FlowGramAccessDeniedException(action);\n        }\n    }\n\n    private FlowGramTraceView resolveTrace(String taskId, FlowGramTaskReportOutput report) {\n        if (traceCollector == null || (properties != null && !properties.isTraceEnabled())) {\n            return null;\n        }\n        return traceCollector.getTrace(taskId, report);\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramRuntimeTraceCollector.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeEvent;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeListener;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView;\n\nimport java.lang.reflect.Field;\nimport java.lang.reflect.Method;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\n\npublic class FlowGramRuntimeTraceCollector implements FlowGramRuntimeListener {\n\n    private final ConcurrentMap<String, TraceSnapshot> snapshots = new ConcurrentHashMap<String, TraceSnapshot>();\n\n    @Override\n    public void onEvent(FlowGramRuntimeEvent event) {\n        if (event == null || isBlank(event.getTaskId())) {\n            return;\n        }\n        TraceSnapshot snapshot = snapshots.computeIfAbsent(event.getTaskId(), TraceSnapshot::new);\n        snapshot.onEvent(event);\n    }\n\n    public FlowGramTraceView getTrace(String taskId) {\n        TraceSnapshot snapshot = taskId == null ? null : snapshots.get(taskId);\n        return snapshot == null ? null : snapshot.toView();\n    }\n\n    public FlowGramTraceView getTrace(String taskId, FlowGramTaskReportOutput report) {\n        TraceSnapshot snapshot = taskId == null ? null : snapshots.get(taskId);\n        if (snapshot == null) {\n            return null;\n        }\n        snapshot.mergeReport(report);\n        return snapshot.toView();\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n\n    private static final class TraceSnapshot {\n\n        private final String taskId;\n        private final List<FlowGramTraceView.EventView> events = new ArrayList<FlowGramTraceView.EventView>();\n        private final Map<String, MutableNodeTrace> nodes = new LinkedHashMap<String, MutableNodeTrace>();\n        private String status;\n        private Long startedAt;\n        private Long endedAt;\n\n        private TraceSnapshot(String taskId) {\n            this.taskId = taskId;\n        }\n\n        private synchronized void onEvent(FlowGramRuntimeEvent event) {\n            events.add(FlowGramTraceView.EventView.builder()\n                    .type(event.getType() == null ? null : event.getType().name())\n                    .timestamp(Long.valueOf(event.getTimestamp()))\n                    .nodeId(event.getNodeId())\n                    .status(event.getStatus())\n                    .error(event.getError())\n                    .build());\n            if (event.getType() == null) {\n                return;\n            }\n            switch (event.getType()) {\n                case TASK_STARTED:\n                    startedAt = Long.valueOf(event.getTimestamp());\n                    status = event.getStatus();\n                    break;\n                case TASK_FINISHED:\n                case TASK_FAILED:\n                case TASK_CANCELED:\n                    if (startedAt == null) {\n                        startedAt = Long.valueOf(event.getTimestamp());\n                    }\n                    endedAt = Long.valueOf(event.getTimestamp());\n                    status = event.getStatus();\n                    break;\n                case NODE_STARTED:\n                case NODE_FINISHED:\n                case NODE_FAILED:\n                case NODE_CANCELED:\n                    updateNode(event);\n                    break;\n                default:\n                    break;\n            }\n        }\n\n        private void updateNode(FlowGramRuntimeEvent event) {\n            if (event.getNodeId() == null) {\n                return;\n            }\n            MutableNodeTrace node = nodes.get(event.getNodeId());\n            if (node == null) {\n                node = new MutableNodeTrace(event.getNodeId());\n                nodes.put(event.getNodeId(), node);\n            }\n            node.eventCount += 1;\n            node.status = event.getStatus();\n            if (event.getType() == FlowGramRuntimeEvent.Type.NODE_STARTED) {\n                node.startedAt = Long.valueOf(event.getTimestamp());\n                return;\n            }\n            node.endedAt = Long.valueOf(event.getTimestamp());\n            node.error = event.getError();\n            node.terminated = true;\n            if (node.startedAt == null) {\n                node.startedAt = Long.valueOf(event.getTimestamp());\n            }\n        }\n\n        private synchronized void mergeReport(FlowGramTaskReportOutput report) {\n            if (report == null) {\n                return;\n            }\n            if (report.getWorkflow() != null) {\n                if (report.getWorkflow().getStartTime() != null) {\n                    startedAt = report.getWorkflow().getStartTime();\n                }\n                if (report.getWorkflow().getEndTime() != null) {\n                    endedAt = report.getWorkflow().getEndTime();\n                }\n                if (report.getWorkflow().getStatus() != null) {\n                    status = report.getWorkflow().getStatus();\n                }\n            }\n            if (report.getNodes() == null) {\n                return;\n            }\n            for (Map.Entry<String, FlowGramTaskReportOutput.NodeStatus> entry : report.getNodes().entrySet()) {\n                String nodeId = entry.getKey();\n                if (nodeId == null) {\n                    continue;\n                }\n                MutableNodeTrace node = nodes.get(nodeId);\n                if (node == null) {\n                    node = new MutableNodeTrace(nodeId);\n                    nodes.put(nodeId, node);\n                }\n                FlowGramTaskReportOutput.NodeStatus statusView = entry.getValue();\n                if (statusView == null) {\n                    continue;\n                }\n                node.status = statusView.getStatus();\n                node.terminated = statusView.isTerminated();\n                node.startedAt = firstNonNull(statusView.getStartTime(), node.startedAt);\n                node.endedAt = firstNonNull(statusView.getEndTime(), node.endedAt);\n                node.error = firstNonNull(statusView.getError(), node.error);\n                NodeMetrics metrics = extractMetrics(statusView.getInputs(), statusView.getOutputs());\n                if (metrics != null) {\n                    node.model = firstNonNull(metrics.model, node.model);\n                    node.promptTokens = firstNonNull(metrics.promptTokens, node.promptTokens);\n                    node.completionTokens = firstNonNull(metrics.completionTokens, node.completionTokens);\n                    node.totalTokens = firstNonNull(metrics.totalTokens, node.totalTokens);\n                    node.inputCost = firstNonNull(metrics.inputCost, node.inputCost);\n                    node.outputCost = firstNonNull(metrics.outputCost, node.outputCost);\n                    node.totalCost = firstNonNull(metrics.totalCost, node.totalCost);\n                    node.currency = firstNonNull(metrics.currency, node.currency);\n                }\n            }\n        }\n\n        private synchronized FlowGramTraceView toView() {\n            Map<String, FlowGramTraceView.NodeView> nodeViews = new LinkedHashMap<String, FlowGramTraceView.NodeView>();\n            int terminatedNodeCount = 0;\n            int successNodeCount = 0;\n            int failedNodeCount = 0;\n            int llmNodeCount = 0;\n            Long promptTokens = null;\n            Long completionTokens = null;\n            Long totalTokens = null;\n            Double inputCost = null;\n            Double outputCost = null;\n            Double totalCost = null;\n            String currency = null;\n            for (Map.Entry<String, MutableNodeTrace> entry : nodes.entrySet()) {\n                MutableNodeTrace value = entry.getValue();\n                Long durationMillis = duration(value.startedAt, value.endedAt);\n                FlowGramTraceView.MetricsView metricsView = buildMetricsView(value);\n                nodeViews.put(entry.getKey(), FlowGramTraceView.NodeView.builder()\n                        .nodeId(value.nodeId)\n                        .status(value.status)\n                        .terminated(value.terminated)\n                        .startedAt(value.startedAt)\n                        .endedAt(value.endedAt)\n                        .durationMillis(durationMillis)\n                        .error(value.error)\n                        .eventCount(Integer.valueOf(value.eventCount))\n                        .model(value.model)\n                        .metrics(metricsView)\n                        .build());\n                if (value.terminated) {\n                    terminatedNodeCount += 1;\n                }\n                if (\"success\".equalsIgnoreCase(value.status)) {\n                    successNodeCount += 1;\n                }\n                if (\"failed\".equalsIgnoreCase(value.status) || \"error\".equalsIgnoreCase(value.status)) {\n                    failedNodeCount += 1;\n                }\n                if (hasUsageMetrics(value)) {\n                    llmNodeCount += 1;\n                    promptTokens = sum(promptTokens, value.promptTokens);\n                    completionTokens = sum(completionTokens, value.completionTokens);\n                    totalTokens = sum(totalTokens, value.totalTokens);\n                    inputCost = sum(inputCost, value.inputCost);\n                    outputCost = sum(outputCost, value.outputCost);\n                    totalCost = sum(totalCost, value.totalCost);\n                    if (currency == null) {\n                        currency = value.currency;\n                    }\n                }\n            }\n            return FlowGramTraceView.builder()\n                    .taskId(taskId)\n                    .status(status)\n                    .startedAt(startedAt)\n                    .endedAt(endedAt)\n                    .summary(FlowGramTraceView.SummaryView.builder()\n                            .durationMillis(duration(startedAt, endedAt))\n                            .eventCount(Integer.valueOf(events.size()))\n                            .nodeCount(Integer.valueOf(nodeViews.size()))\n                            .terminatedNodeCount(Integer.valueOf(terminatedNodeCount))\n                            .successNodeCount(Integer.valueOf(successNodeCount))\n                            .failedNodeCount(Integer.valueOf(failedNodeCount))\n                            .llmNodeCount(Integer.valueOf(llmNodeCount))\n                            .metrics(FlowGramTraceView.MetricsView.builder()\n                                    .promptTokens(promptTokens)\n                                    .completionTokens(completionTokens)\n                                    .totalTokens(totalTokens)\n                                    .inputCost(inputCost)\n                                    .outputCost(outputCost)\n                                    .totalCost(totalCost)\n                                    .currency(currency)\n                                    .build())\n                            .build())\n                    .events(Collections.unmodifiableList(new ArrayList<FlowGramTraceView.EventView>(events)))\n                    .nodes(Collections.unmodifiableMap(nodeViews))\n                    .build();\n        }\n\n        private FlowGramTraceView.MetricsView buildMetricsView(MutableNodeTrace value) {\n            if (value == null || !hasUsageMetrics(value) && value.totalCost == null && value.inputCost == null && value.outputCost == null) {\n                return null;\n            }\n            return FlowGramTraceView.MetricsView.builder()\n                    .promptTokens(value.promptTokens)\n                    .completionTokens(value.completionTokens)\n                    .totalTokens(value.totalTokens)\n                    .inputCost(value.inputCost)\n                    .outputCost(value.outputCost)\n                    .totalCost(value.totalCost)\n                    .currency(value.currency)\n                    .build();\n        }\n\n        private boolean hasUsageMetrics(MutableNodeTrace value) {\n            return value != null && (value.promptTokens != null\n                    || value.completionTokens != null\n                    || value.totalTokens != null\n                    || value.model != null);\n        }\n\n        private NodeMetrics extractMetrics(Map<String, Object> inputs, Map<String, Object> outputs) {\n            if ((inputs == null || inputs.isEmpty()) && (outputs == null || outputs.isEmpty())) {\n                return null;\n            }\n            Object safeInputs = inputs == null ? Collections.<String, Object>emptyMap() : inputs;\n            Object safeOutputs = outputs == null ? Collections.<String, Object>emptyMap() : outputs;\n            Object metrics = propertyValue(safeOutputs, \"metrics\");\n            Object rawResponse = propertyValue(safeOutputs, \"rawResponse\");\n            Object usage = propertyValue(rawResponse, \"usage\");\n            return new NodeMetrics(\n                    firstNonBlank(\n                            stringValue(metrics, \"model\"),\n                            stringValue(rawResponse, \"model\"),\n                            stringValue(safeInputs, \"model\"),\n                            stringValue(safeInputs, \"modelName\")),\n                    longObject(firstNonNull(value(metrics, \"promptTokens\", \"prompt_tokens\"), value(usage, \"promptTokens\", \"prompt_tokens\", \"input\"))),\n                    longObject(firstNonNull(value(metrics, \"completionTokens\", \"completion_tokens\"), value(usage, \"completionTokens\", \"completion_tokens\", \"output\"))),\n                    longObject(firstNonNull(value(metrics, \"totalTokens\", \"total_tokens\"), value(usage, \"totalTokens\", \"total_tokens\", \"total\"))),\n                    doubleObject(value(metrics, \"inputCost\", \"input_cost\")),\n                    doubleObject(value(metrics, \"outputCost\", \"output_cost\")),\n                    doubleObject(value(metrics, \"totalCost\", \"total_cost\")),\n                    firstNonBlank(stringValue(metrics, \"currency\"))\n            );\n        }\n    }\n\n    private static final class MutableNodeTrace {\n        private final String nodeId;\n        private String status;\n        private boolean terminated;\n        private Long startedAt;\n        private Long endedAt;\n        private String error;\n        private int eventCount;\n        private String model;\n        private Long promptTokens;\n        private Long completionTokens;\n        private Long totalTokens;\n        private Double inputCost;\n        private Double outputCost;\n        private Double totalCost;\n        private String currency;\n\n        private MutableNodeTrace(String nodeId) {\n            this.nodeId = nodeId;\n        }\n    }\n\n    private static final class NodeMetrics {\n        private final String model;\n        private final Long promptTokens;\n        private final Long completionTokens;\n        private final Long totalTokens;\n        private final Double inputCost;\n        private final Double outputCost;\n        private final Double totalCost;\n        private final String currency;\n\n        private NodeMetrics(String model,\n                            Long promptTokens,\n                            Long completionTokens,\n                            Long totalTokens,\n                            Double inputCost,\n                            Double outputCost,\n                            Double totalCost,\n                            String currency) {\n            this.model = model;\n            this.promptTokens = promptTokens;\n            this.completionTokens = completionTokens;\n            this.totalTokens = totalTokens;\n            this.inputCost = inputCost;\n            this.outputCost = outputCost;\n            this.totalCost = totalCost;\n            this.currency = currency;\n        }\n    }\n\n    private static Long duration(Long startedAt, Long endedAt) {\n        if (startedAt == null) {\n            return null;\n        }\n        long duration = Math.max((endedAt == null ? startedAt.longValue() : endedAt.longValue()) - startedAt.longValue(), 0L);\n        return Long.valueOf(duration);\n    }\n\n    private static Object value(Object source, String... keys) {\n        if (source == null || keys == null) {\n            return null;\n        }\n        for (String key : keys) {\n            Object value = propertyValue(source, key);\n            if (value != null) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static String stringValue(Object source, String key) {\n        Object value = source == null || key == null ? null : propertyValue(source, key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private static <T> T firstNonNull(T left, T right) {\n        return left != null ? left : right;\n    }\n\n    private static Object firstNonNull(Object... values) {\n        if (values == null) {\n            return null;\n        }\n        for (Object value : values) {\n            if (value != null) {\n                return value;\n            }\n        }\n        return null;\n    }\n\n    private static Long longObject(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return Long.valueOf(((Number) value).longValue());\n        }\n        try {\n            return Long.valueOf(Long.parseLong(String.valueOf(value)));\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    private static Double doubleObject(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return Double.valueOf(((Number) value).doubleValue());\n        }\n        try {\n            return Double.valueOf(Double.parseDouble(String.valueOf(value)));\n        } catch (NumberFormatException ex) {\n            return null;\n        }\n    }\n\n    private static Long sum(Long left, Long right) {\n        if (left == null) {\n            return right;\n        }\n        if (right == null) {\n            return left;\n        }\n        return Long.valueOf(left.longValue() + right.longValue());\n    }\n\n    private static Double sum(Double left, Double right) {\n        if (left == null) {\n            return right;\n        }\n        if (right == null) {\n            return left;\n        }\n        return Double.valueOf(left.doubleValue() + right.doubleValue());\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Object propertyValue(Object source, String name) {\n        if (source == null || name == null || name.trim().isEmpty()) {\n            return null;\n        }\n        Object normalized = normalizeTree(source);\n        if (normalized instanceof Map) {\n            return ((Map<String, Object>) normalized).get(name);\n        }\n        Object value = invokeAccessor(normalized, \"get\" + Character.toUpperCase(name.charAt(0)) + name.substring(1));\n        if (value != null) {\n            return value;\n        }\n        value = invokeAccessor(normalized, \"is\" + Character.toUpperCase(name.charAt(0)) + name.substring(1));\n        if (value != null) {\n            return value;\n        }\n        return fieldValue(normalized, name);\n    }\n\n    private static Object normalizedSource(Object source) {\n        if (source == null || source instanceof Map || source instanceof List\n                || source instanceof String || source instanceof Number || source instanceof Boolean) {\n            return source;\n        }\n        try {\n            return JSON.parseObject(JSON.toJSONString(source));\n        } catch (RuntimeException ignored) {\n            return source;\n        }\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Object normalizeTree(Object source) {\n        Object normalized = normalizedSource(source);\n        if (normalized == null || normalized instanceof String\n                || normalized instanceof Number || normalized instanceof Boolean) {\n            return normalized;\n        }\n        if (normalized instanceof Map) {\n            Map<String, Object> copy = new LinkedHashMap<String, Object>();\n            Map<?, ?> sourceMap = (Map<?, ?>) normalized;\n            for (Map.Entry<?, ?> entry : sourceMap.entrySet()) {\n                copy.put(String.valueOf(entry.getKey()), normalizeTree(entry.getValue()));\n            }\n            return copy;\n        }\n        if (normalized instanceof List) {\n            List<Object> copy = new ArrayList<Object>();\n            for (Object item : (List<Object>) normalized) {\n                copy.add(normalizeTree(item));\n            }\n            return copy;\n        }\n        return normalized;\n    }\n\n    private static Object invokeAccessor(Object source, String methodName) {\n        try {\n            Method method = source.getClass().getMethod(methodName);\n            method.setAccessible(true);\n            return method.invoke(source);\n        } catch (Exception ignored) {\n            // fall through\n        }\n        Class<?> type = source.getClass();\n        while (type != null && type != Object.class) {\n            try {\n                Method method = type.getDeclaredMethod(methodName);\n                method.setAccessible(true);\n                return method.invoke(source);\n            } catch (Exception ignored) {\n                type = type.getSuperclass();\n            }\n        }\n        return null;\n    }\n\n    private static Object fieldValue(Object source, String name) {\n        Class<?> type = source.getClass();\n        while (type != null && type != Object.class) {\n            try {\n                Field field = type.getDeclaredField(name);\n                field.setAccessible(true);\n                return field.get(source);\n            } catch (Exception ignored) {\n                type = type.getSuperclass();\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramStoredTask.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport lombok.AllArgsConstructor;\nimport lombok.Builder;\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\n\nimport java.util.Map;\n\n@Data\n@Builder(toBuilder = true)\n@NoArgsConstructor\n@AllArgsConstructor\npublic class FlowGramStoredTask {\n\n    private String taskId;\n    private String creatorId;\n    private String tenantId;\n    private Long createdAt;\n    private Long expiresAt;\n    private String status;\n    private Boolean terminated;\n    private String error;\n    private Map<String, Object> resultSnapshot;\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramTaskStore.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport java.util.Map;\n\npublic interface FlowGramTaskStore {\n\n    void save(FlowGramStoredTask task);\n\n    FlowGramStoredTask find(String taskId);\n\n    void updateState(String taskId, String status, Boolean terminated, String error, Map<String, Object> resultSnapshot);\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/FlowGramTraceResponseEnricher.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport com.alibaba.fastjson2.JSON;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskReportResponse;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\n\nclass FlowGramTraceResponseEnricher {\n\n    FlowGramTaskReportResponse enrichReportResponse(FlowGramTaskReportResponse response) {\n        if (response == null) {\n            return null;\n        }\n        Map<String, FlowGramTaskReportResponse.NodeStatus> nodes = enrichNodes(response.getNodes());\n        return response.toBuilder()\n                .nodes(nodes)\n                .trace(enrichTrace(response.getTrace(), nodes))\n                .build();\n    }\n\n    FlowGramTraceView enrichTrace(FlowGramTraceView trace,\n                                  Map<String, FlowGramTaskReportResponse.NodeStatus> nodes) {\n        if (trace == null || nodes == null || nodes.isEmpty()) {\n            return trace;\n        }\n        Map<String, FlowGramTraceView.NodeView> sourceNodes = trace.getNodes() == null\n                ? new LinkedHashMap<String, FlowGramTraceView.NodeView>()\n                : trace.getNodes();\n        Map<String, FlowGramTraceView.NodeView> mergedNodes = new LinkedHashMap<String, FlowGramTraceView.NodeView>();\n        Long promptTokens = null;\n        Long completionTokens = null;\n        Long totalTokens = null;\n        Double inputCost = null;\n        Double outputCost = null;\n        Double totalCost = null;\n        String currency = trace.getSummary() == null || trace.getSummary().getMetrics() == null\n                ? null\n                : trace.getSummary().getMetrics().getCurrency();\n        int llmNodeCount = 0;\n\n        for (Map.Entry<String, FlowGramTraceView.NodeView> entry : sourceNodes.entrySet()) {\n            String nodeId = entry.getKey();\n            FlowGramTraceView.NodeView source = entry.getValue();\n            NodeMetrics nodeMetrics = extractNodeMetrics(nodes.get(nodeId));\n            FlowGramTraceView.MetricsView mergedMetrics = mergeMetrics(source == null ? null : source.getMetrics(), nodeMetrics);\n            String model = firstNonBlank(source == null ? null : source.getModel(), nodeMetrics == null ? null : nodeMetrics.model);\n            FlowGramTraceView.NodeView merged = source == null\n                    ? FlowGramTraceView.NodeView.builder().nodeId(nodeId).model(model).metrics(mergedMetrics).build()\n                    : source.toBuilder().model(model).metrics(mergedMetrics).build();\n            mergedNodes.put(nodeId, merged);\n            if (model != null || hasMetrics(mergedMetrics)) {\n                llmNodeCount += 1;\n                promptTokens = sum(promptTokens, mergedMetrics == null ? null : mergedMetrics.getPromptTokens());\n                completionTokens = sum(completionTokens, mergedMetrics == null ? null : mergedMetrics.getCompletionTokens());\n                totalTokens = sum(totalTokens, mergedMetrics == null ? null : mergedMetrics.getTotalTokens());\n                inputCost = sum(inputCost, mergedMetrics == null ? null : mergedMetrics.getInputCost());\n                outputCost = sum(outputCost, mergedMetrics == null ? null : mergedMetrics.getOutputCost());\n                totalCost = sum(totalCost, mergedMetrics == null ? null : mergedMetrics.getTotalCost());\n                if (currency == null && mergedMetrics != null) {\n                    currency = mergedMetrics.getCurrency();\n                }\n            }\n        }\n\n        FlowGramTraceView.SummaryView summary = trace.getSummary() == null\n                ? FlowGramTraceView.SummaryView.builder().build()\n                : trace.getSummary().toBuilder().build();\n        FlowGramTraceView.MetricsView existingSummaryMetrics = summary.getMetrics();\n        FlowGramTraceView.MetricsView summaryMetrics = (existingSummaryMetrics == null\n                ? FlowGramTraceView.MetricsView.builder()\n                : existingSummaryMetrics.toBuilder())\n                .promptTokens(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getPromptTokens(), promptTokens))\n                .completionTokens(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getCompletionTokens(), completionTokens))\n                .totalTokens(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getTotalTokens(), totalTokens))\n                .inputCost(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getInputCost(), inputCost))\n                .outputCost(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getOutputCost(), outputCost))\n                .totalCost(firstNonNull(existingSummaryMetrics == null ? null : existingSummaryMetrics.getTotalCost(), totalCost))\n                .currency(firstNonBlank(existingSummaryMetrics == null ? null : existingSummaryMetrics.getCurrency(), currency))\n                .build();\n\n        return trace.toBuilder()\n                .nodes(mergedNodes)\n                .summary(summary.toBuilder()\n                        .llmNodeCount(summary.getLlmNodeCount() == null || summary.getLlmNodeCount().intValue() <= 0\n                                ? Integer.valueOf(llmNodeCount)\n                                : summary.getLlmNodeCount())\n                        .metrics(summaryMetrics)\n                        .build())\n                .build();\n    }\n\n    private Map<String, FlowGramTaskReportResponse.NodeStatus> enrichNodes(\n            Map<String, FlowGramTaskReportResponse.NodeStatus> nodes) {\n        if (nodes == null || nodes.isEmpty()) {\n            return nodes;\n        }\n        Map<String, FlowGramTaskReportResponse.NodeStatus> enriched = new LinkedHashMap<String, FlowGramTaskReportResponse.NodeStatus>();\n        for (Map.Entry<String, FlowGramTaskReportResponse.NodeStatus> entry : nodes.entrySet()) {\n            FlowGramTaskReportResponse.NodeStatus node = entry.getValue();\n            if (node == null) {\n                enriched.put(entry.getKey(), null);\n                continue;\n            }\n            NodeMetrics metrics = extractNodeMetrics(node);\n            enriched.put(entry.getKey(), node.toBuilder()\n                    .inputs(normalizeMap(node.getInputs()))\n                    .outputs(mergeOutputs(node.getOutputs(), metrics))\n                    .build());\n        }\n        return enriched;\n    }\n\n    private Map<String, Object> mergeOutputs(Map<String, Object> outputs, NodeMetrics metrics) {\n        Map<String, Object> mergedOutputs = normalizeMap(outputs);\n        if (metrics == null || !metrics.hasUsageLikeContent()) {\n            return mergedOutputs;\n        }\n        Map<String, Object> metricMap = normalizeMap(mergedOutputs.get(\"metrics\"));\n        if (metricMap.isEmpty() && metrics.model == null && metrics.promptTokens == null\n                && metrics.completionTokens == null && metrics.totalTokens == null\n                && metrics.inputCost == null && metrics.outputCost == null\n                && metrics.totalCost == null && metrics.currency == null) {\n            return mergedOutputs;\n        }\n        putIfAbsent(metricMap, \"model\", metrics.model);\n        putIfAbsent(metricMap, \"promptTokens\", metrics.promptTokens);\n        putIfAbsent(metricMap, \"completionTokens\", metrics.completionTokens);\n        putIfAbsent(metricMap, \"totalTokens\", metrics.totalTokens);\n        putIfAbsent(metricMap, \"inputCost\", metrics.inputCost);\n        putIfAbsent(metricMap, \"outputCost\", metrics.outputCost);\n        putIfAbsent(metricMap, \"totalCost\", metrics.totalCost);\n        putIfAbsent(metricMap, \"currency\", metrics.currency);\n        mergedOutputs.put(\"metrics\", metricMap);\n        return mergedOutputs;\n    }\n\n    private FlowGramTraceView.MetricsView mergeMetrics(FlowGramTraceView.MetricsView existing, NodeMetrics metrics) {\n        if ((existing == null || !hasMetrics(existing)) && (metrics == null || !metrics.hasUsageLikeContent())) {\n            return existing;\n        }\n        FlowGramTraceView.MetricsView.MetricsViewBuilder builder = existing == null\n                ? FlowGramTraceView.MetricsView.builder()\n                : existing.toBuilder();\n        return builder\n                .promptTokens(firstNonNull(existing == null ? null : existing.getPromptTokens(), metrics == null ? null : metrics.promptTokens))\n                .completionTokens(firstNonNull(existing == null ? null : existing.getCompletionTokens(), metrics == null ? null : metrics.completionTokens))\n                .totalTokens(firstNonNull(existing == null ? null : existing.getTotalTokens(), metrics == null ? null : metrics.totalTokens))\n                .inputCost(firstNonNull(existing == null ? null : existing.getInputCost(), metrics == null ? null : metrics.inputCost))\n                .outputCost(firstNonNull(existing == null ? null : existing.getOutputCost(), metrics == null ? null : metrics.outputCost))\n                .totalCost(firstNonNull(existing == null ? null : existing.getTotalCost(), metrics == null ? null : metrics.totalCost))\n                .currency(firstNonBlank(existing == null ? null : existing.getCurrency(), metrics == null ? null : metrics.currency))\n                .build();\n    }\n\n    private NodeMetrics extractNodeMetrics(FlowGramTaskReportResponse.NodeStatus node) {\n        if (node == null) {\n            return null;\n        }\n        Object inputs = normalizeTree(node.getInputs());\n        Object outputs = normalizeTree(node.getOutputs());\n        Object metrics = value(outputs, \"metrics\");\n        Object rawResponse = value(outputs, \"rawResponse\");\n        Object usage = value(rawResponse, \"usage\");\n        return new NodeMetrics(\n                firstNonBlank(\n                        stringValue(metrics, \"model\"),\n                        stringValue(rawResponse, \"model\"),\n                        stringValue(inputs, \"model\"),\n                        stringValue(inputs, \"modelName\")),\n                longValue(firstNonNull(value(metrics, \"promptTokens\", \"prompt_tokens\"), value(usage, \"promptTokens\", \"prompt_tokens\", \"input\"))),\n                longValue(firstNonNull(value(metrics, \"completionTokens\", \"completion_tokens\"), value(usage, \"completionTokens\", \"completion_tokens\", \"output\"))),\n                longValue(firstNonNull(value(metrics, \"totalTokens\", \"total_tokens\"), value(usage, \"totalTokens\", \"total_tokens\", \"total\"))),\n                doubleValue(value(metrics, \"inputCost\", \"input_cost\")),\n                doubleValue(value(metrics, \"outputCost\", \"output_cost\")),\n                doubleValue(value(metrics, \"totalCost\", \"total_cost\")),\n                firstNonBlank(stringValue(metrics, \"currency\"))\n        );\n    }\n\n    private static boolean hasMetrics(FlowGramTraceView.MetricsView metrics) {\n        return metrics != null && (metrics.getPromptTokens() != null\n                || metrics.getCompletionTokens() != null\n                || metrics.getTotalTokens() != null\n                || metrics.getInputCost() != null\n                || metrics.getOutputCost() != null\n                || metrics.getTotalCost() != null\n                || firstNonBlank(metrics.getCurrency()) != null);\n    }\n\n    private static void putIfAbsent(Map<String, Object> target, String key, Object value) {\n        if (target != null && key != null && value != null && !target.containsKey(key)) {\n            target.put(key, value);\n        }\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Object value(Object source, String... keys) {\n        if (source == null || keys == null) {\n            return null;\n        }\n        Object normalized = normalizeTree(source);\n        for (String key : keys) {\n            if (key == null || key.trim().isEmpty()) {\n                continue;\n            }\n            if (normalized instanceof Map && ((Map<String, Object>) normalized).containsKey(key)) {\n                Object value = ((Map<String, Object>) normalized).get(key);\n                if (value != null) {\n                    return value;\n                }\n            }\n        }\n        return null;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Map<String, Object> normalizeMap(Object source) {\n        Object normalized = normalizeTree(source);\n        if (!(normalized instanceof Map)) {\n            return new LinkedHashMap<String, Object>();\n        }\n        Map<String, Object> copy = new LinkedHashMap<String, Object>();\n        Map<?, ?> sourceMap = (Map<?, ?>) normalized;\n        for (Map.Entry<?, ?> entry : sourceMap.entrySet()) {\n            copy.put(String.valueOf(entry.getKey()), entry.getValue());\n        }\n        return copy;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Object normalizeTree(Object source) {\n        if (source == null || source instanceof String || source instanceof Number || source instanceof Boolean) {\n            return source;\n        }\n        Object normalized = source;\n        if (!(source instanceof Map) && !(source instanceof List)) {\n            try {\n                normalized = JSON.parse(JSON.toJSONString(source));\n            } catch (RuntimeException ignored) {\n                return source;\n            }\n        }\n        if (normalized instanceof Map) {\n            Map<String, Object> copy = new LinkedHashMap<String, Object>();\n            Map<?, ?> sourceMap = (Map<?, ?>) normalized;\n            for (Map.Entry<?, ?> entry : sourceMap.entrySet()) {\n                copy.put(String.valueOf(entry.getKey()), normalizeTree(entry.getValue()));\n            }\n            return copy;\n        }\n        if (normalized instanceof List) {\n            List<Object> copy = new ArrayList<Object>();\n            for (Object item : (List<Object>) normalized) {\n                copy.add(normalizeTree(item));\n            }\n            return copy;\n        }\n        return normalized;\n    }\n\n    private static String stringValue(Object source, String key) {\n        Object value = value(source, key);\n        return value == null ? null : String.valueOf(value);\n    }\n\n    private static Long longValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return Long.valueOf(((Number) value).longValue());\n        }\n        try {\n            return Long.valueOf(Long.parseLong(String.valueOf(value)));\n        } catch (NumberFormatException ignored) {\n            return null;\n        }\n    }\n\n    private static Double doubleValue(Object value) {\n        if (value == null) {\n            return null;\n        }\n        if (value instanceof Number) {\n            return Double.valueOf(((Number) value).doubleValue());\n        }\n        try {\n            return Double.valueOf(Double.parseDouble(String.valueOf(value)));\n        } catch (NumberFormatException ignored) {\n            return null;\n        }\n    }\n\n    private static Long sum(Long left, Long right) {\n        if (left == null) {\n            return right;\n        }\n        if (right == null) {\n            return left;\n        }\n        return Long.valueOf(left.longValue() + right.longValue());\n    }\n\n    private static Double sum(Double left, Double right) {\n        if (left == null) {\n            return right;\n        }\n        if (right == null) {\n            return left;\n        }\n        return Double.valueOf(left.doubleValue() + right.doubleValue());\n    }\n\n    private static String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private static <T> T firstNonNull(T left, T right) {\n        return left != null ? left : right;\n    }\n\n    private static final class NodeMetrics {\n        private final String model;\n        private final Long promptTokens;\n        private final Long completionTokens;\n        private final Long totalTokens;\n        private final Double inputCost;\n        private final Double outputCost;\n        private final Double totalCost;\n        private final String currency;\n\n        private NodeMetrics(String model,\n                            Long promptTokens,\n                            Long completionTokens,\n                            Long totalTokens,\n                            Double inputCost,\n                            Double outputCost,\n                            Double totalCost,\n                            String currency) {\n            this.model = model;\n            this.promptTokens = promptTokens;\n            this.completionTokens = completionTokens;\n            this.totalTokens = totalTokens;\n            this.inputCost = inputCost;\n            this.outputCost = outputCost;\n            this.totalCost = totalCost;\n            this.currency = currency;\n        }\n\n        private boolean hasUsageLikeContent() {\n            return firstNonBlank(model, currency) != null\n                    || promptTokens != null\n                    || completionTokens != null\n                    || totalTokens != null\n                    || inputCost != null\n                    || outputCost != null\n                    || totalCost != null;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/InMemoryFlowGramTaskStore.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\n\npublic class InMemoryFlowGramTaskStore implements FlowGramTaskStore {\n\n    private final ConcurrentMap<String, FlowGramStoredTask> tasks = new ConcurrentHashMap<String, FlowGramStoredTask>();\n\n    @Override\n    public void save(FlowGramStoredTask task) {\n        if (task == null || task.getTaskId() == null || task.getTaskId().trim().isEmpty()) {\n            return;\n        }\n        tasks.put(task.getTaskId(), copy(task));\n    }\n\n    @Override\n    public FlowGramStoredTask find(String taskId) {\n        FlowGramStoredTask task = tasks.get(taskId);\n        return task == null ? null : copy(task);\n    }\n\n    @Override\n    public void updateState(String taskId, String status, Boolean terminated, String error, Map<String, Object> resultSnapshot) {\n        if (taskId == null || taskId.trim().isEmpty()) {\n            return;\n        }\n        tasks.compute(taskId, (key, existing) -> {\n            FlowGramStoredTask target = existing == null\n                    ? FlowGramStoredTask.builder().taskId(taskId).build()\n                    : existing.toBuilder().build();\n            if (status != null) {\n                target.setStatus(status);\n            }\n            if (terminated != null) {\n                target.setTerminated(terminated);\n            }\n            if (error != null || target.getError() != null) {\n                target.setError(error);\n            }\n            if (resultSnapshot != null) {\n                target.setResultSnapshot(copyMap(resultSnapshot));\n            }\n            return target;\n        });\n    }\n\n    private FlowGramStoredTask copy(FlowGramStoredTask task) {\n        return task.toBuilder()\n                .resultSnapshot(copyMap(task.getResultSnapshot()))\n                .build();\n    }\n\n    private Map<String, Object> copyMap(Map<String, Object> source) {\n        Map<String, Object> copy = new LinkedHashMap<String, Object>();\n        if (source == null) {\n            return copy;\n        }\n        for (Map.Entry<String, Object> entry : source.entrySet()) {\n            copy.put(entry.getKey(), entry.getValue());\n        }\n        return copy;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/JdbcFlowGramTaskStore.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.TypeReference;\n\nimport javax.sql.DataSource;\nimport java.sql.Connection;\nimport java.sql.PreparedStatement;\nimport java.sql.ResultSet;\nimport java.sql.Statement;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class JdbcFlowGramTaskStore implements FlowGramTaskStore {\n\n    private final DataSource dataSource;\n    private final String tableName;\n\n    public JdbcFlowGramTaskStore(DataSource dataSource, String tableName, boolean initializeSchema) {\n        if (dataSource == null) {\n            throw new IllegalArgumentException(\"dataSource is required\");\n        }\n        this.dataSource = dataSource;\n        this.tableName = validIdentifier(tableName);\n        if (initializeSchema) {\n            initializeSchema();\n        }\n    }\n\n    @Override\n    public void save(FlowGramStoredTask task) {\n        if (task == null || isBlank(task.getTaskId())) {\n            return;\n        }\n        upsert(copy(task));\n    }\n\n    @Override\n    public FlowGramStoredTask find(String taskId) {\n        if (isBlank(taskId)) {\n            return null;\n        }\n        String sql = \"select task_id, creator_id, tenant_id, created_at, expires_at, status, terminated, error, result_snapshot \" +\n                \"from \" + tableName + \" where task_id = ?\";\n        try (Connection connection = dataSource.getConnection();\n             PreparedStatement statement = connection.prepareStatement(sql)) {\n            statement.setString(1, taskId);\n            try (ResultSet resultSet = statement.executeQuery()) {\n                if (!resultSet.next()) {\n                    return null;\n                }\n                return FlowGramStoredTask.builder()\n                        .taskId(resultSet.getString(\"task_id\"))\n                        .creatorId(resultSet.getString(\"creator_id\"))\n                        .tenantId(resultSet.getString(\"tenant_id\"))\n                        .createdAt(longValue(resultSet, \"created_at\"))\n                        .expiresAt(longValue(resultSet, \"expires_at\"))\n                        .status(resultSet.getString(\"status\"))\n                        .terminated(booleanValue(resultSet, \"terminated\"))\n                        .error(resultSet.getString(\"error\"))\n                        .resultSnapshot(parseSnapshot(resultSet.getString(\"result_snapshot\")))\n                        .build();\n            }\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to load FlowGram task: \" + taskId, e);\n        }\n    }\n\n    @Override\n    public void updateState(String taskId, String status, Boolean terminated, String error, Map<String, Object> resultSnapshot) {\n        if (isBlank(taskId)) {\n            return;\n        }\n        FlowGramStoredTask existing = find(taskId);\n        FlowGramStoredTask target = existing == null\n                ? FlowGramStoredTask.builder().taskId(taskId).build()\n                : existing.toBuilder().build();\n        if (status != null) {\n            target.setStatus(status);\n        }\n        if (terminated != null) {\n            target.setTerminated(terminated);\n        }\n        if (error != null || target.getError() != null) {\n            target.setError(error);\n        }\n        if (resultSnapshot != null) {\n            target.setResultSnapshot(copyMap(resultSnapshot));\n        }\n        upsert(target);\n    }\n\n    private void initializeSchema() {\n        String sql = \"create table if not exists \" + tableName + \" (\" +\n                \"task_id varchar(191) not null, \" +\n                \"creator_id varchar(191), \" +\n                \"tenant_id varchar(191), \" +\n                \"created_at bigint, \" +\n                \"expires_at bigint, \" +\n                \"status varchar(64), \" +\n                \"terminated boolean, \" +\n                \"error text, \" +\n                \"result_snapshot text, \" +\n                \"updated_at bigint not null, \" +\n                \"primary key (task_id)\" +\n                \")\";\n        try (Connection connection = dataSource.getConnection();\n             Statement statement = connection.createStatement()) {\n            statement.executeUpdate(sql);\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to initialize FlowGram task store schema\", e);\n        }\n    }\n\n    private void upsert(FlowGramStoredTask task) {\n        String deleteSql = \"delete from \" + tableName + \" where task_id = ?\";\n        String insertSql = \"insert into \" + tableName +\n                \" (task_id, creator_id, tenant_id, created_at, expires_at, status, terminated, error, result_snapshot, updated_at) \" +\n                \"values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\";\n        try (Connection connection = dataSource.getConnection()) {\n            boolean autoCommit = connection.getAutoCommit();\n            connection.setAutoCommit(false);\n            try {\n                try (PreparedStatement deleteStatement = connection.prepareStatement(deleteSql)) {\n                    deleteStatement.setString(1, task.getTaskId());\n                    deleteStatement.executeUpdate();\n                }\n                try (PreparedStatement insertStatement = connection.prepareStatement(insertSql)) {\n                    insertStatement.setString(1, task.getTaskId());\n                    insertStatement.setString(2, task.getCreatorId());\n                    insertStatement.setString(3, task.getTenantId());\n                    setLong(insertStatement, 4, task.getCreatedAt());\n                    setLong(insertStatement, 5, task.getExpiresAt());\n                    insertStatement.setString(6, task.getStatus());\n                    if (task.getTerminated() == null) {\n                        insertStatement.setObject(7, null);\n                    } else {\n                        insertStatement.setBoolean(7, task.getTerminated());\n                    }\n                    insertStatement.setString(8, task.getError());\n                    Map<String, Object> snapshot = task.getResultSnapshot() == null\n                            ? Collections.<String, Object>emptyMap()\n                            : task.getResultSnapshot();\n                    insertStatement.setString(9, JSON.toJSONString(snapshot));\n                    insertStatement.setLong(10, System.currentTimeMillis());\n                    insertStatement.executeUpdate();\n                }\n                connection.commit();\n            } catch (Exception e) {\n                connection.rollback();\n                throw e;\n            } finally {\n                connection.setAutoCommit(autoCommit);\n            }\n        } catch (Exception e) {\n            throw new IllegalStateException(\"Failed to persist FlowGram task: \" + task.getTaskId(), e);\n        }\n    }\n\n    private FlowGramStoredTask copy(FlowGramStoredTask task) {\n        return task.toBuilder()\n                .resultSnapshot(copyMap(task.getResultSnapshot()))\n                .build();\n    }\n\n    private Map<String, Object> copyMap(Map<String, Object> source) {\n        Map<String, Object> copy = new LinkedHashMap<String, Object>();\n        if (source == null) {\n            return copy;\n        }\n        copy.putAll(source);\n        return copy;\n    }\n\n    private Map<String, Object> parseSnapshot(String json) {\n        if (isBlank(json)) {\n            return new LinkedHashMap<String, Object>();\n        }\n        return JSON.parseObject(json, new TypeReference<LinkedHashMap<String, Object>>() {\n        });\n    }\n\n    private Long longValue(ResultSet resultSet, String column) throws Exception {\n        long value = resultSet.getLong(column);\n        return resultSet.wasNull() ? null : value;\n    }\n\n    private Boolean booleanValue(ResultSet resultSet, String column) throws Exception {\n        boolean value = resultSet.getBoolean(column);\n        return resultSet.wasNull() ? null : value;\n    }\n\n    private void setLong(PreparedStatement statement, int index, Long value) throws Exception {\n        if (value == null) {\n            statement.setObject(index, null);\n        } else {\n            statement.setLong(index, value);\n        }\n    }\n\n    private String validIdentifier(String value) {\n        if (isBlank(value) || !value.matches(\"[A-Za-z_][A-Za-z0-9_]*(\\\\.[A-Za-z_][A-Za-z0-9_]*)?\")) {\n            throw new IllegalArgumentException(\"Invalid sql identifier: \" + value);\n        }\n        return value.trim();\n    }\n\n    private boolean isBlank(String value) {\n        return value == null || value.trim().isEmpty();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/flowgram/springboot/support/RegistryBackedFlowGramModelClientResolver.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.Ai4jFlowGramLlmNodeRunner;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\nimport io.github.lnyocly.ai4j.agent.model.AgentModelClient;\nimport io.github.lnyocly.ai4j.agent.model.ChatModelClient;\nimport io.github.lnyocly.ai4j.flowgram.springboot.config.FlowGramProperties;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceRegistry;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\n\npublic class RegistryBackedFlowGramModelClientResolver implements Ai4jFlowGramLlmNodeRunner.ModelClientResolver {\n\n    private final AiServiceRegistry aiServiceRegistry;\n    private final FlowGramProperties properties;\n    private final ConcurrentMap<String, AgentModelClient> chatClients =\n            new ConcurrentHashMap<String, AgentModelClient>();\n\n    public RegistryBackedFlowGramModelClientResolver(AiServiceRegistry aiServiceRegistry,\n                                                     FlowGramProperties properties) {\n        this.aiServiceRegistry = aiServiceRegistry;\n        this.properties = properties;\n    }\n\n    @Override\n    public AgentModelClient resolve(FlowGramNodeSchema node, Map<String, Object> inputs) {\n        String serviceId = firstNonBlank(\n                valueAsString(inputs == null ? null : inputs.get(\"serviceId\")),\n                valueAsString(inputs == null ? null : inputs.get(\"aiServiceId\")),\n                properties == null ? null : properties.getDefaultServiceId()\n        );\n        if (serviceId == null) {\n            throw new IllegalArgumentException(\"FlowGram LLM node requires serviceId/aiServiceId or ai4j.flowgram.default-service-id\");\n        }\n        return chatClients.computeIfAbsent(serviceId, key -> new ChatModelClient(aiServiceRegistry.getChatService(key)));\n    }\n\n    private String firstNonBlank(String... values) {\n        if (values == null) {\n            return null;\n        }\n        for (String value : values) {\n            if (value != null && !value.trim().isEmpty()) {\n                return value.trim();\n            }\n        }\n        return null;\n    }\n\n    private String valueAsString(Object value) {\n        return value == null ? null : String.valueOf(value);\n    }\n}\n\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
    "content": "io.github.lnyocly.ai4j.flowgram.springboot.autoconfigure.FlowGramAutoConfiguration\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/main/resources/META-INF/spring.factories",
    "content": "org.springframework.boot.autoconfigure.EnableAutoConfiguration=\\\nio.github.lnyocly.ai4j.flowgram.springboot.autoconfigure.FlowGramAutoConfiguration\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/FlowGramJdbcTaskStoreAutoConfigurationTest.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot;\n\nimport io.github.lnyocly.ai4j.AiConfigAutoConfiguration;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramLlmNodeRunner;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramTaskStore;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.JdbcFlowGramTaskStore;\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.SpringBootConfiguration;\nimport org.springframework.boot.autoconfigure.EnableAutoConfiguration;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.test.context.TestPropertySource;\nimport org.springframework.test.context.junit4.SpringRunner;\n\nimport javax.sql.DataSource;\nimport java.util.Map;\n\nimport static org.junit.Assert.assertTrue;\n\n@RunWith(SpringRunner.class)\n@SpringBootTest(\n        classes = FlowGramJdbcTaskStoreAutoConfigurationTest.TestApplication.class,\n        webEnvironment = SpringBootTest.WebEnvironment.MOCK\n)\n@TestPropertySource(properties = {\n        \"org.springframework.boot.logging.LoggingSystem=none\",\n        \"ai4j.flowgram.enabled=true\",\n        \"ai4j.flowgram.task-store.type=jdbc\",\n        \"ai4j.flowgram.task-store.table-name=ai4j_flowgram_task_autotest\"\n})\npublic class FlowGramJdbcTaskStoreAutoConfigurationTest {\n\n    @Autowired\n    private FlowGramTaskStore taskStore;\n\n    @Test\n    public void shouldAutoConfigureJdbcTaskStoreWhenDataSourceIsPresent() {\n        assertTrue(taskStore instanceof JdbcFlowGramTaskStore);\n    }\n\n    @SpringBootConfiguration\n    @EnableAutoConfiguration(exclude = AiConfigAutoConfiguration.class)\n    public static class TestApplication {\n\n        @Bean\n        public DataSource dataSource() {\n            JdbcDataSource dataSource = new JdbcDataSource();\n            dataSource.setURL(\"jdbc:h2:mem:ai4j_flowgram_autoconfig;MODE=MYSQL;DB_CLOSE_DELAY=-1\");\n            dataSource.setUser(\"sa\");\n            dataSource.setPassword(\"\");\n            return dataSource;\n        }\n\n        @Bean\n        public FlowGramLlmNodeRunner flowGramLlmNodeRunner() {\n            return new FlowGramLlmNodeRunner() {\n                @Override\n                public Map<String, Object> run(io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema node,\n                                               Map<String, Object> inputs) {\n                    throw new UnsupportedOperationException(\"not used in auto configuration test\");\n                }\n            };\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/FlowGramRuntimeTraceCollectorTest.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeEvent;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramTaskReportOutput;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTraceView;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramRuntimeTraceCollector;\nimport io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class FlowGramRuntimeTraceCollectorTest {\n\n    @Test\n    public void shouldMergeReportMetricsIntoTraceProjection() {\n        FlowGramRuntimeTraceCollector collector = new FlowGramRuntimeTraceCollector();\n        String taskId = \"task-1\";\n        collector.onEvent(event(FlowGramRuntimeEvent.Type.TASK_STARTED, 1000L, taskId, null, \"processing\", null));\n        collector.onEvent(event(FlowGramRuntimeEvent.Type.NODE_STARTED, 1100L, taskId, \"llm_0\", \"processing\", null));\n        collector.onEvent(event(FlowGramRuntimeEvent.Type.NODE_FINISHED, 1600L, taskId, \"llm_0\", \"success\", null));\n        collector.onEvent(event(FlowGramRuntimeEvent.Type.TASK_FINISHED, 2000L, taskId, null, \"success\", null));\n\n        FlowGramTaskReportOutput report = FlowGramTaskReportOutput.builder()\n                .workflow(FlowGramTaskReportOutput.WorkflowStatus.builder()\n                        .status(\"success\")\n                        .terminated(true)\n                        .startTime(1000L)\n                        .endTime(2000L)\n                        .build())\n                .nodes(singleNodeReport())\n                .build();\n\n        FlowGramTraceView trace = collector.getTrace(taskId, report);\n\n        Assert.assertNotNull(trace);\n        Assert.assertNotNull(trace.getSummary());\n        Assert.assertEquals(Long.valueOf(1000L), trace.getSummary().getDurationMillis());\n        Assert.assertEquals(Integer.valueOf(4), trace.getSummary().getEventCount());\n        Assert.assertEquals(Integer.valueOf(1), trace.getSummary().getLlmNodeCount());\n        Assert.assertEquals(Long.valueOf(120L), trace.getSummary().getMetrics().getPromptTokens());\n        Assert.assertEquals(Long.valueOf(45L), trace.getSummary().getMetrics().getCompletionTokens());\n        Assert.assertEquals(Long.valueOf(165L), trace.getSummary().getMetrics().getTotalTokens());\n        Assert.assertEquals(Double.valueOf(0.0006D), trace.getSummary().getMetrics().getTotalCost());\n\n        FlowGramTraceView.NodeView nodeView = trace.getNodes().get(\"llm_0\");\n        Assert.assertNotNull(nodeView);\n        Assert.assertEquals(\"glm-4.7\", nodeView.getModel());\n        Assert.assertEquals(Long.valueOf(500L), nodeView.getDurationMillis());\n        Assert.assertNotNull(nodeView.getMetrics());\n        Assert.assertEquals(Long.valueOf(165L), nodeView.getMetrics().getTotalTokens());\n        Assert.assertEquals(\"USD\", nodeView.getMetrics().getCurrency());\n    }\n\n    @Test\n    public void shouldExtractUsageFromObjectRawResponse() {\n        FlowGramRuntimeTraceCollector collector = new FlowGramRuntimeTraceCollector();\n        String taskId = \"task-2\";\n        collector.onEvent(event(FlowGramRuntimeEvent.Type.TASK_STARTED, 1000L, taskId, null, \"processing\", null));\n        collector.onEvent(event(FlowGramRuntimeEvent.Type.NODE_STARTED, 1100L, taskId, \"llm_0\", \"processing\", null));\n        collector.onEvent(event(FlowGramRuntimeEvent.Type.NODE_FINISHED, 1600L, taskId, \"llm_0\", \"success\", null));\n        collector.onEvent(event(FlowGramRuntimeEvent.Type.TASK_FINISHED, 2000L, taskId, null, \"success\", null));\n\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        outputs.put(\"rawResponse\", new MinimaxChatCompletionResponse(\n                \"resp-2\",\n                \"chat.completion\",\n                1L,\n                \"MiniMax-M2.1\",\n                null,\n                new Usage(159L, 232L, 391L)));\n\n        Map<String, FlowGramTaskReportOutput.NodeStatus> nodes =\n                new LinkedHashMap<String, FlowGramTaskReportOutput.NodeStatus>();\n        nodes.put(\"llm_0\", FlowGramTaskReportOutput.NodeStatus.builder()\n                .status(\"success\")\n                .terminated(true)\n                .startTime(1100L)\n                .endTime(1600L)\n                .inputs(inputMap(\"MiniMax-M2.1\"))\n                .outputs(outputs)\n                .build());\n\n        FlowGramTaskReportOutput report = FlowGramTaskReportOutput.builder()\n                .workflow(FlowGramTaskReportOutput.WorkflowStatus.builder()\n                        .status(\"success\")\n                        .terminated(true)\n                        .startTime(1000L)\n                        .endTime(2000L)\n                        .build())\n                .nodes(nodes)\n                .build();\n\n        FlowGramTraceView trace = collector.getTrace(taskId, report);\n\n        Assert.assertNotNull(trace);\n        Assert.assertNotNull(trace.getSummary());\n        Assert.assertNotNull(trace.getSummary().getMetrics());\n        Assert.assertEquals(Long.valueOf(159L), trace.getSummary().getMetrics().getPromptTokens());\n        Assert.assertEquals(Long.valueOf(232L), trace.getSummary().getMetrics().getCompletionTokens());\n        Assert.assertEquals(Long.valueOf(391L), trace.getSummary().getMetrics().getTotalTokens());\n        Assert.assertEquals(\"MiniMax-M2.1\", trace.getNodes().get(\"llm_0\").getModel());\n        Assert.assertEquals(Long.valueOf(391L), trace.getNodes().get(\"llm_0\").getMetrics().getTotalTokens());\n    }\n\n    private Map<String, FlowGramTaskReportOutput.NodeStatus> singleNodeReport() {\n        Map<String, Object> metrics = new LinkedHashMap<String, Object>();\n        metrics.put(\"promptTokens\", 120L);\n        metrics.put(\"completionTokens\", 45L);\n        metrics.put(\"totalTokens\", 165L);\n        metrics.put(\"inputCost\", 0.00024D);\n        metrics.put(\"outputCost\", 0.00036D);\n        metrics.put(\"totalCost\", 0.0006D);\n        metrics.put(\"currency\", \"USD\");\n\n        Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n        outputs.put(\"result\", \"hello\");\n        outputs.put(\"metrics\", metrics);\n\n        Map<String, FlowGramTaskReportOutput.NodeStatus> nodes =\n                new LinkedHashMap<String, FlowGramTaskReportOutput.NodeStatus>();\n        nodes.put(\"llm_0\", FlowGramTaskReportOutput.NodeStatus.builder()\n                .status(\"success\")\n                .terminated(true)\n                .startTime(1100L)\n                .endTime(1600L)\n                .inputs(inputMap(\"glm-4.7\"))\n                .outputs(outputs)\n                .build());\n        return nodes;\n    }\n\n    private Map<String, Object> inputMap(String modelName) {\n        Map<String, Object> inputs = new LinkedHashMap<String, Object>();\n        inputs.put(\"modelName\", modelName);\n        return inputs;\n    }\n\n    private FlowGramRuntimeEvent event(FlowGramRuntimeEvent.Type type,\n                                       long timestamp,\n                                       String taskId,\n                                       String nodeId,\n                                       String status,\n                                       String error) {\n        return FlowGramRuntimeEvent.builder()\n                .type(type)\n                .timestamp(timestamp)\n                .taskId(taskId)\n                .nodeId(nodeId)\n                .status(status)\n                .error(error)\n                .build();\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/FlowGramTaskControllerIntegrationTest.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot;\n\nimport com.alibaba.fastjson2.JSON;\nimport com.alibaba.fastjson2.JSONArray;\nimport com.alibaba.fastjson2.JSONObject;\nimport io.github.lnyocly.ai4j.AiConfigAutoConfiguration;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramLlmNodeRunner;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutor;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeEvent;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramRuntimeListener;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramEdgeSchema;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramWorkflowSchema;\nimport io.github.lnyocly.ai4j.platform.minimax.chat.entity.MinimaxChatCompletionResponse;\nimport io.github.lnyocly.ai4j.platform.openai.usage.Usage;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskRunRequest;\nimport io.github.lnyocly.ai4j.flowgram.springboot.dto.FlowGramTaskValidateRequest;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramStoredTask;\nimport io.github.lnyocly.ai4j.flowgram.springboot.support.FlowGramTaskStore;\nimport org.junit.Before;\nimport org.junit.Test;\nimport org.junit.runner.RunWith;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.beans.factory.annotation.Qualifier;\nimport org.springframework.boot.SpringBootConfiguration;\nimport org.springframework.boot.autoconfigure.EnableAutoConfiguration;\nimport org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.http.MediaType;\nimport org.springframework.test.context.TestPropertySource;\nimport org.springframework.test.context.junit4.SpringRunner;\nimport org.springframework.test.web.servlet.MockMvc;\nimport org.springframework.test.web.servlet.MvcResult;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.concurrent.CopyOnWriteArrayList;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertFalse;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\n@RunWith(SpringRunner.class)\n@SpringBootTest(\n        classes = FlowGramTaskControllerIntegrationTest.TestApplication.class,\n        webEnvironment = SpringBootTest.WebEnvironment.MOCK\n)\n@AutoConfigureMockMvc\n@TestPropertySource(properties = {\n        \"ai4j.flowgram.enabled=true\",\n        \"ai4j.flowgram.api.base-path=/flowgram\"\n})\npublic class FlowGramTaskControllerIntegrationTest {\n\n    @Autowired\n    private MockMvc mockMvc;\n\n    @Autowired\n    private FlowGramTaskStore taskStore;\n\n    @Autowired\n    @Qualifier(\"testRuntimeEventCollector\")\n    private TestRuntimeEventCollector runtimeEventCollector;\n\n    @Before\n    public void setUp() {\n        runtimeEventCollector.clear();\n    }\n\n    @Test\n    public void shouldRunTaskThroughControllerAndRegisterExtensionBeans() throws Exception {\n        FlowGramTaskRunRequest request = FlowGramTaskRunRequest.builder()\n                .schema(customExecutorWorkflow())\n                .inputs(mapOf(\"prompt\", \"hello-flowgram\"))\n                .build();\n\n        JSONObject runResponse = postForJson(\"/flowgram/tasks/run\", request);\n        String taskId = runResponse.getString(\"taskId\");\n        assertNotNull(taskId);\n\n        JSONObject result = awaitResult(taskId);\n        assertEquals(\"success\", result.getString(\"status\"));\n        assertTrue(result.getBooleanValue(\"terminated\"));\n        assertEquals(\"custom:HELLO-FLOWGRAM\", result.getJSONObject(\"result\").getString(\"result\"));\n\n        JSONObject report = getForJson(\"/flowgram/tasks/\" + taskId + \"/report\");\n        assertEquals(\"hello-flowgram\", report.getJSONObject(\"inputs\").getString(\"prompt\"));\n        assertEquals(\"custom:HELLO-FLOWGRAM\", report.getJSONObject(\"outputs\").getString(\"result\"));\n        assertEquals(\"success\", report.getJSONObject(\"workflow\").getString(\"status\"));\n        assertEquals(\"success\", report.getJSONObject(\"nodes\").getJSONObject(\"transform_0\").getString(\"status\"));\n        assertEquals(\"hello-flowgram\", report.getJSONObject(\"nodes\").getJSONObject(\"start_0\")\n                .getJSONObject(\"inputs\").getString(\"prompt\"));\n        assertEquals(\"custom:HELLO-FLOWGRAM\", report.getJSONObject(\"nodes\").getJSONObject(\"transform_0\")\n                .getJSONObject(\"outputs\").getString(\"result\"));\n        assertEquals(\"success\", report.getJSONObject(\"trace\").getString(\"status\"));\n        assertEquals(taskId, report.getJSONObject(\"trace\").getString(\"taskId\"));\n        assertTrue(report.getJSONObject(\"trace\").getJSONArray(\"events\").size() >= 4);\n        assertEquals(8, report.getJSONObject(\"trace\").getJSONObject(\"summary\").getIntValue(\"eventCount\"));\n        assertEquals(3, report.getJSONObject(\"trace\").getJSONObject(\"summary\").getIntValue(\"nodeCount\"));\n        assertEquals(\"success\", report.getJSONObject(\"trace\").getJSONObject(\"nodes\")\n                .getJSONObject(\"transform_0\").getString(\"status\"));\n\n        FlowGramStoredTask storedTask = taskStore.find(taskId);\n        assertNotNull(storedTask);\n        assertEquals(\"success\", storedTask.getStatus());\n        assertTrue(Boolean.TRUE.equals(storedTask.getTerminated()));\n        assertEquals(\"custom:HELLO-FLOWGRAM\", storedTask.getResultSnapshot().get(\"result\"));\n\n        assertEquals(taskId, result.getJSONObject(\"trace\").getString(\"taskId\"));\n        assertEquals(\"success\", result.getJSONObject(\"trace\").getString(\"status\"));\n        assertEquals(3, result.getJSONObject(\"trace\").getJSONObject(\"summary\").getIntValue(\"nodeCount\"));\n\n        assertTrue(runtimeEventCollector.hasEventType(FlowGramRuntimeEvent.Type.TASK_FINISHED));\n        assertTrue(runtimeEventCollector.hasNodeEvent(\"transform_0\", FlowGramRuntimeEvent.Type.NODE_FINISHED));\n    }\n\n    @Test\n    public void shouldValidateWorkflowRequest() throws Exception {\n        FlowGramTaskValidateRequest request = FlowGramTaskValidateRequest.builder()\n                .schema(invalidWorkflow())\n                .inputs(Collections.<String, Object>emptyMap())\n                .build();\n\n        JSONObject response = postForJson(\"/flowgram/tasks/validate\", request);\n        assertFalse(response.getBooleanValue(\"valid\"));\n\n        JSONArray errors = response.getJSONArray(\"errors\");\n        assertTrue(errors.toJSONString().contains(\"FlowGram workflow must contain exactly one Start node\"));\n        assertTrue(errors.toJSONString().contains(\"FlowGram workflow must contain at least one End node\"));\n    }\n\n    @Test\n    public void shouldBackfillTraceMetricsFromSerializedLlmResponse() throws Exception {\n        FlowGramTaskRunRequest request = FlowGramTaskRunRequest.builder()\n                .schema(llmWorkflow())\n                .inputs(mapOf(\"prompt\", \"token-check\"))\n                .build();\n\n        JSONObject runResponse = postForJson(\"/flowgram/tasks/run\", request);\n        String taskId = runResponse.getString(\"taskId\");\n        assertNotNull(taskId);\n\n        JSONObject report = getForJson(\"/flowgram/tasks/\" + taskId + \"/report\");\n        JSONObject llmOutputs = report.getJSONObject(\"nodes\").getJSONObject(\"llm_0\").getJSONObject(\"outputs\");\n        JSONObject outputMetrics = llmOutputs.getJSONObject(\"metrics\");\n        assertEquals(159L, outputMetrics.getLongValue(\"promptTokens\"));\n        assertEquals(280L, outputMetrics.getLongValue(\"completionTokens\"));\n        assertEquals(439L, outputMetrics.getLongValue(\"totalTokens\"));\n\n        JSONObject traceMetrics = report.getJSONObject(\"trace\").getJSONObject(\"summary\").getJSONObject(\"metrics\");\n        assertEquals(159L, traceMetrics.getLongValue(\"promptTokens\"));\n        assertEquals(280L, traceMetrics.getLongValue(\"completionTokens\"));\n        assertEquals(439L, traceMetrics.getLongValue(\"totalTokens\"));\n        assertEquals(439L, report.getJSONObject(\"trace\").getJSONObject(\"nodes\")\n                .getJSONObject(\"llm_0\").getJSONObject(\"metrics\").getLongValue(\"totalTokens\"));\n\n        JSONObject result = awaitResult(taskId);\n        JSONObject resultTraceMetrics = result.getJSONObject(\"trace\").getJSONObject(\"summary\").getJSONObject(\"metrics\");\n        assertEquals(439L, resultTraceMetrics.getLongValue(\"totalTokens\"));\n    }\n\n    @Test\n    public void shouldReturnNotFoundForUnknownTask() throws Exception {\n        MvcResult mvcResult = mockMvc.perform(get(\"/flowgram/tasks/{taskId}/result\", \"missing-task\"))\n                .andExpect(status().isNotFound())\n                .andReturn();\n\n        JSONObject response = JSON.parseObject(mvcResult.getResponse().getContentAsString());\n        assertEquals(\"FLOWGRAM_TASK_NOT_FOUND\", response.getString(\"code\"));\n        assertTrue(response.getString(\"message\").contains(\"missing-task\"));\n    }\n\n    private JSONObject awaitResult(String taskId) throws Exception {\n        long deadline = System.currentTimeMillis() + 5000L;\n        while (System.currentTimeMillis() < deadline) {\n            JSONObject response = getForJson(\"/flowgram/tasks/\" + taskId + \"/result\");\n            if (response.getBooleanValue(\"terminated\")) {\n                return response;\n            }\n            Thread.sleep(20L);\n        }\n        throw new AssertionError(\"Timed out waiting for FlowGram task result\");\n    }\n\n    private JSONObject postForJson(String path, Object body) throws Exception {\n        MvcResult mvcResult = mockMvc.perform(post(path)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(JSON.toJSONString(body)))\n                .andExpect(status().isOk())\n                .andReturn();\n        return JSON.parseObject(mvcResult.getResponse().getContentAsString());\n    }\n\n    private JSONObject getForJson(String path) throws Exception {\n        MvcResult mvcResult = mockMvc.perform(get(path))\n                .andExpect(status().isOk())\n                .andReturn();\n        return JSON.parseObject(mvcResult.getResponse().getContentAsString());\n    }\n\n    private static FlowGramWorkflowSchema customExecutorWorkflow() {\n        return FlowGramWorkflowSchema.builder()\n                .nodes(Arrays.asList(\n                        node(\"start_0\", \"Start\", startData()),\n                        node(\"transform_0\", \"Transform\", transformData()),\n                        node(\"end_0\", \"End\", endData(ref(\"transform_0\", \"result\")))\n                ))\n                .edges(Arrays.asList(\n                        edge(\"start_0\", \"transform_0\"),\n                        edge(\"transform_0\", \"end_0\")\n                ))\n                .build();\n    }\n\n    private static FlowGramWorkflowSchema llmWorkflow() {\n        return FlowGramWorkflowSchema.builder()\n                .nodes(Arrays.asList(\n                        node(\"start_0\", \"Start\", startData()),\n                        node(\"llm_0\", \"LLM\", llmData()),\n                        node(\"end_0\", \"End\", endData(ref(\"llm_0\", \"result\")))\n                ))\n                .edges(Arrays.asList(\n                        edge(\"start_0\", \"llm_0\"),\n                        edge(\"llm_0\", \"end_0\")\n                ))\n                .build();\n    }\n\n    private static FlowGramWorkflowSchema invalidWorkflow() {\n        return FlowGramWorkflowSchema.builder()\n                .nodes(Collections.singletonList(\n                        node(\"transform_0\", \"Transform\", transformData())\n                ))\n                .edges(Collections.<FlowGramEdgeSchema>emptyList())\n                .build();\n    }\n\n    private static FlowGramNodeSchema node(String id, String type, Map<String, Object> data) {\n        return FlowGramNodeSchema.builder()\n                .id(id)\n                .type(type)\n                .name(id)\n                .data(data)\n                .build();\n    }\n\n    private static FlowGramEdgeSchema edge(String sourceNodeId, String targetNodeId) {\n        return FlowGramEdgeSchema.builder()\n                .sourceNodeID(sourceNodeId)\n                .targetNodeID(targetNodeId)\n                .build();\n    }\n\n    private static Map<String, Object> startData() {\n        return mapOf(\"outputs\", objectSchema(required(\"prompt\"), property(\"prompt\", stringSchema())));\n    }\n\n    private static Map<String, Object> transformData() {\n        return mapOf(\n                \"inputs\", objectSchema(required(\"text\"), property(\"text\", stringSchema())),\n                \"outputs\", objectSchema(required(\"result\"), property(\"result\", stringSchema())),\n                \"inputsValues\", mapOf(\"text\", ref(\"start_0\", \"prompt\"))\n        );\n    }\n\n    private static Map<String, Object> llmData() {\n        return mapOf(\n                \"inputs\", objectSchema(\n                        required(\"modelName\", \"prompt\"),\n                        property(\"serviceId\", stringSchema()),\n                        property(\"modelName\", stringSchema()),\n                        property(\"prompt\", stringSchema())\n                ),\n                \"outputs\", objectSchema(required(\"result\"), property(\"result\", stringSchema())),\n                \"inputsValues\", mapOf(\n                        \"serviceId\", constant(\"minimax-coding\"),\n                        \"modelName\", constant(\"MiniMax-M2.1\"),\n                        \"prompt\", ref(\"start_0\", \"prompt\")\n                )\n        );\n    }\n\n    private static Map<String, Object> endData(Map<String, Object> resultValue) {\n        return mapOf(\n                \"inputs\", objectSchema(required(\"result\"), property(\"result\", stringSchema())),\n                \"inputsValues\", mapOf(\"result\", resultValue)\n        );\n    }\n\n    private static Map<String, Object> stringSchema() {\n        return mapOf(\"type\", \"string\");\n    }\n\n    private static Map<String, Object> objectSchema(List<String> required, Map<String, Object>... properties) {\n        Map<String, Object> object = new LinkedHashMap<String, Object>();\n        object.put(\"type\", \"object\");\n        object.put(\"required\", required);\n        Map<String, Object> values = new LinkedHashMap<String, Object>();\n        if (properties != null) {\n            for (Map<String, Object> property : properties) {\n                values.putAll(property);\n            }\n        }\n        object.put(\"properties\", values);\n        return object;\n    }\n\n    private static Map<String, Object> property(String name, Map<String, Object> schema) {\n        return mapOf(name, schema);\n    }\n\n    private static List<String> required(String... names) {\n        return Arrays.asList(names);\n    }\n\n    private static Map<String, Object> ref(String... path) {\n        return mapOf(\"type\", \"ref\", \"content\", Arrays.asList(path));\n    }\n\n    private static Map<String, Object> constant(Object value) {\n        return mapOf(\"type\", \"constant\", \"content\", value);\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n\n    @SpringBootConfiguration\n    @EnableAutoConfiguration(exclude = AiConfigAutoConfiguration.class)\n    public static class TestApplication {\n\n        @Bean\n        public FlowGramLlmNodeRunner flowGramLlmNodeRunner() {\n            return new FlowGramLlmNodeRunner() {\n                @Override\n                public Map<String, Object> run(FlowGramNodeSchema node, Map<String, Object> inputs) {\n                    Map<String, Object> outputs = new LinkedHashMap<String, Object>();\n                    outputs.put(\"result\", \"llm:\" + String.valueOf(inputs.get(\"prompt\")));\n                    outputs.put(\"outputText\", \"llm:\" + String.valueOf(inputs.get(\"prompt\")));\n                    outputs.put(\"rawResponse\", new MinimaxChatCompletionResponse(\n                            \"resp-trace\",\n                            \"chat.completion\",\n                            1L,\n                            String.valueOf(inputs.get(\"modelName\")),\n                            null,\n                            new Usage(159L, 280L, 439L)));\n                    outputs.put(\"metrics\", mapOf(\n                            \"durationMillis\", 1234L,\n                            \"model\", String.valueOf(inputs.get(\"modelName\"))\n                    ));\n                    return outputs;\n                }\n            };\n        }\n\n        @Bean\n        public FlowGramNodeExecutor transformNodeExecutor() {\n            return new FlowGramNodeExecutor() {\n                @Override\n                public String getType() {\n                    return \"TRANSFORM\";\n                }\n\n                @Override\n                public FlowGramNodeExecutionResult execute(FlowGramNodeExecutionContext context) {\n                    String text = String.valueOf(context.getInputs().get(\"text\"));\n                    return FlowGramNodeExecutionResult.builder()\n                            .outputs(mapOf(\"result\", \"custom:\" + text.toUpperCase(Locale.ROOT)))\n                            .build();\n                }\n            };\n        }\n\n        @Bean\n        public TestRuntimeEventCollector testRuntimeEventCollector() {\n            return new TestRuntimeEventCollector();\n        }\n\n        @Bean\n        public FlowGramRuntimeListener flowGramRuntimeListener(TestRuntimeEventCollector collector) {\n            return collector;\n        }\n    }\n\n    public static class TestRuntimeEventCollector implements FlowGramRuntimeListener {\n\n        private final List<FlowGramRuntimeEvent> events = new CopyOnWriteArrayList<FlowGramRuntimeEvent>();\n\n        @Override\n        public void onEvent(FlowGramRuntimeEvent event) {\n            if (event != null) {\n                events.add(event);\n            }\n        }\n\n        public void clear() {\n            events.clear();\n        }\n\n        public boolean hasEventType(FlowGramRuntimeEvent.Type type) {\n            for (FlowGramRuntimeEvent event : events) {\n                if (event != null && type == event.getType()) {\n                    return true;\n                }\n            }\n            return false;\n        }\n\n        public boolean hasNodeEvent(String nodeId, FlowGramRuntimeEvent.Type type) {\n            for (FlowGramRuntimeEvent event : events) {\n                if (event != null\n                        && type == event.getType()\n                        && nodeId.equals(event.getNodeId())) {\n                    return true;\n                }\n            }\n            return false;\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramBuiltinNodeExecutorTest.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.node;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\nimport org.junit.Assert;\nimport org.junit.Assume;\nimport org.junit.Test;\n\nimport com.sun.net.httpserver.HttpExchange;\nimport com.sun.net.httpserver.HttpHandler;\nimport com.sun.net.httpserver.HttpServer;\n\nimport javax.script.ScriptEngineManager;\nimport java.io.OutputStream;\nimport java.net.InetSocketAddress;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\npublic class FlowGramBuiltinNodeExecutorTest {\n\n    @Test\n    public void shouldResolveVariableAssignments() throws Exception {\n        FlowGramVariableNodeExecutor executor = new FlowGramVariableNodeExecutor();\n        FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder()\n                .taskId(\"task-variable\")\n                .node(node(\"variable_0\", \"Variable\", mapOf(\n                        \"assign\", Arrays.asList(\n                                mapOf(\n                                        \"left\", \"summary\",\n                                        \"right\", mapOf(\"type\", \"template\", \"content\", \"hello ${start_0.result}\")\n                                )\n                        )\n                )))\n                .nodeOutputs(mapOf(\"start_0\", mapOf(\"result\", \"flowgram\")))\n                .taskInputs(Collections.<String, Object>emptyMap())\n                .inputs(Collections.<String, Object>emptyMap())\n                .locals(Collections.<String, Object>emptyMap())\n                .build());\n\n        Assert.assertEquals(\"hello flowgram\", result.getOutputs().get(\"summary\"));\n    }\n\n    @Test\n    public void shouldRunCodeNodeScript() throws Exception {\n        Assume.assumeTrue(\"Nashorn is not available\", isNashornAvailable());\n\n        FlowGramCodeNodeExecutor executor = new FlowGramCodeNodeExecutor();\n        FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder()\n                .taskId(\"task-code\")\n                .node(node(\"code_0\", \"Code\", mapOf(\n                        \"script\", mapOf(\n                                \"language\", \"javascript\",\n                                \"content\", \"function main(input) { var params = input && input.params ? input.params : {}; return { result: params.input + '-ok' }; }\"\n                        )\n                )))\n                .inputs(mapOf(\"input\", \"hello\"))\n                .taskInputs(Collections.<String, Object>emptyMap())\n                .nodeOutputs(Collections.<String, Object>emptyMap())\n                .locals(Collections.<String, Object>emptyMap())\n                .build());\n\n        Assert.assertEquals(\"hello-ok\", result.getOutputs().get(\"result\"));\n    }\n\n    @Test\n    public void shouldInvokeToolNode() throws Exception {\n        FlowGramToolNodeExecutor executor = new FlowGramToolNodeExecutor();\n        FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder()\n                .taskId(\"task-tool\")\n                .node(node(\"tool_0\", \"Tool\", Collections.<String, Object>emptyMap()))\n                .inputs(mapOf(\n                        \"toolName\", \"queryTrainInfo\",\n                        \"argumentsJson\", \"{\\\"type\\\":40}\"\n                ))\n                .taskInputs(Collections.<String, Object>emptyMap())\n                .nodeOutputs(Collections.<String, Object>emptyMap())\n                .locals(Collections.<String, Object>emptyMap())\n                .build());\n\n        Assert.assertTrue(String.valueOf(result.getOutputs().get(\"result\")).contains(\"允许发车\"));\n    }\n\n    @Test\n    public void shouldInvokeHttpNode() throws Exception {\n        HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);\n        server.createContext(\"/echo\", new HttpHandler() {\n            @Override\n            public void handle(HttpExchange exchange) {\n                try {\n                    byte[] response = \"{\\\"ok\\\":true}\".getBytes(StandardCharsets.UTF_8);\n                    exchange.getResponseHeaders().add(\"Content-Type\", \"application/json\");\n                    exchange.sendResponseHeaders(200, response.length);\n                    OutputStream outputStream = exchange.getResponseBody();\n                    try {\n                        outputStream.write(response);\n                    } finally {\n                        outputStream.close();\n                    }\n                } catch (Exception ex) {\n                    throw new RuntimeException(ex);\n                }\n            }\n        });\n        server.start();\n        try {\n            int port = server.getAddress().getPort();\n            FlowGramHttpNodeExecutor executor = new FlowGramHttpNodeExecutor();\n            FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder()\n                    .taskId(\"task-http\")\n                    .node(node(\"http_0\", \"HTTP\", mapOf(\n                            \"api\", mapOf(\n                                    \"method\", \"GET\",\n                                    \"url\", mapOf(\"type\", \"constant\", \"content\", \"http://127.0.0.1:\" + port + \"/echo\")\n                            ),\n                            \"headersValues\", mapOf(\n                                    \"X-Test\", mapOf(\"type\", \"constant\", \"content\", \"yes\")\n                            ),\n                            \"paramsValues\", Collections.<String, Object>emptyMap(),\n                            \"timeout\", mapOf(\n                                    \"timeout\", 2000,\n                                    \"retryTimes\", 1\n                            ),\n                            \"body\", mapOf(\n                                    \"bodyType\", \"none\"\n                            )\n                    )))\n                    .inputs(Collections.<String, Object>emptyMap())\n                    .taskInputs(Collections.<String, Object>emptyMap())\n                    .nodeOutputs(Collections.<String, Object>emptyMap())\n                    .locals(Collections.<String, Object>emptyMap())\n                    .build());\n\n            Assert.assertEquals(200, result.getOutputs().get(\"statusCode\"));\n            Assert.assertEquals(\"{\\\"ok\\\":true}\", result.getOutputs().get(\"body\"));\n        } finally {\n            server.stop(0);\n        }\n    }\n\n    private static FlowGramNodeSchema node(String id, String type, Map<String, Object> data) {\n        return FlowGramNodeSchema.builder()\n                .id(id)\n                .type(type)\n                .name(id)\n                .data(data)\n                .build();\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n\n    private boolean isNashornAvailable() {\n        return new ScriptEngineManager().getEngineByName(\"nashorn\") != null;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/node/FlowGramKnowledgeRetrieveNodeExecutorTest.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.node;\n\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionContext;\nimport io.github.lnyocly.ai4j.agent.flowgram.FlowGramNodeExecutionResult;\nimport io.github.lnyocly.ai4j.agent.flowgram.model.FlowGramNodeSchema;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.Embedding;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingObject;\nimport io.github.lnyocly.ai4j.platform.openai.embedding.entity.EmbeddingResponse;\nimport io.github.lnyocly.ai4j.rag.DefaultRagContextAssembler;\nimport io.github.lnyocly.ai4j.rag.NoopReranker;\nimport io.github.lnyocly.ai4j.service.IAudioService;\nimport io.github.lnyocly.ai4j.service.IChatService;\nimport io.github.lnyocly.ai4j.service.IEmbeddingService;\nimport io.github.lnyocly.ai4j.service.IImageService;\nimport io.github.lnyocly.ai4j.service.IRealtimeService;\nimport io.github.lnyocly.ai4j.service.IResponsesService;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceRegistration;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceRegistry;\nimport io.github.lnyocly.ai4j.vector.store.VectorDeleteRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchRequest;\nimport io.github.lnyocly.ai4j.vector.store.VectorSearchResult;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.VectorStoreCapabilities;\nimport io.github.lnyocly.ai4j.vector.store.VectorUpsertRequest;\nimport org.junit.Assert;\nimport org.junit.Test;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class FlowGramKnowledgeRetrieveNodeExecutorTest {\n\n    @Test\n    public void shouldProduceStructuredKnowledgeOutputs() throws Exception {\n        FlowGramKnowledgeRetrieveNodeExecutor executor = new FlowGramKnowledgeRetrieveNodeExecutor(\n                new FakeRegistry(),\n                new FakeVectorStore(),\n                new NoopReranker(),\n                new DefaultRagContextAssembler()\n        );\n\n        FlowGramNodeExecutionResult result = executor.execute(FlowGramNodeExecutionContext.builder()\n                .taskId(\"task-knowledge\")\n                .node(node(\"knowledge_0\", \"KNOWLEDGE\", Collections.<String, Object>emptyMap()))\n                .inputs(mapOf(\n                        \"serviceId\", \"main\",\n                        \"embeddingModel\", \"text-embedding-3-small\",\n                        \"namespace\", \"tenant_docs\",\n                        \"query\", \"vacation policy\",\n                        \"topK\", 3,\n                        \"filter\", \"{\\\"tenant\\\":\\\"acme\\\"}\"\n                ))\n                .taskInputs(Collections.<String, Object>emptyMap())\n                .nodeOutputs(Collections.<String, Object>emptyMap())\n                .locals(Collections.<String, Object>emptyMap())\n                .build());\n\n        Assert.assertEquals(1, result.getOutputs().get(\"count\"));\n        Assert.assertTrue(String.valueOf(result.getOutputs().get(\"context\")).contains(\"[S1]\"));\n        Assert.assertTrue(result.getOutputs().containsKey(\"matches\"));\n        Assert.assertTrue(result.getOutputs().containsKey(\"hits\"));\n        Assert.assertTrue(result.getOutputs().containsKey(\"citations\"));\n        Assert.assertTrue(result.getOutputs().containsKey(\"sources\"));\n        Assert.assertTrue(result.getOutputs().containsKey(\"trace\"));\n        Assert.assertTrue(result.getOutputs().containsKey(\"retrievedHits\"));\n        Assert.assertTrue(result.getOutputs().containsKey(\"rerankedHits\"));\n\n        List<Map<String, Object>> hits = (List<Map<String, Object>>) result.getOutputs().get(\"hits\");\n        Assert.assertEquals(1, hits.size());\n        Assert.assertEquals(1, ((Number) hits.get(0).get(\"rank\")).intValue());\n        Assert.assertEquals(\"dense\", String.valueOf(hits.get(0).get(\"retrieverSource\")));\n        Assert.assertEquals(0.91d, ((Number) hits.get(0).get(\"retrievalScore\")).doubleValue(), 0.0001d);\n    }\n\n    private static FlowGramNodeSchema node(String id, String type, Map<String, Object> data) {\n        return FlowGramNodeSchema.builder()\n                .id(id)\n                .type(type)\n                .name(id)\n                .data(data)\n                .build();\n    }\n\n    private static Map<String, Object> mapOf(Object... keyValues) {\n        Map<String, Object> map = new LinkedHashMap<String, Object>();\n        for (int i = 0; i < keyValues.length; i += 2) {\n            map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);\n        }\n        return map;\n    }\n\n    private static class FakeRegistry implements AiServiceRegistry {\n        @Override\n        public AiServiceRegistration find(String id) {\n            return null;\n        }\n\n        @Override\n        public Set<String> ids() {\n            return Collections.singleton(\"main\");\n        }\n\n        @Override\n        public IEmbeddingService getEmbeddingService(String id) {\n            return new IEmbeddingService() {\n                @Override\n                public EmbeddingResponse embedding(String baseUrl, String apiKey, Embedding embeddingReq) {\n                    return embedding(embeddingReq);\n                }\n\n                @Override\n                public EmbeddingResponse embedding(Embedding embeddingReq) {\n                    return EmbeddingResponse.builder()\n                            .object(\"list\")\n                            .model(embeddingReq.getModel())\n                            .data(Collections.singletonList(EmbeddingObject.builder()\n                                    .index(0)\n                                    .embedding(Arrays.asList(0.1f, 0.2f))\n                                    .object(\"embedding\")\n                                    .build()))\n                            .build();\n                }\n            };\n        }\n\n        @Override\n        public IChatService getChatService(String id) {\n            return null;\n        }\n\n        @Override\n        public IAudioService getAudioService(String id) {\n            return null;\n        }\n\n        @Override\n        public IRealtimeService getRealtimeService(String id) {\n            return null;\n        }\n\n        @Override\n        public IImageService getImageService(String id) {\n            return null;\n        }\n\n        @Override\n        public IResponsesService getResponsesService(String id) {\n            return null;\n        }\n    }\n\n    private static class FakeVectorStore implements VectorStore {\n        @Override\n        public int upsert(VectorUpsertRequest request) {\n            return 0;\n        }\n\n        @Override\n        public List<VectorSearchResult> search(VectorSearchRequest request) {\n            return Collections.singletonList(VectorSearchResult.builder()\n                    .id(\"chunk-1\")\n                    .score(0.91f)\n                    .content(\"Employees receive 15 days of annual leave.\")\n                    .metadata(mapOf(\n                            \"sourceName\", \"employee-handbook.pdf\",\n                            \"sourcePath\", \"/docs/employee-handbook.pdf\",\n                            \"pageNumber\", \"6\",\n                            \"sectionTitle\", \"Vacation Policy\"\n                    ))\n                    .build());\n        }\n\n        @Override\n        public boolean delete(VectorDeleteRequest request) {\n            return false;\n        }\n\n        @Override\n        public VectorStoreCapabilities capabilities() {\n            return VectorStoreCapabilities.builder().dataset(true).metadataFilter(true).build();\n        }\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/flowgram/springboot/support/JdbcFlowGramTaskStoreTest.java",
    "content": "package io.github.lnyocly.ai4j.flowgram.springboot.support;\n\nimport org.h2.jdbcx.JdbcDataSource;\nimport org.junit.Test;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\nimport static org.junit.Assert.assertEquals;\nimport static org.junit.Assert.assertNotNull;\nimport static org.junit.Assert.assertTrue;\n\npublic class JdbcFlowGramTaskStoreTest {\n\n    @Test\n    public void shouldPersistAndUpdateTaskState() {\n        JdbcFlowGramTaskStore store = new JdbcFlowGramTaskStore(dataSource(\"store\"), \"ai4j_flowgram_task_test\", true);\n\n        store.save(FlowGramStoredTask.builder()\n                .taskId(\"task-1\")\n                .creatorId(\"user-1\")\n                .tenantId(\"tenant-1\")\n                .createdAt(100L)\n                .expiresAt(200L)\n                .status(\"pending\")\n                .terminated(false)\n                .resultSnapshot(Collections.<String, Object>singletonMap(\"step\", \"start\"))\n                .build());\n\n        Map<String, Object> result = new LinkedHashMap<String, Object>();\n        result.put(\"answer\", \"ok\");\n        store.updateState(\"task-1\", \"success\", true, null, result);\n\n        FlowGramStoredTask task = store.find(\"task-1\");\n        assertNotNull(task);\n        assertEquals(\"user-1\", task.getCreatorId());\n        assertEquals(\"tenant-1\", task.getTenantId());\n        assertEquals(\"success\", task.getStatus());\n        assertTrue(Boolean.TRUE.equals(task.getTerminated()));\n        assertEquals(\"ok\", task.getResultSnapshot().get(\"answer\"));\n    }\n\n    private JdbcDataSource dataSource(String suffix) {\n        JdbcDataSource dataSource = new JdbcDataSource();\n        dataSource.setURL(\"jdbc:h2:mem:ai4j_flowgram_\" + suffix + \";MODE=MYSQL;DB_CLOSE_DELAY=-1\");\n        dataSource.setUser(\"sa\");\n        dataSource.setPassword(\"\");\n        return dataSource;\n    }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/.eslintrc.js",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nconst { defineConfig } = require('@flowgram.ai/eslint-config');\n\nmodule.exports = defineConfig({\n  preset: 'web',\n  packageRoot: __dirname,\n  rules: {\n    'no-console': 'off',\n    'react/prop-types': 'off',\n  },\n  settings: {\n    react: {\n      version: 'detect', // 自动检测 React 版本\n    },\n  },\n});\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/.gitignore",
    "content": "node_modules/\ndist/\n.rsbuild/\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/README.md",
    "content": "# FlowGram.AI - Demo Free Layout\n\nBest-practice demo for free layout\n\n## Installation\n\n```shell\nnpx @flowgram.ai/create-app@latest free-layout\n```\n\n## Project Overview\n\n### Core Tech Stack\n- **Frontend framework**: React 18 + TypeScript\n- **Build tool**: Rsbuild (a modern build tool based on Rspack)\n- **Styling**: Less + Styled Components + CSS Variables\n- **UI library**: Semi Design (@douyinfe/semi-ui)\n- **State management**: Flowgram’s in-house editor framework\n- **Dependency injection**: Inversify\n\n### Core Dependencies\n\n- **@flowgram.ai/free-layout-editor**: Core dependency for the free layout editor\n- **@flowgram.ai/free-snap-plugin**: Auto-alignment and guide-lines plugin\n- **@flowgram.ai/free-lines-plugin**: Connection line rendering plugin\n- **@flowgram.ai/free-node-panel-plugin**: Node add-panel rendering plugin\n- **@flowgram.ai/minimap-plugin**: Minimap plugin\n- **@flowgram.ai/export-plugin**: Download/export plugin\n- **@flowgram.ai/free-container-plugin**: Sub-canvas plugin\n- **@flowgram.ai/free-group-plugin**: Grouping plugin\n- **@flowgram.ai/form-materials**: Form materials\n- **@flowgram.ai/runtime-interface**: Runtime interfaces\n- **@flowgram.ai/runtime-js**: JS runtime module\n- **@flowgram.ai/panel-manager-plugin**:  Sidebar panel management\n\n## Code Guide\n\n### Directory Structure\n```\nsrc/\n├── app.tsx                  # Application entry file\n├── editor.tsx               # Main editor component\n├── initial-data.ts          # Initial data configuration\n├── assets/                  # Static assets\n├── components/              # Component library\n│   ├── index.ts\n│   ├── add-node/            # Add-node component\n│   ├── base-node/           # Base node components\n│   ├── comment/             # Comment components\n│   ├── group/               # Group components\n│   ├── line-add-button/     # Connection add button\n│   ├── node-menu/           # Node menu\n│   ├── node-panel/          # Node add panel\n│   ├── selector-box-popover/ # Selection box popover\n│   ├── sidebar/             # Sidebar\n│   ├── testrun/             # Test-run module\n│   │   ├── hooks/           # Test-run hooks\n│   │   ├── node-status-bar/ # Node status bar\n│   │   ├── testrun-button/  # Test-run button\n│   │   ├── testrun-form/    # Test-run form\n│   │   ├── testrun-json-input/ # JSON input component\n│   │   └── testrun-panel/   # Test-run panel\n│   └── tools/               # Utility components\n├── context/                 # React Context\n│   ├── node-render-context.ts # Current rendering node context\n│   ├── sidebar-context        # Sidebar context\n├── form-components/         # Form component library\n│   ├── form-content/        # Form content\n│   ├── form-header/         # Form header\n│   ├── form-inputs/         # Form inputs\n│   └── form-item/           # Form item\n│   └── feedback.tsx         # Validation error rendering\n├── hooks/\n│   ├── index.ts\n│   ├── use-editor-props.tsx # Editor props hook\n│   ├── use-is-sidebar.ts    # Sidebar state hook\n│   ├── use-node-render-context.ts # Node render context hook\n│   └── use-port-click.ts    # Port click hook\n├── nodes/                    # Node definitions\n│   ├── index.ts\n│   ├── constants.ts         # Node constants\n│   ├── default-form-meta.ts # Default form metadata\n│   ├── block-end/           # Block end node\n│   ├── block-start/         # Block start node\n│   ├── break/               # Break node\n│   ├── code/                # Code node\n│   ├── comment/             # Comment node\n│   ├── condition/           # Condition node\n│   ├── continue/            # Continue node\n│   ├── end/                 # End node\n│   ├── group/               # Group node\n│   ├── http/                # HTTP node\n│   ├── llm/                 # LLM node\n│   ├── loop/                # Loop node\n│   ├── start/               # Start node\n│   └── variable/            # Variable node\n├── plugins/                 # Plugin system\n│   ├── index.ts\n│   ├── context-menu-plugin/ # Right-click context menu plugin\n│   ├── runtime-plugin/      # Runtime plugin\n│   │   ├── client/          # Client\n│   │   │   ├── browser-client/ # Browser client\n│   │   │   └── server-client/  # Server client\n│   │   └── runtime-service/ # Runtime service\n│   └── variable-panel-plugin/ # Variable panel plugin\n│       └── components/      # Variable panel components\n├── services/                 # Service layer\n│   ├── index.ts\n│   └── custom-service.ts    # Custom service\n├── shortcuts/                # Shortcuts system\n│   ├── index.ts\n│   ├── constants.ts         # Shortcut constants\n│   ├── shortcuts.ts         # Shortcut definitions\n│   ├── type.ts              # Type definitions\n│   ├── collapse/            # Collapse shortcut\n│   ├── copy/                # Copy shortcut\n│   ├── delete/              # Delete shortcut\n│   ├── expand/              # Expand shortcut\n│   ├── paste/               # Paste shortcut\n│   ├── select-all/          # Select-all shortcut\n│   ├── zoom-in/             # Zoom-in shortcut\n│   └── zoom-out/            # Zoom-out shortcut\n├── styles/                   # Styles\n├── typings/                  # Type definitions\n│   ├── index.ts\n│   ├── json-schema.ts       # JSON Schema types\n│   └── node.ts              # Node type definitions\n└── utils/                    # Utility functions\n    ├── index.ts\n    └── on-drag-line-end.ts  # Handle end of drag line\n```\n\n### Key Directory Functions\n\n#### 1. `/components` - Component Library\n- **base-node**: Base rendering components for all nodes\n- **testrun**: Complete test-run module, including status bar, form, and panel\n- **sidebar**: Sidebar components providing tools and property panels\n- **node-panel**: Node add panel with drag-to-add capability\n\n#### 2. `/nodes` - Node System\nEach node type has its own directory, including:\n- Node registration (`index.ts`)\n- Form metadata (`form-meta.ts`)\n- Node-specific components and logic\n\n#### 3. `/plugins` - Plugin System\n- **runtime-plugin**: Supports both browser and server modes\n- **context-menu-plugin**: Right-click context menu\n- **variable-panel-plugin**: Variable management panel\n\n#### 4. `/shortcuts` - Shortcuts System\nComplete keyboard shortcut support, including:\n- Basic actions: copy, paste, delete, select-all\n- View actions: zoom-in, zoom-out, collapse, expand\n- Each shortcut has its own implementation module\n\n## Application Architecture\n\n### Core Design Patterns\n\n#### 1. Plugin Architecture\nHighly modular plugin system; each feature is an independent plugin:\n\n```typescript\nplugins: () => [\n  createFreeLinesPlugin({ renderInsideLine: LineAddButton }),\n  createMinimapPlugin({ /* config */ }),\n  createFreeSnapPlugin({ /* alignment config */ }),\n  createFreeNodePanelPlugin({ renderer: NodePanel }),\n  createContainerNodePlugin({}),\n  createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }),\n  createContextMenuPlugin({}),\n  createRuntimePlugin({ mode: 'browser' }),\n  createVariablePanelPlugin({})\n]\n```\n\n#### 2. Node Registry Pattern\nManage different workflow node types via a registry:\n\n```typescript\nexport const nodeRegistries: FlowNodeRegistry[] = [\n  ConditionNodeRegistry,    // Condition node\n  StartNodeRegistry,        // Start node\n  EndNodeRegistry,          // End node\n  LLMNodeRegistry,          // LLM node\n  LoopNodeRegistry,         // Loop node\n  CommentNodeRegistry,      // Comment node\n  HTTPNodeRegistry,         // HTTP node\n  CodeNodeRegistry,         // Code node\n  // ... more node types\n];\n```\n\n#### 3. Dependency Injection\nUse Inversify for service DI:\n\n```typescript\nonBind: ({ bind }) => {\n  bind(CustomService).toSelf().inSingletonScope();\n}\n```\n\n## Core Features\n\n### 1. Editor Configuration System\n\n`useEditorProps` is the configuration center of the editor:\n\n```typescript\nexport function useEditorProps(\n  initialData: FlowDocumentJSON,\n  nodeRegistries: FlowNodeRegistry[]\n): FreeLayoutProps {\n  return useMemo<FreeLayoutProps>(() => ({\n    background: true,                    // Background grid\n    readonly: false,                     // Readonly mode\n    initialData,                         // Initial data\n    nodeRegistries,                      // Node registries\n\n    // Core feature configs\n    playground: { preventGlobalGesture: true /* Prevent Mac browser swipe gestures */ },\n    nodeEngine: { enable: true },\n    variableEngine: { enable: true },\n    history: { enable: true, enableChangeNode: true },\n\n    // Business rules\n    canAddLine: (ctx, fromPort, toPort) => { /* Connection rules */ },\n    canDeleteLine: (ctx, line) => { /* Line deletion rules */ },\n    canDeleteNode: (ctx, node) => { /* Node deletion rules */ },\n    canDropToNode: (ctx, params) => { /* Drag-and-drop rules */ },\n\n    // Plugins\n    plugins: () => [/* Plugin list */],\n\n    // Events\n    onContentChange: debounce((ctx, event) => { /* Auto save */ }, 1000),\n    onInit: (ctx) => { /* Initialization */ },\n    onAllLayersRendered: (ctx) => { /* After render */ }\n  }), []);\n}\n```\n\n### 2. Node Type System\n\nThe app supports multiple workflow node types:\n\n```typescript\nexport enum WorkflowNodeType {\n  Start = 'start',           // Start node\n  End = 'end',               // End node\n  LLM = 'llm',               // Large language model node\n  HTTP = 'http',             // HTTP request node\n  Code = 'code',             // Code execution node\n  Variable = 'variable',     // Variable node\n  Condition = 'condition',   // Conditional node\n  Loop = 'loop',             // Loop node\n  BlockStart = 'block-start', // Sub-canvas start node\n  BlockEnd = 'block-end',    // Sub-canvas end node\n  Comment = 'comment',       // Comment node\n  Continue = 'continue',     // Continue node\n  Break = 'break',           // Break node\n}\n```\n\nEach node follows a unified registration pattern:\n\n```typescript\nexport const StartNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Start,\n  meta: {\n    isStart: true,\n    deleteDisable: true,        // Not deletable\n    copyDisable: true,          // Not copyable\n    nodePanelVisible: false,    // Hidden in node panel\n    defaultPorts: [{ type: 'output' }],\n    size: { width: 360, height: 211 }\n  },\n  info: {\n    icon: iconStart,\n    description: 'The starting node of the workflow, used to set up information needed to launch the workflow.'\n  },\n  formMeta,                     // Form configuration\n  canAdd() { return false; }    // Disallow multiple start nodes\n};\n```\n\n### 3. Plugin Architecture\n\nApp features are modularized via the plugin system:\n\n#### Core Plugin List\n1. **FreeLinesPlugin** - Connection rendering and interaction\n2. **MinimapPlugin** - Minimap navigation\n3. **FreeSnapPlugin** - Auto-alignment and guide-lines\n4. **FreeNodePanelPlugin** - Node add panel\n5. **ContainerNodePlugin** - Container nodes (e.g., loop nodes)\n6. **FreeGroupPlugin** - Node grouping\n7. **ContextMenuPlugin** - Right-click context menu\n8. **RuntimePlugin** - Workflow runtime\n9. **VariablePanelPlugin** - Variable management panel\n\n### 4. Runtime System\n\nTwo run modes are supported:\n\n```typescript\ncreateRuntimePlugin({\n  mode: 'browser',              // Browser mode\n  // mode: 'server',            // Server mode\n  // serverConfig: {\n  //   domain: 'localhost',\n  //   port: 4000,\n  //   protocol: 'http',\n  // },\n})\n```\n\n## Design Philosophy and Advantages\n\n### 1. Highly Modular\n- **Plugin architecture**: Each feature is an independent plugin, easy to extend and maintain\n- **Node registry system**: Add new node types without changing core code\n- **Componentized UI**: Highly reusable components with clear responsibilities\n\n### 2. Type Safety\n- **Full TypeScript support**: End-to-end type safety from configuration to runtime\n- **JSON Schema integration**: Node data validated by schemas\n- **Strongly typed plugin interfaces**: Clear type constraints for plugin development\n\n### 3. User Experience\n- **Real-time preview**: Run and debug workflows live\n- **Rich interactions**: Dragging, zooming, snapping, shortcuts for a complete editing experience\n- **Visual feedback**: Minimap, status indicators, line animations\n\n### 4. Extensibility\n- **Open plugin system**: Third parties can easily develop custom plugins\n- **Flexible node system**: Custom node types and form configurations supported\n- **Multiple runtimes**: Both browser and server modes\n\n### 5. Performance\n- **On-demand loading**: Components and plugins support lazy loading\n- **Debounce**: Performance optimizations for high-frequency operations like auto-save\n\n## Technical Highlights\n\n### 1. In-house Editor Framework\nBased on `@flowgram.ai/free-layout-editor`, providing:\n- Free-layout canvas system\n- Full undo/redo functionality\n- Lifecycle management for nodes and connections\n- Variable engine and expression system\n\n### 2. Advanced Build Configuration\nUsing Rsbuild as the build tool:\n\n```typescript\nexport default defineConfig({\n  plugins: [pluginReact(), pluginLess()],\n  source: {\n    entry: { index: './src/app.tsx' },\n    decorators: { version: 'legacy' }  // Enable decorators\n  },\n  tools: {\n    rspack: {\n      ignoreWarnings: [/Critical dependency/]  // Ignore specific warnings\n    }\n  }\n});\n```\n\n### 3. Internationalization\nBuilt-in multilingual support:\n\n```typescript\ni18n: {\n  locale: navigator.language,\n  languages: {\n    'zh-CN': {\n      'Never Remind': '不再提示',\n      'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',\n    },\n    'en-US': {},\n  }\n}\n```\n\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/README.zh_CN.md",
    "content": "# FlowGram.AI - Demo Free Layout\n\n自由布局最佳实践 demo\n\n## 安装\n\n```shell\nnpx @flowgram.ai/create-app@latest free-layout\n```\n\n## 项目概览\n\n### 核心技术栈\n- **前端框架**: React 18 + TypeScript\n- **构建工具**: Rsbuild (基于 Rspack 的现代构建工具)\n- **样式方案**: Less + Styled Components + CSS Variables\n- **UI 组件库**: Semi Design (@douyinfe/semi-ui)\n- **状态管理**: 基于 Flowgram 自研的编辑器框架\n- **依赖注入**: Inversify\n\n### 核心依赖包\n\n- **@flowgram.ai/free-layout-editor**: 自由布局编辑器核心依赖\n- **@flowgram.ai/free-snap-plugin**: 自动对齐及辅助线插件\n- **@flowgram.ai/free-lines-plugin**: 连线渲染插件\n- **@flowgram.ai/free-node-panel-plugin**: 节点添加面板渲染插件\n- **@flowgram.ai/minimap-plugin**: 缩略图插件\n- **@flowgram.ai/export-plugin**: 下载导出插件\n- **@flowgram.ai/free-container-plugin**: 子画布插件\n- **@flowgram.ai/free-group-plugin**: 分组插件\n- **@flowgram.ai/form-materials**: 表单物料\n- **@flowgram.ai/runtime-interface**: 运行时接口\n- **@flowgram.ai/runtime-js**: js 运行时模块\n- **@flowgram.ai/panel-manager-plugin**:  侧边栏面板管理\n\n## 代码说明\n\n### 目录结构\n```\nsrc/\n├── app.tsx                  # 应用入口文件\n├── editor.tsx               # 编辑器主组件\n├── initial-data.ts          # 初始化数据配置\n├── assets/                  # 静态资源\n├── components/              # 组件库\n│   ├── index.ts\n│   ├── add-node/            # 添加节点组件\n│   ├── base-node/           # 基础节点组件\n│   ├── comment/             # 注释组件\n│   ├── group/               # 分组组件\n│   ├── line-add-button/     # 连线添加按钮\n│   ├── node-menu/           # 节点菜单\n│   ├── node-panel/          # 节点添加面板\n│   ├── selector-box-popover/ # 选择框弹窗\n│   ├── sidebar/             # 侧边栏\n│   ├── testrun/             # 测试运行组件\n│   │   ├── hooks/           # 测试运行钩子\n│   │   ├── node-status-bar/ # 节点状态栏\n│   │   ├── testrun-button/  # 测试运行按钮\n│   │   ├── testrun-form/    # 测试运行表单\n│   │   ├── testrun-json-input/ # JSON输入组件\n│   │   └── testrun-panel/   # 测试运行面板\n│   └── tools/               # 工具组件\n├── context/                 # React Context\n│   ├── node-render-context.ts # 当前渲染节点 Context\n│   ├── sidebar-context        # 侧边栏 Context\n├── form-components/         # 表单组件库\n│   ├── form-content/        # 表单内容\n│   ├── form-header/         # 表单头部\n│   ├── form-inputs/         # 表单输入\n│   └── form-item/           # 表单项\n│   └── feedback.tsx         # 表单校验错误渲染\n├── hooks/\n│   ├── index.ts\n│   ├── use-editor-props.tsx # 编辑器属性钩子\n│   ├── use-is-sidebar.ts    # 侧边栏状态钩子\n│   ├── use-node-render-context.ts # 节点渲染上下文钩子\n│   └── use-port-click.ts    # 端口点击钩子\n├── nodes/                    # 节点定义\n│   ├── index.ts\n│   ├── constants.ts         # 节点常量定义\n│   ├── default-form-meta.ts # 默认表单元数据\n│   ├── block-end/           # 块结束节点\n│   ├── block-start/         # 块开始节点\n│   ├── break/               # 中断节点\n│   ├── code/                # 代码节点\n│   ├── comment/             # 注释节点\n│   ├── condition/           # 条件节点\n│   ├── continue/            # 继续节点\n│   ├── end/                 # 结束节点\n│   ├── group/               # 分组节点\n│   ├── http/                # HTTP节点\n│   ├── llm/                 # LLM节点\n│   ├── loop/                # 循环节点\n│   ├── start/               # 开始节点\n│   └── variable/            # 变量节点\n├── plugins/                 # 插件系统\n│   ├── index.ts\n│   ├── context-menu-plugin/ # 右键菜单插件\n│   ├── runtime-plugin/      # 运行时插件\n│   │   ├── client/          # 客户端\n│   │   │   ├── browser-client/ # 浏览器客户端\n│   │   │   └── server-client/  # 服务器客户端\n│   │   └── runtime-service/ # 运行时服务\n│   └── variable-panel-plugin/ # 变量面板插件\n│       └── components/      # 变量面板组件\n├── services/                 # 服务层\n│   ├── index.ts\n│   └── custom-service.ts    # 自定义服务\n├── shortcuts/                # 快捷键系统\n│   ├── index.ts\n│   ├── constants.ts         # 快捷键常量\n│   ├── shortcuts.ts         # 快捷键定义\n│   ├── type.ts              # 类型定义\n│   ├── collapse/            # 折叠快捷键\n│   ├── copy/                # 复制快捷键\n│   ├── delete/              # 删除快捷键\n│   ├── expand/              # 展开快捷键\n│   ├── paste/               # 粘贴快捷键\n│   ├── select-all/          # 全选快捷键\n│   ├── zoom-in/             # 放大快捷键\n│   └── zoom-out/            # 缩小快捷键\n├── styles/                   # 样式文件\n├── typings/                  # 类型定义\n│   ├── index.ts\n│   ├── json-schema.ts       # JSON Schema类型\n│   └── node.ts              # 节点类型定义\n└── utils/                    # 工具函数\n    ├── index.ts\n    └── on-drag-line-end.ts  # 拖拽连线结束处理\n```\n\n### 关键目录功能说明\n\n#### 1. `/components` - 组件库\n- **base-node**: 所有节点的基础渲染组件\n- **testrun**: 完整的测试运行功能模块，包含状态栏、表单、面板等\n- **sidebar**: 侧边栏组件，提供工具和属性面板\n- **node-panel**: 节点添加面板，支持拖拽添加新节点\n\n#### 2. `/nodes` - 节点系统\n每个节点类型都有独立的目录，包含：\n- 节点注册信息 (`index.ts`)\n- 表单元数据定义 (`form-meta.ts`)\n- 节点特定的组件和逻辑\n\n#### 3. `/plugins` - 插件系统\n- **runtime-plugin**: 支持浏览器和服务器两种运行模式\n- **context-menu-plugin**: 右键菜单功能\n- **variable-panel-plugin**: 变量管理面板\n\n#### 4. `/shortcuts` - 快捷键系统\n完整的快捷键支持，包括：\n- 基础操作：复制、粘贴、删除、全选\n- 视图操作：放大、缩小、折叠、展开\n- 每个快捷键都有独立的实现模块\n\n## 应用架构设计\n\n### 核心设计模式\n\n#### 1. 插件化架构 (Plugin Architecture)\n应用采用高度模块化的插件系统，每个功能都作为独立插件存在：\n\n```typescript\nplugins: () => [\n  createFreeLinesPlugin({ renderInsideLine: LineAddButton }),\n  createMinimapPlugin({ /* 配置 */ }),\n  createFreeSnapPlugin({ /* 对齐配置 */ }),\n  createFreeNodePanelPlugin({ renderer: NodePanel }),\n  createContainerNodePlugin({}),\n  createFreeGroupPlugin({ groupNodeRender: GroupNodeRender }),\n  createContextMenuPlugin({}),\n  createRuntimePlugin({ mode: 'browser' }),\n  createVariablePanelPlugin({})\n]\n```\n\n#### 2. 节点注册系统 (Node Registry Pattern)\n通过注册表模式管理不同类型的工作流节点：\n\n```typescript\nexport const nodeRegistries: FlowNodeRegistry[] = [\n  ConditionNodeRegistry,    // 条件节点\n  StartNodeRegistry,        // 开始节点\n  EndNodeRegistry,          // 结束节点\n  LLMNodeRegistry,          // LLM节点\n  LoopNodeRegistry,         // 循环节点\n  CommentNodeRegistry,      // 注释节点\n  HTTPNodeRegistry,         // HTTP节点\n  CodeNodeRegistry,         // 代码节点\n  // ... 更多节点类型\n];\n```\n\n#### 3. 依赖注入模式 (Dependency Injection)\n使用 Inversify 框架实现服务的依赖注入：\n\n```typescript\nonBind: ({ bind }) => {\n  bind(CustomService).toSelf().inSingletonScope();\n}\n```\n\n## 核心功能分析\n\n### 1. 编辑器配置系统\n\n`useEditorProps` 是整个编辑器的配置中心，包含：\n\n```typescript\nexport function useEditorProps(\n  initialData: FlowDocumentJSON,\n  nodeRegistries: FlowNodeRegistry[]\n): FreeLayoutProps {\n  return useMemo<FreeLayoutProps>(() => ({\n    background: true,                    // 背景网格\n    readonly: false,                     // 是否只读\n    initialData,                         // 初始数据\n    nodeRegistries,                      // 节点注册表\n\n    // 核心功能配置\n    playground: { preventGlobalGesture: true /* 阻止 mac 浏览器手势翻页 */ },\n    nodeEngine: { enable: true },\n    variableEngine: { enable: true },\n    history: { enable: true, enableChangeNode: true },\n\n    // 业务逻辑配置\n    canAddLine: (ctx, fromPort, toPort) => { /* 连线规则 */ },\n    canDeleteLine: (ctx, line) => { /* 删除连线规则 */ },\n    canDeleteNode: (ctx, node) => { /* 删除节点规则 */ },\n    canDropToNode: (ctx, params) => { /* 拖拽规则 */ },\n\n    // 插件配置\n    plugins: () => [/* 插件列表 */],\n\n    // 事件处理\n    onContentChange: debounce((ctx, event) => { /* 自动保存 */ }, 1000),\n    onInit: (ctx) => { /* 初始化 */ },\n    onAllLayersRendered: (ctx) => { /* 渲染完成 */ }\n  }), []);\n}\n```\n\n### 2. 节点类型系统\n\n应用支持多种工作流节点类型：\n\n```typescript\nexport enum WorkflowNodeType {\n  Start = 'start',           // 开始节点\n  End = 'end',               // 结束节点\n  LLM = 'llm',               // 大语言模型节点\n  HTTP = 'http',             // HTTP请求节点\n  Code = 'code',             // 代码执行节点\n  Variable = 'variable',     // 变量节点\n  Condition = 'condition',   // 条件判断节点\n  Loop = 'loop',             // 循环节点\n  BlockStart = 'block-start', // 子画布开始节点\n  BlockEnd = 'block-end',    // 子画布结束节点\n  Comment = 'comment',       // 注释节点\n  Continue = 'continue',     // 继续节点\n  Break = 'break',           // 中断节点\n}\n```\n\n每个节点都遵循统一的注册模式：\n\n```typescript\nexport const StartNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Start,\n  meta: {\n    isStart: true,\n    deleteDisable: true,        // 不可删除\n    copyDisable: true,          // 不可复制\n    nodePanelVisible: false,    // 不在节点面板显示\n    defaultPorts: [{ type: 'output' }],\n    size: { width: 360, height: 211 }\n  },\n  info: {\n    icon: iconStart,\n    description: '工作流的起始节点，用于设置启动工作流所需的信息。'\n  },\n  formMeta,                     // 表单配置\n  canAdd() { return false; }    // 不允许添加多个开始节点\n};\n```\n\n### 3. 插件化架构\n\n应用的功能通过插件系统实现模块化：\n\n#### 核心插件列表\n1. **FreeLinesPlugin** - 连线渲染和交互\n2. **MinimapPlugin** - 缩略图导航\n3. **FreeSnapPlugin** - 自动对齐和辅助线\n4. **FreeNodePanelPlugin** - 节点添加面板\n5. **ContainerNodePlugin** - 容器节点（如循环节点）\n6. **FreeGroupPlugin** - 节点分组功能\n7. **ContextMenuPlugin** - 右键菜单\n8. **RuntimePlugin** - 工作流运行时\n9. **VariablePanelPlugin** - 变量管理面板\n\n### 4. 运行时系统\n\n应用支持两种运行模式：\n\n```typescript\ncreateRuntimePlugin({\n  mode: 'browser',              // 浏览器模式\n  // mode: 'server',            // 服务器模式\n  // serverConfig: {\n  //   domain: 'localhost',\n  //   port: 4000,\n  //   protocol: 'http',\n  // },\n})\n```\n\n## 设计理念与架构优势\n\n### 1. 高度模块化\n- **插件化架构**: 每个功能都是独立插件，易于扩展和维护\n- **节点注册系统**: 新节点类型可以轻松添加，无需修改核心代码\n- **组件化设计**: UI组件高度复用，职责清晰\n\n### 2. 类型安全\n- **完整的TypeScript支持**: 从配置到运行时的全链路类型保护\n- **JSON Schema集成**: 节点数据结构通过Schema验证\n- **强类型的插件接口**: 插件开发有明确的类型约束\n\n### 3. 用户体验优化\n- **实时预览**: 支持工作流的实时运行和调试\n- **丰富的交互**: 拖拽、缩放、对齐、快捷键等完整的编辑体验\n- **可视化反馈**: 缩略图、状态指示、连线动画等视觉反馈\n\n### 4. 扩展性设计\n- **开放的插件系统**: 第三方可以轻松开发自定义插件\n- **灵活的节点系统**: 支持自定义节点类型和表单配置\n- **多运行时支持**: 浏览器和服务器双模式运行\n\n### 5. 性能优化\n- **按需加载**: 组件和插件支持按需加载\n- **防抖处理**: 自动保存等高频操作的性能优化\n\n## 技术亮点\n\n### 1. 自研编辑器框架\n基于 `@flowgram.ai/free-layout-editor` 自研框架，提供：\n- 自由布局的画布系统\n- 完整的撤销/重做功能\n- 节点和连线的生命周期管理\n- 变量引擎和表达式系统\n\n### 2. 先进的构建配置\n使用 Rsbuild 作为构建工具：\n\n```typescript\nexport default defineConfig({\n  plugins: [pluginReact(), pluginLess()],\n  source: {\n    entry: { index: './src/app.tsx' },\n    decorators: { version: 'legacy' }  // 支持装饰器\n  },\n  tools: {\n    rspack: {\n      ignoreWarnings: [/Critical dependency/]  // 忽略特定警告\n    }\n  }\n});\n```\n\n### 3. 国际化支持\n内置多语言支持：\n\n```typescript\ni18n: {\n  locale: navigator.language,\n  languages: {\n    'zh-CN': {\n      'Never Remind': '不再提示',\n      'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',\n    },\n    'en-US': {},\n  }\n}\n```\n\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" data-bundler=\"rspack\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>AI4J FlowGram Workbench Demo</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/package.json",
    "content": "{\n  \"name\": \"ai4j-flowgram-webapp-demo\",\n  \"version\": \"0.0.1\",\n  \"private\": true,\n  \"description\": \"FlowGram workbench demo for ai4j Spring Boot integration\",\n  \"keywords\": [\n    \"ai4j\",\n    \"flowgram\",\n    \"demo\",\n    \"react\",\n    \"rsbuild\"\n  ],\n  \"license\": \"MIT\",\n  \"main\": \"./src/index.ts\",\n  \"overrides\": {\n    \"@codemirror/state\": \"6.6.0\"\n  },\n  \"files\": [\n    \"src/\",\n    \".eslintrc.js\",\n    \".gitignore\",\n    \"index.html\",\n    \"package.json\",\n    \"rsbuild.config.ts\",\n    \"tsconfig.json\",\n    \"README.md\",\n    \"README.zh_CN.md\"\n  ],\n  \"dependencies\": {\n    \"@douyinfe/semi-icons\": \"^2.80.0\",\n    \"@douyinfe/semi-ui\": \"^2.80.0\",\n    \"lodash-es\": \"^4.17.21\",\n    \"nanoid\": \"^5.0.9\",\n    \"react\": \"^18\",\n    \"react-dom\": \"^18\",\n    \"styled-components\": \"^5\",\n    \"classnames\": \"^2.5.1\",\n    \"@flowgram.ai/runtime-interface\": \"1.0.9\",\n    \"@flowgram.ai/free-layout-editor\": \"1.0.9\",\n    \"@flowgram.ai/free-snap-plugin\": \"1.0.9\",\n    \"@flowgram.ai/free-lines-plugin\": \"1.0.9\",\n    \"@flowgram.ai/free-node-panel-plugin\": \"1.0.9\",\n    \"@flowgram.ai/export-plugin\": \"1.0.9\",\n    \"@flowgram.ai/minimap-plugin\": \"1.0.9\",\n    \"@flowgram.ai/free-container-plugin\": \"1.0.9\",\n    \"@flowgram.ai/free-group-plugin\": \"1.0.9\",\n    \"@flowgram.ai/panel-manager-plugin\": \"1.0.9\",\n    \"@flowgram.ai/form-materials\": \"1.0.9\",\n    \"@flowgram.ai/free-stack-plugin\": \"1.0.9\",\n    \"@flowgram.ai/runtime-js\": \"1.0.9\"\n  },\n  \"devDependencies\": {\n    \"@rsbuild/core\": \"^1.2.16\",\n    \"@rsbuild/plugin-react\": \"^1.1.1\",\n    \"@rsbuild/plugin-less\": \"^1.1.1\",\n    \"@types/lodash-es\": \"^4.17.12\",\n    \"@types/node\": \"^18\",\n    \"@types/react\": \"^18\",\n    \"@types/react-dom\": \"^18\",\n    \"@types/styled-components\": \"^5\",\n    \"typescript\": \"^5.8.3\",\n    \"eslint\": \"^8.54.0\",\n    \"cross-env\": \"~7.0.3\",\n    \"@flowgram.ai/ts-config\": \"1.0.9\",\n    \"@flowgram.ai/eslint-config\": \"1.0.9\"\n  },\n  \"scripts\": {\n    \"build\": \"cross-env MODE=app NODE_ENV=production rsbuild build\",\n    \"build:analyze\": \"BUNDLE_ANALYZE=true rsbuild build\",\n    \"dev\": \"cross-env MODE=app NODE_ENV=development rsbuild dev\",\n    \"lint\": \"eslint ./src --cache\",\n    \"lint:fix\": \"eslint ./src --fix\",\n    \"ts-check\": \"tsc --noEmit\",\n    \"start\": \"cross-env MODE=app NODE_ENV=development rsbuild dev\",\n    \"test\": \"exit\",\n    \"test:cov\": \"exit\",\n    \"watch\": \"exit 0\"\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/rsbuild.config.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { pluginReact } from '@rsbuild/plugin-react';\nimport { pluginLess } from '@rsbuild/plugin-less';\nimport { defineConfig } from '@rsbuild/core';\n\nexport default defineConfig({\n  plugins: [pluginReact(), pluginLess()],\n  resolve: {\n    dedupe: ['@codemirror/state', '@codemirror/view'],\n  },\n  source: {\n    entry: {\n      index: './src/app.tsx',\n    },\n    /**\n     * support inversify @injectable() and @inject decorators\n     */\n    decorators: {\n      version: 'legacy',\n    },\n  },\n  html: {\n    title: 'AI4J FlowGram Workbench Demo',\n  },\n  server: {\n    host: '127.0.0.1',\n    port: 5173,\n    open: false,\n    proxy: {\n      '/flowgram': 'http://127.0.0.1:18080',\n    },\n  },\n  tools: {\n    rspack: {\n      /**\n       * ignore warnings from @coze-editor/editor/language-typescript\n       */\n      ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/],\n    },\n  },\n});\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/app.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { createRoot } from 'react-dom/client';\nimport { unstableSetCreateRoot } from '@flowgram.ai/form-materials';\n\nimport { Editor } from './editor';\n\n/**\n * React 18/19 polyfill for form-materials\n */\nunstableSetCreateRoot(createRoot);\n\nconst app = createRoot(document.getElementById('root')!);\n\napp.render(<Editor />);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-auto-layout.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport const IconAutoLayout = (\n  <svg width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path\n      fill=\"currentColor\"\n      d=\"M3 2C2.44772 2 2 2.44771 2 3V12C2 12.5523 2.44772 13 3 13H10C10.5523 13 11 12.5523 11 12V3C11 2.44772 10.5523 2 10 2H3zM4 11V4H9V11H4zM21 22C21.5523 22 22 21.5523 22 21V12C22 11.4477 21.5523 11 21 11H14C13.4477 11 13 11.4477 13 12V21C13 21.5523 13.4477 22 14 22H21zM20 13V20H15V13H20zM2 16C2 15.4477 2.44772 15 3 15H10C10.5523 15 11 15.4477 11 16V21C11 21.5523 10.5523 22 10 22H3C2.44772 22 2 21.5523 2 21V16zM4 20V17H9V20H4zM21 9C21.5523 9 22 8.55228 22 8V3C22 2.44772 21.5523 2 21 2H14C13.4477 2 13 2.44772 13 3V8C13 8.55228 13.4477 9 14 9H21zM20 4V7H15V4H20z\"\n    ></path>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-cancel.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\ninterface Props {\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nexport const IconCancel = ({ className, style }: Props) => (\n  <svg\n    className={className}\n    style={style}\n    width=\"1em\"\n    height=\"1em\"\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <path d=\"M9.5 8C8.67157 8 8 8.67157 8 9.5V14.5C8 15.3284 8.67157 16 9.5 16H14.5C15.3284 16 16 15.3284 16 14.5V9.5C16 8.67157 15.3284 8 14.5 8H9.5Z\"></path>\n    <path d=\"M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21Z\"></path>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-comment.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { CSSProperties, FC } from 'react';\n\ninterface IconCommentProps {\n  style?: CSSProperties;\n}\n\nexport const IconComment: FC<IconCommentProps> = ({ style }) => (\n  <svg\n    width=\"1em\"\n    height=\"1em\"\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    style={style}\n  >\n    <path d=\"M6.5 9C5.94772 9 5.5 9.44772 5.5 10V11C5.5 11.5523 5.94772 12 6.5 12H7.5C8.05228 12 8.5 11.5523 8.5 11V10C8.5 9.44772 8.05228 9 7.5 9H6.5zM11.5 9C10.9477 9 10.5 9.44772 10.5 10V11C10.5 11.5523 10.9477 12 11.5 12H12.5C13.0523 12 13.5 11.5523 13.5 11V10C13.5 9.44772 13.0523 9 12.5 9H11.5zM15.5 10C15.5 9.44772 15.9477 9 16.5 9H17.5C18.0523 9 18.5 9.44772 18.5 10V11C18.5 11.5523 18.0523 12 17.5 12H16.5C15.9477 12 15.5 11.5523 15.5 11V10z\"></path>\n    <path d=\"M23 4C23 2.9 22.1 2 21 2H3C1.9 2 1 2.9 1 4V17.0111C1 18.0211 1.9 19.0111 3 19.0111H7.7586L10.4774 22C10.9822 22.5017 11.3166 22.6311 12 22.7009C12.414 22.707 13.0502 22.5093 13.5 22L16.2414 19.0111H21C22.1 19.0111 23 18.1111 23 17.0111V4ZM3 4H21V17.0111H15.5L12 20.6714L8.5 17.0111H3V4Z\"></path>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-minimap.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport const IconMinimap = () => (\n  <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n    <g id=\"g1\">\n      <path\n        id=\"path1\"\n        fill=\"#000000\"\n        stroke=\"none\"\n        d=\"M 18.09091 6.883101 L 5.409091 6.883101 L 5.409091 16.746737 L 10.664648 16.746737 C 10.927091 17.116341 11.30353 17.422749 11.792977 17.611004 L 12.664289 17.946156 L 12.744959 18.155828 L 5.409091 18.155828 C 4.630871 18.155828 4 17.524979 4 16.746737 L 4 6.883101 C 4 6.104881 4.630871 5.47401 5.409091 5.47401 L 18.09091 5.47401 C 18.86915 5.47401 19.5 6.104881 19.5 6.883101 L 19.5 12.52348 C 19.247208 11.883823 18.730145 11.365912 18.09091 11.111994 L 18.09091 6.883101 Z M 18.09091 18.155828 L 17.881165 18.155828 L 19.469212 14.368896 C 19.479921 14.343321 19.490206 14.317817 19.5 14.292241 L 19.5 16.746737 C 19.5 17.524979 18.86915 18.155828 18.09091 18.155828 Z\"\n      />\n      <path\n        id=\"path2\"\n        fill=\"#000000\"\n        fillRule=\"evenodd\"\n        stroke=\"none\"\n        d=\"M 18.494614 13.960189 C 18.982441 12.796985 17.813459 11.628003 16.650255 12.11576 L 12.133272 14.01 C 10.962248 14.501069 10.987188 16.168798 12.172375 16.62464 L 13.482055 17.128389 L 13.985805 18.438068 C 14.441646 19.623184 16.109375 19.648125 16.600443 18.477171 L 18.494614 13.960189 Z M 17.19515 13.415224 L 15.30098 17.932205 L 14.79723 16.622526 C 14.654066 16.250385 14.359989 15.956307 13.987918 15.813213 L 12.678168 15.309464 L 17.19515 13.415224 Z\"\n      />\n    </g>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-mouse.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport function IconMouse(props: { width?: number; height?: number }) {\n  const { width, height } = props;\n  return (\n    <svg\n      width={width || 34}\n      height={height || 52}\n      viewBox=\"0 0 34 52\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M30.9998 16.6666V35.3333C30.9998 37.5748 30.9948 38.4695 30.9 39.1895C30.2108 44.4247 26.0912 48.5443 20.856 49.2335C20.1361 49.3283 19.2413 49.3333 16.9998 49.3333C14.7584 49.3333 13.8636 49.3283 13.1437 49.2335C7.90847 48.5443 3.78888 44.4247 3.09965 39.1895C3.00487 38.4695 2.99984 37.5748 2.99984 35.3333V16.6666C2.99984 14.4252 3.00487 13.5304 3.09965 12.8105C3.78888 7.57528 7.90847 3.45569 13.1437 2.76646C13.7232 2.69017 14.4159 2.67202 15.8332 2.66785V9.86573C14.4738 10.3462 13.4998 11.6426 13.4998 13.1666V17.8332C13.4998 19.3571 14.4738 20.6536 15.8332 21.1341V23.6666C15.8332 24.3109 16.3555 24.8333 16.9998 24.8333C17.6442 24.8333 18.1665 24.3109 18.1665 23.6666V21.1341C19.5259 20.6536 20.4998 19.3572 20.4998 17.8332V13.1666C20.4998 11.6426 19.5259 10.3462 18.1665 9.86571V2.66785C19.5837 2.67202 20.2765 2.69017 20.856 2.76646C26.0912 3.45569 30.2108 7.57528 30.9 12.8105C30.9948 13.5304 30.9998 14.4252 30.9998 16.6666ZM0.666504 16.6666C0.666504 14.4993 0.666504 13.4157 0.786276 12.5059C1.61335 6.22368 6.55687 1.28016 12.8391 0.453085C13.7489 0.333313 14.8325 0.333313 16.9998 0.333313C19.1671 0.333313 20.2508 0.333313 21.1605 0.453085C27.4428 1.28016 32.3863 6.22368 33.2134 12.5059C33.3332 13.4157 33.3332 14.4994 33.3332 16.6666V35.3333C33.3332 37.5006 33.3332 38.5843 33.2134 39.494C32.3863 45.7763 27.4428 50.7198 21.1605 51.5469C20.2508 51.6666 19.1671 51.6666 16.9998 51.6666C14.8325 51.6666 13.7489 51.6666 12.8391 51.5469C6.55687 50.7198 1.61335 45.7763 0.786276 39.494C0.666504 38.5843 0.666504 37.5006 0.666504 35.3333V16.6666ZM15.8332 13.1666C15.8332 13.0011 15.8676 12.8437 15.9297 12.7011C15.9886 12.566 16.0722 12.4443 16.1749 12.3416C16.386 12.1305 16.6777 11.9999 16.9998 11.9999C17.6435 11.9999 18.1654 12.5212 18.1665 13.1646L18.1665 13.1666V17.8332L18.1665 17.8353C18.1665 17.8364 18.1665 17.8376 18.1665 17.8387C18.1661 17.9132 18.1588 17.986 18.1452 18.0565C18.0853 18.3656 17.9033 18.6312 17.6515 18.8011C17.4655 18.9266 17.2412 18.9999 16.9998 18.9999C16.3555 18.9999 15.8332 18.4776 15.8332 17.8332V13.1666Z\"\n        fill=\"currentColor\"\n        fillOpacity=\"0.8\"\n      />\n    </svg>\n  );\n}\n\nexport const IconMouseTool = () => (\n  <svg\n    width=\"1em\"\n    height=\"1em\"\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M4.5 8C4.5 4.13401 7.63401 1 11.5 1H12.5C16.366 1 19.5 4.13401 19.5 8V17C19.5 20.3137 16.8137 23 13.5 23H10.5C7.18629 23 4.5 20.3137 4.5 17V8ZM11.2517 3.00606C8.60561 3.13547 6.5 5.32184 6.5 8V17C6.5 19.2091 8.29086 21 10.5 21H13.5C15.7091 21 17.5 19.2091 17.5 17V8C17.5 5.32297 15.3962 3.13732 12.7517 3.00622V5.28013C13.2606 5.54331 13.6074 6.06549 13.6074 6.66669V8.75759C13.6074 9.35879 13.2606 9.88097 12.7517 10.1441V11.4091C12.7517 11.8233 12.4159 12.1591 12.0017 12.1591C11.5875 12.1591 11.2517 11.8233 11.2517 11.4091V10.1457C10.7411 9.88298 10.3931 9.35994 10.3931 8.75759V6.66669C10.3931 6.06433 10.7411 5.5413 11.2517 5.27862V3.00606ZM12.0017 6.14397C11.7059 6.14397 11.466 6.38381 11.466 6.67968V8.74462C11.466 9.03907 11.7036 9.27804 11.9975 9.28031L12.0002 9.28032C12.0456 9.28032 12.0896 9.27482 12.1316 9.26447C12.3401 9.21256 12.5002 9.0386 12.5318 8.82287C12.5345 8.80149 12.5359 8.7797 12.5359 8.75759V6.66669C12.5359 6.64463 12.5345 6.62288 12.5318 6.60154C12.4999 6.38354 12.3368 6.20817 12.1252 6.15826C12.0856 6.14891 12.0442 6.14397 12.0017 6.14397Z\"\n    ></path>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-pad.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport function IconPad(props: { width?: number; height?: number }) {\n  const { width, height } = props;\n  return (\n    <svg\n      width={width || 48}\n      height={height || 38}\n      viewBox=\"0 0 48 38\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <rect\n        x=\"1.83317\"\n        y=\"1.49998\"\n        width=\"44.3333\"\n        height=\"35\"\n        rx=\"3.5\"\n        stroke=\"currentColor\"\n        strokeOpacity=\"0.8\"\n        strokeWidth=\"2.33333\"\n      />\n      <path\n        d=\"M14.6665 30.6667H33.3332\"\n        stroke=\"currentColor\"\n        strokeOpacity=\"0.8\"\n        strokeWidth=\"2.33333\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport const IconPadTool = () => (\n  <svg\n    width=\"1em\"\n    height=\"1em\"\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M20.8549 5H3.1451C3.06496 5 3 5.06496 3 5.1451V18.8549C3 18.935 3.06496 19 3.1451 19H20.8549C20.935 19 21 18.935 21 18.8549V5.1451C21 5.06496 20.935 5 20.8549 5ZM3.1451 3C1.96039 3 1 3.96039 1 5.1451V18.8549C1 20.0396 1.96039 21 3.1451 21H20.8549C22.0396 21 23 20.0396 23 18.8549V5.1451C23 3.96039 22.0396 3 20.8549 3H3.1451Z\"\n    ></path>\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M6.99991 16C6.99991 15.4477 7.44762 15 7.99991 15H15.9999C16.5522 15 16.9999 15.4477 16.9999 16C16.9999 16.5523 16.5522 17 15.9999 17H7.99991C7.44762 17 6.99991 16.5523 6.99991 16Z\"\n    ></path>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-success.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\ninterface Props {\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nexport const IconSuccessFill = ({ className, style }: Props) => (\n  <svg\n    className={className}\n    style={style}\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"20\"\n    height=\"20\"\n    fill=\"none\"\n    viewBox=\"0 0 20 20\"\n  >\n    <g clipPath=\"url(#icon-workflow-run-success_svg__a)\">\n      <path\n        fill=\"#3EC254\"\n        d=\"M.833 10A9.166 9.166 0 0 0 10 19.168a9.166 9.166 0 0 0 9.167-9.166A9.166 9.166 0 0 0 10 .834a9.166 9.166 0 0 0-9.167 9.167\"\n      ></path>\n      <path\n        fill=\"#fff\"\n        d=\"M6.077 9.755a.833.833 0 0 0 0 1.179l2.357 2.357a.833.833 0 0 0 1.179 0l4.714-4.714a.833.833 0 1 0-1.178-1.179l-4.125 4.125-1.768-1.768a.833.833 0 0 0-1.179 0\"\n      ></path>\n    </g>\n    <defs>\n      <clipPath id=\"icon-workflow-run-success_svg__a\">\n        <path fill=\"#fff\" d=\"M0 0h20v20H0z\"></path>\n      </clipPath>\n    </defs>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-switch-line.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport const IconSwitchLine = (\n  <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n    <path\n      id=\"switch-line\"\n      fill=\"currentColor\"\n      stroke=\"none\"\n      d=\"M 12.728118 10.060962 C 13.064282 8.716098 14.272528 7.772551 15.65877 7.772343 L 17.689898 7.772343 C 18.0798 7.772343 18.39588 7.456264 18.39588 7.066362 C 18.39588 6.676458 18.0798 6.36038 17.689898 6.36038 L 15.659616 6.36038 C 13.62515 6.360315 11.851767 7.745007 11.358504 9.718771 C 11.02234 11.063635 9.814095 12.007183 8.427853 12.007389 L 7.101437 12.007389 C 6.711768 12.007389 6.395878 12.323277 6.395878 12.712947 C 6.395878 13.102616 6.711768 13.418506 7.101437 13.418506 L 8.426159 13.418506 C 9.812716 13.418323 11.021417 14.361954 11.357657 15.707124 C 11.850921 17.680887 13.624304 19.065578 15.65877 19.065516 L 17.689049 19.065516 C 18.078953 19.065516 18.395033 18.749435 18.395033 18.359533 C 18.395033 17.969631 18.078953 17.653551 17.689049 17.653551 L 15.65877 17.653551 C 14.272528 17.653345 13.064282 16.709797 12.728118 15.364932 C 12.454905 14.27114 11.774856 13.322707 10.826583 12.712947 C 11.774536 12.10303 12.454268 11.154617 12.727271 10.060962 Z\"\n    />\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/assets/icon-warning.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\ninterface Props {\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nexport const IconWarningFill = ({ className, style }: Props) => (\n  <svg\n    className={className}\n    style={style}\n    width=\"1em\"\n    height=\"1em\"\n    viewBox=\"0 0 24 24\"\n    fill=\"currentColor\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12ZM11 8C11 7.44772 11.4477 7 12 7C12.5523 7 13 7.44772 13 8V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8ZM11 16C11 15.4477 11.4477 15 12 15C12.5523 15 13 15.4477 13 16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z\"\n    ></path>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/add-node/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Button } from '@douyinfe/semi-ui';\nimport { IconPlus } from '@douyinfe/semi-icons';\n\nimport { useAddNode } from './use-add-node';\n\nexport const AddNode = (props: { disabled: boolean }) => {\n  const addNode = useAddNode();\n  return (\n    <Button\n      data-testid=\"demo.free-layout.add-node\"\n      icon={<IconPlus />}\n      color=\"highlight\"\n      style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}\n      disabled={props.disabled}\n      onClick={(e) => {\n        const rect = e.currentTarget.getBoundingClientRect();\n        addNode(rect);\n      }}\n    >\n      Add Node\n    </Button>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/add-node/use-add-node.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\nimport { useCallback } from 'react';\n\nimport { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';\nimport {\n  useService,\n  WorkflowDocument,\n  usePlayground,\n  PositionSchema,\n  WorkflowNodeEntity,\n  WorkflowSelectService,\n  WorkflowNodeJSON,\n  getAntiOverlapPosition,\n  WorkflowNodeMeta,\n  FlowNodeBaseType,\n} from '@flowgram.ai/free-layout-editor';\n// hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook\nconst useGetPanelPosition = () => {\n  const playground = usePlayground();\n  return useCallback(\n    (targetBoundingRect: DOMRect): PositionSchema =>\n      // convert mouse position to canvas position - 将鼠标位置转换为画布位置\n      playground.config.getPosFromMouseEvent({\n        clientX: targetBoundingRect.left + 64,\n        clientY: targetBoundingRect.top - 7,\n      }),\n    [playground]\n  );\n};\n// hook to handle node selection - 处理节点选择的 hook\nconst useSelectNode = () => {\n  const selectService = useService(WorkflowSelectService);\n  return useCallback(\n    (node?: WorkflowNodeEntity) => {\n      if (!node) {\n        return;\n      }\n      // select the target node - 选择目标节点\n      selectService.selectNode(node);\n    },\n    [selectService]\n  );\n};\n\nconst getContainerNode = (selectService: WorkflowSelectService) => {\n  const { activatedNode } = selectService;\n  if (!activatedNode) {\n    return;\n  }\n  const { isContainer } = activatedNode.getNodeMeta<WorkflowNodeMeta>();\n  if (isContainer) {\n    return activatedNode;\n  }\n  const parentNode = activatedNode.parent;\n  if (!parentNode || parentNode.flowNodeType === FlowNodeBaseType.ROOT) {\n    return;\n  }\n  return parentNode;\n};\n\n// main hook for adding new nodes - 添加新节点的主 hook\nexport const useAddNode = () => {\n  const workflowDocument = useService(WorkflowDocument);\n  const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);\n  const selectService = useService(WorkflowSelectService);\n  const playground = usePlayground();\n  const getPanelPosition = useGetPanelPosition();\n  const select = useSelectNode();\n\n  return useCallback(\n    async (targetBoundingRect: DOMRect): Promise<void> => {\n      // calculate panel position based on target element - 根据目标元素计算面板位置\n      const panelPosition = getPanelPosition(targetBoundingRect);\n      const containerNode = getContainerNode(selectService);\n      await new Promise<void>((resolve) => {\n        // call the node panel service to show the panel - 调用节点面板服务来显示面板\n        nodePanelService.callNodePanel({\n          position: panelPosition,\n          enableMultiAdd: true,\n          containerNode,\n          panelProps: {},\n          // handle node selection from panel - 处理从面板中选择节点\n          onSelect: async (panelParams?: NodePanelResult) => {\n            if (!panelParams) {\n              return;\n            }\n            const { nodeType, nodeJSON } = panelParams;\n            const position = Boolean(containerNode)\n              ? getAntiOverlapPosition(workflowDocument, {\n                  x: 0,\n                  y: 200,\n                })\n              : undefined;\n            // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点\n            const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(\n              nodeType,\n              position, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点\n              nodeJSON ?? ({} as WorkflowNodeJSON),\n              containerNode?.id\n            );\n            select(node);\n          },\n          // handle panel close - 处理面板关闭\n          onClose: () => {\n            resolve();\n          },\n        });\n      });\n    },\n    [getPanelPosition, nodePanelService, playground.config.zoom, workflowDocument, select]\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/base-node/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback } from 'react';\n\nimport { FlowNodeEntity, useClientContext, useNodeRender } from '@flowgram.ai/free-layout-editor';\nimport { ConfigProvider } from '@douyinfe/semi-ui';\n\nimport { NodeStatusBar } from '../testrun/node-status-bar';\nimport { NodeRenderContext } from '../../context';\nimport { ErrorIcon } from './styles';\nimport { NodeWrapper } from './node-wrapper';\n\nexport const BaseNode = ({ node }: { node: FlowNodeEntity }) => {\n  /**\n   * Provides methods related to node rendering\n   * 提供节点渲染相关的方法\n   */\n  const nodeRender = useNodeRender();\n  const ctx = useClientContext();\n  /**\n   * It can only be used when nodeEngine is enabled\n   * 只有在节点引擎开启时候才能使用表单\n   */\n  const form = nodeRender.form;\n\n  /**\n   * Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library\n   * 用于让 Tooltip 跟随节点缩放, 这个可以根据不同的 ui 库自己实现\n   */\n  const getPopupContainer = useCallback(\n    () => ctx.playground.node.querySelector('.gedit-flow-render-layer') as HTMLDivElement,\n    []\n  );\n\n  return (\n    <ConfigProvider getPopupContainer={getPopupContainer}>\n      <NodeRenderContext.Provider value={nodeRender}>\n        <NodeWrapper>\n          {form?.state.invalid && <ErrorIcon />}\n          {form?.render()}\n        </NodeWrapper>\n        <NodeStatusBar />\n      </NodeRenderContext.Provider>\n    </ConfigProvider>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/base-node/node-wrapper.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React, { useState } from 'react';\n\nimport { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';\nimport { useClientContext } from '@flowgram.ai/free-layout-editor';\n\nimport { FlowNodeMeta } from '../../typings';\nimport { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';\nimport { useNodeRenderContext, usePortClick } from '../../hooks';\nimport { scrollToView } from './utils';\nimport { NodeWrapperStyle } from './styles';\n\nexport interface NodeWrapperProps {\n  isScrollToView?: boolean;\n  children: React.ReactNode;\n}\n\n/**\n * Used for drag-and-drop/click events and ports rendering of nodes\n * 用于节点的拖拽/点击事件和点位渲染\n */\nexport const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {\n  const { children, isScrollToView = false } = props;\n  const nodeRender = useNodeRenderContext();\n  const { node, selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur, readonly } =\n    nodeRender;\n  const [isDragging, setIsDragging] = useState(false);\n  const form = nodeRender.form;\n  const ctx = useClientContext();\n  const onPortClick = usePortClick();\n  const meta = node.getNodeMeta<FlowNodeMeta>();\n\n  const { open } = useNodeFormPanel();\n  const portsRender = ports.map((p) => (\n    <WorkflowPortRender key={p.id} entity={p} onClick={!readonly ? onPortClick : undefined} />\n  ));\n\n  return (\n    <>\n      <NodeWrapperStyle\n        className={selected ? 'selected' : ''}\n        ref={nodeRef}\n        draggable\n        onDragStart={(e) => {\n          startDrag(e);\n          setIsDragging(true);\n        }}\n        onTouchStart={(e) => {\n          startDrag(e as unknown as React.MouseEvent);\n          setIsDragging(true);\n        }}\n        onClick={(e) => {\n          selectNode(e);\n          if (!isDragging) {\n            open({\n              nodeId: nodeRender.node.id,\n            });\n            // 可选：将 isScrollToView 设为 true，可以让节点选中后滚动到画布中间\n            // Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected.\n            if (isScrollToView) {\n              scrollToView(ctx, nodeRender.node);\n            }\n          }\n        }}\n        onMouseUp={() => setIsDragging(false)}\n        onFocus={onFocus}\n        onBlur={onBlur}\n        data-node-selected={String(selected)}\n        style={{\n          ...meta.wrapperStyle,\n          outline: form?.state.invalid ? '1px solid red' : 'none',\n        }}\n      >\n        {children}\n      </NodeWrapperStyle>\n      {portsRender}\n    </>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/base-node/styles.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport styled from 'styled-components';\nimport { IconInfoCircle } from '@douyinfe/semi-icons';\n\nexport const NodeWrapperStyle = styled.div`\n  align-items: flex-start;\n  background-color: #fff;\n  border: 1px solid rgba(6, 7, 9, 0.15);\n  border-radius: 8px;\n  box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  position: relative;\n  width: 360px;\n  height: auto;\n\n  &.selected {\n    border: 1px solid #4e40e5;\n  }\n`;\n\nexport const ErrorIcon = () => (\n  <IconInfoCircle\n    style={{\n      position: 'absolute',\n      color: 'red',\n      left: -6,\n      top: -6,\n      zIndex: 1,\n      background: 'white',\n      borderRadius: 8,\n    }}\n  />\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/base-node/utils.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FreeLayoutPluginContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';\n\nexport function scrollToView(\n  ctx: FreeLayoutPluginContext,\n  node: FlowNodeEntity,\n  sidebarWidth = 448\n) {\n  const bounds = node.transform.bounds;\n  ctx.playground.scrollToView({\n    bounds,\n    scrollDelta: {\n      x: sidebarWidth / 2,\n      y: 0,\n    },\n    zoom: 1,\n    scrollToCenter: true,\n  });\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/blank-area.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport type { FC } from 'react';\n\nimport { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';\n\nimport type { CommentEditorModel } from '../model';\nimport { DragArea } from './drag-area';\n\ninterface IBlankArea {\n  model: CommentEditorModel;\n}\n\nexport const BlankArea: FC<IBlankArea> = (props) => {\n  const { model } = props;\n  const playground = usePlayground();\n  const { selectNode } = useNodeRender();\n\n  return (\n    <div\n      className=\"workflow-comment-blank-area h-full w-full\"\n      onMouseDown={(e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        model.setFocus(false);\n        selectNode(e);\n        playground.node.focus(); // 防止节点无法被删除\n      }}\n      onClick={(e) => {\n        model.setFocus(true);\n        model.selectEnd();\n      }}\n    >\n      <DragArea\n        style={{\n          position: 'relative',\n          width: '100%',\n          height: '100%',\n        }}\n        model={model}\n        stopEvent={false}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/border-area.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { type FC } from 'react';\n\nimport type { CommentEditorModel } from '../model';\nimport { ResizeArea } from './resize-area';\nimport { DragArea } from './drag-area';\n\ninterface IBorderArea {\n  model: CommentEditorModel;\n  overflow: boolean;\n  onResize?: () => {\n    resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;\n    resizeEnd: () => void;\n  };\n}\n\nexport const BorderArea: FC<IBorderArea> = (props) => {\n  const { model, overflow, onResize } = props;\n\n  return (\n    <div style={{ zIndex: 999 }}>\n      {/* 左边 */}\n      <DragArea\n        style={{\n          position: 'absolute',\n          left: -10,\n          top: 10,\n          width: 20,\n          height: 'calc(100% - 20px)',\n        }}\n        model={model}\n      />\n      {/* 右边 */}\n      <DragArea\n        style={{\n          position: 'absolute',\n          right: -10,\n          top: 10,\n          height: 'calc(100% - 20px)',\n          width: overflow ? 10 : 20, // 防止遮挡滚动条\n        }}\n        model={model}\n      />\n      {/* 上边 */}\n      <DragArea\n        style={{\n          position: 'absolute',\n          top: -10,\n          left: 10,\n          width: 'calc(100% - 20px)',\n          height: 20,\n        }}\n        model={model}\n      />\n      {/* 下边 */}\n      <DragArea\n        style={{\n          position: 'absolute',\n          bottom: -10,\n          left: 10,\n          width: 'calc(100% - 20px)',\n          height: 20,\n        }}\n        model={model}\n      />\n      {/** 左上角 */}\n      <ResizeArea\n        style={{\n          position: 'absolute',\n          left: 0,\n          top: 0,\n          cursor: 'nwse-resize',\n        }}\n        model={model}\n        getDelta={({ x, y }) => ({ top: y, right: 0, bottom: 0, left: x })}\n        onResize={onResize}\n      />\n      {/** 右上角 */}\n      <ResizeArea\n        style={{\n          position: 'absolute',\n          right: 0,\n          top: 0,\n          cursor: 'nesw-resize',\n        }}\n        model={model}\n        getDelta={({ x, y }) => ({ top: y, right: x, bottom: 0, left: 0 })}\n        onResize={onResize}\n      />\n      {/** 右下角 */}\n      <ResizeArea\n        style={{\n          position: 'absolute',\n          right: 0,\n          bottom: 0,\n          cursor: 'nwse-resize',\n        }}\n        model={model}\n        getDelta={({ x, y }) => ({ top: 0, right: x, bottom: y, left: 0 })}\n        onResize={onResize}\n      />\n      {/** 左下角 */}\n      <ResizeArea\n        style={{\n          position: 'absolute',\n          left: 0,\n          bottom: 0,\n          cursor: 'nesw-resize',\n        }}\n        model={model}\n        getDelta={({ x, y }) => ({ top: 0, right: 0, bottom: y, left: x })}\n        onResize={onResize}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/container.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport type { ReactNode, FC, CSSProperties } from 'react';\n\ninterface ICommentContainer {\n  focused: boolean;\n  children?: ReactNode;\n  style?: React.CSSProperties;\n}\n\nexport const CommentContainer: FC<ICommentContainer> = (props) => {\n  const { focused, children, style } = props;\n\n  const scrollbarStyle = {\n    // 滚动条样式\n    scrollbarWidth: 'thin',\n    scrollbarColor: 'rgb(159 159 158 / 65%) transparent',\n    // 针对 WebKit 浏览器（如 Chrome、Safari）的样式\n    '&:WebkitScrollbar': {\n      width: '4px',\n    },\n    '&::WebkitScrollbarTrack': {\n      background: 'transparent',\n    },\n    '&::WebkitScrollbarThumb': {\n      backgroundColor: 'rgb(159 159 158 / 65%)',\n      borderRadius: '20px',\n      border: '2px solid transparent',\n    },\n  } as unknown as CSSProperties;\n\n  return (\n    <div\n      className=\"workflow-comment-container\"\n      data-flow-editor-selectable=\"false\"\n      style={{\n        // tailwind 不支持 outline 的样式，所以这里需要使用 style 来设置\n        outline: focused ? '1px solid #FF811A' : '1px solid #F2B600',\n        backgroundColor: focused ? '#FFF3EA' : '#FFFBED',\n        ...scrollbarStyle,\n        ...style,\n      }}\n    >\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/content-drag-area.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { type FC, useState, useEffect, type WheelEventHandler } from 'react';\n\nimport { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';\n\nimport type { CommentEditorModel } from '../model';\nimport { DragArea } from './drag-area';\n\ninterface IContentDragArea {\n  model: CommentEditorModel;\n  focused: boolean;\n  overflow: boolean;\n}\n\nexport const ContentDragArea: FC<IContentDragArea> = (props) => {\n  const { model, focused, overflow } = props;\n  const playground = usePlayground();\n  const { selectNode } = useNodeRender();\n\n  const [active, setActive] = useState(false);\n\n  useEffect(() => {\n    // 当编辑器失去焦点时，取消激活状态\n    if (!focused) {\n      setActive(false);\n    }\n  }, [focused]);\n\n  const handleWheel: WheelEventHandler<HTMLDivElement> = (e) => {\n    const editorElement = model.element;\n    if (active || !overflow || !editorElement) {\n      return;\n    }\n    e.stopPropagation();\n    const maxScroll = editorElement.scrollHeight - editorElement.clientHeight;\n    const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll);\n    editorElement.scroll(0, newScrollTop);\n  };\n\n  const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {\n    if (active) {\n      return;\n    }\n    mouseDownEvent.preventDefault();\n    mouseDownEvent.stopPropagation();\n    model.setFocus(false);\n    selectNode(mouseDownEvent);\n    playground.node.focus(); // 防止节点无法被删除\n\n    const startX = mouseDownEvent.clientX;\n    const startY = mouseDownEvent.clientY;\n\n    const handleMouseUp = (mouseMoveEvent: MouseEvent) => {\n      const deltaX = mouseMoveEvent.clientX - startX;\n      const deltaY = mouseMoveEvent.clientY - startY;\n      // 判断是拖拽还是点击\n      const delta = 5;\n      if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) {\n        // 点击后隐藏\n        setActive(true);\n      }\n      document.removeEventListener('mouseup', handleMouseUp);\n      document.removeEventListener('click', handleMouseUp);\n    };\n\n    document.addEventListener('mouseup', handleMouseUp);\n    document.addEventListener('click', handleMouseUp);\n  };\n\n  return (\n    <div\n      className=\"workflow-comment-content-drag-area\"\n      onMouseDown={handleMouseDown}\n      onWheel={handleWheel}\n      style={{\n        display: active ? 'none' : undefined,\n      }}\n    >\n      <DragArea\n        style={{\n          position: 'relative',\n          width: '100%',\n          height: '100%',\n        }}\n        model={model}\n        stopEvent={false}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/drag-area.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { CSSProperties, MouseEvent, TouchEvent, type FC } from 'react';\n\nimport { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';\n\nimport { type CommentEditorModel } from '../model';\n\ninterface IDragArea {\n  model: CommentEditorModel;\n  stopEvent?: boolean;\n  style?: CSSProperties;\n}\n\nexport const DragArea: FC<IDragArea> = (props) => {\n  const { model, stopEvent = true, style } = props;\n\n  const playground = usePlayground();\n\n  const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender();\n\n  const handleDrag = (e: MouseEvent | TouchEvent) => {\n    if (stopEvent) {\n      e.preventDefault();\n      e.stopPropagation();\n    }\n    model.setFocus(false);\n    onStartDrag(e as MouseEvent);\n    selectNode(e as MouseEvent);\n    playground.node.focus(); // 防止节点无法被删除\n  };\n\n  return (\n    <div\n      className=\"workflow-comment-drag-area\"\n      data-flow-editor-selectable=\"false\"\n      draggable={true}\n      style={style}\n      onMouseDown={handleDrag}\n      onTouchStart={handleDrag}\n      onFocus={onFocus}\n      onBlur={onBlur}\n    />\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/editor.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { type FC, type CSSProperties, useEffect, useRef } from 'react';\n\nimport { usePlayground } from '@flowgram.ai/free-layout-editor';\n\nimport { CommentEditorModel } from '../model';\nimport { usePlaceholder } from '../hooks';\nimport { CommentEditorEvent } from '../constant';\n\ninterface ICommentEditor {\n  model: CommentEditorModel;\n  style?: CSSProperties;\n  value?: string;\n  onChange?: (value: string) => void;\n}\n\nexport const CommentEditor: FC<ICommentEditor> = (props) => {\n  const { model, style, onChange } = props;\n  const playground = usePlayground();\n  const placeholder = usePlaceholder({ model });\n  const editorRef = useRef<HTMLTextAreaElement | null>(null);\n\n  // 同步编辑器内部值变化\n  useEffect(() => {\n    const disposer = model.on((params) => {\n      if (params.type !== CommentEditorEvent.Change) {\n        return;\n      }\n      onChange?.(model.value);\n    });\n    return () => disposer.dispose();\n  }, [model, onChange]);\n\n  useEffect(() => {\n    if (!editorRef.current) {\n      return;\n    }\n    model.element = editorRef.current;\n  }, [editorRef]);\n\n  return (\n    <div className=\"workflow-comment-editor\">\n      <p className=\"workflow-comment-editor-placeholder\">{placeholder}</p>\n      <textarea\n        className=\"workflow-comment-editor-textarea\"\n        ref={editorRef}\n        style={style}\n        readOnly={playground.config.readonly}\n        onChange={(e) => {\n          const { value } = e.target;\n          model.setValue(value);\n        }}\n        onFocus={() => {\n          model.setFocus(true);\n        }}\n        onBlur={() => {\n          model.setFocus(false);\n        }}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/index.css",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.workflow-comment {\n    width: auto;\n    height: auto;\n    min-width: 120px;\n    min-height: 80px;\n}\n\n.workflow-comment-container {\n    display: flex;\n    flex-direction: column;\n    align-items: flex-start;\n    justify-content: flex-start;\n    width: 100%;\n    height: 100%;\n    border-radius: 8px;\n    outline: 1px solid;\n    padding: 6px 2px 6px 10px;\n    overflow: hidden;\n}\n\n.workflow-comment-drag-area {\n    position: absolute;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    cursor: move;\n}\n\n.workflow-comment-content-drag-area {\n    position: absolute;\n    height: 100%;\n    width: calc(100% - 22px);\n}\n\n.workflow-comment-resize-area {\n    position: absolute;\n    width: 10px;\n    height: 10px;\n}\n\n.workflow-comment-editor {\n    width: 100%;\n    height: 100%;\n}\n\n.workflow-comment-editor-placeholder {\n    margin: 0;\n    position: absolute;\n    pointer-events: none;\n    color: rgba(55, 67, 106, 0.38);\n    font-weight: 500;\n}\n\n.workflow-comment-editor-textarea {\n    width: 100%;\n    height: 100%;\n    box-sizing: border-box;\n    appearance: none;\n    border: none;\n    margin: 0;\n    padding: 0;\n    width: 100%;\n    background: none;\n    color: inherit;\n    font-family: inherit;\n    font-size: 16px;\n    resize: none;\n    outline: none;\n}\n\n.workflow-comment-more-button {\n    position: absolute;\n    right: 6px;\n}\n\n.workflow-comment-more-button > .semi-button {\n    color: rgba(255, 255, 255, 0);\n    background: none;\n}\n\n.workflow-comment-more-button > .semi-button:hover {\n    color: #ffa100;\n    background: #fbf2d2cc;\n    backdrop-filter: blur(1px);\n}\n\n.workflow-comment-more-button-focused > .semi-button:hover {\n    color: #ff811a;\n    background: #ffe3cecc;\n    backdrop-filter: blur(1px);\n}\n\n.workflow-comment-more-button > .semi-button:active {\n    color: #f2b600;\n    background: #ede5c7cc;\n    backdrop-filter: blur(1px);\n}\n\n.workflow-comment-more-button-focused > .semi-button:active {\n    color: #ff811a;\n    background: #eed5c1cc;\n    backdrop-filter: blur(1px);\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport './index.css';\n\nexport { CommentRender } from './render';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/more-button.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC } from 'react';\n\nimport { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';\n\nimport { NodeMenu } from '../../node-menu';\n\ninterface IMoreButton {\n  node: WorkflowNodeEntity;\n  focused: boolean;\n  deleteNode: () => void;\n}\n\nexport const MoreButton: FC<IMoreButton> = ({ node, focused, deleteNode }) => (\n  <div\n    className={`workflow-comment-more-button ${\n      focused ? 'workflow-comment-more-button-focused' : ''\n    }`}\n  >\n    <NodeMenu node={node} deleteNode={deleteNode} />\n  </div>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/render.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC } from 'react';\n\nimport {\n  Field,\n  FieldRenderProps,\n  FlowNodeFormData,\n  Form,\n  FormModelV2,\n  useNodeRender,\n  WorkflowNodeEntity,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { useOverflow } from '../hooks/use-overflow';\nimport { useModel } from '../hooks/use-model';\nimport { useSize } from '../hooks';\nimport { CommentEditorFormField } from '../constant';\nimport { MoreButton } from './more-button';\nimport { CommentEditor } from './editor';\nimport { ContentDragArea } from './content-drag-area';\nimport { CommentContainer } from './container';\nimport { BorderArea } from './border-area';\n\nexport const CommentRender: FC<{\n  node: WorkflowNodeEntity;\n}> = (props) => {\n  const { node } = props;\n  const model = useModel();\n\n  const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender();\n\n  const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();\n  const formControl = formModel?.formControl;\n\n  const { width, height, onResize } = useSize();\n  const { overflow, updateOverflow } = useOverflow({ model, height });\n\n  return (\n    <div\n      className=\"workflow-comment\"\n      style={{\n        width,\n        height,\n      }}\n      ref={nodeRef}\n      data-node-selected={String(focused)}\n      onMouseEnter={updateOverflow}\n      onMouseDown={(e) => {\n        setTimeout(() => {\n          // 防止 selectNode 拦截事件，导致 slate 编辑器无法聚焦\n          selectNode(e);\n          // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- delay\n        }, 20);\n      }}\n    >\n      <Form control={formControl}>\n        <>\n          {/* 背景 */}\n          <CommentContainer focused={focused} style={{ height }}>\n            <Field name={CommentEditorFormField.Note}>\n              {({ field }: FieldRenderProps<string>) => (\n                <>\n                  {/** 编辑器 */}\n                  <CommentEditor model={model} value={field.value} onChange={field.onChange} />\n                  {/* 内容拖拽区域（点击后隐藏） */}\n                  <ContentDragArea model={model} focused={focused} overflow={overflow} />\n                  {/* 更多按钮 */}\n                  <MoreButton node={node} focused={focused} deleteNode={deleteNode} />\n                </>\n              )}\n            </Field>\n          </CommentContainer>\n          {/* 边框 */}\n          <BorderArea model={model} overflow={overflow} onResize={onResize} />\n        </>\n      </Form>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/components/resize-area.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { CSSProperties, type FC } from 'react';\n\nimport { MouseTouchEvent, useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';\n\nimport type { CommentEditorModel } from '../model';\n\ninterface IResizeArea {\n  model: CommentEditorModel;\n  onResize?: () => {\n    resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;\n    resizeEnd: () => void;\n  };\n  getDelta?: (delta: { x: number; y: number }) => {\n    top: number;\n    right: number;\n    bottom: number;\n    left: number;\n  };\n  style?: CSSProperties;\n}\n\nexport const ResizeArea: FC<IResizeArea> = (props) => {\n  const { model, onResize, getDelta, style } = props;\n\n  const playground = usePlayground();\n\n  const { selectNode } = useNodeRender();\n\n  const handleResizeStart = (\n    startResizeEvent: React.MouseEvent | React.TouchEvent | MouseEvent\n  ) => {\n    MouseTouchEvent.preventDefault(startResizeEvent);\n    startResizeEvent.stopPropagation();\n    if (!onResize) {\n      return;\n    }\n    const { resizing, resizeEnd } = onResize();\n    model.setFocus(false);\n    selectNode(startResizeEvent as React.MouseEvent);\n    playground.node.focus(); // 防止节点无法被删除\n\n    const { clientX: startX, clientY: startY } = MouseTouchEvent.getEventCoord(\n      startResizeEvent as MouseEvent\n    );\n\n    const handleResizing = (mouseMoveEvent: MouseEvent | TouchEvent) => {\n      const { clientX: moveX, clientY: moveY } = MouseTouchEvent.getEventCoord(mouseMoveEvent);\n      const deltaX = moveX - startX;\n      const deltaY = moveY - startY;\n      const delta = getDelta?.({ x: deltaX, y: deltaY });\n      if (!delta || !resizing) {\n        return;\n      }\n      resizing(delta);\n    };\n\n    const handleResizeEnd = () => {\n      resizeEnd();\n      document.removeEventListener('mousemove', handleResizing);\n      document.removeEventListener('mouseup', handleResizeEnd);\n      document.removeEventListener('click', handleResizeEnd);\n      document.removeEventListener('touchmove', handleResizing);\n      document.removeEventListener('touchend', handleResizeEnd);\n      document.removeEventListener('touchcancel', handleResizeEnd);\n    };\n\n    document.addEventListener('mousemove', handleResizing);\n    document.addEventListener('mouseup', handleResizeEnd);\n    document.addEventListener('click', handleResizeEnd);\n    document.addEventListener('touchmove', handleResizing, { passive: false });\n    document.addEventListener('touchend', handleResizeEnd);\n    document.addEventListener('touchcancel', handleResizeEnd);\n  };\n\n  return (\n    <div\n      className=\"workflow-comment-resize-area\"\n      style={style}\n      data-flow-editor-selectable=\"false\"\n      onMouseDown={handleResizeStart}\n      onTouchStart={handleResizeStart}\n    />\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/constant.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n/* eslint-disable @typescript-eslint/naming-convention -- enum */\n\nexport enum CommentEditorFormField {\n  Size = 'size',\n  Note = 'note',\n}\n\n/** 编辑器事件 */\nexport enum CommentEditorEvent {\n  /** 初始化事件 */\n  Init = 'init',\n  /** 内容变更事件 */\n  Change = 'change',\n  /** 多选事件 */\n  MultiSelect = 'multiSelect',\n  /** 单选事件 */\n  Select = 'select',\n  /** 失焦事件 */\n  Blur = 'blur',\n}\n\nexport const CommentEditorDefaultValue = '';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/hooks/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { useSize } from './use-size';\nexport { usePlaceholder } from './use-placeholder';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/hooks/use-model.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useEffect, useMemo } from 'react';\n\nimport {\n  FlowNodeFormData,\n  FormModelV2,\n  useEntityFromContext,\n  useNodeRender,\n  WorkflowNodeEntity,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { CommentEditorModel } from '../model';\nimport { CommentEditorFormField } from '../constant';\n\nexport const useModel = () => {\n  const node = useEntityFromContext<WorkflowNodeEntity>();\n  const { selected: focused } = useNodeRender();\n\n  const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();\n\n  const model = useMemo(() => new CommentEditorModel(), []);\n\n  // 同步失焦状态\n  useEffect(() => {\n    if (focused) {\n      return;\n    }\n    model.setFocus(focused);\n  }, [focused, model]);\n\n  // 同步表单值初始化\n  useEffect(() => {\n    const value = formModel.getValueIn<string>(CommentEditorFormField.Note);\n    model.setInitValue(value); // 设置初始值\n    model.selectEnd(); // 设置初始化光标位置\n  }, [formModel, model]);\n\n  // 同步表单外部值变化：undo/redo/协同\n  useEffect(() => {\n    const disposer = formModel.onFormValuesChange(({ name }) => {\n      if (name !== CommentEditorFormField.Note && name !== '') {\n        return;\n      }\n      const value = formModel.getValueIn<string>(CommentEditorFormField.Note);\n      model.setValue(value);\n    });\n    return () => disposer.dispose();\n  }, [formModel, model]);\n\n  return model;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/hooks/use-overflow.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback, useState, useEffect } from 'react';\n\nimport { usePlayground } from '@flowgram.ai/free-layout-editor';\n\nimport { CommentEditorModel } from '../model';\nimport { CommentEditorEvent } from '../constant';\n\nexport const useOverflow = (params: { model: CommentEditorModel; height: number }) => {\n  const { model, height } = params;\n  const playground = usePlayground();\n\n  const [overflow, setOverflow] = useState(false);\n\n  const isOverflow = useCallback((): boolean => {\n    if (!model.element) {\n      return false;\n    }\n    return model.element.scrollHeight > model.element.clientHeight;\n  }, [model, height, playground]);\n\n  // 更新 overflow\n  const updateOverflow = useCallback(() => {\n    setOverflow(isOverflow());\n  }, [isOverflow]);\n\n  // 监听高度变化\n  useEffect(() => {\n    updateOverflow();\n  }, [height, updateOverflow]);\n\n  // 监听 change 事件\n  useEffect(() => {\n    const changeDisposer = model.on((params) => {\n      if (params.type !== CommentEditorEvent.Change && params.type !== CommentEditorEvent.Init) {\n        return;\n      }\n      updateOverflow();\n    });\n    return () => {\n      changeDisposer.dispose();\n    };\n  }, [model, updateOverflow]);\n\n  return { overflow, updateOverflow };\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/hooks/use-placeholder.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useState, useEffect } from 'react';\n\nimport { CommentEditorModel } from '../model';\nimport { CommentEditorEvent } from '../constant';\n\nexport const usePlaceholder = (params: { model: CommentEditorModel }): string | undefined => {\n  const { model } = params;\n\n  const [placeholder, setPlaceholder] = useState<string | undefined>('Enter a comment...');\n\n  // 监听 change 事件\n  useEffect(() => {\n    const changeDisposer = model.on((params) => {\n      if (params.type !== CommentEditorEvent.Change && params.type !== CommentEditorEvent.Init) {\n        return;\n      }\n      if (params.value) {\n        setPlaceholder(undefined);\n      } else {\n        setPlaceholder('Enter a comment...');\n      }\n    });\n    return () => {\n      changeDisposer.dispose();\n    };\n  }, [model]);\n\n  return placeholder;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/hooks/use-size.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback, useEffect, useState } from 'react';\n\nimport {\n  FlowNodeFormData,\n  FormModelV2,\n  FreeOperationType,\n  HistoryService,\n  TransformData,\n  useCurrentEntity,\n  usePlayground,\n  useService,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { CommentEditorFormField } from '../constant';\n\nexport const useSize = () => {\n  const node = useCurrentEntity();\n  const nodeMeta = node.getNodeMeta();\n  const playground = usePlayground();\n  const historyService = useService(HistoryService);\n  const { size = { width: 240, height: 150 } } = nodeMeta;\n  const transform = node.getData(TransformData);\n  const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();\n  const formSize = formModel.getValueIn<{ width: number; height: number }>(\n    CommentEditorFormField.Size\n  );\n\n  const [width, setWidth] = useState(formSize?.width ?? size.width);\n  const [height, setHeight] = useState(formSize?.height ?? size.height);\n\n  // 初始化表单值\n  useEffect(() => {\n    const initSize = formModel.getValueIn<{ width: number; height: number }>(\n      CommentEditorFormField.Size\n    );\n    if (!initSize) {\n      formModel.setValueIn(CommentEditorFormField.Size, {\n        width,\n        height,\n      });\n    }\n  }, [formModel, width, height]);\n\n  // 同步表单外部值变化：初始化/undo/redo/协同\n  useEffect(() => {\n    const disposer = formModel.onFormValuesChange(({ name }) => {\n      if (name !== CommentEditorFormField.Size && name !== '') {\n        return;\n      }\n      const newSize = formModel.getValueIn<{ width: number; height: number }>(\n        CommentEditorFormField.Size\n      );\n      if (!newSize) {\n        return;\n      }\n      setWidth(newSize.width);\n      setHeight(newSize.height);\n    });\n    return () => disposer.dispose();\n  }, [formModel]);\n\n  const onResize = useCallback(() => {\n    const resizeState = {\n      width,\n      height,\n      originalWidth: width,\n      originalHeight: height,\n      positionX: transform.position.x,\n      positionY: transform.position.y,\n      offsetX: 0,\n      offsetY: 0,\n    };\n    const resizing = (delta: { top: number; right: number; bottom: number; left: number }) => {\n      if (!resizeState) {\n        return;\n      }\n\n      const { zoom } = playground.config;\n\n      const top = delta.top / zoom;\n      const right = delta.right / zoom;\n      const bottom = delta.bottom / zoom;\n      const left = delta.left / zoom;\n\n      const minWidth = 120;\n      const minHeight = 80;\n\n      const newWidth = Math.max(minWidth, resizeState.originalWidth + right - left);\n      const newHeight = Math.max(minHeight, resizeState.originalHeight + bottom - top);\n\n      // 如果宽度或高度小于最小值，则不更新偏移量\n      const newOffsetX =\n        (left > 0 || right < 0) && newWidth <= minWidth\n          ? resizeState.offsetX\n          : left / 2 + right / 2;\n      const newOffsetY =\n        (top > 0 || bottom < 0) && newHeight <= minHeight ? resizeState.offsetY : top;\n\n      const newPositionX = resizeState.positionX + newOffsetX;\n      const newPositionY = resizeState.positionY + newOffsetY;\n\n      resizeState.width = newWidth;\n      resizeState.height = newHeight;\n      resizeState.offsetX = newOffsetX;\n      resizeState.offsetY = newOffsetY;\n\n      // 更新状态\n      setWidth(newWidth);\n      setHeight(newHeight);\n\n      // 更新偏移量\n      transform.update({\n        position: {\n          x: newPositionX,\n          y: newPositionY,\n        },\n      });\n    };\n\n    const resizeEnd = () => {\n      historyService.transact(() => {\n        historyService.pushOperation(\n          {\n            type: FreeOperationType.dragNodes,\n            value: {\n              ids: [node.id],\n              value: [\n                {\n                  x: resizeState.positionX + resizeState.offsetX,\n                  y: resizeState.positionY + resizeState.offsetY,\n                },\n              ],\n              oldValue: [\n                {\n                  x: resizeState.positionX,\n                  y: resizeState.positionY,\n                },\n              ],\n            },\n          },\n          {\n            noApply: true,\n          }\n        );\n        formModel.setValueIn(CommentEditorFormField.Size, {\n          width: resizeState.width,\n          height: resizeState.height,\n        });\n      });\n    };\n\n    return {\n      resizing,\n      resizeEnd,\n    };\n  }, [node, width, height, transform, playground, formModel, historyService]);\n\n  return {\n    width,\n    height,\n    onResize,\n  };\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { CommentRender } from './components';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/model.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Emitter } from '@flowgram.ai/free-layout-editor';\n\nimport { CommentEditorEventParams } from './type';\nimport { CommentEditorDefaultValue, CommentEditorEvent } from './constant';\n\nexport class CommentEditorModel {\n  private innerValue: string = CommentEditorDefaultValue;\n\n  private emitter: Emitter<CommentEditorEventParams> = new Emitter();\n\n  private editor: HTMLTextAreaElement;\n\n  /** 注册事件 */\n  public on = this.emitter.event;\n\n  /** 获取当前值 */\n  public get value(): string {\n    return this.innerValue;\n  }\n\n  /** 外部设置模型值 */\n  public setValue(value: string = CommentEditorDefaultValue): void {\n    if (!this.initialized) {\n      return;\n    }\n    if (value === this.innerValue) {\n      return;\n    }\n    this.innerValue = value;\n    this.syncEditorValue();\n    this.emitter.fire({\n      type: CommentEditorEvent.Change,\n      value: this.innerValue,\n    });\n  }\n\n  /** 外部设置模型值 */\n  public setInitValue(value: string = CommentEditorDefaultValue): void {\n    if (!this.initialized) {\n      return;\n    }\n    if (value === this.innerValue) {\n      return;\n    }\n    this.innerValue = value;\n    this.syncEditorValue();\n    this.emitter.fire({\n      type: CommentEditorEvent.Init,\n      value: this.innerValue,\n    });\n  }\n\n  public set element(el: HTMLTextAreaElement) {\n    if (this.initialized) {\n      return;\n    }\n    this.editor = el;\n  }\n\n  /** 获取编辑器 DOM 节点 */\n  public get element(): HTMLTextAreaElement {\n    return this.editor;\n  }\n\n  /** 编辑器聚焦/失焦 */\n  public setFocus(focused: boolean): void {\n    if (!this.initialized) {\n      return;\n    }\n    if (focused && !this.focused) {\n      this.editor.focus();\n    } else if (!focused && this.focused) {\n      this.editor.blur();\n      this.deselect();\n      this.emitter.fire({\n        type: CommentEditorEvent.Blur,\n      });\n    }\n  }\n\n  /** 选择末尾 */\n  public selectEnd(): void {\n    if (!this.initialized) {\n      return;\n    }\n    // 获取文本长度\n    const length = this.editor.value.length;\n    // 将选择范围设置为文本末尾(开始位置和结束位置都是文本长度)\n    this.editor.setSelectionRange(length, length);\n  }\n\n  /** 获取聚焦状态 */\n  public get focused(): boolean {\n    return document.activeElement === this.editor;\n  }\n\n  /** 取消选择文本 */\n  private deselect(): void {\n    const selection: Selection | null = window.getSelection();\n\n    // 清除所有选择区域\n    if (selection) {\n      selection.removeAllRanges();\n    }\n  }\n\n  /** 是否初始化 */\n  private get initialized(): boolean {\n    return Boolean(this.editor);\n  }\n\n  /**\n   * 同步编辑器实例内容\n   * > **NOTICE:** *为确保不影响性能，应仅在外部值变更导致编辑器值与模型值不一致时调用*\n   */\n  private syncEditorValue(): void {\n    if (!this.initialized) {\n      return;\n    }\n    this.editor.value = this.innerValue;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/comment/type.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport type { CommentEditorEvent } from './constant';\n\ninterface CommentEditorChangeEvent {\n  type: CommentEditorEvent.Change;\n  value: string;\n}\n\ninterface CommentEditorMultiSelectEvent {\n  type: CommentEditorEvent.MultiSelect;\n}\n\ninterface CommentEditorSelectEvent {\n  type: CommentEditorEvent.Select;\n}\n\ninterface CommentEditorBlurEvent {\n  type: CommentEditorEvent.Blur;\n}\n\ninterface CommentEditorInitEvent {\n  type: CommentEditorEvent.Init;\n  value: string;\n}\n\nexport type CommentEditorEventParams =\n  | CommentEditorChangeEvent\n  | CommentEditorMultiSelectEvent\n  | CommentEditorSelectEvent\n  | CommentEditorBlurEvent\n  | CommentEditorInitEvent;\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/color.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\ntype GroupColor = {\n  '50': string;\n  '300': string;\n  '400': string;\n};\n\nexport const defaultColor = 'Blue';\n\nexport const groupColors: Record<string, GroupColor> = {\n  Red: {\n    '50': '#fef2f2',\n    '300': '#fca5a5',\n    '400': '#f87171',\n  },\n  Orange: {\n    '50': '#fff7ed',\n    '300': '#fdba74',\n    '400': '#fb923c',\n  },\n  Amber: {\n    '50': '#fffbeb',\n    '300': '#fcd34d',\n    '400': '#fbbf24',\n  },\n  Yellow: {\n    '50': '#fef9c3',\n    '300': '#fde047',\n    '400': '#facc15',\n  },\n  Lime: {\n    '50': '#f7fee7',\n    '300': '#bef264',\n    '400': '#a3e635',\n  },\n  Green: {\n    '50': '#f0fdf4',\n    '300': '#86efac',\n    '400': '#4ade80',\n  },\n  Emerald: {\n    '50': '#ecfdf5',\n    '300': '#6ee7b7',\n    '400': '#34d399',\n  },\n  Teal: {\n    '50': '#f0fdfa',\n    '300': '#5eead4',\n    '400': '#2dd4bf',\n  },\n  Cyan: {\n    '50': '#ecfeff',\n    '300': '#67e8f9',\n    '400': '#22d3ee',\n  },\n  Sky: {\n    '50': '#ecfeff',\n    '300': '#7dd3fc',\n    '400': '#38bdf8',\n  },\n  Blue: {\n    '50': '#eff6ff',\n    '300': '#93c5fd',\n    '400': '#60a5fa',\n  },\n  Indigo: {\n    '50': '#eef2ff',\n    '300': '#a5b4fc',\n    '400': '#818cf8',\n  },\n  Violet: {\n    '50': '#f5f3ff',\n    '300': '#c4b5fd',\n    '400': '#a78bfa',\n  },\n  Purple: {\n    '50': '#faf5ff',\n    '300': '#d8b4fe',\n    '400': '#c084fc',\n  },\n  Fuchsia: {\n    '50': '#fdf4ff',\n    '300': '#f0abfc',\n    '400': '#e879f9',\n  },\n  Pink: {\n    '50': '#fdf2f8',\n    '300': '#f9a8d4',\n    '400': '#f472b6',\n  },\n  Rose: {\n    '50': '#fff1f2',\n    '300': '#fda4af',\n    '400': '#fb7185',\n  },\n  Gray: {\n    '50': '#f9fafb',\n    '300': '#d1d5db',\n    '400': '#9ca3af',\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/background.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { CSSProperties, FC, useEffect } from 'react';\n\nimport { useWatch, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';\n\nimport { GroupField } from '../constant';\nimport { defaultColor, groupColors } from '../color';\n\ninterface GroupBackgroundProps {\n  node: WorkflowNodeEntity;\n  style?: CSSProperties;\n  selected: boolean;\n}\n\nexport const GroupBackground: FC<GroupBackgroundProps> = ({ node, style, selected }) => {\n  const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;\n  const color = groupColors[colorName];\n\n  useEffect(() => {\n    const styleElement = document.createElement('style');\n\n    // 使用独特的选择器\n    const styleContent = `\n      .workflow-group-render[data-group-id=\"${node.id}\"] .workflow-group-background {\n        border: 1px solid ${color['300']};\n      }\n\n      .workflow-group-render.selected[data-group-id=\"${node.id}\"] .workflow-group-background {\n        border: 1px solid #4e40e5;\n      }\n    `;\n\n    styleElement.textContent = styleContent;\n    document.head.appendChild(styleElement);\n\n    return () => {\n      styleElement.remove();\n    };\n  }, [color]);\n\n  return (\n    <div\n      className=\"workflow-group-background\"\n      data-flow-editor-selectable=\"true\"\n      style={{\n        ...style,\n        backgroundColor: `${color['300']}${selected ? '40' : '29'}`,\n      }}\n    />\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/color.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC } from 'react';\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { Popover, Tooltip } from '@douyinfe/semi-ui';\n\nimport { GroupField } from '../constant';\nimport { defaultColor, groupColors } from '../color';\n\nexport const GroupColor: FC = () => (\n  <Field<string> name={GroupField.Color}>\n    {({ field }) => {\n      const colorName = field.value ?? defaultColor;\n      return (\n        <Popover\n          position=\"top\"\n          mouseLeaveDelay={300}\n          content={\n            <div className=\"workflow-group-color-palette\">\n              {Object.entries(groupColors).map(([name, color]) => (\n                <Tooltip content={name} key={name} mouseEnterDelay={300}>\n                  <span\n                    className=\"workflow-group-color-item\"\n                    key={name}\n                    style={{\n                      backgroundColor: color['300'],\n                      borderColor: name === colorName ? color['400'] : '#fff',\n                    }}\n                    onClick={() => field.onChange(name)}\n                  />\n                </Tooltip>\n              ))}\n            </div>\n          }\n        >\n          <span\n            className=\"workflow-group-color\"\n            style={{\n              backgroundColor: groupColors[colorName]['300'],\n            }}\n          />\n        </Popover>\n      );\n    }}\n  </Field>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/header.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport type { FC, ReactNode, MouseEvent, CSSProperties, TouchEvent } from 'react';\n\nimport { useWatch } from '@flowgram.ai/free-layout-editor';\n\nimport { GroupField } from '../constant';\nimport { defaultColor, groupColors } from '../color';\n\ninterface GroupHeaderProps {\n  onDrag: (e: MouseEvent | TouchEvent) => void;\n  onFocus: () => void;\n  onBlur: () => void;\n  children: ReactNode;\n  style?: CSSProperties;\n}\n\nexport const GroupHeader: FC<GroupHeaderProps> = ({ onDrag, onFocus, onBlur, children, style }) => {\n  const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;\n  const color = groupColors[colorName];\n  return (\n    <div\n      className=\"workflow-group-header\"\n      data-flow-editor-selectable=\"false\"\n      onMouseDown={onDrag}\n      onTouchStart={onDrag}\n      onFocus={onFocus}\n      onBlur={onBlur}\n      style={{\n        ...style,\n        backgroundColor: color['50'],\n        borderColor: color['300'],\n      }}\n    >\n      {children}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/icon-group.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC } from 'react';\n\ninterface IconGroupProps {\n  size?: number;\n}\n\nexport const IconGroup: FC<IconGroupProps> = ({ size }) => (\n  <svg\n    width=\"10\"\n    height=\"10\"\n    viewBox=\"0 0 10 10\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    style={{\n      width: size,\n      height: size,\n    }}\n  >\n    <path\n      id=\"group\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n      stroke=\"none\"\n      d=\"M 0.009766 10 L 0.009766 9.990234 L 0 9.990234 L 0 7.5 L 1 7.5 L 1 9 L 2.5 9 L 2.5 10 L 0.009766 10 Z M 3.710938 10 L 3.710938 9 L 6.199219 9 L 6.199219 10 L 3.710938 10 Z M 7.5 10 L 7.5 9 L 9 9 L 9 7.5 L 10 7.5 L 10 9.990234 L 9.990234 9.990234 L 9.990234 10 L 7.5 10 Z M 0 6.289063 L 0 3.800781 L 1 3.800781 L 1 6.289063 L 0 6.289063 Z M 9 6.289063 L 9 3.800781 L 10 3.800781 L 10 6.289063 L 9 6.289063 Z M 0 2.5 L 0 0.009766 L 0.009766 0.009766 L 0.009766 0 L 2.5 0 L 2.5 1 L 1 1 L 1 2.5 L 0 2.5 Z M 9 2.5 L 9 1 L 7.5 1 L 7.5 0 L 9.990234 0 L 9.990234 0.009766 L 10 0.009766 L 10 2.5 L 9 2.5 Z M 3.710938 1 L 3.710938 0 L 6.199219 0 L 6.199219 1 L 3.710938 1 Z\"\n    />\n  </svg>\n);\n\nexport const IconUngroup: FC<IconGroupProps> = ({ size }) => (\n  <svg\n    width=\"10\"\n    height=\"10\"\n    viewBox=\"0 0 10 10\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    style={{\n      width: size,\n      height: size,\n    }}\n  >\n    <path\n      id=\"ungroup\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n      stroke=\"none\"\n      d=\"M 9.654297 10.345703 L 8.808594 9.5 L 7.175781 9.5 L 7.175781 8.609375 L 7.917969 8.609375 L 1.390625 2.082031 L 1.390625 2.824219 L 0.5 2.824219 L 0.5 1.191406 L -0.345703 0.345703 L 0.283203 -0.283203 L 1.166016 0.599609 L 2.724609 0.599609 L 2.724609 1.490234 L 2.056641 1.490234 L 8.509766 7.943359 L 8.509766 7.275391 L 9.400391 7.275391 L 9.400391 8.833984 L 10.283203 9.716797 L 9.654297 10.345703 Z M 0.509766 9.5 L 0.509766 9.490234 L 0.5 9.490234 L 0.5 7.275391 L 1.390625 7.275391 L 1.390625 8.609375 L 2.724609 8.609375 L 2.724609 9.5 L 0.509766 9.5 Z M 3.802734 9.5 L 3.802734 8.609375 L 6.017578 8.609375 L 6.017578 9.5 L 3.802734 9.5 Z M 0.5 6.197266 L 0.5 3.982422 L 1.390625 3.982422 L 1.390625 6.197266 L 0.5 6.197266 Z M 8.509766 6.197266 L 8.509766 3.982422 L 9.400391 3.982422 L 9.400391 6.197266 L 8.509766 6.197266 Z M 8.509766 2.824219 L 8.509766 1.490234 L 7.175781 1.490234 L 7.175781 0.599609 L 9.390625 0.599609 L 9.390625 0.609375 L 9.400391 0.609375 L 9.400391 2.824219 L 8.509766 2.824219 Z M 3.802734 1.490234 L 3.802734 0.599609 L 6.017578 0.599609 L 6.017578 1.490234 L 3.802734 1.490234 Z\"\n    />\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { GroupNodeRender } from './node-render';\nexport { IconGroup } from './icon-group';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/node-render.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { MouseEvent, useEffect } from 'react';\n\nimport {\n  FlowNodeFormData,\n  Form,\n  FormModelV2,\n  useNodeRender,\n} from '@flowgram.ai/free-layout-editor';\nimport { useNodeSize } from '@flowgram.ai/free-container-plugin';\n\nimport { HEADER_HEIGHT, HEADER_PADDING } from '../constant';\nimport { UngroupButton } from './ungroup';\nimport { GroupTools } from './tools';\nimport { GroupTips } from './tips';\nimport { GroupHeader } from './header';\nimport { GroupBackground } from './background';\n\nexport const GroupNodeRender = () => {\n  const { node, selected, selectNode, nodeRef, startDrag, onFocus, onBlur } = useNodeRender();\n  const nodeSize = useNodeSize();\n  const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();\n  const formControl = formModel?.formControl;\n\n  const { height, width } = nodeSize ?? {};\n  const nodeHeight = height ?? 0;\n\n  useEffect(() => {\n    // prevent lines in outside cannot be selected - 防止外层线条不可选中\n    const element = node.renderData.node;\n    element.style.pointerEvents = 'none';\n  }, [node]);\n\n  return (\n    <div\n      className={`workflow-group-render ${selected ? 'selected' : ''}`}\n      ref={nodeRef}\n      data-group-id={node.id}\n      data-node-selected={String(selected)}\n      onMouseDown={selectNode}\n      onClick={(e) => {\n        selectNode(e);\n      }}\n      style={{\n        width,\n        height,\n      }}\n    >\n      <Form control={formControl}>\n        <>\n          <GroupHeader\n            onDrag={(e) => {\n              startDrag(e as MouseEvent);\n              e.stopPropagation();\n            }}\n            onFocus={onFocus}\n            onBlur={onBlur}\n            style={{\n              height: HEADER_HEIGHT,\n            }}\n          >\n            <GroupTools />\n          </GroupHeader>\n          <GroupTips />\n          <UngroupButton node={node} />\n          <GroupBackground\n            node={node}\n            selected={selected}\n            style={{\n              top: HEADER_HEIGHT + HEADER_PADDING,\n              height: nodeHeight - HEADER_HEIGHT - HEADER_PADDING,\n            }}\n          />\n        </>\n      </Form>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/tips/global-store.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n/* eslint-disable @typescript-eslint/naming-convention -- no need */\n\nconst STORAGE_KEY = 'workflow-move-into-group-tip-visible';\nconst STORAGE_VALUE = 'false';\n\nexport class TipsGlobalStore {\n  private static _instance?: TipsGlobalStore;\n\n  public static get instance(): TipsGlobalStore {\n    if (!this._instance) {\n      this._instance = new TipsGlobalStore();\n    }\n    return this._instance;\n  }\n\n  private closed = false;\n\n  public isClosed(): boolean {\n    return this.isCloseForever() || this.closed;\n  }\n\n  public close(): void {\n    this.closed = true;\n  }\n\n  public isCloseForever(): boolean {\n    return localStorage.getItem(STORAGE_KEY) === STORAGE_VALUE;\n  }\n\n  public closeForever(): void {\n    localStorage.setItem(STORAGE_KEY, STORAGE_VALUE);\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/tips/icon-close.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport const IconClose = () => (\n  <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 16 16\">\n    <path\n      fill=\"#060709\"\n      fillOpacity=\"0.5\"\n      d=\"M12.13 12.128a.5.5 0 0 0 .001-.706L8.71 8l3.422-3.423a.5.5 0 0 0-.001-.705.5.5 0 0 0-.706-.002L8.002 7.293 4.579 3.87a.5.5 0 0 0-.705.002.5.5 0 0 0-.002.705L7.295 8l-3.423 3.422a.5.5 0 0 0 .002.706c.195.195.51.197.705.001l3.423-3.422 3.422 3.422c.196.196.51.194.706-.001\"\n    ></path>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/tips/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useControlTips } from './use-control';\nimport { GroupTipsStyle } from './style';\nimport { isMacOS } from './is-mac-os';\nimport { IconClose } from './icon-close';\n\nexport const GroupTips = () => {\n  const { visible, close, closeForever } = useControlTips();\n\n  if (!visible) {\n    return null;\n  }\n\n  return (\n    <GroupTipsStyle className={'workflow-group-tips'}>\n      <div className=\"container\">\n        <div className=\"content\">\n          <p className=\"text\">{`Hold ${isMacOS ? 'Cmd ⌘' : 'Ctrl'} to drag node out`}</p>\n          <div\n            className=\"space\"\n            style={{\n              width: 0,\n            }}\n          />\n        </div>\n        <div className=\"actions\">\n          <p className=\"close-forever\" onClick={closeForever}>\n            Never Remind\n          </p>\n          <div className=\"close\" onClick={close}>\n            <IconClose />\n          </div>\n        </div>\n      </div>\n    </GroupTipsStyle>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/tips/is-mac-os.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/tips/style.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport styled from 'styled-components';\n\nexport const GroupTipsStyle = styled.div`\n  position: absolute;\n  top: 35px;\n\n  width: 100%;\n  height: 28px;\n  white-space: nowrap;\n  pointer-events: auto;\n\n  .container {\n    display: inline-flex;\n    justify-content: center;\n    height: 100%;\n    width: 100%;\n    background-color: rgb(255 255 255);\n    border-radius: 8px 8px 0 0;\n\n    .content {\n      overflow: hidden;\n      display: inline-flex;\n      align-items: center;\n      justify-content: flex-start;\n\n      width: fit-content;\n      height: 100%;\n      padding: 0 12px;\n\n      .text {\n        font-size: 14px;\n        font-weight: 400;\n        font-style: normal;\n        line-height: 20px;\n        color: rgba(15, 21, 40, 82%);\n        text-overflow: ellipsis;\n        margin: 0;\n      }\n\n      .space {\n        width: 128px;\n      }\n    }\n\n    .actions {\n      display: flex;\n      gap: 8px;\n      align-items: center;\n\n      height: 28px;\n      padding: 0 12px;\n\n      .close-forever {\n        cursor: pointer;\n\n        padding: 0 3px;\n\n        font-size: 12px;\n        font-weight: 400;\n        font-style: normal;\n        line-height: 12px;\n        color: rgba(32, 41, 69, 62%);\n        margin: 0;\n      }\n\n      .close {\n        display: flex;\n        cursor: pointer;\n        height: 100%;\n        align-items: center;\n      }\n    }\n  }\n`;\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/tips/use-control.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback, useEffect, useState } from 'react';\n\nimport { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';\nimport {\n  NodeIntoContainerService,\n  NodeIntoContainerType,\n} from '@flowgram.ai/free-container-plugin';\n\nimport { TipsGlobalStore } from './global-store';\n\nexport const useControlTips = () => {\n  const node = useCurrentEntity();\n  const [visible, setVisible] = useState(false);\n  const globalStore = TipsGlobalStore.instance;\n\n  const nodeIntoContainerService = useService<NodeIntoContainerService>(NodeIntoContainerService);\n\n  const show = useCallback(() => {\n    if (globalStore.isClosed()) {\n      return;\n    }\n\n    setVisible(true);\n  }, [globalStore]);\n\n  const close = useCallback(() => {\n    globalStore.close();\n    setVisible(false);\n  }, [globalStore]);\n\n  const closeForever = useCallback(() => {\n    globalStore.closeForever();\n    close();\n  }, [close, globalStore]);\n\n  useEffect(() => {\n    // 监听移入\n    const inDisposer = nodeIntoContainerService.on((e) => {\n      if (e.type !== NodeIntoContainerType.In) {\n        return;\n      }\n      if (e.targetContainer === node) {\n        show();\n      }\n    });\n    // 监听移出事件\n    const outDisposer = nodeIntoContainerService.on((e) => {\n      if (e.type !== NodeIntoContainerType.Out) {\n        return;\n      }\n      if (e.sourceContainer === node && !node.blocks.length) {\n        setVisible(false);\n      }\n    });\n    return () => {\n      inDisposer.dispose();\n      outDisposer.dispose();\n    };\n  }, [nodeIntoContainerService, node, show, close, visible]);\n\n  return {\n    visible,\n    close,\n    closeForever,\n  };\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/title.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC, useState } from 'react';\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { Input } from '@douyinfe/semi-ui';\n\nimport { GroupField } from '../constant';\n\nexport const GroupTitle: FC = () => {\n  const [inputting, setInputting] = useState(false);\n  return (\n    <Field<string> name={GroupField.Title}>\n      {({ field }) =>\n        inputting ? (\n          <Input\n            autoFocus\n            className=\"workflow-group-title-input\"\n            size=\"small\"\n            value={field.value}\n            onChange={field.onChange}\n            onMouseDown={(e) => e.stopPropagation()}\n            onBlur={() => setInputting(false)}\n            draggable={false}\n            onEnterPress={() => setInputting(false)}\n          />\n        ) : (\n          <p className=\"workflow-group-title\" onDoubleClick={() => setInputting(true)}>\n            {field.value ?? 'Group'}\n          </p>\n        )\n      }\n    </Field>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/tools.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC } from 'react';\n\nimport { IconHandle } from '@douyinfe/semi-icons';\n\nimport { GroupTitle } from './title';\nimport { GroupColor } from './color';\n\nexport const GroupTools: FC = () => (\n  <div className=\"workflow-group-tools\">\n    <IconHandle className=\"workflow-group-tools-drag\" />\n    <GroupTitle />\n    <GroupColor />\n  </div>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/components/ungroup.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { CSSProperties, FC } from 'react';\n\nimport { CommandRegistry, useService, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';\nimport { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';\nimport { Button, Tooltip } from '@douyinfe/semi-ui';\n\nimport { IconUngroup } from './icon-group';\n\ninterface UngroupButtonProps {\n  node: WorkflowNodeEntity;\n  style?: CSSProperties;\n}\n\nexport const UngroupButton: FC<UngroupButtonProps> = ({ node, style }) => {\n  const commandRegistry = useService(CommandRegistry);\n  return (\n    <Tooltip content=\"Ungroup\">\n      <div className=\"workflow-group-ungroup\" style={style}>\n        <Button\n          icon={<IconUngroup size={14} />}\n          style={{ height: 30, width: 30 }}\n          theme=\"borderless\"\n          type=\"tertiary\"\n          onClick={() => {\n            commandRegistry.executeCommand(WorkflowGroupCommand.Ungroup, node);\n          }}\n        />\n      </div>\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/constant.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport const HEADER_HEIGHT = 30;\nexport const HEADER_PADDING = 5;\n\nexport enum GroupField {\n  Title = 'title',\n  Color = 'color',\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/index.css",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.workflow-group-render {\n    border-radius: 8px;\n    pointer-events: none;\n}\n\n.workflow-group-background {\n    box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);\n}\n.workflow-group-header {\n    height: 30px;\n    width: fit-content;\n    background-color: #fefce8;\n    border: 1px solid #facc15;\n    border-radius: 8px;\n    padding-right: 8px;\n    pointer-events: auto;\n}\n\n.workflow-group-ungroup {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    height: 30px;\n    width: 30px;\n    position: absolute;\n    top: 35px;\n    right: 0;\n    border-radius: 8px;\n    cursor: pointer;\n    pointer-events: auto;\n}\n\n.workflow-group-ungroup .semi-button {\n    color: #9ca3af;\n}\n\n.workflow-group-ungroup:hover .semi-button {\n    color: #374151;\n}\n\n.workflow-group-background {\n    position: absolute;\n    pointer-events: none;\n    top: 0;\n    background-color: #fddf4729;\n    border: 1px solid #fde047;\n    border-radius: 8px;\n    width: 100%;\n}\n\n.workflow-group-render.selected .workflow-group-background {\n    border: 1px solid #facc15;\n}\n\n.workflow-group-tools {\n    display: flex;\n    justify-content: flex-start;\n    align-items: center;\n    gap: 4px;\n    height: 100%;\n    cursor: move;\n    color: oklch(44.6% 0.043 257.281);\n    font-size: 14px;\n}\n.workflow-group-title {\n    margin: 0;\n    max-width: 242px;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    font-weight: 500;\n}\n\n.workflow-group-tools-drag {\n    height: 100%;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    padding-left: 4px;\n}\n\n.workflow-group-color {\n    width: 16px;\n    height: 16px;\n    border-radius: 8px;\n    background-color: #fde047;\n    margin-left: 4px;\n    cursor: pointer;\n}\n\n.workflow-group-title-input {\n    width: 242px;\n    border: none;\n    color: #374151;\n}\n\n.workflow-group-color-palette {\n    display: grid;\n    grid-template-columns: repeat(6, 24px);\n    gap: 12px;\n    margin: 8px;\n    padding: 8px;\n}\n\n.workflow-group-color-item {\n    width: 24px;\n    height: 24px;\n    border-radius: 50%;\n    background-color: #fde047;\n    cursor: pointer;\n    border: 3px solid;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/group/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport './index.css';\n\nexport { GroupNodeRender } from './components';\nexport { IconGroup } from './components';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport * from './base-node';\nexport * from './line-add-button';\nexport * from './node-panel';\nexport * from './comment';\nexport * from './group';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/line-add-button/button.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport const IconPlusCircle = () => (\n  <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n    <g id=\"add\">\n      <path\n        id=\"background\"\n        fill=\"#ffffff\"\n        fillRule=\"evenodd\"\n        stroke=\"none\"\n        d=\"M 24 12 C 24 5.372583 18.627417 0 12 0 C 5.372583 0 -0 5.372583 -0 12 C -0 18.627417 5.372583 24 12 24 C 18.627417 24 24 18.627417 24 12 Z\"\n      />\n      <path\n        id=\"content\"\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n        stroke=\"none\"\n        d=\"M 22 12.005 C 22 6.482153 17.522848 2.004999 12 2.004999 C 6.477152 2.004999 2 6.482153 2 12.005 C 2 17.527847 6.477152 22.004999 12 22.004999 C 17.522848 22.004999 22 17.527847 22 12.005 Z\"\n      />\n      <path\n        id=\"cross\"\n        fill=\"#ffffff\"\n        stroke=\"none\"\n        d=\"M 11.411996 16.411797 C 11.411996 16.736704 11.675362 17 12.00023 17 C 12.325109 17 12.588474 16.736704 12.588474 16.411797 L 12.588474 12.58826 L 16.41201 12.58826 C 16.736919 12.58826 17.000216 12.324894 17.000216 12.000015 C 17.000216 11.675147 16.736919 11.411781 16.41201 11.411781 L 12.588474 11.411781 L 12.588474 7.588234 C 12.588474 7.263367 12.325109 7 12.00023 7 C 11.675362 7 11.411996 7.263367 11.411996 7.588234 L 11.411996 11.411781 L 7.588449 11.411781 C 7.263581 11.411781 7.000215 11.675147 7.000215 12.000015 C 7.000215 12.324894 7.263581 12.58826 7.588449 12.58826 L 11.411996 12.58826 L 11.411996 16.411797 Z\"\n      />\n    </g>\n  </svg>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/line-add-button/index.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.line-add-button {\n  position: absolute;\n  width: 24px;\n  height: 24px;\n  cursor: pointer;\n  color: inherit;\n  pointer-events: all;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/line-add-button/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback } from 'react';\n\nimport {\n  WorkflowNodePanelService,\n  WorkflowNodePanelUtils,\n} from '@flowgram.ai/free-node-panel-plugin';\nimport { LineRenderProps } from '@flowgram.ai/free-lines-plugin';\nimport {\n  delay,\n  HistoryService,\n  useService,\n  WorkflowDocument,\n  WorkflowDragService,\n  WorkflowLinesManager,\n  WorkflowNodeEntity,\n  WorkflowNodeJSON,\n} from '@flowgram.ai/free-layout-editor';\n\nimport './index.less';\nimport { useVisible } from './use-visible';\nimport { IconPlusCircle } from './button';\n\nexport const LineAddButton = (props: LineRenderProps) => {\n  const { line, selected, hovered, color } = props;\n  const visible = useVisible({ line, selected, hovered });\n  const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);\n  const document = useService(WorkflowDocument);\n  const dragService = useService(WorkflowDragService);\n  const linesManager = useService(WorkflowLinesManager);\n  const historyService = useService(HistoryService);\n\n  const { fromPort, toPort } = line;\n\n  const onClick = useCallback(async () => {\n    // calculate the middle point of the line - 计算线条的中点位置\n    const position = {\n      x: (line.position.from.x + line.position.to.x) / 2,\n      y: (line.position.from.y + line.position.to.y) / 2,\n    };\n\n    // get container node for the new node - 获取新节点的容器节点\n    const containerNode = fromPort!.node.parent;\n\n    // show node selection panel - 显示节点选择面板\n    const result = await nodePanelService.singleSelectNodePanel({\n      position,\n      containerNode,\n      panelProps: {\n        enableScrollClose: true,\n        fromPort,\n      },\n    });\n    if (!result) {\n      return;\n    }\n\n    const { nodeType, nodeJSON } = result;\n\n    // adjust position for the new node - 调整新节点的位置\n    const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({\n      nodeType,\n      position,\n      fromPort,\n      toPort,\n      containerNode,\n      document,\n      dragService,\n    });\n\n    // create new workflow node - 创建新的工作流节点\n    const node: WorkflowNodeEntity = document.createWorkflowNodeByType(\n      nodeType,\n      nodePosition,\n      nodeJSON ?? ({} as WorkflowNodeJSON),\n      containerNode?.id\n    );\n\n    // auto offset subsequent nodes - 自动偏移后续节点\n    if (fromPort && toPort) {\n      WorkflowNodePanelUtils.subNodesAutoOffset({\n        node,\n        fromPort,\n        toPort,\n        containerNode,\n        historyService,\n        dragService,\n        linesManager,\n      });\n    }\n\n    // wait for node render - 等待节点渲染\n    await delay(20);\n\n    // build connection lines - 构建连接线\n    WorkflowNodePanelUtils.buildLine({\n      fromPort,\n      node,\n      toPort,\n      linesManager,\n    });\n\n    // remove original line - 移除原始线条\n    line.dispose();\n  }, []);\n\n  if (!visible) {\n    return <></>;\n  }\n\n  return (\n    <div\n      className=\"line-add-button\"\n      style={{\n        transform: `translate(-50%, -50%) translate(${line.center.labelX}px, ${line.center.labelY}px)`,\n        color,\n      }}\n      data-testid=\"sdk.workflow.canvas.line.add\"\n      data-line-id={line.id}\n      onClick={onClick}\n    >\n      <IconPlusCircle />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/line-add-button/use-visible.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { usePlayground, WorkflowLineEntity } from '@flowgram.ai/free-layout-editor';\n\nimport './index.less';\n\nexport const useVisible = (params: {\n  line: WorkflowLineEntity;\n  selected?: boolean;\n  hovered?: boolean;\n}): boolean => {\n  const playground = usePlayground();\n  const { line, selected = false, hovered } = params;\n  if (line.disposed) {\n    // 在 dispose 后，再去获取 line.to | line.from 会导致错误创建端口\n    return false;\n  }\n  if (playground.config.readonly) {\n    return false;\n  }\n  if (!selected && !hovered) {\n    return false;\n  }\n  return true;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/node-menu/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC, useCallback, useState, type MouseEvent } from 'react';\n\nimport {\n  delay,\n  useClientContext,\n  usePlaygroundTools,\n  useService,\n  WorkflowDragService,\n  WorkflowNodeEntity,\n  WorkflowSelectService,\n} from '@flowgram.ai/free-layout-editor';\nimport { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';\nimport { IconButton, Dropdown } from '@douyinfe/semi-ui';\nimport { IconMore } from '@douyinfe/semi-icons';\n\nimport { FlowNodeRegistry } from '../../typings';\nimport { PasteShortcut } from '../../shortcuts/paste';\nimport { CopyShortcut } from '../../shortcuts/copy';\n\ninterface NodeMenuProps {\n  node: WorkflowNodeEntity;\n  updateTitleEdit?: (setEditing: boolean) => void;\n  deleteNode: () => void;\n}\n\nexport const NodeMenu: FC<NodeMenuProps> = ({ node, deleteNode, updateTitleEdit }) => {\n  const [visible, setVisible] = useState(true);\n  const clientContext = useClientContext();\n  const registry = node.getNodeRegistry<FlowNodeRegistry>();\n  const nodeIntoContainerService = useService(NodeIntoContainerService);\n  const selectService = useService(WorkflowSelectService);\n  const dragService = useService(WorkflowDragService);\n  const canMoveOut = nodeIntoContainerService.canMoveOutContainer(node);\n  const tools = usePlaygroundTools();\n\n  const rerenderMenu = useCallback(() => {\n    // force destroy component - 强制销毁组件触发重新渲染\n    setVisible(false);\n    requestAnimationFrame(() => {\n      setVisible(true);\n    });\n  }, []);\n\n  const handleMoveOut = useCallback(\n    async (e: MouseEvent) => {\n      e.stopPropagation();\n      const sourceParent = node.parent;\n      // move out of container - 移出容器\n      nodeIntoContainerService.moveOutContainer({ node });\n      await delay(16);\n      // clear invalid lines - 清除非法线条\n      await nodeIntoContainerService.clearInvalidLines({\n        dragNode: node,\n        sourceParent,\n      });\n      rerenderMenu();\n      // select node - 选中节点\n      selectService.selectNode(node);\n      // start drag node - 开始拖拽\n      dragService.startDragSelectedNodes(e);\n    },\n    [nodeIntoContainerService, node, rerenderMenu]\n  );\n\n  const handleCopy = useCallback(\n    (e: React.MouseEvent) => {\n      const copyShortcut = new CopyShortcut(clientContext);\n      const pasteShortcut = new PasteShortcut(clientContext);\n      const data = copyShortcut.toClipboardData([node]);\n      pasteShortcut.apply(data);\n      e.stopPropagation(); // Disable clicking prevents the sidebar from opening\n    },\n    [clientContext, node]\n  );\n\n  const handleDelete = useCallback(\n    (e: React.MouseEvent) => {\n      deleteNode();\n      e.stopPropagation(); // Disable clicking prevents the sidebar from opening\n    },\n    [clientContext, node]\n  );\n  const handleEditTitle = useCallback(\n    (e: React.MouseEvent) => {\n      updateTitleEdit?.(true);\n      e.stopPropagation(); // Disable clicking prevents the sidebar from opening\n    },\n    [updateTitleEdit]\n  );\n\n  const handleAutoLayout = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation(); // Disable clicking prevents the sidebar from opening\n      tools.autoLayout({\n        containerNode: node,\n        enableAnimation: true,\n        animationDuration: 1000,\n        disableFitView: true,\n      });\n    },\n    [tools]\n  );\n\n  if (!visible) {\n    return <></>;\n  }\n\n  return (\n    <Dropdown\n      trigger=\"hover\"\n      position=\"bottomRight\"\n      render={\n        <Dropdown.Menu>\n          <Dropdown.Item onClick={handleEditTitle}>Edit Title</Dropdown.Item>\n          {canMoveOut && <Dropdown.Item onClick={handleMoveOut}>Move out</Dropdown.Item>}\n          <Dropdown.Item onClick={handleCopy} disabled={registry.meta!.copyDisable === true}>\n            Create Copy\n          </Dropdown.Item>\n          {registry.meta.isContainer && (\n            <Dropdown.Item onClick={handleAutoLayout}>Auto Layout</Dropdown.Item>\n          )}\n          <Dropdown.Item\n            onClick={handleDelete}\n            disabled={!!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable)}\n          >\n            Delete\n          </Dropdown.Item>\n        </Dropdown.Menu>\n      }\n    >\n      <IconButton\n        color=\"secondary\"\n        size=\"small\"\n        theme=\"borderless\"\n        icon={<IconMore />}\n        onClick={(e) => e.stopPropagation()}\n      />\n    </Dropdown>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/node-panel/index.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.node-placeholder {\n  width: 360px;\n\n  background-color: rgba(252, 252, 255, 1);\n  border: 1px solid rgba(68, 83, 130, 0.25);\n  border-radius: 8px;\n  box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 2%), 0 2px 6px 0 rgba(0, 0, 0, 4%);\n}\n\n\n.node-placeholder-skeleton {\n  width: 100%;\n  padding: 12px;\n  background-color: rgba(252, 252, 255, 1);\n  border-radius: 8px;\n\n\n  .semi-skeleton-avatar {\n    background-color: rgba(68, 83, 130, 0.25);\n  }\n\n  .semi-skeleton-title {\n    height: 16px;\n    background-color: rgba(82, 100, 154, 0.13);\n    border-radius: 4px;\n  }\n}\n\n\n.node-placeholder-hd {\n  display: flex;\n  align-items: center;\n  margin-bottom: 12px;\n}\n\n.node-placeholder-avatar {\n  width: 24px;\n  height: 24px;\n  margin-right: 8px;\n  border-radius: 6px;\n}\n\n.node-placeholder-content {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  gap: 3px;\n}\n\n.node-placeholder-footer {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  gap: 2.5px;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/node-panel/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useRef } from 'react';\n\nimport { NodePanelRenderProps as NodePanelRenderPropsDefault } from '@flowgram.ai/free-node-panel-plugin';\nimport { WorkflowPortEntity } from '@flowgram.ai/free-layout-editor';\nimport { Popover } from '@douyinfe/semi-ui';\n\nimport { NodePlaceholder } from './node-placeholder';\nimport { NodeList } from './node-list';\nimport './index.less';\n\ninterface NodePanelRenderProps extends NodePanelRenderPropsDefault {\n  panelProps?: {\n    fromPort?: WorkflowPortEntity; // 从哪个端口添加 From which port to add\n    enableNodePlaceholder?: boolean;\n  };\n}\nexport const NodePanel: React.FC<NodePanelRenderProps> = (props) => {\n  const { onSelect, position, onClose, containerNode, panelProps = {} } = props;\n  const { enableNodePlaceholder, fromPort } = panelProps;\n  const ref = useRef<HTMLDivElement>(null);\n\n  return (\n    <Popover\n      trigger=\"click\"\n      visible={true}\n      onVisibleChange={(v) => (v ? null : onClose())}\n      content={<NodeList onSelect={onSelect} containerNode={containerNode} fromPort={fromPort} />}\n      getPopupContainer={containerNode ? () => ref.current || document.body : undefined}\n      placement=\"right\"\n      popupAlign={{ offset: [30, 0] }}\n      overlayStyle={{\n        padding: 0,\n      }}\n    >\n      <div\n        ref={ref}\n        style={\n          enableNodePlaceholder\n            ? {\n                position: 'absolute',\n                top: position.y - 61.5,\n                left: position.x,\n                width: 360,\n                height: 100,\n              }\n            : {\n                position: 'absolute',\n                top: position.y,\n                left: position.x,\n                width: 0,\n                height: 0,\n              }\n        }\n      >\n        {enableNodePlaceholder && <NodePlaceholder />}\n      </div>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/node-panel/node-list.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React, { FC } from 'react';\n\nimport styled from 'styled-components';\nimport { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';\nimport {\n  useClientContext,\n  WorkflowNodeEntity,\n  WorkflowPortEntity,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { canContainNode } from '../../utils';\nimport { FlowNodeRegistry } from '../../typings';\nimport { nodeRegistries } from '../../nodes';\n\nconst NodeWrap = styled.div`\n  width: 100%;\n  height: 32px;\n  border-radius: 5px;\n  display: flex;\n  align-items: center;\n  cursor: pointer;\n  font-size: 19px;\n  padding: 0 15px;\n  &:hover {\n    background-color: hsl(252deg 62% 55% / 9%);\n    color: hsl(252 62% 54.9%);\n  }\n`;\n\nconst NodeLabel = styled.div`\n  font-size: 12px;\n  margin-left: 10px;\n`;\n\ninterface NodeProps {\n  label: string;\n  icon: JSX.Element;\n  onClick: React.MouseEventHandler<HTMLDivElement>;\n  disabled: boolean;\n}\n\nfunction Node(props: NodeProps) {\n  return (\n    <NodeWrap\n      data-testid={`demo-free-node-list-${props.label}`}\n      onClick={props.disabled ? undefined : props.onClick}\n      style={props.disabled ? { opacity: 0.3 } : {}}\n    >\n      <div style={{ fontSize: 14 }}>{props.icon}</div>\n      <NodeLabel>{props.label}</NodeLabel>\n    </NodeWrap>\n  );\n}\n\nconst NodesWrap = styled.div`\n  max-height: 500px;\n  overflow: auto;\n  &::-webkit-scrollbar {\n    display: none;\n  }\n`;\n\ninterface NodeListProps {\n  onSelect: NodePanelRenderProps['onSelect'];\n  fromPort?: WorkflowPortEntity; // 从哪个端口添加 From which port to add\n  containerNode?: WorkflowNodeEntity;\n}\n\nexport const NodeList: FC<NodeListProps> = (props) => {\n  const { onSelect, containerNode, fromPort } = props;\n  const context = useClientContext();\n  const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {\n    const json = registry.onAdd?.(context);\n    onSelect({\n      nodeType: registry.type as string,\n      selectEvent: e,\n      nodeJSON: json,\n    });\n  };\n  console.log('>>> fromNode', fromPort?.node);\n  return (\n    <NodesWrap style={{ width: 80 * 2 + 20 }}>\n      {nodeRegistries\n        .filter((register) => register.meta.nodePanelVisible !== false)\n        .filter((register) => {\n          if (register.meta.onlyInContainer) {\n            return register.meta.onlyInContainer === containerNode?.flowNodeType;\n          }\n          /**\n           * 循环节点无法嵌套循环节点\n           * Loop node cannot nest loop node\n           */\n          if (containerNode && !canContainNode(register.type, containerNode.flowNodeType)) {\n            return false;\n          }\n          return true;\n        })\n        .map((registry) => (\n          <Node\n            key={registry.type}\n            disabled={!(registry.canAdd?.(context) ?? true)}\n            icon={\n              <img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />\n            }\n            label={registry.type as string}\n            onClick={(e) => handleClick(e, registry)}\n          />\n        ))}\n    </NodesWrap>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/node-panel/node-placeholder.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Skeleton } from '@douyinfe/semi-ui';\n\nexport const NodePlaceholder = () => (\n  <div className=\"node-placeholder\" data-testid=\"workflow.detail.node-panel.placeholder\">\n    <Skeleton\n      className=\"node-placeholder-skeleton\"\n      loading={true}\n      active={true}\n      placeholder={\n        <div className=\"\">\n          <div className=\"node-placeholder-hd\">\n            <Skeleton.Avatar shape=\"square\" className=\"node-placeholder-avatar\" />\n            <Skeleton.Title style={{ width: 141 }} />\n          </div>\n          <div className=\"node-placeholder-content\">\n            <div className=\"node-placeholder-footer\">\n              <Skeleton.Title style={{ width: 85 }} />\n              <Skeleton.Title style={{ width: 241 }} />\n            </div>\n            <Skeleton.Title style={{ width: 220 }} />\n          </div>\n        </div>\n      }\n    />\n  </div>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/problem-panel/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { ProblemButton } from './problem-panel';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/problem-panel/problem-panel.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useService, WorkflowSelectService } from '@flowgram.ai/free-layout-editor';\nimport { IconButton, Spin, Typography, Avatar, Tooltip } from '@douyinfe/semi-ui';\nimport { IconUploadError, IconClose } from '@douyinfe/semi-icons';\n\nimport { useProblemPanel, useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';\nimport { useWatchValidate } from './use-watch-validate';\n\nexport const ProblemPanel = () => {\n  const { results, loading } = useWatchValidate();\n\n  const selectService = useService(WorkflowSelectService);\n\n  const { close: closePanel } = useProblemPanel();\n  const { open: openNodeFormPanel } = useNodeFormPanel();\n\n  return (\n    <div\n      style={{\n        width: '100%',\n        height: '100%',\n        borderRadius: '8px',\n        background: 'rgb(251, 251, 251)',\n        border: '1px solid rgba(82,100,154, 0.13)',\n      }}\n    >\n      <div\n        style={{\n          display: 'flex',\n          height: '50px',\n          alignItems: 'center',\n          justifyContent: 'space-between',\n          padding: '0 12px',\n        }}\n      >\n        <div style={{ display: 'flex', alignItems: 'center', columnGap: '4px', height: '100%' }}>\n          <Typography.Text strong>Problem</Typography.Text>\n          {loading && <Spin size=\"small\" style={{ lineHeight: '0' }} />}\n        </div>\n        <IconButton\n          type=\"tertiary\"\n          theme=\"borderless\"\n          icon={<IconClose />}\n          onClick={() => closePanel()}\n        />\n      </div>\n      <div style={{ padding: '12px', display: 'flex', flexDirection: 'column', rowGap: '4px' }}>\n        {results.map((i) => (\n          <div\n            key={i.node.id}\n            style={{\n              display: 'flex',\n              alignItems: 'center',\n              border: '1px solid #999',\n              borderRadius: '4px',\n              padding: '0 4px',\n              cursor: 'pointer',\n            }}\n            onClick={() => {\n              selectService.selectNodeAndScrollToView(i.node);\n              openNodeFormPanel({ nodeId: i.node.id });\n            }}\n          >\n            <Avatar\n              style={{ flexShrink: '0' }}\n              src={i.node.getNodeRegistry().info.icon}\n              size=\"24px\"\n              shape=\"square\"\n            />\n            <div style={{ marginLeft: '8px' }}>\n              <Typography.Text>{i.node.form?.values.title}</Typography.Text>\n              <br />\n              <Typography.Text type=\"danger\">\n                {i.feedbacks.map((i) => i.feedbackText).join(', ')}\n              </Typography.Text>\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n\nexport const ProblemButton = () => {\n  const { open } = useProblemPanel();\n  return (\n    <Tooltip content=\"Problem\">\n      <IconButton\n        type=\"tertiary\"\n        theme=\"borderless\"\n        icon={<IconUploadError />}\n        onClick={() => open()}\n      />\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/problem-panel/use-watch-validate.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback, useEffect, useState } from 'react';\n\nimport { debounce } from 'lodash-es';\nimport { useService, WorkflowDocument } from '@flowgram.ai/free-layout-editor';\n\nimport { ValidateService, type ValidateResult } from '../../services/validate-service';\n\nconst DEBOUNCE_TIME = 1000;\n\nexport const useWatchValidate = () => {\n  const [results, setResults] = useState<ValidateResult[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  const validateService = useService(ValidateService);\n  const workflowDocument = useService(WorkflowDocument);\n\n  const debounceValidate = useCallback(\n    debounce(async () => {\n      const res = await validateService.validateNodes();\n      validateService.validateLines();\n      setResults(res);\n      setLoading(false);\n    }, DEBOUNCE_TIME),\n    [validateService]\n  );\n\n  const validate = () => {\n    setLoading(true);\n    debounceValidate();\n  };\n\n  useEffect(() => {\n    validate();\n    const disposable = workflowDocument.onContentChange(() => {\n      validate();\n    });\n    return () => disposable.dispose();\n  }, []);\n\n  return { results, loading };\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/selector-box-popover/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FunctionComponent } from 'react';\n\nimport { SelectorBoxPopoverProps } from '@flowgram.ai/free-layout-editor';\nimport { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';\nimport { Button, ButtonGroup, Tooltip } from '@douyinfe/semi-ui';\nimport { IconCopy, IconDeleteStroked, IconExpand, IconShrink } from '@douyinfe/semi-icons';\n\nimport { IconGroup } from '../group';\nimport { FlowCommandId } from '../../shortcuts/constants';\n\nconst BUTTON_HEIGHT = 24;\n\nexport const SelectorBoxPopover: FunctionComponent<SelectorBoxPopoverProps> = ({\n  bounds,\n  children,\n  flowSelectConfig,\n  commandRegistry,\n}) => (\n  <>\n    <div\n      style={{\n        position: 'absolute',\n        left: bounds.right,\n        top: bounds.top,\n        transform: 'translate(-100%, -100%)',\n      }}\n      onMouseDown={(e) => {\n        e.stopPropagation();\n      }}\n    >\n      <ButtonGroup\n        size=\"small\"\n        style={{ display: 'flex', flexWrap: 'nowrap', height: BUTTON_HEIGHT }}\n      >\n        <Tooltip content={'Collapse'}>\n          <Button\n            icon={<IconShrink />}\n            style={{ height: BUTTON_HEIGHT }}\n            type=\"primary\"\n            theme=\"solid\"\n            onMouseDown={(e) => {\n              commandRegistry.executeCommand(FlowCommandId.COLLAPSE);\n            }}\n          />\n        </Tooltip>\n\n        <Tooltip content={'Expand'}>\n          <Button\n            icon={<IconExpand />}\n            style={{ height: BUTTON_HEIGHT }}\n            type=\"primary\"\n            theme=\"solid\"\n            onMouseDown={(e) => {\n              commandRegistry.executeCommand(FlowCommandId.EXPAND);\n            }}\n          />\n        </Tooltip>\n\n        <Tooltip content={'Create Group'}>\n          <Button\n            icon={<IconGroup size={14} />}\n            style={{ height: BUTTON_HEIGHT }}\n            type=\"primary\"\n            theme=\"solid\"\n            onClick={() => {\n              commandRegistry.executeCommand(WorkflowGroupCommand.Group);\n            }}\n          />\n        </Tooltip>\n\n        <Tooltip content={'Copy'}>\n          <Button\n            icon={<IconCopy />}\n            style={{ height: BUTTON_HEIGHT }}\n            type=\"primary\"\n            theme=\"solid\"\n            onClick={() => {\n              commandRegistry.executeCommand(FlowCommandId.COPY);\n            }}\n          />\n        </Tooltip>\n\n        <Tooltip content={'Delete'}>\n          <Button\n            type=\"primary\"\n            theme=\"solid\"\n            icon={<IconDeleteStroked />}\n            style={{ height: BUTTON_HEIGHT }}\n            onClick={() => {\n              commandRegistry.executeCommand(FlowCommandId.DELETE);\n            }}\n          />\n        </Tooltip>\n      </ButtonGroup>\n    </div>\n    <div>{children}</div>\n  </>\n);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/sidebar/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/sidebar/node-form-panel.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback, useEffect, startTransition } from 'react';\n\nimport {\n  PlaygroundEntityContext,\n  useRefresh,\n  useClientContext,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowNodeMeta } from '../../typings';\nimport { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';\nimport { IsSidebarContext } from '../../context';\nimport { SidebarNodeRenderer } from './sidebar-node-renderer';\n\nexport interface NodeFormPanelProps {\n  nodeId: string;\n}\n\nexport const NodeFormPanel: React.FC<NodeFormPanelProps> = ({ nodeId }) => {\n  const { selection, playground, document } = useClientContext();\n  const refresh = useRefresh();\n  const { close: closePanel } = useNodeFormPanel();\n  const handleClose = useCallback(() => {\n    // Sidebar delayed closing\n    startTransition(() => {\n      closePanel();\n    });\n  }, []);\n  const node = document.getNode(nodeId);\n  const sidebarDisabled = node?.getNodeMeta<FlowNodeMeta>()?.sidebarDisabled === true;\n  /**\n   * Listen readonly\n   */\n  useEffect(() => {\n    const disposable = playground.config.onReadonlyOrDisabledChange(() => {\n      handleClose();\n      refresh();\n    });\n    return () => disposable.dispose();\n  }, [playground]);\n  /**\n   * Listen selection\n   */\n  useEffect(() => {\n    const toDispose = selection.onSelectionChanged(() => {\n      /**\n       * 如果没有选中任何节点，则自动关闭侧边栏\n       * If no node is selected, the sidebar is automatically closed\n       */\n      if (selection.selection.length === 0) {\n        handleClose();\n      } else if (selection.selection.length === 1 && selection.selection[0] !== node) {\n        handleClose();\n      }\n    });\n    return () => toDispose.dispose();\n  }, [selection, node, handleClose]);\n  /**\n   * Close when node disposed\n   */\n  useEffect(() => {\n    if (node) {\n      const toDispose = node.onDispose(() => {\n        closePanel();\n      });\n      return () => toDispose.dispose();\n    }\n    return () => {};\n  }, [node, sidebarDisabled, handleClose]);\n  /**\n   * Cloze when sidebar disabled\n   */\n  useEffect(() => {\n    if (!node || sidebarDisabled || playground.config.readonly) {\n      handleClose();\n    }\n  }, [node, sidebarDisabled, playground.config.readonly]);\n\n  if (!node || sidebarDisabled || playground.config.readonly) {\n    return null;\n  }\n\n  return (\n    <IsSidebarContext.Provider value={true}>\n      <PlaygroundEntityContext.Provider key={node.id} value={node}>\n        <SidebarNodeRenderer node={node} />\n      </PlaygroundEntityContext.Provider>\n    </IsSidebarContext.Provider>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/sidebar/sidebar-node-renderer.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useNodeRender, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';\n\nimport { NodeRenderContext } from '../../context';\n\nexport function SidebarNodeRenderer(props: { node: FlowNodeEntity }) {\n  const { node } = props;\n  const nodeRender = useNodeRender(node);\n\n  return (\n    <NodeRenderContext.Provider value={nodeRender}>\n      <div\n        style={{\n          background: 'rgb(251, 251, 251)',\n          height: '100%',\n          width: '100%',\n          borderRadius: 8,\n          border: '1px solid rgba(82,100,154, 0.13)',\n          boxSizing: 'border-box',\n        }}\n      >\n        {nodeRender.form?.render()}\n      </div>\n    </NodeRenderContext.Provider>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/hooks/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { useFields } from './use-fields';\nexport { useFormMeta } from './use-form-meta';\nexport { useSyncDefault } from './use-sync-default';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/hooks/use-fields.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { TestRunFormField, TestRunFormMeta } from '../testrun-form/type';\n\nexport const useFields = (params: {\n  formMeta: TestRunFormMeta;\n  values: Record<string, unknown>;\n  setValues: (values: Record<string, unknown>) => void;\n}): TestRunFormField[] => {\n  const { formMeta, values, setValues } = params;\n\n  // Convert each meta item to a form field with value and onChange handler\n  const fields: TestRunFormField[] = formMeta.map((meta) => {\n    const currentValue = values[meta.name] ?? meta.defaultValue;\n\n    const handleChange = (newValue: unknown): void => {\n      setValues({\n        ...values,\n        [meta.name]: newValue,\n      });\n    };\n\n    return {\n      ...meta,\n      value: currentValue,\n      onChange: handleChange,\n    };\n  });\n\n  return fields;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/hooks/use-form-meta.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useMemo } from 'react';\n\nimport { useService, WorkflowDocument } from '@flowgram.ai/free-layout-editor';\nimport { IJsonSchema, JsonSchemaBasicType } from '@flowgram.ai/form-materials';\n\nimport { TestRunFormMetaItem } from '../testrun-form/type';\nimport { WorkflowNodeType } from '../../../nodes';\n\nconst DEFAULT_DECLARE: IJsonSchema = {\n  type: 'object',\n  properties: {},\n};\n\nexport const useFormMeta = (): TestRunFormMetaItem[] => {\n  const document = useService(WorkflowDocument);\n\n  const startNode = useMemo(\n    () => document.root.blocks.find((node) => node.flowNodeType === WorkflowNodeType.Start),\n    [document]\n  );\n\n  const workflowInputs = startNode?.form?.getValueIn<IJsonSchema>('outputs') || DEFAULT_DECLARE;\n\n  // Add state for form values\n  const formMeta = useMemo(() => {\n    const formFields: TestRunFormMetaItem[] = [];\n    Object.entries(workflowInputs.properties!).forEach(([name, property]) => {\n      formFields.push({\n        type: property.type as JsonSchemaBasicType,\n        name,\n        defaultValue: property.default,\n        required: workflowInputs.required?.includes(name) ?? false,\n        itemsType: property.items?.type as JsonSchemaBasicType,\n      });\n    });\n    return formFields;\n  }, [workflowInputs]);\n\n  return formMeta;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/hooks/use-sync-default.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useEffect } from 'react';\n\nimport { TestRunFormMeta, TestRunFormMetaItem } from '../testrun-form/type';\n\nconst getDefaultValue = (meta: TestRunFormMetaItem) => {\n  if (['object', 'array', 'map'].includes(meta.type) && typeof meta.defaultValue === 'string') {\n    return JSON.parse(meta.defaultValue);\n  }\n  return meta.defaultValue;\n};\n\nexport const useSyncDefault = (params: {\n  formMeta: TestRunFormMeta;\n  values: Record<string, unknown>;\n  setValues: (values: Record<string, unknown>) => void;\n}) => {\n  const { formMeta, values, setValues } = params;\n\n  useEffect(() => {\n    let formMetaValues: Record<string, unknown> = {};\n    formMeta.map((meta) => {\n      // If there is no value in values but there is a default value, trigger onChange once\n      if (!(meta.name in values) && meta.defaultValue !== undefined) {\n        formMetaValues = { ...formMetaValues, [meta.name]: getDefaultValue(meta) };\n      }\n    });\n    setValues({\n      ...values,\n      ...formMetaValues,\n    });\n  }, [formMeta]);\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/json-value-editor/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\n\nimport { TextArea } from '@douyinfe/semi-ui';\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n  typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst formatJsonValue = (value: unknown, kind: 'object' | 'array') => {\n  const fallbackValue = kind === 'array' ? [] : {};\n  return JSON.stringify(value ?? fallbackValue, null, 2);\n};\n\nexport function JsonValueEditor({\n  value,\n  kind,\n  onChange,\n}: {\n  value: unknown;\n  kind: 'object' | 'array';\n  onChange: (value: unknown) => void;\n}) {\n  const defaultJsonText = useMemo(() => formatJsonValue(value, kind), [kind, value]);\n\n  const [jsonText, setJsonText] = useState(defaultJsonText);\n  const [parseError, setParseError] = useState<string>();\n\n  const effectVersion = useRef(0);\n  const changeVersion = useRef(0);\n\n  const handleJsonTextChange = (text: string) => {\n    setJsonText(text);\n    try {\n      const jsonValue = JSON.parse(text);\n      const typeMatches = kind === 'array' ? Array.isArray(jsonValue) : isRecord(jsonValue);\n      if (!typeMatches) {\n        setParseError(`JSON input must be a ${kind}.`);\n        return;\n      }\n      onChange(jsonValue);\n      changeVersion.current++;\n      setParseError(undefined);\n    } catch (error) {\n      setParseError('JSON parse failed. Please check syntax.');\n    }\n  };\n\n  useEffect(() => {\n    // more effect compared with change\n    effectVersion.current = effectVersion.current + 1;\n    if (effectVersion.current === changeVersion.current) {\n      return;\n    }\n    effectVersion.current = changeVersion.current;\n\n    setJsonText(formatJsonValue(value, kind));\n    setParseError(undefined);\n  }, [kind, value]);\n\n  return (\n    <>\n      <TextArea\n        value={jsonText}\n        onChange={handleJsonTextChange}\n        autosize={{\n          minRows: 6,\n          maxRows: 12,\n        }}\n      />\n      {parseError ? <div style={{ color: '#c93c3c', fontSize: 12, marginTop: 8 }}>{parseError}</div> : null}\n    </>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/group/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.node-status-group {\n  padding: 6px;\n  font-weight: 500;\n  color: #333;\n  font-size: 15px;\n  display: flex;\n  align-items: center;\n\n  &-icon {\n    transform: rotate(-90deg);\n    transition: transform 0.2s;\n    cursor: pointer;\n    margin-right: 4px;\n\n    &-expanded {\n      transform: rotate(0deg);\n    }\n  }\n\n  &-tag {\n    margin-left: 4px;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/group/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC, useState } from 'react';\n\nimport classNames from 'classnames';\nimport { Tag } from '@douyinfe/semi-ui';\nimport { IconSmallTriangleDown } from '@douyinfe/semi-icons';\n\nimport { DataStructureViewer } from '../viewer';\n\nimport styles from './index.module.less';\n\ninterface NodeStatusGroupProps {\n  title: string;\n  data: unknown;\n  optional?: boolean;\n  disableCollapse?: boolean;\n}\n\nconst isObjectHasContent = (obj: any = {}): boolean => obj && Object.keys(obj).length > 0;\n\nexport const NodeStatusGroup: FC<NodeStatusGroupProps> = ({\n  title,\n  data,\n  optional = false,\n  disableCollapse = false,\n}) => {\n  const hasContent = isObjectHasContent(data);\n  const [isExpanded, setIsExpanded] = useState(true);\n\n  if (optional && !hasContent) {\n    return null;\n  }\n\n  return (\n    <>\n      <div\n        className={styles['node-status-group']}\n        onClick={() => hasContent && !disableCollapse && setIsExpanded(!isExpanded)}\n      >\n        {!disableCollapse && (\n          <IconSmallTriangleDown\n            className={classNames(styles['node-status-group-icon'], {\n              [styles['node-status-group-icon-expanded']]: isExpanded && hasContent,\n            })}\n          />\n        )}\n        <span>{title}:</span>\n        {!hasContent && (\n          <Tag size=\"small\" className={styles['node-status-group-tag']}>\n            null\n          </Tag>\n        )}\n      </div>\n      {hasContent && isExpanded ? <DataStructureViewer data={data} /> : null}\n    </>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/header/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.node-status-header {\n  border: 1px solid rgba(68, 83, 130, 0.25);\n  border-radius: 8px;\n  background-color: #fff;\n  position: absolute;\n  top: calc(100% + 8px);\n  left: 0;\n  width: 100%;\n  min-width: 360px;\n\n  &-content {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 6px;\n\n    &-opened {\n      padding-bottom: 0;\n    }\n\n    .status-title {\n      height: 24px;\n      display: flex;\n      align-items: center;\n      column-gap: 8px;\n      min-width: 0;\n\n      :global(.coz-tag) {\n        height: 20px;\n      }\n      :global(.semi-tag-content) {\n        font-weight: 500;\n        line-height: 16px;\n        font-size: 12px;\n      }\n      :global(.semi-tag-suffix-icon > div) {\n        font-size: 14px;\n      }\n    }\n\n    .status-btns {\n      height: 24px;\n      display: flex;\n      align-items: center;\n      column-gap: 4px;\n\n      .is-show-detail {\n        transform: rotate(180deg);\n      }\n    }\n  }\n}"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/header/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React, { useState } from 'react';\n\nimport classNames from 'classnames';\nimport { IconChevronDown } from '@douyinfe/semi-icons';\n\nimport { useNodeRenderContext } from '../../../../hooks';\n\nimport styles from './index.module.less';\n\ninterface NodeStatusBarProps {\n  header?: React.ReactNode;\n  defaultShowDetail?: boolean;\n  extraBtns?: React.ReactNode[];\n}\n\nexport const NodeStatusHeader: React.FC<React.PropsWithChildren<NodeStatusBarProps>> = ({\n  header,\n  defaultShowDetail,\n  children,\n  extraBtns = [],\n}) => {\n  const [showDetail, setShowDetail] = useState(defaultShowDetail);\n  const { selectNode } = useNodeRenderContext();\n\n  const handleToggleShowDetail = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    selectNode(e);\n    setShowDetail(!showDetail);\n  };\n\n  return (\n    <div\n      className={styles['node-status-header']}\n      // 必须要禁止 down 冒泡，防止判定圈选和 node hover（不支持多边形）\n      onMouseDown={(e) => e.stopPropagation()}\n    >\n      <div\n        className={classNames(\n          styles['node-status-header-content'],\n          showDetail && styles['node-status-header-content-opened']\n        )}\n        // 必须要禁止 down 冒泡，防止判定圈选和 node hover（不支持多边形）\n        onMouseDown={(e) => e.stopPropagation()}\n        // 其他事件统一走点击事件，且也需要阻止冒泡\n        onClick={handleToggleShowDetail}\n      >\n        <div className={styles['status-title']}>\n          {header}\n          {extraBtns.length > 0 ? extraBtns : null}\n        </div>\n        <div className={styles['status-btns']}>\n          <IconChevronDown\n            className={classNames({\n              [styles['is-show-detail']]: showDetail,\n            })}\n          />\n        </div>\n      </div>\n      {showDetail ? children : null}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useEffect, useState } from 'react';\n\nimport { NodeReport } from '@flowgram.ai/runtime-interface';\nimport { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';\n\nimport { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';\nimport { NodeStatusRender } from './render';\n\nconst useNodeReport = () => {\n  const node = useCurrentEntity();\n  const [report, setReport] = useState<NodeReport>();\n\n  const runtimeService = useService(WorkflowRuntimeService);\n\n  useEffect(() => {\n    const reportDisposer = runtimeService.onNodeReportChange((nodeReport) => {\n      if (nodeReport.id !== node.id) {\n        return;\n      }\n      setReport((prev) =>({\n        ...prev,\n        ...nodeReport,\n      }));\n    });\n    const resetDisposer = runtimeService.onReset(() => {\n      setReport(undefined);\n    });\n    return () => {\n      reportDisposer.dispose();\n      resetDisposer.dispose();\n    };\n  }, []);\n\n  return report;\n};\n\nexport const NodeStatusBar = () => {\n  const report = useNodeReport();\n\n  if (!report) {\n    return null;\n  }\n\n  return <NodeStatusRender report={report} />;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/render/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.nodeStatus {\n  &Succeed {\n    background-color: rgba(105, 209, 140, 0.3);\n    color: #00b42a;\n  }\n\n  &Processing {\n    background-color: rgba(153, 187, 255, 0.3);\n    color: #4d53e8;\n  }\n\n  &Failed {\n    background-color: rgba(255, 163, 171, 0.3);\n    color: #f53f3f;\n  }\n}\n\n.icon {\n  &.processing {\n    color: rgba(77, 83, 232, 1);\n  }\n}\n\n.round {\n  border-radius: 50%;\n}\n\n.desc {\n  margin: 0;\n}\n\n.count {\n  font-weight: 500;\n  color: #333;\n  font-size: 15px;\n  margin-left: 12px;\n}\n\n.snapshotNavigation {\n  margin: 12px;\n  display: flex;\n  gap: 8px;\n  align-items: center;\n  flex-wrap: wrap;\n}\n\n.snapshotButton {\n  min-width: 32px;\n  height: 32px;\n  padding: 0;\n  border-radius: 4px;\n  font-size: 12px;\n  border: 1px solid;\n  font-weight: 500;\n\n  &.active {\n    border-color: #4d53e8;\n    font-weight: 800;\n  }\n\n  &.inactive {\n    border-color: rgba(29, 28, 35, 0.08);\n  }\n}\n\n.snapshotSelect {\n  width: 90px;\n  height: 32px;\n  border: 1px solid;\n\n  &.active {\n    border-color: #4d53e8;\n  }\n\n  &.inactive {\n    border-color: rgba(29, 28, 35, 0.08);\n  }\n}\n\n.container {\n  width: 100%;\n  height: 100%;\n  padding: 4px 2px 4px 2px;\n}\n\n.error {\n  padding: 12px;\n  color: red;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/render/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC, useMemo, useState } from 'react';\n\nimport classnames from 'classnames';\nimport { NodeReport, WorkflowStatus } from '@flowgram.ai/runtime-interface';\nimport { Tag, Button, Select } from '@douyinfe/semi-ui';\nimport { IconSpin } from '@douyinfe/semi-icons';\n\nimport { NodeStatusHeader } from '../header';\nimport { NodeStatusGroup } from '../group';\nimport { IconWarningFill } from '../../../../assets/icon-warning';\nimport { IconSuccessFill } from '../../../../assets/icon-success';\n\nimport styles from './index.module.less';\n\ninterface NodeStatusRenderProps {\n  report: NodeReport;\n}\n\nconst msToSeconds = (ms: number): string => (ms / 1000).toFixed(2) + 's';\nconst displayCount = 6;\n\nexport const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {\n  const { status: nodeStatus } = report;\n  const [currentSnapshotIndex, setCurrentSnapshotIndex] = useState(0);\n\n  const snapshots = report.snapshots || [];\n  const currentSnapshot = snapshots[currentSnapshotIndex] || snapshots[0];\n\n  // 节点 5 个状态\n  const isNodePending = nodeStatus === WorkflowStatus.Pending;\n  const isNodeProcessing = nodeStatus === WorkflowStatus.Processing;\n  const isNodeFailed = nodeStatus === WorkflowStatus.Failed;\n  const isNodeSucceed = nodeStatus === WorkflowStatus.Succeeded;\n  const isNodeCancelled = nodeStatus === WorkflowStatus.Cancelled;\n\n  const tagColor = useMemo(() => {\n    if (isNodeSucceed) {\n      return styles.nodeStatusSucceed;\n    }\n    if (isNodeFailed) {\n      return styles.nodeStatusFailed;\n    }\n    if (isNodeProcessing) {\n      return styles.nodeStatusProcessing;\n    }\n  }, [isNodeSucceed, isNodeFailed, isNodeProcessing]);\n\n  const renderIcon = () => {\n    if (isNodeProcessing) {\n      return <IconSpin spin className={classnames(styles.icon, styles.processing)} />;\n    }\n    if (isNodeSucceed) {\n      return <IconSuccessFill />;\n    }\n    return <IconWarningFill className={classnames(tagColor, styles.round)} />;\n  };\n  const renderDesc = () => {\n    const getDesc = () => {\n      if (isNodeProcessing) {\n        return 'Running';\n      } else if (isNodePending) {\n        return 'Run terminated';\n      } else if (isNodeSucceed) {\n        return 'Succeed';\n      } else if (isNodeFailed) {\n        return 'Failed';\n      } else if (isNodeCancelled) {\n        return 'Cancelled';\n      }\n    };\n\n    const desc = getDesc();\n\n    return desc ? <p className={styles.desc}>{desc}</p> : null;\n  };\n  const renderCost = () => (\n    <Tag size=\"small\" className={tagColor}>\n      {msToSeconds(report.timeCost)}\n    </Tag>\n  );\n\n  const renderSnapshotNavigation = () => {\n    if (snapshots.length <= 1) {\n      return null;\n    }\n\n    const count = <p className={styles.count}>Total: {snapshots.length}</p>;\n\n    if (snapshots.length <= displayCount) {\n      return (\n        <>\n          {count}\n          <div className={styles.snapshotNavigation}>\n            {snapshots.map((_, index) => (\n              <Button\n                key={index}\n                size=\"small\"\n                type={currentSnapshotIndex === index ? 'primary' : 'tertiary'}\n                onClick={() => setCurrentSnapshotIndex(index)}\n                className={classnames(styles.snapshotButton, {\n                  [styles.active]: currentSnapshotIndex === index,\n                  [styles.inactive]: currentSnapshotIndex !== index,\n                })}\n              >\n                {index + 1}\n              </Button>\n            ))}\n          </div>\n        </>\n      );\n    }\n\n    // 超过5个时，前5个显示为按钮，剩余的放在下拉选择中\n    return (\n      <>\n        {count}\n        <div className={styles.snapshotNavigation}>\n          {snapshots.slice(0, displayCount).map((_, index) => (\n            <Button\n              key={index}\n              size=\"small\"\n              type=\"tertiary\"\n              onClick={() => setCurrentSnapshotIndex(index)}\n              className={classnames(styles.snapshotButton, {\n                [styles.active]: currentSnapshotIndex === index,\n                [styles.inactive]: currentSnapshotIndex !== index,\n              })}\n            >\n              {index + 1}\n            </Button>\n          ))}\n          <Select\n            value={currentSnapshotIndex >= displayCount ? currentSnapshotIndex : undefined}\n            onChange={(value) => setCurrentSnapshotIndex(value as number)}\n            className={classnames(styles.snapshotSelect, {\n              [styles.active]: currentSnapshotIndex >= displayCount,\n              [styles.inactive]: currentSnapshotIndex < displayCount,\n            })}\n            size=\"small\"\n            placeholder=\"Select\"\n          >\n            {snapshots.slice(displayCount).map((_, index) => {\n              const actualIndex = index + displayCount;\n              return (\n                <Select.Option key={actualIndex} value={actualIndex}>\n                  {actualIndex + 1}\n                </Select.Option>\n              );\n            })}\n          </Select>\n        </div>\n      </>\n    );\n  };\n\n  if (!report) {\n    return null;\n  }\n\n  return (\n    <NodeStatusHeader\n      header={\n        <>\n          {renderIcon()}\n          {renderDesc()}\n          {renderCost()}\n        </>\n      }\n    >\n      <div className={styles.container}>\n        {isNodeFailed && currentSnapshot?.error && (\n          <div className={styles.error}>{currentSnapshot.error}</div>\n        )}\n        {renderSnapshotNavigation()}\n        <NodeStatusGroup title=\"Inputs\" data={currentSnapshot?.inputs} />\n        <NodeStatusGroup title=\"Outputs\" data={currentSnapshot?.outputs} />\n        <NodeStatusGroup title=\"Branch\" data={currentSnapshot?.branch} optional />\n        <NodeStatusGroup title=\"Data\" data={currentSnapshot?.data} optional />\n      </div>\n    </NodeStatusHeader>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/viewer/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.dataStructureViewer {\n  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;\n  font-size: 14px;\n  line-height: 1.5;\n  color: #333;\n  background: #fafafa;\n  border-radius: 6px;\n  padding: 12px 12px 12px 0;\n  margin: 12px;\n  border: 1px solid #e1e4e8;\n  overflow: hidden;\n\n  .treeNode {\n    margin: 2px 0;\n\n    &Header {\n      display: flex;\n      align-items: flex-start;\n      gap: 4px;\n      min-height: 20px;\n      padding: 2px 0;\n      border-radius: 3px;\n      transition: background-color 0.15s ease;\n\n      &:hover {\n        background-color: rgba(0, 0, 0, 0.04);\n      }\n    }\n\n    &Children {\n      margin-left: 8px;\n      padding-left: 8px;\n      position: relative;\n\n      &::before {\n        content: '';\n        position: absolute;\n        left: 0;\n        top: 0;\n        bottom: 0;\n        width: 1px;\n        background: #e1e4e8;\n      }\n    }\n  }\n\n  .expandButton {\n    background: none;\n    border: none;\n    cursor: pointer;\n    font-size: 10px;\n    color: #666;\n    width: 16px;\n    height: 16px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    border-radius: 2px;\n    transition: all 0.15s ease;\n    padding: 0;\n    margin: 0;\n\n    &:hover {\n      background-color: rgba(0, 0, 0, 0.1);\n      color: #333;\n    }\n\n    &.expanded {\n      transform: rotate(90deg);\n    }\n\n    &.collapsed {\n      transform: rotate(0deg);\n    }\n  }\n\n  .expandPlaceholder {\n    width: 16px;\n    height: 16px;\n    display: inline-block;\n    flex-shrink: 0;\n  }\n\n  .nodeLabel {\n    color: #0969da;\n    font-weight: 500;\n    cursor: pointer;\n    user-select: auto;\n    margin-right: 4px;\n\n    &:hover {\n      text-decoration: underline;\n    }\n  }\n\n  .nodeValue {\n    margin-left: 4px;\n  }\n\n  .primitiveValue {\n    cursor: pointer;\n    user-select: all;\n    padding: 1px 3px;\n    border-radius: 3px;\n    transition: background-color 0.15s ease;\n\n    &:hover {\n      background-color: rgba(0, 0, 0, 0.05);\n    }\n\n    &Quote {\n      color: #8f8f8f;\n    }\n\n    &.string {\n      color: #032f62;\n      background-color: rgba(3, 47, 98, 0.05);\n    }\n\n    &.number {\n      color: #005cc5;\n      background-color: rgba(0, 92, 197, 0.05);\n    }\n\n    &.boolean {\n      color: #e36209;\n      background-color: rgba(227, 98, 9, 0.05);\n    }\n\n    &.null,\n    &.undefined {\n      color: #6a737d;\n      font-style: italic;\n      background-color: rgba(106, 115, 125, 0.05);\n    }\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/node-status-bar/viewer/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React, { useState } from 'react';\n\nimport classnames from 'classnames';\nimport { Toast } from '@douyinfe/semi-ui';\n\nimport styles from './index.module.less';\n\ninterface DataStructureViewerProps {\n  data: any;\n  level?: number;\n}\n\ninterface TreeNodeProps {\n  label: string;\n  value: any;\n  level: number;\n  isLast?: boolean;\n}\n\nconst TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false }) => {\n  const [isExpanded, setIsExpanded] = useState(true);\n\n  const handleCopy = (text: string) => {\n    navigator.clipboard.writeText(text);\n    Toast.success('Copied');\n  };\n\n  const isExpandable = (val: any) =>\n    val !== null &&\n    typeof val === 'object' &&\n    ((Array.isArray(val) && val.length > 0) ||\n      (!Array.isArray(val) && Object.keys(val).length > 0));\n\n  const renderPrimitiveValue = (val: any) => {\n    if (val === null)\n      return <span className={classnames(styles.primitiveValue, styles.null)}>null</span>;\n    if (val === undefined)\n      return <span className={classnames(styles.primitiveValue, styles.undefined)}>undefined</span>;\n\n    switch (typeof val) {\n      case 'string':\n        return (\n          <span>\n            <span className={styles.primitiveValueQuote}>{'\"'}</span>\n            <span\n              className={classnames(styles.primitiveValue, styles.string)}\n              onDoubleClick={() => handleCopy(val)}\n            >\n              {val}\n            </span>\n            <span className={styles.primitiveValueQuote}>{'\"'}</span>\n          </span>\n        );\n      case 'number':\n        return (\n          <span\n            className={classnames(styles.primitiveValue, styles.number)}\n            onDoubleClick={() => handleCopy(String(val))}\n          >\n            {val}\n          </span>\n        );\n      case 'boolean':\n        return (\n          <span\n            className={classnames(styles.primitiveValue, styles.boolean)}\n            onDoubleClick={() => handleCopy(val.toString())}\n          >\n            {val.toString()}\n          </span>\n        );\n      case 'object':\n        // Handle empty objects and arrays\n        if (Array.isArray(val)) {\n          return (\n            <span className={styles.primitiveValue} onDoubleClick={() => handleCopy('[]')}>\n              []\n            </span>\n          );\n        } else {\n          return (\n            <span className={styles.primitiveValue} onDoubleClick={() => handleCopy('{}')}>\n              {'{}'}\n            </span>\n          );\n        }\n      default:\n        return (\n          <span className={styles.primitiveValue} onDoubleClick={() => handleCopy(String(val))}>\n            {String(val)}\n          </span>\n        );\n    }\n  };\n\n  const renderChildren = () => {\n    if (Array.isArray(value)) {\n      return value.map((item, index) => (\n        <TreeNode\n          key={index}\n          label={`${index + 1}.`}\n          value={item}\n          level={level + 1}\n          isLast={index === value.length - 1}\n        />\n      ));\n    } else {\n      const entries = Object.entries(value);\n      return entries.map(([key, val], index) => (\n        <TreeNode\n          key={key}\n          label={`${key}:`}\n          value={val}\n          level={level + 1}\n          isLast={index === entries.length - 1}\n        />\n      ));\n    }\n  };\n\n  return (\n    <div className={styles.treeNode}>\n      <div className={styles.treeNodeHeader}>\n        {isExpandable(value) ? (\n          <button\n            className={classnames(\n              styles.expandButton,\n              isExpanded ? styles.expanded : styles.collapsed\n            )}\n            onClick={() => setIsExpanded(!isExpanded)}\n          >\n            ▶\n          </button>\n        ) : (\n          <span className={styles.expandPlaceholder}></span>\n        )}\n        <span\n          className={styles.nodeLabel}\n          onClick={() =>\n            handleCopy(\n              JSON.stringify({\n                [label]: value,\n              })\n            )\n          }\n        >\n          {label}\n        </span>\n        {!isExpandable(value) && (\n          <span className={styles.nodeValue}>{renderPrimitiveValue(value)}</span>\n        )}\n      </div>\n      {isExpandable(value) && isExpanded && (\n        <div className={styles.treeNodeChildren}>{renderChildren()}</div>\n      )}\n    </div>\n  );\n};\n\nexport const DataStructureViewer: React.FC<DataStructureViewerProps> = ({ data, level = 0 }) => {\n  if (data === null || data === undefined || typeof data !== 'object') {\n    return (\n      <div className={styles.dataStructureViewer}>\n        <TreeNode label=\"value\" value={data} level={0} />\n      </div>\n    );\n  }\n\n  const entries = Object.entries(data);\n\n  return (\n    <div className={styles.dataStructureViewer}>\n      {entries.map(([key, value], index) => (\n        <TreeNode\n          key={key}\n          label={`${key}:`}\n          value={value}\n          level={0}\n          isLast={index === entries.length - 1}\n        />\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-button/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.testrun-success-button {\n  background-color: rgba(0, 178, 60, 1) !important; // override semi style\n  border-radius: 8px;\n  color: #fff !important; // override semi style\n}\n\n.testrun-error-button {\n  background-color: rgba(255, 115, 0, 1) !important; // override semi style\n  border-radius: 8px;\n  color: #fff !important; // override semi style\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-button/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useState, useEffect, useCallback } from 'react';\n\nimport { useClientContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';\nimport { Button, Badge } from '@douyinfe/semi-ui';\nimport { IconPlay } from '@douyinfe/semi-icons';\n\nimport { useTestRunFormPanel } from '../../../plugins/panel-manager-plugin/hooks';\n\nimport styles from './index.module.less';\n\nexport function TestRunButton(props: { disabled: boolean }) {\n  const [errorCount, setErrorCount] = useState(0);\n  const clientContext = useClientContext();\n  const updateValidateData = useCallback(() => {\n    const allForms = clientContext.document.getAllNodes().map((node) => node.form);\n    const count = allForms.filter((form) => form?.state.invalid).length;\n    setErrorCount(count);\n  }, [clientContext]);\n  const { open: openPanel } = useTestRunFormPanel();\n  /**\n   * Validate all node and Save\n   */\n  const onTestRun = useCallback(async () => {\n    const allForms = clientContext.document.getAllNodes().map((node) => node.form);\n    await Promise.all(allForms.map(async (form) => form?.validate()));\n    console.log('>>>>> save data: ', clientContext.document.toJSON());\n    openPanel();\n  }, [clientContext]);\n\n  /**\n   * Listen single node validate\n   */\n  useEffect(() => {\n    const listenSingleNodeValidate = (node: FlowNodeEntity) => {\n      const { form } = node;\n      if (form) {\n        const formValidateDispose = form.onValidate(() => updateValidateData());\n        node.onDispose(() => formValidateDispose.dispose());\n      }\n    };\n    clientContext.document.getAllNodes().map((node) => listenSingleNodeValidate(node));\n    const dispose = clientContext.document.onNodeCreate(({ node }) =>\n      listenSingleNodeValidate(node)\n    );\n    return () => dispose.dispose();\n  }, [clientContext]);\n\n  const button =\n    errorCount === 0 ? (\n      <Button\n        disabled={props.disabled}\n        onClick={onTestRun}\n        icon={<IconPlay size=\"small\" />}\n        className={styles.testrunSuccessButton}\n      >\n        Test Run\n      </Button>\n    ) : (\n      <Badge count={errorCount} position=\"rightTop\" type=\"danger\">\n        <Button\n          type=\"danger\"\n          disabled={props.disabled}\n          onClick={onTestRun}\n          icon={<IconPlay size=\"small\" />}\n          className={styles.testrunErrorButton}\n        >\n            Test Run\n        </Button>\n      </Badge>\n    );\n\n  return button;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-form/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.formContainer {\n  margin: 8px 0;\n}\n\n.formTitle {\n  font-size: 20px;\n  font-weight: 600;\n  color: #1a1a1a;\n  margin-bottom: 24px;\n  text-align: center;\n}\n\n.fieldGroup {\n  margin-bottom: 8px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n}\n\n.fieldLabel {\n  display: block;\n  font-size: 14px;\n  font-weight: 500;\n  color: #333333;\n  margin-bottom: 8px;\n  line-height: 1.4;\n}\n\n.fieldInput {\n  width: 100%;\n\n  :global(.semi-input) {\n\n    &:hover {\n      border-color: #4096ff;\n    }\n\n    &:focus {\n      border-color: #4096ff;\n      box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.1);\n    }\n  }\n\n  :global(.semi-input-number) {\n    width: 100%;\n\n    &:hover {\n      border-color: #4096ff;\n    }\n\n    &:focus-within {\n      border-color: #4096ff;\n      box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.1);\n    }\n  }\n}\n\n.codeEditorWrapper {\n  min-height: 100px;\n  max-height: 200px;\n  background: #fff;\n  padding: 8px 8px 8px 4px;\n  border-radius: 8px;\n  border: 1px solid #7f92cd40;\n  width: 348px;\n\n  :global(.cm-editor) {\n    height: 100% !important;\n    overflow: auto !important;\n  }\n\n  :global(.cm-scroller) {\n    min-height: 100px !important;\n    max-height: 200px !important;\n  }\n\n  :global(.cm-content) {\n    min-height: 100px !important;\n    max-height: 200px !important;\n  }\n\n  :global(.cm-activeLine) {\n    background-color: #efefef78;\n  }\n\n  :global(.cm-activeLineGutter) {\n    background-color: #efefef78;\n  }\n\n  :global(.cm-gutters) {\n    background-color: #fff;\n    color: #000A298A;\n    border-right-color: transparent;\n    border-right-width: 0px;\n  }\n}\n\n.fieldTypeIndicator {\n  display: inline-block;\n  padding: 2px 8px;\n  font-size: 12px;\n  font-weight: 500;\n  border-radius: 4px;\n}\n\n.emptyState {\n  text-align: center;\n  padding: 20px 20px;\n  color: #999999;\n  font-size: 14px;\n\n  .emptyText {\n    font-weight: 500;\n  }\n}\n\n.requiredIndicator {\n  color: #ff4d4f;\n  margin-left: 4px;\n  font-weight: 500;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-form/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC } from 'react';\n\nimport classNames from 'classnames';\nimport { DisplaySchemaTag } from '@flowgram.ai/form-materials';\nimport { Input, Switch, InputNumber } from '@douyinfe/semi-ui';\n\nimport { JsonValueEditor } from '../json-value-editor';\nimport { useFormMeta } from '../hooks/use-form-meta';\nimport { useFields } from '../hooks/use-fields';\nimport { useSyncDefault } from '../hooks';\n\nimport styles from './index.module.less';\n\ninterface TestRunFormProps {\n  values: Record<string, unknown>;\n  setValues: (values: Record<string, unknown>) => void;\n}\n\nexport const TestRunForm: FC<TestRunFormProps> = ({ values, setValues }) => {\n  const formMeta = useFormMeta();\n\n  const fields = useFields({\n    formMeta,\n    values,\n    setValues,\n  });\n\n  useSyncDefault({\n    formMeta,\n    values,\n    setValues,\n  });\n\n  const renderField = (field: any) => {\n    switch (field.type) {\n      case 'boolean':\n        return (\n          <div className={styles.fieldInput}>\n            <Switch checked={field.value} onChange={(checked) => field.onChange(checked)} />\n          </div>\n        );\n      case 'integer':\n        return (\n          <div className={styles.fieldInput}>\n            <InputNumber\n              precision={0}\n              value={field.value}\n              onChange={(value) => field.onChange(value)}\n              placeholder=\"Please input integer\"\n            />\n          </div>\n        );\n      case 'number':\n        return (\n          <div className={styles.fieldInput}>\n            <InputNumber\n              value={field.value}\n              onChange={(value) => field.onChange(value)}\n              placeholder=\"Please input number\"\n            />\n          </div>\n        );\n      case 'object':\n        return (\n          <div className={classNames(styles.fieldInput, styles.codeEditorWrapper)}>\n            <JsonValueEditor\n              value={field.value}\n              kind=\"object\"\n              onChange={(value) => field.onChange(value)}\n            />\n          </div>\n        );\n      case 'array':\n        return (\n          <div className={classNames(styles.fieldInput, styles.codeEditorWrapper)}>\n            <JsonValueEditor\n              value={field.value}\n              kind=\"array\"\n              onChange={(value) => field.onChange(value)}\n            />\n          </div>\n        );\n      default:\n        return (\n          <div className={styles.fieldInput}>\n            <Input\n              value={field.value}\n              onChange={(value) => field.onChange(value)}\n              placeholder=\"Please input text\"\n            />\n          </div>\n        );\n    }\n  };\n\n  // Show empty state if no fields\n  if (fields.length === 0) {\n    return (\n      <div className={styles.formContainer}>\n        <div className={styles.emptyState}>\n          <div className={styles.emptyText}>Empty</div>\n          <div className={styles.emptyText}>No inputs found in start node</div>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className={styles.formContainer}>\n      {fields.map((field) => (\n        <div key={field.name} className={styles.fieldGroup}>\n          <label htmlFor={field.name} className={styles.fieldLabel}>\n            {field.name}\n            {field.required && <span className={styles.requiredIndicator}>*</span>}\n            <span className={styles.fieldTypeIndicator}>\n              <DisplaySchemaTag\n                value={{\n                  type: field.type,\n                  items: field.itemsType\n                    ? {\n                        type: field.itemsType,\n                      }\n                    : undefined,\n                }}\n              />\n            </span>\n          </label>\n          {renderField(field)}\n        </div>\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-form/type.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport type { JsonSchemaBasicType } from '@flowgram.ai/form-materials';\n\nexport interface TestRunFormMetaItem {\n  type: JsonSchemaBasicType;\n  name: string;\n  defaultValue: unknown;\n  required: boolean;\n  itemsType?: JsonSchemaBasicType;\n}\n\nexport type TestRunFormMeta = TestRunFormMetaItem[];\n\nexport interface TestRunFormField extends TestRunFormMetaItem {\n  value: unknown;\n  onChange: (value: unknown) => void;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-json-input/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.testrun-json-input {\n  min-height: 300px;\n  max-height: 400px;\n  background: #fff;\n  padding: 8px 8px 8px 4px;\n  border-radius: 8px;\n  border: 1px solid #7f92cd40;\n  width: 348px;\n\n  :global(.cm-editor) {\n    height: 100% !important;\n    overflow: auto !important;\n  }\n\n  :global(.cm-scroller) {\n    min-height: 300px !important;\n    max-height: 400px !important;\n  }\n\n  :global(.cm-content) {\n    min-height: 300px !important;\n    max-height: 400px !important;\n  }\n\n  :global(.cm-activeLine) {\n    background-color: #efefef78;\n  }\n\n  :global(.cm-activeLineGutter) {\n    background-color: #efefef78;\n  }\n\n  :global(.cm-gutters) {\n    background-color: #fff;\n    color: #000A298A;\n    border-right-color: transparent;\n    border-right-width: 0px;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-json-input/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC } from 'react';\n\nimport { JsonValueEditor } from '../json-value-editor';\nimport { useFormMeta, useSyncDefault } from '../hooks';\n\nimport styles from './index.module.less';\n\ninterface TestRunJsonInputProps {\n  values: Record<string, unknown>;\n  setValues: (values: Record<string, unknown>) => void;\n}\n\nexport const TestRunJsonInput: FC<TestRunJsonInputProps> = ({ values, setValues }) => {\n  const formMeta = useFormMeta();\n\n  useSyncDefault({\n    formMeta,\n    values,\n    setValues,\n  });\n\n  return (\n    <div className={styles['testrun-json-input']}>\n      <JsonValueEditor value={values} kind=\"object\" onChange={(value) => setValues(value as Record<string, unknown>)} />\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-panel/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.testrun-panel-form {\n  .runtime-summary {\n    display: grid;\n    grid-template-columns: repeat(3, minmax(0, 1fr));\n    gap: 12px;\n    margin: 0 12px 12px 0;\n  }\n\n  .runtime-summary-item {\n    border: 1px solid rgba(82, 100, 154, 0.13);\n    border-radius: 10px;\n    background: #f7f9fc;\n    padding: 10px 12px;\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n\n    .label {\n      font-size: 12px;\n      color: #6b7280;\n      text-transform: uppercase;\n      letter-spacing: 0.08em;\n    }\n\n    .value {\n      font-size: 13px;\n      color: #111827;\n      word-break: break-word;\n    }\n  }\n\n  .testrun-panel-input {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 8px;\n    margin: 0 12px 8px 0;\n\n    .title {\n      font-size: 15px;\n      font-weight: 500;\n      color: #333;\n      flex: 1;\n    }\n  }\n\n\n  .error {\n    color: red;\n    font-size: 14px;\n  }\n\n  .code-editor-container {\n    min-height: 200px;\n    max-height: 400px;\n    background: #fff;\n    padding: 8px 8px 8px 4px;\n    border-radius: 4px;\n    border: 1px solid #52649a0f;\n\n    :global(.cm-editor) {\n      height: 100% !important;\n      overflow: auto !important;\n    }\n\n    :global(.cm-scroller) {\n      min-height: 200px !important;\n      max-height: 400px !important;\n    }\n\n    :global(.cm-content) {\n      min-height: 200px !important;\n      max-height: 400px !important;\n    }\n  }\n}\n\n.testrun-panel-running {\n  width: 100%;\n  height: 80%;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n  gap: 16px;\n\n  .text {\n    font-size: 18px;\n  }\n}\n\n.button {\n  border-radius: 8px;\n  min-width: 160px;\n  height: 40px;\n}\n\n\n.testrun-panel-container {\n  background: rgb(255, 255, 255);\n  border-radius: 8px;\n  height: 100%;\n  width: 100%;\n  border: 1px solid rgba(82, 100, 154, 0.13);\n  padding: 8px 0 8px 0;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n  position: relative;\n\n  .testrun-panel-header {\n    background: var(#fcfcff);\n    border-bottom: 1px solid rgba(82, 100, 154, 0.13);\n    border-top-left-radius: 8px;\n    border-top-right-radius: 8px;\n    display: flex;\n    height: 40px;\n    justify-content: space-between;\n    min-height: 40px;\n    width: 100%;\n    align-items: center;\n\n    .testrun-panel-title {\n      font-size: 16px;\n      font-weight: 500;\n      margin: 8px 8px 8px 16px;\n    }\n\n    .testrun-panel-close {\n      margin: 8px 16px 8px 8px;\n    }\n  }\n\n  .testrun-panel-content {\n    height: calc(100% - 40px);\n    margin: 8px 8px 8px 16px;\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n    overflow: auto;\n    margin-bottom: 72px;\n  }\n\n  .testrun-panel-footer {\n    border-top: 1px solid rgba(82, 100, 154, 0.13);\n    position: absolute;\n    background: #fbfbfb;\n    height: 62px;\n    bottom: 0;\n    border-radius: 0 0 8px 8px;\n    display: flex;\n    align-items: center;\n    justify-content: flex-end;\n    gap: 12px;\n    padding: 0 16px;\n    width: 100%;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-panel/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/testrun-panel/test-run-panel.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FC, useMemo, useState } from 'react';\n\nimport { useService } from '@flowgram.ai/free-layout-editor';\nimport { Button, Switch, Tag } from '@douyinfe/semi-ui';\nimport { IconClose } from '@douyinfe/semi-icons';\n\nimport { TestRunJsonInput } from '../testrun-json-input';\nimport { TestRunForm } from '../testrun-form';\nimport { NodeStatusGroup } from '../node-status-bar/group';\nimport { TracePanel } from '../trace-panel';\nimport { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';\nimport { useTestRunFormPanel } from '../../../plugins/panel-manager-plugin/hooks';\nimport { useRuntimeSnapshot } from '../../../workbench/runtime-hooks';\n\nimport styles from './index.module.less';\n\nexport interface TestRunSidePanelProps {}\n\nexport const TestRunSidePanel: FC<TestRunSidePanelProps> = () => {\n  const runtimeService = useService(WorkflowRuntimeService);\n  const { close: closePanel } = useTestRunFormPanel();\n  const snapshot = useRuntimeSnapshot(runtimeService);\n  const [values, setValues] = useState<Record<string, unknown>>(() => runtimeService.getDraftInputs());\n\n  // en - Use localStorage to persist the JSON mode state\n  const [inputJSONMode, _setInputJSONMode] = useState(() => {\n    const savedMode = localStorage.getItem('testrun-input-json-mode');\n    return savedMode ? JSON.parse(savedMode) : false;\n  });\n\n  const setInputJSONMode = (checked: boolean) => {\n    _setInputJSONMode(checked);\n    localStorage.setItem('testrun-input-json-mode', JSON.stringify(checked));\n  };\n\n  const onValidate = async () => {\n    runtimeService.setDraftInputs(values);\n    await runtimeService.taskValidate(values);\n  };\n\n  const onRun = async () => {\n    runtimeService.setDraftInputs(values);\n    if (snapshot.status === 'running') {\n      await runtimeService.taskCancel();\n      return;\n    }\n    await runtimeService.taskRun(values);\n  };\n\n  const onClose = () => {\n    closePanel();\n  };\n\n  const reportData = useMemo(() => {\n    const reports = snapshot.report?.reports ?? {};\n    return Object.fromEntries(\n      Object.entries(reports).map(([nodeID, report]) => [\n        nodeID,\n        {\n          status: report.status,\n          terminated: report.terminated,\n          startTime: report.startTime,\n          endTime: report.endTime,\n          timeCost: report.timeCost,\n        },\n      ])\n    );\n  }, [snapshot.report]);\n\n  const renderForm = (\n    <div className={styles['testrun-panel-form']}>\n      <div className={styles['runtime-summary']}>\n        <div className={styles['runtime-summary-item']}>\n          <span className={styles.label}>Task</span>\n          <span className={styles.value}>{snapshot.taskID ?? 'Not started'}</span>\n        </div>\n        <div className={styles['runtime-summary-item']}>\n          <span className={styles.label}>Status</span>\n          <Tag size=\"small\" color={snapshot.status === 'succeeded' ? 'green' : snapshot.status === 'failed' ? 'red' : snapshot.status === 'running' ? 'blue' : 'white'}>\n            {snapshot.status}\n          </Tag>\n        </div>\n        <div className={styles['runtime-summary-item']}>\n          <span className={styles.label}>Validation</span>\n          <span className={styles.value}>\n            {snapshot.validation ? (snapshot.validation.valid ? 'valid' : 'invalid') : 'pending'}\n          </span>\n        </div>\n      </div>\n\n      <div className={styles['testrun-panel-input']}>\n        <div className={styles.title}>Input Form</div>\n        <div>JSON Mode</div>\n        <Switch\n          checked={inputJSONMode}\n          onChange={(checked: boolean) => setInputJSONMode(checked)}\n          size=\"small\"\n        />\n      </div>\n      {inputJSONMode ? (\n        <TestRunJsonInput values={values} setValues={setValues} />\n      ) : (\n        <TestRunForm values={values} setValues={setValues} />\n      )}\n      {(snapshot.errors ?? []).map((errorMessage) => (\n        <div className={styles.error} key={errorMessage}>\n          {errorMessage}\n        </div>\n      ))}\n      <NodeStatusGroup title=\"Draft Inputs\" data={values} optional disableCollapse />\n      <NodeStatusGroup title=\"Result Inputs\" data={snapshot.result?.inputs} optional disableCollapse />\n      <NodeStatusGroup title=\"Result Outputs\" data={snapshot.result?.outputs} optional disableCollapse />\n      <NodeStatusGroup title=\"Node Reports\" data={reportData} optional />\n      <TracePanel trace={snapshot.trace} taskID={snapshot.taskID} />\n    </div>\n  );\n\n  return (\n    <div className={styles['testrun-panel-container']}>\n      <div className={styles['testrun-panel-header']}>\n        <div className={styles['testrun-panel-title']}>Test Run</div>\n        <Button\n          className={styles['testrun-panel-title']}\n          type=\"tertiary\"\n          icon={<IconClose />}\n          size=\"small\"\n          theme=\"borderless\"\n          onClick={onClose}\n        />\n      </div>\n      <div className={styles['testrun-panel-content']}>\n        {renderForm}\n      </div>\n      <div className={styles['testrun-panel-footer']}>\n        <Button className={styles.button} onClick={onValidate}>\n          Validate\n        </Button>\n        <Button className={styles.button} onClick={onRun}>\n          {snapshot.status === 'running' ? 'Cancel' : 'Run'}\n        </Button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/trace-panel/index.module.less",
    "content": ".trace-panel {\n  border: 1px solid rgba(82, 100, 154, 0.13);\n  border-radius: 12px;\n  background: linear-gradient(180deg, #fbfcff 0%, #f4f7fb 100%);\n  padding: 14px;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.trace-panel-head {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 12px;\n}\n\n.trace-panel-title {\n  font-size: 15px;\n  font-weight: 600;\n  color: #111827;\n}\n\n.trace-panel-copy {\n  margin-top: 4px;\n  font-size: 12px;\n  line-height: 1.5;\n  color: #6b7280;\n}\n\n.trace-summary-grid {\n  display: grid;\n  grid-template-columns: repeat(3, minmax(0, 1fr));\n  gap: 10px;\n}\n\n.trace-metrics-summary-grid {\n  display: grid;\n  grid-template-columns: repeat(4, minmax(0, 1fr));\n  gap: 10px;\n  margin-bottom: 10px;\n}\n\n.trace-summary-card {\n  border-radius: 10px;\n  background: rgba(255, 255, 255, 0.88);\n  border: 1px solid rgba(130, 146, 187, 0.16);\n  padding: 10px 12px;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.trace-summary-label {\n  font-size: 11px;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  color: #7b8499;\n}\n\n.trace-summary-value {\n  font-size: 13px;\n  color: #111827;\n  word-break: break-word;\n}\n\n.trace-empty {\n  border-radius: 10px;\n  background: rgba(255, 255, 255, 0.88);\n  border: 1px dashed rgba(130, 146, 187, 0.24);\n  padding: 14px;\n  font-size: 13px;\n  line-height: 1.6;\n  color: #5b6475;\n}\n\n.trace-events {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.trace-event-item {\n  display: grid;\n  grid-template-columns: 12px minmax(0, 1fr);\n  gap: 10px;\n  padding: 10px 0;\n  border-bottom: 1px solid rgba(82, 100, 154, 0.08);\n}\n\n.trace-event-item:last-child {\n  border-bottom: none;\n}\n\n.trace-event-dot {\n  width: 10px;\n  height: 10px;\n  border-radius: 999px;\n  background: linear-gradient(180deg, #0ea5e9 0%, #2563eb 100%);\n  margin-top: 5px;\n  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);\n}\n\n.trace-event-main {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-wrap: wrap;\n  margin-bottom: 6px;\n}\n\n.trace-event-type {\n  font-size: 13px;\n  font-weight: 600;\n  color: #111827;\n}\n\n.trace-event-node {\n  font-size: 12px;\n  color: #475569;\n  background: rgba(37, 99, 235, 0.08);\n  border-radius: 999px;\n  padding: 2px 8px;\n}\n\n.trace-event-meta {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-wrap: wrap;\n  font-size: 12px;\n  color: #6b7280;\n}\n\n.trace-error {\n  color: #dc2626;\n}\n\n.trace-node-grid {\n  display: grid;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n  gap: 10px;\n}\n\n.trace-metrics-list {\n  display: grid;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n  gap: 10px;\n}\n\n.trace-metric-card {\n  border-radius: 10px;\n  background: rgba(255, 255, 255, 0.92);\n  border: 1px solid rgba(82, 100, 154, 0.12);\n  padding: 12px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.trace-metric-model {\n  margin-top: 4px;\n  font-size: 12px;\n  color: #475569;\n}\n\n.trace-node-card {\n  border-radius: 10px;\n  background: rgba(255, 255, 255, 0.9);\n  border: 1px solid rgba(82, 100, 154, 0.12);\n  padding: 12px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.trace-node-head {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px;\n}\n\n.trace-node-id {\n  font-size: 13px;\n  font-weight: 600;\n  color: #111827;\n  word-break: break-word;\n}\n\n.trace-node-meta {\n  display: grid;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n  gap: 8px;\n}\n\n.trace-node-meta-item {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.trace-node-meta-label {\n  font-size: 11px;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  color: #7b8499;\n}\n\n.trace-node-meta-value {\n  font-size: 12px;\n  line-height: 1.5;\n  color: #111827;\n  word-break: break-word;\n}\n\n.trace-tabs {\n  :global(.semi-tabs-bar) {\n    margin-bottom: 10px;\n  }\n\n  :global(.semi-tabs-pane) {\n    padding-top: 2px;\n  }\n}\n\n@media (max-width: 1200px) {\n  .trace-summary-grid {\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n  }\n\n  .trace-metrics-summary-grid {\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n  }\n\n  .trace-node-grid {\n    grid-template-columns: minmax(0, 1fr);\n  }\n\n  .trace-metrics-list {\n    grid-template-columns: minmax(0, 1fr);\n  }\n}\n\n@media (max-width: 720px) {\n  .trace-summary-grid,\n  .trace-metrics-summary-grid {\n    grid-template-columns: minmax(0, 1fr);\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/testrun/trace-panel/index.tsx",
    "content": "import { FC, useMemo } from 'react';\n\nimport { Tabs, Tag } from '@douyinfe/semi-ui';\n\nimport {\n  FlowGramTraceEventView,\n  FlowGramTraceMetricsView,\n  FlowGramTraceNodeView,\n  FlowGramTraceView,\n} from '../../../plugins/runtime-plugin/trace';\nimport { NodeStatusGroup } from '../node-status-bar/group';\n\nimport styles from './index.module.less';\n\ninterface TracePanelProps {\n  trace?: FlowGramTraceView;\n  taskID?: string;\n}\n\nconst formatTimestamp = (timestamp?: number): string => {\n  if (!timestamp) {\n    return '--';\n  }\n  return new Date(timestamp).toLocaleTimeString();\n};\n\nconst formatDuration = (startedAt?: number, endedAt?: number): string => {\n  if (!startedAt) {\n    return '--';\n  }\n  const duration = Math.max((endedAt ?? startedAt) - startedAt, 0);\n  if (duration < 1000) {\n    return `${duration} ms`;\n  }\n  return `${(duration / 1000).toFixed(duration >= 10000 ? 0 : 1)} s`;\n};\n\nconst formatDurationValue = (durationMillis?: number): string => {\n  if (durationMillis === undefined || durationMillis === null) {\n    return '--';\n  }\n  if (durationMillis < 1000) {\n    return `${durationMillis} ms`;\n  }\n  return `${(durationMillis / 1000).toFixed(durationMillis >= 10000 ? 0 : 1)} s`;\n};\n\nconst formatTokens = (metrics?: FlowGramTraceMetricsView): string => {\n  if (!metrics) {\n    return '--';\n  }\n  const total = metrics.totalTokens;\n  const prompt = metrics.promptTokens;\n  const completion = metrics.completionTokens;\n  if (total == null && prompt == null && completion == null) {\n    return '--';\n  }\n  const parts = [\n    total != null ? `${total} total` : undefined,\n    prompt != null ? `${prompt} in` : undefined,\n    completion != null ? `${completion} out` : undefined,\n  ].filter(Boolean);\n  return parts.join(' · ');\n};\n\nconst formatCost = (metrics?: FlowGramTraceMetricsView): string => {\n  if (!metrics || metrics.totalCost === undefined || metrics.totalCost === null) {\n    return 'n/a';\n  }\n  const currency = metrics.currency?.trim();\n  const value = metrics.totalCost < 0.01\n    ? metrics.totalCost.toFixed(6)\n    : metrics.totalCost.toFixed(4);\n  return currency ? `${currency} ${value}` : value;\n};\n\nconst hasUsageMetrics = (metrics?: FlowGramTraceMetricsView): boolean =>\n  Boolean(\n    metrics\n    && (metrics.promptTokens != null\n      || metrics.completionTokens != null\n      || metrics.totalTokens != null\n      || metrics.totalCost != null)\n  );\n\nconst formatEventType = (type?: string): string =>\n  (type ?? 'unknown')\n    .split('_')\n    .filter(Boolean)\n    .map((part) => part.charAt(0) + part.slice(1).toLowerCase())\n    .join(' ');\n\nconst getStatusColor = (status?: string): 'green' | 'red' | 'blue' | 'grey' | 'white' | 'yellow' => {\n  const normalized = status?.trim().toLowerCase();\n  switch (normalized) {\n    case 'success':\n    case 'succeeded':\n      return 'green';\n    case 'failed':\n    case 'error':\n      return 'red';\n    case 'processing':\n    case 'running':\n      return 'blue';\n    case 'canceled':\n    case 'cancelled':\n      return 'grey';\n    case 'pending':\n      return 'yellow';\n    default:\n      return 'white';\n  }\n};\n\nconst sortEvents = (events: FlowGramTraceEventView[] = []): FlowGramTraceEventView[] =>\n  [...events].sort((left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0));\n\nconst sortNodes = (nodes?: Record<string, FlowGramTraceNodeView>): FlowGramTraceNodeView[] =>\n  Object.values(nodes ?? {}).sort((left, right) => {\n    const timeDelta = (left.startedAt ?? Number.MAX_SAFE_INTEGER) - (right.startedAt ?? Number.MAX_SAFE_INTEGER);\n    if (timeDelta !== 0) {\n      return timeDelta;\n    }\n    return (left.nodeId ?? '').localeCompare(right.nodeId ?? '');\n  });\n\nexport const TracePanel: FC<TracePanelProps> = ({ trace, taskID }) => {\n  const events = useMemo(() => sortEvents(trace?.events), [trace?.events]);\n  const nodes = useMemo(() => sortNodes(trace?.nodes), [trace?.nodes]);\n  const metricsNodes = useMemo(\n    () => nodes.filter((node) => node.model || hasUsageMetrics(node.metrics)),\n    [nodes]\n  );\n  const summary = trace?.summary;\n  const summaryMetrics = summary?.metrics;\n\n  if (!trace) {\n    return (\n      <div className={styles['trace-panel']}>\n        <div className={styles['trace-panel-head']}>\n          <div>\n            <div className={styles['trace-panel-title']}>Trace</div>\n            <div className={styles['trace-panel-copy']}>\n              运行中的任务会在这里展示时间线和节点执行快照。\n            </div>\n          </div>\n        </div>\n        <div className={styles['trace-empty']}>\n          {taskID\n            ? '当前任务还没有返回 trace。请确认后端启用了 ai4j.flowgram.trace-enabled=true。'\n            : '先执行一次工作流，面板就会显示任务 trace。'}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className={styles['trace-panel']}>\n      <div className={styles['trace-panel-head']}>\n        <div>\n          <div className={styles['trace-panel-title']}>Trace</div>\n          <div className={styles['trace-panel-copy']}>\n            这一层直接消费后端投影出来的运行时视图，用来检查任务时间线和节点状态。\n          </div>\n        </div>\n        <Tag size=\"small\" color={getStatusColor(trace.status)}>\n          {trace.status ?? 'unknown'}\n        </Tag>\n      </div>\n\n      <div className={styles['trace-summary-grid']}>\n        <div className={styles['trace-summary-card']}>\n          <div className={styles['trace-summary-label']}>Task</div>\n          <div className={styles['trace-summary-value']}>{trace.taskId ?? taskID ?? '--'}</div>\n        </div>\n        <div className={styles['trace-summary-card']}>\n          <div className={styles['trace-summary-label']}>Window</div>\n          <div className={styles['trace-summary-value']}>\n            {formatTimestamp(trace.startedAt)} - {formatTimestamp(trace.endedAt)}\n          </div>\n        </div>\n        <div className={styles['trace-summary-card']}>\n          <div className={styles['trace-summary-label']}>Duration</div>\n          <div className={styles['trace-summary-value']}>\n            {formatDurationValue(summary?.durationMillis) !== '--'\n              ? formatDurationValue(summary?.durationMillis)\n              : formatDuration(trace.startedAt, trace.endedAt)}\n          </div>\n        </div>\n        <div className={styles['trace-summary-card']}>\n          <div className={styles['trace-summary-label']}>Events / Nodes</div>\n          <div className={styles['trace-summary-value']}>\n            {summary?.eventCount ?? events.length} / {summary?.nodeCount ?? nodes.length}\n          </div>\n        </div>\n        <div className={styles['trace-summary-card']}>\n          <div className={styles['trace-summary-label']}>LLM Tokens</div>\n          <div className={styles['trace-summary-value']}>{formatTokens(summaryMetrics)}</div>\n        </div>\n        <div className={styles['trace-summary-card']}>\n          <div className={styles['trace-summary-label']}>Estimated Cost</div>\n          <div className={styles['trace-summary-value']}>{formatCost(summaryMetrics)}</div>\n        </div>\n        <div className={styles['trace-summary-card']}>\n          <div className={styles['trace-summary-label']}>Node Health</div>\n          <div className={styles['trace-summary-value']}>\n            {(summary?.successNodeCount ?? 0)} ok\n            {' / '}\n            {(summary?.failedNodeCount ?? 0)} failed\n          </div>\n        </div>\n      </div>\n\n      <Tabs className={styles['trace-tabs']} type=\"card\" collapsible>\n        <Tabs.TabPane itemKey=\"metrics\" tab={`Metrics (${metricsNodes.length})`}>\n          <div className={styles['trace-metrics-summary-grid']}>\n            <div className={styles['trace-summary-card']}>\n              <div className={styles['trace-summary-label']}>Prompt Tokens</div>\n              <div className={styles['trace-summary-value']}>\n                {summaryMetrics?.promptTokens ?? '--'}\n              </div>\n            </div>\n            <div className={styles['trace-summary-card']}>\n              <div className={styles['trace-summary-label']}>Completion Tokens</div>\n              <div className={styles['trace-summary-value']}>\n                {summaryMetrics?.completionTokens ?? '--'}\n              </div>\n            </div>\n            <div className={styles['trace-summary-card']}>\n              <div className={styles['trace-summary-label']}>Total Tokens</div>\n              <div className={styles['trace-summary-value']}>\n                {summaryMetrics?.totalTokens ?? '--'}\n              </div>\n            </div>\n            <div className={styles['trace-summary-card']}>\n              <div className={styles['trace-summary-label']}>LLM Nodes</div>\n              <div className={styles['trace-summary-value']}>\n                {summary?.llmNodeCount ?? metricsNodes.length}\n              </div>\n            </div>\n          </div>\n\n          {metricsNodes.length === 0 ? (\n            <div className={styles['trace-empty']}>\n              当前任务没有可展示的模型 usage 指标。通常意味着流程没有经过 LLM 节点，或者该节点没有返回 usage / cost 数据。\n            </div>\n          ) : (\n            <div className={styles['trace-metrics-list']}>\n              {metricsNodes.map((node, index) => (\n                <div\n                  className={styles['trace-metric-card']}\n                  key={`${node.nodeId ?? 'metric'}-${node.startedAt ?? index}-${index}`}\n                >\n                  <div className={styles['trace-node-head']}>\n                    <div>\n                      <div className={styles['trace-node-id']}>{node.nodeId ?? 'unknown-node'}</div>\n                      {node.model ? (\n                        <div className={styles['trace-metric-model']}>{node.model}</div>\n                      ) : null}\n                    </div>\n                    <Tag size=\"small\" color={getStatusColor(node.status)}>\n                      {node.status ?? 'unknown'}\n                    </Tag>\n                  </div>\n                  <div className={styles['trace-node-meta']}>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Duration</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {formatDurationValue(node.durationMillis)}\n                      </span>\n                    </div>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Tokens</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {formatTokens(node.metrics)}\n                      </span>\n                    </div>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Estimated Cost</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {formatCost(node.metrics)}\n                      </span>\n                    </div>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Events</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {node.eventCount ?? 0} · {node.terminated ? 'terminated' : 'active'}\n                      </span>\n                    </div>\n                  </div>\n                  {node.error ? <div className={styles['trace-error']}>{node.error}</div> : null}\n                </div>\n              ))}\n            </div>\n          )}\n        </Tabs.TabPane>\n        <Tabs.TabPane itemKey=\"timeline\" tab={`Timeline (${events.length})`}>\n          {events.length === 0 ? (\n            <div className={styles['trace-empty']}>当前 trace 还没有事件。</div>\n          ) : (\n            <div className={styles['trace-events']}>\n              {events.map((event, index) => (\n                <div\n                  className={styles['trace-event-item']}\n                  key={`${event.type ?? 'event'}-${event.nodeId ?? 'workflow'}-${event.timestamp ?? index}-${index}`}\n                >\n                  <div className={styles['trace-event-dot']} />\n                  <div>\n                    <div className={styles['trace-event-main']}>\n                      <span className={styles['trace-event-type']}>{formatEventType(event.type)}</span>\n                      {event.nodeId ? (\n                        <span className={styles['trace-event-node']}>{event.nodeId}</span>\n                      ) : null}\n                    </div>\n                    <div className={styles['trace-event-meta']}>\n                      <Tag size=\"small\" color={getStatusColor(event.status)}>\n                        {event.status ?? 'unknown'}\n                      </Tag>\n                      <span>{formatTimestamp(event.timestamp)}</span>\n                      {event.error ? (\n                        <span className={styles['trace-error']}>{event.error}</span>\n                      ) : null}\n                    </div>\n                  </div>\n                </div>\n              ))}\n            </div>\n          )}\n        </Tabs.TabPane>\n        <Tabs.TabPane itemKey=\"nodes\" tab={`Nodes (${nodes.length})`}>\n          {nodes.length === 0 ? (\n            <div className={styles['trace-empty']}>当前 trace 还没有节点明细。</div>\n          ) : (\n            <div className={styles['trace-node-grid']}>\n              {nodes.map((node, index) => (\n                <div\n                  className={styles['trace-node-card']}\n                  key={`${node.nodeId ?? 'node'}-${node.startedAt ?? index}-${index}`}\n                >\n                  <div className={styles['trace-node-head']}>\n                    <div className={styles['trace-node-id']}>{node.nodeId ?? 'unknown-node'}</div>\n                    <Tag size=\"small\" color={getStatusColor(node.status)}>\n                      {node.status ?? 'unknown'}\n                    </Tag>\n                  </div>\n                  <div className={styles['trace-node-meta']}>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Started</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {formatTimestamp(node.startedAt)}\n                      </span>\n                    </div>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Ended</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {formatTimestamp(node.endedAt)}\n                      </span>\n                    </div>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Duration</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {formatDurationValue(node.durationMillis) !== '--'\n                          ? formatDurationValue(node.durationMillis)\n                          : formatDuration(node.startedAt, node.endedAt)}\n                      </span>\n                    </div>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Events</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {node.eventCount ?? 0} · {node.terminated ? 'terminated' : 'active'}\n                      </span>\n                    </div>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Model</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {node.model ?? '--'}\n                      </span>\n                    </div>\n                    <div className={styles['trace-node-meta-item']}>\n                      <span className={styles['trace-node-meta-label']}>Tokens</span>\n                      <span className={styles['trace-node-meta-value']}>\n                        {formatTokens(node.metrics)}\n                      </span>\n                    </div>\n                  </div>\n                  {node.error ? <div className={styles['trace-error']}>{node.error}</div> : null}\n                </div>\n              ))}\n            </div>\n          )}\n        </Tabs.TabPane>\n        <Tabs.TabPane itemKey=\"raw\" tab=\"Raw\">\n          <NodeStatusGroup title=\"Trace JSON\" data={trace} disableCollapse />\n        </Tabs.TabPane>\n      </Tabs>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/auto-layout.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback } from 'react';\n\nimport { usePlayground, usePlaygroundTools } from '@flowgram.ai/free-layout-editor';\nimport { IconButton, Tooltip } from '@douyinfe/semi-ui';\n\nimport { IconAutoLayout } from '../../assets/icon-auto-layout';\n\nexport const AutoLayout = () => {\n  const tools = usePlaygroundTools();\n  const playground = usePlayground();\n  const autoLayout = useCallback(async () => {\n    await tools.autoLayout({\n      enableAnimation: true,\n      animationDuration: 1000,\n      layoutConfig: {\n        rankdir: 'LR',\n        align: undefined,\n        nodesep: 100,\n        ranksep: 100,\n      },\n    });\n  }, [tools]);\n\n  return (\n    <Tooltip content={'Auto Layout'}>\n      <IconButton\n        disabled={playground.config.readonly}\n        type=\"tertiary\"\n        theme=\"borderless\"\n        onClick={autoLayout}\n        icon={IconAutoLayout}\n      />\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/comment.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useState, useCallback } from 'react';\n\nimport {\n  delay,\n  usePlayground,\n  useService,\n  WorkflowDocument,\n  WorkflowDragService,\n  WorkflowSelectService,\n} from '@flowgram.ai/free-layout-editor';\nimport { IconButton, Tooltip } from '@douyinfe/semi-ui';\n\nimport { WorkflowNodeType } from '../../nodes';\nimport { IconComment } from '../../assets/icon-comment';\n\nexport const Comment = () => {\n  const playground = usePlayground();\n  const document = useService(WorkflowDocument);\n  const selectService = useService(WorkflowSelectService);\n  const dragService = useService(WorkflowDragService);\n\n  const [tooltipVisible, setTooltipVisible] = useState(false);\n\n  const calcNodePosition = useCallback(\n    (mouseEvent: React.MouseEvent<HTMLButtonElement>) => {\n      const mousePosition = playground.config.getPosFromMouseEvent(mouseEvent);\n      return {\n        x: mousePosition.x,\n        y: mousePosition.y - 75,\n      };\n    },\n    [playground]\n  );\n\n  const createComment = useCallback(\n    async (mouseEvent: React.MouseEvent<HTMLButtonElement>) => {\n      setTooltipVisible(false);\n      const canvasPosition = calcNodePosition(mouseEvent);\n      // create comment node - 创建节点\n      const node = document.createWorkflowNodeByType(WorkflowNodeType.Comment, canvasPosition);\n      // wait comment node render - 等待节点渲染\n      await delay(16);\n      // select comment node - 选中节点\n      selectService.selectNode(node);\n      // maybe touch event - 可能是触摸事件\n      if (mouseEvent.detail !== 0) {\n        // start drag -开始拖拽\n        dragService.startDragSelectedNodes(mouseEvent);\n      }\n    },\n    [selectService, calcNodePosition, document, dragService]\n  );\n\n  return (\n    <Tooltip\n      trigger=\"custom\"\n      visible={tooltipVisible}\n      onVisibleChange={setTooltipVisible}\n      content=\"Comment\"\n    >\n      <IconButton\n        disabled={playground.config.readonly}\n        icon={\n          <IconComment\n            style={{\n              width: 16,\n              height: 16,\n            }}\n          />\n        }\n        type=\"tertiary\"\n        theme=\"borderless\"\n        onClick={createComment}\n        onMouseEnter={() => setTooltipVisible(true)}\n        onMouseLeave={() => setTooltipVisible(false)}\n      />\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/download.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useEffect, useState, type FC } from 'react';\n\nimport { usePlayground, useService } from '@flowgram.ai/free-layout-editor';\nimport { FlowDownloadFormat, FlowDownloadService } from '@flowgram.ai/export-plugin';\nimport { IconButton, Toast, Dropdown, Tooltip } from '@douyinfe/semi-ui';\nimport { IconFilledArrowDown } from '@douyinfe/semi-icons';\n\nconst formatOptions = [\n  {\n    label: 'PNG',\n    value: FlowDownloadFormat.PNG,\n  },\n  {\n    label: 'JPEG',\n    value: FlowDownloadFormat.JPEG,\n  },\n  {\n    label: 'SVG',\n    value: FlowDownloadFormat.SVG,\n  },\n  {\n    label: 'JSON',\n    value: FlowDownloadFormat.JSON,\n  },\n  {\n    label: 'YAML',\n    value: FlowDownloadFormat.YAML,\n  },\n];\n\nexport const DownloadTool: FC = () => {\n  const [downloading, setDownloading] = useState<boolean>(false);\n  const [visible, setVisible] = useState(false);\n  const playground = usePlayground();\n  const { readonly } = playground.config;\n  const downloadService = useService(FlowDownloadService);\n\n  useEffect(() => {\n    const subscription = downloadService.onDownloadingChange((v) => {\n      setDownloading(v);\n    });\n\n    return () => {\n      subscription.dispose();\n    };\n  }, [downloadService]);\n\n  const handleDownload = async (format: FlowDownloadFormat) => {\n    setVisible(false);\n    await downloadService.download({\n      format,\n    });\n    const formatOption = formatOptions.find((option) => option.value === format);\n    Toast.success(`Download ${formatOption?.label} successfully`);\n  };\n\n  const button = (\n    <IconButton\n      type=\"tertiary\"\n      theme=\"borderless\"\n      className={visible ? '!coz-mg-secondary-pressed' : undefined}\n      icon={<IconFilledArrowDown />}\n      loading={downloading}\n      onClick={() => setVisible(true)}\n    />\n  );\n\n  return (\n    <Dropdown\n      trigger=\"custom\"\n      visible={visible}\n      position=\"topLeft\"\n      onClickOutSide={() => setVisible(false)}\n      render={\n        <Dropdown.Menu className=\"min-w-[120px]\">\n          {formatOptions.map((item) => (\n            <Dropdown.Item\n              disabled={downloading || readonly}\n              key={item.value}\n              onClick={() => handleDownload(item.value)}\n            >\n              {item.label}\n            </Dropdown.Item>\n          ))}\n        </Dropdown.Menu>\n      }\n    >\n      {visible ? (\n        button\n      ) : (\n        <div>\n          <Tooltip content=\"Download\">{button}</Tooltip>\n        </div>\n      )}\n    </Dropdown>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/fit-view.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { usePlaygroundTools } from '@flowgram.ai/free-layout-editor';\nimport { IconButton, Tooltip } from '@douyinfe/semi-ui';\nimport { IconExpand } from '@douyinfe/semi-icons';\n\nexport const FitView = () => {\n  const tools = usePlaygroundTools();\n  return (\n    <Tooltip content=\"FitView\">\n      <IconButton\n        icon={<IconExpand />}\n        type=\"tertiary\"\n        theme=\"borderless\"\n        onClick={() => tools.fitView()}\n      />\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useState, useEffect } from 'react';\n\nimport { useRefresh } from '@flowgram.ai/free-layout-editor';\nimport { useClientContext } from '@flowgram.ai/free-layout-editor';\nimport { Tooltip, IconButton, Divider } from '@douyinfe/semi-ui';\nimport { IconUndo, IconRedo } from '@douyinfe/semi-icons';\n\nimport { ZoomSelect } from './zoom-select';\nimport { ToolContainer, ToolSection } from './styles';\nimport { MinimapSwitch } from './minimap-switch';\nimport { Minimap } from './minimap';\nimport { Interactive } from './interactive';\nimport { FitView } from './fit-view';\nimport { AutoLayout } from './auto-layout';\nimport { ProblemButton } from '../problem-panel';\n\nexport const DemoTools = () => {\n  const { history, playground } = useClientContext();\n  const [canUndo, setCanUndo] = useState(false);\n  const [canRedo, setCanRedo] = useState(false);\n  const [minimapVisible, setMinimapVisible] = useState(true);\n  useEffect(() => {\n    const disposable = history.undoRedoService.onChange(() => {\n      setCanUndo(history.canUndo());\n      setCanRedo(history.canRedo());\n    });\n    return () => disposable.dispose();\n  }, [history]);\n  const refresh = useRefresh();\n\n  useEffect(() => {\n    const disposable = playground.config.onReadonlyOrDisabledChange(() => refresh());\n    return () => disposable.dispose();\n  }, [playground]);\n\n  return (\n    <ToolContainer className=\"demo-free-layout-tools\">\n      <ToolSection>\n        <Interactive />\n        <ZoomSelect />\n        <FitView />\n        <AutoLayout />\n        <MinimapSwitch minimapVisible={minimapVisible} setMinimapVisible={setMinimapVisible} />\n        <Minimap visible={minimapVisible} />\n        <Tooltip content=\"Undo\">\n          <IconButton\n            type=\"tertiary\"\n            theme=\"borderless\"\n            icon={<IconUndo />}\n            disabled={!canUndo || playground.config.readonly}\n            onClick={() => history.undo()}\n          />\n        </Tooltip>\n        <Tooltip content=\"Redo\">\n          <IconButton\n            type=\"tertiary\"\n            theme=\"borderless\"\n            icon={<IconRedo />}\n            disabled={!canRedo || playground.config.readonly}\n            onClick={() => history.redo()}\n          />\n        </Tooltip>\n        <ProblemButton />\n        <Divider layout=\"vertical\" style={{ height: '16px' }} margin={3} />\n      </ToolSection>\n    </ToolContainer>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/interactive.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useEffect, useState } from 'react';\n\nimport {\n  usePlaygroundTools,\n  type InteractiveType as IdeInteractiveType,\n} from '@flowgram.ai/free-layout-editor';\nimport { Tooltip, Popover } from '@douyinfe/semi-ui';\n\nimport { MousePadSelector } from './mouse-pad-selector';\n\nexport const CACHE_KEY = 'workflow_prefer_interactive_type';\nexport const IS_MAC_OS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);\n\nexport const getPreferInteractiveType = () => {\n  const data = localStorage.getItem(CACHE_KEY) as string;\n  if (data && [InteractiveType.Mouse, InteractiveType.Pad].includes(data as InteractiveType)) {\n    return data;\n  }\n  return IS_MAC_OS ? InteractiveType.Pad : InteractiveType.Mouse;\n};\n\nexport const setPreferInteractiveType = (type: InteractiveType) => {\n  localStorage.setItem(CACHE_KEY, type);\n};\n\nexport enum InteractiveType {\n  Mouse = 'MOUSE',\n  Pad = 'PAD',\n}\n\nexport const Interactive = () => {\n  const tools = usePlaygroundTools();\n  const [visible, setVisible] = useState(false);\n\n  const [interactiveType, setInteractiveType] = useState<InteractiveType>(\n    () => getPreferInteractiveType() as InteractiveType\n  );\n\n  const [showInteractivePanel, setShowInteractivePanel] = useState(false);\n\n  const mousePadTooltip =\n    interactiveType === InteractiveType.Mouse ? 'Mouse-Friendly' : 'Touchpad-Friendly';\n\n  useEffect(() => {\n    // read from localStorage\n    const preferInteractiveType = getPreferInteractiveType();\n    tools.setInteractiveType(preferInteractiveType as IdeInteractiveType);\n  }, []);\n\n  const handleClose = () => {\n    setVisible(false);\n  };\n\n  return (\n    <Popover trigger=\"custom\" position=\"top\" visible={visible} onClickOutSide={handleClose}>\n      <Tooltip\n        content={mousePadTooltip}\n        style={{ display: showInteractivePanel ? 'none' : 'block' }}\n      >\n        <div className=\"workflow-toolbar-interactive\">\n          <MousePadSelector\n            value={interactiveType}\n            onChange={(value) => {\n              setInteractiveType(value);\n              setPreferInteractiveType(value);\n              tools.setInteractiveType(value as unknown as IdeInteractiveType);\n            }}\n            onPopupVisibleChange={setShowInteractivePanel}\n            containerStyle={{\n              border: 'none',\n              height: '32px',\n              width: '32px',\n              justifyContent: 'center',\n              alignItems: 'center',\n              gap: '2px',\n              padding: '4px',\n              borderRadius: 'var(--small, 6px)',\n            }}\n            iconStyle={{\n              margin: '0',\n              width: '16px',\n              height: '16px',\n            }}\n            arrowStyle={{\n              width: '12px',\n              height: '12px',\n            }}\n          />\n        </div>\n      </Tooltip>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/minimap-switch.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Tooltip, IconButton } from '@douyinfe/semi-ui';\n\nimport { UIIconMinimap } from './styles';\n\nexport const MinimapSwitch = (props: {\n  minimapVisible: boolean;\n  setMinimapVisible: (visible: boolean) => void;\n}) => {\n  const { minimapVisible, setMinimapVisible } = props;\n\n  return (\n    <Tooltip content=\"Minimap\">\n      <IconButton\n        type=\"tertiary\"\n        theme=\"borderless\"\n        icon={<UIIconMinimap visible={minimapVisible} />}\n        onClick={() => setMinimapVisible(!minimapVisible)}\n      />\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/minimap.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { MinimapRender } from '@flowgram.ai/minimap-plugin';\n\nimport { MinimapContainer } from './styles';\n\nexport const Minimap = ({ visible }: { visible?: boolean }) => {\n  if (!visible) {\n    return <></>;\n  }\n  return (\n    <MinimapContainer>\n      <MinimapRender\n        panelStyles={{}}\n        containerStyles={{\n          pointerEvents: 'auto',\n          position: 'relative',\n          top: 'unset',\n          right: 'unset',\n          bottom: 'unset',\n          left: 'unset',\n        }}\n        inactiveStyle={{\n          opacity: 1,\n          scale: 1,\n          translateX: 0,\n          translateY: 0,\n        }}\n      />\n    </MinimapContainer>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/mouse-pad-selector.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n/* stylelint-disable no-descending-specificity */\n/* stylelint-disable selector-class-pattern */\n.ui-mouse-pad-selector {\n  position: relative;\n\n  display: flex;\n  align-items: center;\n\n  box-sizing: border-box;\n  width: 68px;\n  height: 32px;\n  padding: 8px 12px;\n\n  border: 1px solid rgba(29, 28, 35, 8%);\n  border-radius: 8px;\n\n  &-icon {\n    height: 20px;\n    margin-right: 12px;\n  }\n\n  &-arrow {\n    height: 16px;\n    font-size: 12px;\n  }\n\n  &-popover {\n    padding: 16px;\n\n    &-options {\n      display: flex;\n      gap: 12px;\n      margin-top: 12px;\n    }\n\n    .mouse-pad-option {\n      box-sizing: border-box;\n      width: 220px;\n      padding-bottom: 20px;\n\n      text-align: center;\n\n      background: var(--coz-mg-card, #FFF);\n      border: 1px solid var(--coz-stroke-plus, rgba(6, 7, 9, 15%));\n      border-radius: var(--default, 8px);\n\n      &-icon {\n        padding-top: 26px;\n      }\n\n      &-title {\n        padding-top: 8px;\n      }\n\n      &-subTitle {\n        padding: 4px 12px 0;\n      }\n\n      &-icon-selected {\n        color: rgb(19 0 221);\n      }\n\n      &-title-selected {\n        color: var(--coz-fg-hglt, #4E40E5);\n      }\n\n      &-subTitle-selected {\n        color: var(--coz-fg-hglt, #4E40E5);\n      }\n\n      &-selected {\n        cursor: pointer;\n        background-color: var(--coz-mg-hglt, rgba(186, 192, 255, 20%));\n        border: 1px solid var(--coz-stroke-hglt, #4E40E5);\n        border-radius: var(--default, 8px);\n      }\n\n      &:hover:not(&-selected) {\n        cursor: pointer;\n\n        background-color: var(--coz-mg-card-hovered, #FFF);\n        border: 1px solid var(--coz-stroke-plus, rgba(6, 7, 9, 15%));\n        border-radius: var(--default, 8px);\n        box-shadow: 0 8px 24px 0 rgba(0, 0, 0, 16%), 0 16px 48px 0 rgba(0, 0, 0, 8%);\n      }\n\n      &:active:not(&-selected) {\n        background-color: rgba(46, 46, 56, 12%);\n      }\n\n      &:last-of-type {\n        padding-top: 13px;\n      }\n    }\n  }\n\n  &:hover {\n    cursor: pointer;\n    background-color: rgba(46, 46, 56, 8%);\n    border-color: rgba(77, 83, 232, 100%);\n  }\n\n  &:active,\n  &:focus {\n    background-color: rgba(46, 46, 56, 12%);\n    border-color: rgba(77, 83, 232, 100%);\n  }\n\n  &-active {\n    border-color: rgba(77, 83, 232, 100%);\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/mouse-pad-selector.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React, { type CSSProperties, useState } from 'react';\n\nimport { Popover, Typography } from '@douyinfe/semi-ui';\n\nimport { IconPad, IconPadTool } from '../../assets/icon-pad';\nimport { IconMouse, IconMouseTool } from '../../assets/icon-mouse';\n\nimport './mouse-pad-selector.less';\n\nconst { Title, Paragraph } = Typography;\n\nexport enum InteractiveType {\n  Mouse = 'MOUSE',\n  Pad = 'PAD',\n}\n\nexport interface MousePadSelectorProps {\n  value: InteractiveType;\n  onChange: (value: InteractiveType) => void;\n  onPopupVisibleChange?: (visible: boolean) => void;\n  containerStyle?: CSSProperties;\n  iconStyle?: CSSProperties;\n  arrowStyle?: CSSProperties;\n}\n\nconst InteractiveItem: React.FC<{\n  title: string;\n  subTitle: string;\n  icon: React.ReactNode;\n  value: InteractiveType;\n  selected: boolean;\n  onChange: (value: InteractiveType) => void;\n}> = ({ title, subTitle, icon, onChange, value, selected }) => (\n  <div\n    className={`mouse-pad-option ${selected ? 'mouse-pad-option-selected' : ''}`}\n    onClick={() => onChange(value)}\n  >\n    <div className={`mouse-pad-option-icon ${selected ? 'mouse-pad-option-icon-selected' : ''}`}>\n      {icon}\n    </div>\n    <Title\n      heading={6}\n      className={`mouse-pad-option-title ${selected ? 'mouse-pad-option-title-selected' : ''}`}\n    >\n      {title}\n    </Title>\n    <Paragraph\n      type=\"tertiary\"\n      className={`mouse-pad-option-subTitle ${\n        selected ? 'mouse-pad-option-subTitle-selected' : ''\n      }`}\n    >\n      {subTitle}\n    </Paragraph>\n  </div>\n);\n\nexport const MousePadSelector: React.FC<\n  MousePadSelectorProps & React.RefAttributes<HTMLDivElement>\n> = ({ value, onChange, onPopupVisibleChange, containerStyle, iconStyle, arrowStyle }) => {\n  const isMouse = value === InteractiveType.Mouse;\n  const [visible, setVisible] = useState(false);\n\n  return (\n    <Popover\n      trigger=\"custom\"\n      position=\"topLeft\"\n      closeOnEsc\n      visible={visible}\n      onVisibleChange={(v) => {\n        onPopupVisibleChange?.(v);\n      }}\n      onClickOutSide={() => {\n        setVisible(false);\n      }}\n      spacing={20}\n      content={\n        <div className={'ui-mouse-pad-selector-popover'}>\n          <Typography.Title heading={4}>{'Interaction mode'}</Typography.Title>\n          <div className={'ui-mouse-pad-selector-popover-options'}>\n            <InteractiveItem\n              title={'Mouse-Friendly'}\n              subTitle={'Drag the canvas with the left mouse button, zoom with the scroll wheel.'}\n              value={InteractiveType.Mouse}\n              selected={value === InteractiveType.Mouse}\n              icon={<IconMouse />}\n              onChange={onChange}\n            />\n\n            <InteractiveItem\n              title={'Touchpad-Friendly'}\n              subTitle={\n                'Drag with two fingers moving in the same direction, zoom by pinching or spreading two fingers.'\n              }\n              value={InteractiveType.Pad}\n              selected={value === InteractiveType.Pad}\n              icon={<IconPad />}\n              onChange={onChange}\n            />\n          </div>\n        </div>\n      }\n    >\n      <div\n        className={`ui-mouse-pad-selector ${visible ? 'ui-mouse-pad-selector-active' : ''}`}\n        onClick={() => {\n          setVisible(!visible);\n        }}\n        style={containerStyle}\n      >\n        <div className={'ui-mouse-pad-selector-icon'} style={iconStyle}>\n          {isMouse ? <IconMouseTool /> : <IconPadTool />}\n        </div>\n      </div>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/readonly.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback } from 'react';\n\nimport { usePlayground } from '@flowgram.ai/free-layout-editor';\nimport { IconButton, Tooltip } from '@douyinfe/semi-ui';\nimport { IconUnlock, IconLock } from '@douyinfe/semi-icons';\n\nexport const Readonly = () => {\n  const playground = usePlayground();\n  const toggleReadonly = useCallback(() => {\n    playground.config.readonly = !playground.config.readonly;\n  }, [playground]);\n  return playground.config.readonly ? (\n    <Tooltip content=\"Editable\">\n      <IconButton\n        theme=\"borderless\"\n        type=\"tertiary\"\n        icon={<IconLock size=\"default\" />}\n        onClick={toggleReadonly}\n      />\n    </Tooltip>\n  ) : (\n    <Tooltip content=\"Readonly\">\n      <IconButton\n        theme=\"borderless\"\n        type=\"tertiary\"\n        icon={<IconUnlock size=\"default\" />}\n        onClick={toggleReadonly}\n      />\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/save.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useState, useEffect, useCallback } from 'react';\n\nimport { useClientContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';\nimport { Button, Badge } from '@douyinfe/semi-ui';\n\nexport function Save(props: { disabled: boolean }) {\n  const [errorCount, setErrorCount] = useState(0);\n  const clientContext = useClientContext();\n\n  const updateValidateData = useCallback(() => {\n    const allForms = clientContext.document.getAllNodes().map((node) => node.form);\n    const count = allForms.filter((form) => form?.state.invalid).length;\n    setErrorCount(count);\n  }, [clientContext]);\n\n  /**\n   * Validate all node and Save\n   */\n  const onSave = useCallback(async () => {\n    const allForms = clientContext.document.getAllNodes().map((node) => node.form);\n    await Promise.all(allForms.map(async (form) => form?.validate()));\n    console.log('>>>>> save data: ', clientContext.document.toJSON());\n  }, [clientContext]);\n\n  /**\n   * Listen single node validate\n   */\n  useEffect(() => {\n    const listenSingleNodeValidate = (node: FlowNodeEntity) => {\n      const { form } = node;\n      if (form) {\n        const formValidateDispose = form.onValidate(() => updateValidateData());\n        node.onDispose(() => formValidateDispose.dispose());\n      }\n    };\n    clientContext.document.getAllNodes().map((node) => listenSingleNodeValidate(node));\n    const dispose = clientContext.document.onNodeCreate(({ node }) =>\n      listenSingleNodeValidate(node)\n    );\n    return () => dispose.dispose();\n  }, [clientContext]);\n\n  if (errorCount === 0) {\n    return (\n      <Button\n        disabled={props.disabled}\n        onClick={onSave}\n        style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}\n      >\n        Save\n      </Button>\n    );\n  }\n  return (\n    <Badge count={errorCount} position=\"rightTop\" type=\"danger\">\n      <Button\n        type=\"danger\"\n        disabled={props.disabled}\n        onClick={onSave}\n        style={{ backgroundColor: 'rgba(255, 179, 171, 0.3)', borderRadius: '8px' }}\n      >\n          Save\n      </Button>\n    </Badge>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/styles.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport styled from 'styled-components';\n\nimport { IconMinimap } from '../../assets/icon-minimap';\n\nexport const ToolContainer = styled.div`\n  position: absolute;\n  bottom: 16px;\n  display: flex;\n  justify-content: left;\n  min-width: 360px;\n  pointer-events: none;\n  gap: 8px;\n\n  z-index: 20;\n`;\n\nexport const ToolSection = styled.div`\n  display: flex;\n  align-items: center;\n  background-color: #fff;\n  border: 1px solid rgba(68, 83, 130, 0.25);\n  border-radius: 10px;\n  box-shadow: rgba(0, 0, 0, 0.04) 0px 2px 6px 0px, rgba(0, 0, 0, 0.02) 0px 4px 12px 0px;\n  column-gap: 2px;\n  height: 40px;\n  padding: 0 4px;\n  pointer-events: auto;\n`;\n\nexport const SelectZoom = styled.span`\n  padding: 4px;\n  border-radius: 8px;\n  border: 1px solid rgba(68, 83, 130, 0.25);\n  font-size: 12px;\n  width: 50px;\n  cursor: pointer;\n`;\n\nexport const MinimapContainer = styled.div`\n  position: absolute;\n  bottom: 60px;\n  width: 198px;\n`;\n\nexport const UIIconMinimap = styled(IconMinimap)<{ visible: boolean }>`\n  color: ${(props) => (props.visible ? undefined : '#060709cc')};\n`;\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/switch-line.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback } from 'react';\n\nimport { useService, WorkflowLinesManager } from '@flowgram.ai/free-layout-editor';\nimport { IconButton, Tooltip } from '@douyinfe/semi-ui';\n\nimport { IconSwitchLine } from '../../assets/icon-switch-line';\n\nexport const SwitchLine = () => {\n  const linesManager = useService(WorkflowLinesManager);\n  const switchLine = useCallback(() => {\n    linesManager.switchLineType();\n  }, [linesManager]);\n\n  return (\n    <Tooltip content={'Switch Line'}>\n      <IconButton type=\"tertiary\" theme=\"borderless\" onClick={switchLine} icon={IconSwitchLine} />\n    </Tooltip>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/components/tools/zoom-select.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useState } from 'react';\n\nimport { usePlayground, usePlaygroundTools } from '@flowgram.ai/free-layout-editor';\nimport { Divider, Dropdown } from '@douyinfe/semi-ui';\n\nimport { SelectZoom } from './styles';\n\nexport const ZoomSelect = () => {\n  const tools = usePlaygroundTools({ maxZoom: 2, minZoom: 0.25 });\n  const playground = usePlayground();\n  const [dropDownVisible, openDropDown] = useState(false);\n  return (\n    <Dropdown\n      position=\"top\"\n      trigger=\"custom\"\n      visible={dropDownVisible}\n      onClickOutSide={() => openDropDown(false)}\n      render={\n        <Dropdown.Menu>\n          <Dropdown.Item onClick={() => tools.zoomin()}>Zoom in</Dropdown.Item>\n          <Dropdown.Item onClick={() => tools.zoomout()}>Zoom out</Dropdown.Item>\n          <Divider layout=\"horizontal\" />\n          <Dropdown.Item onClick={() => playground.config.updateZoom(0.5)}>\n            Zoom to 50%\n          </Dropdown.Item>\n          <Dropdown.Item onClick={() => playground.config.updateZoom(1)}>\n            Zoom to 100%\n          </Dropdown.Item>\n          <Dropdown.Item onClick={() => playground.config.updateZoom(1.5)}>\n            Zoom to 150%\n          </Dropdown.Item>\n          <Dropdown.Item onClick={() => playground.config.updateZoom(2.0)}>\n            Zoom to 200%\n          </Dropdown.Item>\n        </Dropdown.Menu>\n      }\n    >\n      <SelectZoom onClick={() => openDropDown(true)}>{Math.floor(tools.zoom * 100)}%</SelectZoom>\n    </Dropdown>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/context/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { NodeRenderContext } from './node-render-context';\nexport { IsSidebarContext } from './sidebar-context';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/context/node-render-context.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React from 'react';\n\nimport type { NodeRenderReturnType } from '@flowgram.ai/free-layout-editor';\n\ninterface INodeRenderContext extends NodeRenderReturnType {}\n\n/** 业务自定义节点上下文 */\nexport const NodeRenderContext = React.createContext<INodeRenderContext>({} as INodeRenderContext);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/context/sidebar-context.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React from 'react';\n\nexport const IsSidebarContext = React.createContext<boolean>(false);\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/data/workflow-templates.ts",
    "content": "import { FlowDocumentJSON } from '../typings';\nimport { WorkflowNodeType } from '../nodes';\nimport { FlowValueUtils } from '@flowgram.ai/form-materials';\n\nexport interface WorkflowTemplate {\n  id: string;\n  name: string;\n  description: string;\n  badge?: string;\n  inputs: Record<string, unknown>;\n  document: FlowDocumentJSON;\n}\n\nconst stringSchema = (defaultValue?: string) => ({\n  type: 'string',\n  ...(defaultValue === undefined ? {} : { default: defaultValue }),\n});\n\nconst numberSchema = (defaultValue?: number) => ({\n  type: 'number',\n  ...(defaultValue === undefined ? {} : { default: defaultValue }),\n});\n\nconst arraySchema = (items: Record<string, unknown>, defaultValue?: unknown[]) => ({\n  type: 'array',\n  items,\n  ...(defaultValue === undefined ? {} : { default: defaultValue }),\n});\n\nconst objectSchema = (\n  properties: Record<string, unknown>,\n  required: string[] = []\n): Record<string, unknown> => ({\n  type: 'object',\n  required,\n  properties,\n});\n\nconst constantValue = (content: string | number | boolean | unknown[]) => ({\n  type: 'constant' as const,\n  content,\n  schema: FlowValueUtils.inferConstantJsonSchema({\n    type: 'constant' as const,\n    content,\n  }),\n});\n\nconst refValue = (...content: string[]) => ({\n  type: 'ref' as const,\n  content,\n});\n\nconst templateValue = (content: string) => ({\n  type: 'template' as const,\n  content,\n});\n\nconst clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value));\n\nconst blankCanvasTemplate: WorkflowTemplate = {\n  id: 'blank-canvas',\n  name: '空白画布',\n  description: '仅保留 Start 与 End，适合从零开始搭建流程。',\n  badge: 'Quick Start',\n  inputs: {\n    message: 'hello-flowgram',\n  },\n  document: {\n    nodes: [\n      {\n        id: 'start_0',\n        type: WorkflowNodeType.Start,\n        meta: {\n          position: {\n            x: 120,\n            y: 260,\n          },\n        },\n        data: {\n          title: 'Start',\n          outputs: objectSchema(\n            {\n              message: stringSchema('hello-flowgram'),\n            },\n            ['message']\n          ),\n        },\n      },\n      {\n        id: 'end_0',\n        type: WorkflowNodeType.End,\n        meta: {\n          position: {\n            x: 760,\n            y: 260,\n          },\n        },\n        data: {\n          title: 'End',\n          inputs: objectSchema(\n            {\n              result: stringSchema(),\n            },\n            ['result']\n          ),\n          inputsValues: {\n            result: refValue('start_0', 'message'),\n          },\n        },\n      },\n    ],\n    edges: [\n      {\n        sourceNodeID: 'start_0',\n        targetNodeID: 'end_0',\n      },\n    ],\n  },\n};\n\nconst travelCopilotTemplate: WorkflowTemplate = {\n  id: 'travel-copilot',\n  name: '出行 Copilot',\n  description: '串联 Variable、HTTP、Tool、Code、LLM 的完整业务示例。',\n  badge: 'Default',\n  inputs: {\n    departure: '杭州',\n    destination: '上海',\n    date: '2026-04-02',\n    trainType: '高铁',\n    userGoal: '我想上午出发，优先舒适和准点，并给我一句简短建议',\n  },\n  document: {\n    nodes: [\n      {\n        id: 'start_0',\n        type: WorkflowNodeType.Start,\n        meta: {\n          position: {\n            x: 120,\n            y: 260,\n          },\n        },\n        data: {\n          title: 'Start',\n          outputs: objectSchema(\n            {\n              departure: stringSchema('杭州'),\n              destination: stringSchema('上海'),\n              date: stringSchema('2026-04-02'),\n              trainType: stringSchema('高铁'),\n              userGoal: stringSchema('我想上午出发，优先舒适和准点，并给我一句简短建议'),\n            },\n            ['departure', 'destination', 'date', 'trainType', 'userGoal']\n          ),\n        },\n      },\n      {\n        id: 'variable_0',\n        type: WorkflowNodeType.Variable,\n        meta: {\n          position: {\n            x: 420,\n            y: 220,\n          },\n        },\n        data: {\n          title: '变量整理',\n          assign: [\n            {\n              operator: 'declare',\n              left: 'serviceId',\n              right: constantValue('minimax-coding'),\n            },\n            {\n              operator: 'declare',\n              left: 'modelName',\n              right: constantValue('MiniMax-M2.1'),\n            },\n            {\n              operator: 'declare',\n              left: 'routeLabel',\n              right: templateValue('${start_0.departure} -> ${start_0.destination}'),\n            },\n            {\n              operator: 'declare',\n              left: 'travelIntent',\n              right: templateValue(\n                '${start_0.date} ${start_0.trainType} ${start_0.userGoal}'\n              ),\n            },\n          ],\n        },\n      },\n      {\n        id: 'http_0',\n        type: WorkflowNodeType.HTTP,\n        meta: {\n          position: {\n            x: 760,\n            y: 220,\n          },\n        },\n        data: {\n          title: '天气查询',\n          api: {\n            method: 'GET',\n            url: templateValue('http://127.0.0.1:18080/flowgram/demo/mock/weather'),\n          },\n          paramsValues: {\n            city: refValue('start_0', 'departure'),\n            date: refValue('start_0', 'date'),\n          },\n          headersValues: {},\n          timeout: {\n            timeout: 3000,\n            retryTimes: 1,\n          },\n          body: {\n            bodyType: 'none',\n          },\n          outputs: objectSchema(\n            {\n              body: stringSchema(),\n              statusCode: numberSchema(200),\n              headers: objectSchema({}),\n            },\n            ['body']\n          ),\n        },\n      },\n      {\n        id: 'tool_0',\n        type: WorkflowNodeType.Tool,\n        meta: {\n          position: {\n            x: 1100,\n            y: 220,\n          },\n        },\n        data: {\n          title: '车次工具',\n          inputs: objectSchema(\n            {\n              toolName: stringSchema('queryTrainInfo'),\n              argumentsJson: stringSchema('{\"type\":40}'),\n            },\n            ['toolName']\n          ),\n          inputsValues: {\n            toolName: constantValue('queryTrainInfo'),\n            argumentsJson: templateValue('{\"type\":40}'),\n          },\n          outputs: objectSchema({\n            result: stringSchema(),\n            rawOutput: stringSchema(),\n          }),\n        },\n      },\n      {\n        id: 'code_0',\n        type: WorkflowNodeType.Code,\n        meta: {\n          position: {\n            x: 1440,\n            y: 220,\n          },\n        },\n        data: {\n          title: '整流脚本',\n          inputs: objectSchema(\n            {\n              departure: stringSchema(),\n              destination: stringSchema(),\n              date: stringSchema(),\n              trainType: stringSchema(),\n              userGoal: stringSchema(),\n              routeLabel: stringSchema(),\n              travelIntent: stringSchema(),\n              weatherBody: stringSchema(),\n              toolResult: stringSchema(),\n            },\n            ['departure', 'destination', 'date', 'trainType', 'userGoal', 'weatherBody']\n          ),\n          inputsValues: {\n            departure: refValue('start_0', 'departure'),\n            destination: refValue('start_0', 'destination'),\n            date: refValue('start_0', 'date'),\n            trainType: refValue('start_0', 'trainType'),\n            userGoal: refValue('start_0', 'userGoal'),\n            routeLabel: refValue('variable_0', 'routeLabel'),\n            travelIntent: refValue('variable_0', 'travelIntent'),\n            weatherBody: refValue('http_0', 'body'),\n            toolResult: refValue('tool_0', 'result'),\n          },\n          script: {\n            language: 'javascript',\n            content:\n              \"// Merge weather, tool output and user intent into a structured LLM prompt.\\n\" +\n              \"function main(input) {\\n\" +\n              \"  var params = input && input.params ? input.params : {};\\n\" +\n              \"  var weather = {};\\n\" +\n              \"  try {\\n\" +\n              \"    weather = params.weatherBody ? JSON.parse(params.weatherBody) : {};\\n\" +\n              \"  } catch (error) {\\n\" +\n              \"    weather = { weather: '未知', temperature: '--', advice: '请手动确认天气' };\\n\" +\n              \"  }\\n\" +\n              \"  var weatherSummary = [\\n\" +\n              \"    params.departure || '',\\n\" +\n              \"    (weather.date || params.date || ''),\\n\" +\n              \"    (weather.weather || '未知天气'),\\n\" +\n              \"    (weather.temperature || '温度待确认'),\\n\" +\n              \"    (weather.advice || '请关注出行提醒')\\n\" +\n              \"  ].join(' | ');\\n\" +\n              \"  var rawTrain = String(params.toolResult || '').replace(/\\\\s+/g, ' ').trim();\\n\" +\n              \"  var trainSummary = rawTrain ? rawTrain.slice(0, 96) : '暂未获取到车次摘要';\\n\" +\n              \"  var travelBrief = '路线：' + (params.routeLabel || '') + '；偏好：' + (params.userGoal || '');\\n\" +\n              \"  var finalPrompt = [\\n\" +\n              \"    '你是一名中文出行顾问。',\\n\" +\n              \"    '请根据以下信息给出简短、可执行的建议，控制在三段以内。',\\n\" +\n              \"    '路线：' + (params.routeLabel || ''),\\n\" +\n              \"    '出发日期：' + (params.date || ''),\\n\" +\n              \"    '交通偏好：' + (params.trainType || ''),\\n\" +\n              \"    '用户目标：' + (params.userGoal || ''),\\n\" +\n              \"    '天气：' + weatherSummary,\\n\" +\n              \"    '车次参考：' + trainSummary,\\n\" +\n              \"    '请输出：1) 一句话结论 2) 出发建议 3) 注意事项'\\n\" +\n              \"  ].join('\\\\n');\\n\" +\n              \"  return {\\n\" +\n              \"    weatherSummary: weatherSummary,\\n\" +\n              \"    trainSummary: trainSummary,\\n\" +\n              \"    travelBrief: travelBrief,\\n\" +\n              \"    finalPrompt: finalPrompt\\n\" +\n              \"  };\\n\" +\n              \"}\",\n          },\n          outputs: objectSchema(\n            {\n              weatherSummary: stringSchema(),\n              trainSummary: stringSchema(),\n              travelBrief: stringSchema(),\n              finalPrompt: stringSchema(),\n            },\n            ['weatherSummary', 'trainSummary', 'travelBrief', 'finalPrompt']\n          ),\n        },\n      },\n      {\n        id: 'llm_0',\n        type: WorkflowNodeType.LLM,\n        meta: {\n          position: {\n            x: 1780,\n            y: 220,\n          },\n        },\n        data: {\n          title: '出行建议',\n          inputs: objectSchema(\n            {\n              serviceId: stringSchema('minimax-coding'),\n              modelName: stringSchema('MiniMax-M2.1'),\n              systemPrompt: stringSchema(\n                '你是一个简洁、可靠的中文出行助手，只输出和行程建议相关的内容。'\n              ),\n              prompt: stringSchema(),\n            },\n            ['modelName', 'prompt']\n          ),\n          outputs: objectSchema(\n            {\n              result: stringSchema(),\n            },\n            ['result']\n          ),\n          inputsValues: {\n            serviceId: refValue('variable_0', 'serviceId'),\n            modelName: refValue('variable_0', 'modelName'),\n            systemPrompt: templateValue(\n              '你是一个简洁、可靠的中文出行助手，只输出和行程建议相关的内容。'\n            ),\n            prompt: refValue('code_0', 'finalPrompt'),\n          },\n        },\n      },\n      {\n        id: 'end_0',\n        type: WorkflowNodeType.End,\n        meta: {\n          position: {\n            x: 2140,\n            y: 220,\n          },\n        },\n        data: {\n          title: 'End',\n          inputs: objectSchema(\n            {\n              result: stringSchema(),\n              weatherSummary: stringSchema(),\n              trainSummary: stringSchema(),\n              travelBrief: stringSchema(),\n            },\n            ['result', 'weatherSummary', 'trainSummary', 'travelBrief']\n          ),\n          inputsValues: {\n            result: refValue('llm_0', 'result'),\n            weatherSummary: refValue('code_0', 'weatherSummary'),\n            trainSummary: refValue('code_0', 'trainSummary'),\n            travelBrief: refValue('code_0', 'travelBrief'),\n          },\n        },\n      },\n    ],\n    edges: [\n      {\n        sourceNodeID: 'start_0',\n        targetNodeID: 'variable_0',\n      },\n      {\n        sourceNodeID: 'variable_0',\n        targetNodeID: 'http_0',\n      },\n      {\n        sourceNodeID: 'http_0',\n        targetNodeID: 'tool_0',\n      },\n      {\n        sourceNodeID: 'tool_0',\n        targetNodeID: 'code_0',\n      },\n      {\n        sourceNodeID: 'code_0',\n        targetNodeID: 'llm_0',\n      },\n      {\n        sourceNodeID: 'llm_0',\n        targetNodeID: 'end_0',\n      },\n    ],\n  },\n};\n\nconst conditionTemplate: WorkflowTemplate = {\n  id: 'condition-review',\n  name: '条件分支',\n  description: '演示 Condition 节点如何按输入条件走不同路径。',\n  badge: 'Branch',\n  inputs: {\n    score: 75,\n  },\n  document: {\n    nodes: [\n      {\n        id: 'start_0',\n        type: WorkflowNodeType.Start,\n        meta: {\n          position: {\n            x: 120,\n            y: 260,\n          },\n        },\n        data: {\n          title: 'Start',\n          outputs: objectSchema(\n            {\n              score: numberSchema(75),\n            },\n            ['score']\n          ),\n        },\n      },\n      {\n        id: 'condition_0',\n        type: WorkflowNodeType.Condition,\n        meta: {\n          position: {\n            x: 480,\n            y: 220,\n          },\n        },\n        data: {\n          title: 'Condition',\n          conditions: [\n            {\n              key: 'if_pass',\n              value: {\n                left: refValue('start_0', 'score'),\n                operator: 'gte',\n                right: constantValue(60),\n              },\n            },\n          ],\n        },\n      },\n      {\n        id: 'end_pass',\n        type: WorkflowNodeType.End,\n        meta: {\n          position: {\n            x: 860,\n            y: 120,\n          },\n        },\n        data: {\n          title: 'End Pass',\n          inputs: objectSchema(\n            {\n              result: stringSchema(),\n            },\n            ['result']\n          ),\n          inputsValues: {\n            result: constantValue('passed'),\n          },\n        },\n      },\n      {\n        id: 'end_fail',\n        type: WorkflowNodeType.End,\n        meta: {\n          position: {\n            x: 860,\n            y: 380,\n          },\n        },\n        data: {\n          title: 'End Fail',\n          inputs: objectSchema(\n            {\n              result: stringSchema(),\n            },\n            ['result']\n          ),\n          inputsValues: {\n            result: constantValue('failed'),\n          },\n        },\n      },\n    ],\n    edges: [\n      {\n        sourceNodeID: 'start_0',\n        targetNodeID: 'condition_0',\n      },\n      {\n        sourceNodeID: 'condition_0',\n        sourcePortID: 'if_pass',\n        targetNodeID: 'end_pass',\n      },\n      {\n        sourceNodeID: 'condition_0',\n        sourcePortID: 'else',\n        targetNodeID: 'end_fail',\n      },\n    ],\n  },\n};\n\nconst loopTemplate: WorkflowTemplate = {\n  id: 'loop-digest',\n  name: '循环汇总',\n  description: '演示 Loop 节点如何批量处理并聚合输出。',\n  badge: 'Loop',\n  inputs: {\n    cities: ['beijing', 'shanghai'],\n  },\n  document: {\n    nodes: [\n      {\n        id: 'start_0',\n        type: WorkflowNodeType.Start,\n        meta: {\n          position: {\n            x: 120,\n            y: 260,\n          },\n        },\n        data: {\n          title: 'Start',\n          outputs: objectSchema(\n            {\n              cities: arraySchema(stringSchema(), ['beijing', 'shanghai']),\n            },\n            ['cities']\n          ),\n        },\n      },\n      {\n        id: 'loop_0',\n        type: WorkflowNodeType.Loop,\n        meta: {\n          position: {\n            x: 480,\n            y: 150,\n          },\n        },\n        data: {\n          title: 'Loop Cities',\n          loopFor: refValue('start_0', 'cities'),\n          outputs: objectSchema(\n            {\n              suggestions: arraySchema(stringSchema()),\n            },\n            ['suggestions']\n          ),\n          loopOutputs: {\n            suggestions: refValue('llm_loop_0', 'result'),\n          },\n        },\n        blocks: [\n          {\n            id: 'block_start_loop_0',\n            type: WorkflowNodeType.BlockStart,\n            meta: {\n              position: {\n                x: 24,\n                y: 72,\n              },\n            },\n            data: {},\n          },\n          {\n            id: 'llm_loop_0',\n            type: WorkflowNodeType.LLM,\n            meta: {\n              position: {\n                x: 240,\n                y: 24,\n              },\n            },\n            data: {\n              title: 'LLM Inside Loop',\n              inputs: objectSchema(\n                {\n                  serviceId: stringSchema('minimax-coding'),\n                  modelName: stringSchema('MiniMax-M2.1'),\n                  prompt: {\n                    ...stringSchema(),\n                  },\n                },\n                ['modelName', 'prompt']\n              ),\n              outputs: objectSchema(\n                {\n                  result: stringSchema(),\n                },\n                ['result']\n              ),\n              inputsValues: {\n                serviceId: constantValue('minimax-coding'),\n                modelName: constantValue('MiniMax-M2.1'),\n                prompt: templateValue('Summarize {{loop_0_locals.item}} in one short phrase.'),\n              },\n            },\n          },\n          {\n            id: 'block_end_loop_0',\n            type: WorkflowNodeType.BlockEnd,\n            meta: {\n              position: {\n                x: 520,\n                y: 72,\n              },\n            },\n            data: {},\n          },\n        ],\n        edges: [\n          {\n            sourceNodeID: 'block_start_loop_0',\n            targetNodeID: 'llm_loop_0',\n          },\n          {\n            sourceNodeID: 'llm_loop_0',\n            targetNodeID: 'block_end_loop_0',\n          },\n        ],\n      },\n      {\n        id: 'end_0',\n        type: WorkflowNodeType.End,\n        meta: {\n          position: {\n            x: 980,\n            y: 260,\n          },\n        },\n        data: {\n          title: 'End',\n          inputs: objectSchema(\n            {\n              result: arraySchema(stringSchema()),\n            },\n            ['result']\n          ),\n          inputsValues: {\n            result: refValue('loop_0', 'suggestions'),\n          },\n        },\n      },\n    ],\n    edges: [\n      {\n        sourceNodeID: 'start_0',\n        targetNodeID: 'loop_0',\n      },\n      {\n        sourceNodeID: 'loop_0',\n        targetNodeID: 'end_0',\n      },\n    ],\n  },\n};\n\nconst knowledgeTemplate: WorkflowTemplate = {\n  id: 'knowledge-qa',\n  name: '知识问答',\n  description: '演示 Knowledge 节点接入向量检索后的问答链路。',\n  badge: 'Optional',\n  inputs: {\n    question: '请总结产品退款规则的要点',\n  },\n  document: {\n    nodes: [\n      {\n        id: 'start_0',\n        type: WorkflowNodeType.Start,\n        meta: {\n          position: {\n            x: 120,\n            y: 240,\n          },\n        },\n        data: {\n          title: 'Start',\n          outputs: objectSchema(\n            {\n              question: stringSchema('请总结产品退款规则的要点'),\n            },\n            ['question']\n          ),\n        },\n      },\n      {\n        id: 'knowledge_0',\n        type: WorkflowNodeType.Knowledge,\n        meta: {\n          position: {\n            x: 520,\n            y: 180,\n          },\n        },\n        data: {\n          title: '知识检索',\n          inputs: objectSchema(\n            {\n              serviceId: stringSchema('glm-coding'),\n              embeddingModel: stringSchema('embedding-3'),\n              namespace: stringSchema('default'),\n              query: stringSchema(),\n              topK: numberSchema(3),\n              delimiter: stringSchema('\\n\\n'),\n            },\n            ['serviceId', 'embeddingModel', 'namespace', 'query']\n          ),\n          inputsValues: {\n            serviceId: constantValue('glm-coding'),\n            embeddingModel: constantValue('embedding-3'),\n            namespace: constantValue('default'),\n            query: refValue('start_0', 'question'),\n            topK: constantValue(3),\n            delimiter: constantValue('\\n\\n'),\n          },\n          outputs: objectSchema({\n            context: stringSchema(),\n            count: numberSchema(0),\n            matches: arraySchema(objectSchema({})),\n          }),\n        },\n      },\n      {\n        id: 'llm_0',\n        type: WorkflowNodeType.LLM,\n        meta: {\n          position: {\n            x: 940,\n            y: 180,\n          },\n        },\n        data: {\n          title: '知识回答',\n          inputs: objectSchema(\n            {\n              serviceId: stringSchema('minimax-coding'),\n              modelName: stringSchema('MiniMax-M2.1'),\n              systemPrompt: stringSchema('请只依据提供的知识上下文回答问题。'),\n              prompt: stringSchema(),\n            },\n            ['modelName', 'prompt']\n          ),\n          inputsValues: {\n            serviceId: constantValue('minimax-coding'),\n            modelName: constantValue('MiniMax-M2.1'),\n            systemPrompt: templateValue('请只依据提供的知识上下文回答问题。'),\n            prompt: templateValue(\n              '问题：{{start_0.question}}\\n\\n知识上下文：{{knowledge_0.context}}\\n\\n请给出简洁回答。'\n            ),\n          },\n          outputs: objectSchema(\n            {\n              result: stringSchema(),\n            },\n            ['result']\n          ),\n        },\n      },\n      {\n        id: 'end_0',\n        type: WorkflowNodeType.End,\n        meta: {\n          position: {\n            x: 1340,\n            y: 240,\n          },\n        },\n        data: {\n          title: 'End',\n          inputs: objectSchema(\n            {\n              result: stringSchema(),\n              context: stringSchema(),\n            },\n            ['result']\n          ),\n          inputsValues: {\n            result: refValue('llm_0', 'result'),\n            context: refValue('knowledge_0', 'context'),\n          },\n        },\n      },\n    ],\n    edges: [\n      {\n        sourceNodeID: 'start_0',\n        targetNodeID: 'knowledge_0',\n      },\n      {\n        sourceNodeID: 'knowledge_0',\n        targetNodeID: 'llm_0',\n      },\n      {\n        sourceNodeID: 'llm_0',\n        targetNodeID: 'end_0',\n      },\n    ],\n  },\n};\n\nexport const workflowTemplates: WorkflowTemplate[] = [\n  blankCanvasTemplate,\n  travelCopilotTemplate,\n  conditionTemplate,\n  loopTemplate,\n  knowledgeTemplate,\n];\n\nexport const blankWorkflowTemplateId = blankCanvasTemplate.id;\nexport const defaultWorkflowTemplateId = travelCopilotTemplate.id;\n\nexport const getWorkflowTemplate = (templateId?: string): WorkflowTemplate =>\n  workflowTemplates.find((template) => template.id === templateId) ?? travelCopilotTemplate;\n\nexport const cloneWorkflowTemplate = (templateId?: string): WorkflowTemplate => {\n  const template = getWorkflowTemplate(templateId);\n  return {\n    ...template,\n    inputs: clone(template.inputs),\n    document: clone(template.document),\n  };\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/editor.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback, useState } from 'react';\n\nimport { FreeLayoutEditorProvider } from '@flowgram.ai/free-layout-editor';\n\nimport '@flowgram.ai/free-layout-editor/index.css';\nimport './styles/index.css';\nimport { nodeRegistries } from './nodes';\nimport { useEditorProps } from './hooks';\nimport { FlowDocumentJSON } from './typings';\nimport {\n  blankWorkflowTemplateId,\n  cloneWorkflowTemplate,\n  defaultWorkflowTemplateId,\n  getWorkflowTemplate,\n} from './data/workflow-templates';\nimport { normalizeVariableNodeOutputs } from './nodes/variable/output-schema';\nimport { WorkbenchShell } from './workbench/workbench-shell';\n\nconst DRAFT_STORAGE_KEY = 'ai4j.flowgram.workbench.draft.v2';\nconst PROMPT_EDITOR_COMPONENT = 'prompt-editor';\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n  typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst sanitizePromptEditorConfig = <T,>(value: T): T => {\n  if (Array.isArray(value)) {\n    return value.map((item) => sanitizePromptEditorConfig(item)) as T;\n  }\n  if (!isRecord(value)) {\n    return value;\n  }\n  const nextValue: Record<string, unknown> = {};\n  Object.entries(value).forEach(([key, child]) => {\n    if (\n      key === 'extra' &&\n      isRecord(child) &&\n      child.formComponent === PROMPT_EDITOR_COMPONENT\n    ) {\n      const { formComponent, ...rest } = child;\n      if (Object.keys(rest).length > 0) {\n        nextValue[key] = sanitizePromptEditorConfig(rest);\n      }\n      return;\n    }\n    nextValue[key] = sanitizePromptEditorConfig(child);\n  });\n  return nextValue as T;\n};\n\nconst sanitizeDocument = (document: FlowDocumentJSON): FlowDocumentJSON =>\n  normalizeVariableNodeOutputs(sanitizePromptEditorConfig(document));\n\nconst isUsableDocument = (document: unknown): document is FlowDocumentJSON => {\n  if (!isRecord(document)) {\n    return false;\n  }\n  const workflow = document as unknown as FlowDocumentJSON;\n  return Array.isArray(workflow.nodes) && workflow.nodes.length > 0 && Array.isArray(workflow.edges);\n};\n\nexport const Editor = () => {\n  const [documentSeed, setDocumentSeed] = useState<FlowDocumentJSON>(() => {\n    const defaultTemplate = cloneWorkflowTemplate(defaultWorkflowTemplateId);\n    const sanitizedDefaultDocument = sanitizeDocument(defaultTemplate.document);\n    if (typeof window === 'undefined') {\n      return sanitizedDefaultDocument;\n    }\n    try {\n      const saved = window.localStorage.getItem(DRAFT_STORAGE_KEY);\n      if (!saved) {\n        return sanitizedDefaultDocument;\n      }\n      const parsed = JSON.parse(saved) as FlowDocumentJSON;\n      return isUsableDocument(parsed)\n        ? sanitizeDocument(parsed)\n        : sanitizedDefaultDocument;\n    } catch (error) {\n      return sanitizedDefaultDocument;\n    }\n  });\n  const [initialInputs, setInitialInputs] = useState<Record<string, unknown>>(\n    () => cloneWorkflowTemplate(defaultWorkflowTemplateId).inputs\n  );\n  const [activeTemplateId, setActiveTemplateId] = useState<string | undefined>(\n    defaultWorkflowTemplateId\n  );\n  const [editorRevision, setEditorRevision] = useState(0);\n\n  const editorProps = useEditorProps(documentSeed, nodeRegistries);\n\n  const handleDocumentChange = useCallback((document: FlowDocumentJSON) => {\n    if (!isUsableDocument(document)) {\n      return;\n    }\n    const sanitizedDocument = sanitizeDocument(document);\n    setDocumentSeed(sanitizedDocument);\n    if (typeof window !== 'undefined') {\n      window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(sanitizedDocument));\n    }\n  }, []);\n\n  const handleImportDocument = useCallback((document: FlowDocumentJSON) => {\n    const sanitizedDocument = sanitizeDocument(document);\n    setDocumentSeed(sanitizedDocument);\n    setInitialInputs({});\n    setActiveTemplateId(undefined);\n    setEditorRevision((revision) => revision + 1);\n    if (typeof window !== 'undefined') {\n      window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(sanitizedDocument));\n    }\n  }, []);\n\n  const handleLoadTemplate = useCallback((templateId: string) => {\n    const template = cloneWorkflowTemplate(templateId);\n    const sanitizedDocument = sanitizeDocument(template.document);\n    setDocumentSeed(sanitizedDocument);\n    setInitialInputs(template.inputs);\n    setActiveTemplateId(template.id);\n    setEditorRevision((revision) => revision + 1);\n    if (typeof window !== 'undefined') {\n      window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(sanitizedDocument));\n    }\n  }, []);\n\n  const handleLoadBlank = useCallback(() => {\n    handleLoadTemplate(blankWorkflowTemplateId);\n  }, [handleLoadTemplate]);\n\n  const handleLoadDefaultTemplate = useCallback(() => {\n    handleLoadTemplate(defaultWorkflowTemplateId);\n  }, [handleLoadTemplate]);\n\n  return (\n    <div className=\"doc-free-feature-overview\">\n      <FreeLayoutEditorProvider key={`editor-${editorRevision}`} {...editorProps}>\n        <WorkbenchShell\n          activeTemplateId={activeTemplateId}\n          activeTemplate={activeTemplateId ? getWorkflowTemplate(activeTemplateId) : undefined}\n          initialInputs={initialInputs}\n          onDocumentChange={handleDocumentChange}\n          onImportDocument={handleImportDocument}\n          onLoadBlank={handleLoadBlank}\n          onLoadDefaultTemplate={handleLoadDefaultTemplate}\n          onLoadTemplate={handleLoadTemplate}\n        />\n      </FreeLayoutEditorProvider>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/feedback.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport styled from 'styled-components';\nimport { FieldError, FieldState, FieldWarning } from '@flowgram.ai/free-layout-editor';\n\ninterface StatePanelProps {\n  errors?: FieldState['errors'];\n  warnings?: FieldState['warnings'];\n  invalid?: boolean;\n}\n\nconst Error = styled.span`\n  font-size: 12px;\n  color: red;\n`;\n\nconst Warning = styled.span`\n  font-size: 12px;\n  color: orange;\n`;\n\nexport const Feedback = ({ errors, warnings, invalid }: StatePanelProps) => {\n  const renderFeedbacks = (fs: FieldError[] | FieldWarning[] | undefined) => {\n    if (!fs) return null;\n    return fs.map((f) => <span key={f.name}>{f.message}</span>);\n  };\n  return (\n    <div>\n      <div>\n        <Error>{renderFeedbacks(errors)}</Error>\n      </div>\n      <div>\n        <Warning>{renderFeedbacks(warnings)}</Warning>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-content/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React from 'react';\n\nimport { FlowNodeRegistry } from '@flowgram.ai/free-layout-editor';\n\nimport { useIsSidebar, useNodeRenderContext } from '../../hooks';\nimport { FormTitleDescription, FormWrapper } from './styles';\n\n/**\n * @param props\n * @constructor\n */\nexport function FormContent(props: { children?: React.ReactNode }) {\n  const { node, expanded } = useNodeRenderContext();\n  const isSidebar = useIsSidebar();\n  const registry = node.getNodeRegistry<FlowNodeRegistry>();\n  return (\n    <FormWrapper>\n      <>\n        {isSidebar && <FormTitleDescription>{registry.info?.description}</FormTitleDescription>}\n        {(expanded || isSidebar) && props.children}\n      </>\n    </FormWrapper>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-content/styles.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport styled from 'styled-components';\n\nexport const FormWrapper = styled.div`\n  box-sizing: border-box;\n  width: 100%;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  background-color: rgb(251, 251, 251);\n  border-radius: 0 0 8px 8px;\n  padding: 0 12px 12px;\n`;\n\nexport const FormTitleDescription = styled.div`\n  color: var(--semi-color-text-2);\n  font-size: 12px;\n  line-height: 20px;\n  padding: 0px 4px;\n  word-break: break-all;\n  white-space: break-spaces;\n`;\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-header/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useState, useEffect } from 'react';\n\nimport { useClientContext, CommandService } from '@flowgram.ai/free-layout-editor';\nimport { Button } from '@douyinfe/semi-ui';\nimport { IconClose, IconSmallTriangleDown, IconSmallTriangleLeft } from '@douyinfe/semi-icons';\n\nimport { toggleLoopExpanded } from '../../utils';\nimport { FlowCommandId } from '../../shortcuts';\nimport { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';\nimport { useIsSidebar, useNodeRenderContext } from '../../hooks';\nimport { NodeMenu } from '../../components/node-menu';\nimport { getIcon } from './utils';\nimport { TitleInput } from './title-input';\nimport { Header, Operators } from './styles';\n\nexport function FormHeader() {\n  const { node, expanded, toggleExpand, readonly } = useNodeRenderContext();\n  const [titleEdit, updateTitleEdit] = useState<boolean>(false);\n  const ctx = useClientContext();\n  const isSidebar = useIsSidebar();\n  const handleExpand = (e: React.MouseEvent) => {\n    toggleExpand();\n    e.stopPropagation(); // Disable clicking prevents the sidebar from opening\n  };\n  const { close: closePanel } = useNodeFormPanel();\n  const handleDelete = () => {\n    ctx.get<CommandService>(CommandService).executeCommand(FlowCommandId.DELETE, [node]);\n  };\n  const handleClose = () => {\n    closePanel();\n  };\n  useEffect(() => {\n    // 折叠 loop 子节点\n    if (node.flowNodeType === 'loop') {\n      toggleLoopExpanded(node, expanded);\n    }\n  }, [expanded]);\n\n  return (\n    <Header>\n      {getIcon(node)}\n      <TitleInput readonly={readonly} updateTitleEdit={updateTitleEdit} titleEdit={titleEdit} />\n      {node.renderData.expandable && !isSidebar && (\n        <Button\n          type=\"primary\"\n          icon={expanded ? <IconSmallTriangleDown /> : <IconSmallTriangleLeft />}\n          size=\"small\"\n          theme=\"borderless\"\n          onClick={handleExpand}\n        />\n      )}\n      {readonly ? undefined : (\n        <Operators>\n          <NodeMenu node={node} deleteNode={handleDelete} updateTitleEdit={updateTitleEdit} />\n        </Operators>\n      )}\n      {isSidebar && (\n        <Button\n          type=\"primary\"\n          icon={<IconClose />}\n          size=\"small\"\n          theme=\"borderless\"\n          onClick={handleClose}\n        />\n      )}\n    </Header>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-header/styles.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport styled from 'styled-components';\n\nexport const Header = styled.div`\n  box-sizing: border-box;\n  display: flex;\n  justify-content: flex-start;\n  align-items: center;\n  width: 100%;\n  column-gap: 8px;\n  border-radius: 8px 8px 0 0;\n  cursor: move;\n\n  background: linear-gradient(#f2f2ff 0%, rgb(251, 251, 251) 100%);\n  overflow: hidden;\n\n  padding: 8px;\n`;\n\nexport const Title = styled.div`\n  font-size: 20px;\n  flex: 1;\n  width: 0;\n`;\n\nexport const Icon = styled.img`\n  width: 24px;\n  height: 24px;\n  scale: 0.8;\n  border-radius: 4px;\n`;\n\nexport const Operators = styled.div`\n  display: flex;\n  align-items: center;\n  column-gap: 4px;\n`;\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-header/title-input.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useRef, useEffect } from 'react';\n\nimport { Field, FieldRenderProps } from '@flowgram.ai/free-layout-editor';\nimport { Typography, Input } from '@douyinfe/semi-ui';\n\nimport { Title } from './styles';\nimport { Feedback } from '../feedback';\nconst { Text } = Typography;\n\nexport function TitleInput(props: {\n  readonly: boolean;\n  titleEdit: boolean;\n  updateTitleEdit: (setEdit: boolean) => void;\n}): JSX.Element {\n  const { readonly, titleEdit, updateTitleEdit } = props;\n  const ref = useRef<any>();\n  const titleEditing = titleEdit && !readonly;\n  useEffect(() => {\n    if (titleEditing) {\n      ref.current?.focus();\n    }\n  }, [titleEditing]);\n\n  return (\n    <Title>\n      <Field name=\"title\">\n        {({ field: { value, onChange }, fieldState }: FieldRenderProps<string>) => (\n          <div style={{ height: 24 }}>\n            {titleEditing ? (\n              <Input\n                value={value}\n                onChange={onChange}\n                ref={ref}\n                onBlur={() => updateTitleEdit(false)}\n              />\n            ) : (\n              <Text ellipsis={{ showTooltip: true }}>{value}</Text>\n            )}\n            <Feedback errors={fieldState?.errors} />\n          </div>\n        )}\n      </Field>\n    </Title>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-header/utils.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { type FlowNodeEntity } from '@flowgram.ai/free-layout-editor';\n\nimport { FlowNodeRegistry } from '../../typings';\nimport { Icon } from './styles';\n\nexport const getIcon = (node: FlowNodeEntity) => {\n  const icon = node.getNodeRegistry<FlowNodeRegistry>().info?.icon;\n  if (!icon) return null;\n  return <Icon src={icon} />;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-inputs/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { DynamicValueInput } from '@flowgram.ai/form-materials';\n\nimport { FormItem } from '../form-item';\nimport { Feedback } from '../feedback';\nimport { JsonSchema } from '../../typings';\nimport { useNodeRenderContext } from '../../hooks';\n\nexport function FormInputs() {\n  const { readonly } = useNodeRenderContext();\n\n  return (\n    <Field<JsonSchema> name=\"inputs\">\n      {({ field: inputsField }) => {\n        const required = inputsField.value?.required || [];\n        const properties = inputsField.value?.properties;\n        if (!properties) {\n          return <></>;\n        }\n        const content = Object.keys(properties).map((key) => {\n          const property = properties[key];\n\n          return (\n            <Field key={key} name={`inputsValues.${key}`} defaultValue={property.default}>\n              {({ field, fieldState }) => (\n                <FormItem\n                  name={key}\n                  vertical={false}\n                  type={property.type as string}\n                  required={required.includes(key)}\n                >\n                  <DynamicValueInput\n                    value={field.value}\n                    onChange={field.onChange}\n                    readonly={readonly}\n                    hasError={Object.keys(fieldState?.errors || {}).length > 0}\n                    schema={property}\n                  />\n                  <Feedback errors={fieldState?.errors} warnings={fieldState?.warnings} />\n                </FormItem>\n              )}\n            </Field>\n          );\n        });\n        return <>{content}</>;\n      }}\n    </Field>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-inputs/styles.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n// import styled from 'styled-components';\n\n// TODO\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-item/index.css",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.form-item-type-tag {\n  color: inherit;\n  padding: 0 2px;\n  height: 18px;\n  width: 18px;\n  vertical-align: middle;\n  flex-shrink: 0;\n  flex-grow: 0;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/form-item/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport React, { useCallback } from 'react';\n\nimport { DisplaySchemaTag } from '@flowgram.ai/form-materials';\nimport { Typography, Tooltip } from '@douyinfe/semi-ui';\n\nimport './index.css';\n\nconst { Text } = Typography;\n\ninterface FormItemProps {\n  children: React.ReactNode;\n  name: string;\n  type?: string;\n  required?: boolean;\n  description?: string;\n  labelWidth?: number;\n  labelStyle?: React.CSSProperties;\n  vertical?: boolean;\n  style?: React.CSSProperties;\n}\nexport function FormItem({\n  children,\n  name,\n  required,\n  description,\n  type,\n  labelWidth,\n  labelStyle,\n  vertical,\n  style,\n}: FormItemProps): JSX.Element {\n  const renderTitle = useCallback(\n    (showTooltip?: boolean) => (\n      <div style={{ width: '0', display: 'flex', flex: '1' }}>\n        <Text style={{ width: '100%' }} ellipsis={{ showTooltip: !!showTooltip }}>\n          {name}\n          {required && <span style={{ color: '#f93920', paddingLeft: '2px' }}>*</span>}\n        </Text>\n      </div>\n    ),\n    []\n  );\n  return (\n    <div\n      style={{\n        fontSize: 12,\n        marginBottom: 6,\n        width: '100%',\n        position: 'relative',\n        display: 'flex',\n        gap: 8,\n        ...(vertical\n          ? { flexDirection: 'column' }\n          : {\n              justifyContent: 'center',\n              alignItems: 'center',\n            }),\n        ...style,\n      }}\n    >\n      <div\n        style={{\n          justifyContent: 'center',\n          alignItems: 'center',\n          color: 'var(--semi-color-text-0)',\n          width: labelWidth || 118,\n          minWidth: labelWidth || 118,\n          maxWidth: labelWidth || 118,\n          position: 'relative',\n          display: 'flex',\n          columnGap: 4,\n          flexShrink: 0,\n          ...labelStyle,\n        }}\n      >\n        {type && <DisplaySchemaTag value={{ type }} />}\n        {description ? <Tooltip content={description}>{renderTitle()}</Tooltip> : renderTitle(true)}\n      </div>\n\n      <div\n        style={{\n          flexGrow: 1,\n          minWidth: 0,\n        }}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/form-components/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport * from './feedback';\nexport * from './form-content';\nexport * from './form-inputs';\nexport * from './form-header';\nexport * from './form-item';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/hooks/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { useEditorProps } from './use-editor-props';\nexport { useNodeRenderContext } from './use-node-render-context';\nexport { useIsSidebar } from './use-is-sidebar';\nexport { usePortClick } from './use-port-click';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/hooks/use-editor-props.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n/* eslint-disable no-console */\nimport { useMemo } from 'react';\n\nimport { debounce } from 'lodash-es';\nimport { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';\nimport { createFreeStackPlugin } from '@flowgram.ai/free-stack-plugin';\nimport { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';\nimport { createFreeNodePanelPlugin } from '@flowgram.ai/free-node-panel-plugin';\nimport { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin';\nimport {\n  FlowNodeBaseType,\n  FreeLayoutPluginContext,\n  FreeLayoutProps,\n  WorkflowNodeEntity,\n} from '@flowgram.ai/free-layout-editor';\nimport { createFreeGroupPlugin } from '@flowgram.ai/free-group-plugin';\nimport { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';\nimport { createDownloadPlugin } from '@flowgram.ai/export-plugin';\n\nimport { canContainNode, onDragLineEnd } from '../utils';\nimport { FlowNodeRegistry, FlowDocumentJSON } from '../typings';\nimport { shortcuts } from '../shortcuts';\nimport { CustomService, ValidateService } from '../services';\nimport { WorkflowRuntimeService } from '../plugins/runtime-plugin/runtime-service';\nimport {\n  createRuntimePlugin,\n  createContextMenuPlugin,\n  createVariablePanelPlugin,\n  createPanelManagerPlugin,\n} from '../plugins';\nimport { defaultFormMeta } from '../nodes/default-form-meta';\nimport { WorkflowNodeType } from '../nodes';\nimport { SelectorBoxPopover } from '../components/selector-box-popover';\nimport { BaseNode, CommentRender, GroupNodeRender, LineAddButton, NodePanel } from '../components';\n\nexport function useEditorProps(\n  initialData: FlowDocumentJSON,\n  nodeRegistries: FlowNodeRegistry[]\n): FreeLayoutProps {\n  return useMemo<FreeLayoutProps>(\n    () => ({\n      /**\n       * Whether to enable the background\n       */\n      background: true,\n      /**\n       * 画布相关配置\n       * Canvas-related configurations\n       */\n      playground: {\n        /**\n         * Prevent Mac browser gestures from turning pages\n         * 阻止 mac 浏览器手势翻页\n         */\n        preventGlobalGesture: true,\n      },\n      /**\n       * Whether it is read-only or not, the node cannot be dragged in read-only mode\n       */\n      readonly: false,\n      /**\n       * Line support both-way connection (default true)\n       * 线条支持双向连接\n       */\n      twoWayConnection: true,\n      /**\n       * Initial data\n       * 初始化数据\n       */\n      initialData,\n      /**\n       * Node registries\n       * 节点注册\n       */\n      nodeRegistries,\n      /**\n       * Get the default node registry, which will be merged with the 'nodeRegistries'\n       * 提供默认的节点注册，这个会和 nodeRegistries 做合并\n       */\n      getNodeDefaultRegistry(type) {\n        return {\n          type,\n          meta: {\n            defaultExpanded: true,\n          },\n          formMeta: defaultFormMeta,\n        };\n      },\n      /**\n       * 节点数据转换, 由 ctx.document.fromJSON 调用\n       * Node data transformation, called by ctx.document.fromJSON\n       * @param node\n       * @param json\n       */\n      fromNodeJSON(node, json) {\n        return json;\n      },\n      /**\n       * 节点数据转换, 由 ctx.document.toJSON 调用\n       * Node data transformation, called by ctx.document.toJSON\n       * @param node\n       * @param json\n       */\n      toNodeJSON(node, json) {\n        return json;\n      },\n      lineColor: {\n        hidden: 'var(--g-workflow-line-color-hidden,transparent)',\n        default: 'var(--g-workflow-line-color-default,#4d53e8)',\n        drawing: 'var(--g-workflow-line-color-drawing, #5DD6E3)',\n        hovered: 'var(--g-workflow-line-color-hover,#37d0ff)',\n        selected: 'var(--g-workflow-line-color-selected,#37d0ff)',\n        error: 'var(--g-workflow-line-color-error,red)',\n        flowing: 'var(--g-workflow-line-color-flowing,#4d53e8)',\n      },\n      /*\n       * Check whether the line can be added\n       * 判断是否连线\n       */\n      canAddLine(ctx, fromPort, toPort) {\n        // Cannot be a self-loop on the same node / 不能是同一节点自循环\n        if (fromPort.node === toPort.node) {\n          return false;\n        }\n        // Cannot be in different containers - 不能在不同容器\n        if (\n          fromPort.node.parent?.id !== toPort.node.parent?.id &&\n          ![fromPort.node.parent?.flowNodeType, toPort.node.parent?.flowNodeType].includes(\n            FlowNodeBaseType.GROUP\n          )\n        ) {\n          return false;\n        }\n        /**\n         * 线条环检测，不允许连接到前面的节点\n         * Line loop detection, which is not allowed to connect to the node in front of it\n         */\n        return !fromPort.node.lines.allInputNodes.includes(toPort.node);\n      },\n      /**\n       * Check whether the line can be deleted, this triggers on the default shortcut `Bakspace` or `Delete`\n       * 判断是否能删除连线, 这个会在默认快捷键 (Backspace or Delete) 触发\n       */\n      canDeleteLine(ctx, line, newLineInfo, silent) {\n        return true;\n      },\n      /**\n       * Check whether the node can be deleted, this triggers on the default shortcut `Bakspace` or `Delete`\n       * 判断是否能删除节点, 这个会在默认快捷键 (Backspace or Delete) 触发\n       */\n      canDeleteNode(ctx, node) {\n        return true;\n      },\n      /**\n       * 是否允许拖入子画布 (loop or group)\n       * Whether to allow dragging into the sub-canvas (loop or group)\n       */\n      canDropToNode: (ctx, params) => canContainNode(params.dragNodeType!, params.dropNodeType!),\n      /**\n       * Whether to reset line\n       * 是否允许重连\n       * @param ctx\n       * @param oldLine\n       * @param newLineInfo\n       */\n      canResetLine: (ctx, oldLine, newLineInfo) => true,\n      /**\n       * Drag the end of the line to create an add panel (feature optional)\n       * 拖拽线条结束需要创建一个添加面板 （功能可选）\n       * 希望提供控制线条粗细的配置项\n       */\n      onDragLineEnd,\n      /**\n       * SelectBox config\n       */\n      selectBox: {\n        SelectorBoxPopover,\n      },\n      scroll: {\n        /**\n         * Whether to restrict the node from rolling out of the canvas needs to be closed because there is a running results pane\n         * 是否限制节点不能滚出画布，由于有运行结果面板，所以需要关闭\n         */\n        enableScrollLimit: false,\n      },\n      materials: {\n        components: {},\n        /**\n         * Render Node\n         */\n        renderDefaultNode: BaseNode,\n        renderNodes: {\n          [WorkflowNodeType.Comment]: CommentRender,\n        },\n      },\n      /**\n       * Node engine enable, you can configure formMeta in the FlowNodeRegistry\n       */\n      nodeEngine: {\n        enable: true,\n      },\n      /**\n       * Variable engine enable\n       */\n      variableEngine: {\n        enable: true,\n      },\n      /**\n       * Redo/Undo enable\n       */\n      history: {\n        enable: true,\n        /**\n         * Listen form data change, default true\n         */\n        enableChangeNode: true,\n      },\n      /**\n       * Content change\n       */\n      onContentChange: debounce((ctx: FreeLayoutPluginContext, event) => {\n        if (ctx.document.disposed) return;\n        void event;\n      }, 1000),\n      /**\n       * Running line\n       */\n      isFlowingLine: (ctx, line) => ctx.get(WorkflowRuntimeService).isFlowingLine(line),\n      /**\n       * Shortcuts\n       */\n      shortcuts,\n      /**\n       * Bind custom service\n       */\n      onBind: ({ bind }) => {\n        bind(CustomService).toSelf().inSingletonScope();\n        bind(ValidateService).toSelf().inSingletonScope();\n      },\n      /**\n       * Playground init\n       */\n      onInit(ctx) {\n        void ctx;\n      },\n      /**\n       * Playground render\n       */\n      onAllLayersRendered(ctx) {\n        // ctx.tools.autoLayout(); // init auto layout\n        ctx.tools.fitView(false);\n      },\n      /**\n       * Playground dispose\n       */\n      onDispose() {\n        return undefined;\n      },\n      i18n: {\n        locale: navigator.language,\n        languages: {\n          'zh-CN': {\n            'Never Remind': '不再提示',\n            'Hold {{key}} to drag node out': '按住 {{key}} 可以将节点拖出',\n          },\n          'en-US': {},\n        },\n      },\n      plugins: () => [\n        /**\n         * Custom node sorting, the code below will make the comment nodes always below the normal nodes\n         * 自定义节点排序，下边的代码会让 comment 节点永远在普通节点下边\n         */\n        createFreeStackPlugin({\n          sortNodes: (nodes: WorkflowNodeEntity[]) => {\n            const commentNodes: WorkflowNodeEntity[] = [];\n            const otherNodes: WorkflowNodeEntity[] = [];\n            nodes.forEach((node) => {\n              if (node.flowNodeType === WorkflowNodeType.Comment) {\n                commentNodes.push(node);\n              } else {\n                otherNodes.push(node);\n              }\n            });\n            return [...commentNodes, ...otherNodes];\n          },\n        }),\n        /**\n         * Line render plugin\n         * 连线渲染插件\n         */\n        createFreeLinesPlugin({\n          renderInsideLine: LineAddButton,\n        }),\n        /**\n         * Minimap plugin\n         * 缩略图插件\n         */\n        createMinimapPlugin({\n          disableLayer: true,\n          canvasStyle: {\n            canvasWidth: 182,\n            canvasHeight: 102,\n            canvasPadding: 50,\n            canvasBackground: 'rgba(242, 243, 245, 1)',\n            canvasBorderRadius: 10,\n            viewportBackground: 'rgba(255, 255, 255, 1)',\n            viewportBorderRadius: 4,\n            viewportBorderColor: 'rgba(6, 7, 9, 0.10)',\n            viewportBorderWidth: 1,\n            viewportBorderDashLength: undefined,\n            nodeColor: 'rgba(0, 0, 0, 0.10)',\n            nodeBorderRadius: 2,\n            nodeBorderWidth: 0.145,\n            nodeBorderColor: 'rgba(6, 7, 9, 0.10)',\n            overlayColor: 'rgba(255, 255, 255, 0.55)',\n          },\n        }),\n        /**\n         * Download plugin\n         * 下载插件\n         */\n        createDownloadPlugin({}),\n        /**\n         * Snap plugin\n         * 自动对齐及辅助线插件\n         */\n        createFreeSnapPlugin({\n          edgeColor: '#00B2B2',\n          alignColor: '#00B2B2',\n          edgeLineWidth: 1,\n          alignLineWidth: 1,\n          alignCrossWidth: 8,\n        }),\n        /**\n         * NodeAddPanel render plugin\n         * 节点添加面板渲染插件\n         */\n        createFreeNodePanelPlugin({\n          renderer: NodePanel,\n        }),\n        /**\n         * This is used for the rendering of the loop node sub-canvas\n         * 这个用于 loop 节点子画布的渲染\n         */\n        createContainerNodePlugin({}),\n        /**\n         * Group plugin\n         */\n        createFreeGroupPlugin({\n          groupNodeRender: GroupNodeRender,\n        }),\n        /**\n         * ContextMenu plugin\n         */\n        createContextMenuPlugin({}),\n        /**\n         * Runtime plugin\n         * ⚠️ Browser mode is for demo only; for production, please deploy the server-side runtime\n         * https://flowgram.ai/guide/runtime/introduction.html\n         */\n        createRuntimePlugin({\n          mode: 'server',\n          serverConfig: {\n            domain: '',\n          },\n        }),\n\n        /**\n         * Variable panel plugin\n         * 变量面板插件\n         */\n        createVariablePanelPlugin({\n          initialData: initialData.globalVariable,\n        }),\n        /** Float layout plugin */\n        createPanelManagerPlugin(),\n      ],\n    }),\n    [initialData, nodeRegistries]\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/hooks/use-is-sidebar.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useContext } from 'react';\n\nimport { IsSidebarContext } from '../context';\n\nexport function useIsSidebar() {\n  return useContext(IsSidebarContext);\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/hooks/use-node-render-context.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useContext } from 'react';\n\nimport { NodeRenderContext } from '../context';\n\nexport function useNodeRenderContext() {\n  return useContext(NodeRenderContext);\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/hooks/use-port-click.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useCallback, useState } from 'react';\n\nimport {\n  WorkflowNodePanelService,\n  WorkflowNodePanelUtils,\n  type CallNodePanelParams,\n  type NodePanelResult,\n} from '@flowgram.ai/free-node-panel-plugin';\nimport {\n  delay,\n  usePlayground,\n  useService,\n  WorkflowDocument,\n  WorkflowDragService,\n  WorkflowLinesManager,\n  WorkflowNodeEntity,\n  WorkflowNodeJSON,\n  WorkflowPortEntity,\n} from '@flowgram.ai/free-layout-editor';\n\n/**\n * click port to trigger node select panel\n * 点击端口后唤起节点选择面板\n */\nexport const usePortClick = () => {\n  const playground = usePlayground();\n  const nodePanelService = useService(WorkflowNodePanelService);\n  const document = useService(WorkflowDocument);\n  const dragService = useService(WorkflowDragService);\n  const linesManager = useService(WorkflowLinesManager);\n  const [active, setActive] = useState(false);\n\n  const singleSelectNodePanel = useCallback(\n    async (\n      params: Omit<CallNodePanelParams, 'onSelect' | 'onClose' | 'enableMultiAdd'>\n    ): Promise<NodePanelResult | undefined> => {\n      if (active) {\n        return;\n      }\n      setActive(true);\n      return new Promise((resolve) => {\n        nodePanelService.callNodePanel({\n          ...params,\n          enableMultiAdd: false,\n          onSelect: async (panelParams?: NodePanelResult) => {\n            resolve(panelParams);\n          },\n          onClose: () => {\n            setActive(false);\n            resolve(undefined);\n          },\n        });\n      });\n    },\n    [active]\n  );\n\n  const onPortClick = useCallback(\n    async (e: React.MouseEvent, port: WorkflowPortEntity) => {\n      if (port.portType === 'input') return;\n      const mousePos = playground.config.getPosFromMouseEvent(e);\n      const containerNode = port.node.parent;\n      // open node selection panel - 打开节点选择面板\n      const result = await singleSelectNodePanel({\n        position: mousePos,\n        containerNode,\n        panelProps: {\n          enableScrollClose: true,\n          fromPort: port,\n        },\n      });\n\n      // return if no node selected - 如果没有选择节点则返回\n      if (!result) {\n        return;\n      }\n\n      // get selected node type and data - 获取选择的节点类型和数据\n      const { nodeType, nodeJSON } = result;\n\n      // calculate position for the new node - 计算新节点的位置\n      const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({\n        nodeType,\n        position:\n          port.location === 'bottom'\n            ? {\n                x: mousePos.x,\n                y: mousePos.y + 100,\n              }\n            : {\n                x: mousePos.x + 100,\n                y: mousePos.y,\n              },\n        fromPort: port,\n        containerNode,\n        document,\n        dragService,\n      });\n\n      // create new workflow node - 创建新的工作流节点\n      const node: WorkflowNodeEntity = document.createWorkflowNodeByType(\n        nodeType,\n        nodePosition,\n        nodeJSON ?? ({} as WorkflowNodeJSON),\n        containerNode?.id\n      );\n\n      // wait for node render - 等待节点渲染\n      await delay(20);\n\n      // build connection line - 构建连接线\n      WorkflowNodePanelUtils.buildLine({\n        fromPort: port,\n        node,\n        linesManager,\n      });\n    },\n    [singleSelectNodePanel]\n  );\n\n  return onPortClick;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { Editor as DemoFreeLayout } from './editor';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/initial-data.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowDocumentJSON } from './typings';\n\nexport const initialData: FlowDocumentJSON = {\n  nodes: [\n    {\n      id: 'start_0',\n      type: 'start',\n      meta: {\n        position: {\n          x: 180,\n          y: 601.2,\n        },\n      },\n      data: {\n        title: 'Start',\n        outputs: {\n          type: 'object',\n          properties: {\n            query: {\n              type: 'string',\n              default: 'Hello Flow.',\n            },\n            enable: {\n              type: 'boolean',\n              default: true,\n            },\n            array_obj: {\n              type: 'array',\n              items: {\n                type: 'object',\n                properties: {\n                  int: {\n                    type: 'number',\n                  },\n                  str: {\n                    type: 'string',\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    },\n    {\n      id: 'condition_0',\n      type: 'condition',\n      meta: {\n        position: {\n          x: 1100,\n          y: 546.2,\n        },\n      },\n      data: {\n        title: 'Condition',\n        conditions: [\n          {\n            key: 'if_0',\n            value: {\n              left: {\n                type: 'ref',\n                content: ['start_0', 'query'],\n              },\n              operator: 'contains',\n              right: {\n                type: 'constant',\n                content: 'Hello Flow.',\n              },\n            },\n          },\n        ],\n      },\n    },\n    {\n      id: 'end_0',\n      type: 'end',\n      meta: {\n        position: {\n          x: 2968,\n          y: 601.2,\n        },\n      },\n      data: {\n        title: 'End',\n        inputsValues: {\n          success: {\n            type: 'constant',\n            content: true,\n            schema: {\n              type: 'boolean',\n            },\n          },\n          query: {\n            type: 'ref',\n            content: ['start_0', 'query'],\n          },\n        },\n        inputs: {\n          type: 'object',\n          properties: {\n            success: {\n              type: 'boolean',\n            },\n            query: {\n              type: 'string',\n            },\n          },\n        },\n      },\n    },\n    {\n      id: '159623',\n      type: 'comment',\n      meta: {\n        position: {\n          x: 180,\n          y: 775.2,\n        },\n      },\n      data: {\n        size: {\n          width: 240,\n          height: 150,\n        },\n        note: 'hi ~\\n\\nthis is a comment node\\n\\n- flowgram.ai',\n      },\n    },\n    {\n      id: 'http_rDGIH',\n      type: 'http',\n      meta: {\n        position: {\n          x: 640,\n          y: 421.35,\n        },\n      },\n      data: {\n        title: 'HTTP_1',\n        outputs: {\n          type: 'object',\n          properties: {\n            body: {\n              type: 'string',\n            },\n            headers: {\n              type: 'object',\n            },\n            statusCode: {\n              type: 'integer',\n            },\n          },\n        },\n        api: {\n          method: 'GET',\n          url: {\n            type: 'template',\n            content: '',\n          },\n        },\n        body: {\n          bodyType: 'JSON',\n        },\n        timeout: {\n          timeout: 10000,\n          retryTimes: 1,\n        },\n      },\n    },\n    {\n      id: 'loop_Ycnsk',\n      type: 'loop',\n      meta: {\n        position: {\n          x: 1460,\n          y: 0,\n        },\n      },\n      data: {\n        title: 'Loop_1',\n        loopFor: {\n          type: 'ref',\n          content: ['start_0', 'array_obj'],\n        },\n        loopOutputs: {\n          acm: {\n            type: 'ref',\n            content: ['llm_6aSyo', 'result'],\n          },\n        },\n        outputs: {\n          type: 'object',\n          required: [],\n          properties: {\n            acm: {\n              type: 'array',\n              items: {\n                type: 'string',\n              },\n            },\n          },\n        },\n      },\n      blocks: [\n        {\n          id: 'llm_6aSyo',\n          type: 'llm',\n          meta: {\n            position: {\n              x: 344,\n              y: 0,\n            },\n          },\n          data: {\n            title: 'LLM_3',\n            inputsValues: {\n              modelName: {\n                type: 'constant',\n                content: 'gpt-3.5-turbo',\n              },\n              apiKey: {\n                type: 'constant',\n                content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',\n              },\n              apiHost: {\n                type: 'constant',\n                content: 'https://mock-ai-url/api/v3',\n              },\n              temperature: {\n                type: 'constant',\n                content: 0.5,\n              },\n              systemPrompt: {\n                type: 'template',\n                content: '# Role\\nYou are an AI assistant.\\n',\n              },\n              prompt: {\n                type: 'template',\n                content: '',\n              },\n            },\n            inputs: {\n              type: 'object',\n              required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],\n              properties: {\n                modelName: {\n                  type: 'string',\n                },\n                apiKey: {\n                  type: 'string',\n                },\n                apiHost: {\n                  type: 'string',\n                },\n                temperature: {\n                  type: 'number',\n                },\n                systemPrompt: {\n                  type: 'string',\n                  extra: {\n                    formComponent: 'prompt-editor',\n                  },\n                },\n                prompt: {\n                  type: 'string',\n                  extra: {\n                    formComponent: 'prompt-editor',\n                  },\n                },\n              },\n            },\n            outputs: {\n              type: 'object',\n              properties: {\n                result: {\n                  type: 'string',\n                },\n              },\n            },\n          },\n        },\n        {\n          id: 'llm_ZqKlP',\n          type: 'llm',\n          meta: {\n            position: {\n              x: 804,\n              y: 0,\n            },\n          },\n          data: {\n            title: 'LLM_4',\n            inputsValues: {\n              modelName: {\n                type: 'constant',\n                content: 'gpt-3.5-turbo',\n              },\n              apiKey: {\n                type: 'constant',\n                content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',\n              },\n              apiHost: {\n                type: 'constant',\n                content: 'https://mock-ai-url/api/v3',\n              },\n              temperature: {\n                type: 'constant',\n                content: 0.5,\n              },\n              systemPrompt: {\n                type: 'template',\n                content: '# Role\\nYou are an AI assistant.\\n',\n              },\n              prompt: {\n                type: 'template',\n                content: '',\n              },\n            },\n            inputs: {\n              type: 'object',\n              required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],\n              properties: {\n                modelName: {\n                  type: 'string',\n                },\n                apiKey: {\n                  type: 'string',\n                },\n                apiHost: {\n                  type: 'string',\n                },\n                temperature: {\n                  type: 'number',\n                },\n                systemPrompt: {\n                  type: 'string',\n                  extra: {\n                    formComponent: 'prompt-editor',\n                  },\n                },\n                prompt: {\n                  type: 'string',\n                  extra: {\n                    formComponent: 'prompt-editor',\n                  },\n                },\n              },\n            },\n            outputs: {\n              type: 'object',\n              properties: {\n                result: {\n                  type: 'string',\n                },\n              },\n            },\n          },\n        },\n        {\n          id: 'block_start_PUDtS',\n          type: 'block-start',\n          meta: {\n            position: {\n              x: 32,\n              y: 167.1,\n            },\n          },\n          data: {},\n        },\n        {\n          id: 'block_end_leBbs',\n          type: 'block-end',\n          meta: {\n            position: {\n              x: 1116,\n              y: 167.1,\n            },\n          },\n          data: {},\n        },\n      ],\n      edges: [\n        {\n          sourceNodeID: 'block_start_PUDtS',\n          targetNodeID: 'llm_6aSyo',\n        },\n        {\n          sourceNodeID: 'llm_6aSyo',\n          targetNodeID: 'llm_ZqKlP',\n        },\n        {\n          sourceNodeID: 'llm_ZqKlP',\n          targetNodeID: 'block_end_leBbs',\n        },\n      ],\n    },\n    {\n      id: 'group_nYl6D',\n      type: 'group',\n      meta: {\n        position: {\n          x: 1624,\n          y: 698.2,\n        },\n      },\n      data: {\n        parentID: 'root',\n        blockIDs: ['llm_8--A3', 'llm_vTyMa'],\n      },\n    },\n    {\n      id: 'llm_8--A3',\n      type: 'llm',\n      meta: {\n        position: {\n          x: 180,\n          y: 0,\n        },\n      },\n      data: {\n        title: 'LLM_1',\n        inputsValues: {\n          modelName: {\n            type: 'constant',\n            content: 'gpt-3.5-turbo',\n          },\n          apiKey: {\n            type: 'constant',\n            content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',\n          },\n          apiHost: {\n            type: 'constant',\n            content: 'https://mock-ai-url/api/v3',\n          },\n          temperature: {\n            type: 'constant',\n            content: 0.5,\n          },\n          systemPrompt: {\n            type: 'template',\n            content: '# Role\\nYou are an AI assistant.\\n',\n          },\n          prompt: {\n            type: 'template',\n            content: '# User Input\\nquery:{{start_0.query}}\\nenable:{{start_0.enable}}',\n          },\n        },\n        inputs: {\n          type: 'object',\n          required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],\n          properties: {\n            modelName: {\n              type: 'string',\n            },\n            apiKey: {\n              type: 'string',\n            },\n            apiHost: {\n              type: 'string',\n            },\n            temperature: {\n              type: 'number',\n            },\n            systemPrompt: {\n              type: 'string',\n              extra: {\n                formComponent: 'prompt-editor',\n              },\n            },\n            prompt: {\n              type: 'string',\n              extra: {\n                formComponent: 'prompt-editor',\n              },\n            },\n          },\n        },\n        outputs: {\n          type: 'object',\n          properties: {\n            result: {\n              type: 'string',\n            },\n          },\n        },\n      },\n    },\n    {\n      id: 'llm_vTyMa',\n      type: 'llm',\n      meta: {\n        position: {\n          x: 640,\n          y: 10,\n        },\n      },\n      data: {\n        title: 'LLM_2',\n        inputsValues: {\n          modelName: {\n            type: 'constant',\n            content: 'gpt-3.5-turbo',\n          },\n          apiKey: {\n            type: 'constant',\n            content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',\n          },\n          apiHost: {\n            type: 'constant',\n            content: 'https://mock-ai-url/api/v3',\n          },\n          temperature: {\n            type: 'constant',\n            content: 0.5,\n          },\n          systemPrompt: {\n            type: 'template',\n            content: '# Role\\nYou are an AI assistant.\\n',\n          },\n          prompt: {\n            type: 'template',\n            content: '# LLM Input\\nresult:{{llm_8--A3.result}}',\n          },\n        },\n        inputs: {\n          type: 'object',\n          required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],\n          properties: {\n            modelName: {\n              type: 'string',\n            },\n            apiKey: {\n              type: 'string',\n            },\n            apiHost: {\n              type: 'string',\n            },\n            temperature: {\n              type: 'number',\n            },\n            systemPrompt: {\n              type: 'string',\n              extra: {\n                formComponent: 'prompt-editor',\n              },\n            },\n            prompt: {\n              type: 'string',\n              extra: {\n                formComponent: 'prompt-editor',\n              },\n            },\n          },\n        },\n        outputs: {\n          type: 'object',\n          properties: {\n            result: {\n              type: 'string',\n            },\n          },\n        },\n      },\n    },\n  ],\n  edges: [\n    {\n      sourceNodeID: 'start_0',\n      targetNodeID: 'http_rDGIH',\n    },\n    {\n      sourceNodeID: 'http_rDGIH',\n      targetNodeID: 'condition_0',\n    },\n    {\n      sourceNodeID: 'condition_0',\n      targetNodeID: 'loop_Ycnsk',\n      sourcePortID: 'if_0',\n    },\n    {\n      sourceNodeID: 'condition_0',\n      targetNodeID: 'llm_8--A3',\n      sourcePortID: 'else',\n    },\n    {\n      sourceNodeID: 'llm_vTyMa',\n      targetNodeID: 'end_0',\n    },\n    {\n      sourceNodeID: 'loop_Ycnsk',\n      targetNodeID: 'end_0',\n    },\n    {\n      sourceNodeID: 'llm_8--A3',\n      targetNodeID: 'llm_vTyMa',\n    },\n  ],\n  globalVariable: {\n    type: 'object',\n    required: [],\n    properties: {\n      userId: {\n        type: 'string',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/block-end/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormRenderProps, FormMeta } from '@flowgram.ai/free-layout-editor';\nimport { Avatar } from '@douyinfe/semi-ui';\n\nimport { FlowNodeJSON } from '../../typings';\nimport iconEnd from '../../assets/icon-end.jpg';\n\nexport const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (\n  <>\n    <div\n      style={{\n        width: 60,\n        height: 60,\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n      }}\n    >\n      <Avatar\n        shape=\"circle\"\n        style={{\n          width: 40,\n          height: 40,\n          borderRadius: '50%',\n          cursor: 'move',\n        }}\n        alt=\"Icon\"\n        src={iconEnd}\n      />\n    </div>\n  </>\n);\n\nexport const formMeta: FormMeta<FlowNodeJSON> = {\n  render: renderForm,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/block-end/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowNodeRegistry } from '../../typings';\nimport iconStart from '../../assets/icon-start.jpg';\nimport { formMeta } from './form-meta';\nimport { WorkflowNodeType } from '../constants';\n\nexport const BlockEndNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.BlockEnd,\n  meta: {\n    isNodeEnd: true,\n    deleteDisable: true,\n    copyDisable: true,\n    sidebarDisabled: true,\n    nodePanelVisible: false,\n    defaultPorts: [{ type: 'input' }],\n    size: {\n      width: 100,\n      height: 100,\n    },\n    wrapperStyle: {\n      minWidth: 'unset',\n      width: '100%',\n      borderWidth: 2,\n      borderRadius: 12,\n      cursor: 'move',\n    },\n  },\n  info: {\n    icon: iconStart,\n    description: 'The final node of the block.',\n  },\n  /**\n   * Render node via formMeta\n   */\n  formMeta,\n  /**\n   * Start Node cannot be added\n   */\n  canAdd() {\n    return false;\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/block-start/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormRenderProps, FormMeta } from '@flowgram.ai/free-layout-editor';\nimport { Avatar } from '@douyinfe/semi-ui';\n\nimport { FlowNodeJSON } from '../../typings';\nimport iconStart from '../../assets/icon-start.jpg';\n\nexport const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (\n  <>\n    <div\n      style={{\n        width: 60,\n        height: 60,\n        display: 'flex',\n        alignItems: 'center',\n        justifyContent: 'center',\n      }}\n    >\n      <Avatar\n        shape=\"circle\"\n        style={{\n          width: 40,\n          height: 40,\n          borderRadius: '50%',\n          cursor: 'move',\n        }}\n        alt=\"Icon\"\n        src={iconStart}\n      />\n    </div>\n  </>\n);\n\nexport const formMeta: FormMeta<FlowNodeJSON> = {\n  render: renderForm,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/block-start/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowNodeRegistry } from '../../typings';\nimport iconStart from '../../assets/icon-start.jpg';\nimport { formMeta } from './form-meta';\nimport { WorkflowNodeType } from '../constants';\n\nexport const BlockStartNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.BlockStart,\n  meta: {\n    isStart: true,\n    deleteDisable: true,\n    copyDisable: true,\n    sidebarDisabled: true,\n    nodePanelVisible: false,\n    defaultPorts: [{ type: 'output' }],\n    size: {\n      width: 100,\n      height: 100,\n    },\n    wrapperStyle: {\n      minWidth: 'unset',\n      width: '100%',\n      borderWidth: 2,\n      borderRadius: 12,\n      cursor: 'move',\n    },\n  },\n  info: {\n    icon: iconStart,\n    description: 'The starting node of the block.',\n  },\n  /**\n   * Render node via formMeta\n   */\n  formMeta,\n  /**\n   * Start Node cannot be added\n   */\n  canAdd() {\n    return false;\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/break/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormMeta } from '@flowgram.ai/free-layout-editor';\n\nimport { defaultFormMeta } from '../default-form-meta';\nimport { useIsSidebar } from '../../hooks';\nimport { FormHeader, FormContent } from '../../form-components';\n\nexport const renderForm = () => {\n  const isSidebar = useIsSidebar();\n  if (isSidebar) {\n    return (\n      <>\n        <FormHeader />\n        <FormContent />\n      </>\n    );\n  }\n  return (\n    <>\n      <FormHeader />\n      <FormContent />\n    </>\n  );\n};\n\nexport const formMeta: FormMeta = {\n  ...defaultFormMeta,\n  render: renderForm,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/break/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { nanoid } from 'nanoid';\n\nimport { FlowNodeRegistry } from '../../typings';\nimport iconBreak from '../../assets/icon-break.jpg';\nimport { formMeta } from './form-meta';\nimport { WorkflowNodeType } from '../constants';\n\nlet index = 0;\nexport const BreakNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Break,\n  meta: {\n    defaultPorts: [{ type: 'input' }],\n    sidebarDisabled: true,\n    size: {\n      width: 360,\n      height: 54,\n    },\n    expandable: false,\n    onlyInContainer: WorkflowNodeType.Loop,\n  },\n  info: {\n    icon: iconBreak,\n    description:\n      'The final node of the workflow, used to return the result information after the workflow is run.',\n  },\n  /**\n   * Render node via formMeta\n   */\n  formMeta,\n  onAdd() {\n    return {\n      id: `break_${nanoid(5)}`,\n      type: 'break',\n      data: {\n        title: `Break_${++index}`,\n      },\n    };\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/code/components/code.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { TypeScriptCodeEditor } from '@flowgram.ai/form-materials';\nimport { Divider } from '@douyinfe/semi-ui';\n\nimport { useIsSidebar, useNodeRenderContext } from '../../../hooks';\n\nexport function Code() {\n  const isSidebar = useIsSidebar();\n  const { readonly } = useNodeRenderContext();\n\n  if (!isSidebar) {\n    return null;\n  }\n\n  return (\n    <>\n      <Divider />\n      <Field<string> name=\"script.content\">\n        {({ field }) => (\n          <TypeScriptCodeEditor\n            value={field.value}\n            onChange={(value) => field.onChange(value)}\n            readonly={readonly}\n          />\n        )}\n      </Field>\n    </>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/code/components/inputs.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { DisplayInputsValues, IFlowValue, InputsValues } from '@flowgram.ai/form-materials';\n\nimport { useIsSidebar, useNodeRenderContext } from '../../../hooks';\nimport { FormItem } from '../../../form-components';\n\nexport function Inputs() {\n  const isSidebar = useIsSidebar();\n\n  const { readonly } = useNodeRenderContext();\n\n  if (!isSidebar) {\n    return (\n      <Field<Record<string, IFlowValue | undefined> | undefined> name=\"inputsValues\">\n        {({ field }) => <DisplayInputsValues value={field.value} />}\n      </Field>\n    );\n  }\n\n  return (\n    <FormItem name=\"inputs\" type=\"object\" vertical>\n      <Field<Record<string, IFlowValue | undefined> | undefined> name=\"inputsValues\">\n        {({ field }) => (\n          <InputsValues\n            value={field.value}\n            onChange={(value) => field.onChange(value)}\n            readonly={readonly}\n          />\n        )}\n      </Field>\n    </FormItem>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/code/components/outputs.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { DisplayOutputs, IJsonSchema, JsonSchemaEditor } from '@flowgram.ai/form-materials';\nimport { Divider } from '@douyinfe/semi-ui';\n\nimport { useIsSidebar, useNodeRenderContext } from '../../../hooks';\nimport { FormItem } from '../../../form-components';\n\nexport function Outputs() {\n  const { readonly } = useNodeRenderContext();\n  const isSidebar = useIsSidebar();\n\n  if (!isSidebar) {\n    return (\n      <>\n        <Divider />\n        <Field<IJsonSchema> name=\"outputs\">\n          {({ field }) => <DisplayOutputs value={field.value} />}\n        </Field>\n      </>\n    );\n  }\n\n  return (\n    <>\n      <Divider />\n      <FormItem name=\"outputs\" type=\"object\" vertical>\n        <Field<IJsonSchema> name=\"outputs\">\n          {({ field }) => (\n            <JsonSchemaEditor\n              readonly={readonly}\n              value={field.value}\n              onChange={(value) => field.onChange(value)}\n            />\n          )}\n        </Field>\n      </FormItem>\n    </>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/code/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor';\nimport { createInferInputsPlugin } from '@flowgram.ai/form-materials';\n\nimport { FormHeader, FormContent } from '../../form-components';\nimport { CodeNodeJSON } from './types';\nimport { Outputs } from './components/outputs';\nimport { Inputs } from './components/inputs';\nimport { Code } from './components/code';\nimport { defaultFormMeta } from '../default-form-meta';\n\nexport const FormRender = ({ form }: FormRenderProps<CodeNodeJSON>) => (\n  <>\n    <FormHeader />\n    <FormContent>\n      <Inputs />\n      <Code />\n      <Outputs />\n    </FormContent>\n  </>\n);\n\nexport const formMeta: FormMeta = {\n  render: (props) => <FormRender {...props} />,\n  effect: defaultFormMeta.effect,\n  validate: defaultFormMeta.validate,\n  plugins: [createInferInputsPlugin({ sourceKey: 'inputsValues', targetKey: 'inputs' })],\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/code/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { nanoid } from 'nanoid';\n\nimport { WorkflowNodeType } from '../constants';\nimport { FlowNodeRegistry } from '../../typings';\nimport iconCode from '../../assets/icon-script.png';\nimport { formMeta } from './form-meta';\n\nlet index = 0;\n\nconst defaultCode = `// FlowGram injects upstream values into input.params.\n// Return a plain JSON-serializable object from main().\n// Current MVP supports synchronous JavaScript only.\n\nfunction main(input) {\n  var params = input && input.params ? input.params : {};\n  var value = String(params.input || '');\n\n  return {\n    result: value + value,\n  };\n}`;\n\nexport const CodeNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Code,\n  info: {\n    icon: iconCode,\n    description: 'Run synchronous JavaScript against upstream inputs.',\n  },\n  meta: {\n    size: {\n      width: 360,\n      height: 390,\n    },\n  },\n  onAdd() {\n    return {\n      id: `code_${nanoid(5)}`,\n      type: 'code',\n      data: {\n        title: `Code_${++index}`,\n        inputsValues: {\n          input: { type: 'constant', content: '' },\n        },\n        script: {\n          language: 'javascript',\n          content: defaultCode,\n        },\n        outputs: {\n          type: 'object',\n          required: ['result'],\n          properties: {\n            result: {\n              type: 'string',\n            },\n          },\n        },\n      },\n    };\n  },\n  formMeta: formMeta,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/code/types.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowNodeJSON } from '@flowgram.ai/free-layout-editor';\nimport { IFlowValue, IJsonSchema } from '@flowgram.ai/form-materials';\n\nexport interface CodeNodeJSON extends FlowNodeJSON {\n  data: {\n    title: string;\n    inputsValues: Record<string, IFlowValue>;\n    inputs: IJsonSchema<'object'>;\n    outputs: IJsonSchema<'object'>;\n    script: {\n      language: 'javascript';\n      content: string;\n    };\n  };\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/comment/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { WorkflowNodeType } from '../constants';\nimport { FlowNodeRegistry } from '../../typings';\n\nexport const CommentNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Comment,\n  meta: {\n    sidebarDisabled: true,\n    nodePanelVisible: false,\n    defaultPorts: [],\n    renderKey: WorkflowNodeType.Comment,\n    size: {\n      width: 240,\n      height: 150,\n    },\n  },\n  formMeta: {\n    render: () => <></>,\n  },\n  getInputPoints: () => [], // Comment 节点没有输入\n  getOutputPoints: () => [], // Comment 节点没有输出\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/condition/condition-inputs/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useLayoutEffect } from 'react';\n\nimport { nanoid } from 'nanoid';\nimport { Field, FieldArray, I18n } from '@flowgram.ai/free-layout-editor';\nimport { ConditionRow, ConditionRowValueType } from '@flowgram.ai/form-materials';\nimport { Button } from '@douyinfe/semi-ui';\nimport { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';\n\nimport { useNodeRenderContext } from '../../../hooks';\nimport { FormItem } from '../../../form-components';\nimport { Feedback } from '../../../form-components';\nimport { ConditionPort } from './styles';\n\ninterface ConditionValue {\n  key: string;\n  value?: ConditionRowValueType;\n}\n\nexport function ConditionInputs() {\n  const { node, readonly } = useNodeRenderContext();\n\n  useLayoutEffect(() => {\n    window.requestAnimationFrame(() => {\n      node.ports.updateDynamicPorts();\n    });\n  }, [node]);\n\n  return (\n    <FieldArray name=\"conditions\">\n      {({ field }) => (\n        <>\n          {field.map((child, index) => (\n            <Field<ConditionValue> key={child.name} name={child.name}>\n              {({ field: childField, fieldState: childState }) => (\n                <FormItem name=\"if\" type=\"boolean\" required={true} labelWidth={50}>\n                  <div style={{ display: 'flex', alignItems: 'center' }}>\n                    <ConditionRow\n                      readonly={readonly}\n                      style={{ flexGrow: 1, overflow: 'hidden' }}\n                      value={childField.value.value}\n                      onChange={(v) => childField.onChange({ value: v, key: childField.value.key })}\n                    />\n\n                    {!readonly && (\n                      <Button\n                        theme=\"borderless\"\n                        disabled={readonly}\n                        icon={<IconCrossCircleStroked />}\n                        onClick={() => field.delete(index)}\n                      />\n                    )}\n                  </div>\n\n                  <Feedback errors={childState?.errors} invalid={childState?.invalid} />\n                  <ConditionPort data-port-id={childField.value.key} data-port-type=\"output\" />\n                </FormItem>\n              )}\n            </Field>\n          ))}\n          <FormItem name=\"else\" type=\"boolean\" required={true} labelWidth={100}>\n            <ConditionPort data-port-id=\"else\" data-port-type=\"output\" />\n          </FormItem>\n          {!readonly && (\n            <div>\n              <Button\n                theme=\"borderless\"\n                icon={<IconPlus />}\n                onClick={() =>\n                  field.append({\n                    key: `if_${nanoid(6)}`,\n                    value: { type: 'expression', content: '' },\n                  })\n                }\n              >\n                {I18n.t('Add')}\n              </Button>\n            </div>\n          )}\n        </>\n      )}\n    </FieldArray>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/condition/condition-inputs/styles.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport styled from 'styled-components';\n\nexport const ConditionPort = styled.div`\n  position: absolute;\n  right: -12px;\n  top: 50%;\n`;\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/condition/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';\nimport { autoRenameRefEffect } from '@flowgram.ai/form-materials';\n\nimport { FlowNodeJSON } from '../../typings';\nimport { FormHeader, FormContent } from '../../form-components';\nimport { ConditionInputs } from './condition-inputs';\n\nexport const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (\n  <>\n    <FormHeader />\n    <FormContent>\n      <ConditionInputs />\n    </FormContent>\n  </>\n);\n\nexport const formMeta: FormMeta<FlowNodeJSON> = {\n  render: renderForm,\n  validateTrigger: ValidateTrigger.onChange,\n  validate: {\n    title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),\n    'conditions.*': ({ value }) => {\n      if (!value?.value) return 'Condition is required';\n      return undefined;\n    },\n  },\n  effect: {\n    conditions: autoRenameRefEffect,\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/condition/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { nanoid } from 'nanoid';\n\nimport { FlowNodeRegistry } from '../../typings';\nimport iconCondition from '../../assets/icon-condition.svg';\nimport { formMeta } from './form-meta';\nimport { WorkflowNodeType } from '../constants';\n\nexport const ConditionNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Condition,\n  info: {\n    icon: iconCondition,\n    description:\n      'Connect multiple downstream branches. Only the corresponding branch will be executed if the set conditions are met.',\n  },\n  meta: {\n    defaultPorts: [{ type: 'input' }],\n    // Condition Outputs use dynamic port\n    useDynamicPort: true,\n    expandable: false, // disable expanded\n    size: {\n      width: 360,\n      height: 210,\n    },\n  },\n  formMeta,\n  onAdd() {\n    return {\n      id: `condition_${nanoid(5)}`,\n      type: 'condition',\n      data: {\n        title: 'Condition',\n        conditions: [\n          {\n            key: `if_${nanoid(5)}`,\n            value: {},\n          },\n          {\n            key: `if_${nanoid(5)}`,\n            value: {},\n          },\n        ],\n      },\n    };\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/constants.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport enum WorkflowNodeType {\n  Start = 'start',\n  End = 'end',\n  LLM = 'llm',\n  HTTP = 'http',\n  Code = 'code',\n  Tool = 'tool',\n  Knowledge = 'knowledge',\n  Variable = 'variable',\n  Condition = 'condition',\n  Loop = 'loop',\n  BlockStart = 'block-start',\n  BlockEnd = 'block-end',\n  Comment = 'comment',\n  Continue = 'continue',\n  Break = 'break',\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/continue/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormMeta } from '@flowgram.ai/free-layout-editor';\n\nimport { defaultFormMeta } from '../default-form-meta';\nimport { useIsSidebar } from '../../hooks';\nimport { FormHeader, FormContent } from '../../form-components';\n\nexport const renderForm = () => {\n  const isSidebar = useIsSidebar();\n  if (isSidebar) {\n    return (\n      <>\n        <FormHeader />\n        <FormContent />\n      </>\n    );\n  }\n  return (\n    <>\n      <FormHeader />\n      <FormContent />\n    </>\n  );\n};\n\nexport const formMeta: FormMeta = {\n  ...defaultFormMeta,\n  render: renderForm,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/continue/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { nanoid } from 'nanoid';\n\nimport { FlowNodeRegistry } from '../../typings';\nimport iconContinue from '../../assets/icon-continue.jpg';\nimport { formMeta } from './form-meta';\nimport { WorkflowNodeType } from '../constants';\n\nlet index = 0;\nexport const ContinueNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Continue,\n  meta: {\n    defaultPorts: [{ type: 'input' }],\n    sidebarDisabled: true,\n    size: {\n      width: 360,\n      height: 54,\n    },\n    expandable: false,\n    onlyInContainer: WorkflowNodeType.Loop,\n  },\n  info: {\n    icon: iconContinue,\n    description:\n      'The final node of the workflow, used to return the result information after the workflow is run.',\n  },\n  /**\n   * Render node via formMeta\n   */\n  formMeta,\n  onAdd() {\n    return {\n      id: `continue_${nanoid(5)}`,\n      type: 'continue',\n      data: {\n        title: `Continue_${++index}`,\n      },\n    };\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/default-form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';\nimport {\n  autoRenameRefEffect,\n  provideJsonSchemaOutputs,\n  syncVariableTitle,\n  DisplayOutputs,\n  validateFlowValue,\n  validateWhenVariableSync,\n  listenRefSchemaChange,\n} from '@flowgram.ai/form-materials';\nimport { Divider } from '@douyinfe/semi-ui';\n\nimport { FlowNodeJSON } from '../typings';\nimport { FormHeader, FormContent, FormInputs } from '../form-components';\n\nexport const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (\n  <>\n    <FormHeader />\n    <FormContent>\n      <FormInputs />\n      <Divider />\n      <DisplayOutputs displayFromScope />\n    </FormContent>\n  </>\n);\n\nexport const defaultFormMeta: FormMeta<FlowNodeJSON> = {\n  render: renderForm,\n  validateTrigger: ValidateTrigger.onChange,\n  /**\n   * Supported writing as:\n   * 1: validate as options: { title: () => {} , ... }\n   * 2: validate as dynamic function: (values,  ctx) => ({ title: () => {}, ... })\n   */\n  validate: {\n    title: ({ value }) => (value ? undefined : 'Title is required'),\n    'inputsValues.*': ({ value, context, formValues, name }) => {\n      const valuePropertyKey = name.replace(/^inputsValues\\./, '');\n      const required = formValues.inputs?.required || [];\n\n      return validateFlowValue(value, {\n        node: context.node,\n        required: required.includes(valuePropertyKey),\n        errorMessages: {\n          required: `${valuePropertyKey} is required`,\n        },\n      });\n    },\n  },\n  /**\n   * Initialize (fromJSON) data transformation\n   * 初始化(fromJSON) 数据转换\n   * @param value\n   * @param ctx\n   */\n  formatOnInit: (value, ctx) => value,\n  /**\n   * Save (toJSON) data transformation\n   * 保存(toJSON) 数据转换\n   * @param value\n   * @param ctx\n   */\n  formatOnSubmit: (value, ctx) => value,\n  effect: {\n    title: syncVariableTitle,\n    outputs: provideJsonSchemaOutputs,\n    inputsValues: [...autoRenameRefEffect, ...validateWhenVariableSync({ scope: 'public' })],\n    'inputsValues.*': listenRefSchemaChange((params) => {\n      console.log(`[${params.context.node.id}][${params.name}] Schema Of Ref Updated`);\n    }),\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/end/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field, FormMeta } from '@flowgram.ai/free-layout-editor';\nimport {\n  createInferInputsPlugin,\n  DisplayInputsValues,\n  IFlowValue,\n  InputsValues,\n} from '@flowgram.ai/form-materials';\n\nimport { defaultFormMeta } from '../default-form-meta';\nimport { useIsSidebar } from '../../hooks';\nimport { FormHeader, FormContent } from '../../form-components';\n\nexport const renderForm = () => {\n  const isSidebar = useIsSidebar();\n  if (isSidebar) {\n    return (\n      <>\n        <FormHeader />\n        <FormContent>\n          <Field<Record<string, IFlowValue | undefined> | undefined> name=\"inputsValues\">\n            {({ field: { value, onChange } }) => (\n              <>\n                <InputsValues value={value} onChange={(_v) => onChange(_v)} />\n              </>\n            )}\n          </Field>\n        </FormContent>\n      </>\n    );\n  }\n  return (\n    <>\n      <FormHeader />\n      <FormContent>\n        <Field<Record<string, IFlowValue | undefined> | undefined> name=\"inputsValues\">\n          {({ field: { value } }) => (\n            <>\n              <DisplayInputsValues value={value} />\n            </>\n          )}\n        </Field>\n      </FormContent>\n    </>\n  );\n};\n\nexport const formMeta: FormMeta = {\n  ...defaultFormMeta,\n  render: renderForm,\n  plugins: [\n    createInferInputsPlugin({\n      sourceKey: 'inputsValues',\n      targetKey: 'inputs',\n    }),\n  ],\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/end/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowNodeRegistry } from '../../typings';\nimport iconEnd from '../../assets/icon-end.jpg';\nimport { formMeta } from './form-meta';\nimport { WorkflowNodeType } from '../constants';\n\nexport const EndNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.End,\n  meta: {\n    deleteDisable: true,\n    copyDisable: true,\n    nodePanelVisible: false,\n    defaultPorts: [{ type: 'input' }],\n    size: {\n      width: 360,\n      height: 211,\n    },\n  },\n  info: {\n    icon: iconEnd,\n    description:\n      'The final node of the workflow, used to return the result information after the workflow is run.',\n  },\n  /**\n   * Render node via formMeta\n   */\n  formMeta,\n  /**\n   * End Node cannot be added\n   */\n  canAdd() {\n    return false;\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/group/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\nimport {\n  FlowNodeBaseType,\n  WorkflowNodeEntity,\n  PositionSchema,\n  FlowNodeTransformData,\n  nanoid,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowNodeRegistry } from '../../typings';\n\nlet index = 0;\nexport const GroupNodeRegistry: FlowNodeRegistry = {\n  type: FlowNodeBaseType.GROUP,\n  meta: {\n    renderKey: FlowNodeBaseType.GROUP,\n    defaultPorts: [],\n    isContainer: true,\n    disableSideBar: true,\n    size: {\n      width: 560,\n      height: 400,\n    },\n    padding: () => ({\n      top: 80,\n      bottom: 40,\n      left: 65,\n      right: 65,\n    }),\n    selectable(node: WorkflowNodeEntity, mousePos?: PositionSchema): boolean {\n      if (!mousePos) {\n        return true;\n      }\n      const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);\n      return !transform.bounds.contains(mousePos.x, mousePos.y);\n    },\n    expandable: false,\n    /**\n     * It cannot be added through the panel\n     * 不能通过面板添加\n     */\n    nodePanelVisible: false,\n  },\n  formMeta: {\n    render: () => <></>,\n  },\n  onAdd() {\n    return {\n      type: FlowNodeBaseType.GROUP,\n      id: `group_${nanoid(5)}`,\n      meta: {\n        position: {\n          x: 0,\n          y: 0,\n        },\n      },\n      data: {\n        color: 'Green',\n        title: `Group_${++index}`,\n      },\n    };\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/http/components/api.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { IFlowTemplateValue, PromptEditorWithVariables } from '@flowgram.ai/form-materials';\nimport { Select } from '@douyinfe/semi-ui';\n\nimport { useNodeRenderContext } from '../../../hooks';\nimport { FormItem } from '../../../form-components';\n\nexport function Api() {\n  const { readonly } = useNodeRenderContext();\n\n  return (\n    <div>\n      <FormItem name=\"API\" required vertical type=\"string\">\n        <div style={{ display: 'flex', gap: 5 }}>\n          <Field<string> name=\"api.method\" defaultValue=\"GET\">\n            {({ field }) => (\n              <Select\n                value={field.value}\n                onChange={(value) => {\n                  field.onChange(value as string);\n                }}\n                style={{ width: 85, maxWidth: 85, minWidth: 85 }}\n                size=\"small\"\n                disabled={readonly}\n                optionList={[\n                  { label: 'GET', value: 'GET' },\n                  { label: 'POST', value: 'POST' },\n                  { label: 'PUT', value: 'PUT' },\n                  { label: 'DELETE', value: 'DELETE' },\n                  { label: 'PATCH', value: 'PATCH' },\n                  { label: 'HEAD', value: 'HEAD' },\n                ]}\n              />\n            )}\n          </Field>\n\n          <Field<IFlowTemplateValue> name=\"api.url\">\n            {({ field }) => (\n              <PromptEditorWithVariables\n                disableMarkdownHighlight\n                readonly={readonly}\n                style={{ flexGrow: 1 }}\n                placeholder=\"Input URL, use var by '{'\"\n                value={field.value}\n                onChange={(value) => {\n                  field.onChange(value!);\n                }}\n              />\n            )}\n          </Field>\n        </div>\n      </FormItem>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/http/components/body.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport {\n  IFlowTemplateValue,\n  JsonEditorWithVariables,\n  PromptEditorWithVariables,\n} from '@flowgram.ai/form-materials';\nimport { Select } from '@douyinfe/semi-ui';\n\nimport { useNodeRenderContext } from '../../../hooks';\nimport { FormItem } from '../../../form-components';\n\nconst BODY_TYPE_OPTIONS = [\n  {\n    label: 'None',\n    value: 'none',\n  },\n  {\n    label: 'JSON',\n    value: 'JSON',\n  },\n  {\n    label: 'Raw Text',\n    value: 'raw-text',\n  },\n];\n\nexport function Body() {\n  const { readonly } = useNodeRenderContext();\n\n  const renderBodyEditor = (bodyType: string) => {\n    switch (bodyType) {\n      case 'JSON':\n        return (\n          <Field<IFlowTemplateValue> name=\"body.json\">\n            {({ field }) => (\n              <JsonEditorWithVariables\n                value={field.value?.content}\n                readonly={readonly}\n                activeLinePlaceholder=\"use var by '@'\"\n                onChange={(value) => {\n                  field.onChange({ type: 'template', content: value });\n                }}\n              />\n            )}\n          </Field>\n        );\n      case 'raw-text':\n        return (\n          <Field<IFlowTemplateValue> name=\"body.rawText\">\n            {({ field }) => (\n              <PromptEditorWithVariables\n                disableMarkdownHighlight\n                readonly={readonly}\n                style={{ flexGrow: 1 }}\n                placeholder=\"Input raw text, use var by '{'\"\n                onChange={(value) => {\n                  field.onChange(value!);\n                }}\n              />\n            )}\n          </Field>\n        );\n      default:\n        return null;\n    }\n  };\n\n  return (\n    <Field<string> name=\"body.bodyType\" defaultValue=\"JSON\">\n      {({ field }) => (\n        <div style={{ marginTop: 5 }}>\n          <FormItem name=\"Body\" vertical type=\"object\">\n            <Select\n              value={field.value}\n              onChange={(value) => {\n                field.onChange(value as string);\n              }}\n              style={{ width: '100%', marginBottom: 10 }}\n              disabled={readonly}\n              size=\"small\"\n              optionList={BODY_TYPE_OPTIONS}\n            />\n            {renderBodyEditor(field.value)}\n          </FormItem>\n        </div>\n      )}\n    </Field>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/http/components/headers.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { DisplayInputsValues, IFlowValue, InputsValues } from '@flowgram.ai/form-materials';\n\nimport { useIsSidebar, useNodeRenderContext } from '../../../hooks';\nimport { FormItem } from '../../../form-components';\n\nexport function Headers() {\n  const { readonly } = useNodeRenderContext();\n  const isSidebar = useIsSidebar();\n\n  if (!isSidebar) {\n    return (\n      <FormItem name=\"headers\" type=\"object\" vertical>\n        <Field<Record<string, IFlowValue | undefined> | undefined> name=\"headersValues\">\n          {({ field }) => <DisplayInputsValues value={field.value} />}\n        </Field>\n      </FormItem>\n    );\n  }\n\n  return (\n    <FormItem name=\"headers\" type=\"object\" vertical>\n      <Field<Record<string, IFlowValue | undefined> | undefined> name=\"headersValues\">\n        {({ field }) => (\n          <InputsValues\n            value={field.value}\n            onChange={(value) => field.onChange(value)}\n            readonly={readonly}\n          />\n        )}\n      </Field>\n    </FormItem>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/http/components/params.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { DisplayInputsValues, IFlowValue, InputsValues } from '@flowgram.ai/form-materials';\n\nimport { useIsSidebar, useNodeRenderContext } from '../../../hooks';\nimport { FormItem } from '../../../form-components';\n\nexport function Params() {\n  const { readonly } = useNodeRenderContext();\n  const isSidebar = useIsSidebar();\n\n  if (!isSidebar) {\n    return (\n      <FormItem name=\"params\" type=\"object\" vertical>\n        <Field<Record<string, IFlowValue | undefined> | undefined> name=\"paramsValues\">\n          {({ field }) => <DisplayInputsValues value={field.value} />}\n        </Field>\n      </FormItem>\n    );\n  }\n\n  return (\n    <FormItem name=\"params\" type=\"object\" vertical>\n      <Field<Record<string, IFlowValue | undefined> | undefined> name=\"paramsValues\">\n        {({ field }) => (\n          <InputsValues\n            value={field.value}\n            onChange={(value) => field.onChange(value)}\n            readonly={readonly}\n          />\n        )}\n      </Field>\n    </FormItem>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/http/components/timeout.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { Field } from '@flowgram.ai/free-layout-editor';\nimport { InputNumber } from '@douyinfe/semi-ui';\n\nimport { useNodeRenderContext } from '../../../hooks';\nimport { FormItem } from '../../../form-components';\n\nexport function Timeout() {\n  const { readonly } = useNodeRenderContext();\n\n  return (\n    <div>\n      <FormItem name=\"Timeout(ms)\" required style={{ flex: 1 }} type=\"number\">\n        <Field<number> name=\"timeout.timeout\" defaultValue={10000}>\n          {({ field }) => (\n            <InputNumber\n              size=\"small\"\n              value={field.value}\n              onChange={(value) => {\n                field.onChange(value as number);\n              }}\n              disabled={readonly}\n              style={{ width: '100%' }}\n              min={0}\n            />\n          )}\n        </Field>\n      </FormItem>\n      <FormItem name=\"Retry Times\" required type=\"number\">\n        <Field<number> name=\"timeout.retryTimes\" defaultValue={1}>\n          {({ field }) => (\n            <InputNumber\n              size=\"small\"\n              value={field.value}\n              onChange={(value) => {\n                field.onChange(value as number);\n              }}\n              disabled={readonly}\n              style={{ width: '100%' }}\n              min={0}\n            />\n          )}\n        </Field>\n      </FormItem>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/http/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor';\nimport { createInferInputsPlugin, DisplayOutputs } from '@flowgram.ai/form-materials';\nimport { Divider } from '@douyinfe/semi-ui';\n\nimport { FormHeader, FormContent } from '../../form-components';\nimport { HTTPNodeJSON } from './types';\nimport { Timeout } from './components/timeout';\nimport { Params } from './components/params';\nimport { Headers } from './components/headers';\nimport { Body } from './components/body';\nimport { Api } from './components/api';\nimport { defaultFormMeta } from '../default-form-meta';\n\nexport const FormRender = ({ form }: FormRenderProps<HTTPNodeJSON>) => (\n  <>\n    <FormHeader />\n    <FormContent>\n      <Api />\n      <Divider />\n      <Headers />\n      <Divider />\n      <Params />\n      <Divider />\n      <Body />\n      <Divider />\n      <Timeout />\n      <Divider />\n      <DisplayOutputs displayFromScope />\n    </FormContent>\n  </>\n);\n\nexport const formMeta: FormMeta = {\n  render: (props) => <FormRender {...props} />,\n  effect: defaultFormMeta.effect,\n  plugins: [\n    createInferInputsPlugin({ sourceKey: 'headersValues', targetKey: 'headers' }),\n    createInferInputsPlugin({ sourceKey: 'paramsValues', targetKey: 'params' }),\n  ],\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/http/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { nanoid } from 'nanoid';\n\nimport { WorkflowNodeType } from '../constants';\nimport { FlowNodeRegistry } from '../../typings';\nimport iconHTTP from '../../assets/icon-http.svg';\nimport { formMeta } from './form-meta';\n\nlet index = 0;\n\nexport const HTTPNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.HTTP,\n  info: {\n    icon: iconHTTP,\n    description: 'Call the HTTP API',\n  },\n  meta: {\n    size: {\n      width: 360,\n      height: 390,\n    },\n  },\n  onAdd() {\n    return {\n      id: `http_${nanoid(5)}`,\n      type: 'http',\n      data: {\n        title: `HTTP_${++index}`,\n        api: {\n          method: 'GET',\n        },\n        body: {\n          bodyType: 'JSON',\n        },\n        headers: {},\n        params: {},\n        outputs: {\n          type: 'object',\n          properties: {\n            body: { type: 'string' },\n            headers: { type: 'object' },\n            statusCode: { type: 'integer' },\n          },\n        },\n      },\n    };\n  },\n  formMeta: formMeta,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/http/types.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { IFlowConstantRefValue } from '@flowgram.ai/runtime-interface';\nimport { FlowNodeJSON } from '@flowgram.ai/free-layout-editor';\nimport { IFlowTemplateValue, IJsonSchema } from '@flowgram.ai/form-materials';\n\nexport interface HTTPNodeJSON extends FlowNodeJSON {\n  data: {\n    title: string;\n    outputs: IJsonSchema<'object'>;\n    api: {\n      method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';\n      url: IFlowTemplateValue;\n    };\n    headers: IJsonSchema<'object'>;\n    headersValues: Record<string, IFlowConstantRefValue>;\n    params: IJsonSchema<'object'>;\n    paramsValues: Record<string, IFlowConstantRefValue>;\n    body: {\n      bodyType: 'none' | 'form-data' | 'x-www-form-urlencoded' | 'raw-text' | 'JSON';\n      json?: IFlowTemplateValue;\n      formData?: IJsonSchema<'object'>;\n      formDataValues?: Record<string, IFlowConstantRefValue>;\n      rawText?: IFlowTemplateValue;\n      xWwwFormUrlencoded?: IJsonSchema<'object'>;\n      xWwwFormUrlencodedValues?: Record<string, IFlowConstantRefValue>;\n    };\n    timeout: {\n      retryTimes: number;\n      timeout: number;\n    };\n  };\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowNodeRegistry } from '../typings';\nimport { StartNodeRegistry } from './start';\nimport { LoopNodeRegistry } from './loop';\nimport { LLMNodeRegistry } from './llm';\nimport { HTTPNodeRegistry } from './http';\nimport { CodeNodeRegistry } from './code';\nimport { ToolNodeRegistry } from './tool';\nimport { KnowledgeNodeRegistry } from './knowledge';\nimport { VariableNodeRegistry } from './variable';\nimport { EndNodeRegistry } from './end';\nimport { ConditionNodeRegistry } from './condition';\nimport { BlockStartNodeRegistry } from './block-start';\nimport { BlockEndNodeRegistry } from './block-end';\nexport { WorkflowNodeType } from './constants';\n\nexport const nodeRegistries: FlowNodeRegistry[] = [\n  ConditionNodeRegistry,\n  StartNodeRegistry,\n  EndNodeRegistry,\n  LLMNodeRegistry,\n  HTTPNodeRegistry,\n  CodeNodeRegistry,\n  ToolNodeRegistry,\n  KnowledgeNodeRegistry,\n  VariableNodeRegistry,\n  LoopNodeRegistry,\n  BlockStartNodeRegistry,\n  BlockEndNodeRegistry,\n];\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/knowledge/index.tsx",
    "content": "import { nanoid } from 'nanoid';\n\nimport { WorkflowNodeType } from '../constants';\nimport { FlowNodeRegistry } from '../../typings';\nimport iconKnowledge from '../../assets/icon-knowledge.svg';\nimport { defaultFormMeta } from '../default-form-meta';\n\nlet index = 0;\n\nexport const KnowledgeNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Knowledge,\n  info: {\n    icon: iconKnowledge,\n    description: 'Retrieve top-K passages from Pinecone with embedding search.',\n  },\n  meta: {\n    size: {\n      width: 360,\n      height: 390,\n    },\n  },\n  onAdd() {\n    return {\n      id: `knowledge_${nanoid(5)}`,\n      type: WorkflowNodeType.Knowledge,\n      data: {\n        title: `Knowledge_${++index}`,\n        inputsValues: {\n          serviceId: {\n            type: 'constant',\n            content: 'glm-coding',\n          },\n          embeddingModel: {\n            type: 'constant',\n            content: 'embedding-3',\n          },\n          namespace: {\n            type: 'constant',\n            content: 'default',\n          },\n          query: {\n            type: 'template',\n            content: '',\n          },\n          topK: {\n            type: 'constant',\n            content: 3,\n          },\n          delimiter: {\n            type: 'constant',\n            content: '\\n\\n',\n          },\n        },\n        inputs: {\n          type: 'object',\n          required: ['serviceId', 'embeddingModel', 'namespace', 'query'],\n          properties: {\n            serviceId: {\n              type: 'string',\n            },\n            embeddingModel: {\n              type: 'string',\n            },\n            namespace: {\n              type: 'string',\n            },\n            query: {\n              type: 'string',\n            },\n            topK: {\n              type: 'integer',\n            },\n            delimiter: {\n              type: 'string',\n            },\n          },\n        },\n        outputs: {\n          type: 'object',\n          properties: {\n            context: {\n              type: 'string',\n            },\n            count: {\n              type: 'integer',\n            },\n            matches: {\n              type: 'array',\n            },\n          },\n        },\n      },\n    };\n  },\n  formMeta: defaultFormMeta,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/llm/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { nanoid } from 'nanoid';\n\nimport { WorkflowNodeType } from '../constants';\nimport { FlowNodeRegistry } from '../../typings';\nimport iconLLM from '../../assets/icon-llm.jpg';\n\nlet index = 0;\nexport const LLMNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.LLM,\n  info: {\n    icon: iconLLM,\n    description:\n      'Call the large language model and use variables and prompt words to generate responses.',\n  },\n  meta: {\n    size: {\n      width: 360,\n      height: 390,\n    },\n  },\n  onAdd() {\n    return {\n      id: `llm_${nanoid(5)}`,\n      type: 'llm',\n      data: {\n        title: `LLM_${++index}`,\n        inputsValues: {\n          serviceId: {\n            type: 'constant',\n            content: 'minimax-coding',\n          },\n          modelName: {\n            type: 'constant',\n            content: 'MiniMax-M2.1',\n          },\n          systemPrompt: {\n            type: 'template',\n            content: 'You are a concise assistant that follows the user exactly.',\n          },\n          prompt: {\n            type: 'template',\n            content: '',\n          },\n        },\n        inputs: {\n          type: 'object',\n          required: ['modelName', 'prompt'],\n          properties: {\n            serviceId: {\n              type: 'string',\n            },\n            modelName: {\n              type: 'string',\n            },\n            systemPrompt: {\n              type: 'string',\n            },\n            prompt: {\n              type: 'string',\n            },\n          },\n        },\n        outputs: {\n          type: 'object',\n          required: ['result'],\n          properties: {\n            result: { type: 'string' },\n          },\n        },\n      },\n    };\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/loop/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormRenderProps, FlowNodeJSON, Field, FormMeta } from '@flowgram.ai/free-layout-editor';\nimport { SubCanvasRender } from '@flowgram.ai/free-container-plugin';\nimport {\n  BatchOutputs,\n  BatchVariableSelector,\n  createBatchOutputsFormPlugin,\n  DisplayOutputs,\n  IFlowRefValue,\n  provideBatchInputEffect,\n} from '@flowgram.ai/form-materials';\n\nimport { defaultFormMeta } from '../default-form-meta';\nimport { useIsSidebar, useNodeRenderContext } from '../../hooks';\nimport { FormHeader, FormContent, FormItem, Feedback } from '../../form-components';\n\ninterface LoopNodeJSON extends FlowNodeJSON {\n  data: {\n    loopFor: IFlowRefValue;\n  };\n}\n\nexport const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {\n  const isSidebar = useIsSidebar();\n  const { readonly } = useNodeRenderContext();\n  const formHeight = 115;\n\n  const loopFor = (\n    <Field<IFlowRefValue> name={`loopFor`}>\n      {({ field, fieldState }) => (\n        <FormItem name={'loopFor'} type={'array'} required>\n          <BatchVariableSelector\n            style={{ width: '100%' }}\n            value={field.value?.content}\n            onChange={(val) => field.onChange({ type: 'ref', content: val })}\n            readonly={readonly}\n            hasError={Object.keys(fieldState?.errors || {}).length > 0}\n          />\n          <Feedback errors={fieldState?.errors} />\n        </FormItem>\n      )}\n    </Field>\n  );\n\n  const loopOutputs = (\n    <Field<Record<string, IFlowRefValue | undefined> | undefined> name={`loopOutputs`}>\n      {({ field, fieldState }) => (\n        <FormItem name=\"loopOutputs\" type=\"object\" vertical>\n          <BatchOutputs\n            style={{ width: '100%' }}\n            value={field.value}\n            onChange={(val) => field.onChange(val)}\n            readonly={readonly}\n            hasError={Object.keys(fieldState?.errors || {}).length > 0}\n          />\n          <Feedback errors={fieldState?.errors} />\n        </FormItem>\n      )}\n    </Field>\n  );\n\n  if (isSidebar) {\n    return (\n      <>\n        <FormHeader />\n        <FormContent>\n          {loopFor}\n          {loopOutputs}\n        </FormContent>\n      </>\n    );\n  }\n  return (\n    <>\n      <FormHeader />\n      <FormContent>\n        {loopFor}\n        <SubCanvasRender offsetY={-formHeight} />\n        <DisplayOutputs displayFromScope />\n      </FormContent>\n    </>\n  );\n};\n\nexport const formMeta: FormMeta = {\n  ...defaultFormMeta,\n  render: LoopFormRender,\n  effect: {\n    loopFor: provideBatchInputEffect,\n  },\n  plugins: [createBatchOutputsFormPlugin({ outputKey: 'loopOutputs', inferTargetKey: 'outputs' })],\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/loop/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { nanoid } from 'nanoid';\nimport {\n  WorkflowNodeEntity,\n  PositionSchema,\n  FlowNodeTransformData,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowNodeRegistry } from '../../typings';\nimport iconLoop from '../../assets/icon-loop.jpg';\nimport { formMeta } from './form-meta';\nimport { WorkflowNodeType } from '../constants';\n\nlet index = 0;\nexport const LoopNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Loop,\n  info: {\n    icon: iconLoop,\n    description:\n      'Used to repeatedly execute a series of tasks by setting the number of iterations and logic.',\n  },\n  meta: {\n    /**\n     * Mark as subcanvas\n     * 子画布标记\n     */\n    isContainer: true,\n    /**\n     * The subcanvas default size setting\n     * 子画布默认大小设置\n     */\n    size: {\n      width: 424,\n      height: 244,\n    },\n    // autoResizeDisable: true,\n    /**\n     * The subcanvas padding setting\n     * 子画布 padding 设置\n     */\n    padding: (transform) => {\n      if (!transform.isContainer) {\n        return {\n          top: 0,\n          bottom: 0,\n          left: 0,\n          right: 0,\n        };\n      }\n      return {\n        top: 120,\n        bottom: 80,\n        left: 80,\n        right: 80,\n      };\n    },\n    /**\n     * Controls the node selection status within the subcanvas\n     * 控制子画布内的节点选中状态\n     */\n    selectable(node: WorkflowNodeEntity, mousePos?: PositionSchema): boolean {\n      if (!mousePos) {\n        return true;\n      }\n      const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);\n      // 鼠标开始时所在位置不包括当前节点时才可选中\n      return !transform.bounds.contains(mousePos.x, mousePos.y);\n    },\n    // expandable: false, // disable expanded\n    wrapperStyle: {\n      minWidth: 'unset',\n      width: '100%',\n    },\n    // defaultPorts: [{ type: 'output', location: 'right' }, { type: 'input', location: 'left'}, { type: 'output', location: 'bottom', portID: 'bottom' }, { type: 'input', location: 'top', portID: 'top'}]\n  },\n  onAdd() {\n    return {\n      id: `loop_${nanoid(5)}`,\n      type: WorkflowNodeType.Loop,\n      data: {\n        title: `Loop_${++index}`,\n      },\n      blocks: [\n        {\n          id: `block_start_${nanoid(5)}`,\n          type: WorkflowNodeType.BlockStart,\n          meta: {\n            position: {\n              x: 32,\n              y: 0,\n            },\n          },\n          data: {},\n        },\n        {\n          id: `block_end_${nanoid(5)}`,\n          type: WorkflowNodeType.BlockEnd,\n          meta: {\n            position: {\n              x: 192,\n              y: 0,\n            },\n          },\n          data: {},\n        },\n      ],\n    };\n  },\n  formMeta,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/start/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  Field,\n  FieldRenderProps,\n  FormRenderProps,\n  FormMeta,\n  ValidateTrigger,\n} from '@flowgram.ai/free-layout-editor';\nimport {\n  DisplayOutputs,\n  JsonSchemaEditor,\n  provideJsonSchemaOutputs,\n  syncVariableTitle,\n} from '@flowgram.ai/form-materials';\n\nimport { FlowNodeJSON, JsonSchema } from '../../typings';\nimport { useIsSidebar } from '../../hooks';\nimport { FormHeader, FormContent } from '../../form-components';\n\nexport const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => {\n  const isSidebar = useIsSidebar();\n  if (isSidebar) {\n    return (\n      <>\n        <FormHeader />\n        <FormContent>\n          <Field\n            name=\"outputs\"\n            render={({ field: { value, onChange } }: FieldRenderProps<JsonSchema>) => (\n              <>\n                <JsonSchemaEditor\n                  value={value}\n                  onChange={(value) => onChange(value as JsonSchema)}\n                />\n              </>\n            )}\n          />\n        </FormContent>\n      </>\n    );\n  }\n  return (\n    <>\n      <FormHeader />\n      <FormContent>\n        <DisplayOutputs displayFromScope />\n      </FormContent>\n    </>\n  );\n};\n\nexport const formMeta: FormMeta<FlowNodeJSON> = {\n  render: renderForm,\n  validateTrigger: ValidateTrigger.onChange,\n  validate: {\n    title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),\n  },\n  effect: {\n    title: syncVariableTitle,\n    outputs: provideJsonSchemaOutputs,\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/start/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowNodeRegistry } from '../../typings';\nimport iconStart from '../../assets/icon-start.jpg';\nimport { formMeta } from './form-meta';\nimport { WorkflowNodeType } from '../constants';\n\nexport const StartNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Start,\n  meta: {\n    isStart: true,\n    deleteDisable: true,\n    copyDisable: true,\n    nodePanelVisible: false,\n    defaultPorts: [{ type: 'output' }],\n    size: {\n      width: 360,\n      height: 211,\n    },\n  },\n  info: {\n    icon: iconStart,\n    description:\n      'The starting node of the workflow, used to set the information needed to initiate the workflow.',\n  },\n  /**\n   * Render node via formMeta\n   */\n  formMeta,\n  /**\n   * Start Node cannot be added\n   */\n  canAdd() {\n    return false;\n  },\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/tool/index.tsx",
    "content": "import { nanoid } from 'nanoid';\n\nimport { WorkflowNodeType } from '../constants';\nimport { FlowNodeRegistry } from '../../typings';\nimport iconTool from '../../assets/icon-tool.svg';\nimport { defaultFormMeta } from '../default-form-meta';\n\nlet index = 0;\n\nexport const ToolNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Tool,\n  info: {\n    icon: iconTool,\n    description: 'Invoke a local ai4j tool or MCP-exposed function.',\n  },\n  meta: {\n    size: {\n      width: 360,\n      height: 360,\n    },\n  },\n  onAdd() {\n    return {\n      id: `tool_${nanoid(5)}`,\n      type: WorkflowNodeType.Tool,\n      data: {\n        title: `Tool_${++index}`,\n        inputsValues: {\n          toolName: {\n            type: 'constant',\n            content: 'queryTrainInfo',\n          },\n          argumentsJson: {\n            type: 'template',\n            content: '{\"type\":40}',\n          },\n        },\n        inputs: {\n          type: 'object',\n          required: ['toolName'],\n          properties: {\n            toolName: {\n              type: 'string',\n            },\n            argumentsJson: {\n              type: 'string',\n            },\n          },\n        },\n        outputs: {\n          type: 'object',\n          properties: {\n            result: {\n              type: 'string',\n            },\n            rawOutput: {\n              type: 'string',\n            },\n          },\n        },\n      },\n    };\n  },\n  formMeta: defaultFormMeta,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/variable/form-meta.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor';\nimport { AssignRows, createInferAssignPlugin, DisplayOutputs } from '@flowgram.ai/form-materials';\n\nimport { FormHeader, FormContent } from '../../form-components';\nimport { VariableNodeJSON } from './types';\nimport { defaultFormMeta } from '../default-form-meta';\nimport { useIsSidebar } from '../../hooks';\n\nexport const FormRender = ({ form }: FormRenderProps<VariableNodeJSON>) => {\n  const isSidebar = useIsSidebar();\n\n  return (\n    <>\n      <FormHeader />\n      <FormContent>\n        {isSidebar ? <AssignRows name=\"assign\" /> : <DisplayOutputs displayFromScope />}\n      </FormContent>\n    </>\n  );\n};\n\nexport const formMeta: FormMeta = {\n  render: (props) => <FormRender {...props} />,\n  effect: defaultFormMeta.effect,\n  plugins: [\n    createInferAssignPlugin({\n      assignKey: 'assign',\n      outputKey: 'outputs',\n    }),\n  ],\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/variable/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { nanoid } from 'nanoid';\n\nimport { WorkflowNodeType } from '../constants';\nimport { FlowNodeRegistry } from '../../typings';\nimport iconVariable from '../../assets/icon-variable.png';\nimport { formMeta } from './form-meta';\nimport { inferVariableOutputsSchema } from './output-schema';\n\nlet index = 0;\n\nexport const VariableNodeRegistry: FlowNodeRegistry = {\n  type: WorkflowNodeType.Variable,\n  info: {\n    icon: iconVariable,\n    description: 'Variable Assign and Declaration',\n  },\n  meta: {\n    size: {\n      width: 360,\n      height: 390,\n    },\n  },\n  onAdd() {\n    const assign = [\n      {\n        operator: 'declare' as const,\n        left: 'sum',\n        right: {\n          type: 'constant' as const,\n          content: 0,\n          schema: { type: 'integer' as const },\n        },\n      },\n    ];\n\n    return {\n      id: `variable_${nanoid(5)}`,\n      type: 'variable',\n      data: {\n        title: `Variable_${++index}`,\n        assign,\n        outputs: inferVariableOutputsSchema(assign, new Map()),\n      },\n    };\n  },\n  formMeta: formMeta,\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/variable/output-schema.ts",
    "content": "import { FlowValueUtils } from '@flowgram.ai/form-materials';\nimport type { AssignValueType, IFlowValue } from '@flowgram.ai/form-materials';\n\nimport type { JsonSchema } from '../../typings/json-schema';\nimport type { FlowDocumentJSON, FlowNodeJSON } from '../../typings/node';\nimport { WorkflowNodeType } from '../constants';\n\nconst cloneSchema = <T,>(value: T): T => JSON.parse(JSON.stringify(value));\n\nconst resolveSchemaByPath = (schema: JsonSchema | undefined, path: string[]): JsonSchema | undefined => {\n  let current: JsonSchema | undefined = schema;\n  for (const segment of path) {\n    if (!current || current.type !== 'object') {\n      return undefined;\n    }\n    const properties = current.properties as Record<string, JsonSchema> | undefined;\n    current = properties?.[segment];\n  }\n  return current ? cloneSchema(current) : undefined;\n};\n\nconst inferValueSchema = (\n  value: IFlowValue | undefined,\n  nodeIndex: Map<string, FlowNodeJSON>\n): JsonSchema | undefined => {\n  if (!value) {\n    return undefined;\n  }\n  if (FlowValueUtils.isConstant(value)) {\n    return FlowValueUtils.inferConstantJsonSchema(value);\n  }\n  if (FlowValueUtils.isTemplate(value) || FlowValueUtils.isExpression(value)) {\n    return { type: 'string' };\n  }\n  if (!FlowValueUtils.isRef(value)) {\n    return undefined;\n  }\n\n  const keyPath = value.content ?? [];\n  if (keyPath.length === 0) {\n    return undefined;\n  }\n  const [nodeId, ...path] = keyPath;\n  const targetNode = nodeIndex.get(nodeId);\n  return resolveSchemaByPath(targetNode?.data?.outputs, path);\n};\n\nconst normalizeAssignValueSchema = (value: IFlowValue | undefined): void => {\n  if (!value || !FlowValueUtils.isConstant(value) || value.schema) {\n    return;\n  }\n  const inferredSchema = FlowValueUtils.inferConstantJsonSchema(value);\n  if (inferredSchema) {\n    value.schema = inferredSchema as JsonSchema;\n  }\n};\n\nconst normalizeVariableAssignRows = (assign: AssignValueType[] | undefined): void => {\n  for (const row of assign ?? []) {\n    normalizeAssignValueSchema(row.right);\n  }\n};\n\nexport const inferVariableOutputsSchema = (\n  assign: AssignValueType[] | undefined,\n  nodeIndex: Map<string, FlowNodeJSON>\n): JsonSchema => {\n  const properties: Record<string, JsonSchema> = {};\n  const required: string[] = [];\n\n  for (const row of assign ?? []) {\n    if (row.operator !== 'declare' || !row.left) {\n      continue;\n    }\n    const key = row.left.trim();\n    if (!key) {\n      continue;\n    }\n    const schema = inferValueSchema(row.right, nodeIndex) ?? { type: 'string' };\n    properties[key] = schema;\n    required.push(key);\n  }\n\n  return {\n    type: 'object',\n    required,\n    properties,\n  };\n};\n\nexport const normalizeVariableNodeOutputs = (document: FlowDocumentJSON): FlowDocumentJSON => {\n  const nodeIndex = new Map<string, FlowNodeJSON>();\n  for (const node of document.nodes) {\n    nodeIndex.set(node.id, node);\n  }\n\n  for (let pass = 0; pass < document.nodes.length; pass += 1) {\n    let changed = false;\n    for (const node of document.nodes) {\n      if (node.type !== WorkflowNodeType.Variable) {\n        continue;\n      }\n      normalizeVariableAssignRows(node.data?.assign);\n      const nextOutputs = inferVariableOutputsSchema(node.data?.assign, nodeIndex);\n      const prevOutputs = node.data?.outputs;\n      if (JSON.stringify(prevOutputs ?? null) === JSON.stringify(nextOutputs)) {\n        continue;\n      }\n      node.data.outputs = nextOutputs;\n      changed = true;\n    }\n    if (!changed) {\n      break;\n    }\n  }\n\n  return document;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/nodes/variable/types.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowNodeJSON } from '@flowgram.ai/free-layout-editor';\nimport { AssignValueType, IJsonSchema } from '@flowgram.ai/form-materials';\n\nexport interface VariableNodeJSON extends FlowNodeJSON {\n  data: {\n    title: string;\n    assign: AssignValueType[];\n    outputs: IJsonSchema<'object'>;\n  };\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/context-menu-plugin/context-menu-layer.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';\nimport {\n  Layer,\n  injectable,\n  inject,\n  FreeLayoutPluginContext,\n  WorkflowHoverService,\n  WorkflowNodeEntity,\n  WorkflowNodeJSON,\n  WorkflowSelectService,\n  WorkflowDocument,\n  PositionSchema,\n  WorkflowDragService,\n} from '@flowgram.ai/free-layout-editor';\nimport { ContainerUtils } from '@flowgram.ai/free-container-plugin';\n\n@injectable()\nexport class ContextMenuLayer extends Layer {\n  @inject(FreeLayoutPluginContext) ctx: FreeLayoutPluginContext;\n\n  @inject(WorkflowNodePanelService) nodePanelService: WorkflowNodePanelService;\n\n  @inject(WorkflowHoverService) hoverService: WorkflowHoverService;\n\n  @inject(WorkflowSelectService) selectService: WorkflowSelectService;\n\n  @inject(WorkflowDocument) document: WorkflowDocument;\n\n  @inject(WorkflowDragService) dragService: WorkflowDragService;\n\n  onReady() {\n    this.listenPlaygroundEvent('contextmenu', (e) => {\n      if (this.config.readonlyOrDisabled) return;\n      this.openNodePanel(e);\n      e.preventDefault();\n      e.stopPropagation();\n    });\n  }\n\n  openNodePanel(e: MouseEvent) {\n    const mousePos = this.getPosFromMouseEvent(e);\n    const containerNode = this.getContainerNode(mousePos);\n    this.nodePanelService.callNodePanel({\n      position: mousePos,\n      containerNode,\n      panelProps: {},\n      // handle node selection from panel - 处理从面板中选择节点\n      onSelect: async (panelParams?: NodePanelResult) => {\n        if (!panelParams) {\n          return;\n        }\n        const { nodeType, nodeJSON } = panelParams;\n        const position = this.dragService.adjustSubNodePosition(nodeType, containerNode, mousePos);\n        // create new workflow node based on selected type - 根据选择的类型创建新的工作流节点\n        const node: WorkflowNodeEntity = this.ctx.document.createWorkflowNodeByType(\n          nodeType,\n          position,\n          nodeJSON ?? ({} as WorkflowNodeJSON),\n          containerNode?.id\n        );\n        // select the newly created node - 选择新创建的节点\n        this.selectService.select(node);\n      },\n      // handle panel close - 处理面板关闭\n      onClose: () => {},\n    });\n  }\n\n  private getContainerNode(mousePos: PositionSchema): WorkflowNodeEntity | undefined {\n    const allNodes = this.document.getAllNodes();\n    const containerTransforms = ContainerUtils.getContainerTransforms(allNodes);\n    const collisionTransform = ContainerUtils.getCollisionTransform({\n      targetPoint: mousePos,\n      transforms: containerTransforms,\n      document: this.document,\n    });\n    return collisionTransform?.entity;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/context-menu-plugin/context-menu-plugin.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  definePluginCreator,\n  PluginCreator,\n  FreeLayoutPluginContext,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { ContextMenuLayer } from './context-menu-layer';\n\nexport interface ContextMenuPluginOptions {}\n\n/**\n * Creates a plugin of contextmenu\n * @param ctx - The plugin context, containing the document and other relevant information.\n * @param options - Plugin options, currently an empty object.\n */\nexport const createContextMenuPlugin: PluginCreator<ContextMenuPluginOptions> = definePluginCreator<\n  ContextMenuPluginOptions,\n  FreeLayoutPluginContext\n>({\n  onInit(ctx, options) {\n    ctx.playground.registerLayer(ContextMenuLayer);\n  },\n});\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/context-menu-plugin/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { createContextMenuPlugin } from './context-menu-plugin';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { createContextMenuPlugin } from './context-menu-plugin';\nexport { createRuntimePlugin } from './runtime-plugin';\nexport { createVariablePanelPlugin } from './variable-panel-plugin';\nexport { createPanelManagerPlugin } from './panel-manager-plugin';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/panel-manager-plugin/constants.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport enum PanelType {\n  NodeFormPanel = 'nodeFormPanel',\n  TestRunFormPanel = 'testRunFormPanel',\n  ProblemPanel = 'problemPanel',\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/panel-manager-plugin/hooks.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { usePanelManager } from '@flowgram.ai/panel-manager-plugin';\n\nimport type { NodeFormPanelProps } from '../../components/sidebar/node-form-panel';\nimport { PanelType } from './constants';\n\nexport const useNodeFormPanel = () => {\n  const panelManager = usePanelManager();\n\n  const open = (props: NodeFormPanelProps) => {\n    panelManager.open(PanelType.NodeFormPanel, 'right', {\n      props: props,\n    });\n  };\n  const close = () => panelManager.close(PanelType.NodeFormPanel);\n\n  return { open, close };\n};\n\nexport const useTestRunFormPanel = () => {\n  const panelManager = usePanelManager();\n\n  const open = () => {\n    panelManager.open(PanelType.TestRunFormPanel, 'bottom');\n  };\n  const close = () => panelManager.close(PanelType.TestRunFormPanel);\n\n  return { open, close };\n};\n\nexport const useProblemPanel = () => {\n  const panelManager = usePanelManager();\n\n  const open = () => {\n    panelManager.open(PanelType.ProblemPanel, 'bottom');\n  };\n  const close = () => panelManager.close(PanelType.ProblemPanel);\n\n  return { open, close };\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/panel-manager-plugin/index.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  createPanelManagerPlugin as create,\n  PanelFactory,\n} from '@flowgram.ai/panel-manager-plugin';\n\nimport { DemoTools } from '../../components/tools';\nimport {\n  TestRunSidePanel,\n  TestRunSidePanelProps,\n} from '../../components/testrun/testrun-panel/test-run-panel';\nimport { NodeFormPanel, NodeFormPanelProps } from '../../components/sidebar/node-form-panel';\nimport { ProblemPanel } from '../../components/problem-panel/problem-panel';\nimport { PanelType } from './constants';\n\nconst nodeFormPanelFactory: PanelFactory<NodeFormPanelProps> = {\n  key: PanelType.NodeFormPanel,\n  defaultSize: 500,\n  maxSize: 800,\n  minSize: 300,\n  render: (props: NodeFormPanelProps) => <NodeFormPanel {...props} />,\n};\n\nconst testRunPanelFactory: PanelFactory<TestRunSidePanelProps> = {\n  key: PanelType.TestRunFormPanel,\n  defaultSize: 300,\n  render: () => <TestRunSidePanel />,\n};\n\nconst problemPanelFactory: PanelFactory<void> = {\n  key: PanelType.ProblemPanel,\n  defaultSize: 200,\n  render: () => <ProblemPanel />,\n};\n\nexport const createPanelManagerPlugin = () =>\n  create({\n    factories: [nodeFormPanelFactory, testRunPanelFactory, problemPanelFactory],\n    layerProps: {\n      children: <DemoTools />,\n    },\n  });\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/client/base-client.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FlowGramAPIName, IRuntimeClient } from '@flowgram.ai/runtime-interface';\nimport { injectable } from '@flowgram.ai/free-layout-editor';\n\nimport { FlowGramTraceView } from '../trace';\n\n@injectable()\nexport class WorkflowRuntimeClient implements IRuntimeClient {\n  constructor() {}\n\n  public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun];\n\n  public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport];\n\n  public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult];\n\n  public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel];\n\n  public [FlowGramAPIName.TaskValidate]: IRuntimeClient[FlowGramAPIName.TaskValidate];\n\n  public getLatestTrace(): FlowGramTraceView | undefined {\n    return undefined;\n  }\n\n  public clearLatestTrace(): void {}\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/client/browser-client/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n/* eslint-disable no-console */\nimport { FlowGramAPIName, IRuntimeClient } from '@flowgram.ai/runtime-interface';\nimport { injectable } from '@flowgram.ai/free-layout-editor';\n\nimport { WorkflowRuntimeClient } from '../base-client';\n\n@injectable()\nexport class WorkflowRuntimeBrowserClient extends WorkflowRuntimeClient implements IRuntimeClient {\n  public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun] = async (input) => {\n    const { TaskRunAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载\n    return TaskRunAPI(input);\n  };\n\n  public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport] = async (\n    input\n  ) => {\n    const { TaskReportAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载\n    return TaskReportAPI(input);\n  };\n\n  public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult] = async (\n    input\n  ) => {\n    const { TaskResultAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载\n    return TaskResultAPI(input);\n  };\n\n  public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel] = async (\n    input\n  ) => {\n    const { TaskCancelAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载\n    return TaskCancelAPI(input);\n  };\n\n  public [FlowGramAPIName.TaskValidate]: IRuntimeClient[FlowGramAPIName.TaskValidate] = async (\n    input\n  ) => {\n    const { TaskValidateAPI } = await import('@flowgram.ai/runtime-js'); // Load on demand - 按需加载\n    return TaskValidateAPI(input);\n  };\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/client/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { WorkflowRuntimeClient } from './base-client';\nexport { WorkflowRuntimeBrowserClient } from './browser-client';\nexport { WorkflowRuntimeServerClient } from './server-client';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/client/server-client/constant.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { ServerConfig } from '../../type';\n\nexport const DEFAULT_SERVER_CONFIG: ServerConfig = {\n  domain: '',\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/client/server-client/index.ts",
    "content": "import {\n  FlowGramAPIName,\n  IReport,\n  IRuntimeClient,\n  NodeReport,\n  Snapshot,\n  WorkflowMessageType,\n  WorkflowMessages,\n  WorkflowInputs,\n  WorkflowOutputs,\n  WorkflowStatus,\n} from '@flowgram.ai/runtime-interface';\nimport { injectable } from '@flowgram.ai/free-layout-editor';\nimport { nanoid } from 'nanoid';\n\nimport { ServerConfig } from '../../type';\nimport { FlowGramTraceView } from '../../trace';\nimport { WorkflowRuntimeClient } from '../base-client';\nimport type { ServerError } from './type';\nimport { DEFAULT_SERVER_CONFIG } from './constant';\nimport { serializeWorkflowForBackend } from '../../../../utils/backend-workflow';\n\ninterface BackendTaskRunResponse {\n  taskId?: string;\n}\n\ninterface BackendTaskValidateResponse {\n  valid: boolean;\n  errors?: string[];\n}\n\ninterface BackendTaskCancelResponse {\n  success?: boolean;\n}\n\ninterface BackendWorkflowStatus {\n  status?: string;\n  terminated?: boolean;\n  startTime?: number;\n  endTime?: number;\n  error?: string;\n}\n\ninterface BackendNodeStatus {\n  status?: string;\n  terminated?: boolean;\n  startTime?: number;\n  endTime?: number;\n  error?: string;\n  inputs?: WorkflowInputs;\n  outputs?: WorkflowOutputs;\n}\n\ninterface BackendTaskReportResponse {\n  taskId: string;\n  inputs?: WorkflowInputs;\n  outputs?: WorkflowOutputs;\n  workflow?: BackendWorkflowStatus;\n  nodes?: Record<string, BackendNodeStatus>;\n  trace?: FlowGramTraceView;\n}\n\ntype TraceMetrics = NonNullable<NonNullable<FlowGramTraceView['summary']>['metrics']>;\ntype TraceNodeRecord = NonNullable<FlowGramTraceView['nodes']>;\n\nconst toRecord = (value: unknown): Record<string, unknown> | undefined => {\n  if (!value || typeof value !== 'object' || Array.isArray(value)) {\n    return undefined;\n  }\n  return value as Record<string, unknown>;\n};\n\nconst pickValue = (source: unknown, ...keys: string[]): unknown => {\n  const record = toRecord(source);\n  if (!record) {\n    return undefined;\n  }\n  for (const key of keys) {\n    if (record[key] !== undefined && record[key] !== null) {\n      return record[key];\n    }\n  }\n  return undefined;\n};\n\nconst toNumber = (value: unknown): number | undefined => {\n  if (value === undefined || value === null) {\n    return undefined;\n  }\n  if (typeof value === 'number' && Number.isFinite(value)) {\n    return value;\n  }\n  if (typeof value === 'string' && value.trim()) {\n    const parsed = Number(value);\n    return Number.isFinite(parsed) ? parsed : undefined;\n  }\n  return undefined;\n};\n\nconst toText = (value: unknown): string | undefined => {\n  if (value === undefined || value === null) {\n    return undefined;\n  }\n  const text = String(value).trim();\n  return text ? text : undefined;\n};\n\nconst sumMetric = (left?: number, right?: number): number | undefined => {\n  if (left === undefined) {\n    return right;\n  }\n  if (right === undefined) {\n    return left;\n  }\n  return left + right;\n};\n\nconst hasMetricValue = (metrics?: TraceMetrics): boolean =>\n  Boolean(\n    metrics\n    && (metrics.promptTokens !== undefined\n      || metrics.completionTokens !== undefined\n      || metrics.totalTokens !== undefined\n      || metrics.inputCost !== undefined\n      || metrics.outputCost !== undefined\n      || metrics.totalCost !== undefined)\n  );\n\nconst buildNodeMetrics = (\n  existing: TraceMetrics | undefined,\n  nodeStatus?: BackendNodeStatus\n): TraceMetrics | undefined => {\n  const outputs = toRecord(nodeStatus?.outputs);\n  const metrics = pickValue(outputs, 'metrics');\n  const rawResponse = pickValue(outputs, 'rawResponse');\n  const usage = pickValue(rawResponse, 'usage');\n\n  const promptTokens =\n    toNumber(pickValue(existing, 'promptTokens'))\n    ?? toNumber(pickValue(metrics, 'promptTokens', 'prompt_tokens'))\n    ?? toNumber(pickValue(usage, 'promptTokens', 'prompt_tokens', 'input'));\n  const completionTokens =\n    toNumber(pickValue(existing, 'completionTokens'))\n    ?? toNumber(pickValue(metrics, 'completionTokens', 'completion_tokens'))\n    ?? toNumber(pickValue(usage, 'completionTokens', 'completion_tokens', 'output'));\n  const totalTokens =\n    toNumber(pickValue(existing, 'totalTokens'))\n    ?? toNumber(pickValue(metrics, 'totalTokens', 'total_tokens'))\n    ?? toNumber(pickValue(usage, 'totalTokens', 'total_tokens', 'total'));\n  const inputCost =\n    toNumber(pickValue(existing, 'inputCost'))\n    ?? toNumber(pickValue(metrics, 'inputCost', 'input_cost'));\n  const outputCost =\n    toNumber(pickValue(existing, 'outputCost'))\n    ?? toNumber(pickValue(metrics, 'outputCost', 'output_cost'));\n  const totalCost =\n    toNumber(pickValue(existing, 'totalCost'))\n    ?? toNumber(pickValue(metrics, 'totalCost', 'total_cost'));\n  const currency =\n    toText(pickValue(existing, 'currency'))\n    ?? toText(pickValue(metrics, 'currency'));\n\n  if (\n    promptTokens === undefined\n    && completionTokens === undefined\n    && totalTokens === undefined\n    && inputCost === undefined\n    && outputCost === undefined\n    && totalCost === undefined\n    && currency === undefined\n  ) {\n    return existing;\n  }\n\n  return {\n    promptTokens,\n    completionTokens,\n    totalTokens,\n    inputCost,\n    outputCost,\n    totalCost,\n    currency,\n  };\n};\n\nconst buildNodeModel = (\n  existingModel: string | undefined,\n  nodeStatus?: BackendNodeStatus\n): string | undefined =>\n  existingModel\n  ?? toText(pickValue(pickValue(nodeStatus?.outputs, 'metrics'), 'model'))\n  ?? toText(pickValue(pickValue(nodeStatus?.outputs, 'rawResponse'), 'model'))\n  ?? toText(pickValue(nodeStatus?.inputs, 'model'))\n  ?? toText(pickValue(nodeStatus?.inputs, 'modelName'));\n\nconst backfillTraceMetrics = (\n  trace: FlowGramTraceView | undefined,\n  nodes?: Record<string, BackendNodeStatus>\n): FlowGramTraceView | undefined => {\n  if (!trace || !nodes) {\n    return trace;\n  }\n\n  const nextNodes: TraceNodeRecord = {};\n  let promptTokens: number | undefined;\n  let completionTokens: number | undefined;\n  let totalTokens: number | undefined;\n  let inputCost: number | undefined;\n  let outputCost: number | undefined;\n  let totalCost: number | undefined;\n  let currency = trace.summary?.metrics?.currency;\n  let llmNodeCount = 0;\n\n  Object.entries(trace.nodes ?? {}).forEach(([nodeId, traceNode]) => {\n    const nodeStatus = nodes[nodeId];\n    const metrics = buildNodeMetrics(traceNode?.metrics, nodeStatus);\n    const model = buildNodeModel(traceNode?.model, nodeStatus);\n    nextNodes[nodeId] = {\n      ...traceNode,\n      model,\n      metrics,\n    };\n    if (model || hasMetricValue(metrics)) {\n      llmNodeCount += 1;\n      promptTokens = sumMetric(promptTokens, metrics?.promptTokens);\n      completionTokens = sumMetric(completionTokens, metrics?.completionTokens);\n      totalTokens = sumMetric(totalTokens, metrics?.totalTokens);\n      inputCost = sumMetric(inputCost, metrics?.inputCost);\n      outputCost = sumMetric(outputCost, metrics?.outputCost);\n      totalCost = sumMetric(totalCost, metrics?.totalCost);\n      currency = currency ?? metrics?.currency;\n    }\n  });\n\n  return {\n    ...trace,\n    nodes: nextNodes,\n    summary: {\n      ...trace.summary,\n      llmNodeCount: trace.summary?.llmNodeCount ?? llmNodeCount,\n      metrics: {\n        ...trace.summary?.metrics,\n        promptTokens: trace.summary?.metrics?.promptTokens ?? promptTokens,\n        completionTokens: trace.summary?.metrics?.completionTokens ?? completionTokens,\n        totalTokens: trace.summary?.metrics?.totalTokens ?? totalTokens,\n        inputCost: trace.summary?.metrics?.inputCost ?? inputCost,\n        outputCost: trace.summary?.metrics?.outputCost ?? outputCost,\n        totalCost: trace.summary?.metrics?.totalCost ?? totalCost,\n        currency,\n      },\n    },\n  };\n};\n\nconst mergeTraceViews = (\n  primary: FlowGramTraceView | undefined,\n  fallback: FlowGramTraceView | undefined\n): FlowGramTraceView | undefined => {\n  if (!primary) {\n    return fallback;\n  }\n  if (!fallback) {\n    return primary;\n  }\n\n  const mergedNodes: TraceNodeRecord = { ...(fallback.nodes ?? {}), ...(primary.nodes ?? {}) };\n\n  Object.entries(mergedNodes).forEach(([nodeId, node]) => {\n    const fallbackNode = fallback.nodes?.[nodeId];\n    if (!fallbackNode) {\n      return;\n    }\n    mergedNodes[nodeId] = {\n      ...fallbackNode,\n      ...node,\n      model: node?.model ?? fallbackNode.model,\n      metrics: {\n        ...fallbackNode.metrics,\n        ...node?.metrics,\n        promptTokens: node?.metrics?.promptTokens ?? fallbackNode.metrics?.promptTokens,\n        completionTokens: node?.metrics?.completionTokens ?? fallbackNode.metrics?.completionTokens,\n        totalTokens: node?.metrics?.totalTokens ?? fallbackNode.metrics?.totalTokens,\n        inputCost: node?.metrics?.inputCost ?? fallbackNode.metrics?.inputCost,\n        outputCost: node?.metrics?.outputCost ?? fallbackNode.metrics?.outputCost,\n        totalCost: node?.metrics?.totalCost ?? fallbackNode.metrics?.totalCost,\n        currency: node?.metrics?.currency ?? fallbackNode.metrics?.currency,\n      },\n    };\n  });\n\n  return {\n    ...fallback,\n    ...primary,\n    nodes: mergedNodes,\n    summary: {\n      ...fallback.summary,\n      ...primary.summary,\n      llmNodeCount: primary.summary?.llmNodeCount ?? fallback.summary?.llmNodeCount,\n      metrics: {\n        ...fallback.summary?.metrics,\n        ...primary.summary?.metrics,\n        promptTokens: primary.summary?.metrics?.promptTokens ?? fallback.summary?.metrics?.promptTokens,\n        completionTokens: primary.summary?.metrics?.completionTokens ?? fallback.summary?.metrics?.completionTokens,\n        totalTokens: primary.summary?.metrics?.totalTokens ?? fallback.summary?.metrics?.totalTokens,\n        inputCost: primary.summary?.metrics?.inputCost ?? fallback.summary?.metrics?.inputCost,\n        outputCost: primary.summary?.metrics?.outputCost ?? fallback.summary?.metrics?.outputCost,\n        totalCost: primary.summary?.metrics?.totalCost ?? fallback.summary?.metrics?.totalCost,\n        currency: primary.summary?.metrics?.currency ?? fallback.summary?.metrics?.currency,\n      },\n    },\n  };\n};\n\ninterface BackendTaskResultResponse {\n  taskId: string;\n  status?: string;\n  terminated?: boolean;\n  error?: string;\n  result?: WorkflowOutputs;\n  trace?: FlowGramTraceView;\n}\n\n@injectable()\nexport class WorkflowRuntimeServerClient extends WorkflowRuntimeClient implements IRuntimeClient {\n  private config: ServerConfig = DEFAULT_SERVER_CONFIG;\n\n  private latestTrace?: FlowGramTraceView;\n\n  public init(config: ServerConfig) {\n    this.config = config;\n  }\n\n  public [FlowGramAPIName.TaskRun]: IRuntimeClient[FlowGramAPIName.TaskRun] = async (input) => {\n    const normalizedInput = input as {\n      schema: string;\n      inputs: Record<string, unknown>;\n    };\n\n    const output = await this.request<BackendTaskRunResponse>('/flowgram/tasks/run', {\n      method: 'POST',\n      body: JSON.stringify({\n        schema: serializeWorkflowForBackend(normalizedInput.schema),\n        inputs: normalizedInput.inputs,\n      }),\n    });\n\n    if (!output?.taskId) {\n      return undefined;\n    }\n\n    return {\n      taskID: output.taskId,\n    };\n  };\n\n  public [FlowGramAPIName.TaskReport]: IRuntimeClient[FlowGramAPIName.TaskReport] = async (\n    input\n  ) => {\n    const normalizedInput = input as {\n      taskID: string;\n    };\n\n    const output = await this.request<BackendTaskReportResponse>(\n      `/flowgram/tasks/${normalizedInput.taskID}/report`,\n      {\n        method: 'GET',\n      }\n    );\n\n    if (!output) {\n      return undefined;\n    }\n\n    this.latestTrace = backfillTraceMetrics(output.trace, output.nodes);\n\n    return this.toRuntimeReport(output);\n  };\n\n  public [FlowGramAPIName.TaskResult]: IRuntimeClient[FlowGramAPIName.TaskResult] = async (\n    input\n  ) => {\n    const normalizedInput = input as {\n      taskID: string;\n    };\n\n    const output = await this.request<BackendTaskResultResponse>(\n      `/flowgram/tasks/${normalizedInput.taskID}/result`,\n      {\n        method: 'GET',\n      }\n    );\n\n    this.latestTrace = mergeTraceViews(output?.trace, this.latestTrace);\n\n    return output?.result;\n  };\n\n  public [FlowGramAPIName.TaskCancel]: IRuntimeClient[FlowGramAPIName.TaskCancel] = async (\n    input\n  ) => {\n    const normalizedInput = input as {\n      taskID: string;\n    };\n\n    const output = await this.request<BackendTaskCancelResponse>(\n      `/flowgram/tasks/${normalizedInput.taskID}/cancel`,\n      {\n        method: 'POST',\n      }\n    );\n\n    return {\n      success: Boolean(output?.success),\n    };\n  };\n\n  public [FlowGramAPIName.TaskValidate]: IRuntimeClient[FlowGramAPIName.TaskValidate] = async (\n    input\n  ) => {\n    const normalizedInput = input as {\n      schema: string;\n      inputs: Record<string, unknown>;\n    };\n\n    const output = await this.request<BackendTaskValidateResponse>('/flowgram/tasks/validate', {\n      method: 'POST',\n      body: JSON.stringify({\n        schema: serializeWorkflowForBackend(normalizedInput.schema),\n        inputs: normalizedInput.inputs,\n      }),\n    });\n\n    if (!output) {\n      return undefined;\n    }\n\n    return {\n      valid: output.valid,\n      errors: output.errors,\n    };\n  };\n\n  public getLatestTrace(): FlowGramTraceView | undefined {\n    return this.latestTrace ? JSON.parse(JSON.stringify(this.latestTrace)) : undefined;\n  }\n\n  public clearLatestTrace(): void {\n    this.latestTrace = undefined;\n  }\n\n  private async request<T>(path: string, init: RequestInit): Promise<T | undefined> {\n    try {\n      const response = await fetch(this.toUrl(path), {\n        ...init,\n        headers: {\n          'Content-Type': 'application/json',\n          ...(init.headers ?? {}),\n        },\n      });\n\n      if (!response.ok) {\n        const errorResponse = await this.safeJson<ServerError>(response);\n        console.error('FlowGram request failed', path, response.status, errorResponse);\n        return undefined;\n      }\n\n      return this.safeJson<T>(response);\n    } catch (error) {\n      console.error('FlowGram request error', path, error);\n      return undefined;\n    }\n  }\n\n  private async safeJson<T>(response: Response): Promise<T | undefined> {\n    try {\n      return (await response.json()) as T;\n    } catch (error) {\n      return undefined;\n    }\n  }\n\n  private toRuntimeReport(output: BackendTaskReportResponse): IReport {\n    const workflowStatus = this.toStatusData(output.workflow);\n    const reports: Record<string, NodeReport> = {};\n\n    Object.entries(output.nodes ?? {}).forEach(([nodeID, nodeStatus]) => {\n      reports[nodeID] = {\n        id: nodeID,\n        snapshots: this.toSnapshots(nodeID, nodeStatus),\n        ...this.toStatusData(nodeStatus),\n      };\n    });\n\n    return {\n      id: output.taskId,\n      inputs: output.inputs ?? {},\n      outputs: output.outputs ?? {},\n      workflowStatus,\n      reports,\n      messages: this.toMessages(output),\n    };\n  }\n\n  private toSnapshots(nodeID: string, nodeStatus?: BackendNodeStatus): Snapshot[] {\n    if (!nodeStatus) {\n      return [];\n    }\n\n    const inputs = nodeStatus.inputs ?? {};\n    const outputs = nodeStatus.outputs ?? {};\n    const hasInputs = Object.keys(inputs).length > 0;\n    const hasOutputs = Object.keys(outputs).length > 0;\n\n    if (!hasInputs && !hasOutputs && !nodeStatus.error) {\n      return [];\n    }\n\n    return [\n      {\n        id: nanoid(),\n        nodeID,\n        inputs,\n        outputs,\n        data: {},\n        ...(nodeStatus.error ? { error: nodeStatus.error } : {}),\n      },\n    ];\n  }\n\n  private toMessages(output: BackendTaskReportResponse): WorkflowMessages {\n    const messages: WorkflowMessages = {\n      [WorkflowMessageType.Log]: [],\n      [WorkflowMessageType.Info]: [],\n      [WorkflowMessageType.Debug]: [],\n      [WorkflowMessageType.Error]: [],\n      [WorkflowMessageType.Warn]: [],\n    };\n\n    if (output.workflow?.error) {\n      messages.error.push({\n        id: nanoid(),\n        type: WorkflowMessageType.Error,\n        timestamp: output.workflow.endTime ?? output.workflow.startTime ?? Date.now(),\n        message: output.workflow.error,\n      });\n    }\n\n    Object.entries(output.nodes ?? {}).forEach(([nodeID, nodeStatus]) => {\n      if (!nodeStatus?.error) {\n        return;\n      }\n      messages.error.push({\n        id: nanoid(),\n        type: WorkflowMessageType.Error,\n        nodeID,\n        timestamp: nodeStatus.endTime ?? nodeStatus.startTime ?? Date.now(),\n        message: nodeStatus.error,\n      });\n    });\n\n    return messages;\n  }\n\n  private toStatusData(status?: BackendWorkflowStatus | BackendNodeStatus) {\n    const mappedStatus = this.mapStatus(status?.status);\n    const terminated = status?.terminated ?? this.isTerminal(mappedStatus);\n    const startTime = status?.startTime ?? status?.endTime ?? 0;\n    const endTime = status?.endTime;\n    const timeCost =\n      startTime > 0 ? Math.max((endTime ?? startTime) - startTime, 0) : 0;\n\n    return {\n      status: mappedStatus,\n      terminated,\n      startTime,\n      endTime,\n      timeCost,\n    };\n  }\n\n  private mapStatus(status?: string): WorkflowStatus {\n    const normalized = status?.trim().toLowerCase();\n    switch (normalized) {\n      case 'success':\n      case 'succeeded':\n        return WorkflowStatus.Succeeded;\n      case 'processing':\n      case 'running':\n        return WorkflowStatus.Processing;\n      case 'failed':\n      case 'error':\n        return WorkflowStatus.Failed;\n      case 'canceled':\n      case 'cancelled':\n        return WorkflowStatus.Cancelled;\n      case 'pending':\n      default:\n        return WorkflowStatus.Pending;\n    }\n  }\n\n  private isTerminal(status: WorkflowStatus): boolean {\n    return [WorkflowStatus.Succeeded, WorkflowStatus.Failed, WorkflowStatus.Cancelled].includes(\n      status\n    );\n  }\n\n  private toUrl(path: string): string {\n    if (!this.config.domain) {\n      return path;\n    }\n    const protocol = this.config.protocol ? `${this.config.protocol}:` : window.location.protocol;\n    const host = this.config.port ? `${this.config.domain}:${this.config.port}` : this.config.domain;\n    return `${protocol}//${host}${path}`;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/client/server-client/type.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport interface ServerError {\n  code: string;\n  message: string;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/create-runtime-plugin.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { definePluginCreator, PluginContext } from '@flowgram.ai/free-layout-editor';\n\nimport { RuntimePluginOptions } from './type';\nimport { WorkflowRuntimeService } from './runtime-service';\nimport {\n  WorkflowRuntimeBrowserClient,\n  WorkflowRuntimeClient,\n  WorkflowRuntimeServerClient,\n} from './client';\n\nexport const createRuntimePlugin = definePluginCreator<RuntimePluginOptions, PluginContext>({\n  onBind({ bind, rebind }, options) {\n    bind(WorkflowRuntimeClient).toSelf().inSingletonScope();\n    bind(WorkflowRuntimeServerClient).toSelf().inSingletonScope();\n    bind(WorkflowRuntimeBrowserClient).toSelf().inSingletonScope();\n    if (options.mode === 'server') {\n      rebind(WorkflowRuntimeClient).to(WorkflowRuntimeServerClient);\n    } else {\n      rebind(WorkflowRuntimeClient).to(WorkflowRuntimeBrowserClient);\n    }\n    bind(WorkflowRuntimeService).toSelf().inSingletonScope();\n  },\n  onInit(ctx, options) {\n    if (options.mode === 'server') {\n      const serverClient = ctx.get<WorkflowRuntimeServerClient>(WorkflowRuntimeClient);\n      serverClient.init(options.serverConfig);\n    }\n  },\n});\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { createRuntimePlugin } from './create-runtime-plugin';\nexport { WorkflowRuntimeClient } from './client';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/runtime-service/index.ts",
    "content": "import {\n  IReport,\n  NodeReport,\n  TaskValidateOutput,\n  WorkflowInputs,\n  WorkflowOutputs,\n  WorkflowStatus,\n} from '@flowgram.ai/runtime-interface';\nimport {\n  inject,\n  injectable,\n  WorkflowDocument,\n  Playground,\n  WorkflowLineEntity,\n  WorkflowNodeEntity,\n  Emitter,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { WorkflowRuntimeClient } from '../client';\nimport { FlowGramTraceView } from '../trace';\nimport { GetGlobalVariableSchema } from '../../variable-panel-plugin';\nimport { WorkflowNodeType } from '../../../nodes';\nimport { serializeWorkflowForBackend } from '../../../utils/backend-workflow';\n\nconst SYNC_TASK_REPORT_INTERVAL = 500;\n\ninterface NodeRunningStatus {\n  nodeID: string;\n  status: WorkflowStatus;\n  nodeResultLength: number;\n}\n\nexport type WorkflowRuntimeStatus =\n  | 'idle'\n  | 'validating'\n  | 'running'\n  | 'succeeded'\n  | 'failed'\n  | 'canceled';\n\nexport interface WorkflowRuntimeSnapshot {\n  taskID?: string;\n  inputs: WorkflowInputs;\n  validation?: TaskValidateOutput;\n  report?: IReport;\n  trace?: FlowGramTraceView;\n  result?: {\n    inputs: WorkflowInputs;\n    outputs: WorkflowOutputs;\n  };\n  errors?: string[];\n  status: WorkflowRuntimeStatus;\n}\n\n@injectable()\nexport class WorkflowRuntimeService {\n  @inject(Playground) playground: Playground;\n\n  @inject(WorkflowDocument) document: WorkflowDocument;\n\n  @inject(WorkflowRuntimeClient) runtimeClient: WorkflowRuntimeClient;\n\n  @inject(GetGlobalVariableSchema) getGlobalVariableSchema: GetGlobalVariableSchema;\n\n  private runningNodes: WorkflowNodeEntity[] = [];\n\n  private taskID?: string;\n\n  private syncTaskReportIntervalID?: ReturnType<typeof setInterval>;\n\n  private reportEmitter = new Emitter<NodeReport>();\n\n  private resetEmitter = new Emitter<{}>();\n\n  private resultEmitter = new Emitter<{\n    errors?: string[];\n    result?: {\n      inputs: WorkflowInputs;\n      outputs: WorkflowOutputs;\n    };\n  }>();\n\n  private snapshotEmitter = new Emitter<WorkflowRuntimeSnapshot>();\n\n  private nodeRunningStatus = new Map<string, NodeRunningStatus>();\n\n  private draftInputs: WorkflowInputs = {};\n\n  private validationResult?: TaskValidateOutput;\n\n  private latestReport?: IReport;\n\n  private latestTrace?: FlowGramTraceView;\n\n  private latestResult?: {\n    inputs: WorkflowInputs;\n    outputs: WorkflowOutputs;\n  };\n\n  private latestErrors?: string[];\n\n  private runtimeStatus: WorkflowRuntimeStatus = 'idle';\n\n  public onNodeReportChange = this.reportEmitter.event;\n\n  public onReset = this.resetEmitter.event;\n\n  public onResultChanged = this.resultEmitter.event;\n\n  public onSnapshotChanged = this.snapshotEmitter.event;\n\n  public isFlowingLine(line: WorkflowLineEntity) {\n    return this.runningNodes.some((node) => node.lines.inputLines.includes(line));\n  }\n\n  public getDraftInputs(): WorkflowInputs {\n    return this.clone(this.draftInputs);\n  }\n\n  public setDraftInputs(inputs: WorkflowInputs): void {\n    this.draftInputs = this.clone(inputs ?? {});\n    this.emitSnapshot();\n  }\n\n  public getSnapshot(): WorkflowRuntimeSnapshot {\n    return {\n      taskID: this.taskID,\n      inputs: this.clone(this.draftInputs),\n      validation: this.validationResult,\n      report: this.latestReport,\n      trace: this.latestTrace,\n      result: this.latestResult,\n      errors: this.latestErrors,\n      status: this.runtimeStatus,\n    };\n  }\n\n  public async taskValidate(inputs: WorkflowInputs): Promise<TaskValidateOutput | undefined> {\n    this.setDraftInputs(inputs);\n    this.latestErrors = undefined;\n    this.runtimeStatus = 'validating';\n    this.emitSnapshot();\n\n    const isFormValid = await this.validateForm();\n    if (!isFormValid) {\n      const failedValidation: TaskValidateOutput = {\n        valid: false,\n        errors: ['Form validation failed'],\n      };\n      this.validationResult = failedValidation;\n      this.latestErrors = failedValidation.errors;\n      this.runtimeStatus = 'failed';\n      this.emitSnapshot();\n      return failedValidation;\n    }\n\n    const validateResult = await this.runtimeClient.TaskValidate({\n      schema: this.buildSchema(),\n      inputs: this.getDraftInputs(),\n    });\n\n    if (!validateResult) {\n      this.validationResult = {\n        valid: false,\n        errors: ['Validate request failed'],\n      };\n      this.latestErrors = this.validationResult.errors;\n      this.runtimeStatus = 'failed';\n      this.emitSnapshot();\n      return this.validationResult;\n    }\n\n    this.validationResult = validateResult;\n    this.latestErrors = validateResult.valid ? undefined : validateResult.errors;\n    this.runtimeStatus = validateResult.valid ? (this.taskID ? 'running' : 'idle') : 'failed';\n    this.emitSnapshot();\n    return validateResult;\n  }\n\n  public async taskRun(inputs: WorkflowInputs): Promise<string | undefined> {\n    if (this.taskID) {\n      await this.taskCancel();\n    }\n\n    this.setDraftInputs(inputs);\n\n    const validateResult = await this.taskValidate(inputs);\n    if (!validateResult?.valid) {\n      this.resultEmitter.fire({\n        errors: this.latestErrors ?? ['Validation failed'],\n      });\n      return;\n    }\n\n    this.resetRuntimeState(false);\n\n    let taskID: string | undefined;\n    try {\n      const output = await this.runtimeClient.TaskRun({\n        schema: this.buildSchema(),\n        inputs: this.getDraftInputs(),\n      });\n      taskID = output?.taskID;\n    } catch (error) {\n      this.latestErrors = [(error as Error)?.message ?? 'Task run failed'];\n      this.runtimeStatus = 'failed';\n      this.emitSnapshot();\n      this.resultEmitter.fire({\n        errors: this.latestErrors,\n      });\n      return;\n    }\n\n    if (!taskID) {\n      this.latestErrors = ['Task run failed'];\n      this.runtimeStatus = 'failed';\n      this.emitSnapshot();\n      this.resultEmitter.fire({\n        errors: this.latestErrors,\n      });\n      return;\n    }\n\n    this.taskID = taskID;\n    this.runtimeStatus = 'running';\n    this.emitSnapshot();\n\n    this.syncTaskReportIntervalID = setInterval(() => {\n      void this.syncTaskReport();\n    }, SYNC_TASK_REPORT_INTERVAL);\n\n    return this.taskID;\n  }\n\n  public async taskCancel(): Promise<void> {\n    if (!this.taskID) {\n      return;\n    }\n    await this.runtimeClient.TaskCancel({\n      taskID: this.taskID,\n    });\n  }\n\n  private buildSchema(): string {\n    return serializeWorkflowForBackend({\n      ...(this.document.toJSON() as unknown as WorkflowInputs),\n      globalVariable: this.getGlobalVariableSchema(),\n    } as unknown as import('../../../typings').FlowDocumentJSON);\n  }\n\n  private async validateForm(): Promise<boolean> {\n    const allForms = this.document.getAllNodes().map((node) => node.form);\n    const formValidations = await Promise.all(allForms.map(async (form) => form?.validate()));\n    const validations = formValidations.filter((validation) => validation !== undefined);\n    return validations.every((validation) => validation);\n  }\n\n  private resetRuntimeState(clearInputs: boolean): void {\n    this.taskID = undefined;\n    this.nodeRunningStatus = new Map<string, NodeRunningStatus>();\n    this.runningNodes = [];\n    this.latestReport = undefined;\n    this.latestTrace = undefined;\n    this.latestResult = undefined;\n    this.latestErrors = undefined;\n    this.runtimeStatus = 'idle';\n    this.runtimeClient.clearLatestTrace();\n\n    if (clearInputs) {\n      this.draftInputs = {};\n      this.validationResult = undefined;\n    }\n\n    if (this.syncTaskReportIntervalID) {\n      clearInterval(this.syncTaskReportIntervalID);\n      this.syncTaskReportIntervalID = undefined;\n    }\n\n    this.resetEmitter.fire({});\n    this.emitSnapshot();\n  }\n\n  private async syncTaskReport(): Promise<void> {\n    if (!this.taskID) {\n      return;\n    }\n\n    const currentTaskID = this.taskID;\n    const report = await this.runtimeClient.TaskReport({\n      taskID: currentTaskID,\n    });\n\n    if (!report) {\n      if (this.syncTaskReportIntervalID) {\n        clearInterval(this.syncTaskReportIntervalID);\n        this.syncTaskReportIntervalID = undefined;\n      }\n      this.latestErrors = ['Sync task report failed'];\n      this.runtimeStatus = 'failed';\n      this.emitSnapshot();\n      return;\n    }\n\n    this.latestReport = report;\n    this.latestTrace = this.runtimeClient.getLatestTrace();\n    this.updateReport(report);\n\n    if (report.workflowStatus.terminated) {\n      if (this.syncTaskReportIntervalID) {\n        clearInterval(this.syncTaskReportIntervalID);\n        this.syncTaskReportIntervalID = undefined;\n      }\n\n      if (report.workflowStatus.status === WorkflowStatus.Succeeded) {\n        const outputs = (await this.runtimeClient.TaskResult({\n          taskID: currentTaskID,\n        })) ?? {};\n        this.latestResult = {\n          inputs: this.getDraftInputs(),\n          outputs,\n        };\n        this.latestTrace = this.runtimeClient.getLatestTrace() ?? this.latestTrace;\n        this.latestErrors = undefined;\n        this.runtimeStatus = 'succeeded';\n        this.resultEmitter.fire({\n          result: this.latestResult,\n        });\n      } else {\n        this.latestResult = undefined;\n        this.latestErrors = this.extractErrors(report);\n        this.runtimeStatus =\n          report.workflowStatus.status === WorkflowStatus.Cancelled ? 'canceled' : 'failed';\n        this.resultEmitter.fire({\n          errors: this.latestErrors,\n        });\n      }\n    } else {\n      this.runtimeStatus = 'running';\n    }\n\n    this.emitSnapshot();\n  }\n\n  private extractErrors(report: IReport): string[] {\n    const workflowErrors = report.messages.error.map((message) =>\n      message.nodeID ? `${message.nodeID}: ${message.message}` : message.message\n    );\n\n    if (workflowErrors.length > 0) {\n      return workflowErrors;\n    }\n\n    if (report.workflowStatus.status === WorkflowStatus.Cancelled) {\n      return ['Task canceled'];\n    }\n\n    return ['Workflow execution failed'];\n  }\n\n  private updateReport(report: IReport): void {\n    const { reports } = report;\n    this.runningNodes = [];\n\n    this.document\n      .getAllNodes()\n      .filter(\n        (node) =>\n          ![WorkflowNodeType.BlockStart, WorkflowNodeType.BlockEnd].includes(\n            node.flowNodeType as WorkflowNodeType\n          )\n      )\n      .forEach((node) => {\n        const nodeID = node.id;\n        const nodeReport = reports[nodeID];\n        if (!nodeReport) {\n          return;\n        }\n        if (nodeReport.status === WorkflowStatus.Processing) {\n          this.runningNodes.push(node);\n        }\n        const runningStatus = this.nodeRunningStatus.get(nodeID);\n        if (\n          !runningStatus\n          || nodeReport.status !== runningStatus.status\n          || nodeReport.snapshots.length !== runningStatus.nodeResultLength\n        ) {\n          this.nodeRunningStatus.set(nodeID, {\n            nodeID,\n            status: nodeReport.status,\n            nodeResultLength: nodeReport.snapshots.length,\n          });\n          this.reportEmitter.fire(nodeReport);\n          this.document.linesManager.forceUpdate();\n        } else if (nodeReport.status === WorkflowStatus.Processing) {\n          this.reportEmitter.fire(nodeReport);\n        }\n      });\n  }\n\n  private emitSnapshot(): void {\n    this.snapshotEmitter.fire(this.getSnapshot());\n  }\n\n  private clone<T>(value: T): T {\n    return JSON.parse(JSON.stringify(value ?? {}));\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/trace.ts",
    "content": "export interface FlowGramTraceEventView {\n  type?: string;\n  timestamp?: number;\n  nodeId?: string;\n  status?: string;\n  error?: string;\n}\n\nexport interface FlowGramTraceNodeView {\n  nodeId?: string;\n  status?: string;\n  terminated?: boolean;\n  startedAt?: number;\n  endedAt?: number;\n  durationMillis?: number;\n  error?: string;\n  eventCount?: number;\n  model?: string;\n  metrics?: FlowGramTraceMetricsView;\n}\n\nexport interface FlowGramTraceMetricsView {\n  promptTokens?: number;\n  completionTokens?: number;\n  totalTokens?: number;\n  inputCost?: number;\n  outputCost?: number;\n  totalCost?: number;\n  currency?: string;\n}\n\nexport interface FlowGramTraceSummaryView {\n  durationMillis?: number;\n  eventCount?: number;\n  nodeCount?: number;\n  terminatedNodeCount?: number;\n  successNodeCount?: number;\n  failedNodeCount?: number;\n  llmNodeCount?: number;\n  metrics?: FlowGramTraceMetricsView;\n}\n\nexport interface FlowGramTraceView {\n  taskId?: string;\n  status?: string;\n  startedAt?: number;\n  endedAt?: number;\n  summary?: FlowGramTraceSummaryView;\n  events?: FlowGramTraceEventView[];\n  nodes?: Record<string, FlowGramTraceNodeView>;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/runtime-plugin/type.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport interface RuntimeBrowserOptions {\n  mode?: 'browser';\n}\n\nexport interface RuntimeServerOptions {\n  mode: 'server';\n  serverConfig: ServerConfig;\n}\n\nexport type RuntimePluginOptions = RuntimeBrowserOptions | RuntimeServerOptions;\n\nexport interface ServerConfig {\n  domain: string;\n  port?: number;\n  protocol?: string;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/variable-panel-plugin/components/full-variable-list.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useVariableTree } from '@flowgram.ai/form-materials';\nimport { Tree } from '@douyinfe/semi-ui';\n\nexport function FullVariableList() {\n  const treeData = useVariableTree({});\n\n  return <Tree treeData={treeData} />;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/variable-panel-plugin/components/global-variable-editor.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useEffect } from 'react';\n\nimport {\n  BaseVariableField,\n  GlobalScope,\n  useRefresh,\n  useService,\n} from '@flowgram.ai/free-layout-editor';\nimport { JsonSchemaEditor, JsonSchemaUtils } from '@flowgram.ai/form-materials';\n\nexport function GlobalVariableEditor() {\n  const globalScope = useService(GlobalScope);\n\n  const refresh = useRefresh();\n\n  const globalVar = globalScope.getVar() as BaseVariableField;\n\n  useEffect(() => {\n    const disposable = globalScope.output.onVariableListChange(() => {\n      refresh();\n    });\n\n    return () => {\n      disposable.dispose();\n    };\n  }, []);\n\n  if (!globalVar) {\n    return null;\n  }\n\n  const value = globalVar.type ? JsonSchemaUtils.astToSchema(globalVar.type) : { type: 'object' };\n\n  return (\n    <JsonSchemaEditor\n      value={value}\n      onChange={(_schema) => globalVar.updateType(JsonSchemaUtils.schemaToAST(_schema))}\n    />\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/variable-panel-plugin/components/index.module.less",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n.panel-wrapper {\n  position: relative;\n}\n\n.variable-panel-button {\n  position: absolute;\n  top: 0;\n  right: 0;\n  border-radius: 50%;\n  width: 50px;\n  height: 50px;\n  z-index: 1;\n\n  &.close {\n    width: 30px;\n    height: 30px;\n    top: 10px;\n    right: 10px;\n  }\n}\n\n.panel-container {\n  width: 500px;\n  border-radius: 5px;\n  background-color: #fff;\n  overflow: hidden;\n  box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.1);\n  z-index: 30;\n\n  :global(.semi-tabs-bar) {\n    padding-left: 20px;\n  }\n\n  :global(.semi-tabs-content) {\n    padding: 20px;\n    height: 500px;\n    overflow: auto;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/variable-panel-plugin/components/variable-panel.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { useState } from 'react';\n\nimport { Button, Collapsible, Tabs, Tooltip } from '@douyinfe/semi-ui';\nimport { IconMinus } from '@douyinfe/semi-icons';\n\nimport iconVariable from '../../../assets/icon-variable.png';\nimport { GlobalVariableEditor } from './global-variable-editor';\nimport { FullVariableList } from './full-variable-list';\n\nimport styles from './index.module.less';\n\nexport function VariablePanel() {\n  const [isOpen, setOpen] = useState<boolean>(false);\n\n  return (\n    <div className={styles['panel-wrapper']}>\n      <Tooltip content=\"Toggle Variable Panel\">\n        <Button\n          className={`${styles['variable-panel-button']} ${isOpen ? styles.close : ''}`}\n          theme={isOpen ? 'borderless' : 'light'}\n          onClick={() => setOpen((_open) => !_open)}\n        >\n          {isOpen ? <IconMinus /> : <img src={iconVariable} width={20} height={20} />}\n        </Button>\n      </Tooltip>\n      <Collapsible isOpen={isOpen}>\n        <div className={styles['panel-container']}>\n          <Tabs>\n            <Tabs.TabPane itemKey=\"variables\" tab=\"Variable List\">\n              <FullVariableList />\n            </Tabs.TabPane>\n            <Tabs.TabPane itemKey=\"global\" tab=\"Global Editor\">\n              <GlobalVariableEditor />\n            </Tabs.TabPane>\n          </Tabs>\n        </div>\n      </Collapsible>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/variable-panel-plugin/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { createVariablePanelPlugin, GetGlobalVariableSchema } from './variable-panel-plugin';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/variable-panel-plugin/variable-panel-layer.tsx",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { domUtils, injectable, Layer } from '@flowgram.ai/free-layout-editor';\n\nimport { VariablePanel } from './components/variable-panel';\n\n@injectable()\nexport class VariablePanelLayer extends Layer {\n  onReady(): void {\n    // Fix variable panel in the right of canvas\n    this.config.onDataChange(() => {\n      const { scrollX, scrollY } = this.config.config;\n      domUtils.setStyle(this.node, {\n        position: 'absolute',\n        right: 25 - scrollX,\n        top: scrollY + 25,\n      });\n    });\n  }\n\n  render(): JSX.Element {\n    return <VariablePanel />;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/plugins/variable-panel-plugin/variable-panel-plugin.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  ASTFactory,\n  definePluginCreator,\n  GlobalScope,\n  VariableDeclaration,\n} from '@flowgram.ai/free-layout-editor';\nimport { IJsonSchema, JsonSchemaUtils } from '@flowgram.ai/form-materials';\n\nimport iconVariable from '../../assets/icon-variable.png';\nimport { VariablePanelLayer } from './variable-panel-layer';\n\nconst fetchMockVariableFromRemote = async () => {\n  await new Promise((resolve) => setTimeout(resolve, 1000));\n  return {\n    type: 'object',\n    properties: {\n      userId: { type: 'string' },\n    },\n  };\n};\n\nexport type GetGlobalVariableSchema = () => IJsonSchema;\nexport const GetGlobalVariableSchema = Symbol('GlobalVariableSchemaGetter');\n\nexport const createVariablePanelPlugin = definePluginCreator<{ initialData?: IJsonSchema }>({\n  onBind({ bind }) {\n    bind(GetGlobalVariableSchema).toDynamicValue((ctx) => () => {\n      const variable = ctx.container.get(GlobalScope).getVar() as VariableDeclaration;\n      return JsonSchemaUtils.astToSchema(variable?.type);\n    });\n  },\n  onInit(ctx, opts) {\n    ctx.playground.registerLayer(VariablePanelLayer);\n\n    const globalScope = ctx.get(GlobalScope);\n\n    if (opts.initialData) {\n      globalScope.setVar(\n        ASTFactory.createVariableDeclaration({\n          key: 'global',\n          meta: {\n            title: 'Global',\n            icon: iconVariable,\n          },\n          type: JsonSchemaUtils.schemaToAST(opts.initialData),\n        })\n      );\n    } else {\n      // You can also fetch global variable from remote\n      fetchMockVariableFromRemote().then((v) => {\n        globalScope.setVar(\n          ASTFactory.createVariableDeclaration({\n            key: 'global',\n            meta: {\n              title: 'Global',\n              icon: iconVariable,\n            },\n            type: JsonSchemaUtils.schemaToAST(v),\n          })\n        );\n      });\n    }\n  },\n});\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/services/custom-service.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { injectable, inject } from '@flowgram.ai/free-layout-editor';\nimport {\n  FreeLayoutPluginContext,\n  SelectionService,\n  Playground,\n  WorkflowDocument,\n} from '@flowgram.ai/free-layout-editor';\n\n/**\n * Docs: https://inversify.io/docs/introduction/getting-started/\n * Warning: Use decorator legacy\n *   // rsbuild.config.ts\n *   {\n *     source: {\n *       decorators: {\n *         version: 'legacy'\n *       }\n *     }\n *   }\n * Usage:\n *  1.\n *    const myService = useService(CustomService)\n *    myService.save()\n *  2.\n *    const myService = useClientContext().get(CustomService)\n *  3.\n *    const myService = node.getService(CustomService)\n */\n@injectable()\nexport class CustomService {\n  @inject(FreeLayoutPluginContext) ctx: FreeLayoutPluginContext;\n\n  @inject(SelectionService) selectionService: SelectionService;\n\n  @inject(Playground) playground: Playground;\n\n  @inject(WorkflowDocument) document: WorkflowDocument;\n\n  save() {\n    console.log(this.document.toJSON());\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/services/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { CustomService } from './custom-service';\nexport { ValidateService } from './validate-service';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/services/validate-service.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  inject,\n  injectable,\n  WorkflowLinesManager,\n  FlowNodeEntity,\n  FlowNodeFormData,\n  FormModelV2,\n  WorkflowDocument,\n} from '@flowgram.ai/free-layout-editor';\n\nexport interface ValidateResult {\n  node: FlowNodeEntity;\n  feedbacks: any[];\n}\n\n@injectable()\nexport class ValidateService {\n  @inject(WorkflowLinesManager)\n  protected readonly linesManager: WorkflowLinesManager;\n\n  @inject(WorkflowDocument) private readonly document: WorkflowDocument;\n\n  validateLines() {\n    const allLines = this.linesManager.getAllLines();\n    allLines.forEach((line) => line.validate());\n  }\n\n  async validateNode(node: FlowNodeEntity) {\n    const feedbacks = await node\n      .getData(FlowNodeFormData)\n      .getFormModel<FormModelV2>()\n      .validateWithFeedbacks();\n    return feedbacks;\n  }\n\n  async validateNodes(): Promise<ValidateResult[]> {\n    const nodes = this.document.getAssociatedNodes();\n    const results = await Promise.all(\n      nodes.map(async (node) => {\n        const feedbacks = await this.validateNode(node);\n        return {\n          feedbacks,\n          node,\n        };\n      })\n    );\n\n    return results.filter((i) => i.feedbacks.length);\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/collapse/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  FreeLayoutPluginContext,\n  ShortcutsHandler,\n  WorkflowSelectService,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowCommandId } from '../constants';\n\nexport class CollapseShortcut implements ShortcutsHandler {\n  public commandId = FlowCommandId.COLLAPSE;\n\n  public commandDetail: ShortcutsHandler['commandDetail'] = {\n    label: 'Collapse',\n  };\n\n  public shortcuts = ['meta alt openbracket', 'ctrl alt openbracket'];\n\n  private selectService: WorkflowSelectService;\n\n  constructor(context: FreeLayoutPluginContext) {\n    this.selectService = context.get(WorkflowSelectService);\n    this.execute = this.execute.bind(this);\n  }\n\n  public async execute(): Promise<void> {\n    this.selectService.selectedNodes.forEach((node) => {\n      node.renderData.expanded = false;\n    });\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/constants.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport const WorkflowClipboardDataID = 'flowgram-workflow-clipboard-data';\n\nexport enum FlowCommandId {\n  COPY = 'COPY',\n  PASTE = 'PASTE',\n  CUT = 'CUT',\n  GROUP = 'GROUP',\n  UNGROUP = 'UNGROUP',\n  COLLAPSE = 'COLLAPSE',\n  EXPAND = 'EXPAND',\n  DELETE = 'DELETE',\n  ZOOM_IN = 'ZOOM_IN',\n  ZOOM_OUT = 'ZOOM_OUT',\n  RESET_ZOOM = 'RESET_ZOOM',\n  SELECT_ALL = 'SELECT_ALL',\n  CANCEL_SELECT = 'CANCEL_SELECT',\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/copy/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  FlowNodeBaseType,\n  FreeLayoutPluginContext,\n  PlaygroundConfigEntity,\n  Rectangle,\n  ShortcutsHandler,\n  TransformData,\n  WorkflowDocument,\n  WorkflowEdgeJSON,\n  WorkflowJSON,\n  WorkflowLineEntity,\n  WorkflowNodeEntity,\n  WorkflowNodeJSON,\n  WorkflowNodeMeta,\n  WorkflowSelectService,\n} from '@flowgram.ai/free-layout-editor';\nimport { Toast } from '@douyinfe/semi-ui';\n\nimport type {\n  WorkflowClipboardRect,\n  WorkflowClipboardSource,\n  WorkflowClipboardData,\n} from '../type';\nimport { FlowCommandId, WorkflowClipboardDataID } from '../constants';\nimport { WorkflowNodeType } from '../../nodes';\n\nexport class CopyShortcut implements ShortcutsHandler {\n  public commandId = FlowCommandId.COPY;\n\n  public shortcuts = ['meta c', 'ctrl c'];\n\n  private playgroundConfig: PlaygroundConfigEntity;\n\n  private document: WorkflowDocument;\n\n  private selectService: WorkflowSelectService;\n\n  constructor(context: FreeLayoutPluginContext) {\n    this.playgroundConfig = context.playground.config;\n    this.document = context.get(WorkflowDocument);\n    this.selectService = context.get(WorkflowSelectService);\n    this.execute = this.execute.bind(this);\n  }\n\n  /**\n   * execute copy operation - 执行复制操作\n   */\n  public async execute(): Promise<void> {\n    if (this.readonly || (await this.hasSelectedText())) {\n      return;\n    }\n    if (!this.isValid(this.selectedNodes)) {\n      return;\n    }\n    const data = this.toClipboardData();\n    await this.write(data);\n  }\n\n  /**\n   * create clipboard data - 转换为剪贴板数据\n   */\n  public toClipboardData(nodes?: WorkflowNodeEntity[]): WorkflowClipboardData {\n    const validNodes = this.getValidNodes(nodes ? nodes : this.selectedNodes);\n    const source = this.toSource();\n    const json = this.toJSON(validNodes);\n    const bounds = this.getEntireBounds(validNodes);\n    return {\n      type: WorkflowClipboardDataID,\n      source,\n      json,\n      bounds,\n    };\n  }\n\n  /**\n   * readonly - 是否只读\n   */\n  private get readonly(): boolean {\n    return this.playgroundConfig.readonly;\n  }\n\n  /**\n   * has selected text - 是否有文字被选中\n   */\n  private async hasSelectedText(): Promise<boolean> {\n    if (!window.getSelection()?.toString()) {\n      return false;\n    }\n    await navigator.clipboard.writeText(window.getSelection()?.toString() ?? '');\n    Toast.success({\n      content: 'Text copied',\n    });\n    return true;\n  }\n\n  /**\n   * get selected nodes - 获取选中的节点\n   */\n  private get selectedNodes(): WorkflowNodeEntity[] {\n    return this.selectService.selection.filter(\n      (n) => n instanceof WorkflowNodeEntity\n    ) as WorkflowNodeEntity[];\n  }\n\n  /**\n   * validate selected nodes - 验证选中的节点\n   */\n  private isValid(nodes: WorkflowNodeEntity[]): boolean {\n    if (nodes.length === 0) {\n      Toast.warning({\n        content: 'No nodes selected',\n      });\n      return false;\n    }\n    return true;\n  }\n\n  /**\n   * get valid nodes - 获取有效的节点\n   */\n  private getValidNodes(nodes: WorkflowNodeEntity[]): WorkflowNodeEntity[] {\n    return nodes.filter((n) => {\n      if (\n        [WorkflowNodeType.Start, WorkflowNodeType.End].includes(n.flowNodeType as WorkflowNodeType)\n      ) {\n        return false;\n      }\n      if (n.getNodeMeta<WorkflowNodeMeta>().copyDisable) {\n        return false;\n      }\n      return true;\n    });\n  }\n\n  /**\n   * get source data - 获取来源数据\n   */\n  private toSource(): WorkflowClipboardSource {\n    return {\n      host: window.location.host,\n    };\n  }\n\n  /**\n   * convert nodes to JSON - 将节点转换为JSON\n   */\n  private toJSON(nodes: WorkflowNodeEntity[]): WorkflowJSON {\n    const nodeJSONs = this.getNodeJSONs(nodes);\n    const edgeJSONs = this.getEdgeJSONs(nodes);\n    return {\n      nodes: nodeJSONs,\n      edges: edgeJSONs,\n    };\n  }\n\n  /**\n   * get JSON representation of nodes - 获取节点的JSON表示\n   */\n  private getNodeJSONs(nodes: WorkflowNodeEntity[]): WorkflowNodeJSON[] {\n    const nodeJSONs = nodes.map((node) =>\n      node.flowNodeType === FlowNodeBaseType.GROUP\n        ? this.getGroupNodeJSON(node)\n        : this.document.toNodeJSON(node)\n    );\n    return nodeJSONs.filter(Boolean);\n  }\n\n  /**\n   * get JSON representation of group node - 获取分组节点的JSON\n   */\n  private getGroupNodeJSON(node: WorkflowNodeEntity): WorkflowNodeJSON {\n    const rawJSON = this.document.toNodeJSON(node);\n    return {\n      ...rawJSON,\n      blocks: node.blocks.map((block) => this.document.toNodeJSON(block)),\n    };\n  }\n\n  /**\n   * get edges of all nodes - 获取所有节点的边\n   */\n  private getEdgeJSONs(nodes: WorkflowNodeEntity[]): WorkflowEdgeJSON[] {\n    const lineSet = new Set<WorkflowLineEntity>();\n    const expandedNodes = this.expandGroupNodes(nodes);\n    const nodeIdSet = new Set(expandedNodes.map((n) => n.id));\n    expandedNodes.forEach((node) => {\n      const linesData = node.lines;\n      const lines = [...linesData.inputLines, ...linesData.outputLines];\n      lines.forEach((line) => {\n        if (\n          line.from?.id &&\n          nodeIdSet.has(line.from.id) &&\n          line.to?.id &&\n          nodeIdSet.has(line.to.id)\n        ) {\n          lineSet.add(line);\n        }\n      });\n    });\n    return Array.from(lineSet).map((line) => line.toJSON());\n  }\n\n  /**\n   * expand group nodes - 展开分组子节点\n   */\n  private expandGroupNodes(nodes: WorkflowNodeEntity[]): WorkflowNodeEntity[] {\n    return nodes.flatMap((node) => {\n      if (node.flowNodeType === FlowNodeBaseType.GROUP) {\n        return [node, ...node.blocks];\n      }\n      return node;\n    });\n  }\n\n  /**\n   * get bounding rectangle of all nodes - 获取所有节点的边界矩形\n   */\n  private getEntireBounds(nodes: WorkflowNodeEntity[]): WorkflowClipboardRect {\n    const bounds = nodes.map((node) => node.getData<TransformData>(TransformData).bounds);\n    const rect = Rectangle.enlarge(bounds);\n    return {\n      x: rect.x,\n      y: rect.y,\n      width: rect.width,\n      height: rect.height,\n    };\n  }\n\n  /**\n   * write data to clipboard - 将数据写入剪贴板\n   */\n  private async write(data: WorkflowClipboardData): Promise<void> {\n    try {\n      await navigator.clipboard.writeText(JSON.stringify(data));\n      this.notifySuccess();\n    } catch (err) {\n      console.error('Failed to write text: ', err);\n    }\n  }\n\n  /**\n   * show success notification - 显示成功通知\n   */\n  private notifySuccess(): void {\n    const startEndNodeTypes: WorkflowNodeType[] = [\n      WorkflowNodeType.Start,\n      WorkflowNodeType.End,\n      WorkflowNodeType.BlockStart,\n      WorkflowNodeType.BlockEnd,\n    ];\n    if (\n      this.selectedNodes.some((node) =>\n        startEndNodeTypes.includes(node.flowNodeType as WorkflowNodeType)\n      )\n    ) {\n      Toast.warning({\n        content:\n          'The Start/End node cannot be duplicated, other nodes have been copied to the clipboard',\n        showClose: false,\n      });\n      return;\n    }\n    Toast.success({\n      content: 'Nodes have been copied to the clipboard',\n      showClose: false,\n    });\n    return;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/delete/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  FreeLayoutPluginContext,\n  ShortcutsHandler,\n  WorkflowDocument,\n  WorkflowLineEntity,\n  WorkflowNodeEntity,\n  WorkflowNodeMeta,\n  WorkflowSelectService,\n  HistoryService,\n  PlaygroundConfigEntity,\n} from '@flowgram.ai/free-layout-editor';\nimport { Toast } from '@douyinfe/semi-ui';\n\nimport { FlowCommandId } from '../constants';\nimport { WorkflowNodeType } from '../../nodes';\n\nexport class DeleteShortcut implements ShortcutsHandler {\n  public commandId = FlowCommandId.DELETE;\n\n  public shortcuts = ['backspace', 'delete'];\n\n  private playgroundConfig: PlaygroundConfigEntity;\n\n  private document: WorkflowDocument;\n\n  private selectService: WorkflowSelectService;\n\n  private historyService: HistoryService;\n\n  /**\n   * initialize delete shortcut - 初始化删除快捷键\n   */\n  constructor(context: FreeLayoutPluginContext) {\n    this.playgroundConfig = context.playground.config;\n    this.document = context.get(WorkflowDocument);\n    this.selectService = context.get(WorkflowSelectService);\n    this.historyService = context.get(HistoryService);\n    this.execute = this.execute.bind(this);\n  }\n\n  /**\n   * execute delete operation - 执行删除操作\n   */\n  public async execute(nodes?: WorkflowNodeEntity[]): Promise<void> {\n    if (this.readonly) {\n      return;\n    }\n    const selection = Array.isArray(nodes) ? nodes : this.selectService.selection;\n    if (\n      !this.isValid(\n        selection.filter((n) => n instanceof WorkflowNodeEntity) as WorkflowNodeEntity[]\n      )\n    ) {\n      return;\n    }\n    // Merge actions to redo/undo\n    this.historyService.startTransaction();\n    // delete selected entities - 删除选中实体\n    selection.forEach((entity) => {\n      if (entity instanceof WorkflowNodeEntity) {\n        this.removeNode(entity);\n      } else if (entity instanceof WorkflowLineEntity) {\n        this.removeLine(entity);\n      } else {\n        entity.dispose();\n      }\n    });\n    // filter out disposed entities - 过滤掉已删除的实体\n    this.selectService.selection = this.selectService.selection.filter((s) => !s.disposed);\n    this.historyService.endTransaction();\n  }\n\n  /**\n   * readonly - 是否只读\n   */\n  private get readonly(): boolean {\n    return this.playgroundConfig.readonly;\n  }\n\n  /**\n   * validate if nodes can be deleted - 验证节点是否可以删除\n   */\n  private isValid(nodes: WorkflowNodeEntity[]): boolean {\n    const hasSystemNodes = nodes.some((n) =>\n      [WorkflowNodeType.Start, WorkflowNodeType.End].includes(n.flowNodeType as WorkflowNodeType)\n    );\n    if (hasSystemNodes) {\n      Toast.error({\n        content: 'Start or End node cannot be deleted',\n        showClose: false,\n      });\n      return false;\n    }\n    return true;\n  }\n\n  /**\n   * remove node from workflow - 从工作流中删除节点\n   */\n  private removeNode(node: WorkflowNodeEntity): void {\n    if (!this.document.canRemove(node)) {\n      return;\n    }\n    const nodeMeta = node.getNodeMeta<WorkflowNodeMeta>();\n    const subCanvas = nodeMeta.subCanvas?.(node);\n    if (subCanvas?.isCanvas) {\n      subCanvas.parentNode.dispose();\n      return;\n    }\n    node.dispose();\n  }\n\n  /**\n   * remove line from workflow - 从工作流中删除连线\n   */\n  private removeLine(line: WorkflowLineEntity): void {\n    if (!this.document.linesManager.canRemove(line)) {\n      return;\n    }\n    line.dispose();\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/expand/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  FreeLayoutPluginContext,\n  ShortcutsHandler,\n  WorkflowSelectService,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowCommandId } from '../constants';\n\nexport class ExpandShortcut implements ShortcutsHandler {\n  public commandId = FlowCommandId.EXPAND;\n\n  public commandDetail: ShortcutsHandler['commandDetail'] = {\n    label: 'Expand',\n  };\n\n  public shortcuts = ['meta alt closebracket', 'ctrl alt openbracket'];\n\n  private selectService: WorkflowSelectService;\n\n  constructor(context: FreeLayoutPluginContext) {\n    this.selectService = context.get(WorkflowSelectService);\n    this.execute = this.execute.bind(this);\n  }\n\n  public async execute(): Promise<void> {\n    this.selectService.selectedNodes.forEach((node) => {\n      node.renderData.expanded = true;\n    });\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport * from './constants';\nexport * from './shortcuts';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/paste/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  delay,\n  EntityManager,\n  FlowNodeTransformData,\n  FreeLayoutPluginContext,\n  IPoint,\n  PlaygroundConfigEntity,\n  Rectangle,\n  ShortcutsHandler,\n  WorkflowDocument,\n  WorkflowDragService,\n  WorkflowHoverService,\n  WorkflowJSON,\n  WorkflowNodeEntity,\n  WorkflowNodeMeta,\n  WorkflowSelectService,\n  Playground,\n} from '@flowgram.ai/free-layout-editor';\nimport { Toast } from '@douyinfe/semi-ui';\n\nimport { WorkflowClipboardData, WorkflowClipboardRect } from '../type';\nimport { FlowCommandId, WorkflowClipboardDataID } from '../constants';\nimport { canContainNode } from '../../utils';\nimport { generateUniqueWorkflow } from './unique-workflow';\n\nexport class PasteShortcut implements ShortcutsHandler {\n  public commandId = FlowCommandId.PASTE;\n\n  public shortcuts = ['meta v', 'ctrl v'];\n\n  private playgroundConfig: PlaygroundConfigEntity;\n\n  private document: WorkflowDocument;\n\n  private selectService: WorkflowSelectService;\n\n  private entityManager: EntityManager;\n\n  private hoverService: WorkflowHoverService;\n\n  private dragService: WorkflowDragService;\n\n  private playground: Playground;\n\n  /**\n   * initialize paste shortcut handler - 初始化粘贴快捷键处理器\n   */\n  constructor(context: FreeLayoutPluginContext) {\n    this.playgroundConfig = context.playground.config;\n    this.document = context.get(WorkflowDocument);\n    this.selectService = context.get(WorkflowSelectService);\n    this.entityManager = context.get(EntityManager);\n    this.hoverService = context.get(WorkflowHoverService);\n    this.dragService = context.get(WorkflowDragService);\n    this.playground = context.playground;\n    this.execute = this.execute.bind(this);\n  }\n\n  /**\n   * execute paste action - 执行粘贴操作\n   */\n  public async execute(): Promise<WorkflowNodeEntity[] | undefined> {\n    if (this.readonly) {\n      return;\n    }\n    const data = await this.tryReadClipboard();\n    if (!data) {\n      return;\n    }\n    if (!this.isValidData(data)) {\n      return;\n    }\n    const nodes = this.apply(data);\n    if (nodes.length > 0) {\n      Toast.success({\n        content: 'Copy successfully',\n        showClose: false,\n      });\n      // wait for nodes to render - 等待节点渲染\n      await this.nextTick();\n      // scroll to visible area - 滚动到可视区域\n      this.scrollNodesToView(nodes);\n    }\n    return nodes;\n  }\n\n  /** apply clipboard data - 应用剪切板数据 */\n  public apply(data: WorkflowClipboardData): WorkflowNodeEntity[] {\n    // extract raw json from clipboard data - 从剪贴板数据中提取原始JSON\n    const { json: rawJSON } = data;\n    const json = generateUniqueWorkflow({\n      json: rawJSON,\n      isUniqueId: (id: string) => !this.entityManager.getEntityById(id),\n    });\n\n    const offset = this.calcPasteOffset(data.bounds);\n    let parent = this.getSelectedContainer();\n    // loop 不支持嵌套\n    if (parent && json.nodes.some((n) => !canContainNode(n.type, parent!.flowNodeType))) {\n      parent = undefined;\n    }\n    this.applyOffset({ json, offset, parent });\n    const { nodes } = this.document.batchAddFromJSON(json, {\n      parent,\n    });\n    this.selectNodes(nodes);\n    // 这里需要 focus 画布才能继续使用快捷键\n    // The focus canvas is needed here to continue using the shortcuts\n    this.playground.node.focus();\n    return nodes;\n  }\n\n  /**\n   * readonly - 是否只读\n   */\n  private get readonly(): boolean {\n    return this.playgroundConfig.readonly;\n  }\n\n  private isValidData(data?: WorkflowClipboardData): boolean {\n    if (data?.type !== WorkflowClipboardDataID) {\n      Toast.error({\n        content: 'Invalid clipboard data',\n      });\n      return false;\n    }\n    // Cross-domain means different environments, different plugins, cannot be copied - 跨域名表示不同环境，上架插件不同，不能复制\n    if (data.source.host !== window.location.host) {\n      Toast.error({\n        content: 'Cannot paste nodes from different host',\n      });\n      return false;\n    }\n    // Check container - 检查容器\n    const parent = this.getSelectedContainer();\n    for (const nodeJSON of data.json.nodes) {\n      const res = this.dragService.canDropToNode({\n        dragNodeType: nodeJSON.type,\n        dropNodeType: parent?.flowNodeType,\n        dropNode: parent,\n      });\n      if (!res.allowDrop) {\n        Toast.error({\n          content: res.message ?? 'Cannot paste nodes to invalid container',\n        });\n        return false;\n      }\n    }\n    return true;\n  }\n\n  /** try to read clipboard - 尝试读取剪贴板 */\n  private async tryReadClipboard(): Promise<WorkflowClipboardData | undefined> {\n    try {\n      // need user permission to access clipboard, may throw NotAllowedError - 需要用户授予网页剪贴板读取权限, 如果用户没有授予权限, 代码可能会抛出异常 NotAllowedError\n      const text: string = (await navigator.clipboard.readText()) || '';\n      const clipboardData: WorkflowClipboardData = JSON.parse(text);\n      return clipboardData;\n    } catch (e) {\n      // clipboard data is not fixed, no need to show error - 这里本身剪贴板里的数据就不固定，所以没必要报错\n      return;\n    }\n  }\n\n  /** calculate paste offset - 计算粘贴偏移 */\n  private calcPasteOffset(boundsData: WorkflowClipboardRect): IPoint {\n    // extract bounds data - 提取边界数据\n    const { x, y, width, height } = boundsData;\n    const rect = new Rectangle(x, y, width, height);\n    const { center } = rect;\n    const mousePos = this.hoverService.hoveredPos;\n    return {\n      x: mousePos.x - center.x,\n      y: mousePos.y - center.y,\n    };\n  }\n\n  /**\n   * apply offset to node positions - 应用偏移到节点位置\n   */\n  private applyOffset(params: {\n    json: WorkflowJSON;\n    offset: IPoint;\n    parent?: WorkflowNodeEntity;\n  }): void {\n    const { json, offset, parent } = params;\n    json.nodes.forEach((nodeJSON) => {\n      if (!nodeJSON.meta?.position) {\n        return;\n      }\n      // calculate new position - 计算新位置\n      let position = {\n        x: nodeJSON.meta.position.x + offset.x,\n        y: nodeJSON.meta.position.y + offset.y,\n      };\n      if (parent) {\n        position = this.dragService.adjustSubNodePosition(\n          nodeJSON.type as string,\n          parent,\n          position\n        );\n      }\n      nodeJSON.meta.position = position;\n    });\n  }\n\n  /** get selected container node - 获取鼠标选中的容器 */\n  private getSelectedContainer(): WorkflowNodeEntity | undefined {\n    const { activatedNode } = this.selectService;\n    return activatedNode?.getNodeMeta<WorkflowNodeMeta>().isContainer ? activatedNode : undefined;\n  }\n\n  /** select nodes - 选中节点 */\n  private selectNodes(nodes: WorkflowNodeEntity[]): void {\n    this.selectService.selection = nodes;\n  }\n\n  /** scroll to nodes - 滚动到节点 */\n  private async scrollNodesToView(nodes: WorkflowNodeEntity[]): Promise<void> {\n    const nodeBounds = nodes.map((node) => node.getData(FlowNodeTransformData).bounds);\n    await this.document.playgroundConfig.scrollToView({\n      bounds: Rectangle.enlarge(nodeBounds),\n    });\n  }\n\n  /** wait for next frame - 等待下一帧 */\n  private async nextTick(): Promise<void> {\n    // 16ms is one render frame - 16ms 为一个渲染帧\n    const frameTime = 16;\n    await delay(frameTime);\n    await new Promise((resolve) => requestAnimationFrame(resolve));\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/paste/traverse.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\n// traverse value type - 遍历值类型\nexport type TraverseValue = any;\n\n// traverse node interface - 遍历节点接口\nexport interface TraverseNode {\n  value: TraverseValue; // node value - 节点值\n  container?: TraverseValue; // parent container - 父容器\n  parent?: TraverseNode; // parent node - 父节点\n  key?: string; // object key - 对象键名\n  index?: number; // array index - 数组索引\n}\n\n// traverse context interface - 遍历上下文接口\nexport interface TraverseContext {\n  node: TraverseNode; // current node - 当前节点\n  setValue: (value: TraverseValue) => void; // set value function - 设置值函数\n  getParents: () => TraverseNode[]; // get parents function - 获取父节点函数\n  getPath: () => Array<string | number>; // get path function - 获取路径函数\n  getStringifyPath: () => string; // get string path function - 获取字符串路径函数\n  deleteSelf: () => void; // delete self function - 删除自身函数\n}\n\n// traverse handler type - 遍历处理器类型\nexport type TraverseHandler = (context: TraverseContext) => void;\n\n/**\n * traverse object deeply and handle each value - 深度遍历对象并处理每个值\n * @param value traverse target - 遍历目标\n * @param handle handler function - 处理函数\n */\nexport const traverse = <T extends TraverseValue = TraverseValue>(\n  value: T,\n  handler: TraverseHandler | TraverseHandler[]\n): T => {\n  const traverseHandler: TraverseHandler = Array.isArray(handler)\n    ? (context: TraverseContext) => {\n        handler.forEach((handlerFn) => handlerFn(context));\n      }\n    : handler;\n  TraverseUtils.traverseNodes({ value }, traverseHandler);\n  return value;\n};\n\nnamespace TraverseUtils {\n  /**\n   * traverse nodes deeply and handle each value - 深度遍历节点并处理每个值\n   * @param node traverse node - 遍历节点\n   * @param handle handler function - 处理函数\n   */\n  export const traverseNodes = (node: TraverseNode, handle: TraverseHandler): void => {\n    const { value } = node;\n    if (!value) {\n      // handle null value - 处理空值\n      return;\n    }\n    if (Object.prototype.toString.call(value) === '[object Object]') {\n      // traverse object properties - 遍历对象属性\n      Object.entries(value).forEach(([key, item]) =>\n        traverseNodes(\n          {\n            value: item,\n            container: value,\n            key,\n            parent: node,\n          },\n          handle\n        )\n      );\n    } else if (Array.isArray(value)) {\n      // traverse array elements from end to start - 从末尾开始遍历数组元素\n      for (let index = value.length - 1; index >= 0; index--) {\n        const item: string = value[index];\n        traverseNodes(\n          {\n            value: item,\n            container: value,\n            index,\n            parent: node,\n          },\n          handle\n        );\n      }\n    }\n    const context: TraverseContext = createContext({ node });\n    handle(context);\n  };\n\n  /**\n   * create traverse context - 创建遍历上下文\n   * @param node traverse node - 遍历节点\n   */\n  const createContext = ({ node }: { node: TraverseNode }): TraverseContext => ({\n    node,\n    setValue: (value: unknown) => setValue(node, value),\n    getParents: () => getParents(node),\n    getPath: () => getPath(node),\n    getStringifyPath: () => getStringifyPath(node),\n    deleteSelf: () => deleteSelf(node),\n  });\n\n  /**\n   * set node value - 设置节点值\n   * @param node traverse node - 遍历节点\n   * @param value new value - 新值\n   */\n  const setValue = (node: TraverseNode, value: unknown) => {\n    // handle empty value - 处理空值\n    if (!value || !node) {\n      return;\n    }\n    node.value = value;\n    // get container info from parent scope - 从父作用域获取容器信息\n    const { container, key, index } = node;\n    if (key && container) {\n      container[key] = value;\n    } else if (typeof index === 'number') {\n      container[index] = value;\n    }\n  };\n\n  /**\n   * get parent nodes - 获取父节点列表\n   * @param node traverse node - 遍历节点\n   */\n  const getParents = (node: TraverseNode): TraverseNode[] => {\n    const parents: TraverseNode[] = [];\n    let currentNode: TraverseNode | undefined = node;\n    while (currentNode) {\n      parents.unshift(currentNode);\n      currentNode = currentNode.parent;\n    }\n    return parents;\n  };\n\n  /**\n   * get node path - 获取节点路径\n   * @param node traverse node - 遍历节点\n   */\n  const getPath = (node: TraverseNode): Array<string | number> => {\n    const path: Array<string | number> = [];\n    const parents = getParents(node);\n    parents.forEach((parent) => {\n      if (parent.key) {\n        path.unshift(parent.key);\n      } else if (parent.index) {\n        path.unshift(parent.index);\n      }\n    });\n    return path;\n  };\n\n  /**\n   * get stringify path - 获取字符串路径\n   * @param node traverse node - 遍历节点\n   */\n  const getStringifyPath = (node: TraverseNode): string => {\n    const path = getPath(node);\n    return path.reduce((stringifyPath: string, pathItem: string | number) => {\n      if (typeof pathItem === 'string') {\n        const re = /\\W/g;\n        if (re.test(pathItem)) {\n          // handle special characters - 处理特殊字符\n          return `${stringifyPath}[\"${pathItem}\"]`;\n        }\n        return `${stringifyPath}.${pathItem}`;\n      } else {\n        return `${stringifyPath}[${pathItem}]`;\n      }\n    }, '');\n  };\n\n  /**\n   * delete current node - 删除当前节点\n   * @param node traverse node - 遍历节点\n   */\n  const deleteSelf = (node: TraverseNode): void => {\n    const { container, key, index } = node;\n    if (key && container) {\n      delete container[key];\n    } else if (typeof index === 'number') {\n      container.splice(index, 1);\n    }\n  };\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/paste/unique-workflow.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { customAlphabet } from 'nanoid';\nimport type { WorkflowJSON, WorkflowNodeJSON } from '@flowgram.ai/free-layout-editor';\n\nimport { traverse, TraverseContext } from './traverse';\n\nnamespace UniqueWorkflowUtils {\n  /** generate unique id - 生成唯一ID */\n  const generateUniqueId = customAlphabet('1234567890', 6); // create a function to generate 6-digit number - 创建一个生成6位数字的函数\n\n  /** get all node ids from workflow json - 从工作流JSON中获取所有节点ID */\n  export const getAllNodeIds = (json: WorkflowJSON): string[] => {\n    const nodeIds = new Set<string>(); // use set to store unique ids - 使用Set存储唯一ID\n    const addNodeId = (node: WorkflowNodeJSON) => {\n      nodeIds.add(node.id);\n      if (node.blocks?.length) {\n        node.blocks.forEach((child) => addNodeId(child)); // recursively add child node ids - 递归添加子节点ID\n      }\n    };\n    json.nodes.forEach((node) => addNodeId(node));\n    return Array.from(nodeIds);\n  };\n\n  /** generate node replacement mapping - 生成节点替换映射 */\n  export const generateNodeReplaceMap = (\n    nodeIds: string[],\n    isUniqueId: (id: string) => boolean\n  ): Map<string, string> => {\n    const nodeReplaceMap = new Map<string, string>(); // create map for id replacement - 创建ID替换映射\n    nodeIds.forEach((id) => {\n      if (isUniqueId(id)) {\n        nodeReplaceMap.set(id, id); // keep original id if unique - 如果ID唯一则保持不变\n      } else {\n        let newId: string;\n        do {\n          newId = generateUniqueId(); // generate new id until unique - 生成新ID直到唯一\n        } while (!isUniqueId(newId));\n        nodeReplaceMap.set(id, newId);\n      }\n    });\n    return nodeReplaceMap;\n  };\n\n  /** check if value exists - 检查值是否存在 */\n  const isExist = (value: unknown): boolean => value !== null && value !== undefined;\n\n  /** check if node should be handled - 检查节点是否需要处理 */\n  const shouldHandle = (context: TraverseContext): boolean => {\n    const { node } = context;\n    // check edge data - 检查边数据\n    if (\n      node?.key &&\n      ['sourceNodeID', 'targetNodeID'].includes(node.key) &&\n      node.parent?.parent?.key === 'edges'\n    ) {\n      return true;\n    }\n    // check node data - 检查节点数据\n    if (\n      node?.key === 'id' &&\n      isExist(node.container?.type) &&\n      isExist(node.container?.meta) &&\n      isExist(node.container?.data)\n    ) {\n      return true;\n    }\n    // check variable data - 检查变量数据\n    if (\n      node?.key === 'blockID' &&\n      isExist(node.container?.name) &&\n      node.container?.source === 'block-output'\n    ) {\n      return true;\n    }\n    return false;\n  };\n\n  /**\n   * replace node ids in workflow json - 替换工作流JSON中的节点ID\n   * notice: this method has side effects, it will modify the input json to avoid deep copy overhead\n   * - 注意：此方法有副作用，会修改输入的json以避免深拷贝开销\n   */\n  export const replaceNodeId = (\n    json: WorkflowJSON,\n    nodeReplaceMap: Map<string, string>\n  ): WorkflowJSON => {\n    traverse(json, (context) => {\n      if (!shouldHandle(context)) {\n        return;\n      }\n      const { node } = context;\n      if (nodeReplaceMap.has(node.value)) {\n        context.setValue(nodeReplaceMap.get(node.value)); // replace old id with new id - 用新ID替换旧ID\n      }\n    });\n    return json;\n  };\n}\n\n/** generate unique workflow json - 生成唯一工作流JSON */\nexport const generateUniqueWorkflow = (params: {\n  json: WorkflowJSON;\n  isUniqueId: (id: string) => boolean;\n}): WorkflowJSON => {\n  const { json, isUniqueId } = params;\n  const nodeIds = UniqueWorkflowUtils.getAllNodeIds(json); // get all existing node ids - 获取所有现有节点ID\n  const nodeReplaceMap = UniqueWorkflowUtils.generateNodeReplaceMap(nodeIds, isUniqueId); // generate id replacement map - 生成ID替换映射\n  return UniqueWorkflowUtils.replaceNodeId(json, nodeReplaceMap); // replace all node ids - 替换所有节点ID\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/select-all/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  FreeLayoutPluginContext,\n  Playground,\n  ShortcutsHandler,\n  WorkflowDocument,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowCommandId } from '../constants';\n\nexport class SelectAllShortcut implements ShortcutsHandler {\n  public commandId = FlowCommandId.SELECT_ALL;\n\n  public shortcuts = ['meta a', 'ctrl a'];\n\n  private document: WorkflowDocument;\n\n  private playground: Playground;\n\n  constructor(context: FreeLayoutPluginContext) {\n    this.document = context.get(WorkflowDocument);\n    this.playground = context.playground;\n    this.execute = this.execute.bind(this);\n  }\n\n  public async execute(): Promise<void> {\n    const allNodes = this.document.getAllNodes();\n    this.playground.selectionService.selection = allNodes;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/shortcuts.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { FreeLayoutPluginContext, ShortcutsRegistry } from '@flowgram.ai/free-layout-editor';\n\nimport { ZoomOutShortcut } from './zoom-out';\nimport { ZoomInShortcut } from './zoom-in';\nimport { SelectAllShortcut } from './select-all';\nimport { PasteShortcut } from './paste';\nimport { ExpandShortcut } from './expand';\nimport { DeleteShortcut } from './delete';\nimport { CopyShortcut } from './copy';\nimport { CollapseShortcut } from './collapse';\n\nexport function shortcuts(shortcutsRegistry: ShortcutsRegistry, ctx: FreeLayoutPluginContext) {\n  shortcutsRegistry.addHandlers(\n    new CopyShortcut(ctx),\n    new PasteShortcut(ctx),\n    new SelectAllShortcut(ctx),\n    new CollapseShortcut(ctx),\n    new ExpandShortcut(ctx),\n    new DeleteShortcut(ctx),\n    new ZoomInShortcut(ctx),\n    new ZoomOutShortcut(ctx)\n  );\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/type.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport type { WorkflowJSON } from '@flowgram.ai/free-layout-editor';\n\nimport type { WorkflowClipboardDataID } from './constants';\n\nexport interface WorkflowClipboardSource {\n  host: string;\n  // more: id?, workspaceId? etc.\n}\n\nexport interface WorkflowClipboardRect {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\nexport interface WorkflowClipboardData {\n  type: typeof WorkflowClipboardDataID;\n  json: WorkflowJSON;\n  source: WorkflowClipboardSource;\n  bounds: WorkflowClipboardRect;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/zoom-in/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  FreeLayoutPluginContext,\n  PlaygroundConfigEntity,\n  ShortcutsHandler,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowCommandId } from '../constants';\n\nexport class ZoomInShortcut implements ShortcutsHandler {\n  public commandId = FlowCommandId.ZOOM_IN;\n\n  public shortcuts = ['meta =', 'ctrl ='];\n\n  private playgroundConfig: PlaygroundConfigEntity;\n\n  constructor(context: FreeLayoutPluginContext) {\n    this.playgroundConfig = context.get(PlaygroundConfigEntity);\n    this.execute = this.execute.bind(this);\n  }\n\n  public async execute(): Promise<void> {\n    if (this.playgroundConfig.zoom > 1.9) {\n      return;\n    }\n    this.playgroundConfig.zoomin();\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/shortcuts/zoom-out/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  FreeLayoutPluginContext,\n  PlaygroundConfigEntity,\n  ShortcutsHandler,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowCommandId } from '../constants';\n\nexport class ZoomOutShortcut implements ShortcutsHandler {\n  public commandId = FlowCommandId.ZOOM_OUT;\n\n  public shortcuts = ['meta -', 'ctrl -'];\n\n  private playgroundConfig: PlaygroundConfigEntity;\n\n  constructor(context: FreeLayoutPluginContext) {\n    this.playgroundConfig = context.get(PlaygroundConfigEntity);\n    this.execute = this.execute.bind(this);\n  }\n\n  public async execute(): Promise<void> {\n    if (this.playgroundConfig.zoom > 1.9) {\n      return;\n    }\n    this.playgroundConfig.zoomout();\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/styles/index.css",
    "content": "@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700;800&family=IBM+Plex+Mono:wght@400;500&display=swap');\n\n:root {\n  --g-workflow-port-color-primary: #2b7fff;\n  --g-workflow-port-color-secondary: #7da7ff;\n  --g-workflow-port-color-error: #ef4444;\n  --g-workflow-port-color-background: #ffffff;\n\n  --g-workflow-line-color-hidden: transparent;\n  --g-workflow-line-color-default: #4d63f0;\n  --g-workflow-line-color-drawing: #2498b7;\n  --g-workflow-line-color-hover: #2f62ff;\n  --g-workflow-line-color-selected: #2f62ff;\n  --g-workflow-line-color-error: #ef4444;\n\n  --shell-bg: #f4f7fb;\n  --shell-panel: #ffffff;\n  --shell-panel-soft: #f8fbff;\n  --shell-panel-muted: #f0f4f9;\n  --shell-border: rgba(15, 23, 42, 0.08);\n  --shell-text: #132238;\n  --shell-muted: #607089;\n  --shell-accent: #2f62ff;\n  --shell-accent-soft: rgba(47, 98, 255, 0.08);\n  --shell-accent-strong: #1947db;\n  --shell-shadow: 0 18px 48px rgba(32, 54, 84, 0.08);\n}\n\nhtml,\nbody,\n#root {\n  margin: 0;\n  min-height: 100%;\n  height: 100%;\n  font-family: 'Plus Jakarta Sans', 'Segoe UI', sans-serif;\n  color: var(--shell-text);\n  background:\n    radial-gradient(circle at top left, rgba(80, 125, 255, 0.08), transparent 28%),\n    linear-gradient(180deg, #f8fbff 0%, var(--shell-bg) 100%);\n}\n\nbody {\n  overflow: hidden;\n}\n\nbody::before {\n  content: '';\n  position: fixed;\n  inset: 0;\n  background-image:\n    linear-gradient(rgba(148, 163, 184, 0.05) 1px, transparent 1px),\n    linear-gradient(90deg, rgba(148, 163, 184, 0.05) 1px, transparent 1px);\n  background-size: 24px 24px;\n  pointer-events: none;\n}\n\n.doc-free-feature-overview {\n  height: 100vh;\n  overflow: hidden;\n}\n\n.gedit-selector-bounds-foreground {\n  cursor: move;\n  position: absolute;\n  left: 0;\n  top: 0;\n  width: 0;\n  height: 0;\n  outline: 1px solid var(--g-playground-selectBox-outline);\n  z-index: 33;\n  background-color: var(--g-playground-selectBox-background);\n}\n\n.node-running {\n  border: 1px dashed rgba(47, 98, 255, 0.76) !important;\n  border-radius: 16px;\n}\n\n.workbench-shell {\n  height: 100vh;\n  min-height: 100vh;\n  box-sizing: border-box;\n  padding: 12px;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  overflow: hidden;\n}\n\n.workbench-toolbar {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  flex-wrap: wrap;\n  gap: 14px 18px;\n  padding: 16px 18px;\n  border-radius: 24px;\n  background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 255, 0.96));\n  border: 1px solid var(--shell-border);\n  box-shadow: var(--shell-shadow);\n}\n\n.workbench-toolbar__context {\n  min-width: 320px;\n  max-width: 560px;\n  flex: 1 1 420px;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.workbench-toolbar__eyebrow {\n  font-size: 11px;\n  font-weight: 800;\n  letter-spacing: 0.18em;\n  text-transform: uppercase;\n  color: var(--shell-accent);\n}\n\n.workbench-toolbar__headline {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  flex-wrap: wrap;\n}\n\n.workbench-toolbar__title {\n  font-size: 24px;\n  line-height: 1;\n  font-weight: 800;\n}\n\n.workbench-toolbar__badge {\n  display: inline-flex;\n  align-items: center;\n  min-height: 28px;\n  padding: 0 10px;\n  border-radius: 999px;\n  background: var(--shell-accent-soft);\n  color: var(--shell-accent-strong);\n  font-size: 12px;\n  font-weight: 800;\n}\n\n.workbench-toolbar__subtitle {\n  font-size: 13px;\n  color: var(--shell-muted);\n}\n\n.workbench-toolbar__template {\n  display: flex;\n  align-items: baseline;\n  gap: 6px;\n  flex-wrap: wrap;\n}\n\n.workbench-toolbar__template-label {\n  font-size: 12px;\n  font-weight: 700;\n  color: var(--shell-muted);\n}\n\n.workbench-toolbar__template-name {\n  font-size: 14px;\n  font-weight: 800;\n}\n\n.workbench-toolbar__template-copy {\n  font-size: 13px;\n  color: var(--shell-muted);\n}\n\n.workbench-toolbar__status {\n  min-width: 260px;\n  flex: 1 1 280px;\n}\n\n.workbench-toolbar__meta {\n  display: flex;\n  gap: 8px;\n  flex-wrap: wrap;\n  justify-content: flex-start;\n}\n\n.workbench-toolbar__actions {\n  display: flex;\n  flex: 1 1 420px;\n  gap: 10px;\n  align-items: center;\n  justify-content: flex-end;\n  flex-wrap: wrap;\n}\n\n.workbench-toolbar__action-group {\n  display: flex;\n  gap: 8px;\n  padding-left: 10px;\n  border-left: 1px solid rgba(15, 23, 42, 0.08);\n}\n\n.workbench-toolbar__action-group:first-child {\n  padding-left: 0;\n  border-left: none;\n}\n\n.workbench-action {\n  border-radius: 14px !important;\n  height: 38px !important;\n  padding: 0 14px !important;\n  font-weight: 700 !important;\n  box-shadow: none !important;\n}\n\n.workbench-action--primary {\n  background: linear-gradient(135deg, #2864ff 0%, #2a87ff 100%) !important;\n  color: #ffffff !important;\n  border: none !important;\n}\n\n.workbench-action--secondary {\n  background: rgba(47, 98, 255, 0.08) !important;\n  color: var(--shell-accent-strong) !important;\n  border: 1px solid rgba(47, 98, 255, 0.12) !important;\n}\n\n.workbench-action--ghost {\n  background: #ffffff !important;\n  color: var(--shell-text) !important;\n  border: 1px solid rgba(15, 23, 42, 0.08) !important;\n}\n\n.workbench-body {\n  flex: 1;\n  min-height: 0;\n  display: grid;\n  grid-template-columns: 320px minmax(0, 1fr);\n  gap: 12px;\n  overflow: hidden;\n}\n\n.workbench-sidebar {\n  min-height: 0;\n  overflow: auto;\n  display: flex;\n  flex-direction: column;\n  gap: 14px;\n  padding-right: 4px;\n}\n\n.workbench-panel,\n.workbench-stage {\n  background: rgba(255, 255, 255, 0.96);\n  border: 1px solid var(--shell-border);\n  box-shadow: var(--shell-shadow);\n}\n\n.workbench-panel {\n  border-radius: 22px;\n  padding: 16px;\n}\n\n.workbench-panel__label,\n.workbench-stage__label {\n  font-size: 11px;\n  font-weight: 800;\n  letter-spacing: 0.18em;\n  text-transform: uppercase;\n  color: #1f7a66;\n  margin-bottom: 8px;\n}\n\n.workbench-panel__title,\n.workbench-stage__headline {\n  font-size: 17px;\n  font-weight: 800;\n  margin-bottom: 6px;\n}\n\n.workbench-panel__copy,\n.workbench-stage__copy {\n  margin: 0 0 12px;\n  color: var(--shell-muted);\n  font-size: 13px;\n  line-height: 1.6;\n}\n\n.workbench-panel__inline-note {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-wrap: wrap;\n  margin-bottom: 12px;\n}\n\n.workbench-panel__inline-chip {\n  display: inline-flex;\n  align-items: center;\n  min-height: 24px;\n  padding: 0 9px;\n  border-radius: 999px;\n  background: var(--shell-panel-muted);\n  color: var(--shell-muted);\n  font-size: 12px;\n  font-weight: 700;\n}\n\n.workbench-panel__inline-text {\n  font-size: 12px;\n  font-weight: 700;\n  color: var(--shell-text);\n}\n\n.workbench-template-list {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.workbench-template-card,\n.workbench-material-card {\n  width: 100%;\n  border: 1px solid rgba(15, 23, 42, 0.08);\n  background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);\n  border-radius: 18px;\n  text-align: left;\n  padding: 14px;\n  cursor: pointer;\n  transition:\n    transform 140ms ease,\n    border-color 140ms ease,\n    box-shadow 140ms ease;\n}\n\n.workbench-template-card:hover,\n.workbench-material-card:hover {\n  transform: translateY(-1px);\n  border-color: rgba(47, 98, 255, 0.2);\n  box-shadow: 0 12px 30px rgba(33, 54, 89, 0.08);\n}\n\n.workbench-template-card__meta {\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 8px;\n}\n\n.workbench-template-card__badge {\n  display: inline-flex;\n  align-items: center;\n  min-height: 24px;\n  padding: 0 9px;\n  border-radius: 999px;\n  background: rgba(47, 98, 255, 0.08);\n  color: var(--shell-accent-strong);\n  font-size: 11px;\n  font-weight: 800;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n}\n\n.workbench-template-card__title,\n.workbench-material-card__title {\n  display: block;\n  font-size: 14px;\n  font-weight: 800;\n  color: var(--shell-text);\n}\n\n.workbench-template-card__desc,\n.workbench-material-card__desc {\n  display: block;\n  margin-top: 6px;\n  color: var(--shell-muted);\n  font-size: 12px;\n  line-height: 1.55;\n}\n\n.workbench-template-card--active {\n  border-color: rgba(47, 98, 255, 0.28);\n  background: linear-gradient(180deg, rgba(47, 98, 255, 0.08), #ffffff 100%);\n  box-shadow: 0 16px 32px rgba(47, 98, 255, 0.1);\n}\n\n.workbench-material-grid {\n  display: grid;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n  gap: 10px;\n}\n\n.workbench-material-card__head {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n\n.workbench-material-card__icon {\n  width: 26px;\n  height: 26px;\n  object-fit: cover;\n  border-radius: 9px;\n  flex-shrink: 0;\n}\n\n.workbench-stage {\n  min-height: 0;\n  display: flex;\n  flex-direction: column;\n  border-radius: 24px;\n  overflow: hidden;\n  position: relative;\n}\n\n.workbench-stage__header {\n  padding: 12px 16px;\n  background: linear-gradient(180deg, #ffffff 0%, rgba(255, 255, 255, 0.96) 100%);\n  z-index: 2;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  border-bottom: 1px solid rgba(15, 23, 42, 0.05);\n}\n\n.workbench-stage__canvas {\n  position: relative;\n  flex: 1;\n  min-height: 0;\n  padding: 8px;\n  isolation: isolate;\n  background:\n    radial-gradient(circle at top, rgba(47, 98, 255, 0.06), transparent 24%),\n    linear-gradient(180deg, #fbfdff 0%, #f2f6fb 100%);\n}\n\n.workbench-stage__canvas::before {\n  content: '';\n  position: absolute;\n  inset: 0;\n  background-image:\n    linear-gradient(rgba(148, 163, 184, 0.05) 1px, transparent 1px),\n    linear-gradient(90deg, rgba(148, 163, 184, 0.05) 1px, transparent 1px);\n  background-size: 24px 24px;\n  pointer-events: none;\n}\n\n.demo-editor {\n  position: relative;\n  height: 100%;\n  width: 100%;\n  min-height: 0;\n  z-index: 1;\n}\n\n.workbench-empty-state {\n  position: absolute;\n  inset: 50% auto auto 50%;\n  transform: translate(-50%, -50%);\n  width: min(420px, calc(100% - 40px));\n  padding: 22px;\n  border-radius: 22px;\n  background: rgba(255, 255, 255, 0.96);\n  border: 1px solid rgba(47, 98, 255, 0.12);\n  box-shadow: 0 18px 40px rgba(34, 53, 84, 0.12);\n  backdrop-filter: blur(12px);\n  text-align: center;\n  z-index: 24;\n  pointer-events: auto;\n}\n\n.workbench-stage__summary {\n  min-width: 0;\n  display: flex;\n  align-items: baseline;\n  gap: 10px;\n  flex-wrap: wrap;\n}\n\n.workbench-stage__summary .workbench-stage__label,\n.workbench-stage__summary .workbench-stage__copy {\n  margin-bottom: 0;\n}\n\n.workbench-stage__hint {\n  font-size: 12px;\n  color: var(--shell-muted);\n  white-space: nowrap;\n}\n\n.workbench-empty-state__label {\n  display: inline-flex;\n  align-items: center;\n  min-height: 26px;\n  padding: 0 10px;\n  border-radius: 999px;\n  background: rgba(47, 98, 255, 0.08);\n  color: var(--shell-accent-strong);\n  font-size: 11px;\n  font-weight: 800;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n}\n\n.workbench-empty-state__title {\n  margin-top: 14px;\n  font-size: 22px;\n  font-weight: 800;\n}\n\n.workbench-empty-state__copy {\n  margin: 10px 0 0;\n  color: var(--shell-muted);\n  line-height: 1.7;\n  font-size: 14px;\n}\n\n.workbench-empty-state__actions {\n  margin-top: 18px;\n}\n\n.workbench-empty-state__button {\n  min-height: 40px;\n  padding: 0 16px;\n  border-radius: 14px;\n  font-family: inherit;\n  font-size: 14px;\n  font-weight: 800;\n  cursor: pointer;\n  border: none;\n}\n\n.workbench-empty-state__button--primary {\n  background: linear-gradient(135deg, #2864ff 0%, #2a87ff 100%);\n  color: #ffffff;\n}\n\n.demo-free-layout-tools {\n  position: absolute;\n  right: 18px;\n  bottom: 18px;\n  z-index: 18;\n}\n\n.demo-free-layout-tools .semi-button,\n.demo-free-layout-tools .semi-icon-button {\n  border-radius: 12px !important;\n  backdrop-filter: blur(12px);\n}\n\n.mouse-pad-option-icon {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n}\n\n.semi-tag,\n.semi-tag-content {\n  font-family: 'IBM Plex Mono', monospace;\n}\n\n@media (max-width: 1440px) {\n  .workbench-toolbar__actions {\n    justify-content: flex-start;\n  }\n}\n\n@media (max-width: 1180px) {\n  body {\n    overflow: auto;\n  }\n\n  .workbench-body {\n    grid-template-columns: 1fr;\n  }\n\n  .workbench-sidebar {\n    overflow: visible;\n  }\n\n  .workbench-stage {\n    min-height: 720px;\n  }\n\n  .workbench-stage__header {\n    align-items: flex-start;\n    flex-direction: column;\n  }\n}\n\n@media (max-width: 768px) {\n  .workbench-shell {\n    padding: 10px;\n  }\n\n  .workbench-toolbar,\n  .workbench-panel,\n  .workbench-stage {\n    border-radius: 18px;\n  }\n\n  .workbench-material-grid {\n    grid-template-columns: 1fr;\n  }\n\n  .workbench-toolbar__context,\n  .workbench-toolbar__status,\n  .workbench-toolbar__actions {\n    min-width: 0;\n    flex-basis: 100%;\n  }\n\n  .workbench-toolbar__action-group {\n    padding-left: 0;\n    border-left: none;\n  }\n\n  .workbench-stage__summary {\n    align-items: flex-start;\n    flex-direction: column;\n    gap: 4px;\n  }\n\n  .workbench-stage__hint {\n    white-space: normal;\n  }\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/type.d.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\ndeclare module '*.svg'\ndeclare module '*.png'\ndeclare module '*.jpg'\ndeclare module '*.module.less'\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/typings/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport * from './node';\nexport * from './json-schema';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/typings/json-schema.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport type { IJsonSchema, JsonSchemaBasicType } from '@flowgram.ai/form-materials';\n\nexport type BasicType = JsonSchemaBasicType;\nexport type JsonSchema = IJsonSchema;\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/typings/node.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  WorkflowNodeJSON as FlowNodeJSONDefault,\n  WorkflowNodeRegistry as FlowNodeRegistryDefault,\n  FreeLayoutPluginContext,\n  FlowNodeEntity,\n  type WorkflowEdgeJSON,\n  WorkflowNodeMeta,\n} from '@flowgram.ai/free-layout-editor';\nimport { IFlowValue } from '@flowgram.ai/form-materials';\n\nimport { type JsonSchema } from './json-schema';\nimport { WorkflowNodeType } from '../nodes';\n\n/**\n * You can customize the data of the node, and here you can use JsonSchema to define the input and output of the node\n * 你可以自定义节点的 data 业务数据, 这里演示 通过 JsonSchema 来定义节点的输入/输出\n */\nexport interface FlowNodeJSON extends FlowNodeJSONDefault {\n  data: {\n    /**\n     * Node title\n     */\n    title?: string;\n    /**\n     * Inputs data values\n     */\n    inputsValues?: Record<string, IFlowValue>;\n    /**\n     * Define the inputs data of the node by JsonSchema\n     */\n    inputs?: JsonSchema;\n    /**\n     * Define the outputs data of the node by JsonSchema\n     */\n    outputs?: JsonSchema;\n    /**\n     * Rest properties\n     */\n    [key: string]: any;\n  };\n}\n\n/**\n * You can customize your own node meta\n * 你可以自定义节点的meta\n */\nexport interface FlowNodeMeta extends WorkflowNodeMeta {\n  sidebarDisabled?: boolean;\n  nodePanelHidden?: boolean;\n  wrapperStyle?: React.CSSProperties;\n  onlyInContainer?: WorkflowNodeType;\n}\n\n/**\n * You can customize your own node registry\n * 你可以自定义节点的注册器\n */\nexport interface FlowNodeRegistry extends FlowNodeRegistryDefault {\n  meta: FlowNodeMeta;\n  info?: {\n    icon: string;\n    description: string;\n  };\n  canAdd?: (ctx: FreeLayoutPluginContext) => boolean;\n  canDelete?: (ctx: FreeLayoutPluginContext, from: FlowNodeEntity) => boolean;\n  onAdd?: (ctx: FreeLayoutPluginContext) => FlowNodeJSON;\n}\n\nexport interface FlowDocumentJSON {\n  nodes: FlowNodeJSON[];\n  edges: WorkflowEdgeJSON[];\n  /**\n   * Global Variable Schema Definition\n   * 全局变量的 Schema 定义\n   *\n   * Warning: In real occasion, it's better to store the schema and value of these global variables in a reliable place, since the value of a variable might be leaked in saved schema.\n   * 注意：在真实场景下，全局变量的 Schema 定义和值都应该存储在更可靠的地方，因为全局变量的值可能会泄露在保存的 Schema 中。\n   */\n  globalVariable?: JsonSchema;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/utils/backend-workflow.ts",
    "content": "import { FlowGramNode, WorkflowEdgeSchema, WorkflowNodeSchema, WorkflowSchema } from '@flowgram.ai/runtime-interface';\n\nimport { FlowDocumentJSON } from '../typings';\n\ntype SerializableWorkflow = string | WorkflowSchema | FlowDocumentJSON;\n\nconst UI_ONLY_TYPES = new Set<string>([\n  FlowGramNode.Comment,\n  FlowGramNode.Group,\n  FlowGramNode.BlockStart,\n  FlowGramNode.BlockEnd,\n]);\n\nconst BACKEND_TYPE_MAP: Record<string, string> = {\n  [FlowGramNode.Start]: 'START',\n  [FlowGramNode.End]: 'END',\n  [FlowGramNode.LLM]: 'LLM',\n  [FlowGramNode.HTTP]: 'HTTP',\n  [FlowGramNode.Code]: 'CODE',\n  [FlowGramNode.Condition]: 'CONDITION',\n  [FlowGramNode.Loop]: 'LOOP',\n  variable: 'VARIABLE',\n  tool: 'TOOL',\n  knowledge: 'KNOWLEDGE',\n};\n\nconst clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value));\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n  typeof value === 'object' && value !== null && !Array.isArray(value);\n\nconst normalizeType = (type?: string): string => {\n  if (!type) {\n    return '';\n  }\n  const lowered = type.trim().toLowerCase();\n  return BACKEND_TYPE_MAP[lowered] ?? type;\n};\n\nconst normalizeLoopNodeData = (data: Record<string, unknown>): Record<string, unknown> => {\n  const normalizedData = clone(data);\n  const loopFor = normalizedData.loopFor;\n\n  if (loopFor === undefined) {\n    return normalizedData;\n  }\n\n  const inputs = isRecord(normalizedData.inputs)\n    ? clone(normalizedData.inputs)\n    : {\n        type: 'object',\n        required: [] as string[],\n        properties: {},\n      };\n\n  const properties = isRecord(inputs.properties) ? clone(inputs.properties) : {};\n  if (!isRecord(properties.loopFor)) {\n    properties.loopFor = {\n      type: 'array',\n    };\n  }\n\n  const required = Array.isArray(inputs.required)\n    ? inputs.required.filter((item): item is string => typeof item === 'string')\n    : [];\n  if (!required.includes('loopFor')) {\n    required.push('loopFor');\n  }\n\n  const inputsValues = isRecord(normalizedData.inputsValues)\n    ? clone(normalizedData.inputsValues)\n    : {};\n  if (inputsValues.loopFor === undefined) {\n    inputsValues.loopFor = clone(loopFor);\n  }\n\n  normalizedData.inputs = {\n    ...inputs,\n    type: 'object',\n    required,\n    properties,\n  };\n  normalizedData.inputsValues = inputsValues;\n\n  return normalizedData;\n};\n\nconst normalizeNodeData = (type: string, data: unknown): Record<string, unknown> => {\n  const normalizedData = isRecord(data) ? clone(data) : {};\n\n  if (type === 'Loop') {\n    return normalizeLoopNodeData(normalizedData);\n  }\n\n  return normalizedData;\n};\n\nconst normalizeEdges = (\n  edges: WorkflowEdgeSchema[] | undefined,\n  allowedNodeIds: Set<string>\n): WorkflowEdgeSchema[] => {\n  if (!Array.isArray(edges)) {\n    return [];\n  }\n\n  return edges\n    .filter(\n      (edge): edge is WorkflowEdgeSchema =>\n        Boolean(edge?.sourceNodeID)\n        && Boolean(edge?.targetNodeID)\n        && allowedNodeIds.has(edge.sourceNodeID)\n        && allowedNodeIds.has(edge.targetNodeID)\n    )\n    .map((edge) => ({\n      sourceNodeID: edge.sourceNodeID,\n      targetNodeID: edge.targetNodeID,\n      sourcePortID: edge.sourcePortID,\n      targetPortID: edge.targetPortID,\n    }));\n};\n\nconst normalizeNodes = (nodes: WorkflowNodeSchema[] | undefined): WorkflowNodeSchema[] => {\n  if (!Array.isArray(nodes)) {\n    return [];\n  }\n\n  const normalizedNodes: WorkflowNodeSchema[] = [];\n\n  nodes.forEach((node) => {\n    if (!node?.id || UI_ONLY_TYPES.has(String(node.type))) {\n      return;\n    }\n\n    const blocks = normalizeNodes(node.blocks);\n    const blockIds = new Set(blocks.map((block) => block.id));\n    const normalizedType = normalizeType(String(node.type));\n\n    const normalizedNode = {\n      ...node,\n      type: normalizedType,\n      name: (node as WorkflowNodeSchema & { name?: string }).name ?? node.data?.title ?? node.id,\n      data: normalizeNodeData(normalizedType, node.data),\n      blocks: blocks.length > 0 ? blocks : undefined,\n      edges: blockIds.size > 0 ? normalizeEdges(node.edges, blockIds) : undefined,\n    } as WorkflowNodeSchema & {\n      name?: string;\n    };\n\n    normalizedNodes.push(normalizedNode);\n  });\n\n  return normalizedNodes;\n};\n\nconst parseWorkflow = (schema: SerializableWorkflow): WorkflowSchema => {\n  if (typeof schema === 'string') {\n    return JSON.parse(schema) as WorkflowSchema;\n  }\n  return clone(schema as WorkflowSchema);\n};\n\nexport const normalizeWorkflowForBackend = (\n  schema: WorkflowSchema | FlowDocumentJSON\n): WorkflowSchema => {\n  const parsed = clone(schema as WorkflowSchema);\n  const nodes = normalizeNodes(parsed.nodes);\n  const nodeIds = new Set(nodes.map((node) => node.id));\n\n  return {\n    nodes,\n    edges: normalizeEdges(parsed.edges, nodeIds),\n  };\n};\n\nexport const serializeWorkflowForBackend = (schema: SerializableWorkflow): string =>\n  JSON.stringify(normalizeWorkflowForBackend(parseWorkflow(schema)));\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/utils/can-contain-node.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { type FlowNodeType } from '@flowgram.ai/free-layout-editor';\n\nimport { WorkflowNodeType } from '../nodes';\n\n/**\n * 判断父节点是否可以包含对应子节点\n * Determine whether the parent node can contain the corresponding child node\n * @param childNodeType\n * @param parentNodeType\n */\nexport function canContainNode(\n  childNodeType: WorkflowNodeType | FlowNodeType,\n  parentNodeType: WorkflowNodeType | FlowNodeType\n) {\n  /**\n   * 开始/结束节点无法更改容器\n   * The start and end nodes cannot change container\n   */\n  if (\n    [\n      WorkflowNodeType.Start,\n      WorkflowNodeType.End,\n      WorkflowNodeType.BlockStart,\n      WorkflowNodeType.BlockEnd,\n    ].includes(childNodeType as WorkflowNodeType)\n  ) {\n    return false;\n  }\n  /**\n   * 继续循环与终止循环只能在循环节点中\n   * Continue loop and break loop can only be in loop nodes\n   */\n  if (\n    [WorkflowNodeType.Continue, WorkflowNodeType.Break].includes(\n      childNodeType as WorkflowNodeType\n    ) &&\n    parentNodeType !== WorkflowNodeType.Loop\n  ) {\n    return false;\n  }\n  /**\n   * 循环节点无法嵌套循环节点\n   * Loop node cannot nest loop node\n   */\n  if (childNodeType === WorkflowNodeType.Loop && parentNodeType === WorkflowNodeType.Loop) {\n    return false;\n  }\n  return true;\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/utils/index.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nexport { onDragLineEnd } from './on-drag-line-end';\nexport { toggleLoopExpanded } from './toggle-loop-expanded';\nexport { canContainNode } from './can-contain-node';\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/utils/on-drag-line-end.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport {\n  WorkflowNodePanelService,\n  WorkflowNodePanelUtils,\n} from '@flowgram.ai/free-node-panel-plugin';\nimport {\n  delay,\n  FreeLayoutPluginContext,\n  onDragLineEndParams,\n  WorkflowDragService,\n  WorkflowLinesManager,\n  WorkflowNodeEntity,\n  WorkflowNodeJSON,\n} from '@flowgram.ai/free-layout-editor';\n\n/**\n * Drag the end of the line to create an add panel (feature optional)\n * 拖拽线条结束需要创建一个添加面板 （功能可选）\n */\nexport const onDragLineEnd = async (ctx: FreeLayoutPluginContext, params: onDragLineEndParams) => {\n  // get services from context - 从上下文获取服务\n  const nodePanelService = ctx.get(WorkflowNodePanelService);\n  const document = ctx.document;\n  const dragService = ctx.get(WorkflowDragService);\n  const linesManager = ctx.get(WorkflowLinesManager);\n\n  // get params from drag event - 从拖拽事件获取参数\n  const { fromPort, toPort, mousePos, line, originLine } = params;\n\n  // return if invalid line state - 如果线条状态无效则返回\n  if (originLine || !line) {\n    return;\n  }\n\n  // return if target port exists - 如果目标端口存在则返回\n  if (toPort || !fromPort) {\n    return;\n  }\n\n  // get container node for the new node - 获取新节点的容器节点\n  const containerNode = fromPort.node.parent;\n  const isVertical = fromPort.location === 'bottom';\n\n  // open node selection panel - 打开节点选择面板\n  const result = await nodePanelService.singleSelectNodePanel({\n    position: isVertical\n      ? {\n          x: mousePos.x - 165,\n          y: mousePos.y + 60,\n        }\n      : mousePos,\n    containerNode,\n    panelProps: {\n      enableNodePlaceholder: true,\n      enableScrollClose: true,\n      fromPort,\n    },\n  });\n\n  // return if no node selected - 如果没有选择节点则返回\n  if (!result) {\n    return;\n  }\n\n  // get selected node type and data - 获取选择的节点类型和数据\n  const { nodeType, nodeJSON } = result;\n\n  // calculate position for the new node - 计算新节点的位置\n  const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({\n    nodeType,\n    position: mousePos,\n    fromPort,\n    toPort,\n    containerNode,\n    document,\n    dragService,\n  });\n\n  // create new workflow node - 创建新的工作流节点\n  const node: WorkflowNodeEntity = document.createWorkflowNodeByType(\n    nodeType,\n    nodePosition,\n    nodeJSON ?? ({} as WorkflowNodeJSON),\n    containerNode?.id\n  );\n\n  // wait for node render - 等待节点渲染\n  await delay(20);\n\n  // build connection line - 构建连接线\n  WorkflowNodePanelUtils.buildLine({\n    fromPort,\n    node,\n    linesManager,\n  });\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/utils/toggle-loop-expanded.ts",
    "content": "/**\n * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates\n * SPDX-License-Identifier: MIT\n */\n\nimport { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';\n\nconst HeightCollapsed = 54;\nconst HeightExpanded = 225;\n\nexport function toggleLoopExpanded(\n  node: WorkflowNodeEntity,\n  expanded: boolean = node.transform.collapsed\n) {\n  if (node.transform.collapsed === !expanded) {\n    if (!node.getNodeMeta().isContainer && node.blocks.length !== 0) {\n      return;\n    }\n    const bounds = node.bounds.clone();\n    node.transform.size = {\n      width: bounds.width,\n      height: node.transform.collapsed === expanded ? HeightCollapsed : HeightExpanded,\n    };\n    node.transform.transform.fireChange();\n    return;\n  }\n  const bounds = node.bounds.clone();\n  const prePosition = {\n    x: node.transform.position.x,\n    y: node.transform.position.y,\n  };\n  node.transform.collapsed = !expanded;\n  if (!expanded) {\n    node.transform.transform.clearChildren();\n    node.transform.transform.update({\n      position: {\n        x: prePosition.x - node.transform.padding.left,\n        y: prePosition.y - node.transform.padding.top,\n      },\n      origin: {\n        x: 0,\n        y: 0,\n      },\n    });\n    // When folded, the width and height no longer change according to the child nodes, and need to be set manually\n    // 折叠起来，宽高不再根据子节点变化，需要手动设置\n    node.transform.size = {\n      width: bounds.width,\n      height: HeightCollapsed,\n    };\n  } else {\n    node.transform.transform.update({\n      position: {\n        x: prePosition.x + node.transform.padding.left,\n        y: prePosition.y + node.transform.padding.top,\n      },\n      origin: {\n        x: 0,\n        y: 0,\n      },\n    });\n  }\n\n  // 隐藏子节点线条\n  // Hide the child node lines\n  node.blocks.forEach((block) => {\n    block.lines.allLines.forEach((line) => {\n      line.updateUIState({\n        style: !expanded\n          ? { ...line.uiState.style, display: 'none' }\n          : { ...line.uiState.style, display: 'block' },\n      });\n    });\n  });\n}\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/workbench/runtime-hooks.ts",
    "content": "import { useEffect, useState } from 'react';\n\nimport {\n  WorkflowRuntimeService,\n  WorkflowRuntimeSnapshot,\n} from '../plugins/runtime-plugin/runtime-service';\n\nexport const useRuntimeSnapshot = (\n  runtimeService: WorkflowRuntimeService\n): WorkflowRuntimeSnapshot => {\n  const [snapshot, setSnapshot] = useState<WorkflowRuntimeSnapshot>(() =>\n    runtimeService.getSnapshot()\n  );\n\n  useEffect(() => {\n    setSnapshot(runtimeService.getSnapshot());\n    const disposable = runtimeService.onSnapshotChanged((nextSnapshot) => {\n      setSnapshot(nextSnapshot);\n    });\n    return () => disposable.dispose();\n  }, [runtimeService]);\n\n  return snapshot;\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/workbench/workbench-shell.tsx",
    "content": "import { FC, useEffect, useRef, useState } from 'react';\n\nimport {\n  EditorRenderer,\n  usePlaygroundTools,\n  useService,\n  WorkflowDocument,\n} from '@flowgram.ai/free-layout-editor';\nimport { DockedPanelLayer } from '@flowgram.ai/panel-manager-plugin';\n\nimport { FlowDocumentJSON } from '../typings';\nimport { blankWorkflowTemplateId, WorkflowTemplate } from '../data/workflow-templates';\nimport { WorkflowNodeType } from '../nodes';\nimport { GetGlobalVariableSchema } from '../plugins/variable-panel-plugin';\nimport { WorkflowRuntimeService } from '../plugins/runtime-plugin/runtime-service';\nimport { WorkbenchSidebar } from './workbench-sidebar';\nimport { WorkbenchToolbar } from './workbench-toolbar';\n\ninterface WorkbenchShellProps {\n  activeTemplateId?: string;\n  activeTemplate?: WorkflowTemplate;\n  initialInputs: Record<string, unknown>;\n  onDocumentChange: (document: FlowDocumentJSON) => void;\n  onImportDocument: (document: FlowDocumentJSON) => void;\n  onLoadBlank: () => void;\n  onLoadDefaultTemplate: () => void;\n  onLoadTemplate: (templateId: string) => void;\n}\n\nconst hasRenderableNodes = (document: FlowDocumentJSON): boolean =>\n  Array.isArray(document.nodes) && document.nodes.length > 0;\n\nconst isBlankCanvasDraft = (document: FlowDocumentJSON): boolean => {\n  const nodes = Array.isArray(document.nodes)\n    ? document.nodes.filter((node) => node.type !== WorkflowNodeType.Comment)\n    : [];\n  return (\n    nodes.length === 2 &&\n    nodes.every(\n      (node) => node.type === WorkflowNodeType.Start || node.type === WorkflowNodeType.End\n    )\n  );\n};\n\nconst WorkbenchAutosaveBridge: FC<{\n  onDocumentChange: (document: FlowDocumentJSON) => void;\n}> = ({ onDocumentChange }) => {\n  const document = useService(WorkflowDocument);\n  const getGlobalVariableSchema = useService<GetGlobalVariableSchema>(GetGlobalVariableSchema);\n\n  useEffect(() => {\n    const persist = () => {\n      const payload = {\n        ...document.toJSON(),\n        globalVariable: getGlobalVariableSchema(),\n      } as unknown as FlowDocumentJSON;\n      if (!hasRenderableNodes(payload)) {\n        return;\n      }\n      onDocumentChange(payload);\n    };\n\n    persist();\n    const disposable = document.onContentChange(() => {\n      persist();\n    });\n\n    return () => disposable.dispose();\n  }, [document, getGlobalVariableSchema, onDocumentChange]);\n\n  return null;\n};\n\nconst WorkbenchViewportBridge: FC = () => {\n  const document = useService(WorkflowDocument);\n  const { fitView } = usePlaygroundTools();\n  const fittedRef = useRef(false);\n\n  useEffect(() => {\n    const fitToContent = () => {\n      if (fittedRef.current || document.root.blocks.length === 0) {\n        return;\n      }\n      fittedRef.current = true;\n      requestAnimationFrame(() => {\n        requestAnimationFrame(() => {\n          void fitView(false);\n          window.scrollTo({\n            top: 0,\n            left: 0,\n            behavior: 'instant',\n          });\n        });\n      });\n    };\n\n    fitToContent();\n    const disposable = document.onContentChange(() => {\n      fitToContent();\n    });\n\n    return () => disposable.dispose();\n  }, [document, fitView]);\n\n  return null;\n};\n\nconst WorkbenchBlankStateOverlay: FC<{\n  activeTemplateId?: string;\n  onLoadDefaultTemplate: () => void;\n}> = ({ activeTemplateId, onLoadDefaultTemplate }) => {\n  const document = useService(WorkflowDocument);\n  const [visible, setVisible] = useState(false);\n\n  useEffect(() => {\n    const sync = () => {\n      const payload = document.toJSON() as FlowDocumentJSON;\n      setVisible(activeTemplateId === blankWorkflowTemplateId && isBlankCanvasDraft(payload));\n    };\n\n    sync();\n    const disposable = document.onContentChange(() => {\n      sync();\n    });\n\n    return () => disposable.dispose();\n  }, [activeTemplateId, document]);\n\n  if (!visible) {\n    return null;\n  }\n\n  return (\n    <div className=\"workbench-empty-state\">\n      <div className=\"workbench-empty-state__label\">Blank Canvas</div>\n      <div className=\"workbench-empty-state__title\">从空白流程开始</div>\n      <p className=\"workbench-empty-state__copy\">\n        从左侧拖入节点，逐步搭建你的工作流。若想快速体验运行链路，建议先加载默认示例。\n      </p>\n      <div className=\"workbench-empty-state__actions\">\n        <button\n          type=\"button\"\n          className=\"workbench-empty-state__button workbench-empty-state__button--primary\"\n          onClick={onLoadDefaultTemplate}\n        >\n          加载默认示例\n        </button>\n      </div>\n    </div>\n  );\n};\n\nexport const WorkbenchShell: FC<WorkbenchShellProps> = ({\n  activeTemplateId,\n  activeTemplate,\n  initialInputs,\n  onDocumentChange,\n  onImportDocument,\n  onLoadBlank,\n  onLoadDefaultTemplate,\n  onLoadTemplate,\n}) => {\n  const runtimeService = useService(WorkflowRuntimeService);\n\n  useEffect(() => {\n    runtimeService.setDraftInputs(initialInputs);\n  }, [initialInputs, runtimeService]);\n\n  return (\n    <>\n      <WorkbenchAutosaveBridge onDocumentChange={onDocumentChange} />\n      <WorkbenchViewportBridge />\n      <div className=\"workbench-shell\">\n        <WorkbenchToolbar\n          activeTemplate={activeTemplate}\n          onImportDocument={onImportDocument}\n          onLoadBlank={onLoadBlank}\n          onLoadDefaultTemplate={onLoadDefaultTemplate}\n        />\n        <div className=\"workbench-body\">\n          <WorkbenchSidebar activeTemplateId={activeTemplateId} onLoadTemplate={onLoadTemplate} />\n          <section className=\"workbench-stage\">\n            <div className=\"workbench-stage__header\">\n              <div className=\"workbench-stage__summary\">\n                <div className=\"workbench-stage__label\">Canvas</div>\n                <div className=\"workbench-stage__copy\">\n                  {activeTemplate ? `当前画布：${activeTemplate.name}` : '当前草稿'}\n                  {' · '}拖拽节点后会自动保存到本地草稿\n                </div>\n              </div>\n              <div className=\"workbench-stage__hint\">点击节点后，在右侧表单继续配置参数与输入输出</div>\n            </div>\n            <div className=\"workbench-stage__canvas\">\n              <DockedPanelLayer>\n                <EditorRenderer className=\"demo-editor\" />\n              </DockedPanelLayer>\n              <WorkbenchBlankStateOverlay\n                activeTemplateId={activeTemplateId}\n                onLoadDefaultTemplate={onLoadDefaultTemplate}\n              />\n            </div>\n          </section>\n        </div>\n      </div>\n    </>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/workbench/workbench-sidebar.tsx",
    "content": "import { FC } from 'react';\n\nimport {\n  useClientContext,\n  useService,\n  WorkflowDocument,\n  WorkflowNodeEntity,\n  WorkflowSelectService,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { FlowNodeJSON, FlowNodeRegistry } from '../typings';\nimport { nodeRegistries, WorkflowNodeType } from '../nodes';\nimport { useNodeFormPanel } from '../plugins/panel-manager-plugin/hooks';\nimport { workflowTemplates } from '../data/workflow-templates';\n\ninterface WorkbenchSidebarProps {\n  activeTemplateId?: string;\n  onLoadTemplate: (templateId: string) => void;\n}\n\ninterface WorkflowDocumentWithFactory extends WorkflowDocument {\n  createWorkflowNodeByType: (\n    type: string,\n    position?: unknown,\n    nodeJSON?: FlowNodeJSON,\n    parentId?: string\n  ) => WorkflowNodeEntity;\n}\n\nconst LOCKED_NODE_TYPES: WorkflowNodeType[] = [WorkflowNodeType.Start, WorkflowNodeType.End];\nconst ADDABLE_NODE_TYPES: WorkflowNodeType[] = [\n  WorkflowNodeType.LLM,\n  WorkflowNodeType.HTTP,\n  WorkflowNodeType.Code,\n  WorkflowNodeType.Tool,\n  WorkflowNodeType.Knowledge,\n  WorkflowNodeType.Variable,\n  WorkflowNodeType.Condition,\n  WorkflowNodeType.Loop,\n];\n\nconst NODE_MATERIAL_META: Record<WorkflowNodeType, { title: string; description: string }> = {\n  [WorkflowNodeType.Start]: {\n    title: 'Start',\n    description: '流程起点。',\n  },\n  [WorkflowNodeType.End]: {\n    title: 'End',\n    description: '流程终点。',\n  },\n  [WorkflowNodeType.LLM]: {\n    title: 'LLM',\n    description: '调用已配置的大模型，生成文本或结构化回复。',\n  },\n  [WorkflowNodeType.HTTP]: {\n    title: 'HTTP',\n    description: '请求外部 REST API，返回 body、headers 与状态码。',\n  },\n  [WorkflowNodeType.Code]: {\n    title: 'Code',\n    description: '运行同步 JavaScript，对上游结果做整流与拼装。',\n  },\n  [WorkflowNodeType.Tool]: {\n    title: 'Tool',\n    description: '调用本地 ai4j Tool 或 MCP 暴露出来的能力。',\n  },\n  [WorkflowNodeType.Knowledge]: {\n    title: 'Knowledge',\n    description: '连接向量检索，为问答流程补充召回片段。',\n  },\n  [WorkflowNodeType.Variable]: {\n    title: 'Variable',\n    description: '声明、覆盖或整理变量，供后续节点引用。',\n  },\n  [WorkflowNodeType.Condition]: {\n    title: 'Condition',\n    description: '根据条件路由到不同分支，控制下游执行路径。',\n  },\n  [WorkflowNodeType.Loop]: {\n    title: 'Loop',\n    description: '批量迭代执行子流程，并对结果做汇总。',\n  },\n  [WorkflowNodeType.BlockStart]: {\n    title: 'Block Start',\n    description: '块起点。',\n  },\n  [WorkflowNodeType.BlockEnd]: {\n    title: 'Block End',\n    description: '块终点。',\n  },\n  [WorkflowNodeType.Comment]: {\n    title: 'Comment',\n    description: '注释。',\n  },\n  [WorkflowNodeType.Continue]: {\n    title: 'Continue',\n    description: '继续。',\n  },\n  [WorkflowNodeType.Break]: {\n    title: 'Break',\n    description: '中断。',\n  },\n};\n\nconst getRegistry = (type: WorkflowNodeType): FlowNodeRegistry | undefined =>\n  nodeRegistries.find((registry) => registry.type === type);\n\nexport const WorkbenchSidebar: FC<WorkbenchSidebarProps> = ({\n  activeTemplateId,\n  onLoadTemplate,\n}) => {\n  const editorContext = useClientContext();\n  const workflowDocument = useService(WorkflowDocument) as WorkflowDocumentWithFactory;\n  const selectService = useService(WorkflowSelectService);\n  const { open: openNodeForm } = useNodeFormPanel();\n\n  const createNode = (type: WorkflowNodeType) => {\n    const registry = getRegistry(type);\n    if (!registry?.onAdd) {\n      return;\n    }\n\n    const node = workflowDocument.createWorkflowNodeByType(\n      String(registry.type),\n      undefined,\n      registry.onAdd(editorContext)\n    );\n    selectService.selectNode(node);\n    openNodeForm({ nodeId: node.id });\n  };\n\n  return (\n    <aside className=\"workbench-sidebar\">\n      <section className=\"workbench-panel\">\n        <div className=\"workbench-panel__label\">Templates</div>\n        <div className=\"workbench-panel__title\">模板</div>\n        <p className=\"workbench-panel__copy\">\n          先从现成流程开始，再按需修改节点和参数，会比从纯空白起步更高效。\n        </p>\n        <div className=\"workbench-template-list\">\n          {workflowTemplates.map((template) => (\n            <button\n              key={template.id}\n              type=\"button\"\n              className={`workbench-template-card ${\n                activeTemplateId === template.id ? 'workbench-template-card--active' : ''\n              }`}\n              onClick={() => onLoadTemplate(template.id)}\n            >\n              <span className=\"workbench-template-card__meta\">\n                <span className=\"workbench-template-card__badge\">{template.badge ?? 'Template'}</span>\n              </span>\n              <span className=\"workbench-template-card__title\">{template.name}</span>\n              <span className=\"workbench-template-card__desc\">{template.description}</span>\n            </button>\n          ))}\n        </div>\n      </section>\n\n      <section className=\"workbench-panel\">\n        <div className=\"workbench-panel__label\">Nodes</div>\n        <div className=\"workbench-panel__title\">节点素材</div>\n        <p className=\"workbench-panel__copy\">\n          Start / End 已内置在骨架中，这里保留当前后端已接通的核心业务节点。\n        </p>\n\n        <div className=\"workbench-panel__inline-note\">\n          <span className=\"workbench-panel__inline-chip\">内置节点</span>\n          {LOCKED_NODE_TYPES.map((type) => (\n            <span key={type} className=\"workbench-panel__inline-text\">\n              {type}\n            </span>\n          ))}\n        </div>\n\n        <div className=\"workbench-material-grid\">\n          {ADDABLE_NODE_TYPES.map((type) => {\n            const registry = getRegistry(type);\n            const meta = NODE_MATERIAL_META[type];\n            return (\n              <button\n                key={type}\n                type=\"button\"\n                className=\"workbench-material-card\"\n                onClick={() => createNode(type)}\n              >\n                <div className=\"workbench-material-card__head\">\n                  {registry?.info?.icon ? (\n                    <img className=\"workbench-material-card__icon\" src={registry.info.icon} alt={type} />\n                  ) : null}\n                  <span className=\"workbench-material-card__title\">{meta?.title ?? type}</span>\n                </div>\n                <span className=\"workbench-material-card__desc\">\n                  {meta?.description ?? registry?.info?.description ?? '添加到当前画布。'}\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      </section>\n    </aside>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/src/workbench/workbench-toolbar.tsx",
    "content": "import { ChangeEvent, FC, useEffect, useMemo, useRef, useState } from 'react';\n\nimport { Button, Tag } from '@douyinfe/semi-ui';\nimport {\n  useClientContext,\n  usePlayground,\n  usePlaygroundTools,\n  useService,\n} from '@flowgram.ai/free-layout-editor';\n\nimport { WorkflowTemplate } from '../data/workflow-templates';\nimport { FlowDocumentJSON } from '../typings';\nimport { useTestRunFormPanel } from '../plugins/panel-manager-plugin/hooks';\nimport { GetGlobalVariableSchema } from '../plugins/variable-panel-plugin';\nimport {\n  WorkflowRuntimeService,\n  WorkflowRuntimeStatus,\n} from '../plugins/runtime-plugin/runtime-service';\nimport { useRuntimeSnapshot } from './runtime-hooks';\n\ninterface WorkbenchToolbarProps {\n  activeTemplate?: WorkflowTemplate;\n  onImportDocument: (document: FlowDocumentJSON) => void;\n  onLoadBlank: () => void;\n  onLoadDefaultTemplate: () => void;\n}\n\ntype BackendStatus = 'checking' | 'online' | 'offline';\n\nconst HEALTHCHECK_PAYLOAD = {\n  schema: {\n    nodes: [\n      {\n        id: 'start_0',\n        type: 'Start',\n        name: 'start_0',\n        data: {\n          outputs: {\n            type: 'object',\n            required: ['message'],\n            properties: {\n              message: {\n                type: 'string',\n                default: 'ping',\n              },\n            },\n          },\n        },\n      },\n      {\n        id: 'end_0',\n        type: 'End',\n        name: 'end_0',\n        data: {\n          inputs: {\n            type: 'object',\n            required: ['result'],\n            properties: {\n              result: {\n                type: 'string',\n              },\n            },\n          },\n          inputsValues: {\n            result: {\n              type: 'ref',\n              content: ['start_0', 'message'],\n            },\n          },\n        },\n      },\n    ],\n    edges: [\n      {\n        sourceNodeID: 'start_0',\n        targetNodeID: 'end_0',\n      },\n    ],\n  },\n  inputs: {},\n};\n\nconst STATUS_LABELS: Record<WorkflowRuntimeStatus, string> = {\n  idle: '空闲',\n  validating: '校验中',\n  running: '运行中',\n  succeeded: '成功',\n  failed: '失败',\n  canceled: '已取消',\n};\n\nconst downloadTextFile = (filename: string, content: string): void => {\n  const blob = new Blob([content], { type: 'application/json;charset=utf-8' });\n  const url = URL.createObjectURL(blob);\n  const link = document.createElement('a');\n  link.href = url;\n  link.download = filename;\n  link.click();\n  URL.revokeObjectURL(url);\n};\n\nconst isWorkflowDocument = (value: unknown): value is FlowDocumentJSON => {\n  if (!value || typeof value !== 'object') {\n    return false;\n  }\n  const workflow = value as FlowDocumentJSON;\n  return Array.isArray(workflow.nodes) && Array.isArray(workflow.edges);\n};\n\nexport const WorkbenchToolbar: FC<WorkbenchToolbarProps> = ({\n  activeTemplate,\n  onImportDocument,\n  onLoadBlank,\n  onLoadDefaultTemplate,\n}) => {\n  const { document } = useClientContext();\n  const playground = usePlayground();\n  const tools = usePlaygroundTools();\n  const runtimeService = useService(WorkflowRuntimeService);\n  const getGlobalVariableSchema = useService<GetGlobalVariableSchema>(GetGlobalVariableSchema);\n  const { open: openRuntimePanel } = useTestRunFormPanel();\n  const snapshot = useRuntimeSnapshot(runtimeService);\n  const fileInputRef = useRef<HTMLInputElement>(null);\n  const [backendStatus, setBackendStatus] = useState<BackendStatus>('checking');\n\n  const backendStatusLabel = useMemo(() => {\n    if (backendStatus === 'online') {\n      return '后端在线';\n    }\n    if (backendStatus === 'offline') {\n      return '后端离线';\n    }\n    return '检查后端中';\n  }, [backendStatus]);\n\n  const templateName = activeTemplate?.name ?? '当前草稿';\n  const templateDescription =\n    activeTemplate?.description ?? '导入或编辑中的自定义工作流，会自动保存到本地草稿。';\n  const templateBadge = activeTemplate?.badge ?? 'Draft';\n\n  useEffect(() => {\n    let disposed = false;\n\n    const pingBackend = async () => {\n      try {\n        const response = await fetch('/flowgram/tasks/validate', {\n          method: 'POST',\n          headers: {\n            'Content-Type': 'application/json',\n          },\n          body: JSON.stringify(HEALTHCHECK_PAYLOAD),\n        });\n        if (!disposed) {\n          setBackendStatus(response.ok ? 'online' : 'offline');\n        }\n      } catch (error) {\n        if (!disposed) {\n          setBackendStatus('offline');\n        }\n      }\n    };\n\n    pingBackend();\n    const timer = window.setInterval(pingBackend, 15000);\n\n    return () => {\n      disposed = true;\n      window.clearInterval(timer);\n    };\n  }, []);\n\n  const handleExport = () => {\n    const payload = {\n      ...document.toJSON(),\n      globalVariable: getGlobalVariableSchema(),\n    };\n    downloadTextFile('ai4j-flowgram-workflow.json', JSON.stringify(payload, null, 2));\n  };\n\n  const handleImportChange = async (event: ChangeEvent<HTMLInputElement>) => {\n    const file = event.target.files?.[0];\n    if (!file) {\n      return;\n    }\n\n    try {\n      const content = await file.text();\n      const parsed = JSON.parse(content) as FlowDocumentJSON;\n      if (!isWorkflowDocument(parsed)) {\n        throw new Error('文件内容不是有效的 FlowGram workflow JSON。');\n      }\n      onImportDocument(parsed);\n    } catch (error) {\n      const message = error instanceof Error ? error.message : '导入 JSON 失败。';\n      window.alert(message);\n    } finally {\n      event.target.value = '';\n    }\n  };\n\n  const handleValidate = async () => {\n    openRuntimePanel();\n    await runtimeService.taskValidate(runtimeService.getDraftInputs());\n  };\n\n  const handleRun = async () => {\n    openRuntimePanel();\n    await runtimeService.taskRun(runtimeService.getDraftInputs());\n  };\n\n  const handleCancel = async () => {\n    await runtimeService.taskCancel();\n  };\n\n  const handleAutoLayout = async () => {\n    await tools.autoLayout({\n      enableAnimation: true,\n      animationDuration: 500,\n      layoutConfig: {\n        rankdir: 'LR',\n        align: undefined,\n        nodesep: 100,\n        ranksep: 100,\n      },\n    });\n    await tools.fitView(false);\n    window.scrollTo({\n      top: 0,\n      left: 0,\n      behavior: 'instant',\n    });\n  };\n\n  return (\n    <header className=\"workbench-toolbar\">\n      <div className=\"workbench-toolbar__context\">\n        <div className=\"workbench-toolbar__eyebrow\">AI4J Flow Studio</div>\n        <div className=\"workbench-toolbar__headline\">\n          <div className=\"workbench-toolbar__title\">AI4J Flow Studio</div>\n          <div className=\"workbench-toolbar__badge\">{templateBadge}</div>\n        </div>\n        <div className=\"workbench-toolbar__subtitle\">面向 Spring Boot 的可视化工作流编排台</div>\n        <div className=\"workbench-toolbar__template\">\n          <span className=\"workbench-toolbar__template-label\">当前模板</span>\n          <span className=\"workbench-toolbar__template-name\">{templateName}</span>\n          <span className=\"workbench-toolbar__template-copy\">{templateDescription}</span>\n        </div>\n      </div>\n\n      <div className=\"workbench-toolbar__status\">\n        <div className=\"workbench-toolbar__meta\">\n          <Tag\n            color={\n              backendStatus === 'online' ? 'green' : backendStatus === 'offline' ? 'red' : 'blue'\n            }\n          >\n            {backendStatusLabel}\n          </Tag>\n          <Tag color=\"cyan\">代理 /flowgram -&gt; 127.0.0.1:18080</Tag>\n          <Tag color=\"white\">运行状态 {STATUS_LABELS[snapshot.status]}</Tag>\n          {snapshot.taskID ? <Tag color=\"white\">任务 ID {snapshot.taskID}</Tag> : null}\n        </div>\n      </div>\n\n      <div className=\"workbench-toolbar__actions\">\n        <div className=\"workbench-toolbar__action-group\">\n          <Button className=\"workbench-action workbench-action--secondary\" onClick={onLoadBlank}>\n            新建空白\n          </Button>\n          <Button\n            className=\"workbench-action workbench-action--secondary\"\n            onClick={onLoadDefaultTemplate}\n          >\n            加载示例\n          </Button>\n          <Button\n            className=\"workbench-action workbench-action--ghost\"\n            disabled={playground.config.readonly}\n            onClick={() => {\n              void handleAutoLayout();\n            }}\n          >\n            自动布局\n          </Button>\n        </div>\n        <div className=\"workbench-toolbar__action-group\">\n          <Button className=\"workbench-action workbench-action--ghost\" onClick={handleValidate}>\n            校验\n          </Button>\n          <Button className=\"workbench-action workbench-action--primary\" onClick={handleRun}>\n            运行\n          </Button>\n          <Button\n            className=\"workbench-action workbench-action--ghost\"\n            disabled={!snapshot.taskID}\n            onClick={handleCancel}\n          >\n            取消\n          </Button>\n        </div>\n        <div className=\"workbench-toolbar__action-group\">\n          <Button\n            className=\"workbench-action workbench-action--ghost\"\n            onClick={() => fileInputRef.current?.click()}\n          >\n            导入 JSON\n          </Button>\n          <Button className=\"workbench-action workbench-action--ghost\" onClick={handleExport}>\n            导出 JSON\n          </Button>\n        </div>\n        <input\n          ref={fileInputRef}\n          hidden\n          type=\"file\"\n          accept=\"application/json\"\n          onChange={handleImportChange}\n        />\n      </div>\n    </header>\n  );\n};\n"
  },
  {
    "path": "ai4j-flowgram-webapp-demo/tsconfig.json",
    "content": "{\n  \"extends\": \"@flowgram.ai/ts-config/tsconfig.flow.path.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"./src\",\n    \"outDir\": \"./dist\",\n    \"experimentalDecorators\": true,\n    \"target\": \"es2020\",\n    \"module\": \"esnext\",\n    \"strictPropertyInitialization\": false,\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"moduleResolution\": \"node\",\n    \"skipLibCheck\": true,\n    \"noUnusedLocals\": true,\n    \"noImplicitAny\": true,\n    \"allowJs\": true,\n    \"resolveJsonModule\": true,\n    \"types\": [\n      \"node\"\n    ],\n    \"jsx\": \"react-jsx\",\n    \"lib\": [\n      \"es6\",\n      \"dom\",\n      \"es2020\",\n      \"es2019.Array\"\n    ],\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ]\n    },\n  },\n  \"include\": [\n    \"./src\"\n  ],\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-spring-boot-starter</artifactId>\n    <packaging>jar</packaging>\n    <version>2.3.0</version>\n\n    <name>ai4j-spring-boot-starter</name>\n    <description>ai4j 核心 SDK 的 Spring Boot Starter，简化自动配置与集成。 Spring Boot starter for configuring and integrating the ai4j core SDK.</description>\n\n    <licenses>\n        <license>\n            <name>The Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n        </license>\n    </licenses>\n\n    <issueManagement>\n        <system>GitHub</system>\n        <url>https://github.com/LnYo-Cly/ai4j/issues</url>\n    </issueManagement>\n    <url>https://github.com/LnYo-Cly/ai4j</url>\n\n    <developers>\n        <developer>\n            <id>LnYo-Cly</id>\n            <name>LnYo-Cly</name>\n            <email>lnyocly@gmail.com</email>\n            <url>https://github.com/LnYo-Cly/ai4j</url>\n            <timezone>+8</timezone>\n        </developer>\n    </developers>\n\n    <scm>\n        <!--项目访问url -->\n        <url>https://github.com/LnYo-Cly/ai4j</url>\n        <!--项目访问url.git结尾 -->\n        <connection>scm:git:https://github.com/LnYo-Cly/ai4j.git</connection>\n        <!--项目访问url.git结尾 -->\n        <developerConnection>scm:git:https://github.com/LnYo-Cly/ai4j.git</developerConnection>\n    </scm>\n\n    <properties>\n        <java.version>1.8</java.version>\n        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>\n        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>\n        <spring-boot.version>2.3.12.RELEASE</spring-boot.version>\n        <skipTests>true</skipTests>\n    </properties>\n\n\n    <dependencies>\n        <dependency>\n            <groupId>io.github.lnyo-cly</groupId>\n            <artifactId>ai4j</artifactId>\n            <version>${project.version}</version>\n            <exclusions>\n                <exclusion>\n                    <groupId>org.slf4j</groupId>\n                    <artifactId>slf4j-simple</artifactId>\n                </exclusion>\n            </exclusions>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-autoconfigure</artifactId>\n            <version>${spring-boot.version}</version>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-configuration-processor</artifactId>\n            <version>${spring-boot.version}</version>\n            <optional>true</optional>\n        </dependency>\n        <dependency>\n            <groupId>javax.annotation</groupId>\n            <artifactId>javax.annotation-api</artifactId>\n            <version>1.3.2</version>\n        </dependency>\n        <dependency>\n            <groupId>org.springframework.boot</groupId>\n            <artifactId>spring-boot-test</artifactId>\n            <version>${spring-boot.version}</version>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>junit</groupId>\n            <artifactId>junit</artifactId>\n            <version>4.13.2</version>\n            <scope>test</scope>\n        </dependency>\n        <dependency>\n            <groupId>org.assertj</groupId>\n            <artifactId>assertj-core</artifactId>\n            <version>3.24.2</version>\n            <scope>test</scope>\n        </dependency>\n\n    </dependencies>\n\n    <build>\n        <finalName>${project.name}-${project.version}</finalName>\n        <plugins>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-surefire-plugin</artifactId>\n                <version>2.12.4</version>\n                <configuration>\n                    <skipTests>${skipTests}</skipTests>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-compiler-plugin</artifactId>\n                <version>3.8.1</version>\n                <configuration>\n                    <source>1.8</source>\n                    <target>1.8</target>\n                    <encoding>UTF8</encoding>\n                    <compilerArgs>\n                        <arg>-parameters</arg>\n                    </compilerArgs>\n                    <parameters>true</parameters>\n                    <annotationProcessorPaths>\n                        <path>\n                            <groupId>org.projectlombok</groupId>\n                            <artifactId>lombok</artifactId>\n                            <version>1.18.30</version>\n                        </path>\n                    </annotationProcessorPaths>\n                </configuration>\n            </plugin>\n\n        </plugins>\n    </build>\n\n\n    <profiles>\n        <profile>\n            <id>release</id>\n            <build>\n                <plugins>\n                    <!-- source源码插件 -->\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-source-plugin</artifactId>\n                        <version>3.3.1</version>\n                        <executions>\n                            <execution>\n                                <id>attach-sources</id>\n                                <goals>\n                                    <goal>jar-no-fork</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n\n                    <!-- Javadoc插件 -->\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-javadoc-plugin</artifactId>\n                        <version>3.6.3</version>\n                        <executions>\n                            <execution>\n                                <id>attach-javadocs</id>\n                                <goals>\n                                    <goal>jar</goal>\n                                </goals>\n                                <configuration>\n                                    <doclint>none</doclint>\n                                    <failOnError>false</failOnError>\n                                    <tags>\n                                        <tag>\n                                            <name>Author</name>\n                                            <placement>a</placement>\n                                            <head>Author:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Description</name>\n                                            <placement>a</placement>\n                                            <head>Description:</head>\n                                        </tag>\n                                        <tag>\n                                            <name>Date</name>\n                                            <placement>a</placement>\n                                            <head>Date:</head>\n                                        </tag>\n                                    </tags>\n                                </configuration>\n                            </execution>\n\n                        </executions>\n                    </plugin>\n\n                    <!-- GPG插件 -->\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-gpg-plugin</artifactId>\n                        <version>1.6</version>\n                        <configuration>\n                            <executable>D:\\Develop\\DevelopEnv\\GnuPG\\bin\\gpg.exe</executable>\n                            <keyname>cly</keyname>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>sign-artifacts</id>\n                                <phase>verify</phase>\n                                <goals>\n                                    <goal>sign</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n\n                    <!--   central发布插件    -->\n                    <plugin>\n                        <groupId>org.sonatype.central</groupId>\n                        <artifactId>central-publishing-maven-plugin</artifactId>\n                        <version>0.4.0</version>\n                        <extensions>true</extensions>\n                        <configuration>\n                            <publishingServerId>LnYo-Cly</publishingServerId>\n                            <tokenAuth>true</tokenAuth>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n\n\n</project>\n\n\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/AgentFlowProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowType;\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\n@Data\n@ConfigurationProperties(prefix = \"ai.agentflow\")\npublic class AgentFlowProperties {\n\n    private boolean enabled = false;\n\n    private String defaultName;\n\n    private Map<String, EndpointProperties> profiles = new LinkedHashMap<String, EndpointProperties>();\n\n    @Data\n    public static class EndpointProperties {\n        private AgentFlowType type;\n        private String baseUrl;\n        private String webhookUrl;\n        private String apiKey;\n        private String botId;\n        private String workflowId;\n        private String appId;\n        private String userId;\n        private String conversationId;\n        private Long pollIntervalMillis = 1000L;\n        private Long pollTimeoutMillis = 60000L;\n        private Map<String, String> headers = new LinkedHashMap<String, String>();\n    }\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/AgentFlowRegistry.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlow;\n\nimport java.util.Collections;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class AgentFlowRegistry {\n\n    private final Map<String, AgentFlow> agentFlows;\n    private final String defaultName;\n\n    public AgentFlowRegistry(Map<String, AgentFlow> agentFlows, String defaultName) {\n        Map<String, AgentFlow> source = agentFlows == null\n                ? Collections.<String, AgentFlow>emptyMap()\n                : new LinkedHashMap<String, AgentFlow>(agentFlows);\n        this.agentFlows = Collections.unmodifiableMap(source);\n        this.defaultName = defaultName;\n    }\n\n    public Map<String, AgentFlow> asMap() {\n        return agentFlows;\n    }\n\n    public Set<String> names() {\n        return agentFlows.keySet();\n    }\n\n    public boolean contains(String name) {\n        return agentFlows.containsKey(name);\n    }\n\n    public AgentFlow get(String name) {\n        AgentFlow agentFlow = agentFlows.get(name);\n        if (agentFlow == null) {\n            throw new IllegalArgumentException(\"Unknown agent flow profile: \" + name);\n        }\n        return agentFlow;\n    }\n\n    public AgentFlow getDefault() {\n        if (defaultName != null && defaultName.trim().length() > 0) {\n            return get(defaultName);\n        }\n        if (agentFlows.size() == 1) {\n            return agentFlows.values().iterator().next();\n        }\n        throw new IllegalStateException(\"No default agent flow is configured. Set ai.agentflow.default-name or use AgentFlowRegistry#get(name).\");\n    }\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/AiConfigAutoConfiguration.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport cn.hutool.core.bean.BeanUtil;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlow;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowConfig;\nimport io.github.lnyocly.ai4j.config.*;\nimport io.github.lnyocly.ai4j.interceptor.ContentTypeInterceptor;\nimport io.github.lnyocly.ai4j.interceptor.ErrorInterceptor;\nimport io.github.lnyocly.ai4j.network.ConnectionPoolProvider;\nimport io.github.lnyocly.ai4j.network.DispatcherProvider;\nimport io.github.lnyocly.ai4j.service.AiConfig;\nimport io.github.lnyocly.ai4j.service.factory.AiService;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceFactory;\nimport io.github.lnyocly.ai4j.service.factory.AiServiceRegistry;\nimport io.github.lnyocly.ai4j.service.factory.DefaultAiServiceFactory;\nimport io.github.lnyocly.ai4j.service.factory.DefaultAiServiceRegistry;\nimport io.github.lnyocly.ai4j.service.factory.FreeAiService;\nimport io.github.lnyocly.ai4j.network.OkHttpUtil;\nimport io.github.lnyocly.ai4j.rag.DefaultRagContextAssembler;\nimport io.github.lnyocly.ai4j.rag.NoopReranker;\nimport io.github.lnyocly.ai4j.rag.RagContextAssembler;\nimport io.github.lnyocly.ai4j.rag.Reranker;\nimport io.github.lnyocly.ai4j.service.spi.ServiceLoaderUtil;\nimport io.github.lnyocly.ai4j.vector.service.PineconeService;\nimport io.github.lnyocly.ai4j.vector.store.VectorStore;\nimport io.github.lnyocly.ai4j.vector.store.milvus.MilvusVectorStore;\nimport io.github.lnyocly.ai4j.vector.store.pgvector.PgVectorStore;\nimport io.github.lnyocly.ai4j.vector.store.pinecone.PineconeVectorStore;\nimport io.github.lnyocly.ai4j.vector.store.qdrant.QdrantVectorStore;\nimport io.github.lnyocly.ai4j.websearch.searxng.SearXNGConfig;\nimport okhttp3.OkHttpClient;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.apache.commons.lang3.StringUtils;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnBean;\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;\nimport org.springframework.boot.context.properties.EnableConfigurationProperties;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\nimport javax.annotation.PostConstruct;\nimport java.net.InetSocketAddress;\nimport java.net.Proxy;\nimport java.security.KeyManagementException;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/8/9 23:22\n */\n@Configuration\n@EnableConfigurationProperties({\n        AiConfigProperties.class,\n        OpenAiConfigProperties.class,\n        OkHttpConfigProperties.class,\n        PineconeConfigProperties.class,\n        QdrantConfigProperties.class,\n        MilvusConfigProperties.class,\n        PgVectorConfigProperties.class,\n        ZhipuConfigProperties.class,\n        DeepSeekConfigProperties.class,\n        MoonshotConfigProperties.class,\n        HunyuanConfigProperties.class,\n        LingyiConfigProperties.class,\n        OllamaConfigProperties.class,\n        MinimaxConfigProperties.class,\n        BaichuanConfigProperties.class,\n        SearXNGConfigProperties.class,\n        DashScopeConfigProperties.class,\n        DoubaoConfigProperties.class,\n        JinaConfigProperties.class,\n        AgentFlowProperties.class\n})\n\npublic class AiConfigAutoConfiguration {\n\n    // okhttp閰嶇疆\n    private final OkHttpConfigProperties okHttpConfigProperties;\n\n    // 鍚戦噺鏁版嵁搴撻厤缃?\n    private final PineconeConfigProperties pineconeConfigProperties;\n    private final QdrantConfigProperties qdrantConfigProperties;\n    private final MilvusConfigProperties milvusConfigProperties;\n    private final PgVectorConfigProperties pgVectorConfigProperties;\n\n    // searxng閰嶇疆\n    private final SearXNGConfigProperties searXNGConfigProperties;\n\n    // AI骞冲彴閰嶇疆\n    private final AiConfigProperties aiConfigProperties;\n    private final OpenAiConfigProperties openAiConfigProperties;\n    private final ZhipuConfigProperties zhipuConfigProperties;\n    private final DeepSeekConfigProperties deepSeekConfigProperties;\n    private final MoonshotConfigProperties moonshotConfigProperties;\n    private final HunyuanConfigProperties hunyuanConfigProperties;\n    private final LingyiConfigProperties lingyiConfigProperties;\n    private final OllamaConfigProperties ollamaConfigProperties;\n    private final MinimaxConfigProperties minimaxConfigProperties;\n    private final BaichuanConfigProperties baichuanConfigProperties;\n    private final DashScopeConfigProperties dashScopeConfigProperties;\n    private final DoubaoConfigProperties doubaoConfigProperties;\n    private final JinaConfigProperties jinaConfigProperties;\n    private final AgentFlowProperties agentFlowProperties;\n\n    private io.github.lnyocly.ai4j.service.Configuration configuration = new io.github.lnyocly.ai4j.service.Configuration();\n\n    public AiConfigAutoConfiguration(OkHttpConfigProperties okHttpConfigProperties, OpenAiConfigProperties openAiConfigProperties, PineconeConfigProperties pineconeConfigProperties, QdrantConfigProperties qdrantConfigProperties, MilvusConfigProperties milvusConfigProperties, PgVectorConfigProperties pgVectorConfigProperties, SearXNGConfigProperties searXNGConfigProperties, AiConfigProperties aiConfigProperties, ZhipuConfigProperties zhipuConfigProperties, DeepSeekConfigProperties deepSeekConfigProperties, MoonshotConfigProperties moonshotConfigProperties, HunyuanConfigProperties hunyuanConfigProperties, LingyiConfigProperties lingyiConfigProperties, OllamaConfigProperties ollamaConfigProperties, MinimaxConfigProperties minimaxConfigProperties, BaichuanConfigProperties baichuanConfigProperties, DashScopeConfigProperties dashScopeConfigProperties, DoubaoConfigProperties doubaoConfigProperties, JinaConfigProperties jinaConfigProperties, AgentFlowProperties agentFlowProperties) {\n        this.okHttpConfigProperties = okHttpConfigProperties;\n        this.openAiConfigProperties = openAiConfigProperties;\n        this.pineconeConfigProperties = pineconeConfigProperties;\n        this.qdrantConfigProperties = qdrantConfigProperties;\n        this.milvusConfigProperties = milvusConfigProperties;\n        this.pgVectorConfigProperties = pgVectorConfigProperties;\n        this.searXNGConfigProperties = searXNGConfigProperties;\n        this.aiConfigProperties = aiConfigProperties;\n        this.zhipuConfigProperties = zhipuConfigProperties;\n        this.deepSeekConfigProperties = deepSeekConfigProperties;\n        this.moonshotConfigProperties = moonshotConfigProperties;\n        this.hunyuanConfigProperties = hunyuanConfigProperties;\n        this.lingyiConfigProperties = lingyiConfigProperties;\n        this.ollamaConfigProperties = ollamaConfigProperties;\n        this.minimaxConfigProperties = minimaxConfigProperties;\n        this.baichuanConfigProperties = baichuanConfigProperties;\n        this.dashScopeConfigProperties = dashScopeConfigProperties;\n        this.doubaoConfigProperties = doubaoConfigProperties;\n        this.jinaConfigProperties = jinaConfigProperties;\n        this.agentFlowProperties = agentFlowProperties;\n    }\n\n    @Bean\n    public AiService aiService() {\n        return new AiService(configuration);\n    }\n\n    @Bean\n    public AiServiceFactory aiServiceFactory() {\n        return new DefaultAiServiceFactory();\n    }\n\n    @Bean\n    public AiServiceRegistry aiServiceRegistry(AiServiceFactory aiServiceFactory) {\n        AiConfig aiConfig = new AiConfig();\n        aiConfig.setPlatforms(BeanUtil.copyToList(aiConfigProperties.getPlatforms(), AiPlatform.class));\n        return DefaultAiServiceRegistry.from(configuration, aiConfig, aiServiceFactory);\n    }\n\n    @Bean\n    public FreeAiService getFreeAiService(AiServiceRegistry aiServiceRegistry) {\n        return new FreeAiService(aiServiceRegistry);\n    }\n\n    @Bean\n    @ConditionalOnProperty(prefix = \"ai.agentflow\", name = \"enabled\", havingValue = \"true\")\n    @ConditionalOnMissingBean\n    public AgentFlowRegistry agentFlowRegistry(AiService aiService) {\n        Map<String, AgentFlow> agentFlows = new LinkedHashMap<String, AgentFlow>();\n        if (agentFlowProperties.getProfiles() != null) {\n            for (Map.Entry<String, AgentFlowProperties.EndpointProperties> entry : agentFlowProperties.getProfiles().entrySet()) {\n                if (entry.getValue() == null) {\n                    continue;\n                }\n                agentFlows.put(entry.getKey(), aiService.getAgentFlow(toAgentFlowConfig(entry.getValue())));\n            }\n        }\n        return new AgentFlowRegistry(agentFlows, agentFlowProperties.getDefaultName());\n    }\n\n    @Bean\n    @ConditionalOnBean(AgentFlowRegistry.class)\n    @ConditionalOnProperty(prefix = \"ai.agentflow\", name = \"default-name\")\n    @ConditionalOnMissingBean(AgentFlow.class)\n    public AgentFlow agentFlow(AgentFlowRegistry agentFlowRegistry) {\n        return agentFlowRegistry.getDefault();\n    }\n\n    @Bean\n    public PineconeService pineconeService() {\n        return new PineconeService(configuration);\n    }\n\n    @Bean\n    @ConditionalOnMissingBean(PineconeVectorStore.class)\n    public PineconeVectorStore pineconeVectorStore(PineconeService pineconeService) {\n        return new PineconeVectorStore(pineconeService);\n    }\n\n    @Bean\n    @ConditionalOnProperty(prefix = \"ai.vector.qdrant\", name = \"enabled\", havingValue = \"true\")\n    @ConditionalOnMissingBean(QdrantVectorStore.class)\n    public QdrantVectorStore qdrantVectorStore() {\n        return new QdrantVectorStore(configuration);\n    }\n\n    @Bean\n    @ConditionalOnProperty(prefix = \"ai.vector.milvus\", name = \"enabled\", havingValue = \"true\")\n    @ConditionalOnMissingBean(MilvusVectorStore.class)\n    public MilvusVectorStore milvusVectorStore() {\n        return new MilvusVectorStore(configuration);\n    }\n\n    @Bean\n    @ConditionalOnProperty(prefix = \"ai.vector.pgvector\", name = \"enabled\", havingValue = \"true\")\n    @ConditionalOnMissingBean(PgVectorStore.class)\n    public PgVectorStore pgVectorStore() {\n        return new PgVectorStore(configuration);\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public RagContextAssembler ragContextAssembler() {\n        return new DefaultRagContextAssembler();\n    }\n\n    @Bean\n    @ConditionalOnMissingBean\n    public Reranker ragReranker() {\n        return new NoopReranker();\n    }\n\n    @PostConstruct\n    private void init() {\n        initOkHttp();\n\n        initPineconeConfig();\n        initQdrantConfig();\n        initMilvusConfig();\n        initPgVectorConfig();\n\n        initSearXNGConfig();\n\n        initOpenAiConfig();\n        initZhipuConfig();\n        initDeepSeekConfig();\n        initMoonshotConfig();\n        initHunyuanConfig();\n        initLingyiConfig();\n        initOllamaConfig();\n        initMinimaxConfig();\n        initBaichuanConfig();\n        initDashScopeConfig();\n        initDoubaoConfig();\n        initJinaConfig();\n    }\n\n\n\n    private void initOkHttp() {\n        //configuration.setProxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(\"127.0.0.1\",10809)));\n\n        // 鏃ュ織閰嶇疆\n        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();\n        httpLoggingInterceptor.setLevel(okHttpConfigProperties.getLog());\n\n        // SPI鍔犺浇dispatcher鍜宑onnectionPool\n        DispatcherProvider dispatcherProvider = ServiceLoaderUtil.load(DispatcherProvider.class);\n        ConnectionPoolProvider connectionPoolProvider = ServiceLoaderUtil.load(ConnectionPoolProvider.class);\n\n        // 寮€鍚?Http 瀹㈡埛绔?\n        OkHttpClient.Builder okHttpBuilder = new OkHttpClient\n                .Builder()\n                .addInterceptor(httpLoggingInterceptor)\n                .addInterceptor(new ErrorInterceptor())\n                .addInterceptor(new ContentTypeInterceptor())\n                .connectTimeout(okHttpConfigProperties.getConnectTimeout(), okHttpConfigProperties.getTimeUnit())\n                .writeTimeout(okHttpConfigProperties.getWriteTimeout(), okHttpConfigProperties.getTimeUnit())\n                .readTimeout(okHttpConfigProperties.getReadTimeout(), okHttpConfigProperties.getTimeUnit())\n                .dispatcher(dispatcherProvider.getDispatcher())\n                .connectionPool(connectionPoolProvider.getConnectionPool());\n\n        // 鏄惁寮€鍚疨roxy浠ｇ悊\n        if(StringUtils.isNotBlank(okHttpConfigProperties.getProxyUrl())){\n            Proxy proxy = new Proxy(okHttpConfigProperties.getProxyType(), new InetSocketAddress(okHttpConfigProperties.getProxyUrl(), okHttpConfigProperties.getProxyPort()));\n            okHttpBuilder.proxy(proxy);\n        }\n\n        // 蹇界暐SSL璇佷功楠岃瘉, 榛樿寮€鍚?\n        if(okHttpConfigProperties.isIgnoreSsl()){\n            try {\n                okHttpBuilder\n                        .sslSocketFactory(OkHttpUtil.getIgnoreInitedSslContext().getSocketFactory(), OkHttpUtil.IGNORE_SSL_TRUST_MANAGER_X509)\n                        .hostnameVerifier(OkHttpUtil.getIgnoreSslHostnameVerifier());\n            } catch (NoSuchAlgorithmException e) {\n                throw new RuntimeException(e);\n            } catch (KeyManagementException e) {\n                throw new RuntimeException(e);\n            }\n        }\n\n        OkHttpClient okHttpClient = okHttpBuilder.build();\n\n        configuration.setOkHttpClient(okHttpClient);\n    }\n\n    /**\n     * 鍒濆鍖朞penai 閰嶇疆淇℃伅\n     */\n    private void initOpenAiConfig() {\n        OpenAiConfig openAiConfig = new OpenAiConfig();\n        openAiConfig.setApiHost(openAiConfigProperties.getApiHost());\n        openAiConfig.setApiKey(openAiConfigProperties.getApiKey());\n        openAiConfig.setChatCompletionUrl(openAiConfigProperties.getChatCompletionUrl());\n        openAiConfig.setEmbeddingUrl(openAiConfigProperties.getEmbeddingUrl());\n        openAiConfig.setSpeechUrl(openAiConfigProperties.getSpeechUrl());\n        openAiConfig.setTranscriptionUrl(openAiConfigProperties.getTranscriptionUrl());\n        openAiConfig.setTranslationUrl(openAiConfigProperties.getTranslationUrl());\n        openAiConfig.setRealtimeUrl(openAiConfigProperties.getRealtimeUrl());\n        openAiConfig.setImageGenerationUrl(openAiConfigProperties.getImageGenerationUrl());\n        openAiConfig.setResponsesUrl(openAiConfigProperties.getResponsesUrl());\n\n        configuration.setOpenAiConfig(openAiConfig);\n    }\n\n    /**\n     * 鍒濆鍖朲hipu 閰嶇疆淇℃伅\n     */\n    private void initZhipuConfig() {\n        ZhipuConfig zhipuConfig = new ZhipuConfig();\n        zhipuConfig.setApiHost(zhipuConfigProperties.getApiHost());\n        zhipuConfig.setApiKey(zhipuConfigProperties.getApiKey());\n        zhipuConfig.setChatCompletionUrl(zhipuConfigProperties.getChatCompletionUrl());\n        zhipuConfig.setEmbeddingUrl(zhipuConfigProperties.getEmbeddingUrl());\n\n        configuration.setZhipuConfig(zhipuConfig);\n    }\n\n    /**\n     * 鍒濆鍖栧悜閲忔暟鎹簱 pinecone\n     */\n    private void initPineconeConfig() {\n        PineconeConfig pineconeConfig = new PineconeConfig();\n        pineconeConfig.setHost(pineconeConfigProperties.getHost());\n        pineconeConfig.setKey(pineconeConfigProperties.getKey());\n        pineconeConfig.setUpsert(pineconeConfigProperties.getUpsert());\n        pineconeConfig.setQuery(pineconeConfigProperties.getQuery());\n        pineconeConfig.setDelete(pineconeConfigProperties.getDelete());\n\n        configuration.setPineconeConfig(pineconeConfig);\n    }\n\n    private void initQdrantConfig() {\n        QdrantConfig qdrantConfig = new QdrantConfig();\n        qdrantConfig.setEnabled(qdrantConfigProperties.isEnabled());\n        qdrantConfig.setHost(qdrantConfigProperties.getHost());\n        qdrantConfig.setApiKey(qdrantConfigProperties.getApiKey());\n        qdrantConfig.setVectorName(qdrantConfigProperties.getVectorName());\n        qdrantConfig.setUpsert(qdrantConfigProperties.getUpsert());\n        qdrantConfig.setQuery(qdrantConfigProperties.getQuery());\n        qdrantConfig.setDelete(qdrantConfigProperties.getDelete());\n\n        configuration.setQdrantConfig(qdrantConfig);\n    }\n\n    private void initMilvusConfig() {\n        MilvusConfig milvusConfig = new MilvusConfig();\n        milvusConfig.setEnabled(milvusConfigProperties.isEnabled());\n        milvusConfig.setHost(milvusConfigProperties.getHost());\n        milvusConfig.setToken(milvusConfigProperties.getToken());\n        milvusConfig.setDbName(milvusConfigProperties.getDbName());\n        milvusConfig.setPartitionName(milvusConfigProperties.getPartitionName());\n        milvusConfig.setIdField(milvusConfigProperties.getIdField());\n        milvusConfig.setVectorField(milvusConfigProperties.getVectorField());\n        milvusConfig.setContentField(milvusConfigProperties.getContentField());\n        milvusConfig.setOutputFields(milvusConfigProperties.getOutputFields());\n        milvusConfig.setUpsert(milvusConfigProperties.getUpsert());\n        milvusConfig.setSearch(milvusConfigProperties.getSearch());\n        milvusConfig.setDelete(milvusConfigProperties.getDelete());\n\n        configuration.setMilvusConfig(milvusConfig);\n    }\n\n    private void initPgVectorConfig() {\n        PgVectorConfig pgVectorConfig = new PgVectorConfig();\n        pgVectorConfig.setEnabled(pgVectorConfigProperties.isEnabled());\n        pgVectorConfig.setJdbcUrl(pgVectorConfigProperties.getJdbcUrl());\n        pgVectorConfig.setUsername(pgVectorConfigProperties.getUsername());\n        pgVectorConfig.setPassword(pgVectorConfigProperties.getPassword());\n        pgVectorConfig.setTableName(pgVectorConfigProperties.getTableName());\n        pgVectorConfig.setIdColumn(pgVectorConfigProperties.getIdColumn());\n        pgVectorConfig.setDatasetColumn(pgVectorConfigProperties.getDatasetColumn());\n        pgVectorConfig.setVectorColumn(pgVectorConfigProperties.getVectorColumn());\n        pgVectorConfig.setContentColumn(pgVectorConfigProperties.getContentColumn());\n        pgVectorConfig.setMetadataColumn(pgVectorConfigProperties.getMetadataColumn());\n        pgVectorConfig.setDistanceOperator(pgVectorConfigProperties.getDistanceOperator());\n\n        configuration.setPgVectorConfig(pgVectorConfig);\n    }\n\n    /**\n     * 鍒濆鍖朌eepSeek 閰嶇疆淇℃伅\n     */\n    private void initDeepSeekConfig(){\n        DeepSeekConfig deepSeekConfig = new DeepSeekConfig();\n        deepSeekConfig.setApiHost(deepSeekConfigProperties.getApiHost());\n        deepSeekConfig.setApiKey(deepSeekConfigProperties.getApiKey());\n        deepSeekConfig.setChatCompletionUrl(deepSeekConfigProperties.getChatCompletionUrl());\n\n        configuration.setDeepSeekConfig(deepSeekConfig);\n    }\n\n    /**\n     * 鍒濆鍖朚oonshot 閰嶇疆淇℃伅\n     */\n    private void initMoonshotConfig() {\n        MoonshotConfig moonshotConfig = new MoonshotConfig();\n        moonshotConfig.setApiHost(moonshotConfigProperties.getApiHost());\n        moonshotConfig.setApiKey(moonshotConfigProperties.getApiKey());\n        moonshotConfig.setChatCompletionUrl(moonshotConfigProperties.getChatCompletionUrl());\n\n        configuration.setMoonshotConfig(moonshotConfig);\n    }\n\n    /**\n     * 鍒濆鍖朒unyuan 閰嶇疆淇℃伅\n     */\n    private void initHunyuanConfig() {\n        HunyuanConfig hunyuanConfig = new HunyuanConfig();\n        hunyuanConfig.setApiHost(hunyuanConfigProperties.getApiHost());\n        hunyuanConfig.setApiKey(hunyuanConfigProperties.getApiKey());\n\n        configuration.setHunyuanConfig(hunyuanConfig);\n    }\n\n    /**\n     * 鍒濆鍖杔ingyi 閰嶇疆淇℃伅\n     */\n    private void initLingyiConfig() {\n        LingyiConfig lingyiConfig = new LingyiConfig();\n        lingyiConfig.setApiHost(lingyiConfigProperties.getApiHost());\n        lingyiConfig.setApiKey(lingyiConfigProperties.getApiKey());\n        lingyiConfig.setChatCompletionUrl(lingyiConfigProperties.getChatCompletionUrl());\n\n        configuration.setLingyiConfig(lingyiConfig);\n    }\n\n    /**\n     * 鍒濆鍖朞llama 閰嶇疆淇℃伅\n     */\n    private void initOllamaConfig() {\n        OllamaConfig ollamaConfig = new OllamaConfig();\n        ollamaConfig.setApiHost(ollamaConfigProperties.getApiHost());\n        ollamaConfig.setApiKey(ollamaConfigProperties.getApiKey());\n        ollamaConfig.setChatCompletionUrl(ollamaConfigProperties.getChatCompletionUrl());\n        ollamaConfig.setEmbeddingUrl(ollamaConfigProperties.getEmbeddingUrl());\n        ollamaConfig.setRerankUrl(ollamaConfigProperties.getRerankUrl());\n\n        configuration.setOllamaConfig(ollamaConfig);\n    }\n\n    /**\n     * 鍒濆鍖朚inimax 閰嶇疆淇℃伅\n     */\n    private void initMinimaxConfig() {\n        MinimaxConfig minimaxConfig = new MinimaxConfig();\n        minimaxConfig.setApiHost(minimaxConfigProperties.getApiHost());\n        minimaxConfig.setApiKey(minimaxConfigProperties.getApiKey());\n        minimaxConfig.setChatCompletionUrl(minimaxConfigProperties.getChatCompletionUrl());\n\n        configuration.setMinimaxConfig(minimaxConfig);\n    }\n\n    /**\n     * 鍒濆鍖朆aichuan 閰嶇疆淇℃伅\n     */\n    private void initBaichuanConfig() {\n        BaichuanConfig baichuanConfig = new BaichuanConfig();\n        baichuanConfig.setApiHost(baichuanConfigProperties.getApiHost());\n        baichuanConfig.setApiKey(baichuanConfigProperties.getApiKey());\n        baichuanConfig.setChatCompletionUrl(baichuanConfigProperties.getChatCompletionUrl());\n\n        configuration.setBaichuanConfig(baichuanConfig);\n    }\n\n    /**\n     * 鍒濆鍖杝earxng 閰嶇疆淇℃伅\n     */\n    private void initSearXNGConfig() {\n        SearXNGConfig searXNGConfig = new SearXNGConfig();\n        searXNGConfig.setUrl(searXNGConfigProperties.getUrl());\n        searXNGConfig.setEngines(searXNGConfigProperties.getEngines());\n        searXNGConfig.setNums(searXNGConfigProperties.getNums());\n\n        configuration.setSearXNGConfig(searXNGConfig);\n    }\n\n    /**\n     * 鍒濆鍖朌ashscope 閰嶇疆淇℃伅\n     */\n    private void initDashScopeConfig() {\n        DashScopeConfig dashScopeConfig = new DashScopeConfig();\n        dashScopeConfig.setApiKey(dashScopeConfigProperties.getApiKey());\n        dashScopeConfig.setApiHost(dashScopeConfigProperties.getApiHost());\n        dashScopeConfig.setResponsesUrl(dashScopeConfigProperties.getResponsesUrl());\n\n        configuration.setDashScopeConfig(dashScopeConfig);\n    }\n\n    /**\n     * 鍒濆鍖朌oubao(鐏北寮曟搸鏂硅垷) 閰嶇疆淇℃伅\n     */\n    private void initDoubaoConfig() {\n        DoubaoConfig doubaoConfig = new DoubaoConfig();\n        doubaoConfig.setApiHost(doubaoConfigProperties.getApiHost());\n        doubaoConfig.setApiKey(doubaoConfigProperties.getApiKey());\n        doubaoConfig.setChatCompletionUrl(doubaoConfigProperties.getChatCompletionUrl());\n        doubaoConfig.setImageGenerationUrl(doubaoConfigProperties.getImageGenerationUrl());\n        doubaoConfig.setResponsesUrl(doubaoConfigProperties.getResponsesUrl());\n        doubaoConfig.setRerankApiHost(doubaoConfigProperties.getRerankApiHost());\n        doubaoConfig.setRerankUrl(doubaoConfigProperties.getRerankUrl());\n\n        configuration.setDoubaoConfig(doubaoConfig);\n    }\n\n    private void initJinaConfig() {\n        JinaConfig jinaConfig = new JinaConfig();\n        jinaConfig.setApiHost(jinaConfigProperties.getApiHost());\n        jinaConfig.setApiKey(jinaConfigProperties.getApiKey());\n        jinaConfig.setRerankUrl(jinaConfigProperties.getRerankUrl());\n\n        configuration.setJinaConfig(jinaConfig);\n    }\n\n    private AgentFlowConfig toAgentFlowConfig(AgentFlowProperties.EndpointProperties properties) {\n        return AgentFlowConfig.builder()\n                .type(properties.getType())\n                .baseUrl(properties.getBaseUrl())\n                .webhookUrl(properties.getWebhookUrl())\n                .apiKey(properties.getApiKey())\n                .botId(properties.getBotId())\n                .workflowId(properties.getWorkflowId())\n                .appId(properties.getAppId())\n                .userId(properties.getUserId())\n                .conversationId(properties.getConversationId())\n                .pollIntervalMillis(properties.getPollIntervalMillis())\n                .pollTimeoutMillis(properties.getPollTimeoutMillis())\n                .headers(properties.getHeaders())\n                .build();\n    }\n}\n\n\n\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/AiConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\nimport java.util.List;\n\n/**\n * 配置示例：\n * <pre>\n * ai:\n *   platforms:\n *     - id: \"aliyun\"\n *       platform: \"openai\"\n *       api-key: \"sk-xxx\"\n *       api-host: \"https://dashscope.aliyuncs.com/compatible-mode/\"\n *     - id: \"baidu\"\n *       platform: \"openai\"\n *       api-key: \"sk-xxx\"\n *       api-host: \"https://dashscope.aliyuncs.com/compatible-mode/\"\n * </pre>\n */\n@ConfigurationProperties(prefix = \"ai\")\n@Data\npublic class AiConfigProperties {\n\n    private List<AiPlatformProperties> platforms;\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/AiPlatformProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\n\n@Data\npublic class AiPlatformProperties {\n    // 唯一标识，用于获取对应的服务\n    private String id;\n    // 平台类型，如：openai、zhipu、deepseek、moonshot、hunyuan、lingyi、ollama、minimax、baichuan、pinecone、searxng\n    private String platform;\n    private String apiHost;\n    private String apiKey;\n    private String chatCompletionUrl;\n    private String embeddingUrl;\n    private String speechUrl;\n    private String transcriptionUrl;\n    private String translationUrl;\n    private String realtimeUrl;\n    private String rerankApiHost;\n    private String rerankUrl;\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/BaichuanConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description 智谱 配置文件\n * @Date 2024/8/28 17:39\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.baichuan\")\npublic class BaichuanConfigProperties {\n    private String apiHost = \"https://api.baichuan-ai.com/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/chat/completions\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/DashScopeConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description 鏅鸿氨 閰嶇疆鏂囦欢\n * @Date 2024/8/28 17:39\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.dashscope\")\npublic class DashScopeConfigProperties {\n    private String apiHost = \"https://dashscope.aliyuncs.com/api/v2/apps/protocols/compatible-mode/v1/\";\n    private String responsesUrl = \"responses\";\n    private String apiKey = \"\";\n}\n\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/DeepSeekConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description DeepSeek配置文件\n * @Date 2024/8/29 15:01\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.deepseek\")\npublic class DeepSeekConfigProperties {\n\n    private String apiHost = \"https://api.deepseek.com/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"chat/completions\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/DoubaoConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n\n\n@Data\n@ConfigurationProperties(prefix = \"ai.doubao\")\npublic class DoubaoConfigProperties {\n\n    private String apiHost = \"https://ark.cn-beijing.volces.com/api/v3/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"chat/completions\";\n    private String imageGenerationUrl = \"images/generations\";\n    private String responsesUrl = \"responses\";\n    private String rerankApiHost = \"https://api-knowledgebase.mlp.cn-beijing.volces.com/\";\n    private String rerankUrl = \"api/knowledge/service/rerank\";\n}\n\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/HunyuanConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description 腾讯混元配置文件\n * @Date 2024/9/2 19:13\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.hunyuan\")\npublic class HunyuanConfigProperties {\n    private String apiHost = \"https://hunyuan.tencentcloudapi.com/\";\n    /**\n     * apiKey 属于SecretId与SecretKey的拼接，格式为 {SecretId}.{SecretKey}\n     */\n    private String apiKey = \"\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/JinaConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n@Data\n@ConfigurationProperties(prefix = \"ai.jina\")\npublic class JinaConfigProperties {\n\n    private String apiHost = \"https://api.jina.ai/\";\n\n    private String apiKey = \"\";\n\n    private String rerankUrl = \"v1/rerank\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/LingyiConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description 零一万物配置文件\n * @Date 2024/9/9 23:31\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.lingyi\")\npublic class LingyiConfigProperties {\n    private String apiHost = \"https://api.lingyiwanwu.com/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/chat/completions\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/MilvusConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\nimport java.util.Arrays;\nimport java.util.List;\n\n@Data\n@ConfigurationProperties(prefix = \"ai.vector.milvus\")\npublic class MilvusConfigProperties {\n\n    private boolean enabled = false;\n\n    private String host = \"http://localhost:19530\";\n\n    private String token = \"\";\n\n    private String dbName = \"\";\n\n    private String partitionName = \"\";\n\n    private String idField = \"id\";\n\n    private String vectorField = \"vector\";\n\n    private String contentField = \"content\";\n\n    private List<String> outputFields = Arrays.asList(\n            \"id\",\n            \"content\",\n            \"documentId\",\n            \"sourceName\",\n            \"sourcePath\",\n            \"sourceUri\",\n            \"pageNumber\",\n            \"sectionTitle\",\n            \"chunkIndex\"\n    );\n\n    private String upsert = \"/v2/vectordb/entities/upsert\";\n\n    private String search = \"/v2/vectordb/entities/search\";\n\n    private String delete = \"/v2/vectordb/entities/delete\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/MinimaxConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author : isxuwl\n * @Date: 2024/10/15 16:27\n * @Model Description:\n * @Description:\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.minimax\")\npublic class MinimaxConfigProperties {\n    private String apiHost = \"https://api.minimax.chat/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/text/chatcompletion_v2\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/MoonshotConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description Moonshot配置文件\n * @Date 2024/8/30 15:56\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.moonshot\")\npublic class MoonshotConfigProperties {\n    private String apiHost = \"https://api.moonshot.cn/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/chat/completions\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/OkHttpConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport okhttp3.logging.HttpLoggingInterceptor;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\nimport java.net.Proxy;\nimport java.util.concurrent.TimeUnit;\n\n/**\n * @Author cly\n * @Description OkHttp配置文件\n * @Date 2024/8/10 0:49\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.okhttp\")\npublic class OkHttpConfigProperties {\n\n    private Proxy.Type proxyType = Proxy.Type.HTTP;\n    private String proxyUrl = \"\";\n    private int proxyPort;\n\n    private HttpLoggingInterceptor.Level log = HttpLoggingInterceptor.Level.BASIC;\n    private int connectTimeout = 300;\n    private int writeTimeout = 300;\n    private int readTimeout = 300;\n    private TimeUnit timeUnit = TimeUnit.SECONDS;\n\n    /**\n     * 忽略SSL证书，用于请求Moonshot(Kimi)，其它平台可以不用忽略\n     */\n    private boolean ignoreSsl = true;\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/OllamaConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description Ollama配置文件\n * @Date 2024/9/20 23:01\n */\n@Data\n@ConfigurationProperties(prefix = \"ai.ollama\")\npublic class OllamaConfigProperties {\n    private String apiHost = \"http://localhost:11434/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"api/chat\";\n    private String embeddingUrl = \"api/embed\";\n    private String rerankUrl = \"api/rerank\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/OpenAiConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport lombok.NoArgsConstructor;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description OpenAI閰嶇疆鏂囦欢\n * @Date 2024/8/9 23:17\n */\n\n@Data\n@NoArgsConstructor\n@ConfigurationProperties(prefix = \"ai.openai\")\npublic class OpenAiConfigProperties {\n    private String apiHost = \"https://api.openai.com/\";\n    //private String wsHost = \"wss://api.openai.com/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v1/chat/completions\";\n    private String embeddingUrl = \"v1/embeddings\";\n    private String speechUrl = \"v1/audio/speech\";\n    private String transcriptionUrl = \"v1/audio/transcriptions\";\n    private String translationUrl = \"v1/audio/translations\";\n    private String realtimeUrl = \"v1/realtime\";\n    private String imageGenerationUrl = \"v1/images/generations\";\n    private String responsesUrl = \"v1/responses\";\n}\n\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/PgVectorConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n@Data\n@ConfigurationProperties(prefix = \"ai.vector.pgvector\")\npublic class PgVectorConfigProperties {\n\n    private boolean enabled = false;\n\n    private String jdbcUrl = \"jdbc:postgresql://localhost:5432/postgres\";\n\n    private String username = \"\";\n\n    private String password = \"\";\n\n    private String tableName = \"ai4j_vectors\";\n\n    private String idColumn = \"id\";\n\n    private String datasetColumn = \"dataset\";\n\n    private String vectorColumn = \"embedding\";\n\n    private String contentColumn = \"content\";\n\n    private String metadataColumn = \"metadata\";\n\n    private String distanceOperator = \"<=>\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/PineconeConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description Pinecone 向量数据配置文件\n * @Date 2024/8/16 16:37\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.vector.pinecone\")\npublic class PineconeConfigProperties {\n    private String host = \"https://xxx.svc.xxx.pinecone.io\";\n    private String key = \"\";\n\n    private String upsert = \"/vectors/upsert\";\n    private String query = \"/query\";\n    private String delete = \"/vectors/delete\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/QdrantConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n@Data\n@ConfigurationProperties(prefix = \"ai.vector.qdrant\")\npublic class QdrantConfigProperties {\n\n    private boolean enabled = false;\n\n    private String host = \"http://localhost:6333\";\n\n    private String apiKey = \"\";\n\n    private String vectorName = \"\";\n\n    private String upsert = \"/collections/%s/points\";\n\n    private String query = \"/collections/%s/points/query\";\n\n    private String delete = \"/collections/%s/points/delete\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/SearXNGConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description TODO\n * @Date 2024/12/12 11:55\n */\n@Data\n@ConfigurationProperties(prefix = \"ai.websearch.searxng\")\npublic class SearXNGConfigProperties {\n    private String url;\n    private String engines = \"duckduckgo,google,bing,brave,mojeek,presearch,qwant,startpage,yahoo,arxiv,crossref,google_scholar,internetarchivescholar,semantic_scholar\";\n    private int nums = 20;\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/java/io/github/lnyocly/ai4j/ZhipuConfigProperties.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport lombok.Data;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\n\n/**\n * @Author cly\n * @Description 智谱 配置文件\n * @Date 2024/8/28 17:39\n */\n\n@Data\n@ConfigurationProperties(prefix = \"ai.zhipu\")\npublic class ZhipuConfigProperties {\n    private String apiHost = \"https://open.bigmodel.cn/api/paas/\";\n    private String apiKey = \"\";\n    private String chatCompletionUrl = \"v4/chat/completions\";\n    private String embeddingUrl = \"v4/embeddings\";\n}\n"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports",
    "content": "io.github.lnyocly.ai4j.AiConfigAutoConfiguration"
  },
  {
    "path": "ai4j-spring-boot-starter/src/main/resources/META-INF/spring.factories",
    "content": "org.springframework.boot.autoconfigure.EnableAutoConfiguration=\\\nio.github.lnyocly.ai4j.AiConfigAutoConfiguration"
  },
  {
    "path": "ai4j-spring-boot-starter/src/test/java/io/github/lnyocly/ai4j/AgentFlowAutoConfigurationTest.java",
    "content": "package io.github.lnyocly.ai4j;\n\nimport io.github.lnyocly.ai4j.agentflow.AgentFlow;\nimport io.github.lnyocly.ai4j.agentflow.AgentFlowType;\nimport org.junit.Assert;\nimport org.junit.Test;\nimport org.springframework.boot.test.context.runner.ApplicationContextRunner;\n\npublic class AgentFlowAutoConfigurationTest {\n\n    private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()\n            .withUserConfiguration(AiConfigAutoConfiguration.class);\n\n    @Test\n    public void test_agent_flow_registry_is_created() {\n        contextRunner\n                .withPropertyValues(\n                        \"ai.agentflow.enabled=true\",\n                        \"ai.agentflow.profiles.dify.type=DIFY\",\n                        \"ai.agentflow.profiles.dify.base-url=https://api.dify.ai\",\n                        \"ai.agentflow.profiles.dify.api-key=app-xxx\"\n                )\n                .run(context -> {\n                    Assert.assertTrue(context.containsBean(\"agentFlowRegistry\"));\n                    Assert.assertFalse(context.containsBean(\"agentFlow\"));\n\n                    AgentFlowRegistry registry = context.getBean(AgentFlowRegistry.class);\n                    Assert.assertTrue(registry.contains(\"dify\"));\n                    Assert.assertEquals(AgentFlowType.DIFY, registry.get(\"dify\").getConfig().getType());\n                    Assert.assertEquals(\"https://api.dify.ai\", registry.get(\"dify\").getConfig().getBaseUrl());\n                    Assert.assertEquals(AgentFlowType.DIFY, registry.getDefault().getConfig().getType());\n                });\n    }\n\n    @Test\n    public void test_default_agent_flow_bean_uses_default_name() {\n        contextRunner\n                .withPropertyValues(\n                        \"ai.agentflow.enabled=true\",\n                        \"ai.agentflow.default-name=coze\",\n                        \"ai.agentflow.profiles.dify.type=DIFY\",\n                        \"ai.agentflow.profiles.dify.base-url=https://api.dify.ai\",\n                        \"ai.agentflow.profiles.dify.api-key=app-xxx\",\n                        \"ai.agentflow.profiles.coze.type=COZE\",\n                        \"ai.agentflow.profiles.coze.base-url=https://api.coze.com\",\n                        \"ai.agentflow.profiles.coze.api-key=pat-xxx\",\n                        \"ai.agentflow.profiles.coze.bot-id=bot-123\"\n                )\n                .run(context -> {\n                    Assert.assertTrue(context.containsBean(\"agentFlow\"));\n\n                    AgentFlow agentFlow = context.getBean(AgentFlow.class);\n                    Assert.assertEquals(AgentFlowType.COZE, agentFlow.getConfig().getType());\n                    Assert.assertEquals(\"bot-123\", agentFlow.getConfig().getBotId());\n\n                    AgentFlowRegistry registry = context.getBean(AgentFlowRegistry.class);\n                    Assert.assertEquals(AgentFlowType.COZE, registry.getDefault().getConfig().getType());\n                });\n    }\n}\n"
  },
  {
    "path": "docs-site/.gitignore",
    "content": "# Dependencies\n/node_modules\n\n# Production\n/build\n/build-next\n\n# Generated files\n.docusaurus\n.cache-loader\n\n# Misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n"
  },
  {
    "path": "docs-site/README.md",
    "content": "﻿# docs-site\n\nAI4J 官方 Docusaurus 文档站（中文主站）。\n\n## 本地开发\n\n```bash\ncd docs-site\nnpm install\nnpm start\n```\n\n## 构建与预览\n\n```bash\nnpm run clear\nnpm run build\nnpm run serve\n```\n\n## 文档目录结构\n\n- `docs/`\n  - `getting-started/`：接入与排障\n  - `guides/`：场景实践\n  - `mcp/`：MCP 能力与治理\n  - `agent/`：智能体架构、编排与可观测\n  - `deploy/`：文档站部署运维\n- `blog/`：发布动态与演进记录\n- `src/pages/`：首页与自定义页面\n- `src/theme/NotFound/`：自定义中文 404 页面\n\n## Cloudflare Pages 推荐配置\n\n- Framework preset: `Docusaurus`\n- Root directory: `docs-site`\n- Build command: `npm run build`\n- Build output directory: `build`\n- Environment variable: `NODE_VERSION=20`\n\n详细部署步骤见：`docs/deploy/cloudflare-pages.md`\n"
  },
  {
    "path": "docs-site/docusaurus.config.ts",
    "content": "﻿import {themes as prismThemes} from 'prism-react-renderer';\nimport type {Config} from '@docusaurus/types';\nimport type * as Preset from '@docusaurus/preset-classic';\n\nconst siteUrl = process.env.DOCS_SITE_URL ?? 'https://lnyo-cly.github.io';\nconst siteBaseUrl = process.env.DOCS_SITE_BASE_URL ?? '/ai4j/';\n\nconst config: Config = {\n  title: 'AI4J 文档站',\n  tagline: '面向 JDK8 的 Java 大模型 SDK、Coding Agent 与 Agent 框架',\n  favicon: 'img/favicon.ico',\n  future: {v4: true},\n  url: siteUrl,\n  baseUrl: siteBaseUrl,\n  organizationName: 'LnYo-Cly',\n  projectName: 'ai4j',\n  onBrokenLinks: 'throw',\n  i18n: {\n    defaultLocale: 'zh-Hans',\n    locales: ['zh-Hans'],\n  },\n  presets: [\n    [\n      'classic',\n      {\n        docs: {\n          routeBasePath: 'docs',\n          sidebarPath: './sidebars.ts',\n          include: [\n            'intro.md',\n            'glossary.md',\n            'faq.md',\n            'getting-started/**/*.md',\n            'getting-started/**/*.mdx',\n            'ai-basics/**/*.md',\n            'ai-basics/**/*.mdx',\n            'guides/**/*.md',\n            'guides/**/*.mdx',\n            'mcp/**/*.md',\n            'mcp/**/*.mdx',\n            'coding-agent/**/*.md',\n            'coding-agent/**/*.mdx',\n            'agent/**/*.md',\n            'agent/**/*.mdx',\n            'flowgram/**/*.md',\n            'flowgram/**/*.mdx',\n            'deploy/**/*.md',\n            'deploy/**/*.mdx',\n          ],\n          editUrl: 'https://github.com/LnYo-Cly/ai4j/tree/main/docs-site/',\n        },\n        blog: false,\n        theme: {\n          customCss: './src/css/custom.css',\n        },\n      } satisfies Preset.Options,\n    ],\n  ],\n  themeConfig: {\n    image: 'img/docusaurus-social-card.jpg',\n    navbar: {\n      title: 'AI4J 文档站',\n      logo: {\n        alt: 'AI4J Logo',\n        src: 'img/logo.svg',\n      },\n      items: [\n        {\n          type: 'docSidebar',\n          sidebarId: 'tutorialSidebar',\n          position: 'left',\n          label: '文档',\n        },\n        {\n          href: 'https://github.com/LnYo-Cly/ai4j',\n          label: 'GitHub',\n          position: 'right',\n        },\n      ],\n    },\n    footer: {\n      style: 'dark',\n      links: [\n        {\n          title: '文档',\n          items: [\n            {label: '开始阅读', to: '/docs/intro'},\n            {label: 'Coding Agent', to: '/docs/coding-agent/overview'},\n            {label: 'AI基础能力接入', to: '/docs/ai-basics/overview'},\n            {label: 'MCP', to: '/docs/mcp/overview'},\n            {label: '智能体 Agent', to: '/docs/agent/overview'},\n            {label: 'Flowgram', to: '/docs/flowgram/overview'},\n          ],\n        },\n        {\n          title: '资源',\n          items: [\n            {label: '历史博客迁移映射', to: '/docs/guides/blog-migration-map'},\n            {label: 'Cloudflare Pages 部署指南', to: '/docs/deploy/cloudflare-pages'},\n          ],\n        },\n        {\n          title: '开源',\n          items: [\n            {label: 'GitHub', href: 'https://github.com/LnYo-Cly/ai4j'},\n            {label: 'Issues', href: 'https://github.com/LnYo-Cly/ai4j/issues'},\n          ],\n        },\n      ],\n      copyright: `Copyright (c) ${new Date().getFullYear()} AI4J Contributors · 基于 Docusaurus 构建`,\n    },\n    prism: {\n      theme: prismThemes.github,\n      darkTheme: prismThemes.dracula,\n    },\n  } satisfies Preset.ThemeConfig,\n};\n\nexport default config;\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/code.json",
    "content": "{\n  \"theme.ErrorPageContent.title\": {\n    \"message\": \"页面已崩溃。\",\n    \"description\": \"The title of the fallback page when the page crashed\"\n  },\n  \"theme.BackToTopButton.buttonAriaLabel\": {\n    \"message\": \"回到顶部\",\n    \"description\": \"The ARIA label for the back to top button\"\n  },\n  \"theme.blog.archive.title\": {\n    \"message\": \"历史博文\",\n    \"description\": \"The page & hero title of the blog archive page\"\n  },\n  \"theme.blog.archive.description\": {\n    \"message\": \"历史博文\",\n    \"description\": \"The page & hero description of the blog archive page\"\n  },\n  \"theme.blog.paginator.navAriaLabel\": {\n    \"message\": \"博文列表分页导航\",\n    \"description\": \"The ARIA label for the blog pagination\"\n  },\n  \"theme.blog.paginator.newerEntries\": {\n    \"message\": \"较新的博文\",\n    \"description\": \"The label used to navigate to the newer blog posts page (previous page)\"\n  },\n  \"theme.blog.paginator.olderEntries\": {\n    \"message\": \"较旧的博文\",\n    \"description\": \"The label used to navigate to the older blog posts page (next page)\"\n  },\n  \"theme.blog.post.paginator.navAriaLabel\": {\n    \"message\": \"博文分页导航\",\n    \"description\": \"The ARIA label for the blog posts pagination\"\n  },\n  \"theme.blog.post.paginator.newerPost\": {\n    \"message\": \"较新一篇\",\n    \"description\": \"The blog post button label to navigate to the newer/previous post\"\n  },\n  \"theme.blog.post.paginator.olderPost\": {\n    \"message\": \"较旧一篇\",\n    \"description\": \"The blog post button label to navigate to the older/next post\"\n  },\n  \"theme.tags.tagsPageLink\": {\n    \"message\": \"查看所有标签\",\n    \"description\": \"The label of the link targeting the tag list page\"\n  },\n  \"theme.colorToggle.ariaLabel.mode.system\": {\n    \"message\": \"跟随系统\",\n    \"description\": \"深色模式跟随系统时的显示名称\"\n  },\n  \"theme.colorToggle.ariaLabel.mode.light\": {\n    \"message\": \"浅色模式\",\n    \"description\": \"The name for the light color mode\"\n  },\n  \"theme.colorToggle.ariaLabel.mode.dark\": {\n    \"message\": \"暗黑模式\",\n    \"description\": \"The name for the dark color mode\"\n  },\n  \"theme.colorToggle.ariaLabel\": {\n    \"message\": \"切换浅色/暗黑模式（当前为{mode}）\",\n    \"description\": \"The ARIA label for the color mode toggle\"\n  },\n  \"theme.docs.breadcrumbs.navAriaLabel\": {\n    \"message\": \"页面路径\",\n    \"description\": \"The ARIA label for the breadcrumbs\"\n  },\n  \"theme.docs.DocCard.categoryDescription.plurals\": {\n    \"message\": \"{count} 个项目\",\n    \"description\": \"The default description for a category card in the generated index about how many items this category includes\"\n  },\n  \"theme.docs.paginator.navAriaLabel\": {\n    \"message\": \"文件选项卡\",\n    \"description\": \"The ARIA label for the docs pagination\"\n  },\n  \"theme.docs.paginator.previous\": {\n    \"message\": \"上一页\",\n    \"description\": \"The label used to navigate to the previous doc\"\n  },\n  \"theme.docs.paginator.next\": {\n    \"message\": \"下一页\",\n    \"description\": \"The label used to navigate to the next doc\"\n  },\n  \"theme.docs.tagDocListPageTitle.nDocsTagged\": {\n    \"message\": \"{count} 篇文档带有标签\",\n    \"description\": \"Pluralized label for \\\"{count} docs tagged\\\". Use as much plural forms (separated by \\\"|\\\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)\"\n  },\n  \"theme.docs.tagDocListPageTitle\": {\n    \"message\": \"{nDocsTagged}（标签：{tagName}）\",\n    \"description\": \"文档标签页标题\"\n  },\n  \"theme.docs.versionBadge.label\": {\n    \"message\": \"版本：{versionLabel}\"\n  },\n  \"theme.docs.versions.unreleasedVersionLabel\": {\n    \"message\": \"此为 {siteTitle} {versionLabel} 版尚未发行的文档。\",\n    \"description\": \"The label used to tell the user that he's browsing an unreleased doc version\"\n  },\n  \"theme.docs.versions.unmaintainedVersionLabel\": {\n    \"message\": \"此为 {siteTitle} {versionLabel} 版的文档，现已不再积极维护。\",\n    \"description\": \"The label used to tell the user that he's browsing an unmaintained doc version\"\n  },\n  \"theme.docs.versions.latestVersionSuggestionLabel\": {\n    \"message\": \"最新的文档请参阅 {latestVersionLink} ({versionLabel})。\",\n    \"description\": \"The label used to tell the user to check the latest version\"\n  },\n  \"theme.docs.versions.latestVersionLinkLabel\": {\n    \"message\": \"最新版本\",\n    \"description\": \"The label used for the latest version suggestion link label\"\n  },\n  \"theme.common.editThisPage\": {\n    \"message\": \"编辑此页\",\n    \"description\": \"The link label to edit the current page\"\n  },\n  \"theme.common.headingLinkTitle\": {\n    \"message\": \"{heading}的直接链接\",\n    \"description\": \"Title for link to heading\"\n  },\n  \"theme.lastUpdated.atDate\": {\n    \"message\": \"于 {date} \",\n    \"description\": \"The words used to describe on which date a page has been last updated\"\n  },\n  \"theme.lastUpdated.byUser\": {\n    \"message\": \"由 {user} \",\n    \"description\": \"The words used to describe by who the page has been last updated\"\n  },\n  \"theme.lastUpdated.lastUpdatedAtBy\": {\n    \"message\": \"最后{byUser}{atDate}更新\",\n    \"description\": \"The sentence used to display when a page has been last updated, and by who\"\n  },\n  \"theme.NotFound.title\": {\n    \"message\": \"找不到页面\",\n    \"description\": \"The title of the 404 page\"\n  },\n  \"theme.navbar.mobileVersionsDropdown.label\": {\n    \"message\": \"选择版本\",\n    \"description\": \"The label for the navbar versions dropdown on mobile view\"\n  },\n  \"theme.tags.tagsListLabel\": {\n    \"message\": \"标签：\",\n    \"description\": \"The label alongside a tag list\"\n  },\n  \"theme.admonition.caution\": {\n    \"message\": \"警告\",\n    \"description\": \"The default label used for the Caution admonition (:::caution)\"\n  },\n  \"theme.admonition.danger\": {\n    \"message\": \"危险\",\n    \"description\": \"The default label used for the Danger admonition (:::danger)\"\n  },\n  \"theme.admonition.info\": {\n    \"message\": \"信息\",\n    \"description\": \"The default label used for the Info admonition (:::info)\"\n  },\n  \"theme.admonition.note\": {\n    \"message\": \"备注\",\n    \"description\": \"The default label used for the Note admonition (:::note)\"\n  },\n  \"theme.admonition.tip\": {\n    \"message\": \"提示\",\n    \"description\": \"The default label used for the Tip admonition (:::tip)\"\n  },\n  \"theme.admonition.warning\": {\n    \"message\": \"注意\",\n    \"description\": \"The default label used for the Warning admonition (:::warning)\"\n  },\n  \"theme.AnnouncementBar.closeButtonAriaLabel\": {\n    \"message\": \"关闭\",\n    \"description\": \"The ARIA label for close button of announcement bar\"\n  },\n  \"theme.blog.sidebar.navAriaLabel\": {\n    \"message\": \"最近博文导航\",\n    \"description\": \"The ARIA label for recent posts in the blog sidebar\"\n  },\n  \"theme.DocSidebarItem.expandCategoryAriaLabel\": {\n    \"message\": \"展开侧边栏分类 '{label}'\",\n    \"description\": \"The ARIA label to expand the sidebar category\"\n  },\n  \"theme.DocSidebarItem.collapseCategoryAriaLabel\": {\n    \"message\": \"折叠侧边栏分类 '{label}'\",\n    \"description\": \"The ARIA label to collapse the sidebar category\"\n  },\n  \"theme.IconExternalLink.ariaLabel\": {\n    \"message\": \"（在新标签页打开）\",\n    \"description\": \"外链图标的无障碍标签\"\n  },\n  \"theme.NavBar.navAriaLabel\": {\n    \"message\": \"主导航\",\n    \"description\": \"The ARIA label for the main navigation\"\n  },\n  \"theme.NotFound.p1\": {\n    \"message\": \"我们找不到您要找的页面。\",\n    \"description\": \"The first paragraph of the 404 page\"\n  },\n  \"theme.NotFound.p2\": {\n    \"message\": \"请联系原始链接来源网站的所有者，并告知他们链接已损坏。\",\n    \"description\": \"The 2nd paragraph of the 404 page\"\n  },\n  \"theme.TOCCollapsible.toggleButtonLabel\": {\n    \"message\": \"本页总览\",\n    \"description\": \"The label used by the button on the collapsible TOC component\"\n  },\n  \"theme.navbar.mobileLanguageDropdown.label\": {\n    \"message\": \"选择语言\",\n    \"description\": \"The label for the mobile language switcher dropdown\"\n  },\n  \"theme.blog.post.readMore\": {\n    \"message\": \"阅读更多\",\n    \"description\": \"The label used in blog post item excerpts to link to full blog posts\"\n  },\n  \"theme.blog.post.readMoreLabel\": {\n    \"message\": \"阅读 {title} 的全文\",\n    \"description\": \"The ARIA label for the link to full blog posts from excerpts\"\n  },\n  \"theme.blog.post.readingTime.plurals\": {\n    \"message\": \"阅读需 {readingTime} 分钟\",\n    \"description\": \"Pluralized label for \\\"{readingTime} min read\\\". Use as much plural forms (separated by \\\"|\\\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)\"\n  },\n  \"theme.CodeBlock.copy\": {\n    \"message\": \"复制\",\n    \"description\": \"The copy button label on code blocks\"\n  },\n  \"theme.CodeBlock.copied\": {\n    \"message\": \"复制成功\",\n    \"description\": \"The copied button label on code blocks\"\n  },\n  \"theme.CodeBlock.copyButtonAriaLabel\": {\n    \"message\": \"复制代码到剪贴板\",\n    \"description\": \"The ARIA label for copy code blocks button\"\n  },\n  \"theme.CodeBlock.wordWrapToggle\": {\n    \"message\": \"切换自动换行\",\n    \"description\": \"The title attribute for toggle word wrapping button of code block lines\"\n  },\n  \"theme.docs.breadcrumbs.home\": {\n    \"message\": \"主页面\",\n    \"description\": \"The ARIA label for the home page in the breadcrumbs\"\n  },\n  \"theme.docs.sidebar.collapseButtonTitle\": {\n    \"message\": \"收起侧边栏\",\n    \"description\": \"The title attribute for collapse button of doc sidebar\"\n  },\n  \"theme.docs.sidebar.collapseButtonAriaLabel\": {\n    \"message\": \"收起侧边栏\",\n    \"description\": \"The title attribute for collapse button of doc sidebar\"\n  },\n  \"theme.docs.sidebar.navAriaLabel\": {\n    \"message\": \"文档侧边栏\",\n    \"description\": \"The ARIA label for the sidebar navigation\"\n  },\n  \"theme.docs.sidebar.closeSidebarButtonAriaLabel\": {\n    \"message\": \"关闭导航栏\",\n    \"description\": \"The ARIA label for close button of mobile sidebar\"\n  },\n  \"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel\": {\n    \"message\": \"← 回到主菜单\",\n    \"description\": \"The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)\"\n  },\n  \"theme.docs.sidebar.toggleSidebarButtonAriaLabel\": {\n    \"message\": \"切换导航栏\",\n    \"description\": \"The ARIA label for hamburger menu button of mobile navigation\"\n  },\n  \"theme.docs.sidebar.expandButtonTitle\": {\n    \"message\": \"展开侧边栏\",\n    \"description\": \"The ARIA label and title attribute for expand button of doc sidebar\"\n  },\n  \"theme.docs.sidebar.expandButtonAriaLabel\": {\n    \"message\": \"展开侧边栏\",\n    \"description\": \"The ARIA label and title attribute for expand button of doc sidebar\"\n  },\n  \"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel\": {\n    \"message\": \"展开下拉菜单\",\n    \"description\": \"移动端导航展开按钮无障碍标签\"\n  },\n  \"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel\": {\n    \"message\": \"收起下拉菜单\",\n    \"description\": \"移动端导航收起按钮无障碍标签\"\n  },\n  \"theme.blog.post.plurals\": {\n    \"message\": \"{count} 篇博文\",\n    \"description\": \"Pluralized label for \\\"{count} posts\\\". Use as much plural forms (separated by \\\"|\\\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)\"\n  },\n  \"theme.blog.tagTitle\": {\n    \"message\": \"{nPosts} 含有标签「{tagName}」\",\n    \"description\": \"The title of the page for a blog tag\"\n  },\n  \"theme.blog.author.pageTitle\": {\n    \"message\": \"{authorName} - 共 {nPosts}\",\n    \"description\": \"作者页面标题\"\n  },\n  \"theme.blog.authorsList.pageTitle\": {\n    \"message\": \"作者\",\n    \"description\": \"The title of the authors page\"\n  },\n  \"theme.blog.authorsList.viewAll\": {\n    \"message\": \"查看所有作者\",\n    \"description\": \"The label of the link targeting the blog authors page\"\n  },\n  \"theme.blog.author.noPosts\": {\n    \"message\": \"该作者尚未撰写任何文章。\",\n    \"description\": \"The text for authors with 0 blog post\"\n  },\n  \"theme.contentVisibility.unlistedBanner.title\": {\n    \"message\": \"未列出页\",\n    \"description\": \"The unlisted content banner title\"\n  },\n  \"theme.contentVisibility.unlistedBanner.message\": {\n    \"message\": \"此页面未列出。搜索引擎不会对其索引，只有拥有直接链接的用户才能访问。\",\n    \"description\": \"The unlisted content banner message\"\n  },\n  \"theme.contentVisibility.draftBanner.title\": {\n    \"message\": \"草稿页\",\n    \"description\": \"The draft content banner title\"\n  },\n  \"theme.contentVisibility.draftBanner.message\": {\n    \"message\": \"此页面是草稿，仅在开发环境中可见，不会包含在正式版本中。\",\n    \"description\": \"The draft content banner message\"\n  },\n  \"theme.ErrorPageContent.tryAgain\": {\n    \"message\": \"重试\",\n    \"description\": \"The label of the button to try again rendering when the React error boundary captures an error\"\n  },\n  \"theme.common.skipToMainContent\": {\n    \"message\": \"跳到主要内容\",\n    \"description\": \"The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation\"\n  },\n  \"theme.tags.tagsPageTitle\": {\n    \"message\": \"标签\",\n    \"description\": \"The title of the tag list page\"\n  }\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/_category_.json",
    "content": "﻿{\n  \"label\": \"Agent 智能体\",\n  \"position\": 5,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/agent-teams.md",
    "content": "---\nsidebar_position: 11\n---\n\n# Agent Teams 详解（队员管理 + 任务管理 + 信息管理）\n\n本页是 AI4J Agent Teams 的完整使用与设计说明，重点覆盖：\n\n- 如何只配置 `leadAgent` 就跑起来；\n- 如何管理队员、任务、消息；\n- 如何让队员通过 `team_*` 工具主动协作；\n- 如何把 Team 状态和消息落盘并显式恢复；\n- 如何在工程上做回退、治理与排障。\n\n---\n\n## 1. Agent Teams 解决什么问题\n\n当一个目标需要多角色协作时（例如：采集 -> 分析 -> 格式化 -> 风险校验），单 Agent 往往会出现：\n\n- 上下文混杂，角色边界不清；\n- 任务依赖不透明，失败重试困难；\n- 结果可以生成，但过程不可审计。\n\nAgent Teams 的设计目标是把“多角色协作过程”结构化：\n\n- Lead 负责规划、调度、汇总；\n- Members 负责执行任务；\n- TaskBoard 负责任务状态与依赖；\n- MessageBus 负责成员间信息流转。\n\n---\n\n## 2. 与 SubAgent 的边界\n\n- **SubAgent**：主从 handoff 模式，偏“委派工具调用”。\n- **Agent Teams**：共享任务板模式，偏“团队协同执行”。\n\n简单记忆：\n\n- SubAgent 更像“主 Agent 调子代理工具”；\n- Teams 更像“项目组协作”。\n\n---\n\n## 3. 执行模型（当前实现）\n\n执行链路：\n\n```text\nObjective\n  -> Planner 产出任务 JSON\n  -> TaskBoard 规范化 id / dependsOn\n  -> 按轮次挑选 READY 任务（可并发）\n  -> 成员执行并写回 task state + message\n  -> Synthesizer 汇总最终输出\n```\n\n关键特性：\n\n- 依赖驱动状态流转；\n- 支持并发派发；\n- 支持失败继续（可配置）；\n- 支持消息历史注入到成员 prompt；\n- 支持任务认领超时回收（可配置）。\n\n---\n\n## 4. 快速开始：只配置一个 `leadAgent`\n\n当前推荐默认写法是“单 Lead 模式”。\n\n```java\nAgent lead = Agents.react()\n        .modelClient(new ResponsesModelClient(responsesService))\n        .model(\"doubao-seed-1-8-251228\")\n        .systemPrompt(\"你是一个团队负责人，先规划再汇总\")\n        .build();\n\nAgent researcher = Agents.react()\n        .modelClient(new ResponsesModelClient(responsesService))\n        .model(\"doubao-seed-1-8-251228\")\n        .build();\n\nAgent formatter = Agents.react()\n        .modelClient(new ResponsesModelClient(responsesService))\n        .model(\"doubao-seed-1-8-251228\")\n        .build();\n\nAgentTeam team = Agents.team()\n        .leadAgent(lead)\n        .member(AgentTeamMember.builder()\n                .id(\"researcher\")\n                .name(\"资料员\")\n                .description(\"负责事实收集\")\n                .agent(researcher)\n                .build())\n        .member(AgentTeamMember.builder()\n                .id(\"formatter\")\n                .name(\"整理员\")\n                .description(\"负责输出格式化\")\n                .agent(formatter)\n                .build())\n        .build();\n\nAgentTeamResult result = team.run(\"给出北京天气简报并格式化输出\");\nSystem.out.println(result.getOutput());\n```\n\n说明：\n\n- 未显式配置 `plannerAgent/synthesizerAgent` 时，会回退到 `leadAgent`；\n- 你也可以分别覆盖 planner 与 synthesizer。\n\n---\n\n## 5. 高级模式：覆盖 Planner/Synthesizer\n\n适合做模型分工优化：\n\n- Planner 用推理更强模型；\n- Synthesizer 用成本更低模型。\n\n```java\nAgentTeam team = Agents.team()\n        .leadAgent(lead)\n        .plannerAgent(plannerAgent)          // 可选覆盖\n        .synthesizerAgent(synthAgent)        // 可选覆盖\n        .member(...)\n        .build();\n```\n\n优先级：\n\n1. 自定义 `planner/synthesizer`（接口实现）\n2. `plannerAgent/synthesizerAgent`\n3. `leadAgent`\n\n---\n\n## 6. 队员管理（Member Management）\n\n控制接口：`AgentTeamControl`\n\n- `registerMember(...)`\n- `unregisterMember(...)`\n- `listMembers()`\n\n可通过 `AgentTeamOptions.allowDynamicMemberRegistration` 控制是否允许运行期增删成员。\n\n实践建议：\n\n- 生产环境把成员 ID 作为稳定主键（不要动态变更）；\n- 成员 description 要写“能力边界”，避免规划器误分配；\n- 禁止“同能力重复成员”时可在构建阶段做校验。\n\n---\n\n## 7. 任务管理（Task Management）\n\n### 7.1 任务模型\n\n`AgentTeamTask` 字段：\n\n- `id`\n- `memberId`\n- `task`\n- `context`\n- `dependsOn`\n\n推荐规划输出（JSON）：\n\n```json\n{\n  \"tasks\": [\n    {\"id\": \"collect\", \"memberId\": \"researcher\", \"task\": \"收集天气事实\"},\n    {\"id\": \"format\", \"memberId\": \"formatter\", \"task\": \"格式化输出\", \"dependsOn\": [\"collect\"]}\n  ]\n}\n```\n\n### 7.2 状态机\n\n`AgentTeamTaskStatus`：\n\n- `PENDING`\n- `READY`\n- `IN_PROGRESS`\n- `COMPLETED`\n- `FAILED`\n- `BLOCKED`\n\n### 7.3 运行时任务控制 API\n\n- `listTaskStates()`\n- `claimTask(taskId, memberId)`\n- `releaseTask(taskId, memberId, reason)`\n- `reassignTask(taskId, fromMemberId, toMemberId)`\n- `heartbeatTask(taskId, memberId)`\n\n### 7.4 超时回收\n\n`AgentTeamOptions.taskClaimTimeoutMillis` > 0 时，运行中会自动回收长时间无心跳的任务认领。\n\n---\n\n## 8. 信息管理（Message Management）\n\n### 8.1 消息模型\n\n`AgentTeamMessage` 字段：\n\n- `id`\n- `fromMemberId`\n- `toMemberId`（`*` 表示广播）\n- `type`\n- `taskId`\n- `content`\n- `createdAt`\n\n### 8.2 消息控制 API\n\n- `publishMessage(...)`\n- `sendMessage(from, to, type, taskId, content)`\n- `broadcastMessage(from, type, taskId, content)`\n- `listMessages()`\n- `listMessagesFor(memberId, limit)`\n\n### 8.3 消息历史注入\n\n开启 `includeMessageHistoryInDispatch=true` 时，成员执行 prompt 会带上近期团队消息，有助于跨成员协同。\n\n---\n\n### 8.4 持久化 MessageBus（文件邮箱）\n\n默认 `MessageBus` 是内存实现：`InMemoryAgentTeamMessageBus`。  \n如果你给 `AgentTeamBuilder` 提供 `storageDirectory(...)`，当前实现会自动切到文件邮箱：\n\n- mailbox 文件：`<storageDirectory>/mailbox/<teamId>.jsonl`\n- 每条消息按一行 JSON 追加\n- 新建同 `teamId` 的 Team 时，会自动读回已有邮箱内容\n\n也可以手动覆盖：\n\n```java\nAgentTeam team = Agents.team()\n        .teamId(\"travel-team\")\n        .messageBus(new FileAgentTeamMessageBus(Paths.get(\".ai4j/teams/mailbox/travel-team.jsonl\")))\n        .member(...)\n        .build();\n```\n\n这层能力的作用很直接：\n\n- 消息不再只存在 JVM 内存里；\n- Team 重建后还能继续查看历史消息；\n- trace、审计、宿主 UI 都能拿到稳定的协作记录。\n\n---\n\n## 9. 队员主动协作：`team_*` 内置工具\n\n当前版本支持把 Team 工具自动注入到成员运行时（默认开启）：\n\n`AgentTeamOptions.enableMemberTeamTools = true`\n\n可用工具：\n\n- `team_send_message`\n- `team_broadcast`\n- `team_list_tasks`\n- `team_claim_task`\n- `team_release_task`\n- `team_reassign_task`\n- `team_heartbeat_task`\n\n这意味着：成员不必被动等待 Lead 指令，模型可以在执行中主动协作。\n\n---\n\n## 10. Team 状态持久化与显式恢复\n\n这次增强后，`AgentTeam` 不再只是一次性的内存对象。当前可用能力包括：\n\n- 稳定标识：`teamId(...)`\n- 状态落盘：`stateStore(...)` 或 `storageDirectory(...)`\n- 运行快照：`snapshotState()`\n- 显式恢复：`loadPersistedState()` / `restoreState(...)`\n- 清理：`clearPersistedState()`\n\n最简单的写法是只给一个 `teamId` 和 `storageDirectory`：\n\n```java\nPath root = Paths.get(\".ai4j/teams\");\n\nAgentTeam team = Agents.team()\n        .teamId(\"travel-team\")\n        .storageDirectory(root)\n        .member(...)\n        .build();\n\nteam.run(\"deliver the travel workspace\");\n\nAgentTeam sameTeam = Agents.team()\n        .teamId(\"travel-team\")\n        .storageDirectory(root)\n        .member(...)\n        .build();\n\nAgentTeamState restored = sameTeam.loadPersistedState();\n```\n\n当前默认文件布局：\n\n- state 文件：`<storageDirectory>/state/<teamId>.json`\n- mailbox 文件：`<storageDirectory>/mailbox/<teamId>.jsonl`\n\n`AgentTeamState` 快照里包含：\n\n- `teamId`\n- `objective`\n- `members`\n- `taskStates`\n- `messages`\n- `lastOutput`\n- `lastRounds`\n- `lastRunStartedAt`\n- `lastRunCompletedAt`\n- `updatedAt`\n- `runActive`\n\n这套恢复机制的边界也要说清：\n\n- 它恢复的是 Team 的运行快照，不是从磁盘重新构造成员 Agent；\n- 你仍然要在 builder 里重新提供成员、planner、synthesizer；\n- `loadPersistedState()` 负责把任务状态、消息历史、上次输出重新挂回 Team 对象。\n\n如果你需要完全自定义存储，也可以直接传 `stateStore(...)`：\n\n```java\nAgentTeam team = Agents.team()\n        .teamId(\"travel-team\")\n        .stateStore(new FileAgentTeamStateStore(Paths.get(\".ai4j/custom-team-state\")))\n        .member(...)\n        .build();\n```\n\n---\n\n## 11. AgentTeamOptions 参数建议\n\n### 调度\n\n- `parallelDispatch`: 默认 `true`\n- `maxConcurrency`: 默认 `4`\n- `maxRounds`: 默认 `64`\n\n### 容错\n\n- `continueOnMemberError`: 默认 `true`\n- `broadcastOnPlannerFailure`: 默认 `true`\n- `failOnUnknownMember`: 默认 `false`\n\n### 任务上下文注入\n\n- `includeOriginalObjectiveInDispatch`: 默认 `true`\n- `includeTaskContextInDispatch`: 默认 `true`\n\n### 消息相关\n\n- `enableMessageBus`: 默认 `true`\n- `includeMessageHistoryInDispatch`: 默认 `true`\n- `messageHistoryLimit`: 默认 `20`\n\n### 治理\n\n- `requirePlanApproval`: 默认 `false`\n- `allowDynamicMemberRegistration`: 默认 `true`\n\n### Team 扩展\n\n- `taskClaimTimeoutMillis`: 默认 `0`（关闭）\n- `enableMemberTeamTools`: 默认 `true`\n\n---\n\n## 12. Hook 与 PlanApproval\n\n### 11.1 PlanApproval\n\n在派发前审批规划结果：\n\n```java\n.planApproval((objective, plan, members, options) -> {\n    return plan != null && plan.getTasks() != null && !plan.getTasks().isEmpty();\n})\n```\n\n### 11.2 Hook\n\n监听团队执行关键阶段：\n\n- `beforePlan`\n- `afterPlan`\n- `beforeTask`\n- `afterTask`\n- `afterSynthesis`\n- `onMessage`\n\n可用于：审计、埋点、告警、指标上报。\n\n---\n\n## 13. 常见问题\n\n### Q1：队员只能是 ReAct Agent 吗？\n\n不是。任何 `Agent` 都可以作为成员，包括：\n\n- `Agents.react()`\n- `Agents.codeAct()`\n- 自定义 `runtime(...)` 的 Agent\n\n前提是该 runtime 使用 `toolRegistry/toolExecutor` 机制，才能使用 `team_*` 工具。\n\n### Q2：为什么我看到任务阻塞？\n\n常见原因：\n\n- 依赖任务失败或未完成；\n- 任务依赖写错 id；\n- `maxRounds` 太小导致提前结束。\n\n### Q3：是否支持生产级追踪？\n\n支持。可结合 `AgentTraceListener` + exporter，把 Team 链路接入你的观测系统。\n\n### Q4：`loadPersistedState()` 能否把整个 Team 从磁盘“自动复活”？\n\n不能。当前实现恢复的是运行快照，不是成员 Agent 的序列化镜像。\n\n你需要：\n\n- 用相同 `teamId`\n- 重新提供成员 / planner / synthesizer\n- 再调用 `loadPersistedState()`\n\n这样可以把任务状态、消息历史、上次输出恢复回来。\n\n---\n\n## 14. 对应测试与验证命令\n\n主要测试：\n\n- `AgentTeamTest`\n- `AgentTeamTaskBoardTest`\n- `FileAgentTeamStateStoreTest`\n- `AgentTeamPersistenceTest`\n- `DoubaoAgentTeamBestPracticeTest`\n\n运行示例：\n\n```bash\nmvn -pl ai4j-agent -DskipTests=false \"-Dtest=AgentTeamTest,AgentTeamTaskBoardTest,FileAgentTeamStateStoreTest,AgentTeamPersistenceTest\" test\n```\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/codeact-custom-sandbox.md",
    "content": "﻿---\nsidebar_position: 8\n---\n\n# CodeAct：自定义代码沙箱执行器\n\n你要补全“CodeAct 使用自定义沙箱”的内容，这页按可落地实现来写。\n\n## 1. 先明确：CodeAct 的沙箱扩展点在哪里\n\n扩展点只有一个：`CodeExecutor`。\n\n```java\npublic interface CodeExecutor {\n    CodeExecutionResult execute(CodeExecutionRequest request) throws Exception;\n}\n```\n\n`CodeActRuntime` 不关心你是本地解释器、容器、还是远程沙箱服务，只要你返回标准 `CodeExecutionResult` 即可。\n\n## 2. 默认执行器与边界\n\n默认是 `GraalVmCodeExecutor`，支持 Python(GraalPy) 与 JS。\n\n优点：\n\n- 开箱即用\n- 能直接调用 AI4J 工具\n\n边界：\n\n- 默认不是强隔离容器沙箱\n- 生产高风险场景建议替换为你自己的执行器\n\n## 3. `CodeExecutionRequest` 里你能拿到什么\n\n- `language`：代码语言（python/js）\n- `code`：模型生成代码\n- `toolNames`：当前允许的工具名\n- `toolExecutor`：工具执行器（可用于 `callTool`）\n- `user`：当前用户上下文\n- `timeoutMs`：可用超时时间\n\n## 4. `CodeExecutionResult` 合同（很关键）\n\n- `result`：最终结果（建议优先放最终可消费值）\n- `stdout`：标准输出\n- `error`：错误信息\n\n`error` 为空表示成功；非空表示失败，Runtime 会把失败信息回写 memory（`CODE_ERROR`）。\n\n## 5. 接入自定义执行器\n\n```java\nAgent agent = Agents.codeAct()\n        .modelClient(modelClient)\n        .model(\"doubao-seed-1-8-251228\")\n        .codeExecutor(new MySandboxCodeExecutor())\n        .codeActOptions(CodeActOptions.builder().reAct(true).build())\n        .build();\n```\n\n## 6. 实现模式 A：本地进程沙箱（推荐起步）\n\n适合先在单机环境做可控执行。\n\n### 6.1 核心思路\n\n1. 校验语言与工具白名单\n2. 把代码写入临时文件\n3. 用受限命令启动子进程（如 `python -I`）\n4. 超时杀进程\n5. 收集 stdout/stderr 构造 `CodeExecutionResult`\n\n### 6.2 示例骨架\n\n```java\npublic class ProcessSandboxCodeExecutor implements CodeExecutor {\n\n    @Override\n    public CodeExecutionResult execute(CodeExecutionRequest request) throws Exception {\n        if (request == null || request.getCode() == null) {\n            return CodeExecutionResult.builder().error(\"code is required\").build();\n        }\n\n        String language = normalize(request.getLanguage());\n        if (!\"python\".equals(language)) {\n            return CodeExecutionResult.builder().error(\"only python is allowed\").build();\n        }\n\n        Path tempDir = Files.createTempDirectory(\"ai4j-codeact-\");\n        Path script = tempDir.resolve(\"main.py\");\n        Files.write(script, request.getCode().getBytes(StandardCharsets.UTF_8));\n\n        ProcessBuilder pb = new ProcessBuilder(\"python\", \"-I\", script.toString());\n        pb.directory(tempDir.toFile());\n        Process process = pb.start();\n\n        long timeout = request.getTimeoutMs() == null ? 8000L : request.getTimeoutMs();\n        boolean finished = process.waitFor(timeout, TimeUnit.MILLISECONDS);\n        if (!finished) {\n            process.destroyForcibly();\n            return CodeExecutionResult.builder().error(\"code execution timeout\").build();\n        }\n\n        String stdout = read(process.getInputStream());\n        String stderr = read(process.getErrorStream());\n\n        return CodeExecutionResult.builder()\n                .stdout(stdout)\n                .result(stdout == null ? null : stdout.trim())\n                .error(stderr == null || stderr.trim().isEmpty() ? null : stderr)\n                .build();\n    }\n\n    private String normalize(String lang) {\n        if (lang == null) {\n            return \"python\";\n        }\n        return lang.trim().toLowerCase();\n    }\n\n    private String read(InputStream input) throws IOException {\n        try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {\n            StringBuilder sb = new StringBuilder();\n            String line;\n            while ((line = reader.readLine()) != null) {\n                if (sb.length() > 0) {\n                    sb.append('\\n');\n                }\n                sb.append(line);\n            }\n            return sb.toString();\n        }\n    }\n}\n```\n\n> 这里是“最小版骨架”，生产上还要补资源限制与隔离策略。\n\n## 7. 实现模式 B：远程沙箱服务\n\n适合开源组件场景：SDK 不直接执行不可信代码，而是调用外部执行服务。\n\n### 7.1 思路\n\n- `CodeExecutor` 里只做 HTTP RPC\n- 请求体包含 `language/code/timeout/tool-policy`\n- 返回统一 `result/stdout/error`\n\n### 7.2 示例骨架\n\n```java\npublic class RemoteSandboxCodeExecutor implements CodeExecutor {\n\n    private final OkHttpClient client;\n    private final String endpoint;\n\n    public RemoteSandboxCodeExecutor(OkHttpClient client, String endpoint) {\n        this.client = client;\n        this.endpoint = endpoint;\n    }\n\n    @Override\n    public CodeExecutionResult execute(CodeExecutionRequest request) throws Exception {\n        String body = JSON.toJSONString(request);\n        Request httpRequest = new Request.Builder()\n                .url(endpoint)\n                .post(RequestBody.create(MediaType.parse(\"application/json\"), body))\n                .build();\n\n        try (Response response = client.newCall(httpRequest).execute()) {\n            if (!response.isSuccessful()) {\n                return CodeExecutionResult.builder().error(\"sandbox http error: \" + response.code()).build();\n            }\n            String json = response.body() == null ? \"\" : response.body().string();\n            return JSON.parseObject(json, CodeExecutionResult.class);\n        }\n    }\n}\n```\n\n## 8. 工具调用在自定义沙箱里怎么做\n\n你有两种策略：\n\n1. **Host 回调模式**：沙箱内代码通过桥接函数调用 `request.getToolExecutor()`\n2. **先禁用工具**：沙箱只做纯计算，不允许工具调用\n\n建议开源默认策略：\n\n- 默认只开白名单工具\n- 参数做 JSON Schema 校验\n- 高风险工具默认禁用\n\n## 9. 与 `CodeActOptions.reAct` 的配合\n\n- `reAct=false`：你的执行器返回结果后可直接结束\n- `reAct=true`：执行结果会再回给模型整理为自然语言\n\n这两种模式对“执行器实现”没有破坏性差异，执行器只需保证 `CodeExecutionResult` 正确。\n\n## 10. 生产安全清单（强烈建议）\n\n1. 时间限制：每次执行必须有 timeout。\n2. 资源限制：CPU/内存/进程数。\n3. 文件系统限制：只允许临时目录。\n4. 网络限制：默认无外网（除非明确需要）。\n5. 工具限制：白名单 + 参数校验。\n6. 审计日志：记录代码摘要、工具调用、耗时、退出状态。\n\n## 11. 观测建议\n\n结合 trace + 事件流看三段耗时：\n\n- `MODEL`：代码生成时间\n- `TOOL(type=code)`：沙箱执行时间\n- 下一轮 `MODEL`：结果整理时间（`reAct=true` 时）\n\n## 12. 常见坑\n\n1. `error` 字段不填导致失败被误判为成功。\n2. 忽略 `timeoutMs` 导致执行悬挂。\n3. 自定义执行器没处理编码，中文输出乱码。\n4. 工具白名单缺失，出现越权调用。\n\n## 13. 关联源码与测试\n\n- `CodeExecutor`\n- `CodeExecutionRequest`\n- `CodeExecutionResult`\n- `GraalVmCodeExecutor`\n- `CodeActRuntime`\n- `CodeActRuntimeTest`\n- `CodeActRuntimeWithTraceTest`\n\n你可以先实现一个最小 `ProcessSandboxCodeExecutor` 跑通，再演进到远程沙箱。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/codeact-runtime.md",
    "content": "﻿---\nsidebar_position: 7\n---\n\n# CodeAct 运行时（代码驱动工具调用）\n\n这页会把 CodeAct 相关类、参数、执行时机讲透：\n\n- `CodeActRuntime`\n- `CodeActOptions`\n- `CodeExecutor / GraalVmCodeExecutor`\n- 代码与工具如何互调\n- 失败后如何自动修复\n\n## 1. CodeAct 是什么\n\nCodeAct 不是“普通 function call 的别名”，而是完整闭环：\n\n1. 模型先输出代码（JSON 包裹）\n2. Runtime 执行代码\n3. 代码内部调用工具\n4. 执行结果再决定最终输出（直接返回或回给模型总结）\n\n## 2. 模型输出协议（必须遵守）\n\n`CodeActRuntime` 约定模型只输出 JSON：\n\n- 执行代码：\n\n```json\n{\"type\":\"code\",\"language\":\"python\",\"code\":\"...\"}\n```\n\n- 最终回答：\n\n```json\n{\"type\":\"final\",\"output\":\"...\"}\n```\n\n如果模型输出不是合法 JSON，Runtime 会按普通文本兜底返回。\n\n## 3. 关键类和职责\n\n- `CodeActRuntime`：CodeAct 主循环策略\n- `CodeActOptions`：CodeAct 专属开关（当前是 `reAct`）\n- `CodeExecutionRequest`：代码执行入参\n- `CodeExecutionResult`：执行结果（`stdout/result/error`）\n- `CodeExecutor`：执行器接口\n- `GraalVmCodeExecutor`：默认执行器（Python + JS）\n\n## 4. `CodeActOptions.reAct` 的语义\n\n## `reAct = false`（默认）\n\n- 代码执行成功后，结果可直接作为最终输出。\n- 适合“工具结果就是答案”的场景。\n\n## `reAct = true`\n\n- 代码执行结果会回写 memory，模型再来一轮自然语言整理。\n- 适合“要人类可读总结”的场景。\n\n```java\n.codeActOptions(CodeActOptions.builder().reAct(true).build())\n```\n\n## 5. 执行时的工具注入与调用\n\n执行器会注入两种调用方式：\n\n1. `callTool(\"queryWeather\", {...})`\n2. 直接按工具名调用（如 `queryWeather(location=\"Beijing\", ...)`）\n\nCodeAct 代码示例（Python）：\n\n```python\ncities = [\"Beijing\", \"Shanghai\", \"Shenzhen\"]\nlines = []\nfor city in cities:\n    weather = queryWeather(location=city, type=\"daily\", days=1)\n    lines.append(f\"{city}: {weather}\")\n__codeact_result = \"\\n\".join(lines)\n```\n\n> 约定：若不 `return`，请赋值 `__codeact_result`。\n\n## 6. 为什么你会看到“函数结果直接拼到输出里”\n\n因为这是代码自身的行为，不是 Runtime 强制拼接。\n\n例如：\n\n```python\nsummary_parts.append(f\"Weather in {city}: {weather_data}\")\n```\n\n这段代码当然会把原始工具结果字符串拼出来。\n\n如果你想要结构化/可读结果，需要在代码里先 parse，再 format，或开启 `reAct=true` 让模型再整理一轮。\n\n## 7. 失败后自动修复是否支持\n\n支持，机制是“多步循环 + 错误反馈”：\n\n- 代码执行失败 -> Runtime 写入 `CODE_ERROR: ...`\n- 模型在下一 step 看见错误 -> 重新生成代码\n- 直到成功或达到 `maxSteps`\n\n要让这个链路更稳定：\n\n1. `maxSteps` 不要太小（建议 `>=3`）\n2. system prompt 写清楚“失败后修复并重试”\n3. 保持工具 schema 明确、参数名固定\n\n## 8. 如何在“执行前”看到代码\n\n默认 `CODEACT CODE` 你可能在最后才打印，这是因为你在结果里读了 `toolCalls`。\n\n更推荐做法：监听 `TOOL_CALL` 事件并在 `name=code` 时打印。\n\n```java\nAgentEventPublisher publisher = new AgentEventPublisher();\npublisher.addListener(event -> {\n    if (event.getType() == AgentEventType.TOOL_CALL && event.getPayload() instanceof AgentToolCall) {\n        AgentToolCall call = (AgentToolCall) event.getPayload();\n        if (\"code\".equals(call.getName())) {\n            System.out.println(\"CODEACT CODE (pre-exec): \" + call.getArguments());\n        }\n    }\n});\n```\n\n## 9. GraalVmCodeExecutor 语言支持与回退\n\n当前默认支持：\n\n- `python`（GraalPy）\n- `js/javascript`（Polyglot + ScriptEngine 回退）\n\n执行器行为要点：\n\n- 有超时控制（默认 8 秒，可由 `timeoutMs` 覆盖）\n- 会捕获 `stdout/error`\n- `result` 优先读取 `__codeact_result` 或表达式返回值\n\n## 10. 一份完整测试模板\n\n```java\nAgent agent = Agents.codeAct()\n        .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n        .model(\"doubao-seed-1-8-251228\")\n        .systemPrompt(\"You are a weather assistant. Use Python only.\")\n        .toolRegistry(Arrays.asList(\"queryWeather\"), null)\n        .options(AgentOptions.builder().maxSteps(4).build())\n        .codeActOptions(CodeActOptions.builder().reAct(true).build())\n        .eventPublisher(buildEventPublisher())\n        .build();\n```\n\n参考测试：\n\n- `CodeActRuntimeTest`\n- `CodeActRuntimeWithTraceTest`\n\n## 11. 生产建议（重要）\n\n默认执行器不是强隔离沙箱；生产环境建议：\n\n1. 独立进程或容器执行\n2. CPU/内存/时间限制\n3. 文件系统与网络权限最小化\n4. 工具白名单 + 参数校验\n\n这样才能让 CodeAct 从“能跑”升级为“可上线”。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/coding-agent-cli.md",
    "content": "---\nsidebar_position: 2\n---\n\n# Coding Agent CLI 与 TUI\n\n本文档说明 `ai4j-cli` 当前可用的 coding-agent CLI/TUI 能力，重点覆盖：\n\n- 启动方式与协议选择\n- provider profile 与 workspace override\n- 交互命令与 TUI 行为\n- 当前实现边界\n\n---\n\n## 1. 这套 CLI 现在能做什么\n\n`ai4j-cli` 现在已经不是一个只支持单次 prompt 的壳层，而是一个可持续会话的 coding-agent 入口，当前能力包括：\n\n- one-shot 模式：直接带 `--prompt` 执行一次任务；\n- interactive 模式：进入持续会话，保留 memory / session / process 状态；\n- JLine TUI shell：支持 slash command、命令补全、palette、主缓冲区增量输出；\n- session 持久化：支持保存、恢复、fork、history/tree/events/replay；\n- process 管理：支持查看活跃进程、读取日志、向 stdin 写入、停止进程；\n- provider profile：支持全局保存、workspace 引用、运行时热切换；\n- model override：支持 workspace 级模型覆盖与即时切换；\n- skill discovery：支持发现 workspace / global / 自定义目录中的 skills，并在会话内查看；\n- model request streaming：支持在当前 CLI 会话里切换请求级 `stream=true|false`，并与 transcript 渲染保持一致。\n\n---\n\n## 2. 启动方式\n\n### 2.1 安装 `ai4j` 命令\n\n推荐先通过文档站托管脚本安装 `ai4j-cli`，这样后续启动 `code` / `tui` / `acp` 都直接使用 `ai4j` 命令即可。\n\n```bash\ncurl -fsSL https://lnyo-cly.github.io/ai4j/install.sh | sh\n```\n\n```powershell\nirm https://lnyo-cly.github.io/ai4j/install.ps1 | iex\n```\n\n安装脚本会从 Maven Central 下载 `ai4j-cli`，默认安装到 `~/.ai4j`（Windows 为 `%USERPROFILE%\\.ai4j`）。\n\n### 2.2 one-shot\n\n```powershell\nai4j code `\n  --provider openai `\n  --protocol responses `\n  --model gpt-5-mini `\n  --prompt \"Read README and summarize the project structure\"\n```\n\n### 2.3 interactive CLI\n\n```powershell\nai4j code `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n### 2.4 TUI shell\n\n```powershell\nai4j tui `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n`code` 和 `tui` 使用同一套 coding-agent 会话能力，区别主要在交互壳层：\n\n- `code`：默认走普通 CLI / REPL；\n- `tui`：进入 JLine 驱动的 richer shell，支持 slash palette、按键补全和更完整的 transcript 管理。\n\n### 2.5 从源码打包运行（可选）\n\n如果你正在修改 `ai4j-cli` 源码，可以继续使用本地构建产物：\n\n```powershell\nmvn -pl ai4j-cli -am -DskipTests package\njava -jar .\\ai4j-cli\\target\\ai4j-cli-<version>-jar-with-dependencies.jar code --help\n```\n\n---\n\n## 3. 协议选择：现在只有 `chat` 和 `responses`\n\n当前 `ai4j-cli` 对用户只暴露两种协议：\n\n- `chat`\n- `responses`\n\n显式参数为：\n\n```text\n--protocol <chat|responses>\n```\n\n### 3.1 省略 `--protocol` 时的默认规则\n\n如果不传 `--protocol`，CLI 会按 provider/baseUrl 在本地推导默认协议：\n\n- `openai` + 官方 OpenAI host -> `responses`\n- `openai` + 自定义兼容 `baseUrl` -> `chat`\n- `doubao` / `dashscope` -> `responses`\n- 其他 provider -> `chat`\n\n这是一套本地路由规则，不是远端 capability probe。\n\n### 3.2 兼容旧配置\n\n历史配置里如果还保存了 `auto`：\n\n- 新 CLI 不再接受用户显式传 `--protocol auto`\n- 旧的 `providers.json` 中若存在 `auto`，会在加载时自动归一化成显式协议并写回配置文件\n\n### 3.3 当前 `responses` 支持范围\n\n当前 `ai4j-cli` 内部只对以下 provider 开启了 `responses`：\n\n- `openai`\n- `doubao`\n- `dashscope`\n\n如果对其他 provider 显式设置 `responses`，CLI 会直接报错，而不是模糊回退。\n\n---\n\n## 4. provider profile 与 workspace 配置\n\n当前 CLI 配置分成两层：\n\n- 全局 profile：`~/.ai4j/providers.json`\n- workspace 配置：`<workspace>/.ai4j/workspace.json`\n\n### 4.1 `providers.json`\n\n全局 profile 用来保存可复用的 runtime 配置，例如：\n\n```json\n{\n  \"defaultProfile\": \"zhipu-main\",\n  \"profiles\": {\n    \"zhipu-main\": {\n      \"provider\": \"zhipu\",\n      \"protocol\": \"chat\",\n      \"model\": \"glm-4.7\",\n      \"baseUrl\": \"https://open.bigmodel.cn/api/coding/paas/v4\",\n      \"apiKey\": \"env-or-stored-key\"\n    }\n  }\n}\n```\n\n### 4.2 `workspace.json`\n\nworkspace 层负责保存当前工作区引用的 profile 和模型覆盖，例如：\n\n```json\n{\n  \"activeProfile\": \"zhipu-main\",\n  \"modelOverride\": \"glm-4.7-plus\"\n}\n```\n\n当前解析顺序是：\n\n1. CLI 显式参数\n2. workspace 配置\n3. active profile\n4. default profile\n5. 环境变量 / system properties\n6. 内建默认值\n\n这样做的目的，是让“全局保存、workspace 引用、局部覆盖”这三层职责分离。\n\nskill 发现规则是：\n\n- 默认扫描 `<workspace>/.ai4j/skills`\n- 默认扫描 `~/.ai4j/skills`\n- `skillDirectories` 中的相对路径按 workspace 根目录解析\n- 会话内可用 `/skills` 列表查看，或用 `/skills <name>` 查看单个 skill 的元信息\n\n---\n\n## 5. 当前常用命令\n\n### 5.1 provider 相关\n\n- `/providers`：列出已保存 profiles\n- `/provider`：显示当前有效 provider / profile / protocol / model\n- `/provider use <name>`：切换 workspace 当前使用的 profile，并立即重建当前 session runtime\n- `/provider save <name>`：把当前运行中的 provider/protocol/model/baseUrl/apiKey 保存成 profile\n- `/provider default <name|clear>`：设置或清除全局默认 profile\n- `/provider remove <name>`：删除 profile\n\n### 5.2 新增：`/provider add`\n\n使用显式参数新建 profile：\n\n```text\n/provider add <profile-name> --provider <name> [--protocol <chat|responses>] [--model <name>] [--base-url <url>] [--api-key <key>]\n```\n\n示例：\n\n```text\n/provider add zhipu-main --provider zhipu --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4\n```\n\n如果不传 `--protocol`，CLI 会按当前 provider/baseUrl 推导默认协议，并保存为显式值。\n\n### 5.3 新增：`/provider edit`\n\n更新已有 profile：\n\n```text\n/provider edit <profile-name> [--provider <name>] [--protocol <chat|responses>] [--model <name>|--clear-model] [--base-url <url>|--clear-base-url] [--api-key <key>|--clear-api-key]\n```\n\n示例：\n\n```text\n/provider edit zhipu-main --model glm-4.7-plus\n/provider edit openai-main --protocol responses\n/provider edit zhipu-main --clear-api-key\n```\n\n如果被修改的是当前 effective profile，CLI 会立即重建当前 session runtime。\n\n### 5.4 model 相关\n\n- `/model`：显示当前 effective model 和 workspace override 状态\n- `/model <name>`：保存 workspace model override，并立即切换当前 session runtime\n- `/model reset`：清空 workspace model override，回退到 profile model\n\n### 5.5 skill 相关\n\n- `/skills`：列出当前会话已发现的 skills、扫描 roots 和 workspace 配置位置\n- `/skills <name>`：查看某个 skill 的来源、路径、描述和扫描 roots；只展示元信息，不回显 `SKILL.md` 正文\n\n### 5.6 其他高频命令\n\n- `/save`\n- `/status`\n- `/session`\n- `/sessions`\n- `/resume <id>`\n- `/fork ...`\n- `/history`\n- `/tree`\n- `/events`\n- `/replay`\n- `/compacts`\n- `/processes`\n- `/process status|follow|logs|write|stop ...`\n\n---\n\n## 6. `/stream` 的真实语义\n\n`/stream` 控制的是当前 CLI 会话里的模型请求是否启用 `stream`，不是一个只影响 transcript 外观的渲染开关。\n\n### 6.1 命令\n\n- `/stream`\n- `/stream on`\n- `/stream off`\n\n### 6.2 当前行为\n\n- `/stream`：显示当前状态、作用域、请求级 `stream` 值和渲染行为\n- `/stream on|off`：立即重建当前 session runtime，并切换后续请求的 `stream=true|false`\n- `on`：provider 响应按增量到达，assistant / reasoning 文本按到达顺序增量写入 transcript\n- `off`：provider 走非流式完成响应，assistant 文本以整理后的完成块呈现\n- 作用域是当前 CLI 会话，不会把 `/stream` 持久化成 workspace 配置项\n\n---\n\n## 7. TUI 交互约定\n\n当前 TUI shell 里，和 coding agent 直接相关的交互约定是：\n\n- `/`：打开 slash command 列表\n- `Tab`：应用当前补全项\n- `Ctrl+P`：打开 command palette\n- `Enter`：提交当前输入\n- `Esc`：活跃 turn 时中断当前任务；空闲时关闭 slash palette 或清空输入\n\nslash 命令补全当前已经覆盖：\n\n- 根命令补全\n- `/provider` 二级动作补全\n- `/provider add|edit` 参数补全\n- `/provider add|edit --protocol` 值补全\n- `/model` 候选补全\n- `/skills` 候选补全\n- `/stream on|off` 候选补全\n\n### 7.1 状态文案的当前含义\n\nJLine 主缓冲区状态栏当前使用这些状态：\n\n- `Thinking`：正在分析输入、工作区和工具上下文\n- `Connecting`：正在打开模型请求或等待首个模型事件\n- `Responding`：模型正在持续输出\n- `Working`：当前主要在等待工具或进程结果\n- `Retrying`：请求正在按当前重试策略重试\n- `Waiting`：暂时没有新进展，但尚未判定为卡住\n- `Stalled`：较长时间没有新进展，状态栏会明确提示 `press Esc to interrupt`\n\n当前中断后的可见语义是：\n\n- 已经显示到 transcript 的增量内容不会回滚\n- 被中断的回合不会再补发最终完成块\n- 壳层会输出 `Conversation interrupted by user.`，随后回到可继续输入的状态\n\n---\n\n## 8. 当前边界\n\n当前这套 coding-agent CLI 已经可以稳定使用，但还需要明确几个边界：\n\n- provider 默认协议是本地规则推导，不是在线探测\n- `/model` 的候选来源于本地 runtime/config，不会实时拉取远端官方模型目录\n- `responses` 还不是所有 provider 都支持\n- profile 配置支持全局保存和 workspace 引用，但还没有做更复杂的多 workspace 同步治理\n\n---\n\n## 9. 建议阅读顺序\n\n如果你是第一次接触 AI4J 的 coding agent，建议按这个顺序阅读：\n\n1. 本文：`Coding Agent CLI 与 TUI`\n2. `Runtime 实现详解`\n3. `CodeAct Runtime`\n4. `Model Client 选择与适配`\n5. `Memory 管理`\n6. `Trace 可观测`\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/coding-agent-command-reference.md",
    "content": "---\nsidebar_position: 4\n---\n\n# Coding Agent 命令参考手册\n\n本文档只覆盖当前 `ai4j-cli` coding-agent 会话里已经实现的高频命令。\n\n---\n\n## 1. provider / model / stream\n\n### `/providers`\n\n列出已保存的 provider profiles。\n\n```text\n/providers\n```\n\n---\n\n### `/provider`\n\n显示当前 effective provider 状态。\n\n```text\n/provider\n```\n\n通常会包含：\n\n- 当前 active profile\n- 当前 default profile\n- effective provider\n- effective protocol\n- effective model\n\n---\n\n### `/provider use`\n\n切换当前 workspace 正在使用的 profile，并立即重建当前 session runtime。\n\n```text\n/provider use <profile-name>\n```\n\n示例：\n\n```text\n/provider use zhipu-main\n```\n\n---\n\n### `/provider save`\n\n把当前运行中的 provider / protocol / model / baseUrl / apiKey 保存成 profile。\n\n```text\n/provider save <profile-name>\n```\n\n示例：\n\n```text\n/provider save openai-main\n```\n\n---\n\n### `/provider add`\n\n用显式参数新建 profile。\n\n```text\n/provider add <profile-name> --provider <name> [--protocol <chat|responses>] [--model <name>] [--base-url <url>] [--api-key <key>]\n```\n\n示例：\n\n```text\n/provider add zhipu-main --provider zhipu --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4\n```\n\n说明：\n\n- `--provider` 必填\n- `--protocol` 省略时，会按 provider/baseUrl 推导默认协议\n- 保存结果会写入 `~/.ai4j/providers.json`\n\n---\n\n### `/provider edit`\n\n更新已有 profile。\n\n```text\n/provider edit <profile-name> [--provider <name>] [--protocol <chat|responses>] [--model <name>|--clear-model] [--base-url <url>|--clear-base-url] [--api-key <key>|--clear-api-key]\n```\n\n示例：\n\n```text\n/provider edit zhipu-main --model glm-4.7-plus\n/provider edit openai-main --protocol responses\n/provider edit zhipu-main --clear-api-key\n```\n\n说明：\n\n- 只会更新你显式传入的字段\n- `--clear-model` / `--clear-base-url` / `--clear-api-key` 用于清空字段\n- 如果修改的是当前 effective profile，会立即重建当前 session runtime\n\n---\n\n### `/provider default`\n\n设置或清除全局默认 profile。\n\n```text\n/provider default <profile-name|clear>\n```\n\n示例：\n\n```text\n/provider default openai-main\n/provider default clear\n```\n\n---\n\n### `/provider remove`\n\n删除一个已保存 profile。\n\n```text\n/provider remove <profile-name>\n```\n\n---\n\n### `/model`\n\n显示当前 effective model 与 workspace override。\n\n```text\n/model\n```\n\n---\n\n### `/model <name>`\n\n保存 workspace model override，并立即切换当前 session runtime。\n\n```text\n/model <name>\n```\n\n示例：\n\n```text\n/model glm-4.7-plus\n```\n\n---\n\n### `/model reset`\n\n清空 workspace model override，回退到 profile model。\n\n```text\n/model reset\n```\n\n---\n\n### `/skills`\n\n列出当前会话已发现的 coding skills。\n\n```text\n/skills\n```\n\n通常会包含：\n\n- 当前发现到的 skill 数量\n- workspace 配置文件位置\n- 当前生效的 skill roots\n- 每个 skill 的 name / source / path / description\n\n---\n\n### `/skills <name>`\n\n查看单个 skill 的详细信息。\n\n```text\n/skills <skill-name>\n```\n\n示例：\n\n```text\n/skills repo-review\n```\n\n说明：\n\n- 会显示 skill 的来源、路径、描述\n- 会显示当前 skill roots，便于确认它是从哪里被发现的\n- 只展示元信息，不会打印 `SKILL.md` 正文\n- skill 名称可通过 slash 补全获得\n\n---\n\n### `/stream`\n\n显示当前 CLI 会话的模型请求 streaming 状态。\n\n```text\n/stream\n```\n\n---\n\n### `/stream on|off`\n\n切换当前 CLI 会话的模型请求 streaming 行为。\n\n```text\n/stream on\n/stream off\n```\n\n说明：\n\n- 作用域是当前 CLI 会话\n- 切换时会立即重建当前 session runtime\n- `on`：后续请求使用 `stream=true`，assistant / reasoning 会增量写入 transcript\n- `off`：后续请求使用 `stream=false`，等待完整响应后再输出完成块\n- 这不是 provider 协议切换命令\n\n---\n\n## 2. session / history / compact\n\n### `/status`\n\n显示当前 session 运行状态。\n\n```text\n/status\n```\n\n---\n\n### `/session`\n\n显示当前 session 元信息。\n\n```text\n/session\n```\n\n---\n\n### `/save`\n\n持久化当前 session 状态。\n\n```text\n/save\n```\n\n---\n\n### `/sessions`\n\n列出当前 session store 中的已保存 sessions。\n\n```text\n/sessions\n```\n\n---\n\n### `/resume`\n\n恢复一个已保存 session。\n\n```text\n/resume <id>\n/load <id>\n```\n\n---\n\n### `/fork`\n\n从已有 session fork 一个新分支。\n\n```text\n/fork [new-id]\n/fork <source-id> <new-id>\n```\n\n---\n\n### `/history`\n\n显示从 root 到目标 session 的 lineage。\n\n```text\n/history [id]\n```\n\n---\n\n### `/tree`\n\n显示当前 session tree。\n\n```text\n/tree [id]\n```\n\n---\n\n### `/events`\n\n显示最近 session ledger events。\n\n```text\n/events [n]\n```\n\n---\n\n### `/replay`\n\n按 turn 聚合回放最近会话内容。\n\n```text\n/replay [n]\n```\n\n---\n\n### `/compacts`\n\n查看最近 compact 历史。\n\n```text\n/compacts [n]\n```\n\n---\n\n### `/compact`\n\n对当前 session memory 进行压缩。\n\n```text\n/compact\n/compact <summary>\n```\n\n---\n\n## 3. process 管理\n\n### `/processes`\n\n列出当前活跃和已恢复的进程元信息。\n\n```text\n/processes\n```\n\n---\n\n### `/process status`\n\n查看单个进程元信息。\n\n```text\n/process status <process-id>\n```\n\n---\n\n### `/process follow`\n\n查看进程元信息并跟随缓冲日志。\n\n```text\n/process follow <process-id> [limit]\n```\n\n---\n\n### `/process logs`\n\n读取某个进程的缓冲日志。\n\n```text\n/process logs <process-id> [limit]\n```\n\n---\n\n### `/process write`\n\n向活跃进程的 stdin 写入文本。\n\n```text\n/process write <process-id> <text>\n```\n\n---\n\n### `/process stop`\n\n停止一个活跃进程。\n\n```text\n/process stop <process-id>\n```\n\n---\n\n## 4. 其他命令\n\n- `/help`\n- `/theme [name]`\n- `/commands`\n- `/palette`\n- `/cmd <name> [args]`\n- `/checkpoint`\n- `/clear`\n- `/exit`\n- `/quit`\n\n---\n\n## 5. 补全与交互约定\n\n当前 TUI shell 下：\n\n- `/`：打开命令面板\n- `Tab`：应用当前补全项\n- `Ctrl+P`：打开 command palette\n- `Enter`：提交输入\n- `Esc`：活跃 turn 时中断当前任务；空闲时关闭面板或清空输入\n\n当前状态栏文案含义：\n\n- `Thinking`：分析当前输入和上下文\n- `Connecting`：正在打开模型请求或等待首个模型事件\n- `Responding`：模型正在持续输出\n- `Working`：工具或进程仍在运行\n- `Retrying`：请求正在重试\n- `Waiting`：短时间内没有新进展\n- `Stalled`：较长时间没有新进展，状态栏会提示 `press Esc to interrupt`\n\n当前命令补全已覆盖：\n\n- 根命令\n- `/provider` 二级动作\n- `/provider add|edit` 参数\n- `/provider add|edit --protocol` 值\n- `/model` 候选\n- `/skills` 候选\n- `/stream on|off`\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/custom-agent-development.md",
    "content": "﻿---\nsidebar_position: 4\n---\n\n# 自定义 Agent 开发指南（从 0 到可扩展）\n\n这一页专门回答三个问题：\n\n1. `Agent` 最小可用配置是什么？\n2. `AgentBuilder` 每个常用参数怎么选？\n3. 我想做“自定义 Agent”（自定义模型适配、运行时、工具执行、记忆）时应该从哪里扩展？\n\n## 1. Agent 构建的最小闭环\n\n在当前实现里，真正必填的只有两项：\n\n- `modelClient(...)`\n- `model(...)`\n\n示例：\n\n```java\nAgent agent = Agents.react()\n        .modelClient(new ResponsesModelClient(responsesService))\n        .model(\"doubao-seed-1-8-251228\")\n        .build();\n\nAgentResult result = agent.run(AgentRequest.builder()\n        .input(\"用一句话介绍 Responses API\")\n        .build());\n```\n\n> 其他组件（`runtime/toolExecutor/memory/options/codeExecutor`）都有默认值，会在 `AgentBuilder.build()` 阶段自动补齐。\n\n## 2. AgentBuilder 参数全景（按职责分组）\n\n## 2.1 运行时与核心执行\n\n- `runtime(AgentRuntime)`：切换执行策略（ReAct/CodeAct/DeepResearch/你自己的 Runtime）。\n- `options(AgentOptions)`：通用运行参数。\n  - `maxSteps` 默认 `0`（表示无限）\n  - `stream` 默认 `false`\n- `codeActOptions(CodeActOptions)`：CodeAct 专属参数。\n  - `reAct` 默认 `false`\n\n## 2.2 模型与提示词\n\n- `modelClient(AgentModelClient)`：模型协议适配层（Responses/Chat/自定义）。\n- `model(String)`：模型名。\n- `systemPrompt(String)`：全局角色/规则。\n- `instructions(String)`：当前任务指令。\n- 采样与输出：`temperature/topP/maxOutputTokens`\n- 扩展字段：`reasoning/toolChoice/parallelToolCalls/store/user/extraBody`\n\n## 2.3 工具与 MCP\n\n- `toolRegistry(List<String> functions, List<String> mcpServices)`\n- 或 `toolRegistry(AgentToolRegistry)` 自定义注册器\n- `toolExecutor(ToolExecutor)` 自定义执行器\n\n当前语义（你最近关心的点）：\n\n- **传什么，用什么**。\n- `ToolUtil.getAllTools(functionList, mcpServerIds)` 只返回你显式传入的 Function/MCP 工具。\n- 本地 MCP 全量工具不再自动混入普通 Agent 调用。\n\n## 2.4 CodeAct 执行层\n\n- `codeExecutor(CodeExecutor)`：默认 `GraalVmCodeExecutor`\n- 支持 `language=python/js`，并通过工具桥 `callTool(...)` 或工具同名函数调用。\n\n## 2.5 记忆与会话\n\n- `memorySupplier(Supplier<AgentMemory>)`：默认 `InMemoryAgentMemory::new`\n- `Agent.newSession()` 会为每个 session 复制 context 并注入独立 memory\n\n## 2.6 SubAgent 与治理策略\n\n- `subAgent(SubAgentDefinition)` / `subAgents(...)`\n- `subAgentRegistry(SubAgentRegistry)`\n- `handoffPolicy(HandoffPolicy)`\n\n## 2.7 可观测\n\n- `eventPublisher(AgentEventPublisher)`\n- `traceExporter(TraceExporter)` + `traceConfig(TraceConfig)`\n\n只要配置了 `traceExporter`，`build()` 会自动注册 `AgentTraceListener`。\n\n## 3. 默认装配行为（build() 时发生了什么）\n\n`AgentBuilder.build()` 的核心默认逻辑：\n\n1. `runtime` 为空 -> `ReActRuntime`\n2. `memorySupplier` 为空 -> `InMemoryAgentMemory::new`\n3. `toolRegistry` 为空 -> `StaticToolRegistry.empty()`\n4. `toolExecutor` 为空 -> `ToolUtilExecutor(allowedToolNames)`\n5. 配置了 SubAgent -> 包装成 `SubAgentToolExecutor`\n6. `codeExecutor` 为空 -> `GraalVmCodeExecutor`\n7. `options/codeActOptions/eventPublisher` 都有默认对象\n8. 如果设置 `traceExporter` -> 自动挂 `AgentTraceListener`\n9. `modelClient` 为空会直接抛异常\n\n## 4. 四种“自定义 Agent”扩展方式\n\n## 4.1 自定义模型客户端（最常见）\n\n你可以实现 `AgentModelClient`，把任何模型协议接入到 Agent runtime：\n\n```java\npublic class MyModelClient implements AgentModelClient {\n    @Override\n    public AgentModelResult create(AgentPrompt prompt) {\n        // 1) 把 AgentPrompt 映射到你的模型 API\n        // 2) 把返回值映射成 AgentModelResult\n        return AgentModelResult.builder()\n                .outputText(\"...\")\n                .toolCalls(Collections.emptyList())\n                .memoryItems(Collections.emptyList())\n                .rawResponse(null)\n                .build();\n    }\n\n    @Override\n    public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) {\n        // 需要流式就推送 delta，再 onComplete\n        return create(prompt);\n    }\n}\n```\n\n## 4.2 自定义工具注册/执行\n\n- 自定义 `AgentToolRegistry`：决定“暴露给模型哪些工具”。\n- 自定义 `ToolExecutor`：决定“工具如何执行、鉴权、限流、审计”。\n\n适用场景：\n\n- 接企业内部工具总线\n- 工具调用前后统一埋点\n- 多租户鉴权\n\n## 4.3 自定义记忆与压缩\n\n你可以注入 `memorySupplier`，例如：\n\n- 基于 Redis 的会话记忆\n- 自定义 `MemoryCompressor` 的窗口压缩/摘要压缩\n\n```java\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"your-model\")\n        .memorySupplier(() -> new InMemoryAgentMemory(new WindowedMemoryCompressor(20)))\n        .build();\n```\n\n## 4.4 自定义 Runtime（最高自由度）\n\n如果 ReAct/CodeAct/DeepResearch 都不满足，你可以直接实现 `AgentRuntime`，或继承 `BaseAgentRuntime`。\n\n- 实现 `run(...)` / `runStream(...)`\n- 决定每轮如何构造 prompt、何时结束、何时调用工具\n\n详见下一页《Runtime 实现详解》。\n\n## 5. 推荐的工程化分层\n\n生产项目推荐按三层拆分：\n\n1. **模型适配层**（`AgentModelClient`）\n2. **运行策略层**（`AgentRuntime`）\n3. **业务编排层**（`Workflow + SubAgent`）\n\n这样做的好处：\n\n- 模型迁移不会牵动业务编排\n- 策略升级（ReAct -> CodeAct）成本低\n- 线上排障可定位到明确层次\n\n## 6. 你最近实现过的“可直接复用模板”\n\n- 双 Agent 天气流：`WeatherAgentWorkflowTest`\n- StateGraph 路由/分支/循环：`StateGraphWorkflowTest`\n- CodeAct + Tool + Trace：`CodeActRuntimeTest`、`CodeActRuntimeWithTraceTest`\n- SubAgent handoff 策略：`SubAgentRuntimeTest`、`SubAgentParallelFallbackTest`、`HandoffPolicyTest`\n\n建议你在业务里按这个顺序落地：\n\n1. 先单 Agent + 明确工具白名单\n2. 再接 Workflow\n3. 最后再引入 SubAgent 和复杂 handoff policy\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/memory-management.md",
    "content": "﻿---\nsidebar_position: 6\n---\n\n# Memory 记忆管理与压缩策略\n\n`Memory` 不是某个单独产品的小功能，而是 AI 系统里的通用基础能力。\n\n在 AI4J 里：\n\n- `ai4j` 核心层现在已经提供基础 `ChatMemory`\n- `Agent` 直接建立在 `AgentMemory` 之上\n- `Coding Agent` 的 session memory、compact、resume 也复用了这套基础能力\n- `IChatService` / `IResponsesService` 仍然不会自动替你维护上下文，但你现在可以显式配合 `ChatMemory` 使用\n\n实现层面现在有：\n\n- 基础层：`InMemoryChatMemory`、`JdbcChatMemory`\n- Agent 层：`InMemoryAgentMemory`、`JdbcAgentMemory`\n\n所以这页虽然放在 `Agent` 专题，但概念上同样适用于 `Coding Agent` 和更一般的 AI runtime 设计。\n\n换句话说：\n\n- 如果你只是直接调用 `ChatCompletion` / `ResponseRequest`，可以自己维护历史，也可以直接用核心层 `ChatMemory`\n- 如果你进入 `Agent` / `Coding Agent runtime`，memory 才是默认内建的一等能力\n\n一眼区分两者：\n\n- `ChatMemory`：重点是“多轮对话上下文”\n- `AgentMemory`：重点是“模型输出、工具结果、控制消息如何在每一轮循环里继续参与推理”\n\n如果你只需要基础会话上下文，而不是完整 Agent runtime，优先阅读：\n\n- [ChatMemory：基础会话上下文](/docs/ai-basics/chat/chat-memory)\n\n## 1. 为什么 Agent 一定要有 Memory\n\n没有记忆，Agent 每一轮都会“失忆”，典型问题：\n\n- 工具调用结果无法被下一轮模型看到\n- 多轮任务无法累积上下文\n- CodeAct 的 `CODE_RESULT/CODE_ERROR` 无法闭环\n\nAI4J 的设计是：**Runtime 不直接拼历史字符串，而是把会话状态交给 `AgentMemory` 管理**。\n\n## 2. 当前内存模型（基于 `AgentMemory` 接口）\n\n核心结构：\n\n- `items: List<Object>`\n- `summary: String`（可选）\n- `compressor: MemoryCompressor`（可选）\n\n写入接口：\n\n- `addUserInput(Object input)`\n- `addOutputItems(List<Object> items)`\n- `addToolOutput(String callId, String output)`\n\n读取接口：\n\n- `getItems()`\n- `getSummary()`\n- `clear()`\n\n默认实现仍然是 `InMemoryAgentMemory`。\n\n如果你希望 Agent 会话直接落到关系库，可以使用：\n\n- `JdbcAgentMemory`\n- `JdbcAgentMemoryConfig`\n\n## 3. Memory item 的真实形态\n\nAI4J 通过 `AgentInputItem` 生成统一结构：\n\n- 用户消息：`type=message, role=user`\n- 系统消息：`type=message, role=system`\n- 工具返回：`type=function_call_output, call_id=..., output=...`\n\n这意味着：\n\n- ReAct 工具调用结果可被模型下一轮直接消费\n- CodeAct 执行结果也会以 system message 回写 memory\n\n## 4. Runtime 与 Memory 的交互时机\n\n以 `BaseAgentRuntime` 为例：\n\n1. `run` 开始：`memory.addUserInput(request.input)`\n2. 模型返回后：`memory.addOutputItems(modelResult.memoryItems)`\n3. 每次工具执行后：`memory.addToolOutput(callId, output)`\n4. 下一轮 `buildPrompt`：`items = memory.getItems()`\n\n所以 memory 是每步循环都参与的“状态源”。\n\n---\n\n## 4.1 Coding Agent 为什么也依赖这套 Memory\n\n`Coding Agent` 虽然在产品形态上更像 CLI/TUI/ACP 工具，但底层会话状态并不是另外发明了一套完全不同的内存模型。\n\n从源码和测试可以直接看出，它仍然依赖：\n\n- `MemorySnapshot`\n- `CodingSessionState.memorySnapshot`\n- `CodingSessionCompactor`\n\n这就是为什么 `Coding Agent` 会有：\n\n- session save / resume / fork\n- compact\n- memory item count\n\n也就是：\n\n- `Agent` 侧强调推理循环中的 memory\n- `Coding Agent` 侧强调持续会话中的 session memory\n\n但两者底层是打通的。\n\n## 5. 会话隔离语义（很关键）\n\n`Agent.newSession()` 会创建新的 `AgentSession`，并给它独立 memory：\n\n- 默认：`InMemoryAgentMemory::new`\n- 如果你传了 `memorySupplier`，每次 session 用你自定义 memory\n\n这保证：\n\n- 不同用户会话不会串上下文\n- 并发场景下状态隔离更安全\n\n## 6. 压缩机制：`MemoryCompressor`\n\n`InMemoryAgentMemory` 每次写入后都会 `maybeCompress()`：\n\n- 如果配置了 compressor，就执行 `compress(MemorySnapshot)`\n- 返回新的 `items + summary`\n\n接口：\n\n```java\npublic interface MemoryCompressor {\n    MemorySnapshot compress(MemorySnapshot snapshot);\n}\n```\n\n## 6.1 内置窗口压缩：`WindowedMemoryCompressor`\n\n作用：仅保留最近 N 条 item。\n\n```java\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"doubao-seed-1-8-251228\")\n        .memorySupplier(() -> new InMemoryAgentMemory(new WindowedMemoryCompressor(20)))\n        .build();\n```\n\n适用场景：\n\n- 短任务\n- 高并发低成本\n- 不要求长期语义记忆\n\n## 7. 推荐压缩策略（实践）\n\n## 策略 A：纯窗口\n\n- 成本低\n- 可能丢关键长期信息\n\n## 策略 B：窗口 + 摘要（推荐）\n\n- 旧对话压成 summary\n- 最近 N 轮保留原文\n\n## 策略 C：按任务分段记忆\n\n- 每个子任务独立记忆池\n- 汇总阶段只读子任务摘要\n\n## 8. 自定义 Compressor 示例（摘要 + 窗口）\n\n```java\npublic class HybridMemoryCompressor implements MemoryCompressor {\n\n    private final int maxItems;\n\n    public HybridMemoryCompressor(int maxItems) {\n        this.maxItems = maxItems;\n    }\n\n    @Override\n    public MemorySnapshot compress(MemorySnapshot snapshot) {\n        List<Object> items = snapshot.getItems();\n        if (items == null || items.size() <= maxItems) {\n            return snapshot;\n        }\n\n        int split = items.size() - maxItems;\n        List<Object> head = new ArrayList<>(items.subList(0, split));\n        List<Object> tail = new ArrayList<>(items.subList(split, items.size()));\n\n        String previousSummary = snapshot.getSummary() == null ? \"\" : snapshot.getSummary();\n        String newSummary = previousSummary + \"\\n[压缩] 历史片段条数=\" + head.size();\n\n        return MemorySnapshot.from(tail, newSummary.trim());\n    }\n}\n```\n\n> 你可以把 `head` 交给模型生成更高质量摘要，这样 summary 语义更强。\n\n## 9. 官方 JDBC 持久化与自定义扩展\n\n如果你希望先落 MySQL / PostgreSQL / H2，而不是自己从零写一版 `AgentMemory`，可以先直接用官方 JDBC 实现：\n\n```java\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"doubao-seed-1-8-251228\")\n        .memorySupplier(() -> new JdbcAgentMemory(\n                JdbcAgentMemoryConfig.builder()\n                        .jdbcUrl(\"jdbc:mysql://localhost:3306/ai4j\")\n                        .username(\"root\")\n                        .password(\"123456\")\n                        .sessionId(\"agent-session-001\")\n                        .compressor(new WindowedMemoryCompressor(20))\n                        .build()\n        ))\n        .build();\n```\n\n如果你是 Spring / 连接池场景，也可以直接传 `DataSource`。\n\n例如 Spring Boot + MySQL：\n\n```yaml\nspring:\n  datasource:\n    url: jdbc:mysql://localhost:3306/ai4j?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai\n    username: root\n    password: 123456\n```\n\n```java\n.memorySupplier(() -> new JdbcAgentMemory(\n        JdbcAgentMemoryConfig.builder()\n                .dataSource(dataSource)\n                .sessionId(\"agent-session-001\")\n                .compressor(new WindowedMemoryCompressor(20))\n                .build()\n))\n```\n\n这层官方实现解决的是：\n\n- 跨进程保存 Agent items / summary\n- 同一 session 的恢复\n- 保持和 `InMemoryAgentMemory` 一致的读写语义\n\n如果你要 Redis、分布式缓存、分库分表或统一会话平台，再考虑继续自定义。\n\n### 9.1 自定义持久化 Memory（例如 Redis）\n\n如果你希望会话跨进程/跨实例保留，可实现 `AgentMemory`：\n\n```java\npublic class RedisAgentMemory implements AgentMemory {\n    @Override\n    public void addUserInput(Object input) {\n        // 写 Redis\n    }\n\n    @Override\n    public void addOutputItems(List<Object> items) {\n        // 写 Redis\n    }\n\n    @Override\n    public void addToolOutput(String callId, String output) {\n        // 写 Redis\n    }\n\n    @Override\n    public List<Object> getItems() {\n        return new ArrayList<>();\n    }\n\n    @Override\n    public String getSummary() {\n        return null;\n    }\n\n    @Override\n    public void clear() {\n        // 清理会话数据\n    }\n}\n```\n\n接入：\n\n```java\n.memorySupplier(() -> new RedisAgentMemory())\n```\n\n## 10. 与 Trace 的关系\n\n当前 trace 会记录模型输入输出、工具参数输出；memory 压缩事件类型虽在 `AgentEventType` 中预留了 `MEMORY_COMPRESS`，但默认 runtime 还未主动发布该事件。\n\n建议：\n\n- 在自定义 memory/compressor 内主动打印或上报压缩指标\n- 记录 `before_items/after_items/summary_length`\n\n---\n\n## 10.1 与 Skill、Tool、MCP 的关系\n\n这几个能力经常一起出现，但职责不同：\n\n- `Memory`：保存已经发生过的上下文\n- `Skill`：提供遇到某类任务时的可复用方法说明\n- `Tool`：执行具体动作\n- `MCP`：把外部工具系统挂进来\n\n可以把它们理解成：\n\n- `Memory` 负责“记住”\n- `Skill` 负责“知道怎么做”\n- `Tool/MCP` 负责“真的去做”\n\n## 11. Memory 配置建议矩阵\n\n- FAQ/客服：窗口 20~40，低成本优先\n- 工单处理：窗口 + 摘要，保留关键动作链\n- 研究任务：更大窗口 + 摘要 + 子任务拆分\n- CodeAct：建议保留最近几轮 code/result，避免修复上下文丢失\n\n## 12. 常见问题\n\n1. 输出突然变差：通常是压缩过猛，关键上下文被裁掉。\n2. 会话串数据：检查是否复用了同一个 session 或共享 memory 实例。\n3. token 成本高：先看 items 长度，再考虑窗口压缩和摘要策略。\n\n## 13. 关联源码与测试\n\n- `JdbcAgentMemory`\n- `InMemoryAgentMemory`\n- `MemoryCompressor`\n- `WindowedMemoryCompressor`\n- `MemorySnapshot`\n- `Agent.newSession()` / `AgentSession`\n\n结合 `CodeActRuntimeTest`、`StateGraphWorkflowTest` 观察多步场景下的记忆效果会更直观。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/model-client-selection.md",
    "content": "---\nsidebar_position: 3\n---\n\n# ModelClient 选型：Responses vs Chat\n\n你问过一个非常关键的问题：\n\n> 为什么 Chat 的 stream 看起来是 token-by-token，而 Responses 的 stream 是 event-by-event？\n\n这页专门回答这个问题，并给出选型建议。\n\n## 1. 协议层差异（本质原因）\n\n### Chat 接口\n\n- 典型流式形态是“文本增量块”（delta）。\n- 你通常看到的是接近 token 级别的追加。\n\n### Responses 接口\n\n- 流式是 **事件流**，不仅有文本，还包含：\n  - response.created\n  - response.in_progress\n  - output_item.added\n  - reasoning_summary_text.delta\n  - output_text.delta\n  - response.completed\n- 所以它不是“只按 token 推文本”，而是“按事件类型推状态与内容”。\n\n## 2. 在 AI4J 中怎么选\n\n### 选 ResponsesModelClient 的场景\n\n- 你需要结构化事件（reasoning、message、tool call）\n- 你要做可观测、审计、复杂 agent\n- 你重视 system/instructions 字段语义分离\n\n### 选 ChatModelClient 的场景\n\n- 你只需要经典聊天流式输出\n- 你已有大量 chat-completion 兼容代码\n- 你希望最小改动接入\n\n## 3. 是否可以混用\n\n可以，而且推荐在复杂系统中混用：\n\n- 路由/规划/格式化节点：Responses（结构化更强）\n- 简单问答节点：Chat（轻量稳定）\n\n`WeatherAgentWorkflowTest` 就演示了这种混用方式。\n\n## 4. 代码示例\n\n```java\nAgent weatherAgent = Agents.react()\n        .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.DOUBAO)))\n        .model(\"doubao-seed-1-8-251228\")\n        .toolRegistry(Arrays.asList(\"queryWeather\"), null)\n        .build();\n\nAgent formatAgent = Agents.react()\n        .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n        .model(\"doubao-seed-1-8-251228\")\n        .instructions(\"输出严格 JSON\")\n        .build();\n```\n\n## 5. 流式观测建议\n\n- Chat：主要观测文本增量和完成事件\n- Responses：按事件类型分层观测（状态事件、文本事件、工具事件）\n\n如果你的目标是构建“可追踪的 Agent 平台”，建议以 Responses 为主。\n\n## 6. 常见误解\n\n1. “Responses 不支持实时输出” —— 错。它是事件化实时输出。\n2. “Chat 一定更快” —— 不一定，取决于模型和服务端实现。\n3. “必须二选一” —— 错，AI4J 支持在同一 workflow 中混用。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/multi-provider-profiles.md",
    "content": "---\nsidebar_position: 3\n---\n\n# 多 Provider Profile 实战\n\n本文档聚焦 `ai4j-cli` 当前已经实现的多 provider profile 工作流：\n\n- 全局保存 provider profile\n- workspace 引用当前 profile\n- workspace 单独覆盖 model\n- 当前 session 内即时切换 provider / model\n\n---\n\n## 1. 配置文件位置\n\n### 1.1 全局配置\n\n```text\n~/.ai4j/providers.json\n```\n\n用来保存所有可复用 profile。\n\n### 1.2 工作区配置\n\n```text\n<workspace>/.ai4j/workspace.json\n```\n\n用来保存：\n\n- 当前工作区引用哪个 profile\n- 当前工作区是否有 model override\n\n---\n\n## 2. 一个完整示例\n\n### 2.1 `providers.json`\n\n```json\n{\n  \"defaultProfile\": \"openai-main\",\n  \"profiles\": {\n    \"openai-main\": {\n      \"provider\": \"openai\",\n      \"protocol\": \"responses\",\n      \"model\": \"gpt-5-mini\",\n      \"apiKey\": \"OPENAI_API_KEY\"\n    },\n    \"zhipu-main\": {\n      \"provider\": \"zhipu\",\n      \"protocol\": \"chat\",\n      \"model\": \"glm-4.7\",\n      \"baseUrl\": \"https://open.bigmodel.cn/api/coding/paas/v4\",\n      \"apiKey\": \"ZHIPU_API_KEY\"\n    }\n  }\n}\n```\n\n### 2.2 `workspace.json`\n\n```json\n{\n  \"activeProfile\": \"zhipu-main\",\n  \"modelOverride\": \"glm-4.7-plus\"\n}\n```\n\n这意味着：\n\n- 当前 workspace 默认跑 `zhipu-main`\n- 但 effective model 不再是 profile 里的 `glm-4.7`\n- 而是 workspace override 的 `glm-4.7-plus`\n\n---\n\n## 3. 解析优先级\n\nCLI 当前按以下顺序解析 runtime：\n\n1. CLI 显式参数\n2. workspace 配置\n3. active profile\n4. default profile\n5. 环境变量 / system properties\n6. 内建默认值\n\n这样做的直接结果是：\n\n- 全局 profile 负责沉淀“长期可复用的 provider runtime”\n- workspace 负责声明“当前仓库到底用哪个 profile”\n- CLI 参数负责当前一次运行的显式覆盖\n\n---\n\n## 4. 创建与编辑 profile\n\n### 4.1 保存当前 runtime\n\n```text\n/provider save zhipu-main\n```\n\n把当前会话的 provider / protocol / model / baseUrl / apiKey 保存成 profile。\n\n### 4.2 显式新建 profile\n\n```text\n/provider add zhipu-main --provider zhipu --model glm-4.7 --base-url https://open.bigmodel.cn/api/coding/paas/v4\n```\n\n可选参数：\n\n```text\n/provider add <profile-name> --provider <name> [--protocol <chat|responses>] [--model <name>] [--base-url <url>] [--api-key <key>]\n```\n\n如果不传 `--protocol`，CLI 会根据 provider/baseUrl 推导默认协议，并把结果保存为显式值。\n\n### 4.3 编辑已有 profile\n\n```text\n/provider edit zhipu-main --model glm-4.7-plus\n/provider edit openai-main --protocol responses\n/provider edit zhipu-main --clear-api-key\n```\n\n完整语法：\n\n```text\n/provider edit <profile-name> [--provider <name>] [--protocol <chat|responses>] [--model <name>|--clear-model] [--base-url <url>|--clear-base-url] [--api-key <key>|--clear-api-key]\n```\n\n---\n\n## 5. 切换 profile 与 model\n\n### 5.1 切换当前 workspace profile\n\n```text\n/provider use zhipu-main\n```\n\n效果：\n\n- 写入 `<workspace>/.ai4j/workspace.json`\n- 更新 `activeProfile`\n- 立即重建当前 session runtime\n\n### 5.2 设置全局默认 profile\n\n```text\n/provider default zhipu-main\n/provider default clear\n```\n\n效果：\n\n- 写入 `~/.ai4j/providers.json`\n- 影响没有 workspace activeProfile 的工作区\n\n### 5.3 model override\n\n```text\n/model glm-4.7-plus\n/model reset\n```\n\n效果：\n\n- `/model <name>`：写入 workspace modelOverride，并立即重建当前 session runtime\n- `/model reset`：清空 override，回退到 profile model\n\n---\n\n## 6. 协议规则\n\n当前 CLI 对用户只暴露：\n\n- `chat`\n- `responses`\n\n省略 `--protocol` 时，默认规则是：\n\n- `openai` + 官方 host -> `responses`\n- `openai` + 自定义兼容 `baseUrl` -> `chat`\n- `doubao` / `dashscope` -> `responses`\n- 其他 provider -> `chat`\n\n历史配置中若保存的是 `auto`：\n\n- 新版本不会继续对用户暴露 `auto`\n- 旧 `providers.json` 会在加载时自动归一化成显式协议并写回\n\n---\n\n## 7. 当前推荐工作流\n\n比较稳妥的实践是：\n\n1. 先在全局层沉淀常用 profile，例如 `openai-main`、`zhipu-main`\n2. 每个 workspace 只引用一个 activeProfile\n3. 需要临时切模型时，用 `/model <name>` 做 workspace override\n4. 如果这个模型切换会长期保留，再回头更新 profile\n\n这样可以避免：\n\n- 把“仓库临时测试模型”误写进全局默认 profile\n- 在多个 workspace 间相互污染模型设置\n\n---\n\n## 8. 当前边界\n\n需要明确几件事：\n\n- profile 默认协议仍是本地规则推导，不是在线探测\n- `/model` 候选主要来自本地 runtime/config，不会实时拉远端官方模型目录\n- `responses` 当前只在部分 provider 上启用\n- 这套机制优先解决 CLI coding-agent 使用场景，不是通用配置中心\n\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/overview.md",
    "content": "---\nsidebar_position: 1\n---\n\n# Agent 架构总览\n\n本章节面向要把 Agent 真正上线的开发者，目标是让你快速回答三个问题：\n\n1. AI4J Agent 由哪些模块组成？\n2. 应该如何选择运行时（ReAct / CodeAct / DeepResearch）？\n3. 单 Agent、SubAgent、Agent Teams、StateGraph 的边界分别是什么？\n\n---\n\n## 1. Agent 能力地图（从核心到扩展）\n\nAI4J Agent 不是一个只能聊天的封装，而是一套可拆分、可替换、可观测的工程化框架：\n\n- 模型适配层：统一 Chat/Responses 两类协议；\n- 运行时层：ReAct、CodeAct、DeepResearch；\n- 工具层：Function Tool、MCP Tool、自定义 ToolExecutor；\n- 记忆层：会话记忆、窗口压缩、摘要压缩；\n- 编排层：顺序流、StateGraph、SubAgent、Agent Teams；\n- 治理与观测层：handoff policy、trace/exporter、事件监听；\n- 交付入口层：SDK 内嵌接入、`ai4j-cli` coding-agent CLI/TUI。\n\n核心入口类：\n\n- `Agent`\n- `AgentBuilder`\n- `AgentSession`\n- `AgentContext`\n- `Agents`\n\n---\n\n## 2. 模块结构与职责\n\n### 2.1 `agent`\n\n- 负责组装运行时、模型、工具、记忆；\n- `Agent.newSession()` 用于创建独立会话上下文；\n- `AgentBuilder.build()` 会自动补齐默认组件。\n\n### 2.2 `agent.runtime`\n\n- `BaseAgentRuntime`：统一步骤循环（step loop）；\n- `ReActRuntime`：默认通用运行时；\n- `CodeActRuntime`：代码驱动工具调用；\n- `DeepResearchRuntime`：规划优先、研究型任务。\n\n### 2.3 `agent.model`\n\n- `AgentModelClient` 统一模型调用接口；\n- `ResponsesModelClient` 与 `ChatModelClient` 分别适配不同协议；\n- `AgentPrompt` / `AgentModelResult` 作为运行时与模型适配层之间的稳定契约。\n\n### 2.4 `agent.tool`\n\n- `AgentToolRegistry`：决定“暴露给模型哪些工具”；\n- `ToolExecutor`：决定“工具调用如何执行”；\n- 默认执行器 `ToolUtilExecutor` 支持 Function/MCP。\n\n### 2.5 `agent.memory`\n\n- 默认 `InMemoryAgentMemory`；\n- 支持 `MemoryCompressor` 和窗口压缩策略。\n\n### 2.6 `agent.workflow`\n\n- 顺序编排：`SequentialWorkflow`；\n- 状态图编排：`StateGraphWorkflow`；\n- 可做分支、循环、条件路由。\n\n### 2.7 `agent.subagent`\n\n- 主 Agent 通过 handoff 工具调用子代理；\n- `HandoffPolicy` 管理超时、深度、失败回退等治理策略。\n\n### 2.8 `agent.team`\n\n- 多成员协作（Lead + Members + 任务板 + 消息总线）；\n- 支持任务依赖、轮次调度、消息广播、任务认领/转派。\n\n### 2.9 `agent.trace`\n\n- 统一追踪事件和 span；\n- 可接内存导出器、控制台导出器或自定义 exporter。\n\n---\n\n## 3. 运行时怎么选\n\n### ReActRuntime（默认）\n\n适合大多数业务 Agent：\n\n- 文本任务 + 工具调用；\n- 多轮推理但不需要代码执行环境；\n- 接入与维护成本最低。\n\n### CodeActRuntime\n\n适合“批量工具调用 + 代码组织”任务：\n\n- 一次生成代码，代码内部多次调用工具；\n- 对复杂数据加工更稳定；\n- 可开启 `CodeActOptions.reAct=true` 形成“代码执行后再总结”的双阶段模式。\n\n### DeepResearchRuntime\n\n适合研究型任务：\n\n- 先规划，再分阶段收集证据，再汇总；\n- 更强调结构化输出和来源一致性。\n\n---\n\n## 4. 单 Agent 到多 Agent 的演进建议\n\n推荐按复杂度逐步演进，而不是一开始就上 Teams：\n\n1. 单 Agent（ReAct）：先稳定模型与工具白名单；\n2. CodeAct：处理复杂数据加工或多工具批处理；\n3. StateGraph：任务需要显式分支/循环；\n4. SubAgent：主从委派、严格 handoff 治理；\n5. Agent Teams：多角色协作、共享任务板、成员间通信；\n6. Trace + 在线观测：为排障与审计提供依据。\n\n---\n\n## 5. 最小示例（可运行）\n\n```java\nAgent agent = Agents.react()\n        .modelClient(new ResponsesModelClient(responsesService))\n        .model(\"doubao-seed-1-8-251228\")\n        .systemPrompt(\"你是一个严谨的助手\")\n        .instructions(\"需要时再调用工具\")\n        .toolRegistry(java.util.Arrays.asList(\"queryWeather\"), null)\n        .build();\n\nAgentResult result = agent.run(AgentRequest.builder()\n        .input(\"请给出北京今天的天气摘要\")\n        .build());\n\nSystem.out.println(result.getOutputText());\n```\n\n---\n\n## 6. Agent 章节阅读顺序\n\n建议按下面顺序读完：\n\n1. `Coding Agent CLI 与 TUI`\n2. `多 Provider Profile 实战`\n3. `Coding Agent 命令参考手册`\n4. `Provider 配置样例`\n5. `自定义 Agent 开发指南`\n6. `System Prompt 与 Instructions`\n7. `Model Client 选择与适配`\n8. `Runtime 实现详解`\n9. `Memory 管理`\n10. `Workflow StateGraph`\n11. `CodeAct`（含自定义沙箱）\n12. `SubAgent 与 handoff policy`\n13. `Agent Teams`\n14. `Trace 可观测`\n15. `核心类参考手册`\n\n---\n\n## 7. 关键测试索引\n\n你可以直接运行这些测试来理解行为：\n\n- `WeatherAgentWorkflowTest`\n- `StateGraphWorkflowTest`\n- `CodeActRuntimeTest`\n- `CodeActRuntimeWithTraceTest`\n- `SubAgentRuntimeTest`\n- `SubAgentParallelFallbackTest`\n- `HandoffPolicyTest`\n- `AgentTeamTest`\n- `AgentTeamTaskBoardTest`\n- `DoubaoAgentTeamBestPracticeTest`\n\n如果你希望把 Agent 模块接入生产，建议至少跑通：\n\n1. 一套单 Agent 回归；\n2. 一套 CodeAct 回归；\n3. 一套 Team/Workflow 回归；\n4. 一套 Trace 验证回归。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/provider-config-examples.md",
    "content": "---\nsidebar_position: 5\n---\n\n# Provider 配置样例\n\n本文档给出当前 `ai4j-cli` coding-agent 场景下最常用的三类配置样例：\n\n- Zhipu\n- OpenAI 官方\n- OpenAI-compatible 自定义 `baseUrl`\n\n所有示例都使用占位符，不要把真实密钥提交进仓库。\n\n以下命令示例默认已经通过安装脚本拿到了 `ai4j` 命令；如果你仍想从源码运行，只需要把 `ai4j` 替换成 `java -jar .\\\\ai4j-cli\\\\target\\\\ai4j-cli-<version>-jar-with-dependencies.jar`。\n\n---\n\n## 1. Zhipu（Coding Endpoint）\n\n### 1.1 启动命令\n\n```powershell\nai4j tui `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n### 1.2 profile 样例\n\n```json\n{\n  \"provider\": \"zhipu\",\n  \"protocol\": \"chat\",\n  \"model\": \"glm-4.7\",\n  \"baseUrl\": \"https://open.bigmodel.cn/api/coding/paas/v4\",\n  \"apiKey\": \"${ZHIPU_API_KEY}\"\n}\n```\n\n### 1.3 说明\n\n- 当前 Zhipu 在 `ai4j-cli` 里走 `chat`\n- `baseUrl` 建议填写 coding endpoint\n- 如果你省略 `--protocol`，默认也会落到 `chat`\n\n---\n\n## 2. OpenAI 官方\n\n### 2.1 one-shot 样例\n\n```powershell\nai4j code `\n  --provider openai `\n  --protocol responses `\n  --model gpt-5-mini `\n  --prompt \"Explain the project structure\"\n```\n\n### 2.2 profile 样例\n\n```json\n{\n  \"provider\": \"openai\",\n  \"protocol\": \"responses\",\n  \"model\": \"gpt-5-mini\",\n  \"apiKey\": \"${OPENAI_API_KEY}\"\n}\n```\n\n### 2.3 说明\n\n- 当前 OpenAI 官方 host 默认会落到 `responses`\n- 如果你不传 `baseUrl`，CLI 会把它视为官方 OpenAI host\n- 如果你显式写 `--protocol chat`，CLI 也会照配执行，但当前推荐官方 OpenAI 优先使用 `responses`\n\n---\n\n## 3. OpenAI-compatible 自定义 `baseUrl`\n\n这里指：\n\n- provider 名仍然使用 `openai`\n- 但请求实际发往一个兼容 OpenAI API 的自定义地址\n\n典型例子包括一些兼容层或第三方平台。\n\n### 3.1 启动命令\n\n```powershell\nai4j code `\n  --provider openai `\n  --protocol chat `\n  --model deepseek-chat `\n  --base-url https://api.deepseek.com `\n  --workspace .\n```\n\n### 3.2 profile 样例\n\n```json\n{\n  \"provider\": \"openai\",\n  \"protocol\": \"chat\",\n  \"model\": \"deepseek-chat\",\n  \"baseUrl\": \"https://api.deepseek.com\",\n  \"apiKey\": \"${DEEPSEEK_API_KEY}\"\n}\n```\n\n### 3.3 说明\n\n- 当前只要是 `openai` + 自定义 `baseUrl`，默认协议就会落到 `chat`\n- 这是本地路由规则，不是在线探测\n- 如果第三方平台本身要求别的路径格式，请以对方兼容层文档为准\n\n---\n\n## 4. `providers.json` 完整样例\n\n```json\n{\n  \"defaultProfile\": \"zhipu-main\",\n  \"profiles\": {\n    \"zhipu-main\": {\n      \"provider\": \"zhipu\",\n      \"protocol\": \"chat\",\n      \"model\": \"glm-4.7\",\n      \"baseUrl\": \"https://open.bigmodel.cn/api/coding/paas/v4\",\n      \"apiKey\": \"${ZHIPU_API_KEY}\"\n    },\n    \"openai-main\": {\n      \"provider\": \"openai\",\n      \"protocol\": \"responses\",\n      \"model\": \"gpt-5-mini\",\n      \"apiKey\": \"${OPENAI_API_KEY}\"\n    },\n    \"deepseek-compatible\": {\n      \"provider\": \"openai\",\n      \"protocol\": \"chat\",\n      \"model\": \"deepseek-chat\",\n      \"baseUrl\": \"https://api.deepseek.com\",\n      \"apiKey\": \"${DEEPSEEK_API_KEY}\"\n    }\n  }\n}\n```\n\n---\n\n## 5. `workspace.json` 样例\n\n```json\n{\n  \"activeProfile\": \"zhipu-main\",\n  \"modelOverride\": \"glm-4.7-plus\"\n}\n```\n\n这表示：\n\n- 当前仓库默认使用 `zhipu-main`\n- 但模型临时覆盖成 `glm-4.7-plus`\n\n---\n\n## 6. 推荐做法\n\n- 官方 OpenAI：优先 `responses`\n- Zhipu coding endpoint：使用 `chat`\n- OpenAI-compatible 自定义 host：优先 `chat`\n- 长期稳定配置沉淀到 `providers.json`\n- 仓库级模型试验只写进 `workspace.json`\n\n---\n\n## 7. 不推荐的做法\n\n- 在仓库里提交真实 API key\n- 把临时测试模型直接改成全局默认 profile\n- 把 OpenAI-compatible 自定义 host 错配成官方 OpenAI 的 `responses` 默认思路\n\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/reference-core-classes.md",
    "content": "---\nsidebar_position: 14\n---\n\n# Agent 核心类参考手册（源码对齐版）\n\n本页按“模块 -> 类 -> 关键字段/方法 -> 默认行为”组织，适合作为开发时速查表。\n\n---\n\n## 1. 顶层入口（`agent`）\n\n### 1.1 `Agent`\n\n职责：统一运行入口。\n\n关键方法：\n\n- `run(AgentRequest)`：同步调用\n- `runStream(AgentRequest, AgentListener)`：流式调用\n- `newSession()`：创建独立会话（内存隔离）\n\n### 1.2 `AgentSession`\n\n职责：承载一次会话上下文。\n\n关键方法：\n\n- `run(String input)`\n- `run(AgentRequest)`\n- `runStream(...)`\n- `getContext()`：可在高级场景下读取/修改会话 context\n\n### 1.3 `AgentBuilder`\n\n必填：\n\n- `modelClient(...)`\n- `model(...)`\n\n常用参数分组：\n\n- 运行时：`runtime(...)`、`options(...)`\n- 提示词：`systemPrompt(...)`、`instructions(...)`\n- 采样：`temperature/topP/maxOutputTokens`\n- 工具：`toolRegistry(...)`、`toolExecutor(...)`\n- CodeAct：`codeExecutor(...)`、`codeActOptions(...)`\n- SubAgent：`subAgent(...)`、`handoffPolicy(...)`\n- 观测：`traceExporter(...)`、`traceConfig(...)`、`eventPublisher(...)`\n\n构建默认值（`build()` 时补齐）：\n\n- `runtime` -> `ReActRuntime`\n- `memorySupplier` -> `InMemoryAgentMemory::new`\n- `toolRegistry` -> `StaticToolRegistry.empty()`\n- `toolExecutor` -> `ToolUtilExecutor`\n- `codeExecutor` -> `GraalVmCodeExecutor`\n\n### 1.4 `Agents`\n\n工厂方法：\n\n- `builder()`\n- `react()`\n- `codeAct()`\n- `deepResearch()`\n- `team()`\n\n### 1.5 `AgentContext`\n\n职责：运行时共享参数容器。\n\n主要字段：\n\n- 模型：`modelClient`、`model`、`reasoning`、`toolChoice`\n- 提示：`systemPrompt`、`instructions`\n- 工具：`toolRegistry`、`toolExecutor`\n- 执行：`options`、`codeActOptions`、`codeExecutor`\n- 会话：`memory`\n- 观测：`eventPublisher`\n- 扩展：`extraBody`\n\n---\n\n## 2. Runtime 层（`agent.runtime`）\n\n### 2.1 `BaseAgentRuntime`\n\n职责：统一 step-loop 模板。\n\n核心流程：\n\n1. 读 `AgentMemory` 组 prompt\n2. 调模型\n3. 解析 tool calls\n4. 执行工具并写回 memory\n5. 继续下一步，直到终止条件\n\n核心参数来源：`AgentOptions`\n\n- `maxSteps`\n- `stream`\n\n### 2.2 `ReActRuntime`\n\n默认运行时，适用于大多数“模型 + 工具”场景。\n\n### 2.3 `CodeActRuntime`\n\n代码驱动执行：模型输出代码块，由 `CodeExecutor` 执行。\n\n关键特性：\n\n- 支持 `python/js`\n- 可在代码中调用工具\n- `CodeActOptions.reAct` 控制执行后是否再回模型总结\n\n### 2.4 `DeepResearchRuntime`\n\n规划优先的研究型运行时，适合证据收集与结构化汇总。\n\n---\n\n## 3. Model 层（`agent.model`）\n\n### 3.1 `AgentModelClient`\n\n统一模型接口：\n\n- `create(AgentPrompt)`\n- `createStream(AgentPrompt, AgentModelStreamListener)`\n\n### 3.2 `AgentPrompt`\n\n运行时给模型的标准输入结构，包含：\n\n- `model`\n- `items`\n- `systemPrompt`\n- `instructions`\n- `tools`\n- `stream`\n- `reasoning` 等扩展字段\n\n### 3.3 `AgentModelResult`\n\n模型层标准输出：\n\n- `outputText`\n- `toolCalls`\n- `memoryItems`\n- `rawResponse`\n\n### 3.4 适配器\n\n- `ResponsesModelClient`\n- `ChatModelClient`\n\n可按同样接口扩展第三方模型。\n\n---\n\n## 4. Tool 层（`agent.tool`）\n\n### 4.1 注册器\n\n- `AgentToolRegistry`\n- `StaticToolRegistry`\n- `ToolUtilRegistry`\n- `CompositeToolRegistry`\n\n### 4.2 执行器\n\n- `ToolExecutor`\n- 默认：`ToolUtilExecutor`\n\n### 4.3 Tool 数据结构\n\n- `AgentToolCall`\n- `AgentToolResult`\n\n---\n\n## 5. Memory 层（`agent.memory`）\n\n- `AgentMemory`\n- `InMemoryAgentMemory`\n- `MemoryCompressor`\n- `WindowedMemoryCompressor`\n- `MemorySnapshot`\n\n推荐在长会话中开启压缩策略。\n\n---\n\n## 6. Workflow 层（`agent.workflow`）\n\n- `SequentialWorkflow`\n- `StateGraphWorkflow`\n- `AgentNode`\n- `WorkflowContext`\n- `WorkflowAgent`\n\n用途：把多个 Agent 节点编排成可复用流程。\n\n---\n\n## 7. SubAgent 层（`agent.subagent`）\n\n核心类：\n\n- `SubAgentDefinition`\n- `SubAgentRegistry`\n- `StaticSubAgentRegistry`\n- `SubAgentToolExecutor`\n- `HandoffPolicy`\n- `HandoffFailureAction`\n- `HandoffContext`\n\n适合主从委派，不适合强协作任务板场景。\n\n---\n\n## 8. Team 层（`agent.team`）\n\n### 8.1 编排核心\n\n- `AgentTeam`\n- `AgentTeamBuilder`\n- `AgentTeamPlanner`\n- `AgentTeamSynthesizer`\n- `LlmAgentTeamPlanner`\n- `LlmAgentTeamSynthesizer`\n\n`AgentTeamBuilder` 关键入口：\n\n- `leadAgent(...)`（推荐默认）\n- `plannerAgent(...)`（可选覆盖）\n- `synthesizerAgent(...)`（可选覆盖）\n- `member(...)`\n- `options(...)`\n- `planApproval(...)`\n- `hook(...)`\n- `messageBus(...)`\n- `teamId(...)`\n- `stateStore(...)`\n- `storageDirectory(...)`\n\n### 8.2 任务模型与状态\n\n- `AgentTeamTask`：`id/memberId/task/context/dependsOn`\n- `AgentTeamTaskBoard`：任务状态流转与依赖计算\n- `AgentTeamTaskState`：含 `claimedBy/lastHeartbeatTime/output/error`\n- `AgentTeamTaskStatus`：`PENDING/READY/IN_PROGRESS/COMPLETED/FAILED/BLOCKED`\n\n### 8.3 协作与治理\n\n- `AgentTeamMessage`\n- `AgentTeamMessageBus`\n- `InMemoryAgentTeamMessageBus`\n- `FileAgentTeamMessageBus`\n- `AgentTeamState`\n- `AgentTeamMemberSnapshot`\n- `AgentTeamStateStore`\n- `InMemoryAgentTeamStateStore`\n- `FileAgentTeamStateStore`\n- `AgentTeamPlanApproval`\n- `AgentTeamHook`\n- `AgentTeamControl`\n\n`AgentTeamControl` 当前能力：\n\n- 队员管理：`registerMember/unregisterMember/listMembers`\n- 信息管理：`listMessages/listMessagesFor/sendMessage/broadcastMessage/publishMessage`\n- 任务管理：`listTaskStates/claimTask/releaseTask/reassignTask/heartbeatTask`\n\n`AgentTeam` 现在额外提供的状态接口：\n\n- `getTeamId()`\n- `snapshotState()`\n- `loadPersistedState()`\n- `restoreState(...)`\n- `clearPersistedState()`\n\n默认文件持久化规则：\n\n- 当 builder 只提供 `storageDirectory(...)` 时\n- `state` 会落到 `<storageDirectory>/state/<teamId>.json`\n- `mailbox` 会落到 `<storageDirectory>/mailbox/<teamId>.jsonl`\n- 新建同 `teamId` 的 Team 后，可显式调用 `loadPersistedState()` 恢复运行快照\n\n### 8.4 Team 工具（成员主动协作）\n\n新增包：`agent.team.tool`\n\n- `AgentTeamToolRegistry`\n- `AgentTeamToolExecutor`\n\n默认向成员注入的工具：\n\n- `team_send_message`\n- `team_broadcast`\n- `team_list_tasks`\n- `team_claim_task`\n- `team_release_task`\n- `team_reassign_task`\n- `team_heartbeat_task`\n\n控制开关：`AgentTeamOptions.enableMemberTeamTools`\n\n### 8.5 `AgentTeamOptions` 速查\n\n- 调度：`parallelDispatch/maxConcurrency/maxRounds`\n- 容错：`continueOnMemberError/broadcastOnPlannerFailure/failOnUnknownMember`\n- 上下文：`includeOriginalObjectiveInDispatch/includeTaskContextInDispatch`\n- 消息：`enableMessageBus/includeMessageHistoryInDispatch/messageHistoryLimit`\n- 治理：`requirePlanApproval/allowDynamicMemberRegistration`\n- 任务：`taskClaimTimeoutMillis`\n- 协作工具：`enableMemberTeamTools`\n\n### 8.6 `AgentTeamResult` 输出\n\n执行后返回：\n\n- `teamId`\n- `objective`\n- `plan`\n- `memberResults`\n- `taskStates`\n- `messages`\n- `rounds`\n- `output`\n- `synthesisResult`\n- `totalDurationMillis`\n\n---\n\n## 9. Trace 层（`agent.trace`）\n\n- `TraceSpan`\n  - 基础字段：`traceId/spanId/parentSpanId/name/type/status/startTime/endTime/error`\n  - 扩展字段：`attributes/events/metrics`\n- `TraceSpanEvent`\n  - `timestamp/name/attributes`\n- `TraceMetrics`\n  - `durationMillis/promptTokens/completionTokens/totalTokens/inputCost/outputCost/totalCost/currency`\n- `TraceSpanType`\n  - `RUN/STEP/MODEL/TOOL/HANDOFF/TEAM_TASK/MEMORY/AGENT_FLOW/FLOWGRAM_TASK/FLOWGRAM_NODE`\n- `TraceSpanStatus`\n  - `OK/ERROR/CANCELED`\n- `TraceConfig`\n  - `recordModelInput/recordModelOutput/recordToolArgs/recordToolOutput/recordMetrics/maxFieldLength/masker/pricingResolver`\n- `TracePricing`\n  - `inputCostPerMillionTokens/outputCostPerMillionTokens/currency`\n- `TracePricingResolver`\n  - `resolve(model) -> TracePricing`\n- `TraceExporter`\n  - 统一导出接口：`export(TraceSpan)`\n- 内置 exporter\n  - `ConsoleTraceExporter`\n  - `InMemoryTraceExporter`\n  - `CompositeTraceExporter`\n  - `JsonlTraceExporter`\n  - `OpenTelemetryTraceExporter`\n  - `LangfuseTraceExporter`\n- `AgentTraceListener`\n  - 把 `AgentEvent` 映射成 trace\n  - 当前覆盖 `MODEL_REASONING`、`MODEL_RETRY`、`HANDOFF_*`、`TEAM_*`、`MEMORY_COMPRESS`\n- `AgentFlowTraceBridge`\n  - 把 `AgentFlowTraceListener` 生命周期事件映射成 `AGENT_FLOW` span\n  - 适合把 Dify / Coze / n8n 这类已发布端点调用接进同一套 exporter\n\n用途：\n\n- 在线追踪\n- 链路分析\n- 问题回放\n- 向 OTel / 日志文件 / 测试断言输出统一 trace 数据\n\n---\n\n## 10. CodeAct 执行层（`agent.codeact`）\n\n- `CodeActOptions`\n- `CodeExecutor`\n- `CodeExecutionRequest`\n- `CodeExecutionResult`\n- `GraalVmCodeExecutor`\n\n可替换为自定义沙箱执行器。\n\n---\n\n## 11. 推荐测试索引\n\n- `AgentTeamTest`\n- `AgentTeamTaskBoardTest`\n- `DoubaoAgentTeamBestPracticeTest`\n- `CodeActRuntimeTest`\n- `CodeActRuntimeWithTraceTest`\n- `SubAgentRuntimeTest`\n- `SubAgentParallelFallbackTest`\n- `HandoffPolicyTest`\n\n建议把这些测试作为文档示例的行为真值来源。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/runtime-implementations.md",
    "content": "---\nsidebar_position: 5\n---\n\n# Runtime 实现详解（ReAct / CodeAct / DeepResearch）\n\n如果你要做可扩展 Agent，Runtime 是最关键的“策略层”。\n\n本页会按源码结构解释：\n\n- `BaseAgentRuntime` 的主循环怎么跑\n- `ReActRuntime / CodeActRuntime / DeepResearchRuntime` 分别做了什么\n- 如何写一个你自己的 Runtime\n\n## 1. Runtime 抽象层\n\n`AgentRuntime` 只有两个方法：\n\n- `run(AgentContext, AgentRequest)`\n- `runStream(AgentContext, AgentRequest, AgentListener)`\n\n这意味着：\n\n- 你可以完全自定义执行策略\n- 也可以继承 `BaseAgentRuntime` 复用通用循环\n\n## 2. BaseAgentRuntime：通用循环骨架\n\n`BaseAgentRuntime.runInternal(...)` 的核心流程：\n\n1. 读取 `AgentOptions`（`maxSteps/stream`）\n2. 把用户输入写入 `AgentMemory`\n3. 进入 while 循环（`maxSteps <= 0 || step < maxSteps`）\n4. `buildPrompt()` 构造 `AgentPrompt`\n5. `executeModel()` 调模型\n6. 把模型返回的 memory items 回写到 memory\n7. 判断是否有 tool calls\n   - 无 -> 产出 `FINAL_OUTPUT`，结束\n   - 有 -> 执行工具，写入 `function_call_output`，继续下一轮\n8. 达到显式步数上限后返回最后结果\n\n## 2.1 事件发布点（排障很重要）\n\n每一轮会发布：\n\n- `STEP_START`\n- `MODEL_REQUEST`\n- `MODEL_RESPONSE`\n- `TOOL_CALL`（有工具时）\n- `TOOL_RESULT`（有工具时）\n- `STEP_END`\n- 最终结束前 `FINAL_OUTPUT`\n\n这正是 Trace 能构建 `RUN/STEP/MODEL/TOOL` 的基础。\n\n## 2.2 Prompt 构建规则\n\n`buildPrompt(...)` 会把这些上下文拼起来：\n\n- `memory.getItems()`\n- `systemPrompt + runtimeInstructions()`\n- `instructions`\n- `tools/toolChoice/parallelToolCalls`\n- 采样参数与扩展参数\n\n其中 `runtimeInstructions()` 是 Runtime 自身的“策略提示”，用于约束模型行为。\n\n## 3. ReActRuntime：最轻量默认策略\n\n`ReActRuntime` 只做两件事：\n\n- `runtimeName() -> \"react\"`\n- `runtimeInstructions()` 返回 “必要时使用工具，最终回答简洁”\n\n它复用了 `BaseAgentRuntime` 的全部循环，是最通用、最稳健的默认选择。\n\n## 4. CodeActRuntime：代码驱动的工具编排\n\n`CodeActRuntime` 会要求模型返回严格 JSON：\n\n- 需要执行代码：\n  - `{\"type\":\"code\",\"language\":\"python|js\",\"code\":\"...\"}`\n- 最终答案：\n  - `{\"type\":\"final\",\"output\":\"...\"}`\n\n## 4.1 与 BaseAgentRuntime 的关键差异\n\n1. 不走“模型直接发 tool call”路径，而是走“code 工具”路径。\n2. 每步模型输出先被解析为 `CodeActMessage`。\n3. 如果是 code：\n   - 触发 `TOOL_CALL(name=code)`\n   - 调 `CodeExecutor.execute(...)`\n   - 将执行结果写入 memory 的 `CODE_RESULT/CODE_ERROR` system message\n4. 根据 `CodeActOptions.reAct` 决定收敛策略。\n\n## 4.2 `CodeActOptions.reAct` 两种模式\n\n- `reAct=false`（默认）\n  - 代码执行成功后，结果可直接作为最终输出返回。\n  - 延迟低，适合结构化计算型任务。\n- `reAct=true`\n  - 代码执行结果会回送模型，再由模型输出自然语言最终答案。\n  - 更可读，适合面向用户的最终文本。\n\n## 4.3 失败后自动修复是否支持\n\n支持，但是“循环式自修复”，不是硬编码重试：\n\n- 只要 `maxSteps` 还没耗尽\n- 模型拿到 `CODE_ERROR` 信息后继续输出新 code\n- Runtime 就会继续执行下一轮\n\n这符合当前主流 CodeAct 的实践：**LLM 负责修复策略，Runtime 负责执行闭环**。\n\n## 4.4 CodeAct 指令注入\n\n`CodeActRuntime.runtimeInstructions(...)` 会自动注入：\n\n- JSON 输出协议\n- `__codeact_result` 约定\n- `callTool(\"name\", args)` 与同名函数调用方式\n- 当前可用工具列表（来自 `toolRegistry`）\n\n所以你看到的“可用工具说明”是由 Runtime 自动拼入系统层提示的。\n\n## 5. DeepResearchRuntime：先规划再执行\n\n`DeepResearchRuntime` 在调用 `super.run(...)` 前会做 `preparePlan(...)`：\n\n1. 使用 `Planner` 生成步骤列表\n2. 将计划以 system message 形式写入 memory\n3. 再进入 BaseAgentRuntime 循环\n\n默认 `Planner.simple()` 只返回单步；你可以注入自己的 planner 做更复杂拆解。\n\n## 6. CodeExecutor 与 GraalVmCodeExecutor\n\n`CodeExecutor` 接口很简单：\n\n- 入参：`CodeExecutionRequest`\n  - `language/code/toolNames/toolExecutor/user/timeoutMs`\n- 返回：`CodeExecutionResult`\n  - `stdout/result/error`\n\n默认实现 `GraalVmCodeExecutor`：\n\n- 支持 `python/js`\n- Python 走 GraalPy\n- JS 优先 Polyglot，上下文不可用时回退 `ScriptEngine`\n- 自动注入工具桥（`callTool` + 工具同名函数）\n\n## 7. 如何自定义 Runtime（推荐模板）\n\n建议继承 `BaseAgentRuntime`，只覆写你真正关心的行为。\n\n```java\npublic class MyRuntime extends BaseAgentRuntime {\n\n    @Override\n    protected String runtimeName() {\n        return \"my-runtime\";\n    }\n\n    @Override\n    protected String runtimeInstructions() {\n        return \"Always verify tool output before final answer.\";\n    }\n\n    @Override\n    protected AgentPrompt buildPrompt(AgentContext context, AgentMemory memory, boolean stream) {\n        AgentPrompt prompt = super.buildPrompt(context, memory, stream);\n        // 你可以在这里对 prompt 做二次加工\n        return prompt;\n    }\n}\n```\n\n接入：\n\n```java\nAgent agent = Agents.builder()\n        .runtime(new MyRuntime())\n        .modelClient(modelClient)\n        .model(\"your-model\")\n        .build();\n```\n\n## 8. Runtime 选型建议（实践版）\n\n- 绝大多数业务：`ReActRuntime`\n- 工具调用链复杂（循环/聚合/批处理）：`CodeActRuntime`\n- 研究型任务（先规划再检索）：`DeepResearchRuntime`\n- 强定制策略：自定义 Runtime\n\n## 9. 对应测试\n\n- `CodeActRuntimeTest`\n- `CodeActRuntimeWithTraceTest`\n- `StateGraphWorkflowTest`（Runtime + Workflow 组合）\n\n如果你准备扩展 Runtime，建议先跑这几个测试，再改你的策略代码。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/subagent-handoff-policy.md",
    "content": "﻿---\nsidebar_position: 8\n---\n\n# SubAgent 与 Handoff Policy（主从协作治理）\n\n这一页聚焦“Lead-Agent -> SubAgent”的工程化实现：\n\n- 什么时候该用 SubAgent\n- 子代理在 AI4J 里如何被封装成 Tool\n- `HandoffPolicy` 每个参数的时机\n- 并行、失败回退、超时、深度限制怎么配置\n\n## 1. 适用场景\n\n当一个 Agent 同时承担“路由、检索、分析、格式化”会很快变臃肿，建议拆成：\n\n- Lead-Agent：任务分解 + 结果汇总\n- SubAgent：专项能力（天气、格式化、代码审查、检索）\n\n## 2. 类级结构（按执行链）\n\n1. `SubAgentDefinition`\n   - 定义一个子代理（name/description/toolName/agent/sessionMode）\n2. `StaticSubAgentRegistry`\n   - 把定义转换为可暴露工具，并负责真正调用\n3. `SubAgentToolExecutor`\n   - 拦截工具调用，若命中子代理工具则执行 handoff\n4. `HandoffPolicy`\n   - 统一治理：是否允许、深度、重试、超时、失败动作\n5. `HandoffContext`\n   - 用 `ThreadLocal` 记录嵌套委托深度\n\n## 3. 子代理是如何“变成工具”的\n\n`StaticSubAgentRegistry` 会为每个 `SubAgentDefinition` 自动构造一个 function tool：\n\n- `toolName`：默认 `subagent_<name>`（可手动指定）\n- 参数 schema：`task`（必填）+ `context`（可选）\n\n当主模型调用这个工具时：\n\n1. registry 解析 arguments\n2. 调用对应 `Agent`（new session 或复用 session）\n3. 把子代理结果包装成 JSON 返回主链路\n\n## 4. `SubAgentSessionMode` 选择\n\n- `NEW_SESSION`（默认）\n  - 每次 handoff 独立记忆\n  - 线程安全、隔离性好\n- `REUSE_SESSION`\n  - 同一子代理工具复用会话\n  - 有上下文连续性，适合长期任务\n\n## 5. HandoffPolicy 参数详解\n\n```java\nHandoffPolicy policy = HandoffPolicy.builder()\n        .enabled(true)\n        .maxDepth(1)\n        .maxRetries(0)\n        .timeoutMillis(0L)\n        .allowedTools(null)\n        .deniedTools(null)\n        .onDenied(HandoffFailureAction.FAIL)\n        .onError(HandoffFailureAction.FAIL)\n        .inputFilter(null)\n        .build();\n```\n\n字段说明：\n\n- `enabled`\n  - 是否启用策略检查；false 时直接放行 subagent 执行。\n- `maxDepth`\n  - 最大嵌套深度；`1` 表示只允许 lead -> sub。\n- `maxRetries`\n  - 子代理异常后的重试次数（不含首次）。\n- `timeoutMillis`\n  - 单次 handoff 超时；`0` 表示不超时。\n- `allowedTools`\n  - 允许的 subagent tool 名单（空表示不限）。\n- `deniedTools`\n  - 显式拒绝名单。\n- `onDenied`\n  - 策略拒绝时动作：`FAIL` / `FALLBACK_TO_PRIMARY`\n- `onError`\n  - 子代理执行异常时动作：`FAIL` / `FALLBACK_TO_PRIMARY`\n- `inputFilter`\n  - 委托前改写参数（脱敏、裁剪、补充上下文）。\n\n## 6. `FALLBACK_TO_PRIMARY` 是什么\n\n当 handoff 失败/拒绝时，不抛异常，而是回退到主执行器（`ToolExecutor delegate`）执行同名工具。\n\n常见用途：\n\n- 子代理挂了，主工具兜底\n- 某些环境禁用子代理时自动回退\n\n## 7. 并行 handoff\n\n在主 Agent 配 `parallelToolCalls=true` 且同轮返回多个 subagent tool call 时：\n\n- `BaseAgentRuntime` 会并行执行工具\n- `SubAgentToolExecutor` 内部也支持并发 handoff\n\n这就是你测试里“Codex 风格并行委托”能跑起来的原因。\n\n## 8. 完整示例\n\n```java\nSubAgentDefinition weather = SubAgentDefinition.builder()\n        .name(\"weather\")\n        .toolName(\"delegate_weather\")\n        .description(\"Collect weather\")\n        .agent(weatherAgent)\n        .build();\n\nSubAgentDefinition formatter = SubAgentDefinition.builder()\n        .name(\"formatter\")\n        .toolName(\"delegate_format\")\n        .description(\"Format answer\")\n        .agent(formatAgent)\n        .build();\n\nAgent lead = Agents.react()\n        .modelClient(managerClient)\n        .model(\"manager-model\")\n        .parallelToolCalls(true)\n        .subAgents(Arrays.asList(weather, formatter))\n        .handoffPolicy(HandoffPolicy.builder()\n                .maxDepth(1)\n                .maxRetries(1)\n                .timeoutMillis(15000)\n                .onError(HandoffFailureAction.FALLBACK_TO_PRIMARY)\n                .build())\n        .toolExecutor(primaryFallbackExecutor)\n        .build();\n```\n\n## 9. 为什么开源组件一定要有 Handoff Policy\n\n没有策略层，SubAgent 很容易出现：\n\n1. 无限嵌套委托\n2. 未授权工具越权调用\n3. 失败行为不一致（有时抛异常，有时沉默）\n\n`HandoffPolicy` 的价值就是把“智能协作”变成“可治理的系统能力”。\n\n## 10. 你可直接参考的测试\n\n- `SubAgentRuntimeTest`\n  - 子代理工具暴露、调用与返回格式\n- `SubAgentParallelFallbackTest`\n  - 并行子代理 + 失败回退\n- `HandoffPolicyTest`\n  - allowed/denied、retry、timeout、maxDepth、inputFilter\n\n如果你要做开源框架级能力，建议把这些测试当成行为契约。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/system-prompt-vs-instructions.md",
    "content": "﻿---\nsidebar_position: 2\n---\n\n# System Prompt 与 Instructions 的区别\n\n这两个字段经常被混用，但在可控性上差异很大。\n\n## 1. 一句话区分\n\n- `systemPrompt`：定义“你是谁、必须遵守什么规则”。\n- `instructions`：定义“这次具体要你做什么”。\n\n## 2. 作用域与稳定性\n\n| 维度 | systemPrompt | instructions |\n| --- | --- | --- |\n| 作用范围 | 整个会话/长期有效 | 当前任务/当前轮次 |\n| 稳定性 | 高（少改） | 高灵活（可频繁改） |\n| 典型内容 | 角色、风格、边界、禁令 | 输出格式、步骤要求、当前上下文 |\n\n## 3. AI4J 中的字段建模\n\nAI4J 在 `AgentBuilder` 中将二者显式分离：\n\n```java\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"doubao-seed-1-8-251228\")\n        .systemPrompt(\"你是资深 Java 架构师，回答要简洁并给可执行建议\")\n        .instructions(\"本次输出 JSON，字段: risk, proposal, nextStep\")\n        .build();\n```\n\n## 4. 在不同模型协议中的映射\n\n### ResponsesModelClient\n\n- 字段语义天然分离，推荐优先使用。\n\n### ChatModelClient\n\n- 会映射为 system 消息（先 systemPrompt，再 instructions）。\n- 语义仍可用，但边界感不如 Responses 明确。\n\n## 5. 实战写法模板\n\n### System Prompt 模板（全局）\n\n```text\n你是企业级智能助手。\n必须遵守：\n1) 不编造未验证事实；\n2) 涉及工具时先调用工具再回答；\n3) 输出先给结论，再给依据。\n```\n\n### Instructions 模板（本轮）\n\n```text\n任务：根据 queryWeather 输出今日天气建议。\n输出格式：JSON，字段 city/summary/advice。\n如果工具失败，请给出可执行的重试建议。\n```\n\n## 6. 常见误区\n\n1. 把动态上下文都塞进 systemPrompt，导致 prompt 膨胀。\n2. 每轮都重写一整套全局规则，增加 token 成本。\n3. 在 instructions 写身份设定，导致跨轮不稳定。\n\n## 7. 建议实践\n\n- 将组织级规则沉淀到 `systemPrompt` 模板文件。\n- 将任务级策略放在 `instructions`，按场景动态组装。\n- 对关键链路（金融/医疗/法务）结合 Trace 记录输入输出，便于审计。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/trace-observability.md",
    "content": "---\nsidebar_position: 9\n---\n\n# Trace 与可观测性（轻量链路追踪）\n\n这一页讲的是 `ai4j-agent` 当前已经落地的 trace 能力，不是泛泛而谈“以后可以怎么做”。\n\n重点回答四个问题：\n\n- Agent runtime 现在到底会产出哪些 trace 数据\n- `reasoning / retry / handoff / team / compact` 这些事件怎么映射到 trace\n- 内置 exporter 有哪些，`OpenTelemetry` 是怎么接进来的\n- `Agent` trace 和 `FlowGram` 前端调试视图之间是什么关系\n\n## 1. 当前 trace 组件\n\n- `AgentTraceListener`\n  - 监听 `AgentEvent`，把 runtime 事件折叠成 `TraceSpan`\n- `TraceConfig`\n  - 控制记录开关、脱敏、字段裁剪\n- `TraceSpan`\n  - 一条 span，包含基础字段、attributes、events、metrics\n- `TraceSpanEvent`\n  - span 内部事件，例如 `model.reasoning`、`model.retry`\n- `TraceMetrics`\n  - 统一挂载时延、token、cost 这些指标\n- `TracePricing` / `TracePricingResolver`\n  - 给模型 usage 做成本估算的可选配置\n- `TraceExporter`\n  - 导出接口\n- `AgentFlowTraceBridge`\n  - 监听 `AgentFlowTraceListener`，把 Dify / Coze / n8n 调用投影成统一 `TraceSpan`\n- `ConsoleTraceExporter`\n  - 打印 `TRACE {...}`\n- `InMemoryTraceExporter`\n  - 测试断言和调试采样\n- `CompositeTraceExporter`\n  - 一个 span 扇出到多个 exporter\n- `JsonlTraceExporter`\n  - 追加写入 JSONL 文件\n- `OpenTelemetryTraceExporter`\n  - 把 AI4J trace 桥接导出到 OTel pipeline\n- `LangfuseTraceExporter`\n  - 输出 Langfuse 可识别的 OTel span attributes，方便接 Langfuse\n\n## 2. 启用方式\n\n最小接法：\n\n```java\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"doubao-seed-1-8-251228\")\n        .traceConfig(TraceConfig.builder().build())\n        .traceExporter(new ConsoleTraceExporter())\n        .build();\n```\n\n`AgentBuilder` 的默认行为很简单：\n\n- 只要你设置了 `traceExporter(...)`\n- `build()` 时就会自动挂一个 `AgentTraceListener`\n- 不需要再手动注册 listener\n\n如果你要同时打控制台、内存和文件：\n\n```java\nTraceExporter exporter = new CompositeTraceExporter(\n        new ConsoleTraceExporter(),\n        new InMemoryTraceExporter(),\n        new JsonlTraceExporter(\"logs/agent-trace.jsonl\")\n);\n\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"gpt-4o-mini\")\n        .traceExporter(exporter)\n        .build();\n```\n\n## 3. `TraceSpan` 结构\n\n`TraceSpan` 当前包含：\n\n- `traceId`\n- `spanId`\n- `parentSpanId`\n- `name`\n- `type`\n- `status`\n- `startTime`\n- `endTime`\n- `error`\n- `attributes`\n- `events`\n- `metrics`\n\n`events` 里的每一项是 `TraceSpanEvent`：\n\n- `timestamp`\n- `name`\n- `attributes`\n\n`metrics` 当前包含：\n\n- `durationMillis`\n- `promptTokens`\n- `completionTokens`\n- `totalTokens`\n- `inputCost`\n- `outputCost`\n- `totalCost`\n- `currency`\n\n这意味着当前 trace 不是只有“粗粒度 span”，也支持在一个 span 内附加中间事件。\n\n## 4. Span 类型\n\n当前 `TraceSpanType` 已经不只四种：\n\n- `RUN`\n  - 一次完整 agent 调用\n- `STEP`\n  - 一轮 runtime loop\n- `MODEL`\n  - 一次模型请求\n- `TOOL`\n  - 一次工具执行\n- `HANDOFF`\n  - 一次 subagent handoff\n- `TEAM_TASK`\n  - 一条 team task 的生命周期\n- `MEMORY`\n  - 一次 memory compact / compress\n- `AGENT_FLOW`\n  - 一次外部 Dify / Coze / n8n published endpoint 调用\n- `FLOWGRAM_TASK`\n  - 给 FlowGram runtime 复用的任务级 span 类型\n- `FLOWGRAM_NODE`\n  - 给 FlowGram runtime 复用的节点级 span 类型\n\n其中：\n\n- `AGENT_FLOW`\n  - 来自 `AgentFlowTraceBridge`\n- `FLOWGRAM_TASK / FLOWGRAM_NODE`\n  - 是 trace 核心模型里的通用类型，当前 `AgentTraceListener` 本身不直接产出；FlowGram 侧走的是独立 runtime event + projection 链路\n\n## 5. 状态模型\n\n`TraceSpanStatus` 当前有三种：\n\n- `OK`\n- `ERROR`\n- `CANCELED`\n\n也就是说，trace 层现在可以明确区分：\n\n- 正常结束\n- 异常失败\n- 主动取消\n\n这对 handoff、team task、FlowGram task 都是有意义的。\n\n## 6. Agent 事件如何映射到 trace\n\n### 6.1 运行主链路\n\n- 第一次 `STEP_START`\n  - 创建 `RUN`\n- 每个 `STEP_START / STEP_END`\n  - 创建并结束 `STEP`\n- `MODEL_REQUEST`\n  - 创建 `MODEL`\n- `TOOL_CALL / TOOL_RESULT`\n  - 创建并结束 `TOOL`\n- `FINAL_OUTPUT`\n  - 结束 `RUN`\n- `ERROR`\n  - 将 `RUN` 标记为 `ERROR`\n\n### 6.2 模型中间事件\n\n`BaseAgentRuntime.executeModel(...)` 现在除了 request/response，还会发：\n\n- `MODEL_REASONING`\n- `MODEL_RETRY`\n\n这些不会额外拆成独立 span，而是挂在当前 `MODEL` span 的 `events` 上：\n\n- `model.reasoning`\n- `model.retry`\n- 流式文本增量会作为 `model.response.delta`\n\n这样做的原因是：\n\n- reasoning / retry 本质上属于同一次模型调用的内部过程\n- 单独拆 span 会让层级过碎\n- 挂成 span event 更适合做时间线与回放\n\n### 6.3 SubAgent handoff\n\n`SubAgentToolExecutor` 会发：\n\n- `HANDOFF_START`\n- `HANDOFF_END`\n\n`AgentTraceListener` 会把它们折叠成一个 `HANDOFF` span。\n\n当前 handoff payload 里常见的字段包括：\n\n- `handoffId`\n- `callId`\n- `tool`\n- `subagent`\n- `title`\n- `detail`\n- `status`\n- `depth`\n- `sessionMode`\n- `attempts`\n- `durationMillis`\n- `output`\n- `error`\n\n所以 handoff trace 既能回答“有没有委派”，也能回答：\n\n- 委派给谁\n- 第几层 handoff\n- 是完成、失败还是 fallback\n- 花了多久\n\n### 6.4 Agent Team\n\n`AgentTeamEventHook` 会发：\n\n- `TEAM_TASK_CREATED`\n- `TEAM_TASK_UPDATED`\n- `TEAM_MESSAGE`\n\n映射规则是：\n\n- task create / update\n  - 聚合成 `TEAM_TASK` span\n- team message\n  - 写入对应 `TEAM_TASK` span 的 `team.message` event\n\n这和 handoff 的区别是：\n\n- handoff 更像主 agent 把一个 tool 调用委派出去\n- team task 更像显式任务板上的任务生命周期\n\n### 6.5 Memory compact\n\n`MEMORY_COMPRESS` 现在映射为一个短生命周期 `MEMORY` span。\n\n它适合挂这些信息：\n\n- 为什么压缩\n- summary / checkpoint 标识\n- 是否 fallback\n- 压缩发生在哪个 step\n\n如果你在 Coding Agent 里看 compact 诊断，这一层语义和 agent trace 是能对齐的。\n\n### 6.6 AgentFlow 外部端点调用\n\n`AgentFlow` 本身在 `ai4j` 层只发中立 lifecycle hook：\n\n- start\n- stream event\n- complete\n- error\n\n如果你引入 `ai4j-agent`，可以通过 `AgentFlowTraceBridge` 把它桥接成 `AGENT_FLOW` span。\n\n它覆盖的不是内部 agent runtime，而是：\n\n- Dify chat/workflow\n- Coze chat/workflow\n- n8n webhook workflow\n\n默认映射策略是：\n\n- 一次外部调用 -> 一个 `AGENT_FLOW` span\n- stream 中间增量 -> span event\n- usage -> `TraceMetrics`\n- 最终 output/status/taskId/conversationId/workflowRunId -> attributes\n\n这意味着当系统里同时存在“本地 Agent runtime”与“外部托管 Agent / Workflow 调用”时，最终仍能汇总到同一套 exporter，而不是一半有观测、一半是黑盒。\n\n## 7. 默认记录策略\n\n`TraceConfig.builder().build()` 默认就是：\n\n- `recordModelInput = true`\n- `recordModelOutput = true`\n- `recordToolArgs = true`\n- `recordToolOutput = true`\n- `recordMetrics = true`\n- `maxFieldLength = 0`\n- `masker = null`\n- `pricingResolver = null`\n\n也就是默认偏“全记录”，方便本地调试和研发联调。\n\n## 8. 当前会记录哪些字段\n\n### 8.1 模型输入\n\n`MODEL` span attributes 里常见字段：\n\n- `model`\n- `systemPrompt`\n- `instructions`\n- `items`\n- `tools`\n- `toolChoice`\n- `parallelToolCalls`\n- `temperature`\n- `topP`\n- `maxOutputTokens`\n- `reasoning`\n- `store`\n- `stream`\n- `user`\n- `extraBody`\n\n### 8.2 模型输出\n\n- 最终 raw payload -> `output`\n- 流式文本增量 -> `model.response.delta` event\n- 最终回答 -> `RUN.finalOutput`\n- provider 返回的 `usage/model/finishReason` 也会被抽出来单独记录\n\n### 8.3 模型指标\n\n`MODEL` span 在 payload 带 `usage` 时，会自动补齐：\n\n- `metrics.durationMillis`\n- `metrics.promptTokens`\n- `metrics.completionTokens`\n- `metrics.totalTokens`\n\n如果你配置了 `TracePricingResolver`，还会继续估算：\n\n- `metrics.inputCost`\n- `metrics.outputCost`\n- `metrics.totalCost`\n- `metrics.currency`\n\n同时，`RUN` 和 `STEP` span 会聚合同一轮里的 token / cost，总结视角不需要你自己再扫一遍全部 `MODEL` span。\n\n### 8.4 工具调用\n\n- `tool`\n- `callId`\n- `arguments`\n\n### 8.5 工具返回\n\n- 普通工具：`output`\n- CodeAct 工具：`result/stdout/error`\n\n### 8.6 handoff / team / compact\n\n这些数据主要落在它们各自 span 的 attributes 和 events 上，不再强行塞进 `MODEL` 或 `TOOL`。\n\n## 9. 内置 exporter 的使用边界\n\n### 9.1 `ConsoleTraceExporter`\n\n适合：\n\n- 本地开发\n- 先快速看有没有请求、有没有工具、有没有 handoff\n\n不适合：\n\n- 正式存档\n- 大规模查询\n\n### 9.2 `InMemoryTraceExporter`\n\n适合：\n\n- 单元测试\n- 集成测试里断言 span 类型和字段\n\n### 9.3 `JsonlTraceExporter`\n\n适合：\n\n- 本地归档\n- 调试时导出文件给别的系统离线分析\n- 简单接 ELK / ClickHouse 导入任务\n\n### 9.4 `CompositeTraceExporter`\n\n适合：\n\n- 同时满足调试、留档、平台接入三类需求\n\n### 9.5 `OpenTelemetryTraceExporter`\n\n这是当前推荐的“平台接入桥”。\n\n它的定位不是“用 OTel 完全替代 AI4J trace 模型”，而是：\n\n- 保留 AI4J 自己的 `TraceSpan` 语义\n- 导出时把关键字段映射到 OTel span 和 attributes\n- 方便接已有的 collector / observability pipeline\n\n接法：\n\n```java\nOpenTelemetry openTelemetry = ...;\n\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"gpt-4o-mini\")\n        .traceExporter(new OpenTelemetryTraceExporter(openTelemetry))\n        .build();\n```\n\n当前导出时会写入这些关键属性：\n\n- `ai4j.trace_id`\n- `ai4j.span_id`\n- `ai4j.parent_span_id`\n- `ai4j.span_type`\n- `ai4j.span_status`\n- `ai4j.error`\n- `ai4j.attr.*`\n- `ai4j.event.*`\n- `ai4j.metrics.*`\n- `gen_ai.usage.input_tokens`\n- `gen_ai.usage.output_tokens`\n\n要注意一件事：\n\n- 当前它是“桥接 exporter”\n- 不是把 `AgentRuntime` 全部改造成原生 OTel instrumentation\n- exporter 内部会按 `parentSpanId` 做一层缓冲重排，尽量恢复父子链路，不是简单把每个 span 独立平铺出去\n\n所以如果你需要非常严格的 OTel context propagation / 原生父子链路管理，应该在更深层做原生埋点；如果你只是要接 OTel collector、再喂给 Langfuse 之类系统，这一层已经够用。\n\n### 9.6 `LangfuseTraceExporter`\n\n如果你的后端已经走 OTel pipeline，但上层想直接进 Langfuse，这是推荐接法。\n\n```java\nOpenTelemetry openTelemetry = ...;\n\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"gpt-4o-mini\")\n        .traceExporter(new LangfuseTraceExporter(openTelemetry, \"prod\", \"2026-04-03\"))\n        .build();\n```\n\n它做的事情不是直连 Langfuse 私有协议，而是：\n\n- 继续输出 OTel span\n- 额外写入 Langfuse 识别的 attributes\n- 让你可以复用现有 collector / OTLP pipeline\n\n当前会重点映射：\n\n- `langfuse.observation.type`\n- `langfuse.observation.level`\n- `langfuse.observation.input`\n- `langfuse.observation.output`\n- `langfuse.observation.model`\n- `langfuse.observation.model_parameters`\n- `langfuse.observation.usage_details`\n- `langfuse.observation.cost_details`\n- `langfuse.observation.metadata`\n- `langfuse.trace.name`\n- `langfuse.trace.output`\n- `langfuse.trace.metadata`\n\n`AGENT_FLOW` span 在 Langfuse 里会按 `chain` 语义导出，因此它更适合表现“一个已编排外部流程的调用”，而不是伪装成底层 `generation`。\n\n## 10. 脱敏与裁剪\n\n线上建议至少做两件事：\n\n1. 通过 `masker` 脱敏\n2. 通过 `maxFieldLength` 限制超长字段\n\n示例：\n\n```java\nTraceConfig config = TraceConfig.builder()\n        .maxFieldLength(4000)\n        .masker(text -> text == null\n                ? null\n                : text.replaceAll(\"(?i)api[_-]?key\\\\s*[:=]\\\\s*[^,\\\\s]+\", \"apiKey=***\"))\n        .build();\n```\n\n## 11. 与 FlowGram trace 的关系\n\nAgent trace 和 FlowGram trace 不应该混成一层。\n\n当前推荐边界是：\n\n- `Agent`\n  - 输出 `TraceSpan`\n  - 可接 `OpenTelemetryTraceExporter`\n- `FlowGram`\n  - 先产出 runtime event\n  - 再由后端投影成前端可消费的 `FlowGramTraceView`\n\n`FlowGramTraceView` 当前不只是时间线快照。\n\n在新版 starter 里，后端在返回 `report/result` 前还会补齐：\n\n- `trace.summary.metrics`\n- `trace.nodes[nodeId].metrics`\n- `workflow.nodes[nodeId].outputs.metrics`\n\n也就是说，FlowGram 前端现在可以直接拿后端 projection 看 node duration、LLM tokens 和 cost，不需要默认自己再从 `rawResponse.usage` 做一遍 client-side 解析。\n\n也就是说：\n\n- 后端平台侧可以 OTel-first\n- 但给 `FlowGram.ai` 这类前端画布时，不建议直接让前端读原始 OTel span\n- 应该读后端整理好的 trace projection\n\n## 12. 一段 trace 怎么看\n\n建议按这个顺序读：\n\n1. 先看 `RUN`\n   - 整体耗时、整体状态、最终输出\n2. 再看 `STEP`\n   - 有没有循环过多\n3. 再看 `MODEL`\n   - prompt 是否正确、reasoning/retry 发生在哪\n4. 再看 `TOOL`\n   - 调了什么工具、参数和输出是否异常\n5. 再看 `HANDOFF / TEAM_TASK / MEMORY`\n   - 问题是在委派、协作，还是在压缩点发生的\n\n## 13. 参考测试\n\n- `AgentTraceListenerTest`\n- `AgentTraceUsageTest`\n- `CodeActRuntimeWithTraceTest`\n- `AgentFlowTraceBridgeTest`\n\n## 14. 继续阅读\n\n- [Agent 核心类参考手册](/docs/agent/reference-core-classes)\n- [Flowgram API 与运行时](/docs/flowgram/api-and-runtime)\n- [引用、Trace 与前端展示](/docs/ai-basics/rag/citations-trace-and-ui-integration)\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/weather-workflow-cookbook.md",
    "content": "﻿---\nsidebar_position: 10\n---\n\n# 实战：天气分析双 Agent Workflow（可观测版）\n\n目标：\n\n1. 第一个 Agent 负责天气分析（可调用工具）\n2. 第二个 Agent 负责格式化输出（严格 JSON）\n3. 控制台打印每个节点的开始/结束状态\n\n这就是你要求的“完整可运行示例”模式。\n\n## 1. 架构图\n\n- 节点 A：`WeatherAnalysisAgent`（ChatModelClient + queryWeather）\n- 节点 B：`FormatOutputAgent`（ResponsesModelClient）\n- Workflow：`SequentialWorkflow`\n\n## 2. 为什么两个 Agent 分开更好\n\n- 分析 prompt 与格式 prompt 解耦\n- 可分别替换模型与参数\n- 排障时能明确知道卡在分析还是格式化\n\n## 3. 完整代码（与测试同风格）\n\n```java\nAgent weatherAgent = Agents.react()\n        .modelClient(new ChatModelClient(aiService.getChatService(PlatformType.DOUBAO)))\n        .model(\"doubao-seed-1-8-251228\")\n        .systemPrompt(\"You are a weather analyst. Always call queryWeather before answering.\")\n        .instructions(\"Use queryWeather with the user's location, type=now, days=1.\")\n        .toolRegistry(Arrays.asList(\"queryWeather\"), null)\n        .options(AgentOptions.builder().maxSteps(2).build())\n        .build();\n\nAgent formatAgent = Agents.react()\n        .modelClient(new ResponsesModelClient(aiService.getResponsesService(PlatformType.DOUBAO)))\n        .model(\"doubao-seed-1-8-251228\")\n        .systemPrompt(\"You format weather analysis into strict JSON.\")\n        .instructions(\"Return JSON with fields: city, summary, advice.\")\n        .options(AgentOptions.builder().maxSteps(2).build())\n        .build();\n\nSequentialWorkflow workflow = new SequentialWorkflow()\n        .addNode(new NamedNode(\"WeatherAnalysis\", new RuntimeAgentNode(weatherAgent.newSession())))\n        .addNode(new NamedNode(\"FormatOutput\", new RuntimeAgentNode(formatAgent.newSession())));\n\nWorkflowAgent runner = new WorkflowAgent(workflow, weatherAgent.newSession());\nAgentResult result = runner.run(AgentRequest.builder()\n        .input(\"Get the current weather in Beijing and provide advice.\")\n        .build());\n\nSystem.out.println(\"FINAL OUTPUT: \" + result.getOutputText());\n```\n\n## 4. 节点状态输出模板\n\n```java\nprivate static class NamedNode implements AgentNode {\n    private final String name;\n    private final AgentNode delegate;\n\n    @Override\n    public AgentResult execute(WorkflowContext context, AgentRequest request) throws Exception {\n        System.out.println(\"NODE START: \" + name);\n        try {\n            AgentResult result = delegate.execute(context, request);\n            System.out.println(\"NODE END: \" + name + \" | status=OK\");\n            return result;\n        } catch (Exception e) {\n            System.out.println(\"NODE END: \" + name + \" | status=ERROR\");\n            throw e;\n        }\n    }\n}\n```\n\n你会得到类似日志：\n\n```text\nNODE START: WeatherAnalysis\nNODE END: WeatherAnalysis | status=OK\nNODE START: FormatOutput\nNODE END: FormatOutput | status=OK\nFINAL OUTPUT: {...}\n```\n\n## 5. 输入输出传递规则\n\n`SequentialWorkflow` 默认把前一节点 `result.outputText` 作为下一节点输入：\n\n- A 输出自然语言天气分析\n- B 把该分析转换为 JSON\n\n如果你要传结构化上下文（不仅是文本），建议把字段放进 `WorkflowContext`。\n\n## 6. 常见增强项\n\n1. 在天气节点前加路由节点（是否天气问题）\n2. 在格式节点后加审校节点（Schema 校验）\n3. 给每个节点接 trace/event 监听\n4. 失败时回退到兜底格式化节点\n\n## 7. 对应测试\n\n- `WeatherAgentWorkflowTest`\n\n建议从这个测试复制模板，把 prompt 和字段名替换成你的业务语义。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agent/workflow-stategraph.md",
    "content": "﻿---\nsidebar_position: 6\n---\n\n# Workflow 与 StateGraph（顺序 / 分支 / 循环）\n\n你之前问到：\n\n- `addTransition` 和 `addEdge` 是不是一回事？\n- `addConditionalEdges` 怎么和普通边一起用？\n- `Map.of(\"weather\", \"weather\", \"generic\", \"generic\")` 到底是什么？\n\n这页按源码结构讲清楚。\n\n## 1. 先看类关系（最重要）\n\n核心类在 `io.github.lnyocly.ai4j.agent.workflow`：\n\n- `AgentWorkflow`：工作流接口\n- `WorkflowAgent`：工作流执行入口\n- `WorkflowContext`：工作流状态容器\n- `AgentNode`：节点接口\n- `RuntimeAgentNode`：把 `AgentSession` 包成节点\n- `SequentialWorkflow`：线性流程\n- `StateGraphWorkflow`：图式流程（支持分支、循环）\n- `StateRouter/StateCondition/StateTransition`：路由与边规则\n\n## 2. SequentialWorkflow（线性场景）\n\n适合 A -> B -> C 的固定流程。\n\n关键行为：\n\n- 每个节点执行后，若 `result.outputText != null`，会作为下一节点的输入。\n- 流式模式下，如果节点实现 `WorkflowResultAware`，可回读 `lastResult`。\n\n示例：\n\n```java\nSequentialWorkflow workflow = new SequentialWorkflow()\n        .addNode(new RuntimeAgentNode(agentA.newSession()))\n        .addNode(new RuntimeAgentNode(agentB.newSession()));\n\nWorkflowAgent runner = new WorkflowAgent(workflow, agentA.newSession());\nAgentResult result = runner.run(AgentRequest.builder().input(\"task\").build());\n```\n\n## 3. StateGraphWorkflow（图式场景）\n\n当你需要“条件路由、分支、循环”时，使用它。\n\n## 3.1 关键 API\n\n- `addNode(nodeId, node)`：注册节点\n- `start(nodeId)`：设置起点\n- `maxSteps(int)`：防止死循环（默认 32）\n- `addEdge(from, to)`：固定边\n- `addTransition(from, to, condition)`：带条件的普通边\n- `addConditionalEdges(from, router)`：由路由器直接决定下一节点 ID\n- `addConditionalEdges(from, router, routeMap)`：由路由键映射到节点 ID\n\n## 3.2 `addEdge` 和 `addTransition` 的关系\n\n是的，`addEdge(from, to)` 本质就是 `addTransition(from, to, null)` 的语法糖。\n\n所以你说“只保留 addEdge 能不能理解更好”是成立的：\n\n- 没有条件 -> `addEdge`\n- 有条件 -> `addTransition(..., condition)`\n\n## 3.3 `addConditionalEdges` 什么时候用\n\n当“下一跳由当前状态动态决定”时用它。\n\n典型路由：\n\n- 输入是天气问题 -> `weather`\n- 输入是通用问题 -> `generic`\n\n## 3.4 `routeMap` 是什么\n\n`routeMap` 是“路由键 -> 实际节点 ID”映射。\n\n```java\n.addConditionalEdges(\n    \"decide\",\n    (ctx, req, res) -> String.valueOf(ctx.get(\"routeKey\")),\n    Map.of(\n        \"ROUTE_WEATHER\", \"weather\",\n        \"ROUTE_GENERIC\", \"generic\"\n    )\n)\n```\n\n如果 router 已返回真实节点 ID（如 `weather/generic`），可不传 `routeMap`。\n\n## 4. 一套完整分支编排示例\n\n下面就是你项目里 `StateGraphWorkflowTest#test_state_graph_with_agents` 的同类结构：\n\n```java\nStateGraphWorkflow workflow = new StateGraphWorkflow()\n        .addNode(\"decide\", new RoutingAgentNode(router.newSession()))\n        .addNode(\"weather\", new RuntimeAgentNode(weather.newSession()))\n        .addNode(\"generic\", new RuntimeAgentNode(generic.newSession()))\n        .addNode(\"format\", new FormatNode(format.newSession()))\n        .start(\"decide\")\n        .addConditionalEdges(\"decide\", (ctx, req, res) -> String.valueOf(ctx.get(\"route\")))\n        .addEdge(\"weather\", \"format\")\n        .addEdge(\"generic\", \"format\");\n```\n\n执行语义：\n\n1. `decide` 节点把 `ctx.route` 写成 `weather/generic`\n2. 条件边根据 `route` 决定分支\n3. 分支节点执行后统一流向 `format`\n\n## 5. 循环写法（LangGraph 风格）\n\n你可以路由回自己：\n\n```java\n.addNode(\"loop\", loopNode)\n.addNode(\"done\", doneNode)\n.start(\"loop\")\n.maxSteps(10)\n.addConditionalEdges(\"loop\", (ctx, req, res) -> {\n    Integer count = (Integer) ctx.get(\"count\");\n    return count != null && count < 3 ? \"loop\" : \"done\";\n});\n```\n\n这就是典型状态图模式：同一节点可重复执行，直到状态满足退出条件。\n\n## 6. `WorkflowContext` 里都放什么\n\n建议约定这些 key：\n\n- `route`：路由结果\n- `lastNodeId`：上一个节点\n- `lastResult`：上一步输出\n- `currentNodeId/currentRequest`：当前执行态\n\n你也可以放业务字段：\n\n- `retryCount`\n- `city`\n- `riskLevel`\n\n## 7. 如何观测每个节点状态\n\n最简单做法：包装一个 `NamedNode`，在 `execute()` 里打印：\n\n- `NODE START: <name>`\n- `NODE END: <name> | status=OK/ERROR`\n\n你的天气 workflow 测试已经这么做了，排障体验会明显更好。\n\n## 8. 常见设计误区\n\n1. 只用条件边，不设置 `maxSteps`，容易循环失控。\n2. router 返回业务键，但忘了 routeMap 映射，导致找不到节点。\n3. 路由节点和执行节点耦合太深，后续难扩展。\n4. 节点直接共享可变对象，导致并发场景状态污染。\n\n## 9. 与 LangGraph 思想的对应\n\nAI4J 当前 StateGraph 是轻量 Java 实现，核心思想对齐：\n\n- 节点（Node）\n- 边（Edge）\n- 路由（Router）\n- 状态（State）\n\n同样支持：\n\n- 顺序\n- 分支\n- 循环\n\n## 10. 对应测试\n\n- `StateGraphWorkflowTest#test_branching_route`\n- `StateGraphWorkflowTest#test_loop_route`\n- `StateGraphWorkflowTest#test_state_graph_with_agents`\n- `WeatherAgentWorkflowTest`\n\n建议你直接从这些测试复制骨架改业务字段，最快落地。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/_category_.json",
    "content": "﻿{\n  \"label\": \"AI基础能力接入\",\n  \"position\": 3,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/chat/_category_.json",
    "content": "﻿{\n  \"label\": \"Chat\",\n  \"position\": 2,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/chat/multimodal.md",
    "content": "﻿---\nsidebar_position: 13\n---\n\n# Chat / 多模态（Vision）\n\nai4j 在 Chat 链路中支持文本 + 图片输入，核心对象是 `Content`。\n\n## 1. 快速示例\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\n                \"请描述图片中的场景并识别主要对象\",\n                \"https://example.com/demo.jpg\"\n        ))\n        .build();\n\nChatCompletionResponse response = chatService.chatCompletion(request);\nSystem.out.println(response.getChoices().get(0).getMessage().getContent().getText());\n```\n\n## 2. 底层结构\n\n`ChatMessage.content` 使用 `Content` 封装：\n\n- 纯文本：`Content.ofText(\"...\")`\n- 多模态：`Content.ofMultiModals(List<MultiModal>)`\n\n`MultiModal` 支持：\n\n- `type=text` + `text`\n- `type=image_url` + `imageUrl.url`\n\n## 3. 手工构造多模态片段\n\n```java\nList<Content.MultiModal> parts = new ArrayList<>();\nparts.add(Content.MultiModal.builder()\n        .type(\"text\")\n        .text(\"比较这两张图片的差异\")\n        .build());\nparts.add(Content.MultiModal.builder()\n        .type(\"image_url\")\n        .imageUrl(new Content.MultiModal.ImageUrl(\"https://example.com/a.png\"))\n        .build());\nparts.add(Content.MultiModal.builder()\n        .type(\"image_url\")\n        .imageUrl(new Content.MultiModal.ImageUrl(\"https://example.com/b.png\"))\n        .build());\n\nChatMessage user = ChatMessage.builder()\n        .role(\"user\")\n        .content(Content.ofMultiModals(parts))\n        .build();\n```\n\n## 4. URL 与 Base64\n\n`image_url` 的 `url` 字段既可放网络地址，也可放 base64 data URL。\n\n工程建议：\n\n- 开发调试先用 URL\n- 生产场景可按安全策略改成对象存储签名 URL\n- 大图建议先压缩，减少请求体体积\n\n## 5. 多模态 + 工具调用\n\n多模态请求同样可带工具：\n\n```java\nChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请识别图片是否下雨并查询当地天气\", imageUrl))\n        .functions(\"queryWeather\")\n        .build();\n```\n\n建议在 system/instructions 里明确：\n\n- 先做图像判断\n- 再决定是否调用工具\n\n## 6. 常见问题\n\n### 6.1 模型无视觉输出\n\n- 模型本身是否支持视觉\n- 图片链接是否可公网访问\n- 图片格式/大小是否超限\n\n### 6.2 回答偏离图像内容\n\n- 提示词加约束：仅基于图像可见信息回答\n- 减少无关历史消息\n- 必要时强制输出结构化字段（对象、动作、场景）\n\n## 7. 最佳实践\n\n- 视觉任务优先短 prompt，避免过度引导。\n- 关键业务场景做“模型回归样本库”。\n- 图像识别结果要做二次校验（特别是高风险场景）。\n\n如果你要做“图片生成”而不是“图片理解”，请看 `Image 服务` 页面。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/chat/non-stream.md",
    "content": "﻿---\nsidebar_position: 10\n---\n\n# Chat（非流式）\n\n本页聚焦 `IChatService#chatCompletion(...)` 的标准调用路径。\n\n## 1. 核心对象\n\n- 请求：`ChatCompletion`\n- 消息：`ChatMessage`\n- 响应：`ChatCompletionResponse`\n\n最常用构造方式：\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withSystem(\"你是一个简洁的 Java 助手\"))\n        .message(ChatMessage.withUser(\"用 3 点解释线程池拒绝策略\"))\n        .build();\n```\n\n## 2. 最小示例\n\n```java\nIChatService chatService = aiService.getChatService(PlatformType.OPENAI);\nChatCompletionResponse response = chatService.chatCompletion(request);\n\nString text = response.getChoices().get(0).getMessage().getContent().getText();\nSystem.out.println(text);\n```\n\n## 3. 常用参数\n\n`ChatCompletion` 关键参数：\n\n- `model`：模型 ID\n- `messages`：对话消息\n- `temperature` / `topP`：采样控制\n- `maxCompletionTokens`：输出上限\n- `responseFormat`：结构化输出（例如 json_object）\n- `user`：用户标识\n- `extraBody`：平台扩展参数\n\n## 4. 多轮对话写法\n\n```java\nList<ChatMessage> history = new ArrayList<>();\nhistory.add(ChatMessage.withSystem(\"你是代码审查助手\"));\nhistory.add(ChatMessage.withUser(\"请审查这段 SQL\"));\nhistory.add(ChatMessage.withAssistant(\"请把 SQL 发我\"));\nhistory.add(ChatMessage.withUser(\"select * from user where id = ?\"));\n\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .messages(history)\n        .build();\n```\n\n## 5. 平台覆写参数\n\n如果你要传某平台特有字段，用 `extraBody`：\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"hello\"))\n        .extraBody(\"seed\", 2026)\n        .extraBody(\"custom_flag\", true)\n        .build();\n```\n\n## 6. 错误处理建议\n\n- SDK 层已集成统一错误拦截器（starter 默认启用 `ErrorInterceptor`）。\n- 业务层建议封装统一异常：`AI_TIMEOUT`、`AI_RATE_LIMIT`、`AI_INVALID_REQUEST`。\n\n## 7. 质量基线测试\n\n建议在集成测试中至少断言：\n\n- `response != null`\n- `choices` 非空\n- 最终文本非空\n- token usage（如果开启）可读\n\n## 8. 何时不适合用非流式\n\n以下场景建议切流式：\n\n- 输出较长，用户等待感强\n- 需要实时展示中间结果\n- 需要尽早判断是否中止请求\n\n下一页：`Chat（流式）`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/chat/stream.md",
    "content": "﻿---\nsidebar_position: 11\n---\n\n# Chat（流式）\n\n本页聚焦 `IChatService#chatCompletionStream(...)` 与 `SseListener` 的使用细节。\n\n## 1. 核心结论\n\n- Chat 流式是 SSE 增量输出。\n- 监听器里最常用字段是 `getCurrStr()`。\n- 最终聚合内容可从 `getOutput()` 读取。\n\n## 2. 最小示例\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请分 5 点介绍 JVM 内存模型\"))\n        .stream(true)\n        .streamOptions(new StreamOptions(true))\n        .build();\n\nSseListener listener = new SseListener() {\n    @Override\n    protected void send() {\n        if (!getCurrStr().isEmpty()) {\n            System.out.print(getCurrStr());\n        }\n    }\n};\n\nchatService.chatCompletionStream(request, listener);\nSystem.out.println(\"\\nstream finished\");\n```\n\n## 3. `SseListener` 重要字段\n\n- `getCurrStr()`：当前增量（文本或工具参数片段）\n- `getCurrData()`：当前完整 SSE 数据包原文\n- `getOutput()`：累计输出\n- `getToolCalls()`：累计工具调用\n- `getUsage()`：token 统计（需 `stream_options.include_usage=true`）\n- `getFinishReason()`：结束原因（`stop` / `tool_calls` 等）\n\n## 4. 如何区分文本与工具参数增量\n\n在 `SseListener` 中：\n\n- 常规文本：`currToolName` 为空且 `currStr` 为文本\n- 工具参数：`currToolName` 不为空，`currStr` 可能是 JSON 参数片段\n\n如果你只想打印文本，可在 `send()` 中加过滤逻辑。\n\n## 5. 为什么看起来不是 token 级输出\n\nSSE 分片粒度由平台决定，不一定“一个 token 一次回调”。\n你可能看到按“字、词、短句、整段”输出，都是正常行为。\n\n## 6. 流式 + 工具调用时的行为\n\n当模型返回 `tool_calls` 时，Chat 服务实现会：\n\n1. 收集工具调用参数\n2. 调用 `ToolUtil.invoke(...)`\n3. 回填 `tool` 消息\n4. 自动继续下一轮模型请求\n\n也就是“模型 -> 工具 -> 模型”的闭环是内置完成的。\n\n## 7. 常见问题\n\n### 7.1 只看到最终结果\n\n- 检查是否在 `send()` 里打印 `getCurrStr()`。\n- 不要只在结尾打印 `getOutput()`。\n\n### 7.2 流式不结束\n\n- 检查网络层是否被代理/网关中断。\n- 检查监听器是否因为异常提前退出。\n\n### 7.3 token usage 始终是 0\n\n- 确认请求中包含 `stream_options.include_usage=true`。\n\n## 8. 生产建议\n\n- 给每次流式请求绑定 `requestId` 与用户标识。\n- 对输出做长度上限，避免超长回包压垮前端。\n- 前端支持“停止生成”按钮并联动取消请求。\n\n下一页建议阅读：`Chat / Function Call 与 Tool 注册`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/chat/tool-calling.md",
    "content": "﻿---\nsidebar_position: 12\n---\n\n# Chat / Function Call 与 Tool 注册\n\n本页说明两件事：\n\n1. 怎么把 Java 方法注册成模型可调用工具\n2. Chat 链路如何自动执行工具并回填结果\n\n## 1. 工具注解体系\n\nai4j 内置三类注解：\n\n- `@FunctionCall`：定义工具名和描述\n- `@FunctionRequest`：定义入参类\n- `@FunctionParameter`：定义参数描述/必填\n\n示例（简化版）：\n\n```java\n@FunctionCall(name = \"queryWeather\", description = \"查询天气\")\npublic class QueryWeatherFunction implements Function<QueryWeatherFunction.Request, String> {\n\n    @Data\n    @FunctionRequest\n    public static class Request {\n        @FunctionParameter(description = \"城市名\", required = true)\n        private String location;\n\n        @FunctionParameter(description = \"类型: now/daily/hourly\")\n        private String type;\n\n        @FunctionParameter(description = \"天数\")\n        private int days;\n    }\n\n    @Override\n    public String apply(Request request) {\n        return \"...\";\n    }\n}\n```\n\n## 2. 在请求里暴露工具\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"查询北京天气并给出建议\"))\n        .functions(\"queryWeather\")\n        .build();\n```\n\n关键点：\n\n- `functions(...)` 是显式白名单。\n- 不传就不暴露（避免权限过宽）。\n\n## 3. MCP 工具暴露\n\n除了本地 Function，还可以暴露 MCP 服务工具：\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请读取我的仓库 issue\"))\n        .mcpServices(\"github-service\")\n        .build();\n```\n\n底层走 `ToolUtil.getAllTools(functionList, mcpServerIds)`。\n\n## 4. 工具暴露语义（当前行为）\n\n`ToolUtil.getAllTools(...)` 的语义是：\n\n- 传什么，暴露什么\n- 不会自动把全部本地 MCP 工具塞给模型\n\n这点对安全非常关键。\n\n## 5. Chat 自动工具循环\n\n在 `OpenAiChatService` 等实现里，工具循环是自动的：\n\n1. 模型返回 `finish_reason=tool_calls`\n2. SDK 执行每个 tool call（`ToolUtil.invoke`）\n3. 追加 `assistant(tool_calls)` + `tool(result)` 消息\n4. 再次请求模型\n5. 直到 `finish_reason=stop`\n\n你不需要手写循环控制器。\n\n## 6. 并行工具调用\n\n`ChatCompletion.parallelToolCalls` 控制并行语义。\n\n建议：\n\n- 写操作工具默认关闭并行\n- 查询类工具可开启并行\n\n## 7. 常见问题\n\n### 7.1 模型不触发工具\n\n- `functions(...)` 是否传了正确名称\n- 工具描述是否足够明确\n- 用户指令是否明确“先调用工具再回答”\n\n### 7.2 工具参数解析异常\n\n- 确认 `@FunctionRequest` 入参字段名与模型返回 JSON 一致\n- 参数枚举/类型边界要写清楚\n\n### 7.3 工具结果太长\n\n- 先在工具层做裁剪/摘要\n- 避免将超长原始 JSON 全量回填\n\n## 8. 与 Agent 的关系\n\nAgent 的 `toolRegistry(...)` 本质也是在构建这层“工具白名单”，\n只是把调用策略从 Chat 服务层升级到 Agent runtime 层。\n\n如果业务变复杂（多步推理/路由/循环），建议迁移到 Agent。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/enhancements/_category_.json",
    "content": "﻿{\n  \"label\": \"增强能力\",\n  \"position\": 5,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/enhancements/pinecone-rag-workflow.md",
    "content": "---\nsidebar_position: 41\n---\n\n# Pinecone RAG 工作流\n\n如果你已经确定底层向量库存储用 Pinecone，当前推荐的主线不是再手写一整套 Pinecone 专用流程，而是：\n\n1. `IngestionPipeline` 统一入库\n2. `PineconeVectorStore` 作为底层存储\n3. `RagService` / `Retriever` 负责查询\n4. 可选 `ModelReranker` 做精排\n\n---\n\n## 1. 推荐核心类\n\n- `VectorStore`\n- `PineconeVectorStore`\n- `IngestionPipeline`\n- `IngestionRequest`\n- `IngestionSource`\n- `RagService`\n- `RagQuery`\n- `DenseRetriever`\n- `ModelReranker`\n\n---\n\n## 2. 配置 Pinecone\n\n```java\nPineconeConfig pineconeConfig = new PineconeConfig();\npineconeConfig.setHost(\"https://<index-host>\");\npineconeConfig.setKey(System.getenv(\"PINECONE_API_KEY\"));\n\nconfiguration.setPineconeConfig(pineconeConfig);\nVectorStore vectorStore = aiService.getPineconeVectorStore();\n```\n\n---\n\n## 3. 入库流程\n\n### 3.1 推荐：直接使用 `IngestionPipeline`\n\n```java\nVectorStore vectorStore = aiService.getPineconeVectorStore();\n\nIngestionPipeline ingestionPipeline = aiService.getPineconeIngestionPipeline(PlatformType.OPENAI);\n\nIngestionResult ingestResult = ingestionPipeline.ingest(IngestionRequest.builder()\n        .dataset(\"tenant_a_contract_v202603\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .document(RagDocument.builder()\n                .sourceName(\"合同模板\")\n                .sourcePath(\"/docs/contract-template.pdf\")\n                .tenant(\"tenant_a\")\n                .biz(\"legal\")\n                .version(\"2026.03\")\n                .build())\n        .source(IngestionSource.file(new File(\"D:/data/contract-template.pdf\")))\n        .build());\n\nSystem.out.println(\"upserted=\" + ingestResult.getUpsertedCount());\n```\n\n---\n\n## 4. 查询流程\n\n```java\nRagService ragService = aiService.getRagService(\n        PlatformType.OPENAI,\n        vectorStore\n);\n\nRagQuery ragQuery = RagQuery.builder()\n        .query(\"违约金怎么算\")\n        .dataset(\"tenant_a_contract_v202603\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .topK(5)\n        .build();\n\nRagResult ragResult = ragService.search(ragQuery);\nString context = ragResult.getContext();\nSystem.out.println(ragResult.getCitations());\n```\n\n---\n\n## 5. 如需更高精度，再接 Rerank\n\n```java\nReranker reranker = aiService.getModelReranker(\n        PlatformType.JINA,\n        \"jina-reranker-v2-base-multilingual\",\n        5,\n        \"优先合同原文、章节标题和编号明确的条款\"\n);\n\nRagService ragService = new DefaultRagService(\n        new DenseRetriever(\n                aiService.getEmbeddingService(PlatformType.OPENAI),\n                vectorStore\n        ),\n        reranker,\n        new DefaultRagContextAssembler()\n);\n```\n\n---\n\n## 6. 参数建议\n\n- `dataset`：建议直接编码 tenant / biz / version\n- `topK`：先从 `3~8` 调优\n- `chunkSize`：先从 `600~1200` 试\n- `chunkOverlap`：先从 `10%~25%` 试\n\n---\n\n## 7. 生产实践\n\n- metadata 至少保留 `content/source/title/version/updatedAt`\n- embedding 模型固定，不要混维度\n- 文档更新要有索引重建策略\n- 召回时优先加 metadata 过滤，不要只靠向量相似度\n- 如果只是做通用 RAG，不要把业务逻辑直接写死在已废弃的 `PineconeService` 上\n- 统一优先面向 `VectorStore / IngestionPipeline / RagService`\n\n---\n\n## 8. 常见问题\n\n### 8.1 召回为空\n\n- dataset 错\n- 向量维度不一致\n- 数据没有成功 upsert\n\n### 8.2 召回有内容但回答不准\n\n- 分块策略不合理\n- `topK` 不合适\n- prompt 没限制“必须基于证据”\n\n### 8.3 成本过高\n\n- 批量 embedding 做缓存\n- 去重后再向量化\n- 定期清理低价值文档\n\n### 8.4 什么时候还需要直接用已废弃的 `PineconeService`（Deprecated）\n\n`PineconeService` 目前在文档层已视为 Deprecated。只有在你明确需要 Pinecone 特有底层能力时，才建议继续直接使用：\n\n- namespace 级专用操作\n- 已有旧项目已经大量使用 `PineconeQuery / PineconeDelete`\n- 你在封装 Pinecone 专用管理能力，而不是统一 RAG 能力\n\n---\n\n## 9. 继续阅读\n\n1. [RAG 与知识库增强总览](/docs/ai-basics/rag/overview)\n2. [RAG 架构、分块与索引设计](/docs/ai-basics/rag/architecture-and-indexing)\n3. [Ingestion Pipeline 文档入库流水线](/docs/ai-basics/rag/ingestion-pipeline)\n4. [Embedding 接口](/docs/ai-basics/services/embedding)\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/enhancements/searxng-enhancement.md",
    "content": "﻿---\nsidebar_position: 40\n---\n\n# SearXNG 联网增强（Chat Decorator）\n\nai4j 提供了一个非常实用的增强模式：把任意 `IChatService` 包装成“先检索再回答”。\n\n核心类：`ChatWithWebSearchEnhance`。\n\n## 1. 设计思路\n\n```text\n用户问题\n  -> SearXNG 检索\n  -> 结果截断/拼接\n  -> 注入到最后一条 user message\n  -> 调用原始 Chat 服务\n```\n\n你不需要改业务控制器，只要替换服务实例。\n\n## 2. 配置\n\n### 2.1 非 Spring\n\n```java\nSearXNGConfig searXNGConfig = new SearXNGConfig();\nsearXNGConfig.setUrl(\"http://127.0.0.1:8080/search\");\nsearXNGConfig.setEngines(\"duckduckgo,google,bing\");\nsearXNGConfig.setNums(5);\n\nconfiguration.setSearXNGConfig(searXNGConfig);\n```\n\n### 2.2 Spring\n\n```yaml\nai:\n  websearch:\n    searxng:\n      url: http://127.0.0.1:8080/search\n      engines: duckduckgo,google,bing\n      nums: 5\n```\n\n## 3. 使用方式\n\n```java\nIChatService rawChat = aiService.getChatService(PlatformType.OPENAI);\nIChatService webEnhanced = aiService.webSearchEnhance(rawChat);\n\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请总结今天 AI Agent 领域的重要动态\"))\n        .build();\n\nChatCompletionResponse response = webEnhanced.chatCompletion(request);\n```\n\n## 4. 流式同样生效\n\n`chatCompletionStream(...)` 会走同样的检索注入逻辑。\n\n## 5. 检索上下文注入策略\n\n当前实现会把检索结果拼接到最后一条用户消息中，包含：\n\n- 网络资料\n- 用户问题\n- 回答格式要求\n\n如果你要更严格控制，可自定义一个装饰器实现。\n\n## 6. 关键参数建议\n\n- `nums`：3~8（太大会污染上下文）\n- `engines`：先从 2~4 个开始\n- query 复写策略：必要时先做 query rewrite\n\n## 7. 与 RAG 的组合\n\n推荐混合顺序：\n\n1. 私域检索（Pinecone）\n2. 不足时补充 SearXNG\n3. 合并证据后回答\n\n这样兼顾准确性（私域）与时效性（公网）。\n\n## 8. 安全建议\n\n- 对检索文本做清洗，去除 prompt 注入片段\n- 对来源域名做白名单过滤\n- 高风险问题加人工复核\n\n## 9. 常见问题\n\n### 9.1 `SearXNG url is not configured`\n\n说明 `SearXNGConfig.url` 为空。\n\n### 9.2 回答质量下降\n\n- 检索结果噪声大\n- `nums` 太高\n- 注入文本过长\n\n### 9.3 响应慢\n\n- 搜索引擎过多\n- SearXNG 服务器性能不足\n- 网络链路不稳定\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/enhancements/spi-http-stack.md",
    "content": "﻿---\nsidebar_position: 42\n---\n\n# SPI：Dispatcher 与 ConnectionPool\n\nai4j 的网络层扩展点通过 SPI 提供，目的是让你按业务并发模型定制 OkHttp。\n\n## 1. 扩展接口\n\n- `DispatcherProvider`\n- `ConnectionPoolProvider`\n\n默认实现：\n\n- `DefaultDispatcherProvider`\n- `DefaultConnectionPoolProvider`\n\n## 2. 为什么有必要\n\n不同业务并发模式差异很大：\n\n- 流式对话：长连接多，吞吐要求高\n- 批处理：短请求多，峰值并发高\n- 多租户：需要隔离/限速\n\n如果统一一个默认参数，往往在生产会踩坑。\n\n## 3. 自定义实现示例\n\n### 3.1 Dispatcher\n\n```java\npublic class CustomDispatcherProvider implements DispatcherProvider {\n    @Override\n    public Dispatcher getDispatcher() {\n        Dispatcher dispatcher = new Dispatcher();\n        dispatcher.setMaxRequests(256);\n        dispatcher.setMaxRequestsPerHost(64);\n        return dispatcher;\n    }\n}\n```\n\n### 3.2 ConnectionPool\n\n```java\npublic class CustomConnectionPoolProvider implements ConnectionPoolProvider {\n    @Override\n    public ConnectionPool getConnectionPool() {\n        return new ConnectionPool(100, 5, TimeUnit.MINUTES);\n    }\n}\n```\n\n## 4. SPI 注册\n\n`src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.DispatcherProvider`\n\n```text\ncom.example.CustomDispatcherProvider\n```\n\n`src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.ConnectionPoolProvider`\n\n```text\ncom.example.CustomConnectionPoolProvider\n```\n\n## 5. Spring Boot 自动装配路径\n\nstarter 在 `AiConfigAutoConfiguration` 里通过 `ServiceLoaderUtil.load(...)` 加载 SPI 并写入 `OkHttpClient.Builder`。\n\n这意味着你只要把 SPI 放到 classpath，就会自动生效。\n\n## 6. 非 Spring 用法\n\n```java\nDispatcherProvider dispatcherProvider = ServiceLoaderUtil.load(DispatcherProvider.class);\nConnectionPoolProvider poolProvider = ServiceLoaderUtil.load(ConnectionPoolProvider.class);\n\nOkHttpClient client = new OkHttpClient.Builder()\n        .dispatcher(dispatcherProvider.getDispatcher())\n        .connectionPool(poolProvider.getConnectionPool())\n        .build();\n```\n\n## 7. 调优建议\n\n先压测再调参，不要拍脑袋：\n\n- `maxRequests`\n- `maxRequestsPerHost`\n- 连接池大小\n- keepAlive 时长\n\n并结合上游平台的限流规则。\n\n## 8. 常见问题\n\n### 8.1 SPI 不生效\n\n- `META-INF/services` 文件名写错\n- 实现类全限定名写错\n- 资源没有打包进 jar\n\n### 8.2 多实现冲突\n\n当前 `ServiceLoaderUtil` 取第一个实现，建议每个应用只保留一套 SPI 实现。\n\n### 8.3 调大并发后错误更多\n\n通常是上游限流触发，建议联动：\n\n- 请求限流\n- 指数退避重试\n- 熔断降级\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/overview.md",
    "content": "﻿---\nsidebar_position: 1\n---\n\n# AI基础能力接入总览\n\n本章是 ai4j 的“基础接入层”，与 `MCP`、`Agent` 同级。\n\n目标很明确：\n\n- 先把模型能力稳定接入（平台、协议、同步/流式）；\n- 再逐步增加联网、检索、网络栈调优这些增强能力；\n- 最后再上 MCP 和 Agent 编排。\n\n## 1. 为什么单独成章\n\n和 `MCP`、`Agent` 一样，AI 基础能力本身就是独立系统层：\n\n- 上接业务 API\n- 下接模型平台\n- 中间负责协议统一、参数兼容、错误与流式处理\n\n如果这层不清晰，后续 MCP/Agent 也很难稳定。\n\n## 2. 本章结构\n\n### 2.1 平台与协议层\n\n- 平台适配与统一接口\n- Chat vs Responses 选型\n\n### 2.2 Chat 接入\n\n- 非流式\n- 流式\n- Function Call\n- 多模态\n\n### 2.3 Responses 接入\n\n- 非流式\n- 流式事件模型\n\n### 2.4 其它基础服务\n\n- Embedding\n- Audio\n- Image\n- Realtime\n\n### 2.5 增强能力（仍属基础接入层）\n\n- 联网增强（SearXNG）\n- 向量检索工作流（Pinecone）\n- 网络扩展（SPI: Dispatcher / ConnectionPool）\n\n## 3. “联网增强/向量检索/网络扩展”放这里合适吗？\n\n合适，原因如下：\n\n1. **联网增强**：本质是对 Chat 请求的输入增强，不属于 MCP 或 Agent 专属。\n2. **向量检索**：本质是 Embedding + 向量库接入，属于模型输入构造层。\n3. **网络扩展**：本质是 SDK 网络栈能力（OkHttp 并发/连接池），属于基础设施层。\n\n它们确实是“基础接入增强能力”，不是“高级编排能力”。\n\n## 4. 推荐阅读顺序\n\n1. 平台适配与统一接口\n2. Chat（非流式 -> 流式 -> Tool/多模态）\n3. Responses（非流式 -> 事件流）\n4. Embedding/Audio/Image/Realtime\n5. 增强能力（SearXNG、Pinecone、SPI）\n\n## 5. 对应核心代码\n\n- 服务工厂：`ai4j/src/main/java/io/github/lnyocly/ai4j/service/factor/AiService.java`\n- 平台枚举：`ai4j/src/main/java/io/github/lnyocly/ai4j/service/PlatformType.java`\n- 统一配置：`ai4j/src/main/java/io/github/lnyocly/ai4j/service/Configuration.java`\n\n后续每页都围绕这些入口展开，保持“能直接落地”的粒度。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/platform-adaptation.md",
    "content": "﻿---\nsidebar_position: 2\n---\n\n# 平台适配与统一接口\n\n这一页对应 ai4j 的核心价值：**跨平台协议消歧**。\n\n## 1. 统一入口\n\n你只需要面对统一服务接口，不直接依赖某家平台 SDK。\n\n```java\nAiService aiService = new AiService(configuration);\n\nIChatService chat = aiService.getChatService(PlatformType.OPENAI);\nIResponsesService responses = aiService.getResponsesService(PlatformType.DOUBAO);\nIEmbeddingService embedding = aiService.getEmbeddingService(PlatformType.OLLAMA);\nIImageService image = aiService.getImageService(PlatformType.DOUBAO);\n```\n\n如果某平台不支持该服务，`AiService` 会抛出 `IllegalArgumentException`。\n\n## 2. 平台枚举（PlatformType）\n\n- `OPENAI`\n- `ZHIPU`\n- `DEEPSEEK`\n- `MOONSHOT`\n- `HUNYUAN`\n- `LINGYI`\n- `OLLAMA`\n- `MINIMAX`\n- `BAICHUAN`\n- `DASHSCOPE`\n- `DOUBAO`\n\n## 3. 服务能力矩阵（当前实现）\n\n| 平台 | Chat | Responses | Embedding | Audio | Realtime | Image |\n| --- | --- | --- | --- | --- | --- | --- |\n| OPENAI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| DOUBAO | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |\n| DASHSCOPE | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |\n| OLLAMA | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |\n| ZHIPU | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| DEEPSEEK | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| MOONSHOT | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| HUNYUAN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| LINGYI | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| MINIMAX | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| BAICHUAN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n\n## 4. Spring Boot 配置前缀\n\n`ai4j-spring-boot-starter` 常用前缀：\n\n- `ai.openai.*`\n- `ai.doubao.*`\n- `ai.dashscope.*`\n- `ai.ollama.*`\n- `ai.zhipu.*`\n- `ai.deepseek.*`\n- `ai.moonshot.*`\n- `ai.hunyuan.*`\n- `ai.lingyi.*`\n- `ai.minimax.*`\n- `ai.baichuan.*`\n- 通用网络配置：`ai.okhttp.*`\n\n## 5. 多实例路由（AiServiceRegistry）\n\n如果你希望按租户/业务 id 动态路由模型实例，优先使用 `AiServiceRegistry`。`FreeAiService` 仍保留兼容静态方法。\n\n```java\nIChatService tenantA = aiServiceRegistry.getChatService(\"tenant-a-openai\");\nIChatService tenantB = aiServiceRegistry.getChatService(\"tenant-b-doubao\");\n\n// 兼容旧用法\nIChatService legacy = FreeAiService.getChatService(\"tenant-a-openai\");\n```\n\n适合：多租户、灰度、A/B 测试。\n\n## 6. 工程建议\n\n- 业务层只依赖接口（`IChatService` 等）\n- 平台选择逻辑放在配置或工厂层\n- 统一日志字段：`platform/service/model/traceId`\n\n这样后续切模型或切平台时，业务改动最小。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/responses/_category_.json",
    "content": "﻿{\n  \"label\": \"Responses\",\n  \"position\": 3,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/responses/chat-vs-responses.md",
    "content": "﻿---\nsidebar_position: 22\n---\n\n# Chat vs Responses 选型\n\n这页给一个工程化结论，而不是概念对比。\n\n## 1. 快速结论\n\n- 追求兼容存量、迁移平滑：优先 **Chat**。\n- 追求事件结构化、可观测性、Agent runtime：优先 **Responses**。\n\n## 2. 对比维度\n\n| 维度 | Chat | Responses |\n| --- | --- | --- |\n| 存量兼容 | 高 | 中 |\n| 文本直出易用性 | 高 | 中 |\n| 事件结构化 | 中 | 高 |\n| 推理/函数参数事件可观测 | 中 | 高 |\n| 迁移成本 | 低 | 中 |\n| 适合作为 Agent 底层 | 中 | 高 |\n\n## 3. Chat 更合适的场景\n\n- 你已有大量 `chatCompletion` 代码\n- 需求是“稳定文本回答 + 工具调用”\n- 团队优先低改造成本\n\n## 4. Responses 更合适的场景\n\n- 你要做 trace/审计/事件回放\n- 你要区分 reasoning、message、function arguments\n- 你要构建新的 agent runtime / workflow\n\n## 5. 关于“流式是否 token 级”\n\n两个接口都不能保证 token 级分片。\n\n- Chat 常见更细粒度文本片段\n- Responses 常见事件片段（可能按句）\n\n这不是 SDK 错误，而是上游流式切片策略。\n\n## 6. 推荐迁移路径\n\n### 阶段 1\n\n先保留 Chat 作为主链路，补齐：\n\n- 统一工具注册\n- 统一日志字段\n- 流式回调规范\n\n### 阶段 2\n\n在新业务/新 Agent 中优先 Responses：\n\n- 基于事件做 trace\n- 把工具循环放到 runtime\n\n### 阶段 3\n\n双栈共存，按场景路由：\n\n- 普通问答 -> Chat\n- 智能体编排 -> Responses\n\n## 7. 一个实践建议\n\n不要做“全量切换”。\n\n最优雅做法是：\n\n- 抽象统一的 `ModelClient` 接口\n- Chat/Responses 作为实现\n- 在 AgentBuilder 或业务配置中按场景注入\n\n这样切换成本最低，也最符合开源组件可扩展性。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/responses/non-stream.md",
    "content": "﻿---\nsidebar_position: 20\n---\n\n# Responses（非流式）\n\n`Responses API` 适合事件化语义更强的场景。本页先讲非流式。\n\n## 1. 核心对象\n\n- 服务接口：`IResponsesService`\n- 请求：`ResponseRequest`\n- 响应：`Response`\n\n## 2. 支持平台\n\n当前 `AiService#getResponsesService(...)` 支持：\n\n- `OPENAI`\n- `DOUBAO`\n- `DASHSCOPE`\n\n## 3. 最小示例\n\n```java\nIResponsesService responsesService = aiService.getResponsesService(PlatformType.DOUBAO);\n\nResponseRequest request = ResponseRequest.builder()\n        .model(\"doubao-seed-1-8-251228\")\n        .input(\"请用一句话介绍 Responses API\")\n        .instructions(\"用中文输出\")\n        .maxOutputTokens(256)\n        .build();\n\nResponse response = responsesService.create(request);\nSystem.out.println(response);\n```\n\n## 4. 常用字段\n\n`ResponseRequest` 常用参数：\n\n- `model`\n- `input`（可字符串，也可结构化对象）\n- `instructions`\n- `reasoning`\n- `tools`\n- `toolChoice`\n- `parallelToolCalls`\n- `maxOutputTokens`\n- `temperature`\n- `topP`\n- `metadata`\n- `extraBody`\n\n## 5. 与 Chat 非流式的差异\n\n- Chat 响应主路径是 `choices[0].message`\n- Responses 响应主路径是 `output`（可含 message/reasoning/function_call 等 item）\n\n如果你要拿最终文本，通常需要从 `response.output` 中提取 `message` item 的 `output_text`。\n\n## 6. OpenAI 请求体字段收敛说明\n\n在 `OpenAiResponsesService` 中，SDK 会对请求体字段做白名单收敛。\n\n含义：\n\n- 只有协议允许字段会被发送\n- `extraBody` 中不在白名单的字段会被忽略\n\n这能减少无效字段导致的请求失败。\n\n## 7. 非流式适用场景\n\n- 你只关心最终结果，不关心中间事件\n- 你希望简化回调处理逻辑\n- 批量离线任务（摘要、改写、分类）\n\n## 8. 常见问题\n\n### 8.1 返回对象有内容但你看不到文本\n\n`Response` 不是单一 `content` 字段，注意解析 `output` 列表。\n\n### 8.2 延迟比 Chat 更明显\n\n部分模型在 Responses 下会产出更多中间语义项，建议用流式提升体验。\n\n下一页：`Responses（流式事件模型）`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/responses/stream-events.md",
    "content": "﻿---\nsidebar_position: 21\n---\n\n# Responses（流式事件模型）\n\n这是 Responses 与 Chat 最大差异点：Responses 是**事件流**，而不是只吐文本 token。\n\n## 1. 调用方式\n\n```java\nResponseRequest request = ResponseRequest.builder()\n        .model(\"doubao-seed-1-8-251228\")\n        .input(\"Describe the Responses API in one sentence\")\n        .stream(true)\n        .build();\n\nResponseSseListener listener = new ResponseSseListener() {\n    @Override\n    protected void onEvent() {\n        if (!getCurrText().isEmpty()) {\n            System.out.print(getCurrText());\n        }\n    }\n};\n\nresponsesService.createStream(request, listener);\nSystem.out.println(\"\\nstream finished\");\n```\n\n## 2. 典型事件类型\n\n常见事件（平台可能有差异）：\n\n- `response.created`\n- `response.in_progress`\n- `response.output_text.delta`\n- `response.reasoning_summary_text.delta`\n- `response.function_call_arguments.delta`\n- `response.completed`\n- `response.failed`\n- `response.incomplete`\n\n## 3. `ResponseSseListener` 字段说明\n\n- `getCurrEvent()`：当前事件对象\n- `getEvents()`：全部事件列表\n- `getCurrText()`：当前文本增量\n- `getOutputText()`：累计文本\n- `getReasoningSummary()`：累计 reasoning summary\n- `getCurrFunctionArguments()`：当前函数参数增量\n- `getFunctionArguments()`：累计函数参数\n- `getResponse()`：聚合后的 Response 快照\n\n## 4. 为什么看起来“不是 token-by-token”\n\n因为事件粒度由平台决定：\n\n- 有的平台按字输出\n- 有的平台按词或短句输出\n- 有的平台一次给整段\n\n所以你看到“一句话才刷新一次”不一定是错误，有可能是上游分片策略。\n\n## 5. 终态判定\n\n`OpenAiResponsesService` / `DoubaoResponsesService` 里，以下事件会触发完成：\n\n- `response.completed`\n- `response.failed`\n- `response.incomplete`\n\n以及 SSE 的 `[DONE]`。\n\n## 6. 参数流观察技巧\n\n如果你在做函数调用排障，建议在回调里打印：\n\n```java\nif (!getCurrFunctionArguments().isEmpty()) {\n    System.out.println(\"ARGS DELTA=\" + getCurrFunctionArguments());\n}\n```\n\n这样可以确认参数是模型没生成，还是你只打印了文本增量。\n\n## 7. 常见坑\n\n### 7.1 只看最终 `listener.getResponse()`，误判“流式没输出”\n\n`getResponse()` 是聚合快照，不代表中间增量没来。\n\n### 7.2 控制台缓冲导致晚显示\n\nIDE 控制台可能缓冲，建议：\n\n- 简化输出\n- 使用 `System.out.print` + 手动换行\n- 对比事件时间戳\n\n### 7.3 流式回调异常中断\n\n在 `onEvent()` 里抛异常会直接中断流式处理，建议回调内部捕获异常。\n\n## 8. 生产建议\n\n- 将事件写入结构化日志（type、sequence、latency、traceId）\n- 前端按事件类型渲染，而不是只按文本渲染\n- 高价值场景保存 `response.failed` 的 error payload 便于追查\n\n## 9. 与 Agent 的关系\n\n如果你要自动处理“函数参数流 -> 执行工具 -> 再请求模型”，\n建议在 Agent runtime 层实现，不要把流程硬写在 Controller。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/services/_category_.json",
    "content": "﻿{\n  \"label\": \"其它服务\",\n  \"position\": 4,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/services/audio.md",
    "content": "﻿---\nsidebar_position: 31\n---\n\n# Audio 接口\n\n音频能力统一在 `IAudioService`，当前由 OpenAI 路径实现。\n\n## 1. 能力范围\n\n- 文本转语音：`textToSpeech(...)`\n- 语音转文本：`transcription(...)`\n- 音频翻译：`translation(...)`\n\n## 2. 文本转语音（TTS）\n\n```java\nIAudioService audioService = aiService.getAudioService(PlatformType.OPENAI);\n\nTextToSpeech req = TextToSpeech.builder()\n        .model(\"tts-1\")\n        .voice(\"alloy\")\n        .input(\"欢迎使用 ai4j\")\n        .responseFormat(\"mp3\")\n        .speed(1.0)\n        .build();\n\nInputStream stream = audioService.textToSpeech(req);\n// 自行写文件或返回给前端\n```\n\n## 3. 语音转文本（Transcription）\n\n```java\nTranscription req = Transcription.builder()\n        .file(new File(\"D:/audio/demo.mp3\"))\n        .model(\"whisper-1\")\n        .language(\"zh\")\n        .responseFormat(\"json\")\n        .build();\n\nTranscriptionResponse res = audioService.transcription(req);\nSystem.out.println(res.getText());\n```\n\n## 4. 音频翻译（Translation）\n\n```java\nTranslation req = Translation.builder()\n        .file(new File(\"D:/audio/jp.wav\"))\n        .model(\"whisper-1\")\n        .responseFormat(\"json\")\n        .build();\n\nTranslationResponse res = audioService.translation(req);\nSystem.out.println(res.getText());\n```\n\n## 5. 文件格式限制\n\n`Transcription` / `Translation` 对文件后缀有校验，允许：\n\n- `flac`\n- `mp3`\n- `mp4`\n- `mpeg`\n- `mpga`\n- `m4a`\n- `ogg`\n- `wav`\n- `webm`\n\n不在白名单会直接抛 `IllegalArgumentException`。\n\n## 6. 生产建议\n\n- 语音文件先做大小限制\n- 上传后做临时文件清理\n- 长音频建议分段处理\n- 对敏感音频内容做脱敏存储\n\n## 7. 常见问题\n\n### 7.1 返回流为空\n\n- 检查 TTS 请求参数是否完整\n- 检查上游响应是否成功\n\n### 7.2 转录乱码\n\n- 显式指定 `language`\n- 检查原音频质量与采样率\n\n### 7.3 接口超时\n\n- 大文件建议提高 read timeout\n- 并发上传建议限流\n\n## 8. 推荐集成模式\n\n- API 层只负责文件接收\n- Service 层统一封装音频参数\n- 对外只暴露最终文本/文件 URL，不透传底层对象\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/services/embedding.md",
    "content": "﻿---\nsidebar_position: 30\n---\n\n# Embedding 接口\n\nEmbedding 是 RAG 的基础能力，ai4j 统一为 `IEmbeddingService`。\n\n## 1. 支持平台\n\n当前 `AiService#getEmbeddingService(...)` 支持：\n\n- `OPENAI`\n- `OLLAMA`\n\n## 2. 最小示例\n\n```java\nIEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n\nEmbedding request = Embedding.builder()\n        .model(\"text-embedding-3-small\")\n        .input(\"Explain JVM class loading\")\n        .build();\n\nEmbeddingResponse response = embeddingService.embedding(request);\nList<Float> vector = response.getData().get(0).getEmbedding();\nSystem.out.println(vector.size());\n```\n\n## 3. 批量向量化\n\n`input` 可传 `List<String>`，用于批量生成向量：\n\n```java\nEmbedding request = Embedding.builder()\n        .model(\"text-embedding-3-small\")\n        .input(Arrays.asList(\"文档片段1\", \"文档片段2\", \"文档片段3\"))\n        .build();\n```\n\n## 4. Ollama 兼容细节\n\n`OllamaEmbeddingService` 会把 Ollama 返回结果转换成 OpenAI 风格 `EmbeddingResponse`：\n\n- `data[i].embedding`\n- `usage`\n- `model`\n\n这就是“协议消歧”的核心体现。\n\n## 5. 参数与模型建议\n\n- 固定一个 embedding 模型，不要混用\n- 同一索引内向量维度必须一致\n- 批量请求优先控制单批大小，避免超时\n\n## 6. 常见问题\n\n### 6.1 向量维度不一致\n\n通常是模型变更导致，需重新建索引或重建数据。\n\n### 6.2 返回为空\n\n- API key 未配置\n- 模型名无效\n- 上游限流或超时\n\n### 6.3 批量很慢\n\n建议：\n\n- 分批并发（但注意限流）\n- 缓存重复文本的向量结果\n\n## 7. 与 Pinecone 的配合\n\nEmbedding 产物通常直接写入 Pinecone：\n\n- `List<List<Float>>` -> 向量\n- 原文 -> metadata.content\n\n完整流程见：`Pinecone 向量检索工作流`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/services/image-generation.md",
    "content": "﻿---\nsidebar_position: 32\n---\n\n# Image 接口（生成与流式）\n\n图像能力统一在 `IImageService`，当前支持：\n\n- `OPENAI`\n- `DOUBAO`\n\n## 1. 非流式生成\n\n```java\nIImageService imageService = aiService.getImageService(PlatformType.OPENAI);\n\nImageGeneration request = ImageGeneration.builder()\n        .model(\"gpt-image-1\")\n        .prompt(\"A clean isometric illustration of a Java microservice\")\n        .size(\"1024x1024\")\n        .responseFormat(\"url\")\n        .build();\n\nImageGenerationResponse response = imageService.generate(request);\nSystem.out.println(response);\n```\n\n## 2. 流式生成\n\n```java\nImageGeneration request = ImageGeneration.builder()\n        .model(\"gpt-image-1\")\n        .prompt(\"A futuristic city at sunrise\")\n        .stream(true)\n        .partialImages(1)\n        .responseFormat(\"b64_json\")\n        .build();\n\nImageSseListener listener = new ImageSseListener() {\n    @Override\n    protected void onEvent() {\n        ImageStreamEvent e = getCurrEvent();\n        if (e != null) {\n            System.out.println(\"event=\" + e.getType() + \", idx=\" + e.getImageIndex());\n        }\n    }\n};\n\nimageService.generateStream(request, listener);\n```\n\n## 3. 请求参数（`ImageGeneration`）\n\n常用字段：\n\n- `model`\n- `prompt`\n- `n`\n- `size`\n- `quality`\n- `responseFormat`（`url` / `b64_json`）\n- `outputFormat`（`png` / `jpeg` / `webp`）\n- `outputCompression`\n- `background`\n- `partialImages`\n- `stream`\n- `user`\n- `extraBody`\n\n## 4. 监听器字段（`ImageSseListener`）\n\n- `getCurrEvent()`：当前图片事件\n- `getEvents()`：全量事件\n- `getResponse()`：聚合后的图片响应\n\n## 5. 事件模型说明\n\n流式中可能出现：\n\n- partial image 事件\n- completed 事件\n- error 事件\n\n监听器默认会把“最终图像事件”聚合进 `ImageGenerationResponse`。\n\n## 6. OpenAI 与豆包差异处理\n\nSDK 已做协议适配：\n\n- 请求体字段转换（豆包使用 `DoubaoImageGenerationRequest`）\n- 事件字段兼容（`created` / `created_at`）\n\n业务层可以用同一套 `ImageGeneration`/`ImageSseListener`。\n\n## 7. 常见问题\n\n### 7.1 只收到 partial 没有 final\n\n- 检查是否接收到 `image_generation.completed`\n- 检查网络中断与超时\n\n### 7.2 URL 可访问性问题\n\n- 部分平台返回临时 URL，需尽快下载/转存\n- 生产建议落盘到对象存储\n\n### 7.3 base64 太大\n\n- 建议改用 `url` 模式\n- 或降低分辨率和质量\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/ai-basics/services/realtime.md",
    "content": "﻿---\nsidebar_position: 33\n---\n\n# Realtime 接口（WebSocket）\n\nRealtime 能力统一在 `IRealtimeService`，当前由 OpenAI 实现。\n\n## 1. 核心入口\n\n```java\nIRealtimeService realtimeService = aiService.getRealtimeService(PlatformType.OPENAI);\n```\n\n建立连接：\n\n```java\nWebSocket ws = realtimeService.createRealtimeClient(\n        \"gpt-4o-realtime-preview\",\n        new RealtimeListener() {\n            @Override\n            protected void onOpen(WebSocket webSocket) {\n                System.out.println(\"opened\");\n            }\n\n            @Override\n            protected void onMessage(ByteString bytes) {\n                System.out.println(\"binary=\" + bytes.size());\n            }\n\n            @Override\n            protected void onMessage(String text) {\n                System.out.println(\"text=\" + text);\n            }\n\n            @Override\n            protected void onFailure() {\n                System.out.println(\"failed\");\n            }\n        }\n);\n```\n\n## 2. 连接参数\n\n`createRealtimeClient(baseUrl, apiKey, model, listener)` 支持：\n\n- 自定义 baseUrl\n- 自定义 apiKey\n- 模型名\n- 监听器\n\n默认请求头会带：\n\n- `Authorization: Bearer ...`\n- `OpenAI-Beta: realtime=v1`\n\n## 3. 监听器设计\n\n`RealtimeListener` 是 `WebSocketListener` 的抽象封装，约定了：\n\n- `onOpen`\n- `onMessage(ByteString)`\n- `onMessage(String)`\n- `onFailure`\n\n你可以在 `onMessage(String)` 里做事件分发。\n\n## 4. 使用建议\n\n- 长连接场景建议独立线程池管理\n- 连接断开要做重连策略（指数退避）\n- 消息处理要做 backpressure（防止消费跟不上）\n\n## 5. 常见问题\n\n### 5.1 建连失败\n\n- 检查模型名是否可用\n- 检查网络/代理是否允许 WebSocket\n- 检查 API key 与 host\n\n### 5.2 消息处理阻塞\n\n- 不要在回调里做重 CPU 工作\n- 将业务逻辑投递到异步队列\n\n### 5.3 连接无故关闭\n\n- 服务端超时回收\n- 心跳缺失\n- 网关空闲连接策略\n\n## 6. 生产化建议\n\n- 给每个连接分配 `sessionId`\n- 记录建连耗时、断连原因、重连次数\n- 对音视频大包加大小限制与审计\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/_category_.json",
    "content": "﻿{\n  \"label\": \"核心 SDK 能力\",\n  \"position\": 3,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/agentflow-protocol-mapping.md",
    "content": "---\nsidebar_position: 4\n---\n\n# AgentFlow 协议映射与工作原理\n\n本页聚焦 `agentflow` 内部的协议收敛方式，包括字段映射、stream 处理和错误模型。\n\n## 1. 总体思路\n\n`agentflow` 没有把三家平台做成“伪统一 OpenAI 协议”，而是采用了更保守的策略：\n\n- 顶层统一只抽象公共字段\n- provider 特有字段保留在各自 adapter\n- 原始返回放进 `raw`\n- chat 与 workflow 明确分层\n\n这样做的好处是：\n\n- 不会为了统一而丢掉关键语义\n- 业务层拿到的是稳定字段\n- 排查问题时还能看到原始 payload\n\n## 2. Dify 映射\n\n### 2.1 Chat\n\nai4j 直接对接 Dify App API：\n\n- blocking: `POST /v1/chat-messages`\n- streaming: `POST /v1/chat-messages`\n\n请求映射：\n\n- `prompt -> query`\n- `inputs -> inputs`\n- `userId -> user`\n- `conversationId -> conversation_id`\n- `response_mode -> blocking / streaming`\n\n响应映射：\n\n- `answer -> content`\n- `conversation_id -> conversationId`\n- `message_id / id -> messageId`\n- `task_id -> taskId`\n- `metadata.usage -> AgentFlowUsage`\n\n### 2.2 Workflow\n\nworkflow 也直接走 Dify 发布端点：\n\n- blocking: `POST /v1/workflows/run`\n- streaming: `POST /v1/workflows/run`\n\n请求映射：\n\n- `inputs -> inputs`\n- `userId -> user`\n- `response_mode -> blocking / streaming`\n\n响应映射：\n\n- `data.status -> status`\n- `data.outputs -> outputs`\n- `data.outputs.* -> outputText`\n- `task_id -> taskId`\n- `workflow_run_id -> workflowRunId`\n\n### 2.3 Dify Streaming 处理策略\n\nDify 的 streaming 事件类型比较多，ai4j 当前只抓公共主干：\n\n- chat:\n  - `message`\n  - `agent_message`\n  - `message_end`\n  - `error`\n- workflow:\n  - `workflow_finished`\n  - `message`\n  - `text_chunk`\n  - `error`\n\n策略是：\n\n- 增量文本进入 `contentDelta` / `outputText`\n- 终态事件聚合成最终 response\n- `ping` 等保活事件忽略\n\n## 3. Coze 映射\n\n### 3.1 Chat Blocking\n\nCoze 非流式 chat 不是一次请求直接拿最终答案，而是分三步：\n\n1. `POST /v3/chat` 创建 chat\n2. `GET /v3/chat/retrieve` 轮询状态\n3. `POST /v1/conversation/message/list` 拉取消息列表\n\nai4j 在 adapter 内部把这三步封装掉了，对业务侧仍然暴露单个：\n\n```java\nAgentFlowChatResponse response = agentFlow.chat().chat(request);\n```\n\n因此 `AgentFlowConfig` 里需要：\n\n- `botId`\n- `pollIntervalMillis`\n- `pollTimeoutMillis`\n\n### 3.2 Chat Streaming\n\nCoze streaming 使用原生 SSE 事件流，常见事件包括：\n\n- `conversation.chat.created`\n- `conversation.message.delta`\n- `conversation.message.completed`\n- `conversation.chat.completed`\n- `done`\n- `error`\n\nai4j 的处理规则：\n\n- `conversation.message.delta` 作为真正的增量文本\n- `conversation.chat.completed` 更新 usage / 状态\n- `done` 触发最终完成\n- 非 JSON 的 `data`（例如 `[DONE]`）按原始文本保留，不强制解析\n\n### 3.3 Workflow\n\nCoze workflow 使用：\n\n- blocking: `POST /v1/workflow/run`\n- streaming: `POST /v1/workflow/stream_run`\n\nblocking 的特点是：\n\n- `data` 本身是一个字符串\n- 这个字符串通常是 JSON 序列化结果，但也可能只是纯文本\n\n所以 ai4j 的策略是：\n\n- 先尝试把 `data` 解析成 JSON\n- 解析失败就按纯文本处理\n- 解析结果放进 `outputs/raw`，同时抽取可读的 `outputText`\n\nstreaming 的典型事件：\n\n- `Message`\n- `Interrupt`\n- `Error`\n- `Done`\n\nai4j 当前把 `Message.content` 聚合为最终 `outputText`。\n\n## 4. n8n 映射\n\nn8n 第一阶段采用 webhook-first 方案：\n\n- `workflow().run() -> POST webhookUrl`\n\n原因很简单：\n\n- n8n 的自然发布对象就是 webhook / workflow endpoint\n- 它不是模型 provider，也不天然适合抽象成 chat\n- 先把最常见、最稳定的 blocking webhook 接入做好，收益最高\n\n请求策略：\n\n- `inputs` 直接拍平成 webhook body\n- `metadata` 作为 `_metadata` 附加字段\n- `extraBody` 允许补充自定义字段\n\n响应策略：\n\n- 如果返回 JSON，尽量提取 `result / answer / output / text / content`\n- 如果返回纯文本，直接作为 `outputText`\n- 完整返回保留在 `raw`\n\n## 5. 为什么 `raw` 很重要\n\n第三方平台的 published endpoint 最大的问题不是“能不能调通”，而是“线上出问题时你怎么知道是平台字段变了，还是你自己的业务参数错了”。\n\n所以所有 response / stream event 都保留 `raw`：\n\n- 调试时可以直接打印 provider 原始响应\n- 文档升级或平台字段调整时容易定位\n- 不需要为了统一抽象而丢失细节\n\n这和 ai4j 在 MCP、Trace、Responses 上的设计原则是一致的：公共语义统一，底层原始数据不强行抹平。\n\n## 6. 错误处理策略\n\n`agentflow` 的错误来源主要有三类：\n\n### 6.1 HTTP 错误\n\n- 非 2xx 直接抛 `AgentFlowException`\n- 异常信息里保留 status code 与部分响应体\n\n### 6.2 Provider 业务错误\n\n例如：\n\n- Dify stream `error`\n- Coze `code != 0`\n- Coze chat `failed / requires_action`\n\n这些不会静默吞掉，而是直接转成异常。\n\n### 6.3 Streaming 中断\n\n在 stream 模式下：\n\n- `onError` 先回调\n- 然后方法抛出异常\n\n这样业务层可以同时获得：\n\n- 监听器感知\n- 同步调用栈异常\n\n## 7. 为什么不塞进 `PlatformType`\n\n这一点决定了 `agentflow` 与核心 provider 体系的边界。\n\n如果把 Dify / Coze / n8n 直接塞进 `PlatformType`，会马上出现三个问题：\n\n1. `PlatformType` 从“模型 provider 枚举”变成“什么都往里塞的总开关”\n2. `IChatService` 被迫承担 workflow / webhook 语义\n3. `AiPlatform` 配置会开始混入 `botId / workflowId / webhookUrl`\n\n这会直接破坏原来核心 SDK 的边界。\n\n所以现在的拆分是：\n\n- 模型 provider 继续走 `PlatformType + IChatService`\n- 已发布应用端点走 `AgentFlow`\n\n这个边界清晰之后，后面再扩 Dify / Coze / n8n 的能力，成本就低很多。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/agentflow.md",
    "content": "---\nsidebar_position: 3\n---\n\n# AgentFlow 总览\n\n`AgentFlow` 解决的不是“直接调用模型”的问题，而是“调用已经在第三方平台上编排并发布好的 Agent / Bot / Workflow 端点”。\n\n这一层和 `PlatformType + IChatService` 是两套不同的接入语义：\n\n- `IChatService`\n  - 你在直接调用模型平台\n  - 关注的是 `model`、messages、tool call、stream\n  - 典型对象是 OpenAI、DeepSeek、豆包、Minimax\n- `AgentFlow`\n  - 你在调用已经发布好的应用端点\n  - 关注的是 `botId`、`workflowId`、`webhookUrl`、业务入参\n  - 典型对象是 Dify、Coze、n8n\n\n如果把这两类能力强行混在一起，调用语义会非常混乱，所以 ai4j 把它们拆成了两条并行能力线。\n\n## 1. 这层能力的定位\n\n`AgentFlow` 面向三类场景：\n\n1. 团队已经在 Dify / Coze / n8n 上把流程编排好了，现在 Java 服务只需要稳定调用\n2. 你不想在 Java 侧重复实现整套工作流，而是把 Java 作为业务接入层\n3. 你需要把“模型调用”和“外部 Agent / Workflow 调用”同时放进一个系统里，但又不希望抽象互相污染\n\n它的核心价值不是替代 `IChatService`，而是补上“已发布端点接入”这一块空白。\n\n## 2. 对外 API 结构\n\n顶层入口仍然从 `AiService` 拿：\n\n```java\nConfiguration configuration = new Configuration();\nconfiguration.setOkHttpClient(new OkHttpClient());\n\nAiService aiService = new AiService(configuration);\n\nAgentFlow agentFlow = aiService.getAgentFlow(AgentFlowConfig.builder()\n        .type(AgentFlowType.DIFY)\n        .baseUrl(\"https://api.dify.ai\")\n        .apiKey(\"app-xxx\")\n        .userId(\"demo-user\")\n        .build());\n```\n\n然后分成两条能力：\n\n- `agentFlow.chat()`\n- `agentFlow.workflow()`\n\n`agentflow` 包内部采用以下设计原则：\n\n- `chat` 只处理聊天型 published endpoint\n- `workflow` 只处理工作流型 published endpoint\n- provider 的差异收敛在各自 adapter 中，不污染顶层模型\n\n## 3. 核心类职责\n\n### 3.1 `AgentFlowType`\n\n当前内置：\n\n- `DIFY`\n- `COZE`\n- `N8N`\n\n### 3.2 `AgentFlowConfig`\n\n统一描述第三方已发布端点的接入参数。常用字段包括：\n\n- `baseUrl`\n- `webhookUrl`\n- `apiKey`\n- `botId`\n- `workflowId`\n- `appId`\n- `userId`\n- `conversationId`\n- `headers`\n- `pollIntervalMillis`\n- `pollTimeoutMillis`\n\n`AgentFlowConfig` 描述的不是模型参数，而是第三方发布端点所需的接入上下文。\n\n它现在还额外支持：\n\n- `traceListeners`\n\n这组 listener 用来接收 `AgentFlow` 的调用生命周期事件，包括：\n\n- 调用开始\n- stream 中间事件\n- 调用完成\n- 调用失败\n\n这一层只暴露中立事件，不直接依赖 `ai4j-agent` 的 `TraceSpan` 类型。这样 `ai4j` 保持底层模块定位不变，是否桥接到 trace/exporter，由上层自行决定。\n\n### 3.3 `AgentFlow`\n\n一个轻量 facade，只做两件事：\n\n- 持有 `Configuration`\n- 根据 `AgentFlowConfig.type` 返回对应的 chat / workflow service\n\n它故意没有塞太多行为，避免把 provider-specific 逻辑堆到顶层。\n\n### 3.4 Request / Response / Event\n\nchat 和 workflow 都有自己独立的：\n\n- request\n- response\n- stream event\n- listener\n\n这样做的原因很直接：\n\n- Dify / Coze 的 chat 有 conversation 语义\n- workflow 的结果更接近 outputs / status\n- n8n webhook 甚至不适合伪装成 chat\n\n把 chat 和 workflow 混成一个统一 request/response，最终一定会把抽象拉坏。\n\n## 4. 如何选择 `IChatService` 还是 `AgentFlow`\n\n两类入口的区分标准如下：\n\n- 你是在“对模型说话”，用 `IChatService`\n- 你是在“调用已经编排好的应用端点”，用 `AgentFlow`\n\n### 更适合 `IChatService` 的场景\n\n- 你自己控制 prompt、messages、model、tool\n- 你要做统一的大模型调用层\n- 你要自己在 Java 侧实现 agent loop / tool loop / memory\n\n### 更适合 `AgentFlow` 的场景\n\n- 业务流程已经在 Dify / Coze / n8n 中编排完成\n- Java 只需要把用户输入和业务参数送进去\n- 你要复用第三方平台的 workflow / bot / app 发布结果\n\n## 5. Dify、Coze、n8n 的支持边界\n\n### Dify\n\n支持：\n\n- `chat()` blocking\n- `chatStream()` streaming\n- `workflow().run()` blocking\n- `workflow().runStream()` streaming\n\n### Coze\n\n支持：\n\n- `chat()` blocking\n- `chatStream()` streaming\n- `workflow().run()` blocking\n- `workflow().runStream()` streaming\n\n### n8n\n\n支持：\n\n- `workflow().run()` blocking webhook\n\n当前不支持：\n\n- `chat()`\n- `workflow().runStream()`\n\n原因在于 n8n 的自然接入对象就是 published webhook / workflow endpoint。第一阶段优先稳定 webhook 语义，不额外扩展伪 chat 抽象。\n\n## 6. 最小调用示例\n\n### 6.1 Dify Chat\n\n```java\nAgentFlow dify = aiService.getAgentFlow(AgentFlowConfig.builder()\n        .type(AgentFlowType.DIFY)\n        .baseUrl(\"https://api.dify.ai\")\n        .apiKey(\"app-xxx\")\n        .userId(\"user-1\")\n        .build());\n\nAgentFlowChatResponse response = dify.chat().chat(AgentFlowChatRequest.builder()\n        .prompt(\"给我一份东京三日旅行建议\")\n        .inputs(Collections.<String, Object>singletonMap(\"locale\", \"zh-CN\"))\n        .build());\n\nSystem.out.println(response.getContent());\n```\n\n### 6.2 Coze Workflow\n\n```java\nAgentFlow coze = aiService.getAgentFlow(AgentFlowConfig.builder()\n        .type(AgentFlowType.COZE)\n        .baseUrl(\"https://api.coze.com\")\n        .apiKey(\"pat-xxx\")\n        .workflowId(\"workflow-xxx\")\n        .botId(\"bot-xxx\")\n        .build());\n\nAgentFlowWorkflowResponse response = coze.workflow().run(AgentFlowWorkflowRequest.builder()\n        .inputs(new HashMap<String, Object>() {{\n            put(\"city\", \"Paris\");\n            put(\"days\", 4);\n        }})\n        .build());\n\nSystem.out.println(response.getOutputText());\nSystem.out.println(response.getOutputs());\n```\n\n### 6.3 n8n Webhook\n\n```java\nAgentFlow n8n = aiService.getAgentFlow(AgentFlowConfig.builder()\n        .type(AgentFlowType.N8N)\n        .webhookUrl(\"https://n8n.example.com/webhook/travel-plan\")\n        .build());\n\nAgentFlowWorkflowResponse response = n8n.workflow().run(AgentFlowWorkflowRequest.builder()\n        .inputs(Collections.<String, Object>singletonMap(\"city\", \"Paris\"))\n        .build());\n\nSystem.out.println(response.getOutputText());\n```\n\n## 7. Spring Boot 自动装配\n\n`ai4j-spring-boot-starter` 已支持 `AgentFlow` 自动装配。\n\n### 7.1 配置方式\n\n```yaml\nai:\n  agentflow:\n    enabled: true\n    default-name: dify\n    profiles:\n      dify:\n        type: DIFY\n        base-url: https://api.dify.ai\n        api-key: app-xxx\n        user-id: demo-user\n      coze:\n        type: COZE\n        base-url: https://api.coze.com\n        api-key: pat-xxx\n        bot-id: bot-123\n        workflow-id: workflow-123\n```\n\n### 7.2 Bean 暴露规则\n\n- 开启 `ai.agentflow.enabled=true` 后，会自动注册 `AgentFlowRegistry`\n- 配置了 `ai.agentflow.default-name` 后，会额外注册一个默认 `AgentFlow` bean\n\n因此可以按两种方式使用：\n\n```java\n@Resource\nprivate AgentFlow agentFlow;\n```\n\n或者：\n\n```java\n@Resource\nprivate AgentFlowRegistry agentFlowRegistry;\n\npublic void run() throws Exception {\n    AgentFlow dify = agentFlowRegistry.get(\"dify\");\n    AgentFlow coze = agentFlowRegistry.get(\"coze\");\n}\n```\n\n`AgentFlowRegistry` 适合多 profile 场景，默认 `AgentFlow` bean 适合单一主端点场景。\n\n## 8. Streaming 怎么看\n\nchat 与 workflow 都有 listener：\n\n- `AgentFlowChatListener`\n- `AgentFlowWorkflowListener`\n\n回调结构统一分成：\n\n- `onOpen`\n- `onEvent`\n- `onError`\n- `onComplete`\n\n其中：\n\n- `onEvent` 用来接每个增量事件\n- `onComplete` 给最终聚合后的 response\n- `raw` 字段保留了原始 provider 事件，方便排障\n\n这层设计的重点不是把所有第三方协议强行揉成完全一致，而是把公共字段稳定下来，同时保留原始细节。\n\n## 9. Trace 与可观测\n\n`AgentFlow` 现在可以把自己的生命周期事件接到现有 trace 体系里，但接法是分层的：\n\n- `ai4j`\n  - 只产出 `AgentFlowTraceListener` 事件\n- `ai4j-agent`\n  - 提供 `AgentFlowTraceBridge`\n  - 把这些事件投影成统一的 `TraceSpan`\n\n这样做的原因是：\n\n- `ai4j` 不能反向依赖 `ai4j-agent`\n- `AgentFlow` 需要保持为独立能力层，不和 Agent runtime 强耦合\n- 但上层又希望复用既有的 `ConsoleTraceExporter / JsonlTraceExporter / OpenTelemetryTraceExporter / LangfuseTraceExporter`\n\n### 9.1 直接接入 trace exporter\n\n```java\nTraceExporter exporter = new CompositeTraceExporter(\n        new ConsoleTraceExporter(),\n        new JsonlTraceExporter(\"logs/agentflow-trace.jsonl\")\n);\n\nAgentFlow agentFlow = aiService.getAgentFlow(AgentFlowConfig.builder()\n        .type(AgentFlowType.DIFY)\n        .baseUrl(\"https://api.dify.ai\")\n        .apiKey(\"app-xxx\")\n        .traceListeners(Collections.singletonList(\n                new AgentFlowTraceBridge(exporter, TraceConfig.builder().build())\n        ))\n        .build());\n```\n\n这里的桥接关系很明确：\n\n- `AgentFlowTraceListener`\n  - 是核心层 hook\n- `AgentFlowTraceBridge`\n  - 是 `ai4j-agent` 里的 trace projection\n- `TraceExporter`\n  - 决定最终打到哪里\n\n### 9.2 当前会投哪些 trace 语义\n\n桥接后会产出 `AGENT_FLOW` span，统一覆盖：\n\n- `chat()` blocking\n- `chatStream()` streaming\n- `workflow().run()` blocking\n- `workflow().runStream()` streaming\n- `n8n workflow webhook` blocking\n\nspan attributes 重点包含：\n\n- provider 类型\n- operation 类型\n- streaming 标识\n- 端点配置摘要，例如 `baseUrl / webhookUrl / botId / workflowId`\n- 请求输入摘要\n- 最终 output / status / taskId / conversationId / workflowRunId`\n\nstream 场景下，增量事件不会拆成很多独立 span，而是追加为 span event：\n\n- `agentflow.chat.event`\n- `agentflow.workflow.event`\n\n这样读时间线时可以同时看到：\n\n- 一次外部托管 Agent / Workflow 调用总耗时\n- 中间收到过哪些 provider stream 事件\n- 最终 token usage 与输出结果\n\n### 9.3 和 Agent runtime trace 的关系\n\n这条链路和 `AgentTraceListener` 是并列关系，不是替代关系：\n\n- `AgentTraceListener`\n  - 处理 ai4j 自己的 agent runtime 事件\n- `AgentFlowTraceBridge`\n  - 处理外部 Dify / Coze / n8n 端点调用\n\n这样最终 exporter 看到的是统一的 `TraceSpan` 模型，但来源仍然清晰可分。\n\n## 10. 这层能力和 ai4j 其他模块的关系\n\n`AgentFlow` 与下面这些能力是并列关系，不是替代关系：\n\n- `IChatService`\n- `IResponsesService`\n- `RAG`\n- `MCP`\n- `Tool`\n- `Agent`\n\n一个典型系统完全可能同时存在：\n\n- Java 内部 agent 用 `IChatService` / `Responses`\n- 业务流程调用外部 Dify / Coze app 用 `AgentFlow`\n- 检索增强继续走 RAG / MCP / Tool\n\n这也对应了 ai4j 当前没有将 Dify / Coze / n8n 并入 `PlatformType` 的设计边界。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/audio.md",
    "content": "﻿---\nsidebar_position: 31\n---\n\n# Audio 接口\n\n音频能力统一在 `IAudioService`，当前由 OpenAI 路径实现。\n\n## 1. 能力范围\n\n- 文本转语音：`textToSpeech(...)`\n- 语音转文本：`transcription(...)`\n- 音频翻译：`translation(...)`\n\n## 2. 文本转语音（TTS）\n\n```java\nIAudioService audioService = aiService.getAudioService(PlatformType.OPENAI);\n\nTextToSpeech req = TextToSpeech.builder()\n        .model(\"tts-1\")\n        .voice(\"alloy\")\n        .input(\"欢迎使用 ai4j\")\n        .responseFormat(\"mp3\")\n        .speed(1.0)\n        .build();\n\nInputStream stream = audioService.textToSpeech(req);\n// 自行写文件或返回给前端\n```\n\n## 3. 语音转文本（Transcription）\n\n```java\nTranscription req = Transcription.builder()\n        .file(new File(\"D:/audio/demo.mp3\"))\n        .model(\"whisper-1\")\n        .language(\"zh\")\n        .responseFormat(\"json\")\n        .build();\n\nTranscriptionResponse res = audioService.transcription(req);\nSystem.out.println(res.getText());\n```\n\n## 4. 音频翻译（Translation）\n\n```java\nTranslation req = Translation.builder()\n        .file(new File(\"D:/audio/jp.wav\"))\n        .model(\"whisper-1\")\n        .responseFormat(\"json\")\n        .build();\n\nTranslationResponse res = audioService.translation(req);\nSystem.out.println(res.getText());\n```\n\n## 5. 文件格式限制\n\n`Transcription` / `Translation` 对文件后缀有校验，允许：\n\n- `flac`\n- `mp3`\n- `mp4`\n- `mpeg`\n- `mpga`\n- `m4a`\n- `ogg`\n- `wav`\n- `webm`\n\n不在白名单会直接抛 `IllegalArgumentException`。\n\n## 6. 生产建议\n\n- 语音文件先做大小限制\n- 上传后做临时文件清理\n- 长音频建议分段处理\n- 对敏感音频内容做脱敏存储\n\n## 7. 常见问题\n\n### 7.1 返回流为空\n\n- 检查 TTS 请求参数是否完整\n- 检查上游响应是否成功\n\n### 7.2 转录乱码\n\n- 显式指定 `language`\n- 检查原音频质量与采样率\n\n### 7.3 接口超时\n\n- 大文件建议提高 read timeout\n- 并发上传建议限流\n\n## 8. 推荐集成模式\n\n- API 层只负责文件接收\n- Service 层统一封装音频参数\n- 对外只暴露最终文本/文件 URL，不透传底层对象\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/chat/multimodal.md",
    "content": "﻿---\nsidebar_position: 13\n---\n\n# Chat / 多模态（Vision）\n\nai4j 在 Chat 链路中支持文本 + 图片输入，核心对象是 `Content`。\n\n## 1. 快速示例\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\n                \"请描述图片中的场景并识别主要对象\",\n                \"https://example.com/demo.jpg\"\n        ))\n        .build();\n\nChatCompletionResponse response = chatService.chatCompletion(request);\nSystem.out.println(response.getChoices().get(0).getMessage().getContent().getText());\n```\n\n## 2. 底层结构\n\n`ChatMessage.content` 使用 `Content` 封装：\n\n- 纯文本：`Content.ofText(\"...\")`\n- 多模态：`Content.ofMultiModals(List<MultiModal>)`\n\n`MultiModal` 支持：\n\n- `type=text` + `text`\n- `type=image_url` + `imageUrl.url`\n\n## 3. 手工构造多模态片段\n\n```java\nList<Content.MultiModal> parts = new ArrayList<>();\nparts.add(Content.MultiModal.builder()\n        .type(\"text\")\n        .text(\"比较这两张图片的差异\")\n        .build());\nparts.add(Content.MultiModal.builder()\n        .type(\"image_url\")\n        .imageUrl(new Content.MultiModal.ImageUrl(\"https://example.com/a.png\"))\n        .build());\nparts.add(Content.MultiModal.builder()\n        .type(\"image_url\")\n        .imageUrl(new Content.MultiModal.ImageUrl(\"https://example.com/b.png\"))\n        .build());\n\nChatMessage user = ChatMessage.builder()\n        .role(\"user\")\n        .content(Content.ofMultiModals(parts))\n        .build();\n```\n\n## 4. URL 与 Base64\n\n`image_url` 的 `url` 字段既可放网络地址，也可放 base64 data URL。\n\n工程建议：\n\n- 开发调试先用 URL\n- 生产场景可按安全策略改成对象存储签名 URL\n- 大图建议先压缩，减少请求体体积\n\n## 5. 多模态 + 工具调用\n\n多模态请求同样可带工具：\n\n```java\nChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请识别图片是否下雨并查询当地天气\", imageUrl))\n        .functions(\"queryWeather\")\n        .build();\n```\n\n建议在 system/instructions 里明确：\n\n- 先做图像判断\n- 再决定是否调用工具\n\n## 6. 常见问题\n\n### 6.1 模型无视觉输出\n\n- 模型本身是否支持视觉\n- 图片链接是否可公网访问\n- 图片格式/大小是否超限\n\n### 6.2 回答偏离图像内容\n\n- 提示词加约束：仅基于图像可见信息回答\n- 减少无关历史消息\n- 必要时强制输出结构化字段（对象、动作、场景）\n\n## 7. 最佳实践\n\n- 视觉任务优先短 prompt，避免过度引导。\n- 关键业务场景做“模型回归样本库”。\n- 图像识别结果要做二次校验（特别是高风险场景）。\n\n如果你要做“图片生成”而不是“图片理解”，请看 `Image 服务` 页面。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/chat/non-stream.md",
    "content": "﻿---\nsidebar_position: 10\n---\n\n# Chat（非流式）\n\n本页聚焦 `IChatService#chatCompletion(...)` 的标准调用路径。\n\n## 1. 核心对象\n\n- 请求：`ChatCompletion`\n- 消息：`ChatMessage`\n- 响应：`ChatCompletionResponse`\n\n最常用构造方式：\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withSystem(\"你是一个简洁的 Java 助手\"))\n        .message(ChatMessage.withUser(\"用 3 点解释线程池拒绝策略\"))\n        .build();\n```\n\n## 2. 最小示例\n\n```java\nIChatService chatService = aiService.getChatService(PlatformType.OPENAI);\nChatCompletionResponse response = chatService.chatCompletion(request);\n\nString text = response.getChoices().get(0).getMessage().getContent().getText();\nSystem.out.println(text);\n```\n\n## 3. 常用参数\n\n`ChatCompletion` 关键参数：\n\n- `model`：模型 ID\n- `messages`：对话消息\n- `temperature` / `topP`：采样控制\n- `maxCompletionTokens`：输出上限\n- `responseFormat`：结构化输出（例如 json_object）\n- `user`：用户标识\n- `extraBody`：平台扩展参数\n\n## 4. 多轮对话写法\n\n```java\nList<ChatMessage> history = new ArrayList<>();\nhistory.add(ChatMessage.withSystem(\"你是代码审查助手\"));\nhistory.add(ChatMessage.withUser(\"请审查这段 SQL\"));\nhistory.add(ChatMessage.withAssistant(\"请把 SQL 发我\"));\nhistory.add(ChatMessage.withUser(\"select * from user where id = ?\"));\n\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .messages(history)\n        .build();\n```\n\n## 5. 平台覆写参数\n\n如果你要传某平台特有字段，用 `extraBody`：\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"hello\"))\n        .extraBody(\"seed\", 2026)\n        .extraBody(\"custom_flag\", true)\n        .build();\n```\n\n## 6. 错误处理建议\n\n- SDK 层已集成统一错误拦截器（starter 默认启用 `ErrorInterceptor`）。\n- 业务层建议封装统一异常：`AI_TIMEOUT`、`AI_RATE_LIMIT`、`AI_INVALID_REQUEST`。\n\n## 7. 质量基线测试\n\n建议在集成测试中至少断言：\n\n- `response != null`\n- `choices` 非空\n- 最终文本非空\n- token usage（如果开启）可读\n\n## 8. 何时不适合用非流式\n\n以下场景建议切流式：\n\n- 输出较长，用户等待感强\n- 需要实时展示中间结果\n- 需要尽早判断是否中止请求\n\n下一页：`Chat（流式）`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/chat/stream.md",
    "content": "﻿---\nsidebar_position: 11\n---\n\n# Chat（流式）\n\n本页聚焦 `IChatService#chatCompletionStream(...)` 与 `SseListener` 的使用细节。\n\n## 1. 核心结论\n\n- Chat 流式是 SSE 增量输出。\n- 监听器里最常用字段是 `getCurrStr()`。\n- 最终聚合内容可从 `getOutput()` 读取。\n\n## 2. 最小示例\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请分 5 点介绍 JVM 内存模型\"))\n        .stream(true)\n        .streamOptions(new StreamOptions(true))\n        .build();\n\nSseListener listener = new SseListener() {\n    @Override\n    protected void send() {\n        if (!getCurrStr().isEmpty()) {\n            System.out.print(getCurrStr());\n        }\n    }\n};\n\nchatService.chatCompletionStream(request, listener);\nSystem.out.println(\"\\nstream finished\");\n```\n\n## 3. `SseListener` 重要字段\n\n- `getCurrStr()`：当前增量（文本或工具参数片段）\n- `getCurrData()`：当前完整 SSE 数据包原文\n- `getOutput()`：累计输出\n- `getToolCalls()`：累计工具调用\n- `getUsage()`：token 统计（需 `stream_options.include_usage=true`）\n- `getFinishReason()`：结束原因（`stop` / `tool_calls` 等）\n\n## 4. 如何区分文本与工具参数增量\n\n在 `SseListener` 中：\n\n- 常规文本：`currToolName` 为空且 `currStr` 为文本\n- 工具参数：`currToolName` 不为空，`currStr` 可能是 JSON 参数片段\n\n如果你只想打印文本，可在 `send()` 中加过滤逻辑。\n\n## 5. 为什么看起来不是 token 级输出\n\nSSE 分片粒度由平台决定，不一定“一个 token 一次回调”。\n你可能看到按“字、词、短句、整段”输出，都是正常行为。\n\n## 6. 流式 + 工具调用时的行为\n\n当模型返回 `tool_calls` 时，Chat 服务实现会：\n\n1. 收集工具调用参数\n2. 调用 `ToolUtil.invoke(...)`\n3. 回填 `tool` 消息\n4. 自动继续下一轮模型请求\n\n也就是“模型 -> 工具 -> 模型”的闭环是内置完成的。\n\n## 7. 常见问题\n\n### 7.1 只看到最终结果\n\n- 检查是否在 `send()` 里打印 `getCurrStr()`。\n- 不要只在结尾打印 `getOutput()`。\n\n### 7.2 流式不结束\n\n- 检查网络层是否被代理/网关中断。\n- 检查监听器是否因为异常提前退出。\n\n### 7.3 token usage 始终是 0\n\n- 确认请求中包含 `stream_options.include_usage=true`。\n\n## 8. 生产建议\n\n- 给每次流式请求绑定 `requestId` 与用户标识。\n- 对输出做长度上限，避免超长回包压垮前端。\n- 前端支持“停止生成”按钮并联动取消请求。\n\n下一页建议阅读：`Chat / Function Call 与 Tool 注册`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/chat/tool-calling.md",
    "content": "---\nsidebar_position: 12\n---\n\n# Chat / Function Call 与 Tool 注册\n\n本页说明两件事：\n\n1. 怎么把 Java 方法注册成模型可调用工具\n2. Chat 链路如何自动执行工具并回填结果\n\n## 1. 工具注解体系\n\nai4j 内置三类注解：\n\n- `@FunctionCall`：定义工具名和描述\n- `@FunctionRequest`：定义入参类\n- `@FunctionParameter`：定义参数描述/必填\n\n示例（简化版）：\n\n```java\n@FunctionCall(name = \"queryWeather\", description = \"查询天气\")\npublic class QueryWeatherFunction implements Function<QueryWeatherFunction.Request, String> {\n\n    @Data\n    @FunctionRequest\n    public static class Request {\n        @FunctionParameter(description = \"城市名\", required = true)\n        private String location;\n\n        @FunctionParameter(description = \"类型: now/daily/hourly\")\n        private String type;\n\n        @FunctionParameter(description = \"天数\")\n        private int days;\n    }\n\n    @Override\n    public String apply(Request request) {\n        return \"...\";\n    }\n}\n```\n\n## 2. 在请求里暴露工具\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"查询北京天气并给出建议\"))\n        .functions(\"queryWeather\")\n        .build();\n```\n\n关键点：\n\n- `functions(...)` 是显式白名单。\n- 不传就不暴露（避免权限过宽）。\n\n## 3. MCP 工具暴露\n\n除了本地 Function，还可以暴露 MCP 服务工具：\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请读取我的仓库 issue\"))\n        .mcpServices(\"github-service\")\n        .build();\n```\n\n底层走 `ToolUtil.getAllTools(functionList, mcpServerIds)`。\n\n## 4. 工具暴露语义（当前行为）\n\n`ToolUtil.getAllTools(...)` 的语义是：\n\n- 传什么，暴露什么\n- 不会自动把全部本地 MCP 工具塞给模型\n\n这点对安全非常关键。\n\n## 5. Chat 自动工具循环\n\n在 `OpenAiChatService` 等实现里，工具循环是自动的：\n\n1. 模型返回 `finish_reason=tool_calls`\n2. SDK 执行每个 tool call（`ToolUtil.invoke`）\n3. 追加 `assistant(tool_calls)` + `tool(result)` 消息\n4. 再次请求模型\n5. 直到 `finish_reason=stop`\n\n你不需要手写循环控制器。\n\n## 6. 并行工具调用\n\n`ChatCompletion.parallelToolCalls` 控制并行语义。\n\n建议：\n\n- 写操作工具默认关闭并行\n- 查询类工具可开启并行\n\n## 7. 常见问题\n\n### 7.1 模型不触发工具\n\n- `functions(...)` 是否传了正确名称\n- 工具描述是否足够明确\n- 用户指令是否明确“先调用工具再回答”\n\n### 7.2 工具参数解析异常\n\n- 确认 `@FunctionRequest` 入参字段名与模型返回 JSON 一致\n- 参数枚举/类型边界要写清楚\n\n### 7.3 工具结果太长\n\n- 先在工具层做裁剪/摘要\n- 避免将超长原始 JSON 全量回填\n\n## 8. 与 Agent 的关系\n\nAgent 的 `toolRegistry(...)` 本质也是在构建这层“工具白名单”，\n只是把调用策略从 Chat 服务层升级到 Agent runtime 层。\n\n如果业务变复杂（多步推理/路由/循环），建议迁移到 Agent。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/embedding.md",
    "content": "﻿---\nsidebar_position: 30\n---\n\n# Embedding 接口\n\nEmbedding 是 RAG 的基础能力，ai4j 统一为 `IEmbeddingService`。\n\n## 1. 支持平台\n\n当前 `AiService#getEmbeddingService(...)` 支持：\n\n- `OPENAI`\n- `OLLAMA`\n\n## 2. 最小示例\n\n```java\nIEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OPENAI);\n\nEmbedding request = Embedding.builder()\n        .model(\"text-embedding-3-small\")\n        .input(\"Explain JVM class loading\")\n        .build();\n\nEmbeddingResponse response = embeddingService.embedding(request);\nList<Float> vector = response.getData().get(0).getEmbedding();\nSystem.out.println(vector.size());\n```\n\n## 3. 批量向量化\n\n`input` 可传 `List<String>`，用于批量生成向量：\n\n```java\nEmbedding request = Embedding.builder()\n        .model(\"text-embedding-3-small\")\n        .input(Arrays.asList(\"文档片段1\", \"文档片段2\", \"文档片段3\"))\n        .build();\n```\n\n## 4. Ollama 兼容细节\n\n`OllamaEmbeddingService` 会把 Ollama 返回结果转换成 OpenAI 风格 `EmbeddingResponse`：\n\n- `data[i].embedding`\n- `usage`\n- `model`\n\n这就是“协议消歧”的核心体现。\n\n## 5. 参数与模型建议\n\n- 固定一个 embedding 模型，不要混用\n- 同一索引内向量维度必须一致\n- 批量请求优先控制单批大小，避免超时\n\n## 6. 常见问题\n\n### 6.1 向量维度不一致\n\n通常是模型变更导致，需重新建索引或重建数据。\n\n### 6.2 返回为空\n\n- API key 未配置\n- 模型名无效\n- 上游限流或超时\n\n### 6.3 批量很慢\n\n建议：\n\n- 分批并发（但注意限流）\n- 缓存重复文本的向量结果\n\n## 7. 与 Pinecone 的配合\n\nEmbedding 产物通常直接写入 Pinecone：\n\n- `List<List<Float>>` -> 向量\n- 原文 -> metadata.content\n\n完整流程见：`Pinecone 向量检索工作流`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/image-generation.md",
    "content": "﻿---\nsidebar_position: 32\n---\n\n# Image 接口（生成与流式）\n\n图像能力统一在 `IImageService`，当前支持：\n\n- `OPENAI`\n- `DOUBAO`\n\n## 1. 非流式生成\n\n```java\nIImageService imageService = aiService.getImageService(PlatformType.OPENAI);\n\nImageGeneration request = ImageGeneration.builder()\n        .model(\"gpt-image-1\")\n        .prompt(\"A clean isometric illustration of a Java microservice\")\n        .size(\"1024x1024\")\n        .responseFormat(\"url\")\n        .build();\n\nImageGenerationResponse response = imageService.generate(request);\nSystem.out.println(response);\n```\n\n## 2. 流式生成\n\n```java\nImageGeneration request = ImageGeneration.builder()\n        .model(\"gpt-image-1\")\n        .prompt(\"A futuristic city at sunrise\")\n        .stream(true)\n        .partialImages(1)\n        .responseFormat(\"b64_json\")\n        .build();\n\nImageSseListener listener = new ImageSseListener() {\n    @Override\n    protected void onEvent() {\n        ImageStreamEvent e = getCurrEvent();\n        if (e != null) {\n            System.out.println(\"event=\" + e.getType() + \", idx=\" + e.getImageIndex());\n        }\n    }\n};\n\nimageService.generateStream(request, listener);\n```\n\n## 3. 请求参数（`ImageGeneration`）\n\n常用字段：\n\n- `model`\n- `prompt`\n- `n`\n- `size`\n- `quality`\n- `responseFormat`（`url` / `b64_json`）\n- `outputFormat`（`png` / `jpeg` / `webp`）\n- `outputCompression`\n- `background`\n- `partialImages`\n- `stream`\n- `user`\n- `extraBody`\n\n## 4. 监听器字段（`ImageSseListener`）\n\n- `getCurrEvent()`：当前图片事件\n- `getEvents()`：全量事件\n- `getResponse()`：聚合后的图片响应\n\n## 5. 事件模型说明\n\n流式中可能出现：\n\n- partial image 事件\n- completed 事件\n- error 事件\n\n监听器默认会把“最终图像事件”聚合进 `ImageGenerationResponse`。\n\n## 6. OpenAI 与豆包差异处理\n\nSDK 已做协议适配：\n\n- 请求体字段转换（豆包使用 `DoubaoImageGenerationRequest`）\n- 事件字段兼容（`created` / `created_at`）\n\n业务层可以用同一套 `ImageGeneration`/`ImageSseListener`。\n\n## 7. 常见问题\n\n### 7.1 只收到 partial 没有 final\n\n- 检查是否接收到 `image_generation.completed`\n- 检查网络中断与超时\n\n### 7.2 URL 可访问性问题\n\n- 部分平台返回临时 URL，需尽快下载/转存\n- 生产建议落盘到对象存储\n\n### 7.3 base64 太大\n\n- 建议改用 `url` 模式\n- 或降低分辨率和质量\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/overview.md",
    "content": "﻿---\nsidebar_position: 1\n---\n\n# 核心 SDK 总览\n\n本章用于梳理 ai4j 核心 SDK 的能力边界、入口结构与推荐阅读顺序。\n\n本章把 ai4j 的基础能力拆成可独立阅读的子模块，每个模块都包含：\n\n- 能力边界（能做什么/不能做什么）\n- 核心类与关键参数\n- 同步与流式调用差异\n- 工程化建议与常见坑\n\n## 1. 设计目标\n\nai4j 的核心目标是“**跨平台协议消歧**”：\n\n- 业务代码只依赖统一接口\n- 平台差异收敛在服务实现层\n- 模型切换成本最小化\n\n统一入口由 `AiService` 提供，统一接口包括：\n\n- `IChatService`\n- `IResponsesService`\n- `IEmbeddingService`\n- `IAudioService`\n- `IImageService`\n- `IRealtimeService`\n\n## 2. 阅读顺序（推荐）\n\n第一次接入建议按下面顺序阅读：\n\n1. `平台与服务矩阵`\n2. `Chat / 非流式`\n3. `Chat / 流式`\n4. `Responses / 流式事件模型`\n5. `Function Call 与工具注册`\n6. `AgentFlow（Dify / Coze / n8n）`\n7. `多模态`\n8. `SPI、SearXNG、Pinecone`\n\n## 3. 章节目录\n\n### 3.1 平台与协议层\n\n- `平台与服务矩阵`\n- `Chat vs Responses 选型`\n\n### 3.2 Chat Completions\n\n- `非流式调用`\n- `流式调用`\n- `Function Call 与 Tool 注册`\n- `多模态（Vision）`\n\n### 3.3 Responses API\n\n- `非流式调用`\n- `流式事件模型`\n\n### 3.4 Published Agent / Workflow 端点\n\n- `AgentFlow 总览`\n- `AgentFlow 协议映射与工作原理`\n\n### 3.5 其他服务\n\n- `Embedding`\n- `Audio`\n- `Image`\n- `Realtime`\n\n### 3.6 工程增强能力\n\n- `SearXNG 联网搜索增强`\n- `Pinecone 向量检索工作流`\n- `SPI：Dispatcher 与 ConnectionPool`\n\n## 4. 本章完成后可掌握的内容\n\n读完这一章后，你应该可以：\n\n- 用同一套实体在 OpenAI / 豆包 / DashScope / Ollama 间切换\n- 解释 Chat 与 Responses 的事件模型差异\n- 区分“模型 provider 接入”和“已发布 Agent / Workflow 端点接入”\n- 正确使用 tool/function/mcp 的暴露语义\n- 在项目里按“最小侵入”接入联网检索与向量检索\n- 按并发模型自定义 OkHttp Dispatcher 与连接池\n\n## 5. 对应代码入口\n\n- 服务工厂：`ai4j/src/main/java/io/github/lnyocly/ai4j/service/factor/AiService.java`\n- 平台枚举：`ai4j/src/main/java/io/github/lnyocly/ai4j/service/PlatformType.java`\n- 统一配置：`ai4j/src/main/java/io/github/lnyocly/ai4j/service/Configuration.java`\n- AgentFlow：`ai4j/src/main/java/io/github/lnyocly/ai4j/agentflow`\n\n本章后续页面会围绕这些入口逐层展开。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/pinecone-rag-workflow.md",
    "content": "---\nsidebar_position: 41\n---\n\n# Pinecone 向量检索工作流\n\n本页按“入库 -> 召回 -> 生成”给出 ai4j 当前更推荐的 Pinecone RAG 流程。\n\n核心类：\n\n- `VectorStore`\n- `PineconeVectorStore`\n- `IngestionPipeline`\n- `IngestionRequest`\n- `IngestionSource`\n- `RagService`\n- `RagQuery`\n\n## 1. 配置\n\n```java\nPineconeConfig pineconeConfig = new PineconeConfig();\npineconeConfig.setHost(\"https://<index-host>\");\npineconeConfig.setKey(System.getenv(\"PINECONE_API_KEY\"));\n\nconfiguration.setPineconeConfig(pineconeConfig);\nVectorStore vectorStore = aiService.getPineconeVectorStore();\n```\n\n## 2. 入库（推荐路径）\n\n```java\nIngestionPipeline ingestionPipeline = aiService.getPineconeIngestionPipeline(PlatformType.OPENAI);\n\nIngestionResult ingestResult = ingestionPipeline.ingest(IngestionRequest.builder()\n        .dataset(\"tenant_a_legal_v202603\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .document(RagDocument.builder()\n                .sourceName(\"法规汇编\")\n                .sourcePath(\"/docs/law.txt\")\n                .tenant(\"tenant_a\")\n                .biz(\"legal\")\n                .version(\"2026.03\")\n                .build())\n        .source(IngestionSource.file(new File(\"D:/data/law.txt\")))\n        .build());\n\nSystem.out.println(\"upserted=\" + ingestResult.getUpsertedCount());\n```\n\n## 3. 查询\n\n```java\nRagService ragService = aiService.getRagService(\n        PlatformType.OPENAI,\n        vectorStore\n);\n\nRagQuery ragQuery = RagQuery.builder()\n        .query(\"违约金怎么算\")\n        .dataset(\"tenant_a_legal_v202603\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .topK(5)\n        .build();\n\nRagResult ragResult = ragService.search(ragQuery);\nString context = ragResult.getContext();\n```\n\n## 4. 可选：接入 Rerank\n\n```java\nReranker reranker = aiService.getModelReranker(\n        PlatformType.JINA,\n        \"jina-reranker-v2-base-multilingual\",\n        5,\n        \"优先法规原文、条款标题和编号明确的片段\"\n);\n```\n\n## 5. 参数建议\n\n- `dataset`：建议直接编码 tenant / biz / version\n- `topK`：先从 3~8 调优\n- `chunkSize`：600~1200\n- `chunkOverlap`：10%~25%\n\n## 6. 工程实践\n\n- metadata 至少保留：`content/source/title/version/updatedAt`\n- 向量模型固定，不要混维度\n- 索引重建策略提前设计（版本迁移）\n- 如果只是做通用 RAG，优先面向 `VectorStore / IngestionPipeline / RagService`\n\n## 7. 与 Chat/Agent 结合\n\n召回出的 `context` 可注入 Chat 或 Agent 的输入：\n\n- Chat：拼接到 user/system\n- Agent：写入上下文状态，再交给回答节点\n\n## 8. 常见问题\n\n### 8.1 召回为空\n\n- dataset 错\n- 向量维度不一致\n- 数据未成功 upsert\n\n### 8.2 召回有内容但回答不准\n\n- 分块策略不合理\n- topK 不合适\n- prompt 未限制“必须基于证据”\n\n### 8.3 成本过高\n\n- 批量 embedding 做缓存\n- 去重后再向量化\n- 低价值文档定期清理\n\n### 8.4 什么时候还需要直接用已废弃的 `PineconeService`（Deprecated）\n\n`PineconeService` 目前在文档层已视为 Deprecated。只有在你明确需要 Pinecone 特有底层能力时，才建议继续直接使用：\n\n- namespace 级专用管理操作\n- 已有旧项目已经大量使用 `PineconeQuery / PineconeDelete`\n- 你在封装 Pinecone 专用能力，而不是统一 RAG 能力\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/platform-service-matrix.md",
    "content": "﻿---\nsidebar_position: 2\n---\n\n# 平台与服务矩阵\n\n这页回答两个问题：\n\n1. 当前代码到底支持哪些平台\n2. 每个平台支持到哪类服务\n\n> 说明：本页以 `AiService` 当前实现为准。\n\n## 1. 平台枚举\n\n平台枚举定义在 `PlatformType`：\n\n- `OPENAI`\n- `ZHIPU`\n- `DEEPSEEK`\n- `MOONSHOT`\n- `HUNYUAN`\n- `LINGYI`\n- `OLLAMA`\n- `MINIMAX`\n- `BAICHUAN`\n- `DASHSCOPE`\n- `DOUBAO`\n\n## 2. 服务能力矩阵\n\n| 平台 | Chat | Responses | Embedding | Audio | Realtime | Image |\n| --- | --- | --- | --- | --- | --- | --- |\n| OPENAI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| DOUBAO | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |\n| DASHSCOPE | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |\n| OLLAMA | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |\n| ZHIPU | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| DEEPSEEK | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| MOONSHOT | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| HUNYUAN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| LINGYI | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| MINIMAX | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| BAICHUAN | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n\n## 3. 统一调用入口\n\n```java\nAiService aiService = new AiService(configuration);\n\nIChatService chat = aiService.getChatService(PlatformType.OPENAI);\nIResponsesService responses = aiService.getResponsesService(PlatformType.DOUBAO);\nIEmbeddingService embedding = aiService.getEmbeddingService(PlatformType.OLLAMA);\nIImageService image = aiService.getImageService(PlatformType.DOUBAO);\n```\n\n如果平台不支持该服务，会抛出 `IllegalArgumentException`。\n\n## 4. Spring Boot 配置前缀\n\n`ai4j-spring-boot-starter` 对应的常用配置前缀：\n\n- `ai.openai.*`\n- `ai.doubao.*`\n- `ai.dashscope.*`\n- `ai.ollama.*`\n- `ai.zhipu.*`\n- `ai.deepseek.*`\n- `ai.moonshot.*`\n- `ai.hunyuan.*`\n- `ai.lingyi.*`\n- `ai.minimax.*`\n- `ai.baichuan.*`\n- 通用网络：`ai.okhttp.*`\n\n## 5. 多平台实例（AiServiceRegistry）\n\n如果你需要“一个应用内管理多套平台实例（按 id 路由）”，优先使用 `AiServiceRegistry`。`FreeAiService` 仍保留兼容静态方法。\n\n```java\nIChatService tenantA = aiServiceRegistry.getChatService(\"tenant-a-openai\");\nIChatService tenantB = aiServiceRegistry.getChatService(\"tenant-b-doubao\");\n\n// 兼容旧用法\nIChatService legacy = FreeAiService.getChatService(\"tenant-a-openai\");\n```\n\n适合：\n\n- 多租户隔离\n- 灰度切换模型\n- A/B 平台对比\n\n## 6. 工程建议\n\n- 业务层只依赖接口（`IChatService` 等），不要直连平台实现类。\n- 平台选择下沉到配置/工厂层。\n- 日志至少记录：`platform + service + model + traceId`。\n\n这样后续换平台时，业务代码改动最小。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/realtime.md",
    "content": "﻿---\nsidebar_position: 33\n---\n\n# Realtime 接口（WebSocket）\n\nRealtime 能力统一在 `IRealtimeService`，当前由 OpenAI 实现。\n\n## 1. 核心入口\n\n```java\nIRealtimeService realtimeService = aiService.getRealtimeService(PlatformType.OPENAI);\n```\n\n建立连接：\n\n```java\nWebSocket ws = realtimeService.createRealtimeClient(\n        \"gpt-4o-realtime-preview\",\n        new RealtimeListener() {\n            @Override\n            protected void onOpen(WebSocket webSocket) {\n                System.out.println(\"opened\");\n            }\n\n            @Override\n            protected void onMessage(ByteString bytes) {\n                System.out.println(\"binary=\" + bytes.size());\n            }\n\n            @Override\n            protected void onMessage(String text) {\n                System.out.println(\"text=\" + text);\n            }\n\n            @Override\n            protected void onFailure() {\n                System.out.println(\"failed\");\n            }\n        }\n);\n```\n\n## 2. 连接参数\n\n`createRealtimeClient(baseUrl, apiKey, model, listener)` 支持：\n\n- 自定义 baseUrl\n- 自定义 apiKey\n- 模型名\n- 监听器\n\n默认请求头会带：\n\n- `Authorization: Bearer ...`\n- `OpenAI-Beta: realtime=v1`\n\n## 3. 监听器设计\n\n`RealtimeListener` 是 `WebSocketListener` 的抽象封装，约定了：\n\n- `onOpen`\n- `onMessage(ByteString)`\n- `onMessage(String)`\n- `onFailure`\n\n你可以在 `onMessage(String)` 里做事件分发。\n\n## 4. 使用建议\n\n- 长连接场景建议独立线程池管理\n- 连接断开要做重连策略（指数退避）\n- 消息处理要做 backpressure（防止消费跟不上）\n\n## 5. 常见问题\n\n### 5.1 建连失败\n\n- 检查模型名是否可用\n- 检查网络/代理是否允许 WebSocket\n- 检查 API key 与 host\n\n### 5.2 消息处理阻塞\n\n- 不要在回调里做重 CPU 工作\n- 将业务逻辑投递到异步队列\n\n### 5.3 连接无故关闭\n\n- 服务端超时回收\n- 心跳缺失\n- 网关空闲连接策略\n\n## 6. 生产化建议\n\n- 给每个连接分配 `sessionId`\n- 记录建连耗时、断连原因、重连次数\n- 对音视频大包加大小限制与审计\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/responses/chat-vs-responses.md",
    "content": "﻿---\nsidebar_position: 22\n---\n\n# Chat vs Responses 选型\n\n这页给一个工程化结论，而不是概念对比。\n\n## 1. 快速结论\n\n- 追求兼容存量、迁移平滑：优先 **Chat**。\n- 追求事件结构化、可观测性、Agent runtime：优先 **Responses**。\n\n## 2. 对比维度\n\n| 维度 | Chat | Responses |\n| --- | --- | --- |\n| 存量兼容 | 高 | 中 |\n| 文本直出易用性 | 高 | 中 |\n| 事件结构化 | 中 | 高 |\n| 推理/函数参数事件可观测 | 中 | 高 |\n| 迁移成本 | 低 | 中 |\n| 适合作为 Agent 底层 | 中 | 高 |\n\n## 3. Chat 更合适的场景\n\n- 你已有大量 `chatCompletion` 代码\n- 需求是“稳定文本回答 + 工具调用”\n- 团队优先低改造成本\n\n## 4. Responses 更合适的场景\n\n- 你要做 trace/审计/事件回放\n- 你要区分 reasoning、message、function arguments\n- 你要构建新的 agent runtime / workflow\n\n## 5. 关于“流式是否 token 级”\n\n两个接口都不能保证 token 级分片。\n\n- Chat 常见更细粒度文本片段\n- Responses 常见事件片段（可能按句）\n\n这不是 SDK 错误，而是上游流式切片策略。\n\n## 6. 推荐迁移路径\n\n### 阶段 1\n\n先保留 Chat 作为主链路，补齐：\n\n- 统一工具注册\n- 统一日志字段\n- 流式回调规范\n\n### 阶段 2\n\n在新业务/新 Agent 中优先 Responses：\n\n- 基于事件做 trace\n- 把工具循环放到 runtime\n\n### 阶段 3\n\n双栈共存，按场景路由：\n\n- 普通问答 -> Chat\n- 智能体编排 -> Responses\n\n## 7. 一个实践建议\n\n不要做“全量切换”。\n\n最优雅做法是：\n\n- 抽象统一的 `ModelClient` 接口\n- Chat/Responses 作为实现\n- 在 AgentBuilder 或业务配置中按场景注入\n\n这样切换成本最低，也最符合开源组件可扩展性。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/responses/non-stream.md",
    "content": "﻿---\nsidebar_position: 20\n---\n\n# Responses（非流式）\n\n`Responses API` 适合事件化语义更强的场景。本页先讲非流式。\n\n## 1. 核心对象\n\n- 服务接口：`IResponsesService`\n- 请求：`ResponseRequest`\n- 响应：`Response`\n\n## 2. 支持平台\n\n当前 `AiService#getResponsesService(...)` 支持：\n\n- `OPENAI`\n- `DOUBAO`\n- `DASHSCOPE`\n\n## 3. 最小示例\n\n```java\nIResponsesService responsesService = aiService.getResponsesService(PlatformType.DOUBAO);\n\nResponseRequest request = ResponseRequest.builder()\n        .model(\"doubao-seed-1-8-251228\")\n        .input(\"请用一句话介绍 Responses API\")\n        .instructions(\"用中文输出\")\n        .maxOutputTokens(256)\n        .build();\n\nResponse response = responsesService.create(request);\nSystem.out.println(response);\n```\n\n## 4. 常用字段\n\n`ResponseRequest` 常用参数：\n\n- `model`\n- `input`（可字符串，也可结构化对象）\n- `instructions`\n- `reasoning`\n- `tools`\n- `toolChoice`\n- `parallelToolCalls`\n- `maxOutputTokens`\n- `temperature`\n- `topP`\n- `metadata`\n- `extraBody`\n\n## 5. 与 Chat 非流式的差异\n\n- Chat 响应主路径是 `choices[0].message`\n- Responses 响应主路径是 `output`（可含 message/reasoning/function_call 等 item）\n\n如果你要拿最终文本，通常需要从 `response.output` 中提取 `message` item 的 `output_text`。\n\n## 6. OpenAI 请求体字段收敛说明\n\n在 `OpenAiResponsesService` 中，SDK 会对请求体字段做白名单收敛。\n\n含义：\n\n- 只有协议允许字段会被发送\n- `extraBody` 中不在白名单的字段会被忽略\n\n这能减少无效字段导致的请求失败。\n\n## 7. 非流式适用场景\n\n- 你只关心最终结果，不关心中间事件\n- 你希望简化回调处理逻辑\n- 批量离线任务（摘要、改写、分类）\n\n## 8. 常见问题\n\n### 8.1 返回对象有内容但你看不到文本\n\n`Response` 不是单一 `content` 字段，注意解析 `output` 列表。\n\n### 8.2 延迟比 Chat 更明显\n\n部分模型在 Responses 下会产出更多中间语义项，建议用流式提升体验。\n\n下一页：`Responses（流式事件模型）`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/responses/stream-events.md",
    "content": "﻿---\nsidebar_position: 21\n---\n\n# Responses（流式事件模型）\n\n这是 Responses 与 Chat 最大差异点：Responses 是**事件流**，而不是只吐文本 token。\n\n## 1. 调用方式\n\n```java\nResponseRequest request = ResponseRequest.builder()\n        .model(\"doubao-seed-1-8-251228\")\n        .input(\"Describe the Responses API in one sentence\")\n        .stream(true)\n        .build();\n\nResponseSseListener listener = new ResponseSseListener() {\n    @Override\n    protected void onEvent() {\n        if (!getCurrText().isEmpty()) {\n            System.out.print(getCurrText());\n        }\n    }\n};\n\nresponsesService.createStream(request, listener);\nSystem.out.println(\"\\nstream finished\");\n```\n\n## 2. 典型事件类型\n\n常见事件（平台可能有差异）：\n\n- `response.created`\n- `response.in_progress`\n- `response.output_text.delta`\n- `response.reasoning_summary_text.delta`\n- `response.function_call_arguments.delta`\n- `response.completed`\n- `response.failed`\n- `response.incomplete`\n\n## 3. `ResponseSseListener` 字段说明\n\n- `getCurrEvent()`：当前事件对象\n- `getEvents()`：全部事件列表\n- `getCurrText()`：当前文本增量\n- `getOutputText()`：累计文本\n- `getReasoningSummary()`：累计 reasoning summary\n- `getCurrFunctionArguments()`：当前函数参数增量\n- `getFunctionArguments()`：累计函数参数\n- `getResponse()`：聚合后的 Response 快照\n\n## 4. 为什么看起来“不是 token-by-token”\n\n因为事件粒度由平台决定：\n\n- 有的平台按字输出\n- 有的平台按词或短句输出\n- 有的平台一次给整段\n\n所以你看到“一句话才刷新一次”不一定是错误，有可能是上游分片策略。\n\n## 5. 终态判定\n\n`OpenAiResponsesService` / `DoubaoResponsesService` 里，以下事件会触发完成：\n\n- `response.completed`\n- `response.failed`\n- `response.incomplete`\n\n以及 SSE 的 `[DONE]`。\n\n## 6. 参数流观察技巧\n\n如果你在做函数调用排障，建议在回调里打印：\n\n```java\nif (!getCurrFunctionArguments().isEmpty()) {\n    System.out.println(\"ARGS DELTA=\" + getCurrFunctionArguments());\n}\n```\n\n这样可以确认参数是模型没生成，还是你只打印了文本增量。\n\n## 7. 常见坑\n\n### 7.1 只看最终 `listener.getResponse()`，误判“流式没输出”\n\n`getResponse()` 是聚合快照，不代表中间增量没来。\n\n### 7.2 控制台缓冲导致晚显示\n\nIDE 控制台可能缓冲，建议：\n\n- 简化输出\n- 使用 `System.out.print` + 手动换行\n- 对比事件时间戳\n\n### 7.3 流式回调异常中断\n\n在 `onEvent()` 里抛异常会直接中断流式处理，建议回调内部捕获异常。\n\n## 8. 生产建议\n\n- 将事件写入结构化日志（type、sequence、latency、traceId）\n- 前端按事件类型渲染，而不是只按文本渲染\n- 高价值场景保存 `response.failed` 的 error payload 便于追查\n\n## 9. 与 Agent 的关系\n\n如果你要自动处理“函数参数流 -> 执行工具 -> 再请求模型”，\n建议在 Agent runtime 层实现，不要把流程硬写在 Controller。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/searxng-enhancement.md",
    "content": "﻿---\nsidebar_position: 40\n---\n\n# SearXNG 联网增强（Chat Decorator）\n\nai4j 提供了一个非常实用的增强模式：把任意 `IChatService` 包装成“先检索再回答”。\n\n核心类：`ChatWithWebSearchEnhance`。\n\n## 1. 设计思路\n\n```text\n用户问题\n  -> SearXNG 检索\n  -> 结果截断/拼接\n  -> 注入到最后一条 user message\n  -> 调用原始 Chat 服务\n```\n\n你不需要改业务控制器，只要替换服务实例。\n\n## 2. 配置\n\n### 2.1 非 Spring\n\n```java\nSearXNGConfig searXNGConfig = new SearXNGConfig();\nsearXNGConfig.setUrl(\"http://127.0.0.1:8080/search\");\nsearXNGConfig.setEngines(\"duckduckgo,google,bing\");\nsearXNGConfig.setNums(5);\n\nconfiguration.setSearXNGConfig(searXNGConfig);\n```\n\n### 2.2 Spring\n\n```yaml\nai:\n  websearch:\n    searxng:\n      url: http://127.0.0.1:8080/search\n      engines: duckduckgo,google,bing\n      nums: 5\n```\n\n## 3. 使用方式\n\n```java\nIChatService rawChat = aiService.getChatService(PlatformType.OPENAI);\nIChatService webEnhanced = aiService.webSearchEnhance(rawChat);\n\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请总结今天 AI Agent 领域的重要动态\"))\n        .build();\n\nChatCompletionResponse response = webEnhanced.chatCompletion(request);\n```\n\n## 4. 流式同样生效\n\n`chatCompletionStream(...)` 会走同样的检索注入逻辑。\n\n## 5. 检索上下文注入策略\n\n当前实现会把检索结果拼接到最后一条用户消息中，包含：\n\n- 网络资料\n- 用户问题\n- 回答格式要求\n\n如果你要更严格控制，可自定义一个装饰器实现。\n\n## 6. 关键参数建议\n\n- `nums`：3~8（太大会污染上下文）\n- `engines`：先从 2~4 个开始\n- query 复写策略：必要时先做 query rewrite\n\n## 7. 与 RAG 的组合\n\n推荐混合顺序：\n\n1. 私域检索（Pinecone）\n2. 不足时补充 SearXNG\n3. 合并证据后回答\n\n这样兼顾准确性（私域）与时效性（公网）。\n\n## 8. 安全建议\n\n- 对检索文本做清洗，去除 prompt 注入片段\n- 对来源域名做白名单过滤\n- 高风险问题加人工复核\n\n## 9. 常见问题\n\n### 9.1 `SearXNG url is not configured`\n\n说明 `SearXNGConfig.url` 为空。\n\n### 9.2 回答质量下降\n\n- 检索结果噪声大\n- `nums` 太高\n- 注入文本过长\n\n### 9.3 响应慢\n\n- 搜索引擎过多\n- SearXNG 服务器性能不足\n- 网络链路不稳定\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/core-sdk/spi-http-stack.md",
    "content": "﻿---\nsidebar_position: 42\n---\n\n# SPI：Dispatcher 与 ConnectionPool\n\nai4j 的网络层扩展点通过 SPI 提供，目的是让你按业务并发模型定制 OkHttp。\n\n## 1. 扩展接口\n\n- `DispatcherProvider`\n- `ConnectionPoolProvider`\n\n默认实现：\n\n- `DefaultDispatcherProvider`\n- `DefaultConnectionPoolProvider`\n\n## 2. 为什么有必要\n\n不同业务并发模式差异很大：\n\n- 流式对话：长连接多，吞吐要求高\n- 批处理：短请求多，峰值并发高\n- 多租户：需要隔离/限速\n\n如果统一一个默认参数，往往在生产会踩坑。\n\n## 3. 自定义实现示例\n\n### 3.1 Dispatcher\n\n```java\npublic class CustomDispatcherProvider implements DispatcherProvider {\n    @Override\n    public Dispatcher getDispatcher() {\n        Dispatcher dispatcher = new Dispatcher();\n        dispatcher.setMaxRequests(256);\n        dispatcher.setMaxRequestsPerHost(64);\n        return dispatcher;\n    }\n}\n```\n\n### 3.2 ConnectionPool\n\n```java\npublic class CustomConnectionPoolProvider implements ConnectionPoolProvider {\n    @Override\n    public ConnectionPool getConnectionPool() {\n        return new ConnectionPool(100, 5, TimeUnit.MINUTES);\n    }\n}\n```\n\n## 4. SPI 注册\n\n`src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.DispatcherProvider`\n\n```text\ncom.example.CustomDispatcherProvider\n```\n\n`src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.ConnectionPoolProvider`\n\n```text\ncom.example.CustomConnectionPoolProvider\n```\n\n## 5. Spring Boot 自动装配路径\n\nstarter 在 `AiConfigAutoConfiguration` 里通过 `ServiceLoaderUtil.load(...)` 加载 SPI 并写入 `OkHttpClient.Builder`。\n\n这意味着你只要把 SPI 放到 classpath，就会自动生效。\n\n## 6. 非 Spring 用法\n\n```java\nDispatcherProvider dispatcherProvider = ServiceLoaderUtil.load(DispatcherProvider.class);\nConnectionPoolProvider poolProvider = ServiceLoaderUtil.load(ConnectionPoolProvider.class);\n\nOkHttpClient client = new OkHttpClient.Builder()\n        .dispatcher(dispatcherProvider.getDispatcher())\n        .connectionPool(poolProvider.getConnectionPool())\n        .build();\n```\n\n## 7. 调优建议\n\n先压测再调参，不要拍脑袋：\n\n- `maxRequests`\n- `maxRequestsPerHost`\n- 连接池大小\n- keepAlive 时长\n\n并结合上游平台的限流规则。\n\n## 8. 常见问题\n\n### 8.1 SPI 不生效\n\n- `META-INF/services` 文件名写错\n- 实现类全限定名写错\n- 资源没有打包进 jar\n\n### 8.2 多实现冲突\n\n当前 `ServiceLoaderUtil` 取第一个实现，建议每个应用只保留一套 SPI 实现。\n\n### 8.3 调大并发后错误更多\n\n通常是上游限流触发，建议联动：\n\n- 请求限流\n- 指数退避重试\n- 熔断降级\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/deploy/_category_.json",
    "content": "﻿{\n  \"label\": \"部署\",\n  \"position\": 6,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/deploy/cloudflare-pages.md",
    "content": "﻿---\nsidebar_position: 1\n---\n\n# Cloudflare Pages 部署\n\n推荐组合：**Docusaurus + Cloudflare Pages + 自定义域名**。\n\n## 1. 为什么选 Cloudflare Pages\n\n- 开源项目可用免费额度\n- GitHub 集成后自动构建与自动预览\n- 全球 CDN 分发，静态站访问快\n\n## 2. 部署前检查清单\n\n1. `docs-site/docusaurus.config.ts` 中 `url/baseUrl` 正确\n2. 本地 `npm run build` 成功\n3. 文档链接无 broken links\n4. 目标分支策略明确（main/dev）\n\n## 3. Cloudflare Pages 配置\n\n在控制台 `Workers & Pages -> Create -> Pages`：\n\n- Framework preset: `Docusaurus`\n- Root directory: `docs-site`\n- Build command: `npm run build`\n- Build output directory: `build`\n- Environment variable: `NODE_VERSION=20`\n\n## 4. 首次部署后验证\n\n- 首页是否可访问\n- `/docs/intro` 是否可访问\n- `/blog` 是否可访问\n- 404 页面是否显示中文\n\n## 5. 自定义域名\n\n建议绑定：`docs.ai4j.dev`\n\n绑定后检查：\n\n- DNS 解析生效\n- HTTPS 证书状态正常\n- canonical URL 与 sitemap 正确\n\n## 6. 分支策略建议\n\n- `main`：生产文档\n- `dev`：预发布文档\n- PR 分支：预览环境\n\n## 7. 常见问题\n\n### 7.1 部署后出现 404\n\n优先排查：\n\n1. Root directory 是否误填为仓库根目录\n2. `baseUrl` 是否与部署路径一致\n3. Cloudflare 缓存是否仍是旧版本\n\n### 7.2 页面还是旧文档\n\n- 触发一次重新部署\n- 执行缓存清理（Purge Cache）\n\n### 7.3 本地是中文，线上是英文\n\n- 检查 `i18n.defaultLocale` 是否为 `zh-Hans`\n- 检查是否误保留旧的翻译覆盖文件\n- 重新构建并部署，避免增量缓存污染\n\n## 8. 持续集成建议\n\n仓库已可配置 docs 构建工作流（例如 `.github/workflows/docs-build.yml`），建议每次 PR 自动执行：\n\n- markdown lint（可选）\n- docusaurus build\n- broken link 检查\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/_category_.json",
    "content": "﻿{\n  \"label\": \"快速开始\",\n  \"position\": 2,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/chat-and-responses-guide.md",
    "content": "---\nsidebar_position: 6\n---\n\n# Chat 与 Responses 实战指南\n\n这页聚焦你最常用的两条链路：`Chat Completions` 和 `Responses API`，包含同步、流式、工具调用差异和选型建议。\n\n## 1. 一句话区别\n\n- `Chat`：消息对话模型，兼容性和迁移性强。\n- `Responses`：事件化响应模型，结构化流式信息更丰富。\n\n## 2. 对应接口与监听器\n\n| 维度 | Chat | Responses |\n| --- | --- | --- |\n| 服务接口 | `IChatService` | `IResponsesService` |\n| 请求对象 | `ChatCompletion` | `ResponseRequest` |\n| 响应对象 | `ChatCompletionResponse` | `Response` |\n| 流式监听器 | `SseListener` | `ResponseSseListener` |\n| 增量文本字段 | `getCurrStr()` | `getCurrText()` |\n| 事件对象 | `ChatCompletionResponse` chunk | `ResponseStreamEvent` |\n\n## 3. Chat：同步调用\n\n```java\nIChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\nChatCompletion req = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请用一句话介绍 ai4j\"))\n        .build();\n\nChatCompletionResponse resp = chatService.chatCompletion(req);\nString text = resp.getChoices().get(0).getMessage().getContent().getText();\nSystem.out.println(text);\n```\n\n## 4. Chat：流式调用\n\n```java\nChatCompletion req = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"分三点介绍 ai4j\"))\n        .build();\n\nSseListener listener = new SseListener() {\n    @Override\n    protected void send() {\n        if (!getCurrStr().isEmpty()) {\n            System.out.print(getCurrStr());\n        }\n    }\n};\n\nchatService.chatCompletionStream(req, listener);\nSystem.out.println(\"\\nstream finished\");\n```\n\n## 5. Responses：同步调用\n\n```java\nIResponsesService responsesService = aiService.getResponsesService(PlatformType.DOUBAO);\n\nResponseRequest request = ResponseRequest.builder()\n        .model(\"doubao-seed-1-8-251228\")\n        .input(\"用一句话介绍 Responses API\")\n        .instructions(\"请使用中文\")\n        .build();\n\nResponse response = responsesService.create(request);\nSystem.out.println(response);\n```\n\n## 6. Responses：流式调用\n\n```java\nResponseRequest request = ResponseRequest.builder()\n        .model(\"doubao-seed-1-8-251228\")\n        .input(\"Describe the Responses API in one sentence\")\n        .stream(true)\n        .build();\n\nResponseSseListener listener = new ResponseSseListener() {\n    @Override\n    protected void onEvent() {\n        if (!getCurrText().isEmpty()) {\n            System.out.print(getCurrText());\n        }\n    }\n};\n\nresponsesService.createStream(request, listener);\nSystem.out.println(\"\\nstream finished\");\nSystem.out.println(listener.getResponse());\n```\n\n## 7. 为什么你看到“不是 token 级输出”\n\n你观察到的现象是正常的：Responses 流式是 **事件驱动**，不保证每个事件只包含一个 token。\n\n常见事件类型：\n\n- `response.output_text.delta`：输出文本增量\n- `response.reasoning_summary_text.delta`：推理摘要增量\n- `response.function_call_arguments.delta`：函数参数增量\n- `response.completed` / `response.failed` / `response.incomplete`：终态\n\n不同平台可能把文本切成“字 / 词 / 短句 / 长片段”，所以视觉上不一定是 token-by-token。\n\n## 8. Tool 调用：Chat 与 Responses 的关键差异\n\n### 8.1 Chat（SDK 已内置 tool 循环）\n\n`OpenAiChatService` 等实现中，当 `finish_reason=tool_calls` 时会自动：\n\n1. 解析 tool call\n2. 调用 `ToolUtil.invoke(...)`\n3. 把 tool 结果作为 `tool` 消息回填\n4. 再次请求模型直到得到最终文本\n\n这也是很多人觉得 Chat 链路“开箱即用”的原因。\n\n### 8.2 Responses（基础服务层不自动执行工具）\n\n`IResponsesService` 目前做的是请求与事件解析，不做自动 tool 执行循环。\n\n如果你要在 Responses 模式下做自动工具循环，建议两种方式：\n\n- 使用 `Agent`（推荐）\n- 自己在业务层根据 `ResponseStreamEvent` 实现循环\n\n## 9. 选型建议（工程视角）\n\n优先选 `Chat`：\n\n- 你有大量现存 Chat Completions 代码\n- 你主要需求是稳定文本输出 + function call\n- 你希望最低迁移成本\n\n优先选 `Responses`：\n\n- 你要更细颗粒的事件可观测\n- 你要处理 reasoning / output item / function args 的结构化流\n- 你在构建新一代 Agent runtime\n\n## 10. 常见排障\n\n### 10.1 流式迟迟不结束\n\n- 检查是否接收到终态事件（`response.completed` 等）\n- 检查监听器是否在 `onFailure/onClosed` 调用了 `complete()`\n\n### 10.2 控制台只看到最终结果\n\n- Chat：确认你打印的是 `getCurrStr()`，不是最后汇总字段。\n- Responses：确认你打印的是 `getCurrText()`，而不是只看最终 `listener.getResponse()`。\n\n### 10.3 测试日志里 `Results :` 看起来“空”\n\n这是 surefire 的常见显示样式，不代表没有输出；关键看：\n\n- `Failures/Errors/Skipped`\n- 具体用例日志\n- `target/surefire-reports` 文件\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/coding-agent-cli-quickstart.md",
    "content": "---\nsidebar_position: 6\n---\n\n# Coding Agent CLI 快速开始\n\n如果你想最快体验 AI4J 的 coding agent，而不是从 SDK 代码接起，建议直接从 `ai4j-cli` 开始。\n\n这条链路适合：\n\n- 直接在本地代码仓里做问答、改文件、跑命令；\n- 体验持续会话、session、process、slash command；\n- 验证不同 provider / model / protocol 的切换行为。\n\n---\n\n## 1. 安装 `ai4j` 命令\n\n推荐先通过文档站托管脚本安装 `ai4j-cli`。脚本会从 Maven Central 下载 `ai4j-cli` fat jar，并在本地生成 `ai4j` 命令。\n\n### 1.1 macOS / Linux\n\n```bash\ncurl -fsSL https://lnyo-cly.github.io/ai4j/install.sh | sh\n```\n\n### 1.2 Windows PowerShell\n\n```powershell\nirm https://lnyo-cly.github.io/ai4j/install.ps1 | iex\n```\n\n安装完成后建议先执行：\n\n```bash\nai4j --help\n```\n\n同一套安装同时支持：\n\n- `ai4j code`\n- `ai4j tui`\n- `ai4j acp`\n\n---\n\n## 2. 最小 one-shot 示例\n\n```powershell\nai4j code `\n  --provider openai `\n  --protocol responses `\n  --model gpt-5-mini `\n  --prompt \"Read README and summarize the project structure\"\n```\n\n适用场景：\n\n- 单次任务\n- 不需要持续会话\n- 先验证 provider/model 配置是否能通\n\n---\n\n## 3. 进入交互式 coding session\n\n```powershell\nai4j code `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n交互式会话下，你可以直接输入自然语言任务，也可以使用 slash 命令：\n\n- `/status`\n- `/session`\n- `/providers`\n- `/provider ...`\n- `/model ...`\n- `/skills [name]`\n- `/stream on|off`\n\n这里有两个关键语义：\n\n- `/stream on|off` 切换的是当前 CLI 会话里的模型请求流式开关，并会立即重建当前 session runtime\n- `/skills <name>` 只显示 skill 的来源、路径、描述和扫描 roots，不回显 `SKILL.md` 正文\n\n---\n\n## 4. 启动 TUI shell\n\n```powershell\nai4j tui `\n  --provider zhipu `\n  --protocol chat `\n  --model glm-4.7 `\n  --base-url https://open.bigmodel.cn/api/coding/paas/v4 `\n  --workspace .\n```\n\n当前 TUI shell 支持：\n\n- `/` 打开命令面板\n- `Tab` 应用补全\n- `Ctrl+P` 打开 command palette\n- `Enter` 提交输入\n- `Esc` 在活跃 turn 中断当前任务；空闲时关闭 palette 或清空输入\n\n状态栏会在 `Thinking / Connecting / Responding / Working / Retrying` 之间切换；如果一段时间没有新进展，会升级为 `Waiting`，再继续无进展会显示 `Stalled` 并提示可以按 `Esc` 中断。\n\n---\n\n## 5. 从源码打包运行（可选）\n\n如果你正在开发 `ai4j-cli` 本身，或者想使用本地源码构建产物，也可以继续走 jar 启动方式：\n\n```powershell\nmvn -pl ai4j-cli -am -DskipTests package\njava -jar .\\ai4j-cli\\target\\ai4j-cli-<version>-jar-with-dependencies.jar code --help\n```\n\n---\n\n## 6. 你接下来该看哪一页\n\n如果你已经能跑起来，建议继续看：\n\n1. `Agent / Coding Agent CLI 与 TUI`\n2. `Agent / 多 Provider Profile 实战`\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md",
    "content": "﻿---\nsidebar_position: 1\n---\n\n# 安装与环境准备\n\n本页目标：让你在 **10 分钟内完成依赖接入并跑通首个请求**。\n\n## 1. 环境要求\n\n| 项目 | 要求 | 说明 |\n| --- | --- | --- |\n| JDK | `1.8+` | 核心能力兼容 JDK8 |\n| Maven | `3.8+` | 推荐使用 3.8 或更高版本 |\n| Node.js | `18+` | 仅文档站构建需要，业务运行不依赖 |\n\n## 2. Coding Agent CLI 一键安装\n\n如果你当前的目标是直接把本地 coding agent 跑起来，不必先从源码打包。文档站提供了 GitHub Pages 托管的安装脚本，脚本会从 Maven Central 下载 `ai4j-cli` 并生成 `ai4j` 命令。\n\n### 2.1 macOS / Linux\n\n```bash\ncurl -fsSL https://lnyo-cly.github.io/ai4j/install.sh | sh\n```\n\n### 2.2 Windows PowerShell\n\n```powershell\nirm https://lnyo-cly.github.io/ai4j/install.ps1 | iex\n```\n\n### 2.3 安装结果\n\n- 默认安装目录：`~/.ai4j`（Windows 为 `%USERPROFILE%\\.ai4j`）\n- 默认启动命令：`ai4j`\n- 安装脚本不会替你安装 JDK，仍需预先准备 Java 8+\n- 如需固定版本，可设置 `AI4J_VERSION` 后再执行安装脚本\n\n如果你只使用 SDK，不需要安装 `ai4j-cli`。\n\n## 3. 依赖坐标\n\n### 3.1 非 Spring 项目\n\n```xml\n<dependency>\n  <groupId>io.github.lnyo-cly</groupId>\n  <artifactId>ai4j</artifactId>\n  <version>${latestVersion}</version>\n</dependency>\n```\n\n### 3.2 Spring Boot 项目\n\n```xml\n<dependency>\n  <groupId>io.github.lnyo-cly</groupId>\n  <artifactId>ai4j-spring-boot-starter</artifactId>\n  <version>${latestVersion}</version>\n</dependency>\n```\n\n## 4. 非 Spring 初始化（推荐基线模板）\n\n```java\nOpenAiConfig openAiConfig = new OpenAiConfig();\nopenAiConfig.setApiKey(System.getenv(\"OPENAI_API_KEY\"));\n\nConfiguration configuration = new Configuration();\nconfiguration.setOpenAiConfig(openAiConfig);\n\nOkHttpClient okHttpClient = new OkHttpClient.Builder()\n        .addInterceptor(new ErrorInterceptor())\n        .connectTimeout(300, TimeUnit.SECONDS)\n        .writeTimeout(300, TimeUnit.SECONDS)\n        .readTimeout(300, TimeUnit.SECONDS)\n        .build();\n\nconfiguration.setOkHttpClient(okHttpClient);\nAiService aiService = new AiService(configuration);\n```\n\n## 5. 一次性健康检查\n\n建议按顺序执行以下检查：\n\n1. **依赖检查**：`mvn -q -pl ai4j -DskipTests package`\n2. **配置检查**：确认 API Key 能从环境变量读取\n3. **调用检查**：执行一个最小同步对话\n4. **流式检查**：确认 SSE/stream 能看到增量输出\n\n## 6. 常用构建命令\n\n```bash\n# 根目录构建（默认跳过测试）\nmvn -DskipTests package\n\n# 只构建 ai4j 模块\nmvn -pl ai4j -am -DskipTests package\n\n# 运行 ai4j 模块测试（显式开启）\nmvn -pl ai4j -DskipTests=false test\n\n# 只跑单个测试类\nmvn -pl ai4j -Dtest=OpenAiTest -DskipTests=false test\n```\n\n## 7. 测试为什么会被跳过\n\n当前 POM 默认 `skipTests=true`，所以你必须显式开启：\n\n```bash\nmvn -pl ai4j -DskipTests=false -Dtest=YourTest test\n```\n\n## 8. 生产建议（安装阶段就应确定）\n\n- API Key 只放环境变量/JVM 参数，不写死在代码。\n- `OkHttpClient` 统一在配置层创建，避免业务层重复 new。\n- 先收敛一个“标准模型配置模板”（model / timeout / retry）。\n\n## 9. 常见问题排查\n\n### 9.1 控制台只看到最终结果，看不到流式中间文本\n\n排查顺序：\n\n1. 是否调用了 stream 接口而不是普通接口\n2. listener 是否在 `onEvent/send` 内实时输出 delta\n3. IDE 控制台是否有缓冲（尤其是测试模式）\n\n### 9.2 `There are test failures` 但日志不明显\n\n- 查看 `ai4j/target/surefire-reports`\n- 使用 `-e` 或 `-X` 获取完整栈信息\n\n### 9.3 中文日志乱码\n\n建议统一终端编码为 UTF-8（PowerShell/CMD），并确保 JVM 参数包含：\n\n- `-Dfile.encoding=UTF-8`\n\n## 10. 下一步阅读\n\n- 首次接入：`快速开始 / JDK8 + OpenAI 最小示例`\n- 业务集成：`快速开始 / Spring Boot 快速接入模式`\n- 本地模型：`快速开始 / Ollama 本地模型接入`\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/multimodal-and-function-call.md",
    "content": "﻿---\nsidebar_position: 7\n---\n\n# 多模态与 Function Call\n\n这页覆盖两个高频能力：\n\n1. **多模态输入**（文本 + 图片）\n2. **函数调用**（Function Tool / MCP Tool）\n\n并给出与 `Chat`、`Responses` 的配合方式。\n\n## 1. Chat 多模态（Vision）\n\nai4j 在 Chat 链路里统一了多模态消息结构，你可以直接传文本 + 图片地址（或 base64）。\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\n                \"请描述这张图片中的主要内容\",\n                \"https://example.com/demo.jpg\"\n        ))\n        .build();\n\nChatCompletionResponse response = chatService.chatCompletion(request);\nSystem.out.println(response.getChoices().get(0).getMessage().getContent().getText());\n```\n\n底层使用的是 `Content`：\n\n- 纯文本：`Content.ofText(...)`\n- 多模态：`Content.ofMultiModals(...)`\n- 多模态片段类型：`Content.MultiModal`（`text` / `image_url`）\n\n## 2. 手工构造多模态内容\n\n当你要精细控制多模态片段顺序时：\n\n```java\nList<Content.MultiModal> parts = Content.MultiModal.withMultiModal(\n        \"请比较两张图的差异\",\n        \"https://example.com/a.png\",\n        \"https://example.com/b.png\"\n);\n\nChatMessage user = ChatMessage.builder()\n        .role(\"user\")\n        .content(Content.ofMultiModals(parts))\n        .build();\n```\n\n## 3. Function Tool 声明方式（注解）\n\nai4j 的函数工具通过注解扫描生成 JSON Schema：\n\n- `@FunctionCall`：定义工具名与描述\n- `@FunctionRequest`：标记请求参数类\n- `@FunctionParameter`：标记参数描述/必填\n\n```java\n@FunctionCall(name = \"queryWeather\", description = \"查询天气\")\npublic class QueryWeatherFunction implements Function<QueryWeatherFunction.Request, String> {\n\n    @Data\n    @FunctionRequest\n    public static class Request {\n        @FunctionParameter(description = \"城市名\", required = true)\n        private String location;\n\n        @FunctionParameter(description = \"天气类型: now/daily/hourly\")\n        private String type;\n\n        @FunctionParameter(description = \"查询天数\")\n        private Integer days;\n    }\n\n    @Override\n    public String apply(Request request) {\n        return \"...\";\n    }\n}\n```\n\n## 4. Chat 中启用 Function Call\n\n```java\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"查询北京今天天气并给出穿衣建议\"))\n        .functions(\"queryWeather\")\n        .build();\n\nChatCompletionResponse response = chatService.chatCompletion(request);\n```\n\n这里 `functions(\"queryWeather\")` 是关键，它会触发 `ToolUtil` 把工具注入为 `tools`。\n\n## 5. Tool 暴露语义（重要）\n\n`ToolUtil.getAllTools(functionList, mcpServerIds)` 的语义是：\n\n- **你传什么，就只暴露什么**。\n- 不会再自动塞入全部本地 MCP 工具。\n\n这可以避免“权限过宽”的工具泄露问题。\n\n## 6. Chat 的函数调用执行模型\n\n在 Chat 服务实现中，SDK 会自动完成以下循环：\n\n1. 模型返回 `tool_calls`\n2. SDK 执行工具（`ToolUtil.invoke`）\n3. 把工具结果回填到消息\n4. 再次请求模型\n5. 直到模型返回最终文本\n\n所以你通常不需要自己写“工具循环控制器”。\n\n## 7. Responses 模式下使用工具\n\n`ResponseRequest` 的 `tools` 字段是 `List<Object>`，你可以按平台协议传入工具定义。\n\n```java\nMap<String, Object> functionTool = new LinkedHashMap<>();\nfunctionTool.put(\"type\", \"function\");\nfunctionTool.put(\"name\", \"queryWeather\");\nfunctionTool.put(\"description\", \"查询天气\");\n\nResponseRequest request = ResponseRequest.builder()\n        .model(\"doubao-seed-1-8-251228\")\n        .input(\"查询北京天气\")\n        .tools(Collections.<Object>singletonList(functionTool))\n        .stream(true)\n        .build();\n```\n\n同时你可以在 `ResponseSseListener` 中观察函数参数增量：\n\n- `getCurrFunctionArguments()`\n- `getFunctionArguments()`\n\n## 8. 多工具并行开关\n\n`ChatCompletion` 与 `ResponseRequest` 都支持 `parallel_tool_calls`（对应字段为 `parallelToolCalls`）。\n\n建议：\n\n- 工具有副作用（写库、发消息）时，默认关闭并行。\n- 纯查询型工具（天气、检索）可考虑开启。\n\n## 9. 与 MCP 的关系\n\nFunction 与 MCP 都会以工具形式进入模型上下文，但来源不同：\n\n- Function：本地 Java 函数，注解扫描注册\n- MCP：来自 MCP Server（本地或远程）\n\n在 Agent 里统一通过：\n\n```java\n.toolRegistry(functionNames, mcpServerIds)\n```\n\n详细见：`MCP / Tool 暴露语义与安全边界`。\n\n## 10. 最佳实践\n\n- 工具描述要写“动作 + 输入约束 + 输出语义”。\n- `instructions` 明确“何时必须调用工具”。\n- 工具返回值尽量结构化（JSON 字符串优于自由文本）。\n- 对高风险工具做白名单限制，不要把全量工具直接暴露给模型。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/platforms-and-service-matrix.md",
    "content": "---\nsidebar_position: 2\n---\n\n# 平台与服务能力矩阵\n\n这页用于回答一个核心问题：**ai4j 到底统一了什么，以及每个平台当前支持到什么程度**。\n\n## 1. ai4j 的核心价值：消除接口协议歧义\n\nai4j 的早期定位就是：\n\n- 用统一的 Java 实体封装不同平台的 API 细节；\n- 让业务层尽量不感知“这是 OpenAI 还是豆包、DashScope、Ollama”；\n- 通过 `AiService` 提供统一服务入口，降低平台切换成本。\n\n也就是你在业务代码里主要面对这些统一接口：\n\n- `IChatService`\n- `IResponsesService`\n- `IEmbeddingService`\n- `IAudioService`\n- `IRealtimeService`\n- `IImageService`\n\n## 2. 统一服务入口\n\n```java\nAiService aiService = new AiService(configuration);\n\nIChatService chatService = aiService.getChatService(PlatformType.OPENAI);\nIResponsesService responsesService = aiService.getResponsesService(PlatformType.DOUBAO);\nIEmbeddingService embeddingService = aiService.getEmbeddingService(PlatformType.OLLAMA);\nIImageService imageService = aiService.getImageService(PlatformType.DOUBAO);\n```\n\n> 如果某个平台不支持某服务，`AiService` 会抛出 `IllegalArgumentException`。\n\n## 3. 平台能力矩阵（以当前代码实现为准）\n\n| 平台 (`PlatformType`) | Chat | Responses | Embedding | Audio | Realtime | Image |\n| --- | --- | --- | --- | --- | --- | --- |\n| `OPENAI` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| `DOUBAO` | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |\n| `DASHSCOPE` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |\n| `OLLAMA` | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |\n| `ZHIPU` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| `DEEPSEEK` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| `MOONSHOT` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| `HUNYUAN` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| `LINGYI` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| `MINIMAX` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| `BAICHUAN` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n\n## 4. 每类服务对应的统一实体\n\n### 4.1 Chat Completions\n\n- 请求：`ChatCompletion`\n- 消息：`ChatMessage`\n- 响应：`ChatCompletionResponse`\n- 流式监听：`SseListener`\n\n### 4.2 Responses API\n\n- 请求：`ResponseRequest`\n- 响应：`Response`\n- 流式事件：`ResponseStreamEvent`\n- 流式监听：`ResponseSseListener`\n\n### 4.3 Embedding\n\n- 请求：`Embedding`\n- 响应：`EmbeddingResponse`\n\n### 4.4 Audio\n\n- TTS：`TextToSpeech`\n- STT：`Transcription`\n- 翻译：`Translation`\n\n### 4.5 Image\n\n- 请求：`ImageGeneration`\n- 响应：`ImageGenerationResponse`\n- 流式监听：`ImageSseListener`\n\n## 5. Spring Boot 配置前缀\n\n在 `ai4j-spring-boot-starter` 中，你可以按平台配置：\n\n- `ai.openai.*`\n- `ai.doubao.*`\n- `ai.dashscope.*`\n- `ai.zhipu.*`\n- `ai.deepseek.*`\n- `ai.moonshot.*`\n- `ai.hunyuan.*`\n- `ai.lingyi.*`\n- `ai.ollama.*`\n- `ai.minimax.*`\n- `ai.baichuan.*`\n\n通用网络层配置：\n\n- `ai.okhttp.*`\n\n## 6. 多平台实例管理（AiServiceRegistry）\n\n如果你希望以“配置驱动”的方式维护多平台实例（例如租户按平台路由），优先使用 `AiServiceRegistry`。`FreeAiService` 仍保留兼容静态方法。\n\n```java\n// 通过 ai.platforms 配置多个平台，按 id 获取\nIChatService chat = aiServiceRegistry.getChatService(\"tenant-a-openai\");\n\n// 兼容旧用法\nIChatService legacy = FreeAiService.getChatService(\"tenant-a-openai\");\n```\n\n适合场景：\n\n- 多租户平台路由\n- 灰度切模型/切平台\n- A/B 模型对比\n\n## 7. 何时用 Chat，何时用 Responses\n\n- 如果你需要兼容性高、生态成熟、迁移存量代码：优先 `Chat`。\n- 如果你要结构化事件流（reasoning / output item / function args）：优先 `Responses`。\n\n详细对比见：`快速开始 / Chat 与 Responses 实战指南`。\n\n## 8. 建议的工程分层\n\n业务层建议只持有接口，不要直接依赖平台实现类：\n\n- Controller -> Service -> `IChatService` / `IResponsesService`\n- 平台选择逻辑下沉到配置或工厂层\n- 统一在日志中记录 `platform + service + model`\n\n这样后续切平台时，影响面最小。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart-ollama.md",
    "content": "﻿---\nsidebar_position: 4\n---\n\n# Ollama 本地模型接入\n\n历史主题来源：在 Ollama 运行 DeepSeek / Qwen / Llama。\n\n本页重点：**业务代码尽量不改，只替换模型后端**。\n\n## 1. 基础配置\n\n```yaml\nai:\n  ollama:\n    base-url: http://127.0.0.1:11434\n```\n\n## 2. 获取本地模型服务\n\n```java\nIChatService chatService = aiService.getChatService(PlatformType.OLLAMA);\n```\n\n## 3. 非流式调用\n\n```java\nChatCompletion req = ChatCompletion.builder()\n        .model(\"qwen2.5:7b\")\n        .message(ChatMessage.withUser(\"给我一些 Java 线程池最佳实践\"))\n        .build();\n\nChatCompletionResponse resp = chatService.chatCompletion(req);\nSystem.out.println(resp.getChoices().get(0).getMessage().getContent());\n```\n\n## 4. 流式调用\n\n```java\nchatService.chatCompletionStream(req, new SseListener() {\n    @Override\n    protected void send() {\n        if (!getCurrStr().isEmpty()) {\n            System.out.print(getCurrStr());\n        }\n    }\n});\n```\n\n## 5. 工具调用兼容性\n\n- 工具声明方式可与 OpenAI 兼容链路保持一致。\n- 但不同本地模型在 function-calling 稳定性上差异很大。\n- 建议准备“解析失败兜底策略”：\n  - 规则解析\n  - 重试一次\n  - 回退普通回答\n\n## 6. 性能与稳定性建议\n\n- 首次请求会包含模型预热成本，压测要忽略冷启动样本。\n- 对大模型建议设置并发上限，避免内存挤兑。\n- 流式场景优先传短问题，验证链路后再上复杂任务。\n\n## 7. 与云端模型混用策略\n\n推荐“分层路由”思路：\n\n- 高频低风险任务 -> 本地模型\n- 高复杂/高准确任务 -> 云端模型\n\n在 Agent 场景中，可把模型选择逻辑放到路由节点（`StateGraphWorkflow`）。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart-openai-jdk8.md",
    "content": "﻿---\nsidebar_position: 2\n---\n\n# JDK8 + OpenAI 最小示例（Spring Boot）\n\n历史主题来源：Spring Boot + OpenAI + JDK8 快速实践。\n\n本页目标：在 JDK8 工程里完整打通 **同步 + 流式 + Tool** 三条链路。\n\n## 1. 配置文件\n\n```yaml\nai:\n  openai:\n    api-key: ${OPENAI_API_KEY}\n  okhttp:\n    proxy-url: 127.0.0.1\n    proxy-port: 10809\n```\n\n> 如果没有代理，可移除 `okhttp.proxy-*`。\n\n## 2. 获取服务入口\n\n```java\n@Autowired\nprivate AiService aiService;\n\npublic IChatService chatService() {\n    return aiService.getChatService(PlatformType.OPENAI);\n}\n```\n\n## 3. 同步调用（先打通）\n\n```java\nChatCompletion req = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"用一句话介绍 AI4J\"))\n        .build();\n\nChatCompletionResponse resp = chatService().chatCompletion(req);\nString text = resp.getChoices().get(0).getMessage().getContent();\nSystem.out.println(text);\n```\n\n## 4. 流式调用（确认增量输出）\n\n```java\nChatCompletion req = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"请分 3 点介绍 AI4J\"))\n        .build();\n\nSseListener listener = new SseListener() {\n    @Override\n    protected void send() {\n        if (!getCurrStr().isEmpty()) {\n            System.out.print(getCurrStr());\n        }\n    }\n};\n\nchatService().chatCompletionStream(req, listener);\nSystem.out.println(\"\\nstream finished\");\n```\n\n## 5. Tool 调用（天气示例）\n\n```java\nChatCompletion req = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"查询北京天气并给出建议\"))\n        .functions(\"queryWeather\")\n        .build();\n\nChatCompletionResponse resp = chatService().chatCompletion(req);\nSystem.out.println(resp.getChoices().get(0).getMessage().getContent());\n```\n\n## 6. 质量基线（建议写成测试）\n\n在你的 `*Test` 中至少断言：\n\n- 返回对象非空\n- 输出文本非空\n- 流式能接收到中间增量\n- Tool 调用路径确实发生（可看日志或 toolResults）\n\n## 7. 常见坑\n\n### 7.1 模型可用但 Tool 不触发\n\n- 检查 `functions(\"queryWeather\")` 是否传入\n- 检查工具名是否与注解/注册名一致\n- 检查提示词是否明确“先调用工具再回答”\n\n### 7.2 流式感觉“很慢”\n\n- 先区分：是模型首 token 慢，还是控制台没实时 flush\n- 测试模式下建议输出短文本，避免误判\n\n## 8. 迁移到 Agent 的建议\n\n当你完成本页后，下一步不要直接堆业务逻辑在 Controller 里，建议迁移到：\n\n- `Agent + Runtime`（管理推理与工具循环）\n- `Workflow`（管理多节点编排）\n- `Trace`（排障与审计）\n\n详见：`Agent / Agent 架构总览`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart-springboot.md",
    "content": "﻿---\nsidebar_position: 3\n---\n\n# Spring Boot 快速接入模式\n\n这页关注“怎么把 SDK 接进现有业务系统”，而不是单点 demo。\n\n## 1. 推荐分层结构\n\n```text\nsrc/main/java\n  |- controller      # 只做协议转换与鉴权入口\n  |- service         # 业务编排、异常语义\n  |- ai\n  |  |- prompts      # system/instruction 模板\n  |  |- tools        # Function/MCP 工具调用封装\n  |  `- workflow     # Agent/StateGraph 编排\n  `- config          # AI4J 与 HTTP 客户端配置\n```\n\n## 2. SSE Controller 模板\n\n```java\n@GetMapping(value = \"/chat/stream\", produces = MediaType.TEXT_EVENT_STREAM_VALUE)\npublic SseEmitter stream(@RequestParam String q) {\n    SseEmitter emitter = new SseEmitter(300000L);\n    IChatService chatService = aiService.getChatService(PlatformType.OPENAI);\n\n    ChatCompletion req = ChatCompletion.builder()\n            .model(\"gpt-4o-mini\")\n            .message(ChatMessage.withUser(q))\n            .build();\n\n    chatService.chatCompletionStream(req, new SseListener() {\n        @Override\n        protected void send() {\n            try {\n                emitter.send(getCurrStr());\n            } catch (IOException e) {\n                emitter.completeWithError(e);\n            }\n        }\n    });\n    return emitter;\n}\n```\n\n## 3. 生产化建议\n\n### 3.1 API 层\n\n- 统一鉴权（用户、租户、调用来源）\n- 入参校验与长度限制\n- 限流与熔断\n\n### 3.2 Service 层\n\n- 将 prompt 组装与业务逻辑解耦\n- 为模型错误建立统一错误码映射\n- 对长耗时请求增加超时与降级模型\n\n### 3.3 观测层\n\n- 每次请求生成 `requestId` 并贯穿日志\n- 记录模型耗时、token、tool 次数\n- 保留失败样本（脱敏后）用于回归测试\n\n## 4. 什么时候升级到 Agent\n\n当出现以下任一情况，建议使用 Agent：\n\n- 单轮 prompt 无法稳定完成任务\n- 需要“思考 -> 调工具 -> 再思考”的多步流程\n- 需要子代理分工或状态图编排\n\n升级路径：\n\n1. 先把现有调用封装为 `AgentNode`\n2. 用 `SequentialWorkflow` 替代手写 if/else 链\n3. 再迁移到 `StateGraphWorkflow` 支持路由与循环\n\n## 5. 最低测试覆盖建议\n\n- 控制器：协议与状态码测试\n- 服务层：prompt 构建与结果解析测试\n- Agent/Workflow：至少 1 个真实场景集成测试\n\n可参考：`ai4j/src/test/java/io/github/lnyocly/agent/WeatherAgentWorkflowTest.java`。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/troubleshooting.md",
    "content": "﻿---\nsidebar_position: 5\n---\n\n# 常见问题与排障手册\n\n本页收敛 AI4J 接入阶段最常见问题，并给出“先看哪里、再做什么”的排障顺序。\n\n## 1. 测试被跳过（Skipped）\n\n现象：`Tests run: x, Skipped: x`。\n\n原因：Maven 默认 `skipTests=true`。\n\n处理：\n\n```bash\nmvn -pl ai4j -DskipTests=false -Dtest=YourTest test\n```\n\n## 2. 流式输出不实时\n\n排查顺序：\n\n1. 确认调用的是 stream API。\n2. listener 回调内是否直接输出 delta。\n3. IDE 控制台是否延迟刷新。\n4. 模型端是否真的在流式返回（抓 HTTP 日志）。\n\n## 3. Tool 未触发\n\n排查顺序：\n\n1. 工具是否注册（`toolRegistry(...)`）。\n2. 工具名是否一致（大小写、下划线）。\n3. system/instruction 是否明确要求先调用工具。\n4. 是否被 handoff policy 或工具白名单拦截。\n\n## 4. CodeAct 报错但结果为空\n\n建议优先看：\n\n- `CODEACT_CODE (pre-exec)` 是否打印\n- `TOOL(type=code)` span 状态\n- `CODE_ERROR` 是否进入下一轮重试\n\n若要“代码执行失败后交回模型修复并再跑”，请把 `AgentOptions.maxSteps` 设为 >1，且系统提示明确“失败需修复重试”。\n\n## 5. Trace 看起来信息不全\n\n检查：\n\n- 是否配置了 `traceExporter`\n- `TraceConfig` 的 record 开关是否被关闭\n- 是否使用了 `TraceMasker` 对字段做了脱敏/裁剪\n\n默认配置是全量记录（模型入参、模型输出、工具参数、工具输出）。\n\n## 6. MCP 工具暴露超预期\n\n当前语义：\n\n- `ToolUtil.getAllTools(functions, mcpServices)`：只返回你显式传入的工具/服务\n- `ToolUtil.getLocalMcpTools()`：仅用于 MCP Server 暴露本地工具\n\n如果你在 Agent 场景中看到了多余工具，优先检查调用点是否错误使用了 `getLocalMcpTools()`。\n\n## 7. 乱码问题\n\n- 终端建议统一 UTF-8\n- JVM 参数显式设置 `-Dfile.encoding=UTF-8`\n- 项目文件编码统一 UTF-8\n\n## 8. 定位优先级建议\n\n1. 先看“请求是否发出”（HTTP 日志）\n2. 再看“模型是否响应”（状态码、响应体）\n3. 再看“工具是否执行”（Tool 日志 / Trace）\n4. 最后看“业务拼装是否正确”（Prompt、Workflow 路由）\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/_category_.json",
    "content": "﻿{\n  \"label\": \"场景指南\",\n  \"position\": 3,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/blog-migration-map.md",
    "content": "﻿---\nsidebar_position: 4\n---\n\n# 历史博客迁移映射\n\n本页用于把历史 CSDN 文章映射到结构化文档，方便持续维护与版本同步。\n\n## 1. 映射总表\n\n1. **Spring Boot + OpenAI + JDK8 快速接入**\n   - 原文：[142177544](https://blog.csdn.net/qq_35650513/article/details/142177544)\n   - 新文档：`快速开始 / JDK8 + OpenAI 最小示例`\n\n2. **DeepSeek / Qwen / Llama 本地模型接入**\n   - 原文：[142408092](https://blog.csdn.net/qq_35650513/article/details/142408092)\n   - 新文档：`快速开始 / Ollama 本地模型接入`\n\n3. **DeepSeek 流式 + 联网 + RAG + 多轮**\n   - 原文：[146084038](https://blog.csdn.net/qq_35650513/article/details/146084038)\n   - 新文档：`场景指南 / DeepSeek：流式 + 联网搜索 + RAG + 多轮会话`\n\n4. **SearXNG 联网增强**\n   - 原文：[144572824](https://blog.csdn.net/qq_35650513/article/details/144572824)\n   - 新文档：`场景指南 / SearXNG 联网搜索增强`\n\n5. **法律助手 RAG（Pinecone）**\n   - 原文：[142568177](https://blog.csdn.net/qq_35650513/article/details/142568177)\n   - 新文档：`场景指南 / 基于 Pinecone 的法律助手 RAG`\n\n6. **MCP + MySQL 动态管理**\n   - 原文：[150532784](https://blog.csdn.net/qq_35650513/article/details/150532784)\n   - 新文档：`MCP / MySQL 动态 MCP 服务管理`\n\n## 2. 为什么需要迁移为文档库\n\n- 文章天然是时间线结构，不适合长期按主题检索。\n- 文档库可以按模块维护，和代码变更同步。\n- 社区贡献者可直接通过 PR 补充和修订。\n\n## 3. 迁移策略建议\n\n1. 先保留原文链接，确保历史可追溯。\n2. 将“概念”与“落地代码”拆分成独立章节。\n3. 每次版本升级时同步更新对应文档页。\n4. 对高频问题沉淀到 `快速开始 / 常见问题与排障手册`。\n\n## 4. 后续计划（建议）\n\n- 增加“版本差异说明”（如 1.3 -> 1.4）\n- 增加“案例仓库索引”\n- 增加“常见错误日志 -> 排障步骤”的快速检索页\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/deepseek-stream-search-rag.md",
    "content": "﻿---\nsidebar_position: 1\n---\n\n# DeepSeek：流式 + 联网搜索 + RAG + 多轮会话\n\n历史主题来源：一个完整 DeepSeek 应用的工程化落地。\n\n## 1. 目标能力\n\n- 流式输出（低等待感）\n- 联网增强（解决时效信息）\n- RAG 检索（解决私域知识）\n- 多轮会话（上下文连续）\n\n## 2. 推荐架构\n\n```text\n前端聊天页\n  |- /chat/stream   # 流式\n  |- /chat/search   # 联网增强\n  `- /chat/rag      # 知识库检索\n\nSpring Boot + AI4J\n  |- Prompt 组装\n  |- Tool/MCP 调用\n  `- Agent/Workflow\n\n模型 + 搜索 + 向量库\n```\n\n## 3. 关键工程拆分\n\n### 流式层\n\n- SSE 推送增量\n- 前端拼接消息\n- 支持中断与重连\n\n### 搜索层\n\n- 多源检索聚合\n- 结果清洗与摘要\n- 注入 prompt 上下文\n\n### RAG 层\n\n- 文档解析、分块、向量化、存储\n- 召回 + 重排 + 证据拼接\n\n### 会话层\n\n- 滑动窗口记忆\n- 压缩旧轮对话\n- 关键事实持久化\n\n## 4. 控制器骨架\n\n```java\n@RestController\n@RequestMapping(\"/chat\")\npublic class ChatController {\n    @GetMapping(value = \"/stream\", produces = MediaType.TEXT_EVENT_STREAM_VALUE)\n    public SseEmitter stream(@RequestParam String question) { return null; }\n\n    @GetMapping(\"/search\")\n    public String search(@RequestParam String question) { return null; }\n\n    @GetMapping(\"/rag\")\n    public String rag(@RequestParam String question) { return null; }\n}\n```\n\n## 5. 上线顺序（很关键）\n\n1. 先把流式链路稳定（可用性优先）\n2. 加联网增强（时效性优先）\n3. 加 RAG（准确性优先）\n4. 加 Trace 和评估指标（可运营优先）\n\n## 6. 评估指标建议\n\n- 流式首包时间（TTFT）\n- 回答完成耗时\n- 检索命中率\n- 引用覆盖率\n- 人工纠错率\n\n## 7. 常见失败点\n\n- 联网文本过长导致 prompt 污染\n- 检索片段质量差导致“看似有证据但不可用”\n- 多轮上下文无限增长导致成本上升\n\n建议每一步都加上“长度上限 + 质量阈值 + fallback”。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/pinecone-vector-workflow.md",
    "content": "---\nsidebar_position: 5\n---\n\n# Pinecone 向量库完整工作流\n\n这页给你一条当前更推荐的 Pinecone RAG 基线：\n\n1. `IngestionPipeline` 统一入库\n2. `PineconeVectorStore` 作为底层存储\n3. `RagService` 统一查询\n4. 需要时再接 `ModelReranker`\n\n## 1. 配置 Pinecone\n\n### 1.1 非 Spring\n\n```java\nPineconeConfig pineconeConfig = new PineconeConfig();\npineconeConfig.setHost(\"https://<index-host>\");\npineconeConfig.setKey(System.getenv(\"PINECONE_API_KEY\"));\n\nConfiguration configuration = new Configuration();\nconfiguration.setPineconeConfig(pineconeConfig);\nconfiguration.setOpenAiConfig(openAiConfig);\nconfiguration.setOkHttpClient(okHttpClient);\n\nAiService aiService = new AiService(configuration);\nVectorStore vectorStore = aiService.getPineconeVectorStore();\n```\n\n### 1.2 Spring Boot\n\n```yaml\nai:\n  vector:\n    pinecone:\n      host: https://<index-host>\n      key: ${PINECONE_API_KEY}\n      upsert: /vectors/upsert\n      query: /query\n      delete: /vectors/delete\n```\n\n## 2. 入库流程（IngestionPipeline）\n\n```java\nVectorStore vectorStore = aiService.getPineconeVectorStore();\n\nIngestionPipeline ingestionPipeline = aiService.getPineconeIngestionPipeline(PlatformType.OPENAI);\n\nIngestionResult ingestResult = ingestionPipeline.ingest(IngestionRequest.builder()\n        .dataset(\"tenant_a_contract_v202603\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .document(RagDocument.builder()\n                .sourceName(\"合同模板\")\n                .sourcePath(\"/docs/contract-template.pdf\")\n                .tenant(\"tenant_a\")\n                .biz(\"legal\")\n                .version(\"2026.03\")\n                .build())\n        .source(IngestionSource.file(new File(\"D:/data/contract-template.pdf\")))\n        .build());\n\nSystem.out.println(\"upserted=\" + ingestResult.getUpsertedCount());\n```\n\n## 3. 查询流程（RagService）\n\n```java\nString question = \"合同违约金条款如何计算？\";\n\nRagService ragService = aiService.getRagService(\n        PlatformType.OPENAI,\n        vectorStore\n);\n\nRagQuery ragQuery = RagQuery.builder()\n        .query(question)\n        .dataset(\"tenant_a_contract_v202603\")\n        .embeddingModel(\"text-embedding-3-small\")\n        .topK(5)\n        .build();\n\nRagResult ragResult = ragService.search(ragQuery);\nString context = ragResult.getContext();\nSystem.out.println(context);\nSystem.out.println(ragResult.getCitations());\n```\n\n接下来把 `context` 注入聊天请求即可：\n\n```java\nChatCompletion req = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withSystem(\"请仅基于给定资料回答，并引用关键依据\"))\n        .message(ChatMessage.withUser(\"问题：\" + question + \"\\n\\n资料：\\n\" + context))\n        .build();\n```\n\n## 4. 可选：接入 Rerank\n\n```java\nReranker reranker = aiService.getModelReranker(\n        PlatformType.JINA,\n        \"jina-reranker-v2-base-multilingual\",\n        5,\n        \"优先合同原文、章节标题和编号明确的条款\"\n);\n```\n\n## 5. 参数说明\n\n### 5.1 `dataset`\n\n- 建议直接编码 `tenant + biz + version`\n- 在 Pinecone 下，它会映射到逻辑 namespace 隔离语义\n\n### 5.2 `chunkSize / chunkOverlap`\n\n- `chunkSize`：单块最大长度\n- `chunkOverlap`：相邻块重叠长度\n\n经验值：\n\n- 技术文档：`800~1200`\n- 法律条文：`600~1000`\n- 重叠一般取 `10%~25%`\n\n### 5.3 `topK`\n\n- `3~8` 通常是更稳妥的起点\n- 太大容易把 Pinecone 召回噪声直接送进上下文\n\n## 6. 生产化建议\n\n- 使用固定 embedding 模型，避免向量维度不一致。\n- `dataset` 设计成 `tenant + biz + version`，方便回滚。\n- metadata 至少保留：`content/source/title/version/updatedAt`。\n- 定期清理过期数据，避免召回污染。\n- 如果只是做通用 RAG，优先面向 `VectorStore / IngestionPipeline / RagService`，不要把业务逻辑直接写死在已废弃的 `PineconeService` 上。\n\n## 7. 与联网增强的边界\n\n这条 Pinecone 工作流解决的是私域知识库检索，不是联网搜索。\n\n边界可以简单记成：\n\n- Pinecone / RAG：查内部资料\n- SearXNG：查公网网页\n\n如果你的问题主要依赖内部文档、制度、合同、知识库，这页就是主线。\n\n## 8. 常见错误\n\n### 8.1 查询结果为空\n\n- dataset 不一致\n- 向量维度与索引配置不匹配\n- topK 太小 / 文本分块质量差\n\n### 8.2 回答“看起来有依据但不准确”\n\n- 分块粒度不合适\n- 未做 metadata 过滤\n- prompt 没有限定“仅基于上下文”\n\n建议先把检索质量做稳，再调模型生成风格。\n\n### 8.3 什么时候还需要直接用已废弃的 `PineconeService`（Deprecated）\n\n`PineconeService` 目前在文档层已视为 Deprecated。只有在你明确需要 Pinecone 特有底层能力时，才建议继续直接使用：\n\n- namespace 级专用管理操作\n- 已有旧项目已经大量使用 `PineconeQuery / PineconeDelete`\n- 你在封装 Pinecone 专用能力，而不是统一 RAG 能力\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/rag-legal-assistant.md",
    "content": "﻿---\nsidebar_position: 3\n---\n\n# 基于 Pinecone 的法律助手 RAG\n\n历史主题来源：法律助手场景的 RAG 落地实践。\n\n## 1. 目标\n\n- 将法规、政策、案例等私域材料纳入知识库\n- 回答时引用可追溯证据，降低幻觉\n\n## 2. 数据流程\n\n### 2.1 入库\n\n1. 文档解析（PDF/Word/网页）\n2. 文本分块（按语义或固定长度）\n3. 向量化\n4. 写入 Pinecone（带元数据）\n\n### 2.2 查询\n\n1. 问题向量化\n2. Top-K 召回\n3. 重排与过滤\n4. 拼接证据上下文\n5. 让模型基于证据生成答复\n\n## 3. 元数据建议\n\n最低建议字段：\n\n- `source`\n- `title`\n- `section`\n- `updatedAt`\n- `version`\n\n有了这些字段，才能做后续追溯与版本管理。\n\n## 4. 最小伪代码\n\n```java\nList<VectorMatch> matches = pineconeService.query(questionEmbedding, 5);\nString context = join(matches);\n\nChatCompletion req = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withSystem(\"请仅基于提供材料回答\"))\n        .message(ChatMessage.withUser(\"问题:\" + question + \"\\n上下文:\\n\" + context))\n        .build();\n```\n\n## 5. 生产建议\n\n### 5.1 证据优先\n\n要求模型输出时显式给出证据来源（文档名/条款号）。\n\n### 5.2 时效治理\n\n法规有版本更新时，必须支持“按版本召回”或“失效材料剔除”。\n\n### 5.3 人工校验\n\n法律场景高风险，建议对关键输出增加人工复核流程。\n\n## 6. 指标体系\n\n- 检索命中率\n- 证据覆盖率\n- 人工纠错率\n- 单次回答成本\n\n## 7. 常见坑\n\n- 分块过大导致召回噪声高\n- 元数据缺失导致无法回溯\n- prompt 未限制“必须基于证据”导致幻觉上升\n\n建议先把“检索质量”和“证据引用”做稳，再优化文案表达。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/searxng-web-search.md",
    "content": "﻿---\nsidebar_position: 2\n---\n\n# SearXNG 联网搜索增强\n\n这套能力适合：模型本身不带联网，或你需要更可控的“检索 -> 回答”链路。\n\n在 ai4j 中，核心入口是：\n\n- `AiService.webSearchEnhance(IChatService chatService)`\n- 装饰器实现类：`ChatWithWebSearchEnhance`\n- 配置类：`SearXNGConfig`\n\n## 1. 机制概览\n\n`ChatWithWebSearchEnhance` 会在调用模型前做三件事：\n\n1. 读取用户最新问题\n2. 请求 SearXNG（JSON）获取检索结果\n3. 将检索结果拼接进 prompt，再交给原始 `IChatService`\n\n所以你的业务层仍调用同一个 `IChatService` 接口。\n\n## 2. 基础配置\n\n### 2.1 非 Spring\n\n```java\nSearXNGConfig searXNGConfig = new SearXNGConfig();\nsearXNGConfig.setUrl(\"http://127.0.0.1:8080/search\");\nsearXNGConfig.setEngines(\"duckduckgo,google,bing\");\nsearXNGConfig.setNums(5);\n\nConfiguration configuration = new Configuration();\nconfiguration.setSearXNGConfig(searXNGConfig);\nconfiguration.setOpenAiConfig(openAiConfig);\nconfiguration.setOkHttpClient(okHttpClient);\n```\n\n### 2.2 Spring Boot\n\n```yaml\nai:\n  websearch:\n    searxng:\n      url: http://127.0.0.1:8080/search\n      engines: duckduckgo,google,bing\n      nums: 5\n```\n\n## 3. 使用方式\n\n```java\nAiService aiService = new AiService(configuration);\nIChatService rawChat = aiService.getChatService(PlatformType.OPENAI);\nIChatService webEnhancedChat = aiService.webSearchEnhance(rawChat);\n\nChatCompletion request = ChatCompletion.builder()\n        .model(\"gpt-4o-mini\")\n        .message(ChatMessage.withUser(\"2026 年最新 AI Agent 框架趋势是什么？\"))\n        .build();\n\nChatCompletionResponse response = webEnhancedChat.chatCompletion(request);\nSystem.out.println(response.getChoices().get(0).getMessage().getContent().getText());\n```\n\n## 4. 流式也可以直接用\n\n```java\nwebEnhancedChat.chatCompletionStream(request, new SseListener() {\n    @Override\n    protected void send() {\n        if (!getCurrStr().isEmpty()) {\n            System.out.print(getCurrStr());\n        }\n    }\n});\n```\n\n## 5. 与 RAG 的关系\n\n- SearXNG：公网检索，时效强\n- Pinecone/RAG：私域检索，准确强\n\n推荐混合策略：\n\n1. 先查私域（RAG）\n2. 证据不足时再查 SearXNG\n3. 合并证据后再生成\n\n## 6. `SearXNGConfig` 参数建议\n\n- `url`：必填，SearXNG `/search` 地址\n- `engines`：建议先从 2~4 个引擎开始\n- `nums`：建议 3~8，过大容易污染上下文\n\n## 7. 安全建议\n\n- 配置域名白名单（至少在网关层做限制）\n- 对检索结果做长度截断与去重\n- 对外部文本做 prompt 注入防护（去系统指令片段）\n\n## 8. 可观测建议\n\n最少记录以下字段：\n\n- `query`\n- `engines`\n- 命中来源域名列表\n- 注入模型前上下文长度\n- 最终回答与引用\n\n这样才能排查“为什么这次回答不可信”。\n\n## 9. 常见问题\n\n### 9.1 报错 `SearXNG url is not configured`\n\n- `SearXNGConfig.url` 未配置或为空。\n\n### 9.2 检索成功但回答质量差\n\n- `nums` 太大导致噪声过多\n- `engines` 配置太杂\n- 提示词没有限制“基于检索结果回答”\n\n### 9.3 联网响应太慢\n\n- 减少 `engines` 数量\n- 减少 `nums`\n- 给 SearXNG 独立超时设置或本地部署\n\n## 10. 何时不建议开启联网增强\n\n- 高实时低延迟接口（如强 SLA 的在线问答）\n- 强隐私场景且不允许公网访问\n- 你已有稳定内部检索链路（RAG 足够）\n\n这时建议把 SearXNG 做成“可选降级路径”，按场景开启。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/spi-dispatcher-connectionpool.md",
    "content": "﻿---\nsidebar_position: 4\n---\n\n# SPI：自定义 Dispatcher 与 ConnectionPool\n\nai4j 的网络层默认基于 OkHttp，并通过 SPI 暴露两个关键扩展点：\n\n- `DispatcherProvider`\n- `ConnectionPoolProvider`\n\n这使你可以按自身并发策略替换默认实现，而不改 SDK 源码。\n\n## 1. 为什么要做这个扩展\n\n真实业务里，不同场景对网络层诉求不同：\n\n- 高并发问答：要限制全局并发，防止把上游打爆；\n- 多租户：要隔离连接池、控制队头阻塞；\n- 网关环境：要统一超时、并发和连接生命周期。\n\n把并发策略抽到 SPI，是更稳定的工程做法。\n\n## 2. ai4j 的 SPI 接口\n\n```java\npublic interface DispatcherProvider {\n    Dispatcher getDispatcher();\n}\n\npublic interface ConnectionPoolProvider {\n    ConnectionPool getConnectionPool();\n}\n```\n\n默认实现位于：\n\n- `io.github.lnyocly.ai4j.network.impl.DefaultDispatcherProvider`\n- `io.github.lnyocly.ai4j.network.impl.DefaultConnectionPoolProvider`\n\n并在 `META-INF/services` 中注册。\n\n## 3. Spring Boot 中是如何加载的\n\n`ai4j-spring-boot-starter` 在 `AiConfigAutoConfiguration#initOkHttp()` 里会调用：\n\n- `ServiceLoaderUtil.load(DispatcherProvider.class)`\n- `ServiceLoaderUtil.load(ConnectionPoolProvider.class)`\n\n拿到实例后写入 `OkHttpClient.Builder`：\n\n- `.dispatcher(...)`\n- `.connectionPool(...)`\n\n## 4. 自定义实现示例\n\n### 4.1 自定义 DispatcherProvider\n\n```java\npackage com.example.ai4j.spi;\n\nimport io.github.lnyocly.ai4j.network.DispatcherProvider;\nimport okhttp3.Dispatcher;\n\npublic class HighThroughputDispatcherProvider implements DispatcherProvider {\n    @Override\n    public Dispatcher getDispatcher() {\n        Dispatcher dispatcher = new Dispatcher();\n        dispatcher.setMaxRequests(256);\n        dispatcher.setMaxRequestsPerHost(64);\n        return dispatcher;\n    }\n}\n```\n\n### 4.2 自定义 ConnectionPoolProvider\n\n```java\npackage com.example.ai4j.spi;\n\nimport io.github.lnyocly.ai4j.network.ConnectionPoolProvider;\nimport okhttp3.ConnectionPool;\n\nimport java.util.concurrent.TimeUnit;\n\npublic class TunedConnectionPoolProvider implements ConnectionPoolProvider {\n    @Override\n    public ConnectionPool getConnectionPool() {\n        return new ConnectionPool(80, 5, TimeUnit.MINUTES);\n    }\n}\n```\n\n### 4.3 注册 SPI 文件\n\n`src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.DispatcherProvider`\n\n```text\ncom.example.ai4j.spi.HighThroughputDispatcherProvider\n```\n\n`src/main/resources/META-INF/services/io.github.lnyocly.ai4j.network.ConnectionPoolProvider`\n\n```text\ncom.example.ai4j.spi.TunedConnectionPoolProvider\n```\n\n## 5. 非 Spring 项目如何使用\n\n非 Spring 项目同样可以显式加载：\n\n```java\nDispatcherProvider dispatcherProvider = ServiceLoaderUtil.load(DispatcherProvider.class);\nConnectionPoolProvider poolProvider = ServiceLoaderUtil.load(ConnectionPoolProvider.class);\n\nOkHttpClient client = new OkHttpClient.Builder()\n        .dispatcher(dispatcherProvider.getDispatcher())\n        .connectionPool(poolProvider.getConnectionPool())\n        .build();\n```\n\n## 6. 常见问题\n\n### 6.1 没有找到 SPI 实现\n\n异常通常是：`No implementation found for ...`。\n\n排查顺序：\n\n1. `META-INF/services` 文件名是否与接口全限定名完全一致；\n2. 文件内容是否是实现类全限定名；\n3. 资源文件是否被打进最终 jar。\n\n### 6.2 多个实现冲突\n\n`ServiceLoaderUtil.load(...)` 当前取“第一个可用实现”。\n\n建议：\n\n- 每个应用只保留一套 SPI 实现；\n- 或保证 classpath 顺序稳定。\n\n### 6.3 参数应该怎么调\n\n建议先从保守值开始压测，再逐步放开：\n\n- `maxRequests`\n- `maxRequestsPerHost`\n- 连接池大小与保活时间\n\n并结合上游平台 QPS 限制做限流。\n\n## 7. 生产建议\n\n- 将 SPI 实现和业务代码放在同一仓库，方便版本协同。\n- 配套暴露并发指标（队列长度、拒绝数、超时率）。\n- 重大变更（连接池参数）走灰度发布。\n\nSPI 这层做好后，模型切换和流量增长会稳定很多。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/intro.md",
    "content": "﻿---\nsidebar_position: 1\n---\n\n# AI4J 文档中心\n\n欢迎来到 **AI4J** 官方文档站。\n\n这套文档是面向工程落地的文档库：\n\n- 聚焦 Java/JDK8 生产接入；\n- 覆盖模型能力、MCP、Agent 到部署；\n- 示例以“可直接落地”为目标。\n\n## 1. AI4J 定位\n\nAI4J 是一个工程化 Java 大模型 SDK，核心价值：\n\n1. **平台协议消歧**：统一封装多家模型平台协议。\n2. **基础能力完整**：Chat、Responses、Embedding、Audio、Image、Realtime。\n3. **工具生态完善**：Function Tool、MCP Client/Server/Gateway。\n4. **智能体能力可扩展**：ReAct、CodeAct、SubAgent、Agent Teams、StateGraph、Trace。\n\n## 2. 文档导航\n\n- **快速开始**：安装、最小示例、Spring Boot、Ollama。\n- **AI基础能力接入**：平台适配、Chat、Responses、Audio 等基础能力与增强能力。\n- **MCP**：协议、客户端接入、第三方 MCP、网关、服务暴露。\n- **Agent 智能体**：运行时、记忆、编排、SubAgent、Agent Teams、追踪。\n- **场景实践**：RAG、联网检索等端到端案例。\n- **部署**：Cloudflare Pages 等部署与维护。\n\n如果你是为了直接使用本地 coding agent，建议优先看：\n\n- `快速开始 / Coding Agent CLI 快速开始`\n- `Agent / Coding Agent CLI 与 TUI`\n- `Agent / 多 Provider Profile 实战`\n\n## 3. 推荐阅读顺序\n\n### 3.1 首次接入\n\n1. `快速开始 / 安装与环境准备`\n2. `快速开始 / JDK8 + OpenAI 最小示例`\n3. `快速开始 / Coding Agent CLI 快速开始`\n4. `AI基础能力接入 / 平台适配与统一接口`\n5. `AI基础能力接入 / Chat / 非流式 + 流式`\n\n### 3.2 进阶能力\n\n1. `AI基础能力接入 / Responses`\n2. `Agent / Coding Agent CLI 与 TUI`\n3. `Agent / 多 Provider Profile 实战`\n4. `AI基础能力接入 / 增强能力（联网、向量、SPI）`\n5. `MCP / 总览与集成`\n6. `Agent / 架构与编排`\n\n## 4. 仓库结构\n\n- `ai4j/`：核心 SDK\n- `ai4j-spring-boot-starter/`：Spring Boot 自动装配\n- `docs-site/`：文档站源码\n\n## 5. 历史博客迁移\n\n你之前的 CSDN 实践内容已迁移并结构化，见：\n\n- `场景实践 / 历史博客迁移映射`\n\n## 6. 文档约定\n\n- 优先给可运行最小示例\n- 参数含义与默认行为写清楚\n- 高风险能力明确安全边界\n- 对“框架保证 vs 模型依赖”明确标注\n\n如果你希望优先补充某个专题，可以直接提 Issue。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/_category_.json",
    "content": "﻿{\n  \"label\": \"MCP\",\n  \"position\": 4,\n  \"collapsed\": false\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/build-your-mcp-server.md",
    "content": "﻿---\nsidebar_position: 6\n---\n\n# 构建并对外发布 MCP Server\n\n你问到“如何把自己的能力暴露成 MCP 给别人用”，这页给完整步骤。\n\n## 1. 目标流程\n\n1. 用注解定义本地 MCP 能力（Tool/Resource/Prompt）\n2. 启动 MCP Server（STDIO/SSE/Streamable HTTP）\n3. 外部 MCP Client 连接并调用\n\n## 2. 能力定义：注解体系\n\n- `@McpService`：定义服务元信息\n- `@McpTool` + `@McpParameter`：定义工具\n- `@McpResource` + `@McpResourceParameter`：定义资源\n- `@McpPrompt` + `@McpPromptParameter`：定义提示模板\n\n## 3. 示例：定义一个可发布的服务\n\n```java\n@McpService(name = \"WeatherService\", description = \"Weather MCP service\", transport = \"streamable_http\", port = 8081)\npublic class WeatherMcpService {\n\n    @McpTool(name = \"query_weather\", description = \"Query weather by city\")\n    public String queryWeather(@McpParameter(name = \"city\", description = \"City name\") String city) {\n        return \"Weather(\" + city + \")\";\n    }\n\n    @McpResource(uri = \"weather://city/{city}\", name = \"city-weather\", description = \"Weather resource\")\n    public String weatherResource(@McpResourceParameter(name = \"city\") String city) {\n        return \"Resource(\" + city + \")\";\n    }\n\n    @McpPrompt(name = \"weather-summary\", description = \"Generate weather summary\")\n    public String weatherPrompt(@McpPromptParameter(name = \"city\") String city) {\n        return \"Please summarize weather for \" + city;\n    }\n}\n```\n\n## 4. Server 启动方式\n\nAI4J 提供 `McpServerFactory`：\n\n```java\nMcpServer server = McpServerFactory.createServer(\"streamable_http\", \"weather-server\", \"1.0.0\", 8081);\nserver.start().join();\n\n// 关闭\nserver.stop().join();\n```\n\n支持：\n\n- `stdio`\n- `sse`\n- `streamable_http`（`http` 兼容映射）\n\n## 5. 三种 Server 类型怎么选\n\n- `StdioMcpServer`\n  - 适合作为本地子进程服务\n- `SseMcpServer`\n  - 适合已有 SSE 消费方\n- `StreamableHttpMcpServer`\n  - 最推荐用于公网/内网服务发布\n\n## 6. 暴露内容来源机制\n\nServer 在运行时会扫描并暴露本地 MCP 注解能力：\n\n- Tool：来自本地 MCP 工具缓存\n- Resource：来自 `McpResourceAdapter`\n- Prompt：来自 `McpPromptAdapter`\n\n其中 Tool 列表对外暴露使用 `ToolUtil.getLocalMcpTools()`。\n\n## 7. 对外发布时的工程建议\n\n1. **版本标识**：`@McpService.version` + 发布日志。\n2. **命名规范**：tool/resource/prompt 名称稳定，避免频繁破坏性变更。\n3. **参数兼容**：新增参数尽量 optional，避免影响老客户端。\n4. **超时与限流**：工具方法要有超时保护。\n5. **审计**：记录请求来源、方法、耗时、错误。\n\n## 8. 让第三方调用你的 MCP\n\n第三方可用任意兼容 MCP 的客户端接入；AI4J 客户端示例：\n\n```java\nMcpTransport transport = new StreamableHttpTransport(\"http://127.0.0.1:8081/mcp\");\nMcpClient client = new McpClient(\"consumer\", \"1.0.0\", transport);\nclient.connect().join();\n\nList<McpToolDefinition> tools = client.getAvailableTools().join();\nString result = client.callTool(\"query_weather\", Collections.singletonMap(\"city\", \"Beijing\")).join();\n```\n\n## 9. 常见问题\n\n1. 客户端连上但 `tools/list` 为空\n   - 检查注解扫描范围与工具命名。\n2. 资源/提示不可见\n   - 检查是否使用了 `@McpResource/@McpPrompt`，以及参数注解是否齐全。\n3. HTTP 发布后 404\n   - 确认端点路径（通常 `/mcp` 或 SSE 对应路径）和反向代理配置。\n\n## 10. 参考源码\n\n- `McpServer`\n- `McpServerFactory`\n- `StdioMcpServer`\n- `SseMcpServer`\n- `StreamableHttpMcpServer`\n- `McpToolAdapter`\n- `McpResourceAdapter`\n- `McpPromptAdapter`\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/client-integration.md",
    "content": "﻿---\nsidebar_position: 3\n---\n\n# MCP Client 接入（单服务模式）\n\n本页聚焦“你只接 1 个第三方 MCP 服务”的最短路径。\n\n## 1. 最小流程\n\n1. 创建 `McpTransport`\n2. 创建 `McpClient`\n3. `connect()` 初始化会话\n4. `getAvailableTools()` 拉取工具\n5. `callTool()` 执行工具\n6. `disconnect()` 释放连接\n\n## 2. 方式 A：STDIO 接本地第三方 MCP\n\n```java\nMcpTransport transport = new StdioTransport(\n        \"npx\",\n        Arrays.asList(\"-y\", \"@modelcontextprotocol/server-filesystem\", \"D:/workspace\"),\n        null\n);\n\nMcpClient client = new McpClient(\"demo-client\", \"1.0.0\", transport);\nclient.connect().join();\n\nList<McpToolDefinition> tools = client.getAvailableTools().join();\nSystem.out.println(\"TOOLS=\" + tools.size());\n\nString result = client.callTool(\"read_file\", Collections.singletonMap(\"path\", \"README.md\")).join();\nSystem.out.println(result);\n\nclient.disconnect().join();\n```\n\n## 3. 方式 B：SSE/HTTP 接远程第三方 MCP\n\n```java\nTransportConfig config = TransportConfig.streamableHttp(\"https://example.com/mcp\");\nconfig.setHeaders(Collections.singletonMap(\"Authorization\", \"Bearer your-token\"));\n\nMcpTransport transport = McpTransportFactory.createTransport(\"streamable_http\", config);\nMcpClient client = new McpClient(\"demo-client\", \"1.0.0\", transport);\nclient.connect().join();\n```\n\n如果是 SSE：\n\n```java\nMcpTransport transport = new SseTransport(\"https://example.com/sse\");\nMcpClient client = new McpClient(\"demo-client\", \"1.0.0\", transport);\nclient.connect().join();\n```\n\n## 4. Resource / Prompt 高层 API\n\n除了 Tool，现在 `McpClient` 也已经提供了 Resource / Prompt 的便捷方法：\n\n- `getAvailableResources()` -> `resources/list`\n- `readResource(uri)` -> `resources/read`\n- `getAvailablePrompts()` -> `prompts/list`\n- `getPrompt(name, arguments)` -> `prompts/get`\n\n示例：\n\n```java\nList<McpResource> resources = client.getAvailableResources().join();\nMcpResourceContent resource = client.readResource(\"file://docs/README.md\").join();\n\nList<McpPrompt> prompts = client.getAvailablePrompts().join();\nMcpPromptResult prompt = client.getPrompt(\n        \"code_review_prompt\",\n        Collections.<String, Object>singletonMap(\"language\", \"java\")\n).join();\n```\n\n适合场景：\n\n- Resource：读配置、模板、文件、知识片段\n- Prompt：拿第三方 MCP 服务提供的提示模板\n\n## 5. `McpClient` 当前能力边界\n\n当前高层 API 重点封装了：\n\n- `getAvailableTools()` -> `tools/list`\n- `callTool(name, args)` -> `tools/call`\n- `getAvailableResources()` -> `resources/list`\n- `readResource(uri)` -> `resources/read`\n- `getAvailablePrompts()` -> `prompts/list`\n- `getPrompt(name, args)` -> `prompts/get`\n\n当前返回类型仍然保持“轻量高层对象”风格：\n\n- Tool -> `McpToolDefinition`\n- Resource -> `McpResource` / `McpResourceContent`\n- Prompt -> `McpPrompt` / `McpPromptResult`\n\n## 6. 与 Agent 的桥接方式\n\n单服务模式下，你通常有两种桥接：\n\n1. 自己在工具执行层调用 `McpClient.callTool(...)`\n2. 通过 `McpGateway` 聚合后，再交给 `ToolUtil` / `toolRegistry`\n\n如果你后续会接多个服务，建议直接进阶到 Gateway 模式。\n\n## 7. 稳定性建议\n\n- 连接前检查 transport 配置完整性\n- `connect()` 和 `callTool()` 设置外层超时\n- 对 `callTool()` 做错误分层（网络错误/业务错误）\n- 在 finally 中 `disconnect()`\n\n对于 Resource / Prompt 也建议做：\n\n- URI 白名单\n- Prompt 名称白名单\n- 外层超时和降级\n\n## 8. 安全建议\n\n- 工具名白名单\n- 参数 schema 校验\n- 认证信息只走 header/env，不写死在代码\n\n## 9. 常见排障\n\n1. `not connected or not initialized`\n   - 先确认 `connect().join()` 成功。\n2. `tool not found`\n   - 先 `getAvailableTools()` 看服务端暴露名。\n3. `resource not found` / `prompt not found`\n   - 先分别用 `getAvailableResources()` / `getAvailablePrompts()` 看暴露清单。\n4. HTTP 401/403\n   - 检查 `TransportConfig.headers` 认证配置。\n\n## 10. 下一步阅读\n\n- 《接入第三方 MCP（全部方式）》\n- 《MCP Gateway 管理》\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/gateway-management.md",
    "content": "﻿---\nsidebar_position: 5\n---\n\n# MCP Gateway 管理（多服务聚合与治理）\n\n`McpGateway` 是 AI4J 中 MCP 平台化的核心：\n\n- 统一管理多个 `McpClient`\n- 聚合工具列表\n- 按 service/user 路由调用\n- 支持动态增删与状态查询\n\n## 1. 关键 API\n\n初始化与生命周期：\n\n- `initialize()` / `initialize(configFile)`\n- `shutdown()`\n- `isInitialized()`\n\n客户端管理：\n\n- `addMcpClient(serviceId, client)`\n- `removeMcpClient(serviceId)`\n- `addUserMcpClient(userId, serviceId, client)`\n- `removeUserMcpClient(userId, serviceId)`\n- `clearUserMcpClients(userId)`\n\n工具与调用：\n\n- `getAvailableTools()`\n- `getAvailableTools(serviceIds)`\n- `getUserAvailableTools(serviceIds, userId)`\n- `callTool(toolName, arguments)`\n- `callUserTool(userId, toolName, arguments)`\n\n观测与诊断：\n\n- `getGatewayStatus()`\n- `getToolToClientMap()`\n\n## 2. 网关初始化模式\n\n## 模式 A：配置文件初始化\n\n```java\nMcpGateway gateway = new McpGateway();\ngateway.initialize(\"mcp-servers-config.json\").join();\n```\n\n## 模式 B：自定义配置源\n\n```java\nMcpConfigSource source = new FileMcpConfigSource(\"mcp-servers-config.json\");\nMcpGateway gateway = new McpGateway();\ngateway.setConfigSource(source);\ngateway.initialize().join();\n```\n\n`McpConfigSource` 支持监听配置增删改，适合做热更新平台。\n\n## 3. 路由语义\n\n### 全局客户端\n\n- clientKey: `serviceId`\n- toolKey: `toolName`\n\n### 用户客户端\n\n- clientKey: `user_{userId}_service_{serviceId}`\n- toolKey: `user_{userId}_tool_{toolName}`\n\n调用优先级（用户模式）：\n\n1. 先查用户专属工具\n2. 未命中再回退全局工具\n\n## 4. 动态管理示例\n\n```java\nMcpClient weatherClient = new McpClient(\"weather\", \"1.0.0\", weatherTransport);\ngateway.addMcpClient(\"weather\", weatherClient).join();\n\nMap<String, Object> status = gateway.getGatewayStatus();\nSystem.out.println(status);\n\ngateway.removeMcpClient(\"weather\").join();\n```\n\n## 5. 与 Agent 集成建议\n\n推荐结构：\n\n1. 启动期初始化 gateway\n2. Agent 构建时显式传入 `mcpServices`\n3. 工具执行统一走 `ToolUtil` -> `McpGateway`\n\n```java\n.toolRegistry(Arrays.asList(\"queryWeather\"), Arrays.asList(\"github\", \"filesystem\"))\n```\n\n## 6. 高可用建议\n\n1. 服务分级：核心/非核心分层\n2. 超时策略：按服务差异化设置\n3. 熔断降级：非核心失败不阻断主链路\n4. 版本治理：配置有版本号与回滚\n5. 指标监控：按 service/tool 统计成功率与耗时\n\n## 7. 安全建议\n\n- 工具白名单优先\n- 多租户场景强制 userId 校验\n- 高风险工具单独审计\n- Token/Key 不落日志\n\n## 8. 关联文档\n\n- 《接入第三方 MCP（全部方式）》\n- 《构建并对外发布 MCP Server》\n- 《Tool 暴露语义与安全边界》\n- 《MySQL 动态 MCP 服务管理》\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/mcp-agent-end-to-end.md",
    "content": "﻿---\nsidebar_position: 7\n---\n\n# MCP 与 Agent 一体化实战（端到端）\n\n这一页给你一个“从第三方 MCP 到 Agent 工具调用”的完整闭环示例，目标是做到：\n\n1. 接入外部 MCP 服务\n2. 通过 `McpGateway` 聚合工具\n3. 在 Agent 中按场景暴露 MCP 工具\n4. 运行并观测结果\n\n> 这是你后续做开源示例、演示仓库、CI 回归最实用的一条链路。\n\n## 1. 场景设定\n\n- 外部 MCP 服务：`weather-http`（假设已提供 `query_weather` 工具）\n- 模型：Doubao / OpenAI 兼容模型\n- Agent：ReAct Runtime\n- 工具暴露策略：只暴露 `mcpServerIds=[\"weather-http\"]`，不自动暴露其它工具\n\n## 2. 准备 MCP 配置\n\n在 `mcp-servers-config.json` 中声明服务：\n\n```json\n{\n  \"mcpServers\": {\n    \"weather-http\": {\n      \"type\": \"streamable_http\",\n      \"url\": \"http://127.0.0.1:8000/mcp\",\n      \"enabled\": true\n    }\n  }\n}\n```\n\n> `serviceId`（这里是 `weather-http`）就是后续 `toolRegistry(..., mcpServices)` 里要传的 ID。新配置建议直接写 `streamable_http`，不要再把 `http` 当成主写法。\n\n## 3. 启动并初始化网关\n\n```java\nMcpGateway gateway = McpGateway.getInstance();\ngateway.initialize(\"mcp-servers-config.json\").join();\n\nList<Tool.Function> gatewayTools = gateway.getAvailableTools().join();\nSystem.out.println(\"MCP tools in gateway: \" + gatewayTools.size());\n```\n\n建议初始化后先打印工具名，确认服务确实注册成功。\n\n## 4. 构建模型服务\n\n```java\nDoubaoConfig doubaoConfig = new DoubaoConfig();\ndoubaoConfig.setApiKey(System.getenv(\"ARK_API_KEY\"));\n\nConfiguration configuration = new Configuration();\nconfiguration.setDoubaoConfig(doubaoConfig);\n\nAiService aiService = new AiService(configuration);\nResponsesModelClient modelClient = new ResponsesModelClient(\n        aiService.getResponsesService(PlatformType.DOUBAO)\n);\n```\n\n## 5. 构建 Agent（接入 MCP）\n\n```java\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"doubao-seed-1-8-251228\")\n        .systemPrompt(\"你是天气助手，必要时必须调用工具后再回答。\")\n        .instructions(\"使用 MCP 工具查询天气，并给出简洁建议。\")\n        .toolRegistry(Collections.<String>emptyList(), Arrays.asList(\"weather-http\"))\n        .options(AgentOptions.builder().maxSteps(4).build())\n        .build();\n```\n\n这里关键点：\n\n- `functionList` 传空\n- `mcpServices` 只传 `weather-http`\n- 达成“只暴露该 MCP 服务工具”的精确控制\n\n## 6. 发起请求并查看输出\n\n```java\nAgentResult result = agent.run(AgentRequest.builder()\n        .input(\"请查询北京今天天气，并给出穿衣建议\")\n        .build());\n\nSystem.out.println(\"OUTPUT: \" + result.getOutputText());\n```\n\n如果链路正常，模型会触发 MCP tool call，再给出最终文本。\n\n## 7. 增加 Trace 观测（推荐默认开启）\n\n```java\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"doubao-seed-1-8-251228\")\n        .toolRegistry(Collections.<String>emptyList(), Arrays.asList(\"weather-http\"))\n        .traceExporter(new ConsoleTraceExporter())\n        .traceConfig(TraceConfig.builder().build())\n        .build();\n```\n\n你会看到 RUN/STEP/MODEL/TOOL 的 trace，能快速判断慢在模型还是慢在 MCP。\n\n## 8. 常见故障与定位\n\n## 8.1 Agent 看不到 MCP 工具\n\n排查顺序：\n\n1. `gateway.isInitialized()` 是否为 true\n2. `gateway.getAvailableTools()` 是否有工具\n3. `toolRegistry(..., Arrays.asList(\"weather-http\"))` 的 serviceId 是否拼写一致\n\n## 8.2 模型没有触发工具\n\n排查顺序：\n\n1. `systemPrompt/instructions` 是否明确“必须调用工具”\n2. `maxSteps` 是否过小\n3. 工具描述是否足够让模型理解用途\n\n## 8.3 调用超时或失败\n\n排查顺序：\n\n1. MCP 服务端可达性（url/认证）\n2. 服务端 `tools/call` 是否可用\n3. 网关/反向代理超时配置\n\n## 9. 多租户扩展（可选）\n\n如果你做 SaaS，可用用户级 MCP 客户端：\n\n```java\ngateway.addUserMcpClient(\"u1001\", \"weather-http\", userClient).join();\nString output = gateway.callUserTool(\"u1001\", \"query_weather\", Collections.singletonMap(\"location\", \"Beijing\")).join();\n```\n\n配合 Agent 时，建议在会话层绑定 userId，并统一审计。\n\n## 10. 与 Workflow 组合（实战升级）\n\n推荐把 MCP 查询和结果格式化拆成两个节点：\n\n1. 节点 A：MCP 查询 + 初步分析\n2. 节点 B：JSON 格式化\n\n这样可以明显提升稳定性和可维护性（对应你当前的天气双 Agent 模式）。\n\n## 11. 一份可复用的最小测试模板\n\n```java\n@Test\npublic void test_mcp_agent_e2e() throws Exception {\n    McpGateway gateway = McpGateway.getInstance();\n    gateway.initialize(\"mcp-servers-config.json\").join();\n\n    Agent agent = Agents.react()\n            .modelClient(modelClient)\n            .model(\"doubao-seed-1-8-251228\")\n            .systemPrompt(\"你是天气助手，必须先调用工具\")\n            .toolRegistry(Collections.<String>emptyList(), Arrays.asList(\"weather-http\"))\n            .options(AgentOptions.builder().maxSteps(4).build())\n            .build();\n\n    AgentResult result = agent.run(AgentRequest.builder().input(\"北京天气\").build());\n    Assert.assertNotNull(result);\n    Assert.assertNotNull(result.getOutputText());\n    Assert.assertTrue(result.getOutputText().length() > 0);\n}\n```\n\n## 12. 生产落地建议\n\n1. Gateway 初始化失败时，提供降级回答而不是直接 500。\n2. MCP 工具必须按业务场景白名单暴露。\n3. Trace 默认开启，日志至少保留 `serviceId/toolName/status/latency`。\n4. 关键第三方 MCP 建议配熔断和重试策略。\n\n---\n\n如果你接下来愿意，我可以再补一个“**MCP + StateGraph + SubAgent 三层编排**”的端到端案例页，直接对应你目前的 Agent 架构路线。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/mysql-dynamic-datasource.md",
    "content": "﻿---\nsidebar_position: 5\n---\n\n# MySQL 动态 MCP 服务管理\n\n历史主题来源：通过 MySQL 管理 MCP 服务配置，实现“热更新而非重启发布”。\n\n## 1. 为什么要动态管理\n\n静态配置文件在以下场景成本很高：\n\n- 新增服务需要发布\n- 停用故障服务响应慢\n- 缺少统一审计\n\n动态管理的目标是：**配置可治理、服务可热更新、操作可审计**。\n\n## 2. 示例表结构\n\n```sql\nCREATE TABLE mcp_service_config (\n  id BIGINT PRIMARY KEY AUTO_INCREMENT,\n  service_id VARCHAR(128) NOT NULL,\n  service_name VARCHAR(255) NOT NULL,\n  type VARCHAR(32) NOT NULL,\n  config_json TEXT NOT NULL,\n  enabled TINYINT NOT NULL DEFAULT 1,\n  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n);\n```\n\n建议补充字段：\n\n- `version`：配置版本\n- `operator`：操作人\n- `tenant_id`：租户标识\n- `remark`：变更说明\n\n## 3. 启动流程\n\n1. 启动时读取 `enabled=1` 的服务配置\n2. 反序列化为 Gateway 服务定义\n3. 初始化 `McpGateway`\n4. 做健康检查后再对外暴露\n\n## 4. 运行时流程\n\n- **新增服务**：插入 DB -> 校验 -> 热加载到网关\n- **停用服务**：更新 DB -> 从网关摘除\n- **更新服务**：版本比较 -> 安全切换 -> 记录审计\n\n## 5. 关键治理点\n\n### 5.1 变更安全\n\n- 变更前后快照\n- 灰度生效\n- 快速回滚\n\n### 5.2 权限控制\n\n- 仅平台管理员可变更服务配置\n- 关键服务变更可加二次确认\n\n### 5.3 运行监控\n\n- 按服务维度统计调用成功率\n- 慢服务告警\n- 连续失败自动摘除（可选）\n\n## 6. 与 Agent 协同\n\n动态 MCP 服务可作为 Agent 的可选工具池，配合 `toolRegistry` 做场景化暴露：\n\n- 财务场景只暴露财务相关工具\n- 运维场景只暴露运维相关工具\n\n## 7. 迁移建议\n\n从静态配置迁移到动态配置时，建议两阶段：\n\n1. 双读（静态 + 动态）验证一致性\n2. 切主动态后保留静态兜底一个版本周期\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/overview.md",
    "content": "﻿---\nsidebar_position: 1\n---\n\n# MCP 总览（概念、角色、能力边界）\n\n你希望把 MCP 文档补全为“可直接落地”的版本，这一节先给完整地图。\n\n## 1. MCP 是什么\n\nMCP（Model Context Protocol）可以理解成“模型连接外部能力的标准协议层”，它把工具、资源、提示等能力统一成可发现、可调用、可扩展的接口。\n\n在 AI4J 里，MCP 覆盖了两条主线：\n\n1. **你作为 MCP Client**：连接第三方 MCP 服务并把工具暴露给模型\n2. **你作为 MCP Server**：把本地 Java 能力发布成 MCP 给别人调用\n\n## 2. MCP 核心对象（协议视角）\n\n- **Tool**：可执行动作（例如查询天气、创建 Issue）\n- **Resource**：可读取的数据对象（按 URI 访问）\n- **Prompt**：可参数化提示模板\n- **Initialize/Notification**：会话初始化与状态通知\n\nAI4J 当前“客户端高层 API”重点封装了 Tool（`tools/list`、`tools/call`）；Server 端支持 Tool/Resource/Prompt 的标准端点。\n\n## 3. AI4J 的 MCP 模块地图\n\n- Client：`mcp.client.McpClient`\n- Transport：`mcp.transport.*`\n  - `StdioTransport`\n  - `SseTransport`\n  - `StreamableHttpTransport`\n- Gateway：`mcp.gateway.McpGateway`\n- Config：`mcp.config.*`\n- Server：`mcp.server.*`\n- 注解与适配器：`mcp.annotation.*` + `mcp.util.*Adapter`\n\n## 4. 三种传输类型\n\n- `STDIO`：本地子进程通信（常用于本地 MCP 工具进程）\n- `SSE`：Server-Sent Events（双端点：SSE + message）\n- `Streamable HTTP`：HTTP MCP 端点（通常 `/mcp`）\n\n详细差异见《MCP 传输类型详解》。\n\n## 5. 你最常用的四种接入方式\n\n1. 直接 `McpClient + Transport` 连接单个第三方 MCP\n2. `McpGateway.initialize(config)` 管理多个 MCP\n3. `McpGateway.addMcpClient/addUserMcpClient` 动态增删\n4. 在 Agent 里通过 `toolRegistry(functions, mcpServices)` 场景化暴露\n\n详细步骤见《接入第三方 MCP（全部方式）》。\n\n## 6. 暴露你自己的 MCP 服务\n\nAI4J 支持你把本地注解能力暴露成 MCP Server：\n\n- `@McpService`\n- `@McpTool`\n- `@McpResource`\n- `@McpPrompt`\n\n再配合 `McpServerFactory` 选择 `stdio/sse/streamable_http` 启动。\n\n完整流程见《构建并对外发布 MCP Server》。\n\n## 7. 工具暴露语义（与你最近调整一致）\n\n当前工具语义：\n\n- `ToolUtil.getAllTools(functionList, mcpServerIds)`：只使用你显式传入的 Function/MCP 服务\n- `ToolUtil.getLocalMcpTools()`：用于 MCP Server 对外暴露本地 MCP 工具\n\n这保证普通 Agent 不会意外暴露全部本地 MCP 工具。\n\n## 8. 推荐阅读路径\n\n1. 本页\n2. 《MCP 传输类型详解》\n3. 《MCP Client 接入》\n4. 《接入第三方 MCP（全部方式）》\n5. 《MCP Gateway 管理》\n6. 《构建并对外发布 MCP Server》\n7. 《Tool 暴露语义与安全边界》\n8. 《MySQL 动态 MCP 服务管理》\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/third-party-mcp-integration.md",
    "content": "﻿---\nsidebar_position: 4\n---\n\n# 接入第三方 MCP（全部方式）\n\n你要求“外部第三方 MCP 的所有使用方式”，这里按工程复杂度给出完整路径。\n\n## 1. 方式总览\n\n### 方式 1：单连接直连（`McpClient`）\n\n- 适合：1~2 个服务、快速验证\n- 优点：最简单\n- 缺点：服务多了后连接与路由逻辑分散\n\n### 方式 2：网关配置加载（`McpGateway.initialize(...)`）\n\n- 适合：多服务统一管理\n- 优点：工具聚合、统一调用入口\n- 缺点：需要维护配置文件/配置源\n\n### 方式 3：运行时动态增删（`addMcpClient/removeMcpClient`）\n\n- 适合：平台化、热更新\n- 优点：不停机增删服务\n- 缺点：需要更严格治理与审计\n\n### 方式 4：用户级隔离（`addUserMcpClient`）\n\n- 适合：SaaS 多租户\n- 优点：租户工具可隔离\n- 缺点：命名与权限策略要严格\n\n### 方式 5：接入 Agent（`toolRegistry(..., mcpServices)`）\n\n- 适合：模型任务执行\n- 优点：按场景显式暴露工具\n- 缺点：前置要求是 gateway 已可用\n\n## 2. 方式 1：单连接直连\n\n```java\nMcpTransport transport = McpTransportFactory.createTransport(\n        \"stdio\",\n        TransportConfig.stdio(\"npx\", Arrays.asList(\"-y\", \"12306-mcp\"))\n);\n\nMcpClient client = new McpClient(\"ticket-client\", \"1.0.0\", transport);\nclient.connect().join();\n\nList<McpToolDefinition> tools = client.getAvailableTools().join();\nString output = client.callTool(\"search_train\", Collections.singletonMap(\"from\", \"北京\")).join();\n\nclient.disconnect().join();\n```\n\n## 3. 方式 2：网关配置加载\n\n`ai4j/src/main/resources/mcp-servers-config.json` 示例：\n\n```json\n{\n  \"mcpServers\": {\n    \"test_weather_http\": {\n      \"type\": \"streamable_http\",\n      \"url\": \"http://127.0.0.1:8000/mcp\"\n    }\n  }\n}\n```\n\n推荐在新配置里直接写 `streamable_http`，`http` 只保留兼容旧配置。\n\n初始化：\n\n```java\nMcpGateway gateway = new McpGateway();\ngateway.initialize(\"mcp-servers-config.json\").join();\n\nList<Tool.Function> tools = gateway.getAvailableTools().join();\nString result = gateway.callTool(\"query_weather\", Collections.singletonMap(\"location\", \"Beijing\")).join();\n```\n\n## 4. 方式 3：动态增删服务（热更新）\n\n```java\nMcpClient githubClient = new McpClient(\"github\", \"1.0.0\", githubTransport);\ngateway.addMcpClient(\"github\", githubClient).join();\n\n// ... later\ngateway.removeMcpClient(\"github\").join();\n```\n\n也可通过 `McpConfigSource` + 监听器实现配置驱动热更新（文件、MySQL、Redis 都可扩展）。\n\n## 5. 方式 4：用户级第三方 MCP（多租户）\n\n```java\nMcpClient userClient = new McpClient(\"user-github\", \"1.0.0\", transport);\ngateway.addUserMcpClient(\"u123\", \"github\", userClient).join();\n\nString output = gateway.callUserTool(\"u123\", \"search_repositories\", Collections.singletonMap(\"q\", \"ai4j\")).join();\n```\n\n路由命名规则：\n\n- 用户服务键：`user_{userId}_service_{serviceId}`\n- 用户工具键：`user_{userId}_tool_{toolName}`\n\n## 6. 方式 5：把第三方 MCP 暴露给 Agent\n\n### 6.1 先初始化网关\n\n```java\nMcpGateway gateway = McpGateway.getInstance();\ngateway.initialize(\"mcp-servers-config.json\").join();\n```\n\n### 6.2 Agent 只暴露指定 MCP 服务\n\n```java\nAgent agent = Agents.react()\n        .modelClient(modelClient)\n        .model(\"doubao-seed-1-8-251228\")\n        .toolRegistry(Arrays.asList(\"queryWeather\"), Arrays.asList(\"github\"))\n        .build();\n```\n\n当前语义：\n\n- 只暴露 `functionList + mcpServerIds` 中显式传入内容\n- 不会自动注入全部本地 MCP 工具\n\n## 7. 第三方 MCP 接入治理清单\n\n1. 服务注册：serviceId 命名规范（避免冲突）\n2. 认证管理：token/key 走 header/env\n3. 限流熔断：慢服务隔离\n4. 审计：记录 toolName、serviceId、耗时、状态\n5. 升级策略：先灰度后全量\n\n## 8. 常见错误与修复\n\n1. 网关拿不到工具\n   - 检查 `gateway.isInitialized()` 与配置路径。\n2. Agent 能看到的工具不全\n   - 检查 `toolRegistry(..., mcpServices)` 是否传了正确 `mcpServerIds`。\n3. 用户工具调用失败\n   - 检查 userId/serviceId 前缀和 `callUserTool` 参数。\n\n## 9. 进阶建议\n\n- 小项目：直连 `McpClient`\n- 中大型项目：统一 `McpGateway`\n- 平台项目：`McpGateway + 动态配置源 + 审计` 标准化落地\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/tool-exposure-semantics.md",
    "content": "﻿---\nsidebar_position: 4\n---\n\n# Tool 暴露语义与安全边界\n\n这页回答一个你非常关心的问题：\n\n> `getAllTools(...)` 到底应该是“传什么用什么”，还是“自动全量注入”？\n\nAI4J 当前语义已经统一为：**传什么，用什么**。\n\n## 1. 关键方法语义\n\n### `ToolUtil.getAllTools(functionList, mcpServerIds)`\n\n- 只合并你显式传入的 function 与 mcp 服务\n- 不自动注入全部本地 MCP 工具\n\n### `ToolUtil.getLocalMcpTools()`\n\n- 返回本地 MCP tool 缓存\n- 主要用于 MCP Server 对外暴露本地能力\n\n## 2. 为什么这么设计\n\n如果默认全量注入，会出现以下风险：\n\n- Agent 在不知情情况下获得额外高权限工具\n- 提示词越狱时攻击面扩大\n- 调试时难以判断工具来源\n\n显式传入语义可以把风险收敛到调用点。\n\n## 3. 推荐使用模式\n\n### 业务 Agent\n\n```java\n.toolRegistry(Arrays.asList(\"queryWeather\", \"queryStock\"), Arrays.asList(\"github-service\"))\n```\n\n仅暴露本场景需要的工具。\n\n### MCP Server 构建\n\n在 server 构建流程里使用 `getLocalMcpTools()`，暴露你希望对外发布的本地工具。\n\n## 4. 代码审查检查点\n\n- 是否误把 `getLocalMcpTools()` 用在普通 agent 场景\n- 是否把高风险工具误加入默认注册列表\n- 是否存在“工具名冲突导致的错误调用”\n\n## 5. 安全基线建议\n\n1. 工具白名单优先，不依赖黑名单。\n2. 高风险工具单独二次确认。\n3. 工具参数做强校验，禁止原样透传外部输入。\n4. 工具调用全链路打 trace，便于审计。\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current/mcp/transport-types.md",
    "content": "﻿---\nsidebar_position: 2\n---\n\n# MCP 传输类型详解（STDIO / SSE / Streamable HTTP）\n\n这页专门回答“各类 MCP transport 有什么区别，怎么选”。\n\n## 1. 三种 transport 对照表\n\n| 类型 | 典型场景 | 连接方式 | AI4J 实现 |\n| --- | --- | --- | --- |\n| STDIO | 本地进程工具 | 启动子进程 + stdin/stdout | `StdioTransport` |\n| SSE | 远程事件流服务 | `GET /sse` + `POST /message` | `SseTransport` |\n| Streamable HTTP | 标准 HTTP MCP 端点 | `POST /mcp`（可返回 JSON 或 SSE） | `StreamableHttpTransport` |\n\n## 2. STDIO\n\n### 特点\n\n- 适合本机或同宿主机工具\n- 无需额外开放端口\n- 由客户端负责拉起 MCP 进程\n\n### AI4J 用法\n\n```java\nMcpTransport transport = new StdioTransport(\n        \"npx\",\n        Arrays.asList(\"-y\", \"@modelcontextprotocol/server-filesystem\", \"D:/workspace\"),\n        null\n);\n\nMcpClient client = new McpClient(\"my-client\", \"1.0.0\", transport);\nclient.connect().join();\n```\n\n### 选型建议\n\n- 本地开发优先\n- 对隔离要求高时，配合独立用户/容器运行进程\n\n## 3. SSE\n\n### 特点\n\n- 服务端主动推送事件，适合长连接\n- 通常拆为事件端点与消息端点\n- 网络抖动时要处理重连\n\n### AI4J 用法\n\n```java\nMcpTransport transport = new SseTransport(\"https://example.com/sse\");\nMcpClient client = new McpClient(\"my-client\", \"1.0.0\", transport);\nclient.connect().join();\n```\n\n### 选型建议\n\n- 已有 SSE MCP 服务时使用\n- 需要关注代理层对长连接的支持\n\n## 4. Streamable HTTP\n\n### 特点\n\n- 统一 HTTP 端点（通常 `/mcp`）\n- 兼容返回单次 JSON 或事件流\n- 对云原生部署友好\n\n### AI4J 用法\n\n```java\nTransportConfig config = TransportConfig.streamableHttp(\"https://example.com/mcp\");\nconfig.setHeaders(Collections.singletonMap(\"Authorization\", \"Bearer xxx\"));\n\nMcpTransport transport = new StreamableHttpTransport(config);\nMcpClient client = new McpClient(\"my-client\", \"1.0.0\", transport);\nclient.connect().join();\n```\n\n## 5. 用 `McpTransportFactory` 统一创建\n\n```java\nTransportConfig config = TransportConfig.stdio(\"npx\", Arrays.asList(\"-y\", \"12306-mcp\"));\nMcpTransport transport = McpTransportFactory.createTransport(\"stdio\", config);\n```\n\n支持类型别名解析：\n\n- `stdio`\n- `sse`\n- `streamable_http`\n- `http`（兼容别名，最终会归一化为 `streamable_http`）\n\n## 6. 心跳与连接稳定性\n\n`McpTransport` 接口有 `needsHeartbeat()`：\n\n- 网络型 transport（SSE/HTTP）通常需要心跳\n- 进程型 transport（STDIO）通常不需要\n\n`McpClient` 会在需要时启动心跳与重连逻辑。\n\n## 7. 生产选型建议\n\n- **单机工具链**：STDIO\n- **已有 SSE 服务**：SSE\n- **标准化平台与网关治理**：Streamable HTTP\n\n## 8. 常见问题\n\n1. `connect()` 卡住：多为端点不通或初始化未完成。\n2. SSE 频繁断开：检查网关/反向代理超时设置。\n3. STDIO 启动失败：优先看 command/args/env 是否正确。\n\n## 9. 关联源码\n\n- `McpTransport`\n- `TransportConfig`\n- `StdioTransport`\n- `SseTransport`\n- `StreamableHttpTransport`\n- `McpTransportFactory`\n\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json",
    "content": "﻿{\n  \"version.label\": {\n    \"message\": \"当前版本\",\n    \"description\": \"current 版本标签\"\n  },\n  \"sidebar.tutorialSidebar.category.Getting Started\": {\n    \"message\": \"快速开始\",\n    \"description\": \"旧键名兼容：快速开始\"\n  },\n  \"sidebar.tutorialSidebar.category.Guides\": {\n    \"message\": \"场景指南\",\n    \"description\": \"旧键名兼容：场景指南\"\n  },\n  \"sidebar.tutorialSidebar.category.MCP\": {\n    \"message\": \"MCP\",\n    \"description\": \"MCP 分类\"\n  },\n  \"sidebar.tutorialSidebar.category.Agent\": {\n    \"message\": \"Agent 智能体\",\n    \"description\": \"旧键名兼容：Agent 分类\"\n  },\n  \"sidebar.tutorialSidebar.category.Deployment\": {\n    \"message\": \"部署\",\n    \"description\": \"旧键名兼容：部署分类\"\n  },\n  \"sidebar.tutorialSidebar.category.快速开始\": {\n    \"message\": \"快速开始\",\n    \"description\": \"中文键名：快速开始\"\n  },\n  \"sidebar.tutorialSidebar.category.场景指南\": {\n    \"message\": \"场景指南\",\n    \"description\": \"中文键名：场景指南\"\n  },\n  \"sidebar.tutorialSidebar.category.Agent 智能体\": {\n    \"message\": \"Agent 智能体\",\n    \"description\": \"中文键名：Agent 智能体\"\n  },\n  \"sidebar.tutorialSidebar.category.部署\": {\n    \"message\": \"部署\",\n    \"description\": \"中文键名：部署\"\n  }\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-theme-classic/footer.json",
    "content": "﻿{\n  \"link.title.Docs\": {\n    \"message\": \"文档\",\n    \"description\": \"页脚文档分组标题\"\n  },\n  \"link.title.Resources\": {\n    \"message\": \"资源\",\n    \"description\": \"页脚资源分组标题\"\n  },\n  \"link.title.Open Source\": {\n    \"message\": \"开源\",\n    \"description\": \"页脚开源分组标题\"\n  },\n  \"link.title.文档\": {\n    \"message\": \"文档\",\n    \"description\": \"页脚文档分组标题（中文键）\"\n  },\n  \"link.title.资源\": {\n    \"message\": \"资源\",\n    \"description\": \"页脚资源分组标题（中文键）\"\n  },\n  \"link.title.开源\": {\n    \"message\": \"开源\",\n    \"description\": \"页脚开源分组标题（中文键）\"\n  },\n  \"link.item.label.Start Here\": {\n    \"message\": \"开始阅读\",\n    \"description\": \"开始阅读链接\"\n  },\n  \"link.item.label.Agent\": {\n    \"message\": \"智能体 Agent\",\n    \"description\": \"Agent 链接\"\n  },\n  \"link.item.label.MCP\": {\n    \"message\": \"MCP\",\n    \"description\": \"MCP 链接\"\n  },\n  \"link.item.label.Blog Migration Map\": {\n    \"message\": \"历史博客迁移映射\",\n    \"description\": \"历史博客迁移映射链接\"\n  },\n  \"link.item.label.Cloudflare Deployment\": {\n    \"message\": \"Cloudflare Pages 部署指南\",\n    \"description\": \"Cloudflare 部署链接\"\n  },\n  \"link.item.label.开始阅读\": {\n    \"message\": \"开始阅读\",\n    \"description\": \"开始阅读链接（中文键）\"\n  },\n  \"link.item.label.智能体 Agent\": {\n    \"message\": \"智能体 Agent\",\n    \"description\": \"智能体链接（中文键）\"\n  },\n  \"link.item.label.历史博客迁移映射\": {\n    \"message\": \"历史博客迁移映射\",\n    \"description\": \"迁移映射链接（中文键）\"\n  },\n  \"link.item.label.Cloudflare Pages 部署指南\": {\n    \"message\": \"Cloudflare Pages 部署指南\",\n    \"description\": \"Cloudflare 部署链接（中文键）\"\n  },\n  \"link.item.label.GitHub\": {\n    \"message\": \"GitHub\",\n    \"description\": \"GitHub 链接\"\n  },\n  \"link.item.label.Issues\": {\n    \"message\": \"Issues\",\n    \"description\": \"Issues 链接\"\n  },\n  \"copyright\": {\n    \"message\": \"Copyright (c) 2026 AI4J Contributors · 基于 Docusaurus 构建\",\n    \"description\": \"页脚版权\"\n  }\n}\n"
  },
  {
    "path": "docs-site/i18n/zh-Hans/docusaurus-theme-classic/navbar.json",
    "content": "﻿{\n  \"title\": {\n    \"message\": \"AI4J 文档站\",\n    \"description\": \"导航栏标题\"\n  },\n  \"logo.alt\": {\n    \"message\": \"AI4J Logo\",\n    \"description\": \"导航栏 Logo 的 alt 文本\"\n  },\n  \"item.label.Docs\": {\n    \"message\": \"文档\",\n    \"description\": \"Docs 导航项\"\n  },\n  \"item.label.Blog\": {\n    \"message\": \"博客\",\n    \"description\": \"Blog 导航项\"\n  },\n  \"item.label.GitHub\": {\n    \"message\": \"GitHub\",\n    \"description\": \"GitHub 导航项\"\n  },\n  \"item.label.文档\": {\n    \"message\": \"文档\",\n    \"description\": \"文档导航项\"\n  },\n  \"item.label.博客\": {\n    \"message\": \"博客\",\n    \"description\": \"博客导航项\"\n  }\n}\n"
  },
  {
    "path": "docs-site/package.json",
    "content": "{\n  \"name\": \"docs-site\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"docusaurus\": \"docusaurus\",\n    \"start\": \"docusaurus start\",\n    \"build\": \"docusaurus build\",\n    \"swizzle\": \"docusaurus swizzle\",\n    \"deploy\": \"docusaurus deploy\",\n    \"clear\": \"docusaurus clear\",\n    \"serve\": \"docusaurus serve\",\n    \"write-translations\": \"docusaurus write-translations\",\n    \"write-heading-ids\": \"docusaurus write-heading-ids\",\n    \"typecheck\": \"tsc\"\n  },\n  \"dependencies\": {\n    \"@docusaurus/core\": \"3.9.2\",\n    \"@docusaurus/preset-classic\": \"3.9.2\",\n    \"@mdx-js/react\": \"^3.0.0\",\n    \"clsx\": \"^2.0.0\",\n    \"prism-react-renderer\": \"^2.3.0\",\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\"\n  },\n  \"devDependencies\": {\n    \"@docusaurus/module-type-aliases\": \"3.9.2\",\n    \"@docusaurus/tsconfig\": \"3.9.2\",\n    \"@docusaurus/types\": \"3.9.2\",\n    \"typescript\": \"~5.6.2\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.5%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 3 chrome version\",\n      \"last 3 firefox version\",\n      \"last 5 safari version\"\n    ]\n  },\n  \"engines\": {\n    \"node\": \">=20.0\"\n  }\n}\n"
  },
  {
    "path": "docs-site/scripts/generate_agent_teams_api_docs.py",
    "content": "from __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import List\n\nREPO_ROOT = Path(__file__).resolve().parents[2]\nSOURCE_ROOT = REPO_ROOT / \"ai4j-agent\" / \"src\" / \"main\" / \"java\" / \"io\" / \"github\" / \"lnyocly\" / \"ai4j\" / \"agent\" / \"team\"\nOUTPUT_FILE = REPO_ROOT / \"docs-site\" / \"docs\" / \"agent\" / \"agent-teams-api-reference.md\"\n\nCLASS_RE = re.compile(r\"^\\s*(public|protected|private)?\\s*(?:static\\s+)?(?:abstract\\s+)?(class|interface|enum)\\s+([A-Za-z_][A-Za-z0-9_]*)\")\nFIELD_RE = re.compile(\n    r\"^\\s*(public|protected|private)\\s+\"\n    r\"((?:(?:static|final|volatile|transient)\\s+)*)\"\n    r\"([A-Za-z0-9_<>,\\[\\]. ?]+?)\\s+\"\n    r\"([A-Za-z_][A-Za-z0-9_]*)\\s*(?:=[^;]*)?;\\s*$\"\n)\nMETHOD_RE = re.compile(\n    r\"^\\s*(public|protected|private)\\s+\"\n    r\"((?:(?:static|final|synchronized|native|abstract|default|strictfp)\\s+)*)\"\n    r\"(?:<[^>]+>\\s*)?\"\n    r\"([A-Za-z0-9_<>,\\[\\]. ?]+?)\\s+\"\n    r\"([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(?:throws [^{;]+)?[;{]\\s*$\"\n)\nCTOR_RE = re.compile(\n    r\"^\\s*(public|protected|private)\\s+\"\n    r\"((?:(?:static|final|synchronized)\\s+)*)\"\n    r\"([A-Za-z_][A-Za-z0-9_]*)\\s*\\(([^)]*)\\)\\s*(?:throws [^{;]+)?\\{\\s*$\"\n)\n\nCONTROL_PREFIXES = (\n    \"if \",\n    \"for \",\n    \"while \",\n    \"switch \",\n    \"catch \",\n    \"return \",\n    \"throw \",\n    \"else \",\n    \"do \",\n    \"try \",\n    \"synchronized \",\n)\n\nCLASS_DESCRIPTIONS = {\n    \"AgentTeam\": \"Agent Teams 的总调度器。负责规划、任务派发、并发执行、消息协作、最终汇总。\",\n    \"AgentTeamBuilder\": \"构建 AgentTeam 的入口，组装 lead/planner/synthesizer/member/options。\",\n    \"AgentTeamControl\": \"团队运行期控制接口，统一成员、任务、消息操作能力。\",\n    \"AgentTeamHook\": \"生命周期钩子接口，支持在规划/任务/汇总阶段埋点与审计。\",\n    \"AgentTeamMember\": \"成员定义对象，包含成员身份与绑定 Agent 实例。\",\n    \"AgentTeamMemberResult\": \"单个任务执行结果对象，记录产出、耗时、错误和状态。\",\n    \"AgentTeamMessage\": \"团队消息模型，承载 from/to/type/taskId/content。\",\n    \"AgentTeamMessageBus\": \"消息总线抽象，定义 publish/snapshot/historyFor/clear。\",\n    \"AgentTeamOptions\": \"团队运行配置对象，控制并发、容错、消息注入、超时回收等行为。\",\n    \"AgentTeamPlan\": \"Planner 输出后的计划模型，包含任务列表。\",\n    \"AgentTeamPlanApproval\": \"计划审批回调，允许在派发前人为/策略拦截。\",\n    \"AgentTeamPlanner\": \"规划器接口，输入目标和成员，输出任务计划。\",\n    \"AgentTeamPlanParser\": \"将模型输出文本解析为 AgentTeamPlan，含 JSON 提取和兜底策略。\",\n    \"AgentTeamResult\": \"一次团队运行的完整结果快照，含计划、成员结果、任务状态、消息、轮次。\",\n    \"AgentTeamSynthesizer\": \"汇总器接口，将成员结果整合为最终输出。\",\n    \"AgentTeamTask\": \"任务定义模型，包含 id/memberId/task/context/dependsOn。\",\n    \"AgentTeamTaskBoard\": \"任务状态机与依赖调度核心，实现 READY/IN_PROGRESS/COMPLETED 等流转。\",\n    \"AgentTeamTaskState\": \"任务运行态对象，记录 claim、heartbeat、输出、错误、耗时。\",\n    \"AgentTeamTaskStatus\": \"任务状态枚举。\",\n    \"InMemoryAgentTeamMessageBus\": \"内存消息总线实现，适合单进程场景。\",\n    \"LlmAgentTeamPlanner\": \"基于 Agent 的默认规划器实现，模型输出计划，失败时可回退简单计划。\",\n    \"LlmAgentTeamSynthesizer\": \"基于 Agent 的默认汇总器实现，汇总成员结果为最终答复。\",\n    \"AgentTeamToolExecutor\": \"team_* 工具执行器，将成员工具调用路由到 AgentTeamControl。\",\n    \"AgentTeamToolRegistry\": \"team_* 工具注册表，定义并暴露团队内置协作工具。\",\n    \"RuntimeMember\": \"AgentTeam 内部成员运行态对象，包装成员定义并缓存执行引用。\",\n    \"PreparedDispatch\": \"AgentTeam 内部派发单元，绑定 task/member 用于执行轮次。\",\n    \"DispatchOutcome\": \"AgentTeam 内部派发汇总对象，记录成员结果和轮次数。\",\n}\n\n\n@dataclass\nclass FieldInfo:\n    visibility: str\n    modifiers: str\n    type_name: str\n    name: str\n    line: int\n\n\n@dataclass\nclass MethodInfo:\n    visibility: str\n    modifiers: str\n    return_type: str\n    name: str\n    params: str\n    line: int\n    is_constructor: bool = False\n\n\n@dataclass\nclass ClassInfo:\n    kind: str\n    name: str\n    full_name: str\n    file_path: str\n    line: int\n    fields: List[FieldInfo] = field(default_factory=list)\n    methods: List[MethodInfo] = field(default_factory=list)\n\n\n@dataclass\nclass StackEntry:\n    class_info: ClassInfo\n    active_depth: int\n\n\ndef clean_modifiers(raw: str) -> str:\n    value = \" \".join(raw.strip().split())\n    return value if value else \"-\"\n\n\ndef infer_field_description(name: str, type_name: str) -> str:\n    lower = name.lower()\n    if name.endswith(\"Id\") or lower.endswith(\"id\"):\n        return \"唯一标识/关联标识字段，用于实体定位或引用。\"\n    if \"timeout\" in lower:\n        return \"超时配置字段，用于控制等待和回收策略。\"\n    if \"concurrency\" in lower or lower.startswith(\"max\"):\n        return \"并发或阈值配置字段，影响调度上限。\"\n    if lower.startswith(\"enable\") or lower.startswith(\"allow\") or lower.startswith(\"require\"):\n        return \"布尔开关字段，控制功能启用或治理策略。\"\n    if \"message\" in lower:\n        return \"消息相关字段，承载协作通信数据。\"\n    if \"task\" in lower:\n        return \"任务相关字段，保存任务定义或运行态信息。\"\n    if \"member\" in lower:\n        return \"成员相关字段，描述团队角色或成员映射。\"\n    if \"plan\" in lower:\n        return \"规划相关字段，保存 planner 输入/输出。\"\n    if \"result\" in lower or \"output\" in lower:\n        return \"结果字段，存储执行输出或汇总产物。\"\n    if lower.startswith(\"options\") or lower.endswith(\"options\"):\n        return \"运行配置对象，影响行为和策略。\"\n    if lower.endswith(\"agent\"):\n        return \"Agent 执行实例引用。\"\n    if \"list\" in type_name.lower() or \"map\" in type_name.lower():\n        return \"集合字段，用于维护批量数据或索引映射。\"\n    return \"运行期状态或配置字段，参与该类的核心行为。\"\n\n\ndef infer_method_description(name: str, is_constructor: bool, return_type: str) -> str:\n    if is_constructor:\n        return \"构造函数，初始化该类型的必要依赖与默认状态。\"\n\n    lower = name.lower()\n    if lower in {\"build\", \"builder\"}:\n        return \"构建入口方法，用于创建并返回目标对象。\"\n    if lower.startswith(\"run\") or lower.startswith(\"execute\"):\n        return \"执行入口方法，驱动主流程并返回执行结果。\"\n    if lower.startswith(\"plan\"):\n        return \"规划方法，根据目标/成员生成任务计划。\"\n    if lower.startswith(\"synthesize\"):\n        return \"汇总方法，将多成员结果合并为最终输出。\"\n    if lower.startswith(\"dispatch\") or lower.startswith(\"prepared\"):\n        return \"任务派发方法，负责轮次调度与执行分配。\"\n    if lower.startswith(\"claim\") or lower.startswith(\"release\") or lower.startswith(\"reassign\"):\n        return \"任务认领/释放/重分配方法，维护任务所有权。\"\n    if lower.startswith(\"heartbeat\") or lower.startswith(\"recover\"):\n        return \"运行保活与恢复方法，用于检测超时并回收任务。\"\n    if lower.startswith(\"mark\"):\n        return \"状态写入方法，推进任务状态机到下一个阶段。\"\n    if lower.startswith(\"list\") or lower.startswith(\"snapshot\") or lower.startswith(\"history\"):\n        return \"查询方法，读取当前快照或历史记录。\"\n    if lower.startswith(\"publish\") or lower.startswith(\"send\") or lower.startswith(\"broadcast\"):\n        return \"消息发布方法，向单成员或全体广播协作信息。\"\n    if lower.startswith(\"parse\"):\n        return \"解析方法，将文本/参数转换为结构化对象。\"\n    if lower.startswith(\"normalize\") or lower.startswith(\"resolve\") or lower.startswith(\"validate\"):\n        return \"规范化与校验方法，保证输入可用和行为一致。\"\n    if lower.startswith(\"supports\"):\n        return \"能力探测方法，判断是否支持某个功能或工具。\"\n    if lower.startswith(\"copy\") or lower.startswith(\"to\"):\n        return \"转换方法，在内部对象与公开对象之间映射。\"\n    if return_type == \"boolean\":\n        return \"布尔判定方法，返回条件是否满足。\"\n    return \"内部辅助方法，服务于该类的核心执行逻辑。\"\n\n\ndef parse_java_file(path: Path) -> List[ClassInfo]:\n    rel_path = path.relative_to(REPO_ROOT).as_posix()\n    classes: List[ClassInfo] = []\n    stack: List[StackEntry] = []\n    depth = 0\n\n    lines = path.read_text(encoding=\"utf-8\").splitlines()\n    for idx, raw_line in enumerate(lines, 1):\n        line = raw_line.strip()\n\n        while stack and depth < stack[-1].active_depth:\n            stack.pop()\n\n        class_match = CLASS_RE.match(line)\n        if class_match and \"(\" not in line:\n            kind = class_match.group(2)\n            name = class_match.group(3)\n            parent = stack[-1].class_info.full_name if stack else \"\"\n            full_name = f\"{parent}.{name}\" if parent else name\n            info = ClassInfo(kind=kind, name=name, full_name=full_name, file_path=rel_path, line=idx)\n            classes.append(info)\n            if \"{\" in line:\n                stack.append(StackEntry(class_info=info, active_depth=depth + line.count(\"{\") - line.count(\"}\")))\n\n        if stack:\n            current = stack[-1].class_info\n\n            field_match = FIELD_RE.match(line)\n            if field_match and \"(\" not in line:\n                current.fields.append(\n                    FieldInfo(\n                        visibility=field_match.group(1),\n                        modifiers=clean_modifiers(field_match.group(2) or \"\"),\n                        type_name=\" \".join(field_match.group(3).split()),\n                        name=field_match.group(4),\n                        line=idx,\n                    )\n                )\n            else:\n                if not line.startswith(\"@\") and not any(line.startswith(prefix) for prefix in CONTROL_PREFIXES):\n                    method_match = METHOD_RE.match(line)\n                    if method_match:\n                        current.methods.append(\n                            MethodInfo(\n                                visibility=method_match.group(1),\n                                modifiers=clean_modifiers(method_match.group(2) or \"\"),\n                                return_type=\" \".join(method_match.group(3).split()),\n                                name=method_match.group(4),\n                                params=\" \".join(method_match.group(5).split()),\n                                line=idx,\n                            )\n                        )\n                    else:\n                        ctor_match = CTOR_RE.match(line)\n                        if ctor_match:\n                            ctor_name = ctor_match.group(3)\n                            if ctor_name == current.name:\n                                current.methods.append(\n                                    MethodInfo(\n                                        visibility=ctor_match.group(1),\n                                        modifiers=clean_modifiers(ctor_match.group(2) or \"\"),\n                                        return_type=\"(constructor)\",\n                                        name=ctor_name,\n                                        params=\" \".join(ctor_match.group(4).split()),\n                                        line=idx,\n                                        is_constructor=True,\n                                    )\n                                )\n\n        depth += raw_line.count(\"{\")\n        depth -= raw_line.count(\"}\")\n\n    return classes\n\n\ndef render_markdown(classes: List[ClassInfo]) -> str:\n    lines: List[str] = []\n    lines.extend(\n        [\n            \"---\",\n            \"sidebar_position: 15\",\n            \"---\",\n            \"\",\n            \"# Agent Teams 全量 API 参考（类/函数/变量 + Demo + 预期）\",\n            \"\",\n            \"本页覆盖 `io.github.lnyocly.ai4j.agent.team` 与 `io.github.lnyocly.ai4j.agent.team.tool` 包中所有源码类，\",\n            \"并按“类 -> 变量 -> 函数”展开说明，方便排查与二次开发。\",\n            \"\",\n            \"> 文档由脚本从源码生成，建议在 Agent Teams 代码变更后重新执行：\",\n            \"> `python docs-site/scripts/generate_agent_teams_api_docs.py`\",\n            \"\",\n            \"## 1. 快速 Demo\",\n            \"\",\n            \"```java\",\n            \"Agent lead = Agents.react()\",\n            \"        .modelClient(new ResponsesModelClient(responsesService))\",\n            \"        .model(\\\"doubao-seed-1-8-251228\\\")\",\n            \"        .systemPrompt(\\\"你是团队负责人，先规划再汇总\\\")\",\n            \"        .build();\",\n            \"\",\n            \"Agent backend = Agents.react()\",\n            \"        .modelClient(new ResponsesModelClient(responsesService))\",\n            \"        .model(\\\"doubao-seed-1-8-251228\\\")\",\n            \"        .build();\",\n            \"\",\n            \"Agent frontend = Agents.react()\",\n            \"        .modelClient(new ResponsesModelClient(responsesService))\",\n            \"        .model(\\\"doubao-seed-1-8-251228\\\")\",\n            \"        .build();\",\n            \"\",\n            \"AgentTeam team = Agents.team()\",\n            \"        .leadAgent(lead)\",\n            \"        .member(AgentTeamMember.builder().id(\\\"backend\\\").name(\\\"后端\\\").agent(backend).build())\",\n            \"        .member(AgentTeamMember.builder().id(\\\"frontend\\\").name(\\\"前端\\\").agent(frontend).build())\",\n            \"        .options(AgentTeamOptions.builder()\",\n            \"                .parallelDispatch(true)\",\n            \"                .continueOnMemberError(true)\",\n            \"                .maxRounds(64)\",\n            \"                .build())\",\n            \"        .build();\",\n            \"\",\n            \"AgentTeamResult result = team.run(\\\"输出本周交付计划\\\");\",\n            \"System.out.println(result.getOutput());\",\n            \"```\",\n            \"\",\n            \"## 2. 预期行为（用于验收）\",\n            \"\",\n            \"- 预期 1：Planner 先产出 `tasks`，任务进入 `PENDING/READY`。\",\n            \"- 预期 2：并发开启时，多成员任务会并行执行；串行模式则按批次单线程执行。\",\n            \"- 预期 3：成员成功后任务转为 `COMPLETED`；异常转 `FAILED`，依赖任务可能转 `BLOCKED`。\",\n            \"- 预期 4：`continueOnMemberError=true` 时，失败任务不会中断整个团队，最终仍会尝试汇总。\",\n            \"- 预期 5：启用 `enableMemberTeamTools` 后，成员可调用 `team_send_message/team_claim_task/...` 完成主动协作。\",\n            \"\",\n            \"## 3. 类/变量/函数全量说明\",\n            \"\",\n        ]\n    )\n\n    for cls in sorted(classes, key=lambda c: (c.file_path, c.full_name)):\n        lines.append(f\"### {cls.kind} `{cls.full_name}`\")\n        lines.append(\"\")\n        lines.append(f\"- 源码：`{cls.file_path}:{cls.line}`\")\n        lines.append(f\"- 职责：{CLASS_DESCRIPTIONS.get(cls.name, '该类型用于 Agent Teams 运行链路中的结构定义或执行逻辑。')}\")\n        lines.append(\"\")\n\n        lines.append(\"**变量（字段）**\")\n        lines.append(\"\")\n        if not cls.fields:\n            lines.append(\"- 无显式字段（或仅由 Lombok/编译器生成）。\")\n        else:\n            lines.append(\"| 名称 | 类型 | 可见性 | 修饰符 | 说明 |\")\n            lines.append(\"| --- | --- | --- | --- | --- |\")\n            for field in cls.fields:\n                desc = infer_field_description(field.name, field.type_name)\n                lines.append(\n                    f\"| `{field.name}` | `{field.type_name}` | `{field.visibility}` | `{field.modifiers}` | {desc} |\"\n                )\n        lines.append(\"\")\n\n        lines.append(\"**函数（方法）**\")\n        lines.append(\"\")\n        if not cls.methods:\n            lines.append(\"- 无显式方法（或主要由 Lombok 生成 getter/setter/builder）。\")\n        else:\n            lines.append(\"| 方法 | 返回 | 可见性 | 修饰符 | 说明 |\")\n            lines.append(\"| --- | --- | --- | --- | --- |\")\n            for method in cls.methods:\n                signature = f\"{method.name}({method.params})\"\n                desc = infer_method_description(method.name, method.is_constructor, method.return_type)\n                lines.append(\n                    f\"| `{signature}` | `{method.return_type}` | `{method.visibility}` | `{method.modifiers}` | {desc} |\"\n                )\n        lines.append(\"\")\n\n    return \"\\n\".join(lines).rstrip() + \"\\n\"\n\n\ndef main() -> None:\n    if not SOURCE_ROOT.exists():\n        raise SystemExit(f\"Source root not found: {SOURCE_ROOT}\")\n\n    all_classes: List[ClassInfo] = []\n    for file_path in sorted(SOURCE_ROOT.rglob(\"*.java\")):\n        all_classes.extend(parse_java_file(file_path))\n\n    markdown = render_markdown(all_classes)\n    OUTPUT_FILE.write_text(markdown, encoding=\"utf-8\")\n    print(f\"Generated {OUTPUT_FILE.relative_to(REPO_ROOT)} with {len(all_classes)} class entries.\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "docs-site/sidebars.ts",
    "content": "import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';\n\nconst sidebars: SidebarsConfig = {\n  tutorialSidebar: [\n    'intro',\n    {\n      type: 'category',\n      label: '快速开始',\n      items: [\n        'getting-started/installation',\n        'getting-started/modules-and-maven-central',\n        'getting-started/version-compatibility',\n        'getting-started/platforms-and-service-matrix',\n        'getting-started/quickstart-openai-jdk8',\n        'getting-started/quickstart-springboot',\n        'getting-started/spring-boot-autoconfiguration',\n        'getting-started/quickstart-ollama',\n        'getting-started/chat-and-responses-guide',\n        'getting-started/multimodal-and-function-call',\n        'getting-started/troubleshooting',\n      ],\n    },\n    {\n      type: 'category',\n      label: 'Coding Agent',\n      items: [\n        'coding-agent/overview',\n        'coding-agent/quickstart',\n        'coding-agent/release-and-installation',\n        'coding-agent/cli-and-tui',\n        'coding-agent/runtime-architecture',\n        'coding-agent/session-runtime',\n        'coding-agent/compact-and-checkpoint',\n        'coding-agent/prompt-assembly',\n        'coding-agent/configuration',\n        'coding-agent/provider-profiles',\n        'coding-agent/tools-and-approvals',\n        'coding-agent/skills',\n        'coding-agent/mcp-integration',\n        'coding-agent/acp-integration',\n        'coding-agent/tui-customization',\n        'coding-agent/command-reference',\n      ],\n    },\n    {\n      type: 'category',\n      label: 'AI基础能力接入',\n      items: [\n        'ai-basics/overview',\n        'ai-basics/architecture-and-package-map',\n        'ai-basics/service-factory-and-registry',\n        'ai-basics/unified-service-entry',\n        'ai-basics/memory-and-tool-boundaries',\n        'ai-basics/skills',\n        'ai-basics/platform-adaptation',\n        'ai-basics/request-and-response-conventions',\n        'ai-basics/provider-and-model-extension',\n        {\n          type: 'category',\n          label: 'Chat',\n          items: [\n            'ai-basics/chat/non-stream',\n            'ai-basics/chat/stream',\n            'ai-basics/chat/tool-calling',\n            'ai-basics/chat/multimodal',\n          ],\n        },\n        {\n          type: 'category',\n          label: 'Responses',\n          items: [\n            'ai-basics/responses/non-stream',\n            'ai-basics/responses/stream-events',\n            'ai-basics/responses/chat-vs-responses',\n          ],\n        },\n        {\n          type: 'category',\n          label: '其它服务',\n          items: [\n            'ai-basics/services/embedding',\n            'ai-basics/services/rerank',\n            'ai-basics/services/audio',\n            'ai-basics/services/image-generation',\n            'ai-basics/services/realtime',\n          ],\n        },\n        {\n          type: 'category',\n          label: '联网增强',\n          items: [\n            'ai-basics/online-search/overview',\n            'ai-basics/online-search/searxng',\n          ],\n        },\n        {\n          type: 'category',\n          label: 'RAG / 知识库增强',\n          items: [\n            'ai-basics/rag/overview',\n            'ai-basics/rag/architecture-and-indexing',\n            'ai-basics/rag/vector-store-and-storage-backends',\n            'ai-basics/rag/ingestion-pipeline',\n            'ai-basics/rag/chunking-strategies',\n            'ai-basics/rag/hybrid-retrieval-and-rerank-workflow',\n            'ai-basics/rag/citations-trace-and-ui-integration',\n            'ai-basics/rag/pinecone-workflow',\n          ],\n        },\n        {\n          type: 'category',\n          label: '网络栈扩展',\n          items: [\n            'ai-basics/enhancements/spi-http-stack',\n          ],\n        },\n      ],\n    },\n    {\n      type: 'category',\n      label: 'MCP',\n      items: [\n        'mcp/overview',\n        'mcp/use-cases-and-paths',\n        'mcp/configuration-and-gateway-reference',\n        'mcp/transport-types',\n        'mcp/client-integration',\n        'mcp/third-party-mcp-integration',\n        'mcp/gateway-management',\n        'mcp/build-your-mcp-server',\n        'mcp/mcp-agent-end-to-end',\n        'mcp/tool-exposure-semantics',\n        'mcp/mysql-dynamic-datasource',\n      ],\n    },\n    {\n      type: 'category',\n      label: 'Agent 智能体',\n      items: [\n        'agent/overview',\n        'agent/minimal-react-agent',\n        'agent/use-cases-and-paths',\n        'agent/system-prompt-vs-instructions',\n        'agent/model-client-selection',\n        'agent/custom-agent-development',\n        'agent/runtime-implementations',\n        'agent/memory-management',\n        'agent/workflow-stategraph',\n        'agent/codeact-runtime',\n        'agent/codeact-custom-sandbox',\n        'agent/subagent-handoff-policy',\n        'agent/agent-teams',\n        'agent/agent-teams-api-reference',\n        'agent/trace-observability',\n        'agent/weather-workflow-cookbook',\n        'agent/reference-core-classes',\n      ],\n    },\n    {\n      type: 'category',\n      label: 'Agentic 工作流平台',\n      items: [\n        'flowgram/overview',\n        'flowgram/use-cases-and-paths',\n        'flowgram/quickstart',\n        'flowgram/api-and-runtime',\n        'flowgram/frontend-backend-integration',\n        'flowgram/workflow-execution-pipeline',\n        'flowgram/frontend-custom-node-development',\n        'flowgram/builtin-nodes',\n        'flowgram/custom-node-extension',\n        'flowgram/agent-tool-knowledge-integration',\n      ],\n    },\n    {\n      type: 'category',\n      label: '场景实践',\n      items: [\n        'guides/springboot-mysql-chat-memory',\n        'guides/springboot-jdbc-agent-memory',\n        'guides/flowgram-mysql-taskstore',\n        'guides/rag-ingestion-vector-store',\n        'guides/deepseek-stream-search-rag',\n        'guides/rag-legal-assistant',\n        'guides/blog-migration-map',\n      ],\n    },\n    {\n      type: 'category',\n      label: '部署',\n      items: ['deploy/cloudflare-pages'],\n    },\n    'faq',\n    'glossary',\n  ],\n};\n\nexport default sidebars;\n"
  },
  {
    "path": "docs-site/src/components/HomepageFeatures/index.tsx",
    "content": "﻿import type {ReactNode} from 'react';\nimport clsx from 'clsx';\nimport Heading from '@theme/Heading';\nimport styles from './styles.module.css';\n\ntype FeatureItem = {\n  title: string;\n  Svg: React.ComponentType<React.ComponentProps<'svg'>>;\n  description: ReactNode;\n};\n\nconst FeatureList: FeatureItem[] = [\n  {\n    title: 'JDK8 友好落地',\n    Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default,\n    description: (\n      <>\n        为存量 Java 系统准备的工程化路径：从最小示例到生产化治理，不要求先升级技术栈。\n      </>\n    ),\n  },\n  {\n    title: 'MCP 全链路能力',\n    Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default,\n    description: (\n      <>\n        覆盖 MCP Client、MCP Server、Gateway 与动态服务管理，支持“传什么工具就用什么工具”。\n      </>\n    ),\n  },\n  {\n    title: 'Coding Agent 交付入口',\n    Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,\n    description: (\n      <>\n        内置 CLI、TUI、ACP 三种交付入口，同时可继续扩展到 CodeAct、SubAgent、StateGraph 与 Agent Teams。\n      </>\n    ),\n  },\n];\n\nfunction Feature({title, Svg, description}: FeatureItem) {\n  return (\n    <div className={clsx('col col--4')}>\n      <div className=\"text--center\">\n        <Svg className={styles.featureSvg} role=\"img\" />\n      </div>\n      <div className=\"text--center padding-horiz--md\">\n        <Heading as=\"h3\">{title}</Heading>\n        <p>{description}</p>\n      </div>\n    </div>\n  );\n}\n\nexport default function HomepageFeatures(): ReactNode {\n  return (\n    <section className={styles.features}>\n      <div className=\"container\">\n        <div className=\"row\">\n          {FeatureList.map((props, idx) => (\n            <Feature key={idx} {...props} />\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "docs-site/src/components/HomepageFeatures/styles.module.css",
    "content": ".features {\n  display: flex;\n  align-items: center;\n  padding: 2rem 0;\n  width: 100%;\n}\n\n.featureSvg {\n  height: 200px;\n  width: 200px;\n}\n"
  },
  {
    "path": "docs-site/src/css/custom.css",
    "content": ":root {\n  --ifm-color-primary: #0b5ed7;\n  --ifm-color-primary-dark: #0a54c2;\n  --ifm-color-primary-darker: #094fb7;\n  --ifm-color-primary-darkest: #084194;\n  --ifm-color-primary-light: #1d6ee4;\n  --ifm-color-primary-lighter: #2c78e8;\n  --ifm-color-primary-lightest: #4b8cef;\n  --ifm-code-font-size: 95%;\n}\n\n.navbar__title {\n  font-weight: 700;\n  letter-spacing: 0.2px;\n}\n\n.footer {\n  --ifm-footer-background-color: #0f172a;\n}\n"
  },
  {
    "path": "docs-site/src/pages/index.module.css",
    "content": ".heroBanner {\n  padding: 5rem 0;\n  text-align: center;\n  position: relative;\n  overflow: hidden;\n  background: linear-gradient(135deg, #0f172a 0%, #1e293b 40%, #0b5ed7 100%);\n}\n\n@media screen and (max-width: 996px) {\n  .heroBanner {\n    padding: 3rem 1.2rem;\n  }\n}\n\n.buttons {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-wrap: wrap;\n  margin-top: 1.2rem;\n}\n"
  },
  {
    "path": "docs-site/src/pages/index.tsx",
    "content": "﻿import type {ReactNode} from 'react';\nimport clsx from 'clsx';\nimport Link from '@docusaurus/Link';\nimport useDocusaurusContext from '@docusaurus/useDocusaurusContext';\nimport Layout from '@theme/Layout';\nimport HomepageFeatures from '@site/src/components/HomepageFeatures';\nimport Heading from '@theme/Heading';\n\nimport styles from './index.module.css';\n\nfunction HomepageHeader() {\n  const {siteConfig} = useDocusaurusContext();\n  return (\n    <header className={clsx('hero hero--primary', styles.heroBanner)}>\n      <div className=\"container\">\n        <Heading as=\"h1\" className=\"hero__title\">\n          {siteConfig.title}\n        </Heading>\n        <p className=\"hero__subtitle\">{siteConfig.tagline}</p>\n        <div className={styles.buttons}>\n          <Link className=\"button button--secondary button--lg\" to=\"/docs/intro\">\n            开始阅读文档\n          </Link>\n          <Link className=\"button button--info button--lg margin-left--md\" to=\"/docs/coding-agent/overview\">\n            查看 Coding Agent\n          </Link>\n        </div>\n      </div>\n    </header>\n  );\n}\n\nexport default function Home(): ReactNode {\n  const {siteConfig} = useDocusaurusContext();\n  return (\n    <Layout\n      title={`${siteConfig.title}`}\n      description=\"AI4J 官方文档：JDK8 友好的 Java 大模型 SDK、Coding Agent、MCP 与 Agent 架构。\">\n      <HomepageHeader />\n      <main>\n        <HomepageFeatures />\n      </main>\n    </Layout>\n  );\n}\n\n"
  },
  {
    "path": "docs-site/src/theme/NotFound/Content/index.tsx",
    "content": "﻿import React from 'react';\nimport Link from '@docusaurus/Link';\nimport Heading from '@theme/Heading';\n\nexport default function NotFoundContent(): React.ReactElement {\n  return (\n    <main className=\"container margin-vert--xl\">\n      <div className=\"row\">\n        <div className=\"col col--8 col--offset-2 text--center\">\n          <Heading as=\"h1\">页面未找到</Heading>\n          <p>你访问的地址不存在，可能是链接已变更或部署路径配置不一致。</p>\n          <p>\n            请先返回 <Link to=\"/\">首页</Link>，或进入 <Link to=\"/docs/intro\">文档首页</Link> 继续浏览。\n          </p>\n        </div>\n      </div>\n    </main>\n  );\n}\n"
  },
  {
    "path": "docs-site/static/.nojekyll",
    "content": ""
  },
  {
    "path": "docs-site/static/install.ps1",
    "content": "$ErrorActionPreference = \"Stop\"\n\nfunction Write-Info {\n    param([string]$Message)\n    Write-Host $Message\n}\n\nfunction Fail {\n    param([string]$Message)\n    throw \"ai4j installer: $Message\"\n}\n\nfunction Test-SkipPathUpdate {\n    $value = $env:AI4J_SKIP_PATH_UPDATE\n    if (-not $value) {\n        return $false\n    }\n\n    return @(\"1\", \"true\", \"yes\") -contains $value.ToLowerInvariant()\n}\n\nfunction Resolve-Version {\n    if ($env:AI4J_VERSION) {\n        return $env:AI4J_VERSION\n    }\n\n    $repo = if ($env:AI4J_MAVEN_REPO) { $env:AI4J_MAVEN_REPO.TrimEnd('/') } else { \"https://repo.maven.apache.org/maven2\" }\n    $metadataUrl = \"$repo/io/github/lnyo-cly/ai4j-cli/maven-metadata.xml\"\n    [xml]$xml = (Invoke-WebRequest -UseBasicParsing -Uri $metadataUrl).Content\n    $version = $xml.metadata.versioning.release\n    if (-not $version) {\n        $version = $xml.metadata.versioning.latest\n    }\n    if (-not $version) {\n        Fail \"unable to resolve latest ai4j-cli version from Maven metadata\"\n    }\n    return $version\n}\n\nfunction Ensure-Java {\n    $java = Get-Command java -ErrorAction SilentlyContinue\n    if (-not $java) {\n        Fail \"Java 8+ is required. Install Java first, then rerun this installer.\"\n    }\n\n    $firstLine = (& java -version 2>&1 | Select-Object -First 1)\n    if ($firstLine -notmatch '\"([^\"]+)\"') {\n        Fail \"unable to detect Java version\"\n    }\n\n    $version = $Matches[1]\n    if ($version.StartsWith(\"1.\")) {\n        $major = [int]($version.Split(\".\")[1])\n    } else {\n        $major = [int]($version.Split(\".\")[0])\n    }\n\n    if ($major -lt 8) {\n        Fail \"Java 8+ is required. Current Java major version: $major\"\n    }\n}\n\nfunction Ensure-Path {\n    param([string]$BinDir)\n\n    if (Test-SkipPathUpdate) {\n        Write-Info \"Skipping PATH update because AI4J_SKIP_PATH_UPDATE is set.\"\n        return\n    }\n\n    $currentUserPath = [Environment]::GetEnvironmentVariable(\"Path\", \"User\")\n    $segments = @()\n    if ($currentUserPath) {\n        $segments = $currentUserPath.Split(\";\") | Where-Object { $_ -and $_.Trim() }\n    }\n\n    $normalized = $segments | ForEach-Object { $_.TrimEnd('\\') }\n    if ($normalized -contains $BinDir.TrimEnd('\\')) {\n        if (-not (($env:Path.Split(\";\") | ForEach-Object { $_.TrimEnd('\\') }) -contains $BinDir.TrimEnd('\\'))) {\n            $env:Path = \"$BinDir;$env:Path\"\n        }\n        Write-Info \"ai4j is already available on PATH for the current user.\"\n        return\n    }\n\n    $newPath = if ($currentUserPath) { \"$currentUserPath;$BinDir\" } else { $BinDir }\n    [Environment]::SetEnvironmentVariable(\"Path\", $newPath, \"User\")\n    $env:Path = \"$BinDir;$env:Path\"\n    Write-Info \"Added $BinDir to the user PATH.\"\n    Write-Info \"Open a new terminal if 'ai4j' is not found immediately.\"\n}\n\nfunction Main {\n    Ensure-Java\n\n    $repo = if ($env:AI4J_MAVEN_REPO) { $env:AI4J_MAVEN_REPO.TrimEnd('/') } else { \"https://repo.maven.apache.org/maven2\" }\n    $version = Resolve-Version\n    $ai4jHome = if ($env:AI4J_HOME) { $env:AI4J_HOME } else { Join-Path $HOME \".ai4j\" }\n    $binDir = Join-Path $ai4jHome \"bin\"\n    $libDir = Join-Path $ai4jHome \"lib\"\n    $jarUrl = \"$repo/io/github/lnyo-cly/ai4j-cli/$version/ai4j-cli-$version-jar-with-dependencies.jar\"\n    $jarPath = Join-Path $libDir \"ai4j-cli.jar\"\n    $tmpJar = Join-Path $libDir \"ai4j-cli.jar.tmp\"\n    $versionFile = Join-Path $ai4jHome \"version.txt\"\n    $cmdPath = Join-Path $binDir \"ai4j.cmd\"\n\n    Write-Info \"Installing ai4j-cli $version\"\n    New-Item -ItemType Directory -Force -Path $binDir | Out-Null\n    New-Item -ItemType Directory -Force -Path $libDir | Out-Null\n    Invoke-WebRequest -UseBasicParsing -Uri $jarUrl -OutFile $tmpJar\n    Move-Item -Force $tmpJar $jarPath\n    Set-Content -Path $versionFile -Value $version -Encoding Ascii\n\n    $cmdContent = @\"\n@echo off\nsetlocal\nset \"INSTALL_HOME=$ai4jHome\"\nif not defined AI4J_HOME set \"AI4J_HOME=%INSTALL_HOME%\"\nset \"JAVA_BIN=java\"\nif defined AI4J_JAVA set \"JAVA_BIN=%AI4J_JAVA%\"\nif not exist \"%AI4J_HOME%\\lib\\ai4j-cli.jar\" (\n  echo ai4j launcher: missing \"%AI4J_HOME%\\lib\\ai4j-cli.jar\" 1>&2\n  exit /b 1\n)\nif defined AI4J_JAVA_OPTS (\n  %JAVA_BIN% %AI4J_JAVA_OPTS% -jar \"%AI4J_HOME%\\lib\\ai4j-cli.jar\" %*\n  set \"EXIT_CODE=%ERRORLEVEL%\"\n) else (\n  %JAVA_BIN% -jar \"%AI4J_HOME%\\lib\\ai4j-cli.jar\" %*\n  set \"EXIT_CODE=%ERRORLEVEL%\"\n)\nendlocal & exit /b %EXIT_CODE%\n\"@\n    Set-Content -Path $cmdPath -Value $cmdContent -Encoding Ascii\n\n    Ensure-Path -BinDir $binDir\n\n    Write-Info \"\"\n    Write-Info \"Installed ai4j-cli $version to $ai4jHome\"\n    Write-Info \"Then run: ai4j --help\"\n}\n\nMain\n"
  },
  {
    "path": "docs-site/static/install.sh",
    "content": "#!/usr/bin/env sh\nset -eu\n\nAI4J_HOME=\"${AI4J_HOME:-$HOME/.ai4j}\"\nAI4J_BIN_DIR=\"$AI4J_HOME/bin\"\nAI4J_LIB_DIR=\"$AI4J_HOME/lib\"\nAI4J_VERSION_FILE=\"$AI4J_HOME/version.txt\"\nMAVEN_REPO=\"${AI4J_MAVEN_REPO:-https://repo.maven.apache.org/maven2}\"\nMETADATA_URL=\"$MAVEN_REPO/io/github/lnyo-cly/ai4j-cli/maven-metadata.xml\"\n\nsay() {\n  printf '%s\\n' \"$*\"\n}\n\nfail() {\n  printf 'ai4j installer: %s\\n' \"$*\" >&2\n  exit 1\n}\n\nhave_cmd() {\n  command -v \"$1\" >/dev/null 2>&1\n}\n\nskip_path_update() {\n  case \"${AI4J_SKIP_PATH_UPDATE:-}\" in\n    1|true|TRUE|yes|YES)\n      return 0\n      ;;\n    *)\n      return 1\n      ;;\n  esac\n}\n\ndownload_to() {\n  url=\"$1\"\n  output=\"$2\"\n  if have_cmd curl; then\n    curl -fsSL \"$url\" -o \"$output\"\n    return\n  fi\n  if have_cmd wget; then\n    wget -qO \"$output\" \"$url\"\n    return\n  fi\n  fail \"curl or wget is required\"\n}\n\ndownload_text() {\n  url=\"$1\"\n  if have_cmd curl; then\n    curl -fsSL \"$url\"\n    return\n  fi\n  if have_cmd wget; then\n    wget -qO- \"$url\"\n    return\n  fi\n  fail \"curl or wget is required\"\n}\n\nresolve_version() {\n  if [ -n \"${AI4J_VERSION:-}\" ]; then\n    printf '%s' \"$AI4J_VERSION\"\n    return\n  fi\n\n  metadata=\"$(download_text \"$METADATA_URL\" | tr -d '\\r\\n')\"\n  version=\"$(printf '%s' \"$metadata\" | sed -n 's:.*<release>\\([^<]*\\)</release>.*:\\1:p')\"\n  if [ -z \"$version\" ]; then\n    version=\"$(printf '%s' \"$metadata\" | sed -n 's:.*<latest>\\([^<]*\\)</latest>.*:\\1:p')\"\n  fi\n  if [ -z \"$version\" ]; then\n    fail \"unable to resolve latest ai4j-cli version from Maven metadata\"\n  fi\n  printf '%s' \"$version\"\n}\n\njava_major_version() {\n  version=\"$(\n    java -version 2>&1 \\\n      | awk -F '\"' '/version/ {print $2; exit}'\n  )\"\n  if [ -z \"$version\" ]; then\n    fail \"unable to detect Java version\"\n  fi\n  case \"$version\" in\n    1.*)\n      printf '%s' \"$version\" | cut -d. -f2\n      ;;\n    *)\n      printf '%s' \"$version\" | cut -d. -f1\n      ;;\n  esac\n}\n\nensure_java() {\n  if ! have_cmd java; then\n    fail \"Java 8+ is required. Install Java first, then rerun this installer.\"\n  fi\n  major=\"$(java_major_version)\"\n  if [ \"$major\" -lt 8 ]; then\n    fail \"Java 8+ is required. Current Java major version: $major\"\n  fi\n}\n\nwrite_launcher() {\n  launcher=\"$AI4J_BIN_DIR/ai4j\"\n  install_home_escaped=\"$(printf '%s' \"$AI4J_HOME\" | sed \"s/'/'\\\\\\\\''/g\")\"\n  {\n    printf '%s\\n' '#!/usr/bin/env sh'\n    printf '%s\\n' 'set -eu'\n    printf '\\n'\n    printf \"INSTALL_HOME='%s'\\n\" \"$install_home_escaped\"\n    cat <<'EOF'\nAI4J_HOME=\"${AI4J_HOME:-$INSTALL_HOME}\"\nJAVA_BIN=\"${AI4J_JAVA:-java}\"\nJAR_PATH=\"$AI4J_HOME/lib/ai4j-cli.jar\"\n\nif [ ! -f \"$JAR_PATH\" ]; then\n  printf 'ai4j launcher: missing %s\\n' \"$JAR_PATH\" >&2\n  exit 1\nfi\n\nif [ -n \"${AI4J_JAVA_OPTS:-}\" ]; then\n  # shellcheck disable=SC2086\n  exec \"$JAVA_BIN\" $AI4J_JAVA_OPTS -jar \"$JAR_PATH\" \"$@\"\nfi\n\nexec \"$JAVA_BIN\" -jar \"$JAR_PATH\" \"$@\"\nEOF\n  } > \"$launcher\"\n  chmod +x \"$launcher\"\n}\n\npath_contains() {\n  case \":$PATH:\" in\n    *\":$1:\"*) return 0 ;;\n    *) return 1 ;;\n  esac\n}\n\nensure_path() {\n  if skip_path_update; then\n    say \"Skipping PATH update because AI4J_SKIP_PATH_UPDATE is set.\"\n    return\n  fi\n\n  if path_contains \"$AI4J_BIN_DIR\"; then\n    say \"ai4j is already available on PATH in this shell.\"\n    return\n  fi\n\n  shell_name=\"${SHELL:-}\"\n  case \"$shell_name\" in\n    */zsh) rc_file=\"$HOME/.zshrc\" ;;\n    */bash) rc_file=\"$HOME/.bashrc\" ;;\n    *) rc_file=\"$HOME/.profile\" ;;\n  esac\n\n  export_line=\"export PATH=\\\"$AI4J_BIN_DIR:\\$PATH\\\"\"\n  if [ -f \"$rc_file\" ] && grep -F \"$export_line\" \"$rc_file\" >/dev/null 2>&1; then\n    say \"PATH entry already present in $rc_file\"\n    return\n  fi\n\n  {\n    printf '\\n# ai4j installer\\n'\n    printf '%s\\n' \"$export_line\"\n  } >> \"$rc_file\"\n  say \"Added $AI4J_BIN_DIR to PATH in $rc_file\"\n  say \"Run: export PATH=\\\"$AI4J_BIN_DIR:\\$PATH\\\"\"\n}\n\nmain() {\n  ensure_java\n\n  version=\"$(resolve_version)\"\n  jar_url=\"$MAVEN_REPO/io/github/lnyo-cly/ai4j-cli/$version/ai4j-cli-$version-jar-with-dependencies.jar\"\n  tmp_jar=\"$AI4J_LIB_DIR/ai4j-cli.jar.tmp\"\n  jar_path=\"$AI4J_LIB_DIR/ai4j-cli.jar\"\n\n  say \"Installing ai4j-cli $version\"\n  mkdir -p \"$AI4J_BIN_DIR\" \"$AI4J_LIB_DIR\"\n  download_to \"$jar_url\" \"$tmp_jar\"\n  mv \"$tmp_jar\" \"$jar_path\"\n  printf '%s\\n' \"$version\" > \"$AI4J_VERSION_FILE\"\n  write_launcher\n  ensure_path\n\n  say \"\"\n  say \"Installed ai4j-cli $version to $AI4J_HOME\"\n  say \"Restart your shell if 'ai4j' is not found immediately.\"\n  say \"Then run: ai4j --help\"\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "docs-site/tsconfig.json",
    "content": "{\n  // This file is not used in compilation. It is here just for a nice editor experience.\n  \"extends\": \"@docusaurus/tsconfig\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\"\n  },\n  \"exclude\": [\".docusaurus\", \"build\"]\n}\n"
  },
  {
    "path": "pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n    <modelVersion>4.0.0</modelVersion>\n\n    <groupId>io.github.lnyo-cly</groupId>\n    <artifactId>ai4j-sdk</artifactId>\n\n    <version>2.3.0</version>\n\n    <packaging>pom</packaging>\n\n    <name>ai4j-sdk</name>\n    <description>ai4j 多模块 SDK 的父 POM与发布入口。 Parent POM and publication entry for the ai4j multi-module SDK.</description>\n\n    <licenses>\n        <license>\n            <name>The Apache License, Version 2.0</name>\n            <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>\n        </license>\n    </licenses>\n\n    <issueManagement>\n        <system>GitHub</system>\n        <url>https://github.com/LnYo-Cly/ai4j/issues</url>\n    </issueManagement>\n\n    <url>https://github.com/LnYo-Cly/ai4j</url>\n\n    <developers>\n        <developer>\n            <id>LnYo-Cly</id>\n            <name>LnYo-Cly</name>\n            <email>lnyocly@gmail.com</email>\n            <url>https://github.com/LnYo-Cly/ai4j</url>\n            <timezone>+8</timezone>\n        </developer>\n    </developers>\n\n    <scm>\n        <url>https://github.com/LnYo-Cly/ai4j</url>\n        <connection>scm:git:https://github.com/LnYo-Cly/ai4j.git</connection>\n        <developerConnection>scm:git:https://github.com/LnYo-Cly/ai4j.git</developerConnection>\n    </scm>\n\n    <properties>\n        <java.version>1.8</java.version>\n        <maven.compiler.source>${java.version}</maven.compiler.source>\n        <maven.compiler.target>${java.version}</maven.compiler.target>\n        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>\n        <flatten-maven-plugin.version>1.7.3</flatten-maven-plugin.version>\n        <root.publish.skip>true</root.publish.skip>\n    </properties>\n\n    <modules>\n        <module>ai4j</module>\n        <module>ai4j-agent</module>\n        <module>ai4j-coding</module>\n        <module>ai4j-cli</module>\n        <module>ai4j-spring-boot-starter</module>\n        <module>ai4j-flowgram-spring-boot-starter</module>\n        <module>ai4j-flowgram-demo</module>\n        <module>ai4j-bom</module>\n    </modules>\n\n    <build>\n        <defaultGoal>validate</defaultGoal>\n        <plugins>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-compiler-plugin</artifactId>\n                <version>3.11.0</version>\n                <configuration>\n                    <source>${maven.compiler.source}</source>\n                    <target>${maven.compiler.target}</target>\n                    <encoding>${project.build.sourceEncoding}</encoding>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-deploy-plugin</artifactId>\n                <version>3.1.1</version>\n                <inherited>false</inherited>\n                <configuration>\n                    <skip>${root.publish.skip}</skip>\n                </configuration>\n            </plugin>\n            <plugin>\n                <groupId>org.apache.maven.plugins</groupId>\n                <artifactId>maven-install-plugin</artifactId>\n                <version>3.1.1</version>\n                <inherited>false</inherited>\n                <configuration>\n                    <skip>${root.publish.skip}</skip>\n                </configuration>\n            </plugin>\n        </plugins>\n    </build>\n\n    <profiles>\n        <profile>\n            <id>release</id>\n            <properties>\n                <root.publish.skip>false</root.publish.skip>\n            </properties>\n            <build>\n                <plugins>\n                    <plugin>\n                        <groupId>org.apache.maven.plugins</groupId>\n                        <artifactId>maven-gpg-plugin</artifactId>\n                        <version>1.6</version>\n                        <configuration>\n                            <executable>D:\\Develop\\DevelopEnv\\GnuPG\\bin\\gpg.exe</executable>\n                            <keyname>cly</keyname>\n                        </configuration>\n                        <executions>\n                            <execution>\n                                <id>sign-artifacts</id>\n                                <phase>verify</phase>\n                                <goals>\n                                    <goal>sign</goal>\n                                </goals>\n                            </execution>\n                        </executions>\n                    </plugin>\n                    <plugin>\n                        <groupId>org.sonatype.central</groupId>\n                        <artifactId>central-publishing-maven-plugin</artifactId>\n                        <version>0.4.0</version>\n                        <extensions>true</extensions>\n                        <configuration>\n                            <publishingServerId>LnYo-Cly</publishingServerId>\n                            <tokenAuth>true</tokenAuth>\n                            <excludeArtifacts>\n                                <excludeArtifact>ai4j-sdk</excludeArtifact>\n                                <excludeArtifact>ai4j-flowgram-demo</excludeArtifact>\n                            </excludeArtifacts>\n                        </configuration>\n                    </plugin>\n                </plugins>\n            </build>\n        </profile>\n    </profiles>\n</project>\n\n\n"
  }
]