├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.yml ├── PULL_REQUEST_TEMPLATE.md ├── conventional-commit-lint.yaml ├── release-please.yml ├── release-trigger.yml └── workflows │ └── npm-publish.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── package-lock.json ├── package.json ├── scripts └── generateTypes.js ├── src ├── a2a_response.ts ├── client │ └── client.ts ├── index.ts ├── samples │ ├── agents │ │ └── movie-agent │ │ │ ├── README.md │ │ │ ├── genkit.ts │ │ │ ├── index.ts │ │ │ ├── movie_agent.prompt │ │ │ ├── tmdb.ts │ │ │ └── tools.ts │ └── cli.ts ├── server │ ├── a2a_express_app.ts │ ├── agent_execution │ │ ├── agent_executor.ts │ │ └── request_context.ts │ ├── error.ts │ ├── events │ │ ├── execution_event_bus.ts │ │ ├── execution_event_bus_manager.ts │ │ └── execution_event_queue.ts │ ├── request_handler │ │ ├── a2a_request_handler.ts │ │ └── default_request_handler.ts │ ├── result_manager.ts │ ├── store.ts │ ├── transports │ │ └── jsonrpc_transport_handler.ts │ └── utils.ts └── types.ts ├── test └── server │ └── default_request_handler.spec.ts └── tsconfig.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code owners file. 2 | # This file controls who is tagged for review for any given pull request. 3 | # 4 | # For syntax help see: 5 | # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax 6 | 7 | * @google-a2a/googlers 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | type: "Bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for stopping by to let us know something could be better! 10 | Private Feedback? Please use this [Google form](https://goo.gle/a2a-feedback) 11 | - type: textarea 12 | id: what-happened 13 | attributes: 14 | label: What happened? 15 | description: Also tell us what you expected to happen and how to reproduce the issue. 16 | placeholder: Tell us what you see! 17 | value: "A bug happened!" 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: logs 22 | attributes: 23 | label: Relevant log output 24 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 25 | render: shell 26 | - type: checkboxes 27 | id: terms 28 | attributes: 29 | label: Code of Conduct 30 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/google-a2a/A2A?tab=coc-ov-file#readme) 31 | options: 32 | - label: I agree to follow this project's Code of Conduct 33 | required: true 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature Request 2 | description: Suggest an idea for this repository 3 | title: "[Feat]: " 4 | type: "Feature" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for stopping by to let us know something could be better! 10 | Private Feedback? Please use this [Google form](https://goo.gle/a2a-feedback) 11 | - type: textarea 12 | id: problem 13 | attributes: 14 | label: Is your feature request related to a problem? Please describe. 15 | description: A clear and concise description of what the problem is. 16 | placeholder: Ex. I'm always frustrated when [...] 17 | - type: textarea 18 | id: describe 19 | attributes: 20 | label: Describe the solution you'd like 21 | description: A clear and concise description of what you want to happen. 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: alternatives 26 | attributes: 27 | label: Describe alternatives you've considered 28 | description: A clear and concise description of any alternative solutions or features you've considered. 29 | - type: textarea 30 | id: context 31 | attributes: 32 | label: Additional context 33 | description: Add any other context or screenshots about the feature request here. 34 | - type: checkboxes 35 | id: terms 36 | attributes: 37 | label: Code of Conduct 38 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/google-a2a/A2A?tab=coc-ov-file#readme) 39 | options: 40 | - label: I agree to follow this project's Code of Conduct 41 | required: true 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Thank you for opening a Pull Request! 4 | Before submitting your PR, there are a few things you can do to make sure it goes smoothly: 5 | 6 | - [ ] Follow the [`CONTRIBUTING` Guide](https://github.com/google-a2a/a2a-js/blob/main/CONTRIBUTING.md). 7 | - [ ] Make your Pull Request title in the specification. 8 | - Important Prefixes for [release-please](https://github.com/googleapis/release-please): 9 | - `fix:` which represents bug fixes, and correlates to a [SemVer](https://semver.org/) patch. 10 | - `feat:` represents a new feature, and correlates to a SemVer minor. 11 | - `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking change (indicated by the `!`) and will result in a SemVer major. 12 | - [ ] Ensure the tests and linter pass 13 | - [ ] Appropriate docs were updated (if necessary) 14 | 15 | Fixes # 🦕 16 | -------------------------------------------------------------------------------- /.github/conventional-commit-lint.yaml: -------------------------------------------------------------------------------- 1 | enabled: true 2 | always_check_pr_title: true 3 | -------------------------------------------------------------------------------- /.github/release-please.yml: -------------------------------------------------------------------------------- 1 | releaseType: node 2 | handleGHRelease: true 3 | bumpMinorPreMajor: false 4 | bumpPatchForMinorPreMajor: true 5 | -------------------------------------------------------------------------------- /.github/release-trigger.yml: -------------------------------------------------------------------------------- 1 | enabled: true 2 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | registry-url: 'https://wombat-dressing-room.appspot.com/' 30 | - run: npm ci 31 | - run: npm publish --provenance --access public 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *.pyc 5 | *$py.class 6 | **/dist 7 | /tmp 8 | /out-tsc 9 | /bazel-out 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | Pipfile.lock 98 | Pipfile 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | .venv* 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # macOS 139 | .DS_Store 140 | 141 | # PyCharm 142 | .idea 143 | 144 | # User-specific files 145 | language/examples/prompt-design/train.csv 146 | README-TOC*.md 147 | 148 | # Terraform 149 | terraform.tfstate** 150 | .terraform* 151 | .Terraform* 152 | 153 | tmp* 154 | 155 | # Node 156 | **/node_modules 157 | npm-debug.log 158 | yarn-error.log 159 | 160 | # IDEs and editors 161 | .idea/ 162 | .project 163 | .classpath 164 | .c9/ 165 | *.launch 166 | .settings/ 167 | *.sublime-workspace 168 | 169 | # Miscellaneous 170 | **/.angular/* 171 | /.angular/cache 172 | .sass-cache/ 173 | /connect.lock 174 | /coverage 175 | /libpeerconnection.log 176 | testem.log 177 | /typings 178 | 179 | # System files 180 | .DS_Store 181 | Thumbs.db 182 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.2.2](https://github.com/google-a2a/a2a-js/compare/v0.2.1...v0.2.2) (2025-06-20) 4 | 5 | 6 | ### Features 7 | 8 | * add action to publish to npm ([e4ab96e](https://github.com/google-a2a/a2a-js/commit/e4ab96ed4f875cc3079534637fbf88f9adad7f74)) 9 | * add sample agent ([#19](https://github.com/google-a2a/a2a-js/issues/19)) ([1f21a0a](https://github.com/google-a2a/a2a-js/commit/1f21a0a8662550547c1703d33e71f5cf7bd28d6b)) 10 | * add test coverage ([#20](https://github.com/google-a2a/a2a-js/issues/20)) ([7bde9cd](https://github.com/google-a2a/a2a-js/commit/7bde9cd839c015e270719d312df18ddc0c6f34b0)) 11 | * generate types from spec & use unknown in types ([#17](https://github.com/google-a2a/a2a-js/issues/17)) ([748f928](https://github.com/google-a2a/a2a-js/commit/748f9283a8e93d6104e29309f27d83fb2f9193e0)) 12 | * reject sendMessage for tasks in terminal states ([#29](https://github.com/google-a2a/a2a-js/issues/29)) ([9f86195](https://github.com/google-a2a/a2a-js/commit/9f86195d01fada7f041df0199cf93bcff2da8b80)) 13 | * Supply taskId & contextId in requestContext ([#22](https://github.com/google-a2a/a2a-js/issues/22)) ([79db7f4](https://github.com/google-a2a/a2a-js/commit/79db7f48cac482b176f2297ca374e1e937eda1d0)) 14 | * support non-blocking message send ([#28](https://github.com/google-a2a/a2a-js/issues/28)) ([6984dbb](https://github.com/google-a2a/a2a-js/commit/6984dbb3655a71bb540e6c14cb2f4792a4556fad)) 15 | * use string union instead of enums ([#24](https://github.com/google-a2a/a2a-js/issues/24)) ([bcc1f7e](https://github.com/google-a2a/a2a-js/commit/bcc1f7e0e14065163dacf3f60e74c7bb501f243e)) 16 | 17 | ## 0.2.1 (2025-06-06) 18 | 19 | 20 | ### Features 21 | 22 | * Add cancelTask to executor & finished to eventBus ([831c393](https://github.com/google-a2a/a2a-js/commit/831c3937ba59e0b4c2fdfd9577f506921929034a)) 23 | * Add sdk files for client & server ([00fe8cd](https://github.com/google-a2a/a2a-js/commit/00fe8cd33db4d5464a320dc2d16fd483b5a2fbbf)) 24 | * add sdk/tests for client & server ([a921c98](https://github.com/google-a2a/a2a-js/commit/a921c98946ba4e0636d9d6d320918e1fcb3ba5aa)) 25 | * add tests for all APIs ([e6281ca](https://github.com/google-a2a/a2a-js/commit/e6281caa131ebcc247cf750f597ead2ea28f2c3d)) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * library should released as 0.2.1 ([#8](https://github.com/google-a2a/a2a-js/issues/8)) ([0335732](https://github.com/google-a2a/a2a-js/commit/033573295e0ab8115d2fcd0c64a0bd5df1537b67)) 31 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of 9 | experience, education, socio-economic status, nationality, personal appearance, 10 | race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | This Code of Conduct also applies outside the project spaces when the Project 56 | Steward has a reasonable belief that an individual's behavior may have a 57 | negative impact on the project or its community. 58 | 59 | ## Conflict Resolution 60 | 61 | We do not believe that all conflict is bad; healthy debate and disagreement 62 | often yield positive results. However, it is never okay to be disrespectful or 63 | to engage in behavior that violates the project’s code of conduct. 64 | 65 | If you see someone violating the code of conduct, you are encouraged to address 66 | the behavior directly with those involved. Many issues can be resolved quickly 67 | and easily, and this gives people more control over the outcome of their 68 | dispute. If you are unable to resolve the matter for any reason, or if the 69 | behavior is threatening or harassing, report it. We are dedicated to providing 70 | an environment where participants feel welcome and safe. 71 | 72 | Reports should be directed to *[PROJECT STEWARD NAME(s) AND EMAIL(s)]*, the 73 | Project Steward(s) for *[PROJECT NAME]*. It is the Project Steward’s duty to 74 | receive and address reported violations of the code of conduct. They will then 75 | work with a committee consisting of representatives from the Open Source 76 | Programs Office and the Google Open Source Strategy team. If for any reason you 77 | are uncomfortable reaching out to the Project Steward, please email 78 | opensource@google.com. 79 | 80 | We will investigate every complaint, but you may not receive a direct response. 81 | We will use our discretion in determining when and how to follow up on reported 82 | incidents, which may range from not taking action to permanent expulsion from 83 | the project and project-sponsored spaces. We will notify the accused of the 84 | report and provide them an opportunity to discuss it before any action is taken. 85 | The identity of the reporter will be omitted from the details of the report 86 | supplied to the accused. In potentially harmful situations, such as ongoing 87 | harassment or threats to anyone's safety, we may take action without notice. 88 | 89 | ## Attribution 90 | 91 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, 92 | available at 93 | https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 94 | 95 | Note: A version of this file is also available in the 96 | [New Project repo](https://github.com/google/new-project/blob/master/docs/code-of-conduct.md). 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | We'd love to accept your patches and contributions to this project. 4 | 5 | ## Before you begin 6 | 7 | ### Sign our Contributor License Agreement 8 | 9 | Contributions to this project must be accompanied by a 10 | [Contributor License Agreement](https://cla.developers.google.com/about) (CLA). 11 | You (or your employer) retain the copyright to your contribution; this simply 12 | gives us permission to use and redistribute your contributions as part of the 13 | project. 14 | 15 | If you or your current employer have already signed the Google CLA (even if it 16 | was for a different project), you probably don't need to do it again. 17 | 18 | Visit to see your current agreements or to 19 | sign a new one. 20 | 21 | ### Review our community guidelines 22 | 23 | This project follows 24 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 25 | 26 | ## Contribution process 27 | 28 | ### Code reviews 29 | 30 | All submissions, including submissions by project members, require review. We 31 | use GitHub pull requests for this purpose. Consult 32 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 33 | information on using pull requests. 34 | 35 | ### Contributor Guide 36 | 37 | You may follow these steps to contribute: 38 | 39 | 1. **Fork the official repository.** This will create a copy of the official repository in your own account. 40 | 2. **Sync the branches.** This will ensure that your copy of the repository is up-to-date with the latest changes from the official repository. 41 | 3. **Work on your forked repository's feature branch.** This is where you will make your changes to the code. 42 | 4. **Commit your updates on your forked repository's feature branch.** This will save your changes to your copy of the repository. 43 | 5. **Submit a pull request to the official repository's main branch.** This will request that your changes be merged into the official repository. 44 | 6. **Resolve any linting errors.** This will ensure that your changes are formatted correctly. 45 | 46 | Here are some additional things to keep in mind during the process: 47 | 48 | - **Test your changes.** Before you submit a pull request, make sure that your changes work as expected. 49 | - **Be patient.** It may take some time for your pull request to be reviewed and merged. 50 | 51 | --- 52 | 53 | ## For Google Employees 54 | 55 | Complete the following steps to register your GitHub account and be added as a contributor to this repository. 56 | 57 | 1. Register your GitHub account at [go/GitHub](http://go/github) 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A2A JavaScript SDK 2 | 3 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) 4 | 5 | 6 | 7 | 8 |

9 | A2A Logo 10 |

11 |

A JavaScript library that helps run agentic applications as A2AServers following the Agent2Agent (A2A) Protocol.

12 | 13 | 14 | 15 | 16 | ## Installation 17 | 18 | You can install the A2A SDK using either `npm`. 19 | 20 | ```bash 21 | npm install @a2a-js/sdk 22 | ``` 23 | 24 | You can also find JavaScript samples [here](https://github.com/google-a2a/a2a-samples/tree/main/samples/js). 25 | 26 | ## A2A Server 27 | 28 | This directory contains a TypeScript server implementation for the Agent-to-Agent (A2A) communication protocol, built using Express.js. 29 | 30 | ### 1. Define Agent Card 31 | ```typescript 32 | import { AgentCard } from "@a2a-js/sdk"; 33 | 34 | const movieAgentCard: AgentCard = { 35 | name: 'Movie Agent', 36 | description: 'An agent that can answer questions about movies and actors using TMDB.', 37 | // Adjust the base URL and port as needed. 38 | url: 'http://localhost:41241/', 39 | provider: { 40 | organization: 'A2A Agents', 41 | url: 'https://example.com/a2a-agents' // Added provider URL 42 | }, 43 | version: '0.0.2', // Incremented version 44 | capabilities: { 45 | streaming: true, // Supports streaming 46 | pushNotifications: false, // Assuming not implemented for this agent yet 47 | stateTransitionHistory: true, // Agent uses history 48 | }, 49 | securitySchemes: undefined, // Or define actual security schemes if any 50 | security: undefined, 51 | defaultInputModes: ['text/plain'], 52 | defaultOutputModes: ['text/plain'], 53 | skills: [ 54 | { 55 | id: 'general_movie_chat', 56 | name: 'General Movie Chat', 57 | description: 'Answer general questions or chat about movies, actors, directors.', 58 | tags: ['movies', 'actors', 'directors'], 59 | examples: [ 60 | 'Tell me about the plot of Inception.', 61 | 'Recommend a good sci-fi movie.', 62 | 'Who directed The Matrix?', 63 | 'What other movies has Scarlett Johansson been in?', 64 | 'Find action movies starring Keanu Reeves', 65 | 'Which came out first, Jurassic Park or Terminator 2?', 66 | ], 67 | inputModes: ['text/plain'], // Explicitly defining for skill 68 | outputModes: ['text/plain'] // Explicitly defining for skill 69 | }, 70 | ], 71 | supportsAuthenticatedExtendedCard: false, 72 | }; 73 | ``` 74 | 75 | ### 2. Define Agent Executor 76 | ```typescript 77 | import { 78 | InMemoryTaskStore, 79 | TaskStore, 80 | A2AExpressApp, 81 | AgentExecutor, 82 | RequestContext, 83 | ExecutionEventBus, 84 | DefaultRequestHandler, 85 | } from "@a2a-js/sdk"; 86 | 87 | // 1. Define your agent's logic as a AgentExecutor 88 | class MyAgentExecutor implements AgentExecutor { 89 | private cancelledTasks = new Set(); 90 | 91 | public cancelTask = async ( 92 | taskId: string, 93 | eventBus: ExecutionEventBus, 94 | ): Promise => { 95 | this.cancelledTasks.add(taskId); 96 | // The execute loop is responsible for publishing the final state 97 | }; 98 | 99 | async execute( 100 | requestContext: RequestContext, 101 | eventBus: ExecutionEventBus 102 | ): Promise { 103 | const userMessage = requestContext.userMessage; 104 | const existingTask = requestContext.task; 105 | 106 | // Determine IDs for the task and context, from requestContext. 107 | const taskId = requestContext.taskId; 108 | const contextId = requestContext.contextId; 109 | 110 | console.log( 111 | `[MyAgentExecutor] Processing message ${userMessage.messageId} for task ${taskId} (context: ${contextId})` 112 | ); 113 | 114 | // 1. Publish initial Task event if it's a new task 115 | if (!existingTask) { 116 | const initialTask: Task = { 117 | kind: 'task', 118 | id: taskId, 119 | contextId: contextId, 120 | status: { 121 | state: 'submitted', 122 | timestamp: new Date().toISOString(), 123 | }, 124 | history: [userMessage], 125 | metadata: userMessage.metadata, 126 | artifacts: [], // Initialize artifacts array 127 | }; 128 | eventBus.publish(initialTask); 129 | } 130 | 131 | // 2. Publish "working" status update 132 | const workingStatusUpdate: TaskStatusUpdateEvent = { 133 | kind: 'status-update', 134 | taskId: taskId, 135 | contextId: contextId, 136 | status: { 137 | state: 'working', 138 | message: { 139 | kind: 'message', 140 | role: 'agent', 141 | messageId: uuidv4(), 142 | parts: [{ kind: 'text', text: 'Generating code...' }], 143 | taskId: taskId, 144 | contextId: contextId, 145 | }, 146 | timestamp: new Date().toISOString(), 147 | }, 148 | final: false, 149 | }; 150 | eventBus.publish(workingStatusUpdate); 151 | 152 | // Simulate work... 153 | await new Promise((resolve) => setTimeout(resolve, 1000)); 154 | 155 | // Check for request cancellation 156 | if (this.cancelledTasks.has(taskId)) { 157 | console.log(`[MyAgentExecutor] Request cancelled for task: ${taskId}`); 158 | const cancelledUpdate: TaskStatusUpdateEvent = { 159 | kind: 'status-update', 160 | taskId: taskId, 161 | contextId: contextId, 162 | status: { 163 | state: 'canceled', 164 | timestamp: new Date().toISOString(), 165 | }, 166 | final: true, 167 | }; 168 | eventBus.publish(cancelledUpdate); 169 | eventBus.finished(); 170 | return; 171 | } 172 | 173 | // 3. Publish artifact update 174 | const artifactUpdate: TaskArtifactUpdateEvent = { 175 | kind: 'artifact-update', 176 | taskId: taskId, 177 | contextId: contextId, 178 | artifact: { 179 | artifactId: "artifact-1", 180 | name: "artifact-1", 181 | parts: [{ text: `Task ${context.task.id} completed.` }], 182 | }, 183 | append: false, // Each emission is a complete file snapshot 184 | lastChunk: true, // True for this file artifact 185 | }; 186 | eventBus.publish(artifactUpdate); 187 | 188 | // 4. Publish final status update 189 | const finalUpdate: TaskStatusUpdateEvent = { 190 | kind: 'status-update', 191 | taskId: taskId, 192 | contextId: contextId, 193 | status: { 194 | state: 'completed', 195 | message: { 196 | kind: 'message', 197 | role: 'agent', 198 | messageId: uuidv4(), 199 | taskId: taskId, 200 | contextId: contextId, 201 | }, 202 | timestamp: new Date().toISOString(), 203 | }, 204 | final: true, 205 | }; 206 | eventBus.publish(finalUpdate); 207 | eventBus.finished(); 208 | } 209 | } 210 | ``` 211 | 212 | ### 3. Start the server 213 | ```typescript 214 | const taskStore: TaskStore = new InMemoryTaskStore(); 215 | const agentExecutor: AgentExecutor = new MyAgentExecutor(); 216 | 217 | const requestHandler = new DefaultRequestHandler( 218 | coderAgentCard, 219 | taskStore, 220 | agentExecutor 221 | ); 222 | 223 | const appBuilder = new A2AExpressApp(requestHandler); 224 | const expressApp = appBuilder.setupRoutes(express(), ''); 225 | 226 | const PORT = process.env.CODER_AGENT_PORT || 41242; // Different port for coder agent 227 | expressApp.listen(PORT, () => { 228 | console.log(`[MyAgent] Server using new framework started on http://localhost:${PORT}`); 229 | console.log(`[MyAgent] Agent Card: http://localhost:${PORT}/.well-known/agent.json`); 230 | console.log('[MyAgent] Press Ctrl+C to stop the server'); 231 | }); 232 | ``` 233 | ### Agent Executor 234 | Developers are expected to implement this interface and provide two methods: `execute` and `cancelTask`. 235 | 236 | #### `execute` 237 | - This method is provided with a `RequestContext` and an `EventBus` to publish execution events. 238 | - Executor can either respond by publishing a Message or Task. 239 | - For a task, check if there's an existing task in `RequestContext`. If not, publish an initial Task event using `taskId` & `contextId` from `RequestContext`. 240 | - Executor can subsequently publish `TaskStatusUpdateEvent` or `TaskArtifactUpdateEvent`. 241 | - Executor should indicate which is the `final` event and also call `finished()` method of event bus. 242 | - Executor should also check if an ongoing task has been cancelled. If yes, cancel the execution and emit an `TaskStatusUpdateEvent` with cancelled state. 243 | 244 | #### `cancelTask` 245 | Executors should implement cancellation mechanism for an ongoing task. 246 | 247 | ## A2A Client 248 | 249 | There's a `A2AClient` class, which provides methods for interacting with an A2A server over HTTP using JSON-RPC. 250 | 251 | ### Key Features: 252 | 253 | - **JSON-RPC Communication:** Handles sending requests and receiving responses (both standard and streaming via Server-Sent Events) according to the JSON-RPC 2.0 specification. 254 | - **A2A Methods:** Implements standard A2A methods like `sendMessage`, `sendMessageStream`, `getTask`, `cancelTask`, `setTaskPushNotificationConfig`, `getTaskPushNotificationConfig`, and `resubscribeTask`. 255 | - **Error Handling:** Provides basic error handling for network issues and JSON-RPC errors. 256 | - **Streaming Support:** Manages Server-Sent Events (SSE) for real-time task updates (`sendMessageStream`, `resubscribeTask`). 257 | - **Extensibility:** Allows providing a custom `fetch` implementation for different environments (e.g., Node.js). 258 | 259 | ### Basic Usage 260 | 261 | ```typescript 262 | import { 263 | A2AClient, 264 | Message, 265 | MessageSendParams, 266 | Task, 267 | TaskQueryParams, 268 | SendMessageResponse, 269 | GetTaskResponse, 270 | SendMessageSuccessResponse, 271 | GetTaskSuccessResponse 272 | } from "@a2a-js/sdk"; 273 | import { v4 as uuidv4 } from "uuid"; 274 | 275 | const client = new A2AClient("http://localhost:41241"); // Replace with your server URL 276 | 277 | async function run() { 278 | const messageId = uuidv4(); 279 | let taskId: string | undefined; 280 | 281 | try { 282 | // 1. Send a message to the agent. 283 | const sendParams: MessageSendParams = { 284 | message: { 285 | messageId: messageId, 286 | role: "user", 287 | parts: [{ kind: "text", text: "Hello, agent!" }], 288 | kind: "message" 289 | }, 290 | configuration: { 291 | blocking: true, 292 | acceptedOutputModes: ['text/plain'] 293 | } 294 | }; 295 | 296 | const sendResponse: SendMessageResponse = await client.sendMessage(sendParams); 297 | 298 | if (sendResponse.error) { 299 | console.error("Error sending message:", sendResponse.error); 300 | return; 301 | } 302 | 303 | // On success, the result can be a Task or a Message. Check which one it is. 304 | const result = (sendResponse as SendMessageSuccessResponse).result; 305 | 306 | if (result.kind === 'task') { 307 | // The agent created a task. 308 | const taskResult = result as Task; 309 | console.log("Send Message Result (Task):", taskResult); 310 | taskId = taskResult.id; // Save the task ID for the next call 311 | } else if (result.kind === 'message') { 312 | // The agent responded with a direct message. 313 | const messageResult = result as Message; 314 | console.log("Send Message Result (Direct Message):", messageResult); 315 | // No task was created, so we can't get task status. 316 | } 317 | 318 | // 2. If a task was created, get its status. 319 | if (taskId) { 320 | const getParams: TaskQueryParams = { id: taskId }; 321 | const getResponse: GetTaskResponse = await client.getTask(getParams); 322 | 323 | if (getResponse.error) { 324 | console.error(`Error getting task ${taskId}:`, getResponse.error); 325 | return; 326 | } 327 | 328 | const getTaskResult = (getResponse as GetTaskSuccessResponse).result; 329 | console.log("Get Task Result:", getTaskResult); 330 | } 331 | 332 | } catch (error) { 333 | console.error("A2A Client Communication Error:", error); 334 | } 335 | } 336 | 337 | run(); 338 | ``` 339 | 340 | ### Streaming Usage 341 | 342 | ```typescript 343 | import { 344 | A2AClient, 345 | TaskStatusUpdateEvent, 346 | TaskArtifactUpdateEvent, 347 | MessageSendParams, 348 | Task, 349 | Message, 350 | } from "@a2a-js/sdk"; 351 | import { v4 as uuidv4 } from "uuid"; 352 | 353 | const client = new A2AClient("http://localhost:41241"); 354 | 355 | async function streamTask() { 356 | const messageId = uuidv4(); 357 | try { 358 | console.log(`\n--- Starting streaming task for message ${messageId} ---`); 359 | 360 | // Construct the `MessageSendParams` object. 361 | const streamParams: MessageSendParams = { 362 | message: { 363 | messageId: messageId, 364 | role: "user", 365 | parts: [{ kind: "text", text: "Stream me some updates!" }], 366 | kind: "message" 367 | }, 368 | }; 369 | 370 | // Use the `sendMessageStream` method. 371 | const stream = client.sendMessageStream(streamParams); 372 | let currentTaskId: string | undefined; 373 | 374 | for await (const event of stream) { 375 | // The first event is often the Task object itself, establishing the ID. 376 | if ((event as Task).kind === 'task') { 377 | currentTaskId = (event as Task).id; 378 | console.log(`[${currentTaskId}] Task created. Status: ${(event as Task).status.state}`); 379 | continue; 380 | } 381 | 382 | // Differentiate subsequent stream events. 383 | if ((event as TaskStatusUpdateEvent).kind === 'status-update') { 384 | const statusEvent = event as TaskStatusUpdateEvent; 385 | console.log( 386 | `[${statusEvent.taskId}] Status Update: ${statusEvent.status.state} - ${ 387 | statusEvent.status.message?.parts[0]?.text ?? "" 388 | }` 389 | ); 390 | if (statusEvent.final) { 391 | console.log(`[${statusEvent.taskId}] Stream marked as final.`); 392 | break; // Exit loop when server signals completion 393 | } 394 | } else if ((event as TaskArtifactUpdateEvent).kind === 'artifact-update') { 395 | const artifactEvent = event as TaskArtifactUpdateEvent; 396 | // Use artifact.name or artifact.artifactId for identification 397 | console.log( 398 | `[${artifactEvent.taskId}] Artifact Update: ${ 399 | artifactEvent.artifact.name ?? artifactEvent.artifact.artifactId 400 | } - Part Count: ${artifactEvent.artifact.parts.length}` 401 | ); 402 | } else { 403 | // This could be a direct Message response if the agent doesn't create a task. 404 | console.log("Received direct message response in stream:", event); 405 | } 406 | } 407 | console.log(`--- Streaming for message ${messageId} finished ---`); 408 | } catch (error) { 409 | console.error(`Error during streaming for message ${messageId}:`, error); 410 | } 411 | } 412 | 413 | streamTask(); 414 | ``` 415 | 416 | ## License 417 | 418 | This project is licensed under the terms of the [Apache 2.0 License](https://raw.githubusercontent.com/google-a2a/a2a-python/refs/heads/main/LICENSE). 419 | 420 | ## Contributing 421 | 422 | See [CONTRIBUTING.md](https://github.com/google-a2a/a2a-js/blob/main/CONTRIBUTING.md) for contribution guidelines. 423 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security issue, please use [g.co/vulnz](https://g.co/vulnz). 4 | 5 | The Google Security Team will respond within 5 working days of your report on g.co/vulnz. 6 | 7 | We use g.co/vulnz for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@a2a-js/sdk", 3 | "version": "0.2.2", 4 | "description": "Server & Client SDK for Agent2Agent protocol", 5 | "repository": "google-a2a/a2a-js.git", 6 | "engines": { 7 | "node": ">=18" 8 | }, 9 | "main": "build/src/index.js", 10 | "types": "build/src/index.d.ts", 11 | "type": "module", 12 | "files": [ 13 | "build/src", 14 | "!build/src/**/*.map", 15 | "README.md" 16 | ], 17 | "devDependencies": { 18 | "@genkit-ai/googleai": "^1.8.0", 19 | "@genkit-ai/vertexai": "^1.8.0", 20 | "@types/chai": "^5.2.2", 21 | "@types/mocha": "^10.0.10", 22 | "@types/node": "^22.13.14", 23 | "@types/sinon": "^17.0.4", 24 | "c8": "^10.1.3", 25 | "chai": "^5.2.0", 26 | "genkit": "^1.8.0", 27 | "gts": "^6.0.2", 28 | "json-schema-to-typescript": "^15.0.4", 29 | "mocha": "^11.6.0", 30 | "sinon": "^20.0.0", 31 | "tsx": "^4.19.3", 32 | "typescript": "^5.8.2" 33 | }, 34 | "scripts": { 35 | "clean": "gts clean", 36 | "build": "tsc -p .", 37 | "pretest": "npm run build", 38 | "test": "mocha build/test/**/*.js", 39 | "coverage": "c8 npm run test", 40 | "generate": "curl https://raw.githubusercontent.com/google-a2a/A2A/refs/heads/main/specification/json/a2a.json > spec.json && node scripts/generateTypes.js && rm spec.json", 41 | "sample:cli": "tsx src/samples/cli.ts", 42 | "sample:movie-agent": "tsx src/samples/agents/movie-agent/index.ts" 43 | }, 44 | "dependencies": { 45 | "@types/cors": "^2.8.17", 46 | "@types/express": "^5.0.1", 47 | "body-parser": "^2.2.0", 48 | "cors": "^2.8.5", 49 | "express": "^4.21.2", 50 | "uuid": "^11.1.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /scripts/generateTypes.js: -------------------------------------------------------------------------------- 1 | import { compile, compileFromFile } from 'json-schema-to-typescript' 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | const typeSchemaContents = fs.readFileSync(path.join(process.cwd(), 'spec.json'), 'utf8'); 6 | const typeSchema = JSON.parse(typeSchemaContents.toString()); 7 | 8 | compile(typeSchema, 'MySchema', { 9 | additionalProperties: false, 10 | enableConstEnums: false, 11 | unreachableDefinitions: true, 12 | unknownAny: true, 13 | }) 14 | .then(ts => fs.writeFileSync('src/types.ts', ts)) 15 | -------------------------------------------------------------------------------- /src/a2a_response.ts: -------------------------------------------------------------------------------- 1 | import { SendMessageResponse, SendStreamingMessageResponse, GetTaskResponse, CancelTaskResponse, SetTaskPushNotificationConfigResponse, GetTaskPushNotificationConfigResponse, JSONRPCErrorResponse } from "./types.js"; 2 | 3 | /** 4 | * Represents any valid JSON-RPC response defined in the A2A protocol. 5 | */ 6 | export type A2AResponse = 7 | | SendMessageResponse 8 | | SendStreamingMessageResponse 9 | | GetTaskResponse 10 | | CancelTaskResponse 11 | | SetTaskPushNotificationConfigResponse 12 | | GetTaskPushNotificationConfigResponse 13 | | JSONRPCErrorResponse; // Catch-all for other error responses 14 | -------------------------------------------------------------------------------- /src/client/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AgentCard, 3 | AgentCapabilities, 4 | JSONRPCRequest, 5 | JSONRPCResponse, 6 | JSONRPCSuccessResponse, 7 | JSONRPCError, 8 | JSONRPCErrorResponse, 9 | Message, 10 | Task, 11 | TaskStatusUpdateEvent, 12 | TaskArtifactUpdateEvent, 13 | MessageSendParams, 14 | SendMessageResponse, 15 | SendStreamingMessageResponse, 16 | SendStreamingMessageSuccessResponse, 17 | TaskQueryParams, 18 | GetTaskResponse, 19 | GetTaskSuccessResponse, 20 | TaskIdParams, 21 | CancelTaskResponse, 22 | CancelTaskSuccessResponse, 23 | TaskPushNotificationConfig, // Renamed from PushNotificationConfigParams for direct schema alignment 24 | SetTaskPushNotificationConfigRequest, 25 | SetTaskPushNotificationConfigResponse, 26 | SetTaskPushNotificationConfigSuccessResponse, 27 | GetTaskPushNotificationConfigRequest, 28 | GetTaskPushNotificationConfigResponse, 29 | GetTaskPushNotificationConfigSuccessResponse, 30 | TaskResubscriptionRequest, 31 | A2AError, 32 | SendMessageSuccessResponse 33 | } from '../types.js'; // Assuming schema.ts is in the same directory or appropriately pathed 34 | 35 | // Helper type for the data yielded by streaming methods 36 | type A2AStreamEventData = Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent; 37 | 38 | /** 39 | * A2AClient is a TypeScript HTTP client for interacting with A2A-compliant agents. 40 | */ 41 | export class A2AClient { 42 | private agentBaseUrl: string; 43 | private agentCardPromise: Promise; 44 | private requestIdCounter: number = 1; 45 | private serviceEndpointUrl?: string; // To be populated from AgentCard after fetching 46 | 47 | /** 48 | * Constructs an A2AClient instance. 49 | * It initiates fetching the agent card from the provided agent baseUrl. 50 | * The Agent Card is expected at `${agentBaseUrl}/.well-known/agent.json`. 51 | * The `url` field from the Agent Card will be used as the RPC service endpoint. 52 | * @param agentBaseUrl The base URL of the A2A agent (e.g., https://agent.example.com). 53 | */ 54 | constructor(agentBaseUrl: string) { 55 | this.agentBaseUrl = agentBaseUrl.replace(/\/$/, ""); // Remove trailing slash if any 56 | this.agentCardPromise = this._fetchAndCacheAgentCard(); 57 | } 58 | 59 | /** 60 | * Fetches the Agent Card from the agent's well-known URI and caches its service endpoint URL. 61 | * This method is called by the constructor. 62 | * @returns A Promise that resolves to the AgentCard. 63 | */ 64 | private async _fetchAndCacheAgentCard(): Promise { 65 | const agentCardUrl = `${this.agentBaseUrl}/.well-known/agent.json`; 66 | try { 67 | const response = await fetch(agentCardUrl, { 68 | headers: { 'Accept': 'application/json' }, 69 | }); 70 | if (!response.ok) { 71 | throw new Error(`Failed to fetch Agent Card from ${agentCardUrl}: ${response.status} ${response.statusText}`); 72 | } 73 | const agentCard: AgentCard = await response.json(); 74 | if (!agentCard.url) { 75 | throw new Error("Fetched Agent Card does not contain a valid 'url' for the service endpoint."); 76 | } 77 | this.serviceEndpointUrl = agentCard.url; // Cache the service endpoint URL from the agent card 78 | return agentCard; 79 | } catch (error) { 80 | console.error("Error fetching or parsing Agent Card:"); 81 | // Allow the promise to reject so users of agentCardPromise can handle it. 82 | throw error; 83 | } 84 | } 85 | 86 | /** 87 | * Retrieves the Agent Card. 88 | * If an `agentBaseUrl` is provided, it fetches the card from that specific URL. 89 | * Otherwise, it returns the card fetched and cached during client construction. 90 | * @param agentBaseUrl Optional. The base URL of the agent to fetch the card from. 91 | * If provided, this will fetch a new card, not use the cached one from the constructor's URL. 92 | * @returns A Promise that resolves to the AgentCard. 93 | */ 94 | public async getAgentCard(agentBaseUrl?: string): Promise { 95 | if (agentBaseUrl) { 96 | const specificAgentBaseUrl = agentBaseUrl.replace(/\/$/, ""); 97 | const agentCardUrl = `${specificAgentBaseUrl}/.well-known/agent.json`; 98 | const response = await fetch(agentCardUrl, { 99 | headers: { 'Accept': 'application/json' }, 100 | }); 101 | if (!response.ok) { 102 | throw new Error(`Failed to fetch Agent Card from ${agentCardUrl}: ${response.status} ${response.statusText}`); 103 | } 104 | return await response.json() as AgentCard; 105 | } 106 | // If no specific URL is given, return the promise for the initially configured agent's card. 107 | return this.agentCardPromise; 108 | } 109 | 110 | /** 111 | * Gets the RPC service endpoint URL. Ensures the agent card has been fetched first. 112 | * @returns A Promise that resolves to the service endpoint URL string. 113 | */ 114 | private async _getServiceEndpoint(): Promise { 115 | if (this.serviceEndpointUrl) { 116 | return this.serviceEndpointUrl; 117 | } 118 | // If serviceEndpointUrl is not set, it means the agent card fetch is pending or failed. 119 | // Awaiting agentCardPromise will either resolve it or throw if fetching failed. 120 | await this.agentCardPromise; 121 | if (!this.serviceEndpointUrl) { 122 | // This case should ideally be covered by the error handling in _fetchAndCacheAgentCard 123 | throw new Error("Agent Card URL for RPC endpoint is not available. Fetching might have failed."); 124 | } 125 | return this.serviceEndpointUrl; 126 | } 127 | 128 | /** 129 | * Helper method to make a generic JSON-RPC POST request. 130 | * @param method The RPC method name. 131 | * @param params The parameters for the RPC method. 132 | * @returns A Promise that resolves to the RPC response. 133 | */ 134 | private async _postRpcRequest( 135 | method: string, 136 | params: TParams 137 | ): Promise { 138 | const endpoint = await this._getServiceEndpoint(); 139 | const requestId = this.requestIdCounter++; 140 | const rpcRequest: JSONRPCRequest = { 141 | jsonrpc: "2.0", 142 | method, 143 | params: params as { [key: string]: any; }, // Cast because TParams structure varies per method 144 | id: requestId, 145 | }; 146 | 147 | const httpResponse = await fetch(endpoint, { 148 | method: "POST", 149 | headers: { 150 | "Content-Type": "application/json", 151 | "Accept": "application/json", // Expect JSON response for non-streaming requests 152 | }, 153 | body: JSON.stringify(rpcRequest), 154 | }); 155 | 156 | if (!httpResponse.ok) { 157 | let errorBodyText = '(empty or non-JSON response)'; 158 | try { 159 | errorBodyText = await httpResponse.text(); 160 | const errorJson = JSON.parse(errorBodyText); 161 | // If the body is a valid JSON-RPC error response, let it be handled by the standard parsing below. 162 | // However, if it's not even a JSON-RPC structure but still an error, throw based on HTTP status. 163 | if (!errorJson.jsonrpc && errorJson.error) { // Check if it's a JSON-RPC error structure 164 | throw new Error(`RPC error for ${method}: ${errorJson.error.message} (Code: ${errorJson.error.code}, HTTP Status: ${httpResponse.status}) Data: ${JSON.stringify(errorJson.error.data)}`); 165 | } else if (!errorJson.jsonrpc) { 166 | throw new Error(`HTTP error for ${method}! Status: ${httpResponse.status} ${httpResponse.statusText}. Response: ${errorBodyText}`); 167 | } 168 | } catch (e: any) { 169 | // If parsing the error body fails or it's not a JSON-RPC error, throw a generic HTTP error. 170 | // If it was already an error thrown from within the try block, rethrow it. 171 | if (e.message.startsWith('RPC error for') || e.message.startsWith('HTTP error for')) throw e; 172 | throw new Error(`HTTP error for ${method}! Status: ${httpResponse.status} ${httpResponse.statusText}. Response: ${errorBodyText}`); 173 | } 174 | } 175 | 176 | const rpcResponse = await httpResponse.json(); 177 | 178 | if (rpcResponse.id !== requestId) { 179 | // This is a significant issue for request-response matching. 180 | console.error(`CRITICAL: RPC response ID mismatch for method ${method}. Expected ${requestId}, got ${rpcResponse.id}. This may lead to incorrect response handling.`); 181 | // Depending on strictness, one might throw an error here. 182 | // throw new Error(`RPC response ID mismatch for method ${method}. Expected ${requestId}, got ${rpcResponse.id}`); 183 | } 184 | 185 | return rpcResponse as TResponse; 186 | } 187 | 188 | 189 | /** 190 | * Sends a message to the agent. 191 | * The behavior (blocking/non-blocking) and push notification configuration 192 | * are specified within the `params.configuration` object. 193 | * Optionally, `params.message.contextId` or `params.message.taskId` can be provided. 194 | * @param params The parameters for sending the message, including the message content and configuration. 195 | * @returns A Promise resolving to SendMessageResponse, which can be a Message, Task, or an error. 196 | */ 197 | public async sendMessage(params: MessageSendParams): Promise { 198 | return this._postRpcRequest("message/send", params); 199 | } 200 | 201 | /** 202 | * Sends a message to the agent and streams back responses using Server-Sent Events (SSE). 203 | * Push notification configuration can be specified in `params.configuration`. 204 | * Optionally, `params.message.contextId` or `params.message.taskId` can be provided. 205 | * Requires the agent to support streaming (`capabilities.streaming: true` in AgentCard). 206 | * @param params The parameters for sending the message. 207 | * @returns An AsyncGenerator yielding A2AStreamEventData (Message, Task, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent). 208 | * The generator throws an error if streaming is not supported or if an HTTP/SSE error occurs. 209 | */ 210 | public async *sendMessageStream(params: MessageSendParams): AsyncGenerator { 211 | const agentCard = await this.agentCardPromise; // Ensure agent card is fetched 212 | if (!agentCard.capabilities?.streaming) { 213 | throw new Error("Agent does not support streaming (AgentCard.capabilities.streaming is not true)."); 214 | } 215 | 216 | const endpoint = await this._getServiceEndpoint(); 217 | const clientRequestId = this.requestIdCounter++; // Use a unique ID for this stream request 218 | const rpcRequest: JSONRPCRequest = { // This is the initial JSON-RPC request to establish the stream 219 | jsonrpc: "2.0", 220 | method: "message/stream", 221 | params: params as { [key: string]: any; }, 222 | id: clientRequestId, 223 | }; 224 | 225 | const response = await fetch(endpoint, { 226 | method: "POST", 227 | headers: { 228 | "Content-Type": "application/json", 229 | "Accept": "text/event-stream", // Crucial for SSE 230 | }, 231 | body: JSON.stringify(rpcRequest), 232 | }); 233 | 234 | if (!response.ok) { 235 | // Attempt to read error body for more details 236 | let errorBody = ""; 237 | try { 238 | errorBody = await response.text(); 239 | const errorJson = JSON.parse(errorBody); 240 | if (errorJson.error) { 241 | throw new Error(`HTTP error establishing stream for message/stream: ${response.status} ${response.statusText}. RPC Error: ${errorJson.error.message} (Code: ${errorJson.error.code})`); 242 | } 243 | } catch (e: any) { 244 | if (e.message.startsWith('HTTP error establishing stream')) throw e; 245 | // Fallback if body is not JSON or parsing fails 246 | throw new Error(`HTTP error establishing stream for message/stream: ${response.status} ${response.statusText}. Response: ${errorBody || '(empty)'}`); 247 | } 248 | throw new Error(`HTTP error establishing stream for message/stream: ${response.status} ${response.statusText}`); 249 | } 250 | if (!response.headers.get("Content-Type")?.startsWith("text/event-stream")) { 251 | // Server should explicitly set this content type for SSE. 252 | throw new Error("Invalid response Content-Type for SSE stream. Expected 'text/event-stream'."); 253 | } 254 | 255 | // Yield events from the parsed SSE stream. 256 | // Each event's 'data' field is a JSON-RPC response. 257 | yield* this._parseA2ASseStream(response, clientRequestId); 258 | } 259 | 260 | /** 261 | * Sets or updates the push notification configuration for a given task. 262 | * Requires the agent to support push notifications (`capabilities.pushNotifications: true` in AgentCard). 263 | * @param params Parameters containing the taskId and the TaskPushNotificationConfig. 264 | * @returns A Promise resolving to SetTaskPushNotificationConfigResponse. 265 | */ 266 | public async setTaskPushNotificationConfig(params: TaskPushNotificationConfig): Promise { 267 | const agentCard = await this.agentCardPromise; 268 | if (!agentCard.capabilities?.pushNotifications) { 269 | throw new Error("Agent does not support push notifications (AgentCard.capabilities.pushNotifications is not true)."); 270 | } 271 | // The 'params' directly matches the structure expected by the RPC method. 272 | return this._postRpcRequest( 273 | "tasks/pushNotificationConfig/set", 274 | params 275 | ); 276 | } 277 | 278 | /** 279 | * Gets the push notification configuration for a given task. 280 | * @param params Parameters containing the taskId. 281 | * @returns A Promise resolving to GetTaskPushNotificationConfigResponse. 282 | */ 283 | public async getTaskPushNotificationConfig(params: TaskIdParams): Promise { 284 | // The 'params' (TaskIdParams) directly matches the structure expected by the RPC method. 285 | return this._postRpcRequest( 286 | "tasks/pushNotificationConfig/get", 287 | params 288 | ); 289 | } 290 | 291 | 292 | /** 293 | * Retrieves a task by its ID. 294 | * @param params Parameters containing the taskId and optional historyLength. 295 | * @returns A Promise resolving to GetTaskResponse, which contains the Task object or an error. 296 | */ 297 | public async getTask(params: TaskQueryParams): Promise { 298 | return this._postRpcRequest("tasks/get", params); 299 | } 300 | 301 | /** 302 | * Cancels a task by its ID. 303 | * @param params Parameters containing the taskId. 304 | * @returns A Promise resolving to CancelTaskResponse, which contains the updated Task object or an error. 305 | */ 306 | public async cancelTask(params: TaskIdParams): Promise { 307 | return this._postRpcRequest("tasks/cancel", params); 308 | } 309 | 310 | /** 311 | * Resubscribes to a task's event stream using Server-Sent Events (SSE). 312 | * This is used if a previous SSE connection for an active task was broken. 313 | * Requires the agent to support streaming (`capabilities.streaming: true` in AgentCard). 314 | * @param params Parameters containing the taskId. 315 | * @returns An AsyncGenerator yielding A2AStreamEventData (Message, Task, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent). 316 | */ 317 | public async *resubscribeTask(params: TaskIdParams): AsyncGenerator { 318 | const agentCard = await this.agentCardPromise; 319 | if (!agentCard.capabilities?.streaming) { 320 | throw new Error("Agent does not support streaming (required for tasks/resubscribe)."); 321 | } 322 | 323 | const endpoint = await this._getServiceEndpoint(); 324 | const clientRequestId = this.requestIdCounter++; // Unique ID for this resubscribe request 325 | const rpcRequest: JSONRPCRequest = { // Initial JSON-RPC request to establish the stream 326 | jsonrpc: "2.0", 327 | method: "tasks/resubscribe", 328 | params: params as { [key: string]: any; }, 329 | id: clientRequestId, 330 | }; 331 | 332 | const response = await fetch(endpoint, { 333 | method: "POST", 334 | headers: { 335 | "Content-Type": "application/json", 336 | "Accept": "text/event-stream", 337 | }, 338 | body: JSON.stringify(rpcRequest), 339 | }); 340 | 341 | if (!response.ok) { 342 | let errorBody = ""; 343 | try { 344 | errorBody = await response.text(); 345 | const errorJson = JSON.parse(errorBody); 346 | if (errorJson.error) { 347 | throw new Error(`HTTP error establishing stream for tasks/resubscribe: ${response.status} ${response.statusText}. RPC Error: ${errorJson.error.message} (Code: ${errorJson.error.code})`); 348 | } 349 | } catch (e: any) { 350 | if (e.message.startsWith('HTTP error establishing stream')) throw e; 351 | throw new Error(`HTTP error establishing stream for tasks/resubscribe: ${response.status} ${response.statusText}. Response: ${errorBody || '(empty)'}`); 352 | } 353 | throw new Error(`HTTP error establishing stream for tasks/resubscribe: ${response.status} ${response.statusText}`); 354 | } 355 | if (!response.headers.get("Content-Type")?.startsWith("text/event-stream")) { 356 | throw new Error("Invalid response Content-Type for SSE stream on resubscribe. Expected 'text/event-stream'."); 357 | } 358 | 359 | // The events structure for resubscribe is assumed to be the same as message/stream. 360 | // Each event's 'data' field is a JSON-RPC response. 361 | yield* this._parseA2ASseStream(response, clientRequestId); 362 | } 363 | 364 | /** 365 | * Parses an HTTP response body as an A2A Server-Sent Event stream. 366 | * Each 'data' field of an SSE event is expected to be a JSON-RPC 2.0 Response object, 367 | * specifically a SendStreamingMessageResponse (or similar structure for resubscribe). 368 | * @param response The HTTP Response object whose body is the SSE stream. 369 | * @param originalRequestId The ID of the client's JSON-RPC request that initiated this stream. 370 | * Used to validate the `id` in the streamed JSON-RPC responses. 371 | * @returns An AsyncGenerator yielding the `result` field of each valid JSON-RPC success response from the stream. 372 | */ 373 | private async *_parseA2ASseStream( 374 | response: Response, 375 | originalRequestId: number | string | null 376 | ): AsyncGenerator { 377 | if (!response.body) { 378 | throw new Error("SSE response body is undefined. Cannot read stream."); 379 | } 380 | const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); 381 | let buffer = ""; // Holds incomplete lines from the stream 382 | let eventDataBuffer = ""; // Holds accumulated 'data:' lines for the current event 383 | 384 | try { 385 | while (true) { 386 | const { done, value } = await reader.read(); 387 | if (done) { 388 | // Process any final buffered event data if the stream ends abruptly after a 'data:' line 389 | if (eventDataBuffer.trim()) { 390 | const result = this._processSseEventData(eventDataBuffer, originalRequestId); 391 | yield result; 392 | } 393 | break; // Stream finished 394 | } 395 | 396 | buffer += value; // Append new chunk to buffer 397 | let lineEndIndex; 398 | // Process all complete lines in the buffer 399 | while ((lineEndIndex = buffer.indexOf('\n')) >= 0) { 400 | const line = buffer.substring(0, lineEndIndex).trim(); // Get and trim the line 401 | buffer = buffer.substring(lineEndIndex + 1); // Remove processed line from buffer 402 | 403 | if (line === "") { // Empty line: signifies the end of an event 404 | if (eventDataBuffer) { // If we have accumulated data for an event 405 | const result = this._processSseEventData(eventDataBuffer, originalRequestId); 406 | yield result; 407 | eventDataBuffer = ""; // Reset buffer for the next event 408 | } 409 | } else if (line.startsWith("data:")) { 410 | eventDataBuffer += line.substring(5).trimStart() + "\n"; // Append data (multi-line data is possible) 411 | } else if (line.startsWith(":")) { 412 | // This is a comment line in SSE, ignore it. 413 | } else if (line.includes(":")) { 414 | // Other SSE fields like 'event:', 'id:', 'retry:'. 415 | // The A2A spec primarily focuses on the 'data' field for JSON-RPC payloads. 416 | // For now, we don't specifically handle these other SSE fields unless required by spec. 417 | } 418 | } 419 | } 420 | } catch (error: any) { 421 | // Log and re-throw errors encountered during stream processing 422 | console.error("Error reading or parsing SSE stream:", error.message); 423 | throw error; 424 | } finally { 425 | reader.releaseLock(); // Ensure the reader lock is released 426 | } 427 | } 428 | 429 | /** 430 | * Processes a single SSE event's data string, expecting it to be a JSON-RPC response. 431 | * @param jsonData The string content from one or more 'data:' lines of an SSE event. 432 | * @param originalRequestId The ID of the client's request that initiated the stream. 433 | * @returns The `result` field of the parsed JSON-RPC success response. 434 | * @throws Error if data is not valid JSON, not a valid JSON-RPC response, an error response, or ID mismatch. 435 | */ 436 | private _processSseEventData( 437 | jsonData: string, 438 | originalRequestId: number | string | null 439 | ): TStreamItem { 440 | if (!jsonData.trim()) { 441 | throw new Error("Attempted to process empty SSE event data."); 442 | } 443 | try { 444 | // SSE data can be multi-line, ensure it's treated as a single JSON string. 445 | const sseJsonRpcResponse = JSON.parse(jsonData.replace(/\n$/, '')); // Remove trailing newline if any 446 | 447 | // Type assertion to SendStreamingMessageResponse, as this is the expected structure for A2A streams. 448 | const a2aStreamResponse: SendStreamingMessageResponse = sseJsonRpcResponse as SendStreamingMessageResponse; 449 | 450 | if (a2aStreamResponse.id !== originalRequestId) { 451 | // According to JSON-RPC spec, notifications (which SSE events can be seen as) might not have an ID, 452 | // or if they do, it should match. A2A spec implies streamed events are tied to the initial request. 453 | console.warn(`SSE Event's JSON-RPC response ID mismatch. Client request ID: ${originalRequestId}, event response ID: ${a2aStreamResponse.id}.`); 454 | // Depending on strictness, this could be an error. For now, it's a warning. 455 | } 456 | 457 | if (this.isErrorResponse(a2aStreamResponse)) { 458 | const err = a2aStreamResponse.error as (JSONRPCError | A2AError); 459 | throw new Error(`SSE event contained an error: ${err.message} (Code: ${err.code}) Data: ${JSON.stringify(err.data)}`); 460 | } 461 | 462 | // Check if 'result' exists, as it's mandatory for successful JSON-RPC responses 463 | if (!('result' in a2aStreamResponse) || typeof (a2aStreamResponse as SendStreamingMessageSuccessResponse).result === 'undefined') { 464 | throw new Error(`SSE event JSON-RPC response is missing 'result' field. Data: ${jsonData}`); 465 | } 466 | 467 | const successResponse = a2aStreamResponse as SendStreamingMessageSuccessResponse; 468 | return successResponse.result as TStreamItem; 469 | } catch (e: any) { 470 | // Catch errors from JSON.parse or if it's an error response that was thrown by this function 471 | if (e.message.startsWith("SSE event contained an error") || e.message.startsWith("SSE event JSON-RPC response is missing 'result' field")) { 472 | throw e; // Re-throw errors already processed/identified by this function 473 | } 474 | // For other parsing errors or unexpected structures: 475 | console.error("Failed to parse SSE event data string or unexpected JSON-RPC structure:", jsonData, e); 476 | throw new Error(`Failed to parse SSE event data: "${jsonData.substring(0, 100)}...". Original error: ${e.message}`); 477 | } 478 | } 479 | 480 | isErrorResponse(response: JSONRPCResponse): response is JSONRPCErrorResponse { 481 | return "error" in response; 482 | } 483 | } 484 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main entry point for the A2A Server V2 library. 3 | * Exports the server class, store implementations, and core types. 4 | */ 5 | 6 | 7 | export type { AgentExecutor } from "./server/agent_execution/agent_executor.js"; 8 | export { RequestContext } from "./server/agent_execution/request_context.js"; 9 | 10 | export type { ExecutionEventBus } from "./server/events/execution_event_bus.js"; 11 | export { DefaultExecutionEventBus } from "./server/events/execution_event_bus.js"; 12 | export type { ExecutionEventBusManager } from "./server/events/execution_event_bus_manager.js"; 13 | export { DefaultExecutionEventBusManager } from "./server/events/execution_event_bus_manager.js"; 14 | 15 | export type { A2ARequestHandler } from "./server/request_handler/a2a_request_handler.js"; 16 | export { DefaultRequestHandler } from "./server/request_handler/default_request_handler.js"; 17 | export { ResultManager } from "./server/result_manager.js"; 18 | export type { TaskStore } from "./server/store.js"; 19 | export { InMemoryTaskStore } from "./server/store.js"; 20 | 21 | export { JsonRpcTransportHandler } from "./server/transports/jsonrpc_transport_handler.js"; 22 | export { A2AExpressApp } from "./server/a2a_express_app.js"; 23 | export { A2AError } from "./server/error.js"; 24 | 25 | // Export Client 26 | export { A2AClient } from "./client/client.js"; 27 | 28 | // Re-export all schema types for convenience 29 | export * from "./types.js"; 30 | export type { A2AResponse } from "./a2a_response.js"; 31 | -------------------------------------------------------------------------------- /src/samples/agents/movie-agent/README.md: -------------------------------------------------------------------------------- 1 | # Movie Info Agent 2 | 3 | This agent uses the TMDB API to answer questions about movies. To run: 4 | 5 | ```bash 6 | export TMDB_API_KEY= # see https://developer.themoviedb.org/docs/getting-started 7 | export GEMINI_API_KEY= 8 | npm run agents:movie-agent 9 | ``` 10 | 11 | The agent will start on `http://localhost:41241`. 12 | -------------------------------------------------------------------------------- /src/samples/agents/movie-agent/genkit.ts: -------------------------------------------------------------------------------- 1 | import { googleAI } from "@genkit-ai/googleai"; 2 | import { genkit } from "genkit"; 3 | import { dirname } from "path"; 4 | import { fileURLToPath } from "url"; 5 | 6 | export const ai = genkit({ 7 | plugins: [googleAI()], 8 | model: googleAI.model("gemini-2.0-flash"), 9 | promptDir: dirname(fileURLToPath(import.meta.url)), 10 | }); 11 | 12 | export { z } from "genkit"; 13 | -------------------------------------------------------------------------------- /src/samples/agents/movie-agent/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { v4 as uuidv4 } from 'uuid'; // For generating unique IDs 3 | 4 | import { 5 | InMemoryTaskStore, 6 | TaskStore, 7 | A2AExpressApp, 8 | AgentExecutor, 9 | RequestContext, 10 | ExecutionEventBus, 11 | DefaultRequestHandler, 12 | AgentCard, 13 | Task, 14 | TaskState, 15 | TaskStatusUpdateEvent, 16 | TextPart, 17 | Message 18 | } from "../../../index.js"; 19 | import { MessageData } from "genkit"; 20 | import { ai } from "./genkit.js"; 21 | import { searchMovies, searchPeople } from "./tools.js"; 22 | 23 | if (!process.env.GEMINI_API_KEY || !process.env.TMDB_API_KEY) { 24 | console.error("GEMINI_API_KEY and TMDB_API_KEY environment variables are required") 25 | process.exit(1); 26 | } 27 | 28 | // Simple store for contexts 29 | const contexts: Map = new Map(); 30 | 31 | // Load the Genkit prompt 32 | const movieAgentPrompt = ai.prompt('movie_agent'); 33 | 34 | /** 35 | * MovieAgentExecutor implements the agent's core logic. 36 | */ 37 | class MovieAgentExecutor implements AgentExecutor { 38 | private cancelledTasks = new Set(); 39 | 40 | public cancelTask = async ( 41 | taskId: string, 42 | eventBus: ExecutionEventBus, 43 | ): Promise => { 44 | this.cancelledTasks.add(taskId); 45 | // The execute loop is responsible for publishing the final state 46 | }; 47 | 48 | async execute( 49 | requestContext: RequestContext, 50 | eventBus: ExecutionEventBus 51 | ): Promise { 52 | const userMessage = requestContext.userMessage; 53 | const existingTask = requestContext.task; 54 | 55 | // Determine IDs for the task and context 56 | const taskId = requestContext.taskId; 57 | const contextId = requestContext.contextId; 58 | 59 | console.log( 60 | `[MovieAgentExecutor] Processing message ${userMessage.messageId} for task ${taskId} (context: ${contextId})` 61 | ); 62 | 63 | // 1. Publish initial Task event if it's a new task 64 | if (!existingTask) { 65 | const initialTask: Task = { 66 | kind: 'task', 67 | id: taskId, 68 | contextId: contextId, 69 | status: { 70 | state: 'submitted', 71 | timestamp: new Date().toISOString(), 72 | }, 73 | history: [userMessage], // Start history with the current user message 74 | metadata: userMessage.metadata, // Carry over metadata from message if any 75 | }; 76 | eventBus.publish(initialTask); 77 | } 78 | 79 | // 2. Publish "working" status update 80 | const workingStatusUpdate: TaskStatusUpdateEvent = { 81 | kind: 'status-update', 82 | taskId: taskId, 83 | contextId: contextId, 84 | status: { 85 | state: 'working', 86 | message: { 87 | kind: 'message', 88 | role: 'agent', 89 | messageId: uuidv4(), 90 | parts: [{ kind: 'text', text: 'Processing your question, hang tight!' }], 91 | taskId: taskId, 92 | contextId: contextId, 93 | }, 94 | timestamp: new Date().toISOString(), 95 | }, 96 | final: false, 97 | }; 98 | eventBus.publish(workingStatusUpdate); 99 | 100 | // 3. Prepare messages for Genkit prompt 101 | const historyForGenkit = contexts.get(contextId) || []; 102 | if (!historyForGenkit.find(m => m.messageId === userMessage.messageId)) { 103 | historyForGenkit.push(userMessage); 104 | } 105 | contexts.set(contextId, historyForGenkit) 106 | 107 | const messages: MessageData[] = historyForGenkit 108 | .map((m) => ({ 109 | role: (m.role === 'agent' ? 'model' : 'user') as 'user' | 'model', 110 | content: m.parts 111 | .filter((p): p is TextPart => p.kind === 'text' && !!(p as TextPart).text) 112 | .map((p) => ({ 113 | text: (p as TextPart).text, 114 | })), 115 | })) 116 | .filter((m) => m.content.length > 0); 117 | 118 | if (messages.length === 0) { 119 | console.warn( 120 | `[MovieAgentExecutor] No valid text messages found in history for task ${taskId}.` 121 | ); 122 | const failureUpdate: TaskStatusUpdateEvent = { 123 | kind: 'status-update', 124 | taskId: taskId, 125 | contextId: contextId, 126 | status: { 127 | state: 'failed', 128 | message: { 129 | kind: 'message', 130 | role: 'agent', 131 | messageId: uuidv4(), 132 | parts: [{ kind: 'text', text: 'No message found to process.' }], 133 | taskId: taskId, 134 | contextId: contextId, 135 | }, 136 | timestamp: new Date().toISOString(), 137 | }, 138 | final: true, 139 | }; 140 | eventBus.publish(failureUpdate); 141 | return; 142 | } 143 | 144 | const goal = existingTask?.metadata?.goal as string | undefined || userMessage.metadata?.goal as string | undefined; 145 | 146 | try { 147 | // 4. Run the Genkit prompt 148 | const response = await movieAgentPrompt( 149 | { goal: goal, now: new Date().toISOString() }, 150 | { 151 | messages, 152 | tools: [searchMovies, searchPeople], 153 | } 154 | ); 155 | 156 | // Check if the request has been cancelled 157 | if (this.cancelledTasks.has(taskId)) { 158 | console.log(`[MovieAgentExecutor] Request cancelled for task: ${taskId}`); 159 | 160 | const cancelledUpdate: TaskStatusUpdateEvent = { 161 | kind: 'status-update', 162 | taskId: taskId, 163 | contextId: contextId, 164 | status: { 165 | state: 'canceled', 166 | timestamp: new Date().toISOString(), 167 | }, 168 | final: true, // Cancellation is a final state 169 | }; 170 | eventBus.publish(cancelledUpdate); 171 | return; 172 | } 173 | 174 | const responseText = response.text; // Access the text property using .text() 175 | console.info(`[MovieAgentExecutor] Prompt response: ${responseText}`); 176 | const lines = responseText.trim().split('\n'); 177 | const finalStateLine = lines.at(-1)?.trim().toUpperCase(); 178 | const agentReplyText = lines.slice(0, lines.length - 1).join('\n').trim(); 179 | 180 | let finalA2AState: TaskState = "unknown"; 181 | 182 | if (finalStateLine === 'COMPLETED') { 183 | finalA2AState = 'completed'; 184 | } else if (finalStateLine === 'AWAITING_USER_INPUT') { 185 | finalA2AState = 'input-required'; 186 | } else { 187 | console.warn( 188 | `[MovieAgentExecutor] Unexpected final state line from prompt: ${finalStateLine}. Defaulting to 'completed'.` 189 | ); 190 | finalA2AState = 'completed'; // Default if LLM deviates 191 | } 192 | 193 | // 5. Publish final task status update 194 | const agentMessage: Message = { 195 | kind: 'message', 196 | role: 'agent', 197 | messageId: uuidv4(), 198 | parts: [{ kind: 'text', text: agentReplyText || "Completed." }], // Ensure some text 199 | taskId: taskId, 200 | contextId: contextId, 201 | }; 202 | historyForGenkit.push(agentMessage); 203 | contexts.set(contextId, historyForGenkit) 204 | 205 | const finalUpdate: TaskStatusUpdateEvent = { 206 | kind: 'status-update', 207 | taskId: taskId, 208 | contextId: contextId, 209 | status: { 210 | state: finalA2AState, 211 | message: agentMessage, 212 | timestamp: new Date().toISOString(), 213 | }, 214 | final: true, 215 | }; 216 | eventBus.publish(finalUpdate); 217 | 218 | console.log( 219 | `[MovieAgentExecutor] Task ${taskId} finished with state: ${finalA2AState}` 220 | ); 221 | 222 | } catch (error: any) { 223 | console.error( 224 | `[MovieAgentExecutor] Error processing task ${taskId}:`, 225 | error 226 | ); 227 | const errorUpdate: TaskStatusUpdateEvent = { 228 | kind: 'status-update', 229 | taskId: taskId, 230 | contextId: contextId, 231 | status: { 232 | state: 'failed', 233 | message: { 234 | kind: 'message', 235 | role: 'agent', 236 | messageId: uuidv4(), 237 | parts: [{ kind: 'text', text: `Agent error: ${error.message}` }], 238 | taskId: taskId, 239 | contextId: contextId, 240 | }, 241 | timestamp: new Date().toISOString(), 242 | }, 243 | final: true, 244 | }; 245 | eventBus.publish(errorUpdate); 246 | } 247 | } 248 | } 249 | 250 | // --- Server Setup --- 251 | 252 | const movieAgentCard: AgentCard = { 253 | name: 'Movie Agent', 254 | description: 'An agent that can answer questions about movies and actors using TMDB.', 255 | // Adjust the base URL and port as needed. /a2a is the default base in A2AExpressApp 256 | url: 'http://localhost:41241/', // Example: if baseUrl in A2AExpressApp 257 | provider: { 258 | organization: 'A2A Samples', 259 | url: 'https://example.com/a2a-samples' // Added provider URL 260 | }, 261 | version: '0.0.2', // Incremented version 262 | capabilities: { 263 | streaming: true, // The new framework supports streaming 264 | pushNotifications: false, // Assuming not implemented for this agent yet 265 | stateTransitionHistory: true, // Agent uses history 266 | }, 267 | // authentication: null, // Property 'authentication' does not exist on type 'AgentCard'. 268 | securitySchemes: undefined, // Or define actual security schemes if any 269 | security: undefined, 270 | defaultInputModes: ['text'], 271 | defaultOutputModes: ['text', 'task-status'], // task-status is a common output mode 272 | skills: [ 273 | { 274 | id: 'general_movie_chat', 275 | name: 'General Movie Chat', 276 | description: 'Answer general questions or chat about movies, actors, directors.', 277 | tags: ['movies', 'actors', 'directors'], 278 | examples: [ 279 | 'Tell me about the plot of Inception.', 280 | 'Recommend a good sci-fi movie.', 281 | 'Who directed The Matrix?', 282 | 'What other movies has Scarlett Johansson been in?', 283 | 'Find action movies starring Keanu Reeves', 284 | 'Which came out first, Jurassic Park or Terminator 2?', 285 | ], 286 | inputModes: ['text'], // Explicitly defining for skill 287 | outputModes: ['text', 'task-status'] // Explicitly defining for skill 288 | }, 289 | ], 290 | supportsAuthenticatedExtendedCard: false, 291 | }; 292 | 293 | async function main() { 294 | // 1. Create TaskStore 295 | const taskStore: TaskStore = new InMemoryTaskStore(); 296 | 297 | // 2. Create AgentExecutor 298 | const agentExecutor: AgentExecutor = new MovieAgentExecutor(); 299 | 300 | // 3. Create DefaultRequestHandler 301 | const requestHandler = new DefaultRequestHandler( 302 | movieAgentCard, 303 | taskStore, 304 | agentExecutor 305 | ); 306 | 307 | // 4. Create and setup A2AExpressApp 308 | const appBuilder = new A2AExpressApp(requestHandler); 309 | const expressApp = appBuilder.setupRoutes(express()); 310 | 311 | // 5. Start the server 312 | const PORT = process.env.PORT || 41241; 313 | expressApp.listen(PORT, () => { 314 | console.log(`[MovieAgent] Server using new framework started on http://localhost:${PORT}`); 315 | console.log(`[MovieAgent] Agent Card: http://localhost:${PORT}/.well-known/agent.json`); 316 | console.log('[MovieAgent] Press Ctrl+C to stop the server'); 317 | }); 318 | } 319 | 320 | main().catch(console.error); 321 | 322 | -------------------------------------------------------------------------------- /src/samples/agents/movie-agent/movie_agent.prompt: -------------------------------------------------------------------------------- 1 | {{role "system"}} 2 | You are a movie expert. Answer the user's question about movies and film industry personalities, using the searchMovies and searchPeople tools to find out more information as needed. Feel free to call them multiple times in parallel if necessary.{{#if goal}} 3 | 4 | Your goal in this task is: {{goal}}{{/if}} 5 | 6 | The current date and time is: {{now}} 7 | 8 | If the user asks you for specific information about a movie or person (such as the plot or a specific role an actor played), do a search for that movie/actor using the available functions before responding. 9 | 10 | ## Output Instructions 11 | 12 | ALWAYS end your response with either "COMPLETED" or "AWAITING_USER_INPUT" on its own line. If you have answered the user's question, use COMPLETED. If you need more information to answer the question, use AWAITING_USER_INPUT. 13 | 14 | 15 | 16 | when was [some_movie] released? 17 | 18 | 19 | [some_movie] was released on October 3, 1992. 20 | COMPLETED 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/samples/agents/movie-agent/tmdb.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility function to call the TMDB API 3 | * @param endpoint The TMDB API endpoint (e.g., 'movie', 'person') 4 | * @param query The search query 5 | * @returns Promise that resolves to the API response data 6 | */ 7 | export async function callTmdbApi(endpoint: string, query: string) { 8 | // Validate API key 9 | const apiKey = process.env.TMDB_API_KEY; 10 | if (!apiKey) { 11 | throw new Error("TMDB_API_KEY environment variable is not set"); 12 | } 13 | 14 | try { 15 | // Make request to TMDB API 16 | const url = new URL(`https://api.themoviedb.org/3/search/${endpoint}`); 17 | url.searchParams.append("api_key", apiKey); 18 | url.searchParams.append("query", query); 19 | url.searchParams.append("include_adult", "false"); 20 | url.searchParams.append("language", "en-US"); 21 | url.searchParams.append("page", "1"); 22 | 23 | const response = await fetch(url.toString()); 24 | 25 | if (!response.ok) { 26 | throw new Error( 27 | `TMDB API error: ${response.status} ${response.statusText}` 28 | ); 29 | } 30 | 31 | return await response.json(); 32 | } catch (error) { 33 | console.error(`Error calling TMDB API (${endpoint}):`, error); 34 | throw error; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/samples/agents/movie-agent/tools.ts: -------------------------------------------------------------------------------- 1 | import { ai, z } from "./genkit.js"; 2 | import { callTmdbApi } from "./tmdb.js"; 3 | 4 | export const searchMovies = ai.defineTool( 5 | { 6 | name: "searchMovies", 7 | description: "search TMDB for movies by title", 8 | inputSchema: z.object({ 9 | query: z.string(), 10 | }), 11 | }, 12 | async ({ query }) => { 13 | console.log("[tmdb:searchMovies]", JSON.stringify(query)); 14 | try { 15 | const data = await callTmdbApi("movie", query); 16 | 17 | // Only modify image paths to be full URLs 18 | const results = data.results.map((movie: any) => { 19 | if (movie.poster_path) { 20 | movie.poster_path = `https://image.tmdb.org/t/p/w500${movie.poster_path}`; 21 | } 22 | if (movie.backdrop_path) { 23 | movie.backdrop_path = `https://image.tmdb.org/t/p/w500${movie.backdrop_path}`; 24 | } 25 | return movie; 26 | }); 27 | 28 | return { 29 | ...data, 30 | results, 31 | }; 32 | } catch (error) { 33 | console.error("Error searching movies:", error); 34 | // Re-throwing allows Genkit/the caller to handle it appropriately 35 | throw error; 36 | } 37 | } 38 | ); 39 | 40 | export const searchPeople = ai.defineTool( 41 | { 42 | name: "searchPeople", 43 | description: "search TMDB for people by name", 44 | inputSchema: z.object({ 45 | query: z.string(), 46 | }), 47 | }, 48 | async ({ query }) => { 49 | console.log("[tmdb:searchPeople]", JSON.stringify(query)); 50 | try { 51 | const data = await callTmdbApi("person", query); 52 | 53 | // Only modify image paths to be full URLs 54 | const results = data.results.map((person: any) => { 55 | if (person.profile_path) { 56 | person.profile_path = `https://image.tmdb.org/t/p/w500${person.profile_path}`; 57 | } 58 | 59 | // Also modify poster paths in known_for works 60 | if (person.known_for && Array.isArray(person.known_for)) { 61 | person.known_for = person.known_for.map((work: any) => { 62 | if (work.poster_path) { 63 | work.poster_path = `https://image.tmdb.org/t/p/w500${work.poster_path}`; 64 | } 65 | if (work.backdrop_path) { 66 | work.backdrop_path = `https://image.tmdb.org/t/p/w500${work.backdrop_path}`; 67 | } 68 | return work; 69 | }); 70 | } 71 | 72 | return person; 73 | }); 74 | 75 | return { 76 | ...data, 77 | results, 78 | }; 79 | } catch (error) { 80 | console.error("Error searching people:", error); 81 | // Re-throwing allows Genkit/the caller to handle it appropriately 82 | throw error; 83 | } 84 | } 85 | ); 86 | -------------------------------------------------------------------------------- /src/samples/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import readline from "node:readline"; 4 | import crypto from "node:crypto"; 5 | 6 | import { 7 | // Specific Params/Payload types used by the CLI 8 | MessageSendParams, // Changed from TaskSendParams 9 | TaskStatusUpdateEvent, 10 | TaskArtifactUpdateEvent, 11 | Message, 12 | Task, // Added for direct Task events 13 | // Other types needed for message/part handling 14 | TaskState, 15 | FilePart, 16 | DataPart, 17 | // Type for the agent card 18 | AgentCard, 19 | Part, // Added for explicit Part typing 20 | A2AClient, 21 | } from "../index.js"; 22 | 23 | // --- ANSI Colors --- 24 | const colors = { 25 | reset: "\x1b[0m", 26 | bright: "\x1b[1m", 27 | dim: "\x1b[2m", 28 | red: "\x1b[31m", 29 | green: "\x1b[32m", 30 | yellow: "\x1b[33m", 31 | blue: "\x1b[34m", 32 | magenta: "\x1b[35m", 33 | cyan: "\x1b[36m", 34 | gray: "\x1b[90m", 35 | }; 36 | 37 | // --- Helper Functions --- 38 | function colorize(color: keyof typeof colors, text: string): string { 39 | return `${colors[color]}${text}${colors.reset}`; 40 | } 41 | 42 | function generateId(): string { // Renamed for more general use 43 | return crypto.randomUUID(); 44 | } 45 | 46 | // --- State --- 47 | let currentTaskId: string | undefined = undefined; // Initialize as undefined 48 | let currentContextId: string | undefined = undefined; // Initialize as undefined 49 | const serverUrl = process.argv[2] || "http://localhost:41241"; // Agent's base URL 50 | const client = new A2AClient(serverUrl); 51 | let agentName = "Agent"; // Default, try to get from agent card later 52 | 53 | // --- Readline Setup --- 54 | const rl = readline.createInterface({ 55 | input: process.stdin, 56 | output: process.stdout, 57 | prompt: colorize("cyan", "You: "), 58 | }); 59 | 60 | // --- Response Handling --- 61 | // Function now accepts the unwrapped event payload directly 62 | function printAgentEvent( 63 | event: TaskStatusUpdateEvent | TaskArtifactUpdateEvent 64 | ) { 65 | const timestamp = new Date().toLocaleTimeString(); 66 | const prefix = colorize("magenta", `\n${agentName} [${timestamp}]:`); 67 | 68 | // Check if it's a TaskStatusUpdateEvent 69 | if (event.kind === "status-update") { 70 | const update = event as TaskStatusUpdateEvent; // Cast for type safety 71 | const state = update.status.state; 72 | let stateEmoji = "❓"; 73 | let stateColor: keyof typeof colors = "yellow"; 74 | 75 | switch (state) { 76 | case "working": 77 | stateEmoji = "⏳"; 78 | stateColor = "blue"; 79 | break; 80 | case "input-required": 81 | stateEmoji = "🤔"; 82 | stateColor = "yellow"; 83 | break; 84 | case "completed": 85 | stateEmoji = "✅"; 86 | stateColor = "green"; 87 | break; 88 | case "canceled": 89 | stateEmoji = "⏹️"; 90 | stateColor = "gray"; 91 | break; 92 | case "failed": 93 | stateEmoji = "❌"; 94 | stateColor = "red"; 95 | break; 96 | default: 97 | stateEmoji = "ℹ️"; // For other states like submitted, rejected etc. 98 | stateColor = "dim"; 99 | break; 100 | } 101 | 102 | console.log( 103 | `${prefix} ${stateEmoji} Status: ${colorize(stateColor, state)} (Task: ${update.taskId}, Context: ${update.contextId}) ${update.final ? colorize("bright", "[FINAL]") : ""}` 104 | ); 105 | 106 | if (update.status.message) { 107 | printMessageContent(update.status.message); 108 | } 109 | } 110 | // Check if it's a TaskArtifactUpdateEvent 111 | else if (event.kind === "artifact-update") { 112 | const update = event as TaskArtifactUpdateEvent; // Cast for type safety 113 | console.log( 114 | `${prefix} 📄 Artifact Received: ${update.artifact.name || "(unnamed)" 115 | } (ID: ${update.artifact.artifactId}, Task: ${update.taskId}, Context: ${update.contextId})` 116 | ); 117 | // Create a temporary message-like structure to reuse printMessageContent 118 | printMessageContent({ 119 | messageId: generateId(), // Dummy messageId 120 | kind: "message", // Dummy kind 121 | role: "agent", // Assuming artifact parts are from agent 122 | parts: update.artifact.parts, 123 | taskId: update.taskId, 124 | contextId: update.contextId, 125 | }); 126 | } else { 127 | // This case should ideally not be reached if called correctly 128 | console.log( 129 | prefix, 130 | colorize("yellow", "Received unknown event type in printAgentEvent:"), 131 | event 132 | ); 133 | } 134 | } 135 | 136 | function printMessageContent(message: Message) { 137 | message.parts.forEach((part: Part, index: number) => { // Added explicit Part type 138 | const partPrefix = colorize("red", ` Part ${index + 1}:`); 139 | if (part.kind === "text") { // Check kind property 140 | console.log(`${partPrefix} ${colorize("green", "📝 Text:")}`, part.text); 141 | } else if (part.kind === "file") { // Check kind property 142 | const filePart = part as FilePart; 143 | console.log( 144 | `${partPrefix} ${colorize("blue", "📄 File:")} Name: ${filePart.file.name || "N/A" 145 | }, Type: ${filePart.file.mimeType || "N/A"}, Source: ${("bytes" in filePart.file) ? "Inline (bytes)" : filePart.file.uri 146 | }` 147 | ); 148 | } else if (part.kind === "data") { // Check kind property 149 | const dataPart = part as DataPart; 150 | console.log( 151 | `${partPrefix} ${colorize("yellow", "📊 Data:")}`, 152 | JSON.stringify(dataPart.data, null, 2) 153 | ); 154 | } else { 155 | console.log(`${partPrefix} ${colorize("yellow", "Unsupported part kind:")}`, part); 156 | } 157 | }); 158 | } 159 | 160 | // --- Agent Card Fetching --- 161 | async function fetchAndDisplayAgentCard() { 162 | // Use the client's getAgentCard method. 163 | // The client was initialized with serverUrl, which is the agent's base URL. 164 | console.log( 165 | colorize("dim", `Attempting to fetch agent card from agent at: ${serverUrl}`) 166 | ); 167 | try { 168 | // client.getAgentCard() uses the agentBaseUrl provided during client construction 169 | const card: AgentCard = await client.getAgentCard(); 170 | agentName = card.name || "Agent"; // Update global agent name 171 | console.log(colorize("green", `✓ Agent Card Found:`)); 172 | console.log(` Name: ${colorize("bright", agentName)}`); 173 | if (card.description) { 174 | console.log(` Description: ${card.description}`); 175 | } 176 | console.log(` Version: ${card.version || "N/A"}`); 177 | if (card.capabilities?.streaming) { 178 | console.log(` Streaming: ${colorize("green", "Supported")}`); 179 | } else { 180 | console.log(` Streaming: ${colorize("yellow", "Not Supported (or not specified)")}`); 181 | } 182 | // Update prompt prefix to use the fetched name 183 | // The prompt is set dynamically before each rl.prompt() call in the main loop 184 | // to reflect the current agentName if it changes (though unlikely after initial fetch). 185 | } catch (error: any) { 186 | console.log( 187 | colorize("yellow", `⚠️ Error fetching or parsing agent card`) 188 | ); 189 | throw error; 190 | } 191 | } 192 | 193 | // --- Main Loop --- 194 | async function main() { 195 | console.log(colorize("bright", `A2A Terminal Client`)); 196 | console.log(colorize("dim", `Agent Base URL: ${serverUrl}`)); 197 | 198 | await fetchAndDisplayAgentCard(); // Fetch the card before starting the loop 199 | 200 | console.log(colorize("dim", `No active task or context initially. Use '/new' to start a fresh session or send a message.`)); 201 | console.log( 202 | colorize("green", `Enter messages, or use '/new' to start a new session. '/exit' to quit.`) 203 | ); 204 | 205 | rl.setPrompt(colorize("cyan", `${agentName} > You: `)); // Set initial prompt 206 | rl.prompt(); 207 | 208 | rl.on("line", async (line) => { 209 | const input = line.trim(); 210 | rl.setPrompt(colorize("cyan", `${agentName} > You: `)); // Ensure prompt reflects current agentName 211 | 212 | if (!input) { 213 | rl.prompt(); 214 | return; 215 | } 216 | 217 | if (input.toLowerCase() === "/new") { 218 | currentTaskId = undefined; 219 | currentContextId = undefined; // Reset contextId on /new 220 | console.log( 221 | colorize("bright", `✨ Starting new session. Task and Context IDs are cleared.`) 222 | ); 223 | rl.prompt(); 224 | return; 225 | } 226 | 227 | if (input.toLowerCase() === "/exit") { 228 | rl.close(); 229 | return; 230 | } 231 | 232 | // Construct params for sendMessageStream 233 | const messageId = generateId(); // Generate a unique message ID 234 | 235 | const messagePayload: Message = { 236 | messageId: messageId, 237 | kind: "message", // Required by Message interface 238 | role: "user", 239 | parts: [ 240 | { 241 | kind: "text", // Required by TextPart interface 242 | text: input, 243 | }, 244 | ], 245 | }; 246 | 247 | // Conditionally add taskId to the message payload 248 | if (currentTaskId) { 249 | messagePayload.taskId = currentTaskId; 250 | } 251 | // Conditionally add contextId to the message payload 252 | if (currentContextId) { 253 | messagePayload.contextId = currentContextId; 254 | } 255 | 256 | 257 | const params: MessageSendParams = { 258 | message: messagePayload, 259 | // Optional: configuration for streaming, blocking, etc. 260 | // configuration: { 261 | // acceptedOutputModes: ['text/plain', 'application/json'], // Example 262 | // blocking: false // Default for streaming is usually non-blocking 263 | // } 264 | }; 265 | 266 | try { 267 | console.log(colorize("red", "Sending message...")); 268 | // Use sendMessageStream 269 | const stream = client.sendMessageStream(params); 270 | 271 | // Iterate over the events from the stream 272 | for await (const event of stream) { 273 | const timestamp = new Date().toLocaleTimeString(); // Get fresh timestamp for each event 274 | const prefix = colorize("magenta", `\n${agentName} [${timestamp}]:`); 275 | 276 | if (event.kind === "status-update" || event.kind === "artifact-update") { 277 | const typedEvent = event as TaskStatusUpdateEvent | TaskArtifactUpdateEvent; 278 | printAgentEvent(typedEvent); 279 | 280 | // If the event is a TaskStatusUpdateEvent and it's final, reset currentTaskId 281 | if (typedEvent.kind === "status-update" && (typedEvent as TaskStatusUpdateEvent).final && (typedEvent as TaskStatusUpdateEvent).status.state !== "input-required") { 282 | console.log(colorize("yellow", ` Task ${typedEvent.taskId} is final. Clearing current task ID.`)); 283 | currentTaskId = undefined; 284 | // Optionally, you might want to clear currentContextId as well if a task ending implies context ending. 285 | // currentContextId = undefined; 286 | // console.log(colorize("dim", ` Context ID also cleared as task is final.`)); 287 | } 288 | 289 | } else if (event.kind === "message") { 290 | const msg = event as Message; 291 | console.log(`${prefix} ${colorize("green", "✉️ Message Stream Event:")}`); 292 | printMessageContent(msg); 293 | if (msg.taskId && msg.taskId !== currentTaskId) { 294 | console.log(colorize("dim", ` Task ID context updated to ${msg.taskId} based on message event.`)); 295 | currentTaskId = msg.taskId; 296 | } 297 | if (msg.contextId && msg.contextId !== currentContextId) { 298 | console.log(colorize("dim", ` Context ID updated to ${msg.contextId} based on message event.`)); 299 | currentContextId = msg.contextId; 300 | } 301 | } else if (event.kind === "task") { 302 | const task = event as Task; 303 | console.log(`${prefix} ${colorize("blue", "ℹ️ Task Stream Event:")} ID: ${task.id}, Context: ${task.contextId}, Status: ${task.status.state}`); 304 | if (task.id !== currentTaskId) { 305 | console.log(colorize("dim", ` Task ID updated from ${currentTaskId || 'N/A'} to ${task.id}`)); 306 | currentTaskId = task.id; 307 | } 308 | if (task.contextId && task.contextId !== currentContextId) { 309 | console.log(colorize("dim", ` Context ID updated from ${currentContextId || 'N/A'} to ${task.contextId}`)); 310 | currentContextId = task.contextId; 311 | } 312 | if (task.status.message) { 313 | console.log(colorize("gray", " Task includes message:")); 314 | printMessageContent(task.status.message); 315 | } 316 | if (task.artifacts && task.artifacts.length > 0) { 317 | console.log(colorize("gray", ` Task includes ${task.artifacts.length} artifact(s).`)); 318 | } 319 | } else { 320 | console.log(prefix, colorize("yellow", "Received unknown event structure from stream:"), event); 321 | } 322 | } 323 | console.log(colorize("dim", `--- End of response stream for this input ---`)); 324 | } catch (error: any) { 325 | const timestamp = new Date().toLocaleTimeString(); 326 | const prefix = colorize("red", `\n${agentName} [${timestamp}] ERROR:`); 327 | console.error( 328 | prefix, 329 | `Error communicating with agent:`, 330 | error.message || error 331 | ); 332 | if (error.code) { 333 | console.error(colorize("gray", ` Code: ${error.code}`)); 334 | } 335 | if (error.data) { 336 | console.error( 337 | colorize("gray", ` Data: ${JSON.stringify(error.data)}`) 338 | ); 339 | } 340 | if (!(error.code || error.data) && error.stack) { 341 | console.error(colorize("gray", error.stack.split('\n').slice(1, 3).join('\n'))); 342 | } 343 | } finally { 344 | rl.prompt(); 345 | } 346 | }).on("close", () => { 347 | console.log(colorize("yellow", "\nExiting A2A Terminal Client. Goodbye!")); 348 | process.exit(0); 349 | }); 350 | } 351 | 352 | // --- Start --- 353 | main().catch(err => { 354 | console.error(colorize("red", "Unhandled error in main:"), err); 355 | process.exit(1); 356 | }); 357 | -------------------------------------------------------------------------------- /src/server/a2a_express_app.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, Express } from 'express'; 2 | 3 | import { A2AError } from "./error.js"; 4 | import { A2AResponse, JSONRPCErrorResponse, JSONRPCSuccessResponse } from "../index.js"; 5 | import { A2ARequestHandler } from "./request_handler/a2a_request_handler.js"; 6 | import { JsonRpcTransportHandler } from "./transports/jsonrpc_transport_handler.js"; 7 | 8 | export class A2AExpressApp { 9 | private requestHandler: A2ARequestHandler; // Kept for getAgentCard 10 | private jsonRpcTransportHandler: JsonRpcTransportHandler; 11 | 12 | constructor(requestHandler: A2ARequestHandler) { 13 | this.requestHandler = requestHandler; // DefaultRequestHandler instance 14 | this.jsonRpcTransportHandler = new JsonRpcTransportHandler(requestHandler); 15 | } 16 | 17 | /** 18 | * Adds A2A routes to an existing Express app. 19 | * @param app Optional existing Express app. 20 | * @param baseUrl The base URL for A2A endpoints (e.g., "/a2a/api"). 21 | * @returns The Express app with A2A routes. 22 | */ 23 | public setupRoutes(app: Express, baseUrl: string = ''): Express { 24 | app.use(express.json()); 25 | 26 | app.get(`${baseUrl}/.well-known/agent.json`, async (req: Request, res: Response) => { 27 | try { 28 | // getAgentCard is on A2ARequestHandler, which DefaultRequestHandler implements 29 | const agentCard = await this.requestHandler.getAgentCard(); 30 | res.json(agentCard); 31 | } catch (error: any) { 32 | console.error("Error fetching agent card:", error); 33 | res.status(500).json({ error: "Failed to retrieve agent card" }); 34 | } 35 | }); 36 | 37 | app.post(baseUrl, async (req: Request, res: Response) => { 38 | try { 39 | const rpcResponseOrStream = await this.jsonRpcTransportHandler.handle(req.body); 40 | 41 | // Check if it's an AsyncGenerator (stream) 42 | if (typeof (rpcResponseOrStream as any)?.[Symbol.asyncIterator] === 'function') { 43 | const stream = rpcResponseOrStream as AsyncGenerator; 44 | 45 | res.setHeader('Content-Type', 'text/event-stream'); 46 | res.setHeader('Cache-Control', 'no-cache'); 47 | res.setHeader('Connection', 'keep-alive'); 48 | res.flushHeaders(); 49 | 50 | try { 51 | for await (const event of stream) { 52 | // Each event from the stream is already a JSONRPCResult 53 | res.write(`id: ${new Date().getTime()}\n`); 54 | res.write(`data: ${JSON.stringify(event)}\n\n`); 55 | } 56 | } catch (streamError: any) { 57 | console.error(`Error during SSE streaming (request ${req.body?.id}):`, streamError); 58 | // If the stream itself throws an error, send a final JSONRPCErrorResponse 59 | const a2aError = streamError instanceof A2AError ? streamError : A2AError.internalError(streamError.message || 'Streaming error.'); 60 | const errorResponse: JSONRPCErrorResponse = { 61 | jsonrpc: '2.0', 62 | id: req.body?.id || null, // Use original request ID if available 63 | error: a2aError.toJSONRPCError(), 64 | }; 65 | if (!res.headersSent) { // Should not happen if flushHeaders worked 66 | res.status(500).json(errorResponse); // Should be JSON, not SSE here 67 | } else { 68 | // Try to send as last SSE event if possible, though client might have disconnected 69 | res.write(`id: ${new Date().getTime()}\n`); 70 | res.write(`event: error\n`); // Custom event type for client-side handling 71 | res.write(`data: ${JSON.stringify(errorResponse)}\n\n`); 72 | } 73 | } finally { 74 | if (!res.writableEnded) { 75 | res.end(); 76 | } 77 | } 78 | } else { // Single JSON-RPC response 79 | const rpcResponse = rpcResponseOrStream as A2AResponse; 80 | res.status(200).json(rpcResponse); 81 | } 82 | } catch (error: any) { // Catch errors from jsonRpcTransportHandler.handle itself (e.g., initial parse error) 83 | console.error("Unhandled error in A2AExpressApp POST handler:", error); 84 | const a2aError = error instanceof A2AError ? error : A2AError.internalError('General processing error.'); 85 | const errorResponse: JSONRPCErrorResponse = { 86 | jsonrpc: '2.0', 87 | id: req.body?.id || null, 88 | error: a2aError.toJSONRPCError(), 89 | }; 90 | if (!res.headersSent) { 91 | res.status(500).json(errorResponse); 92 | } else if (!res.writableEnded) { 93 | // If headers sent (likely during a stream attempt that failed early), try to end gracefully 94 | res.end(); 95 | } 96 | } 97 | }); 98 | // The separate /stream endpoint is no longer needed. 99 | return app; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/server/agent_execution/agent_executor.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionEventBus } from "../events/execution_event_bus.js"; 2 | import { RequestContext } from "./request_context.js"; 3 | 4 | export interface AgentExecutor { 5 | /** 6 | * Executes the agent logic based on the request context and publishes events. 7 | * @param requestContext The context of the current request. 8 | * @param eventBus The bus to publish execution events to. 9 | */ 10 | execute: ( 11 | requestContext: RequestContext, 12 | eventBus: ExecutionEventBus 13 | ) => Promise; 14 | 15 | /** 16 | * Method to explicitly cancel a running task. 17 | * The implementation should handle the logic of stopping the execution 18 | * and publishing the final 'canceled' status event on the provided event bus. 19 | * @param taskId The ID of the task to cancel. 20 | * @param eventBus The event bus associated with the task's execution. 21 | */ 22 | cancelTask: ( 23 | taskId: string, 24 | eventBus: ExecutionEventBus 25 | ) => Promise; 26 | } 27 | -------------------------------------------------------------------------------- /src/server/agent_execution/request_context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Message, 3 | Task, 4 | } from "../../types.js"; 5 | 6 | export class RequestContext { 7 | public readonly userMessage: Message; 8 | public readonly task?: Task; 9 | public readonly referenceTasks?: Task[]; 10 | public readonly taskId: string; 11 | public readonly contextId: string; 12 | 13 | constructor( 14 | userMessage: Message, 15 | taskId: string, 16 | contextId: string, 17 | task?: Task, 18 | referenceTasks?: Task[], 19 | ) { 20 | this.userMessage = userMessage; 21 | this.taskId = taskId; 22 | this.contextId = contextId; 23 | this.task = task; 24 | this.referenceTasks = referenceTasks; 25 | } 26 | } -------------------------------------------------------------------------------- /src/server/error.ts: -------------------------------------------------------------------------------- 1 | import * as schema from "../types.js"; 2 | 3 | /** 4 | * Custom error class for A2A server operations, incorporating JSON-RPC error codes. 5 | */ 6 | export class A2AError extends Error { 7 | public code: number; 8 | public data?: Record; 9 | public taskId?: string; // Optional task ID context 10 | 11 | constructor( 12 | code: number, 13 | message: string, 14 | data?: Record, 15 | taskId?: string 16 | ) { 17 | super(message); 18 | this.name = "A2AError"; 19 | this.code = code; 20 | this.data = data; 21 | this.taskId = taskId; // Store associated task ID if provided 22 | } 23 | 24 | /** 25 | * Formats the error into a standard JSON-RPC error object structure. 26 | */ 27 | toJSONRPCError(): schema.JSONRPCError { 28 | const errorObject: schema.JSONRPCError = { 29 | code: this.code, 30 | message: this.message, 31 | }; 32 | 33 | if(this.data !== undefined) { 34 | errorObject.data = this.data; 35 | } 36 | 37 | return errorObject; 38 | } 39 | 40 | // Static factory methods for common errors 41 | 42 | static parseError(message: string, data?: Record): A2AError { 43 | return new A2AError(-32700, message, data); 44 | } 45 | 46 | static invalidRequest(message: string, data?: Record): A2AError { 47 | return new A2AError(-32600, message, data); 48 | } 49 | 50 | static methodNotFound(method: string): A2AError { 51 | return new A2AError( 52 | -32601, 53 | `Method not found: ${method}` 54 | ); 55 | } 56 | 57 | static invalidParams(message: string, data?: Record): A2AError { 58 | return new A2AError(-32602, message, data); 59 | } 60 | 61 | static internalError(message: string, data?: Record): A2AError { 62 | return new A2AError(-32603, message, data); 63 | } 64 | 65 | static taskNotFound(taskId: string): A2AError { 66 | return new A2AError( 67 | -32001, 68 | `Task not found: ${taskId}`, 69 | undefined, 70 | taskId 71 | ); 72 | } 73 | 74 | static taskNotCancelable(taskId: string): A2AError { 75 | return new A2AError( 76 | -32002, 77 | `Task not cancelable: ${taskId}`, 78 | undefined, 79 | taskId 80 | ); 81 | } 82 | 83 | static pushNotificationNotSupported(): A2AError { 84 | return new A2AError( 85 | -32003, 86 | "Push Notification is not supported" 87 | ); 88 | } 89 | 90 | static unsupportedOperation(operation: string): A2AError { 91 | return new A2AError( 92 | -32004, 93 | `Unsupported operation: ${operation}` 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/server/events/execution_event_bus.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { 4 | Message, 5 | Task, 6 | TaskStatusUpdateEvent, 7 | TaskArtifactUpdateEvent, 8 | } from "../../types.js"; 9 | 10 | export type AgentExecutionEvent = 11 | | Message 12 | | Task 13 | | TaskStatusUpdateEvent 14 | | TaskArtifactUpdateEvent; 15 | 16 | export interface ExecutionEventBus { 17 | publish(event: AgentExecutionEvent): void; 18 | on(eventName: 'event' | 'finished', listener: (event: AgentExecutionEvent) => void): this; 19 | off(eventName: 'event' | 'finished', listener: (event: AgentExecutionEvent) => void): this; 20 | once(eventName: 'event' | 'finished', listener: (event: AgentExecutionEvent) => void): this; 21 | removeAllListeners(eventName?: 'event' | 'finished'): this; 22 | finished(): void; 23 | } 24 | 25 | export class DefaultExecutionEventBus extends EventEmitter implements ExecutionEventBus { 26 | constructor() { 27 | super(); 28 | } 29 | 30 | publish(event: AgentExecutionEvent): void { 31 | this.emit('event', event); 32 | } 33 | 34 | finished(): void { 35 | this.emit('finished'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/server/events/execution_event_bus_manager.ts: -------------------------------------------------------------------------------- 1 | import { DefaultExecutionEventBus, ExecutionEventBus } from "./execution_event_bus.js"; 2 | 3 | export interface ExecutionEventBusManager { 4 | createOrGetByTaskId(taskId: string): ExecutionEventBus; 5 | getByTaskId(taskId: string): ExecutionEventBus | undefined; 6 | cleanupByTaskId(taskId: string): void; 7 | } 8 | 9 | export class DefaultExecutionEventBusManager implements ExecutionEventBusManager { 10 | private taskIdToBus: Map = new Map(); 11 | 12 | /** 13 | * Creates or retrieves an existing ExecutionEventBus based on the taskId. 14 | * @param taskId The ID of the task. 15 | * @returns An instance of IExecutionEventBus. 16 | */ 17 | public createOrGetByTaskId(taskId: string): ExecutionEventBus { 18 | if (!this.taskIdToBus.has(taskId)) { 19 | this.taskIdToBus.set(taskId, new DefaultExecutionEventBus()); 20 | } 21 | return this.taskIdToBus.get(taskId)!; 22 | } 23 | 24 | /** 25 | * Retrieves an existing ExecutionEventBus based on the taskId. 26 | * @param taskId The ID of the task. 27 | * @returns An instance of IExecutionEventBus or undefined if not found. 28 | */ 29 | public getByTaskId(taskId: string): ExecutionEventBus | undefined { 30 | return this.taskIdToBus.get(taskId); 31 | } 32 | 33 | /** 34 | * Removes the event bus for a given taskId. 35 | * This should be called when an execution flow is complete to free resources. 36 | * @param taskId The ID of the task. 37 | */ 38 | public cleanupByTaskId(taskId: string): void { 39 | const bus = this.taskIdToBus.get(taskId); 40 | if (bus) { 41 | bus.removeAllListeners(); 42 | } 43 | this.taskIdToBus.delete(taskId); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/server/events/execution_event_queue.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TaskStatusUpdateEvent, 3 | } from "../../types.js"; 4 | import { ExecutionEventBus, AgentExecutionEvent } from "./execution_event_bus.js"; 5 | 6 | /** 7 | * An async queue that subscribes to an ExecutionEventBus for events 8 | * and provides an async generator to consume them. 9 | */ 10 | export class ExecutionEventQueue { 11 | private eventBus: ExecutionEventBus; 12 | private eventQueue: AgentExecutionEvent[] = []; 13 | private resolvePromise?: (value: void | PromiseLike) => void; 14 | private stopped: boolean = false; 15 | private boundHandleEvent: (event: AgentExecutionEvent) => void; 16 | 17 | constructor(eventBus: ExecutionEventBus) { 18 | this.eventBus = eventBus; 19 | this.eventBus.on('event', this.handleEvent); 20 | this.eventBus.on('finished', this.handleFinished); 21 | } 22 | 23 | private handleEvent = (event: AgentExecutionEvent) => { 24 | if (this.stopped) return; 25 | this.eventQueue.push(event); 26 | if (this.resolvePromise) { 27 | this.resolvePromise(); 28 | this.resolvePromise = undefined; 29 | } 30 | } 31 | 32 | private handleFinished = () => { 33 | this.stop(); 34 | } 35 | 36 | /** 37 | * Provides an async generator that yields events from the event bus. 38 | * Stops when a Message event is received or a TaskStatusUpdateEvent with final=true is received. 39 | */ 40 | public async *events(): AsyncGenerator { 41 | while (!this.stopped || this.eventQueue.length > 0) { 42 | if (this.eventQueue.length > 0) { 43 | const event = this.eventQueue.shift()!; 44 | yield event; 45 | if (event.kind === 'message' || ( 46 | event.kind === 'status-update' && 47 | (event as TaskStatusUpdateEvent).final 48 | )) { 49 | this.handleFinished(); 50 | break; 51 | } 52 | } else if(!this.stopped) { 53 | await new Promise((resolve) => { 54 | this.resolvePromise = resolve; 55 | }); 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * Stops the event queue from processing further events. 62 | */ 63 | public stop(): void { 64 | this.stopped = true; 65 | if (this.resolvePromise) { 66 | this.resolvePromise(); // Unblock any pending await 67 | this.resolvePromise = undefined; 68 | } 69 | 70 | this.eventBus.off('event', this.handleEvent); 71 | this.eventBus.off('finished', this.handleFinished); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/server/request_handler/a2a_request_handler.ts: -------------------------------------------------------------------------------- 1 | import { Message, AgentCard, MessageSendParams, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TaskQueryParams, TaskIdParams, TaskPushNotificationConfig } from "../../types.js"; 2 | 3 | export interface A2ARequestHandler { 4 | getAgentCard(): Promise; 5 | 6 | sendMessage( 7 | params: MessageSendParams 8 | ): Promise; 9 | 10 | sendMessageStream( 11 | params: MessageSendParams 12 | ): AsyncGenerator< 13 | | Message 14 | | Task 15 | | TaskStatusUpdateEvent 16 | | TaskArtifactUpdateEvent, 17 | void, 18 | undefined 19 | >; 20 | 21 | getTask(params: TaskQueryParams): Promise; 22 | cancelTask(params: TaskIdParams): Promise; 23 | 24 | setTaskPushNotificationConfig( 25 | params: TaskPushNotificationConfig 26 | ): Promise; 27 | 28 | getTaskPushNotificationConfig( 29 | params: TaskIdParams 30 | ): Promise; 31 | 32 | resubscribe( 33 | params: TaskIdParams 34 | ): AsyncGenerator< 35 | | Task 36 | | TaskStatusUpdateEvent 37 | | TaskArtifactUpdateEvent, 38 | void, 39 | undefined 40 | >; 41 | } -------------------------------------------------------------------------------- /src/server/request_handler/default_request_handler.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; // For generating unique IDs 2 | 3 | import { Message, AgentCard, PushNotificationConfig, Task, MessageSendParams, TaskState, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TaskQueryParams, TaskIdParams, TaskPushNotificationConfig } from "../../types.js"; 4 | import { AgentExecutor } from "../agent_execution/agent_executor.js"; 5 | import { RequestContext } from "../agent_execution/request_context.js"; 6 | import { A2AError } from "../error.js"; 7 | import { ExecutionEventBusManager, DefaultExecutionEventBusManager } from "../events/execution_event_bus_manager.js"; 8 | import { ExecutionEventBus } from "../events/execution_event_bus.js"; 9 | import { ExecutionEventQueue } from "../events/execution_event_queue.js"; 10 | import { ResultManager } from "../result_manager.js"; 11 | import { TaskStore } from "../store.js"; 12 | import { A2ARequestHandler } from "./a2a_request_handler.js"; 13 | 14 | const terminalStates: TaskState[] = ["completed", "failed", "canceled", "rejected"]; 15 | 16 | export class DefaultRequestHandler implements A2ARequestHandler { 17 | private readonly agentCard: AgentCard; 18 | private readonly taskStore: TaskStore; 19 | private readonly agentExecutor: AgentExecutor; 20 | private readonly eventBusManager: ExecutionEventBusManager; 21 | // Store for push notification configurations (could be part of TaskStore or separate) 22 | private readonly pushNotificationConfigs: Map = new Map(); 23 | 24 | 25 | constructor( 26 | agentCard: AgentCard, 27 | taskStore: TaskStore, 28 | agentExecutor: AgentExecutor, 29 | eventBusManager: ExecutionEventBusManager = new DefaultExecutionEventBusManager(), 30 | ) { 31 | this.agentCard = agentCard; 32 | this.taskStore = taskStore; 33 | this.agentExecutor = agentExecutor; 34 | this.eventBusManager = eventBusManager; 35 | } 36 | 37 | async getAgentCard(): Promise { 38 | return this.agentCard; 39 | } 40 | 41 | private async _createRequestContext( 42 | incomingMessage: Message, 43 | taskId: string, 44 | isStream: boolean, 45 | ): Promise { 46 | let task: Task | undefined; 47 | let referenceTasks: Task[] | undefined; 48 | 49 | // incomingMessage would contain taskId, if a task already exists. 50 | if (incomingMessage.taskId) { 51 | task = await this.taskStore.load(incomingMessage.taskId); 52 | if (!task) { 53 | throw A2AError.taskNotFound(incomingMessage.taskId); 54 | } 55 | 56 | if (terminalStates.includes(task.status.state)) { 57 | // Throw an error that conforms to the JSON-RPC Invalid Request error specification. 58 | throw A2AError.invalidRequest(`Task ${task.id} is in a terminal state (${task.status.state}) and cannot be modified.`) 59 | } 60 | } 61 | 62 | if (incomingMessage.referenceTaskIds && incomingMessage.referenceTaskIds.length > 0) { 63 | referenceTasks = []; 64 | for (const refId of incomingMessage.referenceTaskIds) { 65 | const refTask = await this.taskStore.load(refId); 66 | if (refTask) { 67 | referenceTasks.push(refTask); 68 | } else { 69 | console.warn(`Reference task ${refId} not found.`); 70 | // Optionally, throw an error or handle as per specific requirements 71 | } 72 | } 73 | } 74 | 75 | // Ensure contextId is present 76 | const messageForContext = { ...incomingMessage }; 77 | if (!messageForContext.contextId) { 78 | messageForContext.contextId = task?.contextId || uuidv4(); 79 | } 80 | 81 | const contextId = incomingMessage.contextId || uuidv4(); 82 | 83 | return new RequestContext( 84 | messageForContext, 85 | taskId, 86 | contextId, 87 | task, 88 | referenceTasks 89 | ); 90 | } 91 | 92 | private async _processEvents( 93 | taskId: string, 94 | resultManager: ResultManager, 95 | eventQueue: ExecutionEventQueue, 96 | options?: { 97 | firstResultResolver?: (value: Message | Task | PromiseLike) => void; 98 | firstResultRejector?: (reason?: any) => void; 99 | } 100 | ): Promise { 101 | let firstResultSent = false; 102 | try { 103 | for await (const event of eventQueue.events()) { 104 | await resultManager.processEvent(event); 105 | 106 | if (options?.firstResultResolver && !firstResultSent) { 107 | if (event.kind === 'message' || event.kind === 'task') { 108 | options.firstResultResolver(event as Message | Task); 109 | firstResultSent = true; 110 | } 111 | } 112 | } 113 | if (options?.firstResultRejector && !firstResultSent) { 114 | options.firstResultRejector(A2AError.internalError('Execution finished before a message or task was produced.')); 115 | } 116 | } catch (error) { 117 | console.error(`Event processing loop failed for task ${taskId}:`, error); 118 | if (options?.firstResultRejector && !firstResultSent) { 119 | options.firstResultRejector(error); 120 | } 121 | // re-throw error for blocking case to catch 122 | throw error; 123 | } finally { 124 | this.eventBusManager.cleanupByTaskId(taskId); 125 | } 126 | } 127 | 128 | async sendMessage( 129 | params: MessageSendParams 130 | ): Promise { 131 | const incomingMessage = params.message; 132 | if (!incomingMessage.messageId) { 133 | throw A2AError.invalidParams('message.messageId is required.'); 134 | } 135 | 136 | // Default to blocking behavior if 'blocking' is not explicitly false. 137 | const isBlocking = params.configuration?.blocking !== false; 138 | const taskId = incomingMessage.taskId || uuidv4(); 139 | 140 | // Instantiate ResultManager before creating RequestContext 141 | const resultManager = new ResultManager(this.taskStore); 142 | resultManager.setContext(incomingMessage); // Set context for ResultManager 143 | 144 | const requestContext = await this._createRequestContext(incomingMessage, taskId, false); 145 | // Use the (potentially updated) contextId from requestContext 146 | const finalMessageForAgent = requestContext.userMessage; 147 | 148 | 149 | const eventBus = this.eventBusManager.createOrGetByTaskId(taskId); 150 | // EventQueue should be attached to the bus, before the agent execution begins. 151 | const eventQueue = new ExecutionEventQueue(eventBus); 152 | 153 | // Start agent execution (non-blocking). 154 | // It runs in the background and publishes events to the eventBus. 155 | this.agentExecutor.execute(requestContext, eventBus).catch(err => { 156 | console.error(`Agent execution failed for message ${finalMessageForAgent.messageId}:`, err); 157 | // Publish a synthetic error event, which will be handled by the ResultManager 158 | // and will also settle the firstResultPromise for non-blocking calls. 159 | const errorTask: Task = { 160 | id: requestContext.task?.id || uuidv4(), // Use existing task ID or generate new 161 | contextId: finalMessageForAgent.contextId!, 162 | status: { 163 | state: "failed", 164 | message: { 165 | kind: "message", 166 | role: "agent", 167 | messageId: uuidv4(), 168 | parts: [{ kind: "text", text: `Agent execution error: ${err.message}` }], 169 | taskId: requestContext.task?.id, 170 | contextId: finalMessageForAgent.contextId!, 171 | }, 172 | timestamp: new Date().toISOString(), 173 | }, 174 | history: requestContext.task?.history ? [...requestContext.task.history] : [], 175 | kind: "task", 176 | }; 177 | if (finalMessageForAgent) { // Add incoming message to history 178 | if (!errorTask.history?.find(m => m.messageId === finalMessageForAgent.messageId)) { 179 | errorTask.history?.push(finalMessageForAgent); 180 | } 181 | } 182 | eventBus.publish(errorTask); 183 | eventBus.publish({ // And publish a final status update 184 | kind: "status-update", 185 | taskId: errorTask.id, 186 | contextId: errorTask.contextId, 187 | status: errorTask.status, 188 | final: true, 189 | } as TaskStatusUpdateEvent); 190 | eventBus.finished(); 191 | }); 192 | 193 | if (isBlocking) { 194 | // In blocking mode, wait for the full processing to complete. 195 | await this._processEvents(taskId, resultManager, eventQueue); 196 | const finalResult = resultManager.getFinalResult(); 197 | if (!finalResult) { 198 | throw A2AError.internalError('Agent execution finished without a result, and no task context found.'); 199 | } 200 | 201 | return finalResult; 202 | } else { 203 | // In non-blocking mode, return a promise that will be settled by fullProcessing. 204 | return new Promise((resolve, reject) => { 205 | this._processEvents(taskId, resultManager, eventQueue, { 206 | firstResultResolver: resolve, 207 | firstResultRejector: reject, 208 | }); 209 | }); 210 | } 211 | } 212 | 213 | async *sendMessageStream( 214 | params: MessageSendParams 215 | ): AsyncGenerator< 216 | | Message 217 | | Task 218 | | TaskStatusUpdateEvent 219 | | TaskArtifactUpdateEvent, 220 | void, 221 | undefined 222 | > { 223 | const incomingMessage = params.message; 224 | if (!incomingMessage.messageId) { 225 | // For streams, messageId might be set by client, or server can generate if not present. 226 | // Let's assume client provides it or throw for now. 227 | throw A2AError.invalidParams('message.messageId is required for streaming.'); 228 | } 229 | 230 | const taskId = incomingMessage.taskId || uuidv4(); 231 | 232 | // Instantiate ResultManager before creating RequestContext 233 | const resultManager = new ResultManager(this.taskStore); 234 | resultManager.setContext(incomingMessage); // Set context for ResultManager 235 | 236 | const requestContext = await this._createRequestContext(incomingMessage, taskId, true); 237 | const finalMessageForAgent = requestContext.userMessage; 238 | 239 | const eventBus = this.eventBusManager.createOrGetByTaskId(taskId); 240 | const eventQueue = new ExecutionEventQueue(eventBus); 241 | 242 | 243 | // Start agent execution (non-blocking) 244 | this.agentExecutor.execute(requestContext, eventBus).catch(err => { 245 | console.error(`Agent execution failed for stream message ${finalMessageForAgent.messageId}:`, err); 246 | // Publish a synthetic error event if needed 247 | const errorTaskStatus: TaskStatusUpdateEvent = { 248 | kind: "status-update", 249 | taskId: requestContext.task?.id || uuidv4(), // Use existing or a placeholder 250 | contextId: finalMessageForAgent.contextId!, 251 | status: { 252 | state: "failed", 253 | message: { 254 | kind: "message", 255 | role: "agent", 256 | messageId: uuidv4(), 257 | parts: [{ kind: "text", text: `Agent execution error: ${err.message}` }], 258 | taskId: requestContext.task?.id, 259 | contextId: finalMessageForAgent.contextId!, 260 | }, 261 | timestamp: new Date().toISOString(), 262 | }, 263 | final: true, // This will terminate the stream for the client 264 | }; 265 | eventBus.publish(errorTaskStatus); 266 | }); 267 | 268 | try { 269 | for await (const event of eventQueue.events()) { 270 | await resultManager.processEvent(event); // Update store in background 271 | yield event; // Stream the event to the client 272 | } 273 | } finally { 274 | // Cleanup when the stream is fully consumed or breaks 275 | this.eventBusManager.cleanupByTaskId(taskId); 276 | } 277 | } 278 | 279 | async getTask(params: TaskQueryParams): Promise { 280 | const task = await this.taskStore.load(params.id); 281 | if (!task) { 282 | throw A2AError.taskNotFound(params.id); 283 | } 284 | if (params.historyLength !== undefined && params.historyLength >= 0) { 285 | if (task.history) { 286 | task.history = task.history.slice(-params.historyLength); 287 | } 288 | } else { 289 | // Negative or invalid historyLength means no history 290 | task.history = []; 291 | } 292 | return task; 293 | } 294 | 295 | async cancelTask(params: TaskIdParams): Promise { 296 | const task = await this.taskStore.load(params.id); 297 | if (!task) { 298 | throw A2AError.taskNotFound(params.id); 299 | } 300 | 301 | // Check if task is in a cancelable state 302 | const nonCancelableStates = ["completed", "failed", "canceled", "rejected"]; 303 | if (nonCancelableStates.includes(task.status.state)) { 304 | throw A2AError.taskNotCancelable(params.id); 305 | } 306 | 307 | const eventBus = this.eventBusManager.getByTaskId(params.id); 308 | 309 | if(eventBus) { 310 | await this.agentExecutor.cancelTask(params.id, eventBus); 311 | } 312 | else { 313 | // Here we are marking task as cancelled. We are not waiting for the executor to actually cancel processing. 314 | task.status = { 315 | state: "canceled", 316 | message: { // Optional: Add a system message indicating cancellation 317 | kind: "message", 318 | role: "agent", 319 | messageId: uuidv4(), 320 | parts: [{ kind: "text", text: "Task cancellation requested by user." }], 321 | taskId: task.id, 322 | contextId: task.contextId, 323 | }, 324 | timestamp: new Date().toISOString(), 325 | }; 326 | // Add cancellation message to history 327 | task.history = [...(task.history || []), task.status.message]; 328 | 329 | await this.taskStore.save(task); 330 | } 331 | 332 | const latestTask = await this.taskStore.load(params.id); 333 | return latestTask; 334 | } 335 | 336 | async setTaskPushNotificationConfig( 337 | params: TaskPushNotificationConfig 338 | ): Promise { 339 | if (!this.agentCard.capabilities.pushNotifications) { 340 | throw A2AError.pushNotificationNotSupported(); 341 | } 342 | const taskAndHistory = await this.taskStore.load(params.taskId); 343 | if (!taskAndHistory) { 344 | throw A2AError.taskNotFound(params.taskId); 345 | } 346 | // Store the config. In a real app, this might be stored in the TaskStore 347 | // or a dedicated push notification service. 348 | this.pushNotificationConfigs.set(params.taskId, params.pushNotificationConfig); 349 | return params; 350 | } 351 | 352 | async getTaskPushNotificationConfig( 353 | params: TaskIdParams 354 | ): Promise { 355 | if (!this.agentCard.capabilities.pushNotifications) { 356 | throw A2AError.pushNotificationNotSupported(); 357 | } 358 | const taskAndHistory = await this.taskStore.load(params.id); // Ensure task exists 359 | if (!taskAndHistory) { 360 | throw A2AError.taskNotFound(params.id); 361 | } 362 | const config = this.pushNotificationConfigs.get(params.id); 363 | if (!config) { 364 | throw A2AError.internalError(`Push notification config not found for task ${params.id}.`); 365 | } 366 | return { taskId: params.id, pushNotificationConfig: config }; 367 | } 368 | 369 | async *resubscribe( 370 | params: TaskIdParams 371 | ): AsyncGenerator< 372 | | Task // Initial task state 373 | | TaskStatusUpdateEvent 374 | | TaskArtifactUpdateEvent, 375 | void, 376 | undefined 377 | > { 378 | if (!this.agentCard.capabilities.streaming) { 379 | throw A2AError.unsupportedOperation("Streaming (and thus resubscription) is not supported."); 380 | } 381 | 382 | const task = await this.taskStore.load(params.id); 383 | if (!task) { 384 | throw A2AError.taskNotFound(params.id); 385 | } 386 | 387 | // Yield the current task state first 388 | yield task; 389 | 390 | // If task is already in a final state, no more events will come. 391 | const finalStates = ["completed", "failed", "canceled", "rejected"]; 392 | if (finalStates.includes(task.status.state)) { 393 | return; 394 | } 395 | 396 | const eventBus = this.eventBusManager.getByTaskId(params.id); 397 | if (!eventBus) { 398 | // No active execution for this task, so no live events. 399 | console.warn(`Resubscribe: No active event bus for task ${params.id}.`); 400 | return; 401 | } 402 | 403 | // Attach a new queue to the existing bus for this resubscription 404 | const eventQueue = new ExecutionEventQueue(eventBus); 405 | // Note: The ResultManager part is already handled by the original execution flow. 406 | // Resubscribe just listens for new events. 407 | 408 | try { 409 | for await (const event of eventQueue.events()) { 410 | // We only care about updates related to *this* task. 411 | // The event bus might be shared if messageId was reused, though 412 | // ExecutionEventBusManager tries to give one bus per original message. 413 | if (event.kind === 'status-update' && event.taskId === params.id) { 414 | yield event as TaskStatusUpdateEvent; 415 | } else if (event.kind === 'artifact-update' && event.taskId === params.id) { 416 | yield event as TaskArtifactUpdateEvent; 417 | } else if (event.kind === 'task' && event.id === params.id) { 418 | // This implies the task was re-emitted, yield it. 419 | yield event as Task; 420 | } 421 | // We don't yield 'message' events on resubscribe typically, 422 | // as those signal the end of an interaction for the *original* request. 423 | // If a 'message' event for the original request terminates the bus, this loop will also end. 424 | } 425 | } finally { 426 | eventQueue.stop(); 427 | } 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /src/server/result_manager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Message, 3 | Task, 4 | TaskArtifactUpdateEvent, 5 | TaskStatusUpdateEvent 6 | } from "../types.js"; 7 | import { AgentExecutionEvent } from "./events/execution_event_bus.js"; 8 | import { TaskStore } from "./store.js"; 9 | 10 | 11 | export class ResultManager { 12 | private taskStore: TaskStore; 13 | private currentTask?: Task; 14 | private latestUserMessage?: Message; // To add to history if a new task is created 15 | private finalMessageResult?: Message; // Stores the message if it's the final result 16 | 17 | constructor(taskStore: TaskStore) { 18 | this.taskStore = taskStore; 19 | } 20 | 21 | public setContext(latestUserMessage: Message): void { 22 | this.latestUserMessage = latestUserMessage; 23 | } 24 | 25 | /** 26 | * Processes an agent execution event and updates the task store. 27 | * @param event The agent execution event. 28 | */ 29 | public async processEvent(event: AgentExecutionEvent): Promise { 30 | if (event.kind === 'message') { 31 | this.finalMessageResult = event as Message; 32 | // If a message is received, it's usually the final result, 33 | // but we continue processing to ensure task state (if any) is also saved. 34 | // The ExecutionEventQueue will stop after a message event. 35 | } else if (event.kind === 'task') { 36 | const taskEvent = event as Task; 37 | this.currentTask = { ...taskEvent }; // Make a copy 38 | 39 | // Ensure the latest user message is in history if not already present 40 | if (this.latestUserMessage) { 41 | if (!this.currentTask.history?.find(msg => msg.messageId === this.latestUserMessage!.messageId)) { 42 | this.currentTask.history = [this.latestUserMessage, ...(this.currentTask.history || [])]; 43 | } 44 | } 45 | await this.saveCurrentTask(); 46 | } else if (event.kind === 'status-update') { 47 | const updateEvent = event as TaskStatusUpdateEvent; 48 | if (this.currentTask && this.currentTask.id === updateEvent.taskId) { 49 | this.currentTask.status = updateEvent.status; 50 | if (updateEvent.status.message) { 51 | // Add message to history if not already present 52 | if (!this.currentTask.history?.find(msg => msg.messageId === updateEvent.status.message!.messageId)) { 53 | this.currentTask.history = [...(this.currentTask.history || []), updateEvent.status.message]; 54 | } 55 | } 56 | await this.saveCurrentTask(); 57 | } else if (!this.currentTask && updateEvent.taskId) { 58 | // Potentially an update for a task we haven't seen the 'task' event for yet, 59 | // or we are rehydrating. Attempt to load. 60 | const loaded = await this.taskStore.load(updateEvent.taskId); 61 | if (loaded) { 62 | this.currentTask = loaded; 63 | this.currentTask.status = updateEvent.status; 64 | if (updateEvent.status.message) { 65 | if (!this.currentTask.history?.find(msg => msg.messageId === updateEvent.status.message!.messageId)) { 66 | this.currentTask.history = [...(this.currentTask.history || []), updateEvent.status.message]; 67 | } 68 | } 69 | await this.saveCurrentTask(); 70 | } else { 71 | console.warn(`ResultManager: Received status update for unknown task ${updateEvent.taskId}`); 72 | } 73 | } 74 | // If it's a final status update, the ExecutionEventQueue will stop. 75 | // The final result will be the currentTask. 76 | } else if (event.kind === 'artifact-update') { 77 | const artifactEvent = event as TaskArtifactUpdateEvent; 78 | if (this.currentTask && this.currentTask.id === artifactEvent.taskId) { 79 | if (!this.currentTask.artifacts) { 80 | this.currentTask.artifacts = []; 81 | } 82 | const existingArtifactIndex = this.currentTask.artifacts.findIndex( 83 | (art) => art.artifactId === artifactEvent.artifact.artifactId 84 | ); 85 | if (existingArtifactIndex !== -1) { 86 | if (artifactEvent.append) { 87 | // Basic append logic, assuming parts are compatible 88 | // More sophisticated merging might be needed for specific part types 89 | const existingArtifact = this.currentTask.artifacts[existingArtifactIndex]; 90 | existingArtifact.parts.push(...artifactEvent.artifact.parts); 91 | if (artifactEvent.artifact.description) existingArtifact.description = artifactEvent.artifact.description; 92 | if (artifactEvent.artifact.name) existingArtifact.name = artifactEvent.artifact.name; 93 | if (artifactEvent.artifact.metadata) existingArtifact.metadata = { ...existingArtifact.metadata, ...artifactEvent.artifact.metadata }; 94 | 95 | } else { 96 | this.currentTask.artifacts[existingArtifactIndex] = artifactEvent.artifact; 97 | } 98 | } else { 99 | this.currentTask.artifacts.push(artifactEvent.artifact); 100 | } 101 | await this.saveCurrentTask(); 102 | } else if (!this.currentTask && artifactEvent.taskId) { 103 | // Similar to status update, try to load if task not in memory 104 | const loaded = await this.taskStore.load(artifactEvent.taskId); 105 | if (loaded) { 106 | this.currentTask = loaded; 107 | if (!this.currentTask.artifacts) this.currentTask.artifacts = []; 108 | // Apply artifact update logic (as above) 109 | const existingArtifactIndex = this.currentTask.artifacts.findIndex( 110 | (art) => art.artifactId === artifactEvent.artifact.artifactId 111 | ); 112 | if (existingArtifactIndex !== -1) { 113 | if (artifactEvent.append) { 114 | this.currentTask.artifacts[existingArtifactIndex].parts.push(...artifactEvent.artifact.parts); 115 | } else { 116 | this.currentTask.artifacts[existingArtifactIndex] = artifactEvent.artifact; 117 | } 118 | } else { 119 | this.currentTask.artifacts.push(artifactEvent.artifact); 120 | } 121 | await this.saveCurrentTask(); 122 | } else { 123 | console.warn(`ResultManager: Received artifact update for unknown task ${artifactEvent.taskId}`); 124 | } 125 | } 126 | } 127 | } 128 | 129 | private async saveCurrentTask(): Promise { 130 | if (this.currentTask) { 131 | await this.taskStore.save(this.currentTask); 132 | } 133 | } 134 | 135 | /** 136 | * Gets the final result, which could be a Message or a Task. 137 | * This should be called after the event stream has been fully processed. 138 | * @returns The final Message or the current Task. 139 | */ 140 | public getFinalResult(): Message | Task | undefined { 141 | if (this.finalMessageResult) { 142 | return this.finalMessageResult; 143 | } 144 | return this.currentTask; 145 | } 146 | 147 | /** 148 | * Gets the task currently being managed by this ResultManager instance. 149 | * This task could be one that was started with or one created during agent execution. 150 | * @returns The current Task or undefined if no task is active. 151 | */ 152 | public getCurrentTask(): Task | undefined { 153 | return this.currentTask; 154 | } 155 | } -------------------------------------------------------------------------------- /src/server/store.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import path from "path"; 3 | import {Task} from "../types.js"; 4 | import { A2AError } from "./error.js"; 5 | import { 6 | getCurrentTimestamp, 7 | isArtifactUpdate, 8 | isTaskStatusUpdate, 9 | } from "./utils.js"; 10 | 11 | /** 12 | * Simplified interface for task storage providers. 13 | * Stores and retrieves the task. 14 | */ 15 | export interface TaskStore { 16 | /** 17 | * Saves a task. 18 | * Overwrites existing data if the task ID exists. 19 | * @param data An object containing the task. 20 | * @returns A promise resolving when the save operation is complete. 21 | */ 22 | save(task: Task): Promise; 23 | 24 | /** 25 | * Loads a task by task ID. 26 | * @param taskId The ID of the task to load. 27 | * @returns A promise resolving to an object containing the Task, or undefined if not found. 28 | */ 29 | load(taskId: string): Promise; 30 | } 31 | 32 | // ======================== 33 | // InMemoryTaskStore 34 | // ======================== 35 | 36 | // Use Task directly for storage 37 | export class InMemoryTaskStore implements TaskStore { 38 | private store: Map = new Map(); 39 | 40 | async load(taskId: string): Promise { 41 | const entry = this.store.get(taskId); 42 | // Return copies to prevent external mutation 43 | return entry ? {...entry} : undefined; 44 | } 45 | 46 | async save(task: Task): Promise { 47 | // Store copies to prevent internal mutation if caller reuses objects 48 | this.store.set(task.id, {...task}); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/server/transports/jsonrpc_transport_handler.ts: -------------------------------------------------------------------------------- 1 | import { A2AResponse } from "../../a2a_response.js"; 2 | import { JSONRPCRequest, JSONRPCErrorResponse, MessageSendParams, TaskQueryParams, TaskIdParams, TaskPushNotificationConfig, JSONRPCSuccessResponse, SendStreamingMessageSuccessResponse, A2ARequest } from "../../types.js"; 3 | import { A2AError } from "../error.js"; 4 | import { A2ARequestHandler } from "../request_handler/a2a_request_handler.js"; 5 | 6 | /** 7 | * Handles JSON-RPC transport layer, routing requests to A2ARequestHandler. 8 | */ 9 | export class JsonRpcTransportHandler { 10 | private requestHandler: A2ARequestHandler; 11 | 12 | constructor(requestHandler: A2ARequestHandler) { 13 | this.requestHandler = requestHandler; 14 | } 15 | 16 | /** 17 | * Handles an incoming JSON-RPC request. 18 | * For streaming methods, it returns an AsyncGenerator of JSONRPCResult. 19 | * For non-streaming methods, it returns a Promise of a single JSONRPCMessage (Result or ErrorResponse). 20 | */ 21 | public async handle( 22 | requestBody: any 23 | ): Promise> { 24 | let rpcRequest: A2ARequest; 25 | 26 | try { 27 | if (typeof requestBody === 'string') { 28 | rpcRequest = JSON.parse(requestBody); 29 | } else if (typeof requestBody === 'object' && requestBody !== null) { 30 | rpcRequest = requestBody as A2ARequest; 31 | } else { 32 | throw A2AError.parseError('Invalid request body type.'); 33 | } 34 | 35 | if ( 36 | rpcRequest.jsonrpc !== '2.0' || 37 | !rpcRequest.method || 38 | typeof rpcRequest.method !== 'string' 39 | ) { 40 | throw A2AError.invalidRequest( 41 | 'Invalid JSON-RPC request structure.' 42 | ); 43 | } 44 | } catch (error: any) { 45 | const a2aError = error instanceof A2AError ? error : A2AError.parseError(error.message || 'Failed to parse JSON request.'); 46 | return { 47 | jsonrpc: '2.0', 48 | id: (typeof rpcRequest!?.id !== 'undefined' ? rpcRequest!.id : null), 49 | error: a2aError.toJSONRPCError(), 50 | } as JSONRPCErrorResponse; 51 | } 52 | 53 | const { method, params = {}, id: requestId = null } = rpcRequest; 54 | 55 | try { 56 | if (method === 'message/stream' || method === 'tasks/resubscribe') { 57 | const agentCard = await this.requestHandler.getAgentCard(); 58 | if (!agentCard.capabilities.streaming) { 59 | throw A2AError.unsupportedOperation(`Method ${method} requires streaming capability.`); 60 | } 61 | const agentEventStream = method === 'message/stream' 62 | ? this.requestHandler.sendMessageStream(params as MessageSendParams) 63 | : this.requestHandler.resubscribe(params as TaskIdParams); 64 | 65 | // Wrap the agent event stream into a JSON-RPC result stream 66 | return (async function* jsonRpcEventStream(): AsyncGenerator { 67 | try { 68 | for await (const event of agentEventStream) { 69 | yield { 70 | jsonrpc: '2.0', 71 | id: requestId, // Use the original request ID for all streamed responses 72 | result: event, 73 | }; 74 | } 75 | } catch (streamError: any) { 76 | // If the underlying agent stream throws an error, we need to yield a JSONRPCErrorResponse. 77 | // However, an AsyncGenerator is expected to yield JSONRPCResult. 78 | // This indicates an issue with how errors from the agent's stream are propagated. 79 | // For now, log it. The Express layer will handle the generator ending. 80 | console.error(`Error in agent event stream for ${method} (request ${requestId}):`, streamError); 81 | // Ideally, the Express layer should catch this and send a final error to the client if the stream breaks. 82 | // Or, the agentEventStream itself should yield a final error event that gets wrapped. 83 | // For now, we re-throw so it can be caught by A2AExpressApp's stream handling. 84 | throw streamError; 85 | } 86 | })(); 87 | } else { 88 | // Handle non-streaming methods 89 | let result: any; 90 | switch (method) { 91 | case 'message/send': 92 | result = await this.requestHandler.sendMessage(params as MessageSendParams); 93 | break; 94 | case 'tasks/get': 95 | result = await this.requestHandler.getTask(params as TaskQueryParams); 96 | break; 97 | case 'tasks/cancel': 98 | result = await this.requestHandler.cancelTask(params as TaskIdParams); 99 | break; 100 | case 'tasks/pushNotificationConfig/set': 101 | result = await this.requestHandler.setTaskPushNotificationConfig( 102 | params as TaskPushNotificationConfig 103 | ); 104 | break; 105 | case 'tasks/pushNotificationConfig/get': 106 | result = await this.requestHandler.getTaskPushNotificationConfig( 107 | params as TaskIdParams 108 | ); 109 | break; 110 | default: 111 | throw A2AError.methodNotFound(method); 112 | } 113 | return { 114 | jsonrpc: '2.0', 115 | id: requestId, 116 | result: result, 117 | } as A2AResponse; 118 | } 119 | } catch (error: any) { 120 | const a2aError = error instanceof A2AError ? error : A2AError.internalError(error.message || 'An unexpected error occurred.'); 121 | return { 122 | jsonrpc: '2.0', 123 | id: requestId, 124 | error: a2aError.toJSONRPCError(), 125 | } as JSONRPCErrorResponse; 126 | } 127 | } 128 | } -------------------------------------------------------------------------------- /src/server/utils.ts: -------------------------------------------------------------------------------- 1 | import { TaskStatus, Artifact } from "../types.js"; 2 | 3 | /** 4 | * Generates a timestamp in ISO 8601 format. 5 | * @returns The current timestamp as a string. 6 | */ 7 | export function getCurrentTimestamp(): string { 8 | return new Date().toISOString(); 9 | } 10 | 11 | /** 12 | * Checks if a value is a plain object (excluding arrays and null). 13 | * @param value The value to check. 14 | * @returns True if the value is a plain object, false otherwise. 15 | */ 16 | export function isObject(value: unknown): value is Record { 17 | return typeof value === "object" && value !== null && !Array.isArray(value); 18 | } 19 | 20 | /** 21 | * Type guard to check if an object is a TaskStatus update (lacks 'parts'). 22 | * Used to differentiate yielded updates from the handler. 23 | */ 24 | export function isTaskStatusUpdate( 25 | update: any // eslint-disable-line @typescript-eslint/no-explicit-any 26 | ): update is Omit { 27 | // Check if it has 'state' and NOT 'parts' (which Artifacts have) 28 | return isObject(update) && "state" in update && !("parts" in update); 29 | } 30 | 31 | /** 32 | * Type guard to check if an object is an Artifact update (has 'parts'). 33 | * Used to differentiate yielded updates from the handler. 34 | */ 35 | export function isArtifactUpdate( 36 | update: any // eslint-disable-line @typescript-eslint/no-explicit-any 37 | ): update is Artifact { 38 | // Check if it has 'parts' 39 | return isObject(update) && "parts" in update; 40 | } 41 | -------------------------------------------------------------------------------- /test/server/default_request_handler.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { assert, expect } from 'chai'; 3 | import sinon, { SinonStub, SinonFakeTimers } from 'sinon'; 4 | 5 | import { AgentExecutor } from '../../src/server/agent_execution/agent_executor.js'; 6 | import { describe, beforeEach, afterEach, it } from 'node:test'; 7 | import { RequestContext, ExecutionEventBus, TaskStore, InMemoryTaskStore, DefaultRequestHandler, AgentCard, Artifact, Message, MessageSendParams, PushNotificationConfig, Task, TaskIdParams, TaskPushNotificationConfig, TaskState, TaskStatusUpdateEvent } from '../../src/index.js'; 8 | import { DefaultExecutionEventBusManager, ExecutionEventBusManager } from '../../src/server/events/execution_event_bus_manager.js'; 9 | import { A2ARequestHandler } from '../../src/server/request_handler/a2a_request_handler.js'; 10 | 11 | /** 12 | * A realistic mock of AgentExecutor for cancellation tests. 13 | */ 14 | class CancellableMockAgentExecutor implements AgentExecutor { 15 | private cancelledTasks = new Set(); 16 | private clock: SinonFakeTimers; 17 | 18 | constructor(clock: SinonFakeTimers) { 19 | this.clock = clock; 20 | } 21 | 22 | public execute = async ( 23 | requestContext: RequestContext, 24 | eventBus: ExecutionEventBus, 25 | ): Promise => { 26 | const taskId = requestContext.taskId; 27 | const contextId = requestContext.contextId; 28 | 29 | eventBus.publish({ id: taskId, contextId, status: { state: "submitted" }, kind: 'task' }); 30 | eventBus.publish({ taskId, contextId, kind: 'status-update', status: { state: "working" }, final: false }); 31 | 32 | // Simulate a long-running process 33 | for (let i = 0; i < 5; i++) { 34 | if (this.cancelledTasks.has(taskId)) { 35 | eventBus.publish({ taskId, contextId, kind: 'status-update', status: { state: "canceled" }, final: true }); 36 | eventBus.finished(); 37 | return; 38 | } 39 | // Use fake timers to simulate work 40 | await this.clock.tickAsync(100); 41 | } 42 | 43 | eventBus.publish({ taskId, contextId, kind: 'status-update', status: { state: "completed" }, final: true }); 44 | eventBus.finished(); 45 | }; 46 | 47 | public cancelTask = async ( 48 | taskId: string, 49 | eventBus: ExecutionEventBus, 50 | ): Promise => { 51 | this.cancelledTasks.add(taskId); 52 | // The execute loop is responsible for publishing the final state 53 | }; 54 | 55 | // Stub for spying on cancelTask calls 56 | public cancelTaskSpy = sinon.spy(this, 'cancelTask'); 57 | } 58 | 59 | describe('DefaultRequestHandler as A2ARequestHandler', () => { 60 | let handler: A2ARequestHandler; 61 | let mockTaskStore: TaskStore; 62 | let mockAgentExecutor: AgentExecutor; 63 | let executionEventBusManager: ExecutionEventBusManager; 64 | let clock: SinonFakeTimers; 65 | 66 | const testAgentCard: AgentCard = { 67 | name: 'Test Agent', 68 | description: 'An agent for testing purposes', 69 | url: 'http://localhost:8080', 70 | version: '1.0.0', 71 | capabilities: { 72 | streaming: true, 73 | pushNotifications: true, 74 | }, 75 | defaultInputModes: ['text/plain'], 76 | defaultOutputModes: ['text/plain'], 77 | skills: [ 78 | { 79 | id: 'test-skill', 80 | name: 'Test Skill', 81 | description: 'A skill for testing', 82 | tags: ['test'], 83 | }, 84 | ], 85 | }; 86 | 87 | // Before each test, reset the components to a clean state 88 | beforeEach(() => { 89 | mockTaskStore = new InMemoryTaskStore(); 90 | // Default mock for most tests 91 | mockAgentExecutor = new MockAgentExecutor(); 92 | executionEventBusManager = new DefaultExecutionEventBusManager(); 93 | handler = new DefaultRequestHandler( 94 | testAgentCard, 95 | mockTaskStore, 96 | mockAgentExecutor, 97 | executionEventBusManager, 98 | ); 99 | }); 100 | 101 | // After each test, restore any sinon fakes or stubs 102 | afterEach(() => { 103 | sinon.restore(); 104 | if(clock) { 105 | clock.restore(); 106 | } 107 | }); 108 | 109 | // Helper function to create a basic user message 110 | const createTestMessage = (id: string, text: string): Message => ({ 111 | messageId: id, 112 | role: 'user', 113 | parts: [{ kind: 'text', text }], 114 | kind: 'message', 115 | }); 116 | 117 | /** 118 | * A mock implementation of AgentExecutor to control agent behavior during tests. 119 | */ 120 | class MockAgentExecutor implements AgentExecutor { 121 | // Stubs to control and inspect calls to execute and cancelTask 122 | public execute: SinonStub< 123 | [RequestContext, ExecutionEventBus], 124 | Promise 125 | > = sinon.stub(); 126 | public cancelTask: SinonStub<[string, ExecutionEventBus], Promise> = 127 | sinon.stub(); 128 | } 129 | 130 | it('sendMessage: should return a simple message response', async () => { 131 | const params: MessageSendParams = { 132 | message: createTestMessage('msg-1', 'Hello'), 133 | }; 134 | 135 | const agentResponse: Message = { 136 | messageId: 'agent-msg-1', 137 | role: 'agent', 138 | parts: [{ kind: 'text', text: 'Hi there!' }], 139 | kind: 'message', 140 | }; 141 | 142 | (mockAgentExecutor as MockAgentExecutor).execute.callsFake(async (ctx, bus) => { 143 | bus.publish(agentResponse); 144 | bus.finished(); 145 | }); 146 | 147 | const result = await handler.sendMessage(params); 148 | 149 | assert.deepEqual(result, agentResponse, "The result should be the agent's message"); 150 | assert.isTrue((mockAgentExecutor as MockAgentExecutor).execute.calledOnce, "AgentExecutor.execute should be called once"); 151 | }); 152 | 153 | it('sendMessage: (blocking) should return a task in a completed state with an artifact', async () => { 154 | const params: MessageSendParams = { 155 | message: createTestMessage('msg-2', 'Do a task') 156 | }; 157 | 158 | const taskId = 'task-123'; 159 | const contextId = 'ctx-abc'; 160 | const testArtifact: Artifact = { 161 | artifactId: 'artifact-1', 162 | name: 'Test Document', 163 | description: 'A test artifact.', 164 | parts: [{ kind: 'text', text: 'This is the content of the artifact.' }] 165 | }; 166 | 167 | (mockAgentExecutor as MockAgentExecutor).execute.callsFake(async (ctx, bus) => { 168 | bus.publish({ 169 | id: taskId, 170 | contextId, 171 | status: { state: "submitted" }, 172 | kind: 'task' 173 | }); 174 | bus.publish({ 175 | taskId, 176 | contextId, 177 | kind: 'status-update', 178 | status: { state: "working" }, 179 | final: false 180 | }); 181 | bus.publish({ 182 | taskId, 183 | contextId, 184 | kind: 'artifact-update', 185 | artifact: testArtifact 186 | }); 187 | bus.publish({ 188 | taskId, 189 | contextId, 190 | kind: 'status-update', 191 | status: { state: "completed", message: { role: 'agent', parts: [{kind: 'text', text: 'Done!'}], messageId: 'agent-msg-2', kind: 'message'} }, 192 | final: true 193 | }); 194 | bus.finished(); 195 | }); 196 | 197 | const result = await handler.sendMessage(params); 198 | const taskResult = result as Task; 199 | 200 | assert.equal(taskResult.kind, 'task'); 201 | assert.equal(taskResult.id, taskId); 202 | assert.equal(taskResult.status.state, "completed"); 203 | assert.isDefined(taskResult.artifacts, 'Task result should have artifacts'); 204 | assert.isArray(taskResult.artifacts); 205 | assert.lengthOf(taskResult.artifacts!, 1); 206 | assert.deepEqual(taskResult.artifacts![0], testArtifact); 207 | }); 208 | 209 | it('sendMessage: should handle agent execution failure for blocking calls', async () => { 210 | const errorMessage = 'Agent failed!'; 211 | (mockAgentExecutor as MockAgentExecutor).execute.rejects(new Error(errorMessage)); 212 | 213 | // Test blocking case 214 | const blockingParams: MessageSendParams = { 215 | message: createTestMessage('msg-fail-block', 'Test failure blocking'), 216 | }; 217 | 218 | const blockingResult = await handler.sendMessage(blockingParams); 219 | const blockingTask = blockingResult as Task; 220 | assert.equal(blockingTask.kind, 'task', 'Result should be a task'); 221 | assert.equal(blockingTask.status.state, 'failed', 'Task status should be failed'); 222 | assert.include((blockingTask.status.message?.parts[0] as any).text, errorMessage, 'Error message should be in the status'); 223 | }); 224 | 225 | it('sendMessage: (non-blocking) should return first task event immediately and process full task in background', async () => { 226 | clock = sinon.useFakeTimers(); 227 | const saveSpy = sinon.spy(mockTaskStore, 'save'); 228 | 229 | const params: MessageSendParams = { 230 | message: createTestMessage('msg-nonblock', 'Do a long task'), 231 | configuration: { blocking: false, acceptedOutputModes: [] } 232 | }; 233 | 234 | const taskId = 'task-nonblock-123'; 235 | const contextId = 'ctx-nonblock-abc'; 236 | 237 | (mockAgentExecutor as MockAgentExecutor).execute.callsFake(async (ctx, bus) => { 238 | // First event is the task creation, which should be returned immediately 239 | bus.publish({ 240 | id: taskId, 241 | contextId, 242 | status: { state: "submitted" }, 243 | kind: 'task' 244 | }); 245 | 246 | // Simulate work before publishing more events 247 | await clock.tickAsync(500); 248 | 249 | bus.publish({ 250 | taskId, 251 | contextId, 252 | kind: 'status-update', 253 | status: { state: "completed" }, 254 | final: true 255 | }); 256 | bus.finished(); 257 | }); 258 | 259 | // This call should return as soon as the first 'task' event is published 260 | const immediateResult = await handler.sendMessage(params); 261 | 262 | // Assert that we got the initial task object back right away 263 | const taskResult = immediateResult as Task; 264 | assert.equal(taskResult.kind, 'task'); 265 | assert.equal(taskResult.id, taskId); 266 | assert.equal(taskResult.status.state, 'submitted', "Should return immediately with 'submitted' state"); 267 | 268 | // The background processing should not have completed yet 269 | assert.isTrue(saveSpy.calledOnce, "Save should be called for the initial task creation"); 270 | assert.equal(saveSpy.firstCall.args[0].status.state, 'submitted'); 271 | 272 | // Allow the background processing to complete 273 | await clock.runAllAsync(); 274 | 275 | // Now, check the final state in the store to ensure background processing finished 276 | const finalTask = await mockTaskStore.load(taskId); 277 | assert.isDefined(finalTask); 278 | assert.equal(finalTask!.status.state, 'completed', "Task should be 'completed' in the store after background processing"); 279 | assert.isTrue(saveSpy.calledTwice, "Save should be called twice (submitted and completed)"); 280 | assert.equal(saveSpy.secondCall.args[0].status.state, 'completed'); 281 | }); 282 | 283 | it('sendMessage: should handle agent execution failure for non-blocking calls', async () => { 284 | const errorMessage = 'Agent failed!'; 285 | (mockAgentExecutor as MockAgentExecutor).execute.rejects(new Error(errorMessage)); 286 | 287 | // Test non-blocking case 288 | const nonBlockingParams: MessageSendParams = { 289 | message: createTestMessage('msg-fail-nonblock', 'Test failure non-blocking'), 290 | configuration: { blocking: false, acceptedOutputModes: [] }, 291 | }; 292 | 293 | const nonBlockingResult = await handler.sendMessage(nonBlockingParams); 294 | const nonBlockingTask = nonBlockingResult as Task; 295 | assert.equal(nonBlockingTask.kind, 'task', 'Result should be a task'); 296 | assert.equal(nonBlockingTask.status.state, 'failed', 'Task status should be failed'); 297 | assert.include((nonBlockingTask.status.message?.parts[0] as any).text, errorMessage, 'Error message should be in the status'); 298 | }); 299 | 300 | it('sendMessageStream: should stream submitted, working, and completed events', async () => { 301 | const params: MessageSendParams = { 302 | message: createTestMessage('msg-3', 'Stream a task') 303 | }; 304 | const taskId = 'task-stream-1'; 305 | const contextId = 'ctx-stream-1'; 306 | 307 | (mockAgentExecutor as MockAgentExecutor).execute.callsFake(async (ctx, bus) => { 308 | bus.publish({ id: taskId, contextId, status: { state: "submitted" }, kind: 'task' }); 309 | await new Promise(res => setTimeout(res, 10)); 310 | bus.publish({ taskId, contextId, kind: 'status-update', status: { state: "working" }, final: false }); 311 | await new Promise(res => setTimeout(res, 10)); 312 | bus.publish({ taskId, contextId, kind: 'status-update', status: { state: "completed" }, final: true }); 313 | bus.finished(); 314 | }); 315 | 316 | const eventGenerator = handler.sendMessageStream(params); 317 | const events = []; 318 | for await (const event of eventGenerator) { 319 | events.push(event); 320 | } 321 | 322 | assert.lengthOf(events, 3, "Stream should yield 3 events"); 323 | assert.equal((events[0] as Task).status.state, "submitted"); 324 | assert.equal((events[1] as TaskStatusUpdateEvent).status.state, "working"); 325 | assert.equal((events[2] as TaskStatusUpdateEvent).status.state, "completed"); 326 | assert.isTrue((events[2] as TaskStatusUpdateEvent).final); 327 | }); 328 | 329 | it('sendMessage: should reject if task is in a terminal state', async () => { 330 | const taskId = 'task-terminal-1'; 331 | const terminalStates: TaskState[] = ['completed', 'failed', 'canceled', 'rejected']; 332 | 333 | for (const state of terminalStates) { 334 | const fakeTask: Task = { 335 | id: taskId, 336 | contextId: 'ctx-terminal', 337 | status: { state: state as TaskState }, 338 | kind: 'task' 339 | }; 340 | await mockTaskStore.save(fakeTask); 341 | 342 | const params: MessageSendParams = { 343 | message: { ...createTestMessage('msg-1', 'test'), taskId: taskId } 344 | }; 345 | 346 | try { 347 | await handler.sendMessage(params); 348 | assert.fail(`Should have thrown for state: ${state}`); 349 | } catch (error: any) { 350 | expect(error.code).to.equal(-32600); // Invalid Request 351 | expect(error.message).to.contain(`Task ${taskId} is in a terminal state (${state}) and cannot be modified.`); 352 | } 353 | } 354 | }); 355 | 356 | it('sendMessageStream: should reject if task is in a terminal state', async () => { 357 | const taskId = 'task-terminal-2'; 358 | const fakeTask: Task = { 359 | id: taskId, 360 | contextId: 'ctx-terminal-stream', 361 | status: { state: 'completed' }, 362 | kind: 'task' 363 | }; 364 | await mockTaskStore.save(fakeTask); 365 | 366 | const params: MessageSendParams = { 367 | message: { ...createTestMessage('msg-1', 'test'), taskId: taskId } 368 | }; 369 | 370 | const generator = handler.sendMessageStream(params); 371 | 372 | try { 373 | await generator.next(); 374 | assert.fail('sendMessageStream should have thrown an error'); 375 | } catch(error: any) { 376 | expect(error.code).to.equal(-32600); 377 | expect(error.message).to.contain(`Task ${taskId} is in a terminal state (completed) and cannot be modified.`); 378 | } 379 | }); 380 | 381 | it('sendMessageStream: should stop at input-required state', async () => { 382 | const params: MessageSendParams = { 383 | message: createTestMessage('msg-4', 'I need input') 384 | }; 385 | const taskId = 'task-input'; 386 | const contextId = 'ctx-input'; 387 | 388 | (mockAgentExecutor as MockAgentExecutor).execute.callsFake(async (ctx, bus) => { 389 | bus.publish({ id: taskId, contextId, status: { state: "submitted" }, kind: 'task' }); 390 | bus.publish({ taskId, contextId, kind: 'status-update', status: { state: "input-required" }, final: true }); 391 | bus.finished(); 392 | }); 393 | 394 | const eventGenerator = handler.sendMessageStream(params); 395 | const events = []; 396 | for await (const event of eventGenerator) { 397 | events.push(event); 398 | } 399 | 400 | assert.lengthOf(events, 2); 401 | const lastEvent = events[1] as TaskStatusUpdateEvent; 402 | assert.equal(lastEvent.status.state, "input-required"); 403 | assert.isTrue(lastEvent.final); 404 | }); 405 | 406 | it('resubscribe: should allow multiple clients to receive events for the same task', async () => { 407 | const saveSpy = sinon.spy(mockTaskStore, 'save'); 408 | clock = sinon.useFakeTimers(); 409 | const params: MessageSendParams = { 410 | message: createTestMessage('msg-5', 'Long running task') 411 | }; 412 | 413 | let taskId; 414 | let contextId; 415 | 416 | (mockAgentExecutor as MockAgentExecutor).execute.callsFake(async (ctx, bus) => { 417 | taskId = ctx.taskId; 418 | contextId = ctx.contextId; 419 | 420 | bus.publish({ id: taskId, contextId, status: { state: "submitted" }, kind: 'task' }); 421 | bus.publish({ taskId, contextId, kind: 'status-update', status: { state: "working" }, final: false }); 422 | await clock.tickAsync(100); 423 | bus.publish({ taskId, contextId, kind: 'status-update', status: { state: "completed" }, final: true }); 424 | bus.finished(); 425 | }); 426 | 427 | const stream1_generator = handler.sendMessageStream(params); 428 | const stream1_iterator = stream1_generator[Symbol.asyncIterator](); 429 | 430 | const firstEventResult = await stream1_iterator.next(); 431 | const firstEvent = firstEventResult.value as Task; 432 | assert.equal(firstEvent.id, taskId, 'Should get task event first'); 433 | 434 | const secondEventResult = await stream1_iterator.next(); 435 | const secondEvent = secondEventResult.value as TaskStatusUpdateEvent; 436 | assert.equal(secondEvent.taskId, taskId, 'Should get the task status update event second'); 437 | 438 | const stream2_generator = handler.resubscribe({ id: taskId }); 439 | 440 | const results1: any[] = [firstEvent, secondEvent]; 441 | const results2: any[] = []; 442 | 443 | const collect = async (iterator: AsyncGenerator, results: any[]) => { 444 | for await (const res of iterator) { 445 | results.push(res); 446 | } 447 | }; 448 | 449 | const p1 = collect(stream1_iterator, results1); 450 | const p2 = collect(stream2_generator, results2); 451 | 452 | await clock.runAllAsync(); 453 | await Promise.all([p1, p2]); 454 | 455 | assert.equal((results1[0] as TaskStatusUpdateEvent).status.state, "submitted"); 456 | assert.equal((results1[1] as TaskStatusUpdateEvent).status.state, "working"); 457 | assert.equal((results1[2] as TaskStatusUpdateEvent).status.state, "completed"); 458 | 459 | // First event of resubscribe is always a task. 460 | assert.equal((results2[0] as Task).status.state, "working"); 461 | assert.equal((results2[1] as TaskStatusUpdateEvent).status.state, "completed"); 462 | 463 | assert.isTrue(saveSpy.calledThrice, 'TaskStore.save should be called 3 times'); 464 | const lastSaveCall = saveSpy.lastCall.args[0]; 465 | assert.equal(lastSaveCall.id, taskId); 466 | assert.equal(lastSaveCall.status.state, "completed"); 467 | }); 468 | 469 | it('getTask: should return an existing task from the store', async () => { 470 | const fakeTask: Task = { 471 | id: 'task-exist', 472 | contextId: 'ctx-exist', 473 | status: { state: "working" }, 474 | kind: 'task', 475 | history: [] 476 | }; 477 | await mockTaskStore.save(fakeTask); 478 | 479 | const result = await handler.getTask({ id: 'task-exist' }); 480 | assert.deepEqual(result, fakeTask); 481 | }); 482 | 483 | it('set/getTaskPushNotificationConfig: should save and retrieve config', async () => { 484 | const taskId = 'task-push-config'; 485 | const fakeTask: Task = { id: taskId, contextId: 'ctx-push', status: { state: "working" }, kind: 'task' }; 486 | await mockTaskStore.save(fakeTask); 487 | 488 | const pushConfig: PushNotificationConfig = { 489 | url: 'https://example.com/notify', 490 | token: 'secret-token' 491 | }; 492 | 493 | const setParams: TaskPushNotificationConfig = { taskId, pushNotificationConfig: pushConfig }; 494 | const setResponse = await handler.setTaskPushNotificationConfig(setParams); 495 | assert.deepEqual(setResponse.pushNotificationConfig, pushConfig, "Set response should return the config"); 496 | 497 | const getParams: TaskIdParams = { id: taskId }; 498 | const getResponse = await handler.getTaskPushNotificationConfig(getParams); 499 | assert.deepEqual(getResponse.pushNotificationConfig, pushConfig, "Get response should return the saved config"); 500 | }); 501 | 502 | it('cancelTask: should cancel a running task and notify listeners', async () => { 503 | clock = sinon.useFakeTimers(); 504 | // Use the more advanced mock for this specific test 505 | const cancellableExecutor = new CancellableMockAgentExecutor(clock); 506 | handler = new DefaultRequestHandler( 507 | testAgentCard, 508 | mockTaskStore, 509 | cancellableExecutor, 510 | executionEventBusManager, 511 | ); 512 | 513 | const streamParams: MessageSendParams = { message: createTestMessage('msg-9', 'Start and cancel') }; 514 | const streamGenerator = handler.sendMessageStream(streamParams); 515 | 516 | const streamEvents: any[] = []; 517 | const streamingPromise = (async () => { 518 | for await (const event of streamGenerator) { 519 | streamEvents.push(event); 520 | } 521 | })(); 522 | 523 | // Allow the task to be created and enter the 'working' state 524 | await clock.tickAsync(150); 525 | 526 | const createdTask = streamEvents.find(e => e.kind === 'task') as Task; 527 | assert.isDefined(createdTask, 'Task creation event should have been received'); 528 | const taskId = createdTask.id; 529 | 530 | // Now, issue the cancel request 531 | const cancelResponse = await handler.cancelTask({ id: taskId }); 532 | 533 | // Let the executor's loop run to completion to detect the cancellation 534 | await clock.runAllAsync(); 535 | await streamingPromise; 536 | 537 | assert.isTrue(cancellableExecutor.cancelTaskSpy.calledOnceWith(taskId, sinon.match.any)); 538 | 539 | const lastEvent = streamEvents[streamEvents.length - 1] as TaskStatusUpdateEvent; 540 | assert.equal(lastEvent.status.state, "canceled"); 541 | 542 | const finalTask = await handler.getTask({ id: taskId }); 543 | assert.equal(finalTask.status.state, "canceled"); 544 | 545 | // Canceled API issues cancel request to executor and returns latest task state. 546 | // In this scenario, executor is waiting on clock to detect that task has been cancelled. 547 | // While the cancel API has returned with latest task state => Working. 548 | assert.equal(cancelResponse.status.state, "working"); 549 | }); 550 | 551 | it('cancelTask: should fail for tasks in a terminal state', async () => { 552 | const taskId = 'task-terminal'; 553 | const fakeTask: Task = { id: taskId, contextId: 'ctx-terminal', status: { state: "completed" }, kind: 'task' }; 554 | await mockTaskStore.save(fakeTask); 555 | 556 | try { 557 | await handler.cancelTask({ id: taskId }); 558 | assert.fail('Should have thrown a TaskNotCancelableError'); 559 | } catch (error: any) { 560 | assert.equal(error.code, -32002); 561 | expect(error.message).to.contain('Task not cancelable'); 562 | } 563 | assert.isFalse((mockAgentExecutor as MockAgentExecutor).cancelTask.called); 564 | }); 565 | }); 566 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "NodeNext", 5 | "skipLibCheck": true, 6 | "rootDir": ".", 7 | "outDir": "build", 8 | "sourceMap": true, 9 | "declaration": true, 10 | "esModuleInterop": true, // Often helpful with NodeNext 11 | "resolveJsonModule": true // Good practice for importing JSON if needed 12 | }, 13 | "include": ["src/**/*.ts", "test/**/*.ts"] 14 | } 15 | --------------------------------------------------------------------------------