diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..3050c14ed --- /dev/null +++ b/.editorconfig @@ -0,0 +1,36 @@ +root = true + +[*.{kt,kts}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +max_line_length = 120 + +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL + +# Disable wildcard imports entirely +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = unset + +ktlint_code_style = ktlint_official +ktlint_standard_annotation = disabled +ktlint_standard_class-naming = disabled +ktlint_standard_class-signature = disabled +ktlint_standard_filename = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_if-else-bracing = enabled +ktlint_standard_if-else-wrapping = enabled +ktlint_standard_no-consecutive-comments = disabled +ktlint_standard_no-single-line-block-comment = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled +ktlint_standard_try-catch-finally-spacing = enabled +ktlint_standard_backing-property-naming = disabled + +[**/build/**/*] +ktlint = disabled \ No newline at end of file diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index 06d7c5a8a..29099b9a9 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -5,17 +5,21 @@ on: branches: [main] jobs: - build: + lint: name: Lint Code Base runs-on: ubuntu-latest + permissions: + contents: read + packages: read + statuses: write steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Lint Code Base + - name: GitHub Super Linter uses: super-linter/super-linter/slim@v8 env: DEFAULT_BRANCH: main @@ -23,7 +27,7 @@ jobs: LOG_LEVEL: WARN SHELLCHECK_OPTS: -e SC1091 -e 2086 VALIDATE_ALL_CODEBASE: false - FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/).*" + FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/).*|CODE_OF_CONDUCT.md|(extensions/agp/).*" VALIDATE_PYTHON_BLACK: false VALIDATE_PYTHON_FLAKE8: false VALIDATE_PYTHON_ISORT: false @@ -44,3 +48,6 @@ jobs: VALIDATE_JUPYTER_NBQA_PYLINT: false VALIDATE_JUPYTER_NBQA_RUFF: false VALIDATE_TRIVY: false + VALIDATE_BIOME_FORMAT: false + VALIDATE_BIOME_LINT: false + VALIDATE_GITHUB_ACTIONS_ZIZMOR: false diff --git a/extensions/agp/agp_run.py b/extensions/agp/agp_run.py new file mode 100644 index 000000000..e10b50f3e --- /dev/null +++ b/extensions/agp/agp_run.py @@ -0,0 +1,124 @@ +import logging + +from agp_protocol import ( + AGPTable, + AgentGatewayProtocol, + CapabilityAnnouncement, + IntentPayload, +) + + +# Set logging level to WARNING so only our custom routing failures are visible +logging.basicConfig(level=logging.WARNING) + + +def run_simulation(): + """Simulates the core routing process of the Agent Gateway Protocol (AGP), + demonstrating Policy-Based Routing and cost optimization. + """ + # --- PHASE 1: Setup and Announcement --- + + # 1. Initialize the central routing table + corporate_agp_table = AGPTable() + + # 2. Initialize the Corporate Gateway Agent (Router) + corporate_gateway = AgentGatewayProtocol( + squad_name='Corporate_GW', agp_table=corporate_agp_table + ) + + # 3. Squads announce their capabilities to the Corporate Gateway + + print('===============================================================') + print(' AGENT GATEWAY PROTOCOL (AGP) ROUTING SIMULATION') + print('===============================================================') + print('\n--- PHASE 1: SQUAD ANNOUNCEMENTS ---') + + # --- Announcement 1: Engineering Squad (Internal, Secure) --- + # Can provision VMs, handles sensitive data (PII), but is more expensive than the external vendor. + eng_announcement = CapabilityAnnouncement( + capability='infra:provision:vm', + version='1.0', + cost=0.10, # Higher cost + policy={'security_level': 5, 'requires_PII': True}, + ) + corporate_gateway.announce_capability( + eng_announcement, path='Squad_Engineering/vm_provisioner' + ) + + # --- Announcement 2: External Vendor Squad (Cheapest, Low Security) --- + # Can provision VMs, but fails the PII check and only meets standard security. + vendor_announcement = CapabilityAnnouncement( + capability='infra:provision:vm', + version='1.1', + cost=0.05, # Lowest cost + policy={'security_level': 3, 'requires_PII': False}, + ) + corporate_gateway.announce_capability( + vendor_announcement, path='External_Vendor/vm_provisioning_api' + ) + + # --- Announcement 3: Finance Squad (Standard Analysis) --- + finance_announcement = CapabilityAnnouncement( + capability='financial_analysis:quarterly', + version='2.0', + cost=0.15, + policy={'security_level': 3, 'geo': 'US'}, + ) + corporate_gateway.announce_capability( + finance_announcement, path='Squad_Finance/analysis_tool' + ) + + # --- PHASE 2: Intent Routing Simulation --- + + print('\n--- PHASE 2: INTENT ROUTING ---') + + # Intent A: Standard VM provisioning (Cost-driven, minimal policy) + # Expected: Route to External Vendor (Cost: 0.05) because it's cheapest and complies with security_level: 3. + intent_a = IntentPayload( + target_capability='infra:provision:vm', + payload={'type': 'standard', 'user': 'bob'}, + policy_constraints={'security_level': 3}, + ) + print( + '\n[Intent A] Requesting standard VM provisioning (Lowest cost, Security Level 3).' + ) + corporate_gateway.route_intent(intent_a) + + # Intent B: Sensitive VM provisioning (Policy-driven, requires PII) + # Expected: Route to Engineering Squad (Cost: 0.10) because the External Vendor (0.05) fails the PII policy. + # The router uses the sufficiency check (5 >= 5 is True). + intent_b = IntentPayload( + target_capability='infra:provision:vm', + payload={'type': 'sensitive', 'user': 'alice', 'data': 'ssn_data'}, + policy_constraints={'security_level': 5, 'requires_PII': True}, + ) + print( + '\n[Intent B] Requesting sensitive VM provisioning (Requires PII and Security Level 5).' + ) + corporate_gateway.route_intent(intent_b) + + # Intent C: Requesting provisioning with security level 7 (Unmatched Policy) + # Expected: Fails because no announced route can satisfy level 7. + intent_c = IntentPayload( + target_capability='infra:provision:vm', + payload={'type': 'max_security'}, + policy_constraints={'security_level': 7}, + ) + print( + '\n[Intent C] Requesting provisioning with security level 7 (Unmatched Policy).' + ) + corporate_gateway.route_intent(intent_c) + + # Intent D: Requesting HR onboarding (Unknown Capability) + # Expected: Fails because the capability was never announced. + intent_d = IntentPayload( + target_capability='hr:onboard:new_hire', + payload={'employee': 'Charlie'}, + policy_constraints={}, + ) + print('\n[Intent D] Requesting HR onboarding (Unknown Capability).') + corporate_gateway.route_intent(intent_d) + + +if __name__ == '__main__': + run_simulation() diff --git a/extensions/agp/poetry.lock b/extensions/agp/poetry.lock new file mode 100644 index 000000000..9a0ccb676 --- /dev/null +++ b/extensions/agp/poetry.lock @@ -0,0 +1,391 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "autoflake" +version = "2.3.1" +description = "Removes unused imports and unused variables" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840"}, + {file = "autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e"}, +] + +[package.dependencies] +pyflakes = ">=3.0.0" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pydantic" +version = "2.11.9" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, + {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyflakes" +version = "3.4.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "ruff" +version = "0.13.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c"}, + {file = "ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2"}, + {file = "ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af"}, + {file = "ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e"}, + {file = "ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989"}, + {file = "ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3"}, + {file = "ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2"}, + {file = "ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330"}, + {file = "ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e"}, +] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.11\""} + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[metadata] +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "afa56f22f84c1320786f16aa43157a3fbea2db221eb6e09e88de60a4f5a5ec0a" diff --git a/extensions/agp/pyproject.toml b/extensions/agp/pyproject.toml new file mode 100644 index 000000000..c64976594 --- /dev/null +++ b/extensions/agp/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "agp-protocol" +version = "1.0.0" +description = "Agent Gateway Protocol (AGP) routing layer implementation." +authors = ["Google Octo "] +license = "Apache-2.0" + +# Defines where the source code package 'agp_protocol' is located (inside the 'src' folder) +packages = [ + { include = "agp_protocol", from = "src" } +] + +[tool.poetry.dependencies] +python = "^3.9" +pydantic = "^2.0.0" + +[tool.poetry.group.dev.dependencies] +# Dependencies needed only for development and testing +pytest = "^8.0.0" +ruff = "^0.13.3" +autoflake = "^2.3.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/extensions/agp/spec.md b/extensions/agp/spec.md new file mode 100644 index 000000000..6ad88ce17 --- /dev/null +++ b/extensions/agp/spec.md @@ -0,0 +1,112 @@ +# Agent Gateway Protocol (AGP) Specification (V1) + +* **URI:** `https://github.com/a2aproject/a2a-samples/tree/main/extensions/agp` + +* **Type:** Core Protocol Layer / Routing Extension + +* **Version:** 1.0.0 + +## Abstract + +The Agent Gateway Protocol (AGP) proposes a hierarchical architecture for distributed AI systems, **enhancing the capabilities** of the flat A2A mesh by introducing a structure of interconnected Autonomous Squads (ASq). AGP routes **Intent** payloads based on declared **Capabilities**, mirroring the Border Gateway Protocol (BGP) for Internet scalability and policy enforcement. This structure divides agents into **hierarchical domains**, with each domain focusing on specific Agent Capabilities that reflect enterprise organizational needs (e.g., Finance, Engineering, HR, BUs, and so on) - gulli@google.com + +## 1. Data Structure: Capability Announcement + +This payload is used by a Squad Gateway Agent to announce the services its squad can fulfill to its peers (other squads). + +### CapabilityAnnouncement Object Schema + +| Field | Type | Required | Description | + | ----- | ----- | ----- | ----- | +| `capability` | string | Yes | The function or skill provided (e.g., `financial_analysis:quarterly`). | +| `version` | string | Yes | Version of the capability schema/interface (e.g., `1.5`). | +| `cost` | number | No | Estimated cost metric (e.g., `0.05` USD, or token count). | +| `policy` | object | Yes | Key-value pairs defining required policies (e.g., `requires_pii:true`, `security_level:5`). | + +### Example Announcement Payload + +```json +{ + "capability": "financial_analysis:quarterly", + "version": "1.5", + "cost": 0.05, + "policy": { + "requires_auth": "level_3" + } +} +``` + +## 2. Data Structure: Intent Payload + +This payload defines the *what* (the goal) and *constraints* (metadata), replacing a standard request. + +### Intent Object Schema + +| Field | Type | Required | Description | + | ----- | ----- | ----- | ----- | +| `target_capability` | string | Yes | The capability the Intent seeks to fulfill. | +| `payload` | object | Yes | The core data arguments required for the task. | +| `policy_constraints` | object | No | Client-defined constraints that must be matched against the announced `policy` during routing. | + +### Example Intent Payload + +```json +{ + "target_capability": "billing:invoice:generate", + "payload": { + "customer_id": 123, + "amount": 99.99 + }, + "policy_constraints": { + "requires_pii": true + } +} +``` + +## 3. Core Routing and Table Structures + +The protocol relies on the Gateway Agent maintaining an **AGP Table** (a routing table) built from Capability Announcements. This section defines the core structures used internally by the Gateway Agent. + +### A. RouteEntry Object Schema + +| Field | Type | Required | Description | + | ----- | ----- | ----- | ----- | +| `path` | string | Yes | The destination Squad/API path (e.g., `Squad_Finance/gateway`). | +| `cost` | number | Yes | The cost metric for this route (used for lowest-cost selection). | +| `policy` | object | Yes | Policies of the destination, used for matching Intent constraints. | + +### B. AGPTable Object + +The AGPTable maps a `capability` key to a list of potential `RouteEntry` objects. + +## 4. Agent Declaration and Role + +To participate in the AGP hierarchy, an A2A agent **MUST** declare its role as a Gateway and the supported AGP version within its Agent Card, using the A2A extension mechanism. + +### AgentCard AGP Declaration + +This declaration is placed within the `extensions` array of the Agent Card's `AgentCapabilities`. + +```json +{ + "uri": "https://github.com/a2aproject/a2a-samples/tree/main/extensions/agp", + "params": { + "agent_role": "gateway", + "supported_agp_versions": ["1.0"] + } +} +``` + +## 5. Extension Error Reference + +When a Gateway Agent attempts to route an Intent but fails due to policy or availability issues, it **MUST** return a JSON-RPC error with specific AGP-defined codes. + +| Code | Name | Description | Routing Consequence | + | ----- | ----- | ----- | ----- | +| **-32200** | `AGP_ROUTE_NOT_FOUND` | No agent or squad has announced the requested `target_capability`. | Intent cannot be routed; returned to sender. | +| **-32201** | `AGP_POLICY_VIOLATION` | Routes were found, but none satisfied the constraints in the Intent's `metadata` (e.g., no squad accepts PII data). | Intent cannot be routed safely; returned to sender. | +| **-32202** | `AGP_TABLE_STALE` | The Agent Gateway's routing table is outdated and needs a refresh via a standard AGP refresh mechanism. | Gateway attempts refresh before re-routing, or returns error. | + +## 6. Conclusion + +The Agent Gateway Protocol (AGP) offers a powerful and necessary enhancement layer over the foundational A2A structure. By implementing Policy-Based Routing, AGP ensures that distributed AI systems are not only efficient and financially optimized but also secure and policy-compliant—a critical step toward trustworthy, industrial-scale multi-agent collaboration. diff --git a/extensions/agp/src/agp_protocol/__init__.py b/extensions/agp/src/agp_protocol/__init__.py new file mode 100644 index 000000000..84f6b3d65 --- /dev/null +++ b/extensions/agp/src/agp_protocol/__init__.py @@ -0,0 +1,170 @@ +import logging + +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# --- Core Data Structures --- + + +class CapabilityAnnouncement(BaseModel): + """Data structure for a service announcement by a Gateway Agent.""" + + capability: str = Field( + ..., + description="The function or skill provided (e.g., 'financial_analysis:quarterly').", + ) + version: str = Field(..., description='Version of the capability schema.') + cost: float | None = Field(None, description='Estimated cost metric.') + policy: dict[str, Any] = Field( + ..., + description='Key-value pairs defining required security/data policies.', + ) + + model_config = ConfigDict(extra='forbid') + + +class IntentPayload(BaseModel): + """The request payload routed by AGP.""" + + target_capability: str = Field( + ..., description='The capability the Intent seeks to fulfill.' + ) + payload: dict[str, Any] = Field( + ..., description='The core data arguments required for the task.' + ) + # FIX APPLIED: Renaming internal field to policy_constraints for clarity + policy_constraints: dict[str, Any] = Field( + default_factory=dict, + description='Client-defined constraints that must be matched against the announced policy.', + alias='policy_constraints', + ) + + model_config = ConfigDict(extra='forbid', populate_by_name=True) + + +# --- AGP Routing Structures --- + + +class RouteEntry(BaseModel): + """A single possible route to fulfill a fulfill a capability.""" + + path: str = Field( + ..., + description="The destination Squad/API path (e.g., 'Squad_Finance/gateway').", + ) + cost: float = Field(..., description='Cost metric for this route.') + policy: dict[str, Any] = Field( + ..., + description='Policies of the destination, used for matching Intent constraints.', + ) + + +class AGPTable(BaseModel): + """The central routing table maintained by a Gateway Agent.""" + + routes: dict[str, list[RouteEntry]] = Field(default_factory=dict) + + model_config = ConfigDict(extra='forbid') + + +# --- Core AGP Routing Logic --- + + +class AgentGatewayProtocol: + """ + Simulates the core functions of an Autonomous Squad Gateway Agent. + Handles Capability Announcements and Policy-Based Intent Routing. + The primary routing logic is in _select_best_route to allow easy overriding via subclassing. + """ + + def __init__(self, squad_name: str, agp_table: AGPTable): + self.squad_name = squad_name + self.agp_table = agp_table + + def announce_capability( + self, announcement: CapabilityAnnouncement, path: str + ): + """Simulates receiving a capability announcement and updating the AGP Table.""" + entry = RouteEntry( + path=path, + cost=announcement.cost or 0.0, + policy=announcement.policy, + ) + + capability_key = announcement.capability + + # Use setdefault to initialize the list if the key is new + self.agp_table.routes.setdefault(capability_key, []).append(entry) + + print( + f'[{self.squad_name}] ANNOUNCED: {capability_key} routed via {path}' + ) + + # Protected method containing the core, overridable routing logic + def _select_best_route(self, intent: IntentPayload) -> RouteEntry | None: + """ + Performs Policy-Based Routing to find the best available squad. + + Routing Logic: + 1. Find all routes matching the target_capability. + 2. Filter routes based on matching all policy constraints (PBR). + 3. Select the lowest-cost route among the compliant options. + """ + target_cap = intent.target_capability + # CRITICAL CHANGE: Use the correct snake_case attribute name for constraints + intent_constraints = intent.policy_constraints + + if target_cap not in self.agp_table.routes: + logging.warning( + f"[{self.squad_name}] ROUTING FAILED: Capability '{target_cap}' is unknown." + ) + return None + + possible_routes = self.agp_table.routes[target_cap] + + # --- 2. Policy Filtering (Optimized using list comprehension and all()) --- + compliant_routes = [ + route + for route in possible_routes + if all( + # Check if the constraint key exists in the route policy AND the values are sufficient. + key in route.policy + and ( + # If the key is 'security_level' and both values are numeric, check for >= sufficiency. + route.policy[key] >= value + if key == 'security_level' + and isinstance(route.policy.get(key), (int, float)) + and isinstance(value, (int, float)) + # Otherwise (e.g., boolean flags like 'requires_PII'), require exact equality. + else route.policy[key] == value + ) + for key, value in intent_constraints.items() + ) + ] + + if not compliant_routes: + logging.warning( + f'[{self.squad_name}] ROUTING FAILED: No compliant route found for constraints: {intent_constraints}' + ) + return None + + # --- 3. Best Route Selection (Lowest Cost) --- + best_route = min(compliant_routes, key=lambda r: r.cost) + + return best_route + + # Public method that is typically called by the A2A endpoint + def route_intent(self, intent: IntentPayload) -> RouteEntry | None: + """ + Public entry point for routing an Intent payload. + Calls the internal selection logic and prints the result. + """ + best_route = self._select_best_route(intent) + + if best_route: + print( + f"[{self.squad_name}] ROUTING SUCCESS: Intent for '{intent.target_capability}' routed to {best_route.path} (Cost: {best_route.cost})" + ) + return best_route diff --git a/extensions/agp/tests/test_agp.py b/extensions/agp/tests/test_agp.py new file mode 100644 index 000000000..083986a68 --- /dev/null +++ b/extensions/agp/tests/test_agp.py @@ -0,0 +1,470 @@ +import pytest + +from agp_protocol import ( + AGPTable, + AgentGatewayProtocol, + CapabilityAnnouncement, + IntentPayload, + RouteEntry, +) + + +# --- Fixtures for Routing Table Setup --- + + +@pytest.fixture +def all_available_routes() -> list[RouteEntry]: + """Defines a list of heterogeneous routes covering all capabilities needed for testing.""" + return [ + # 1. Base License/Legal Route (Security Level 3, Geo US) - Cost 0.20 + RouteEntry( + path='Squad_Legal/licensing_api', + cost=0.20, + policy={'security_level': 3, 'geo': 'US'}, + ), + # 2. Secure/PII Route (Security Level 5, PII Handling True, Geo US) - Cost 0.10 + RouteEntry( + path='Squad_Finance/payroll_service', + cost=0.10, + policy={'security_level': 5, 'requires_pii': True, 'geo': 'US'}, + ), + # 3. External Route (Cheapest, Low Security, Geo EU) - Cost 0.05 + RouteEntry( + path='Vendor_EU/proxy_gateway', + cost=0.05, + policy={'security_level': 1, 'geo': 'EU'}, + ), + # 4. Hardware Provisioning Route (Engineering, Security Level 3, Geo US) - Cost 0.08 + RouteEntry( + path='Squad_Engineering/hardware_tool', + cost=0.08, + policy={'security_level': 3, 'geo': 'US'}, + ), + # 5. NDA Contract Generation Route (Legal, Security Level 3, Geo US) - Cost 0.15 + RouteEntry( + path='Squad_Legal/contracts_tool', + cost=0.15, + policy={'security_level': 3, 'geo': 'US'}, + ), + # 6. Low-Cost US Route (Security Level 2, Geo US) - Cost 0.07 + RouteEntry( + path='Vendor_US/data_service', + cost=0.07, + policy={'security_level': 2, 'geo': 'US'}, + ), + # 7. Zero-Cost Internal Route (Security Level 3, Geo US) - Cost 0.00 (NEW) + RouteEntry( + path='Internal/Free_Cache', + cost=0.00, + policy={'security_level': 3, 'geo': 'US'}, + ), + # 8. High-Cost Geo EU Route (Security Level 4, Geo EU) - Cost 0.30 (NEW) + RouteEntry( + path='Vendor_Secure_EU/proxy_gateway', + cost=0.30, + policy={'security_level': 4, 'geo': 'EU'}, + ), + ] + + +@pytest.fixture +def populated_agp_table(all_available_routes) -> AGPTable: + """Creates an AGPTable populated with routes for all test capabilities.""" + table = AGPTable() + + # Routes for Core Routing Tests (Tests 1-19 use 'procure:license') + table.routes['procure:license'] = [ + all_available_routes[0], + all_available_routes[1], + all_available_routes[2], + all_available_routes[5], + all_available_routes[6], # Zero Cost Route + all_available_routes[7], # Secure EU Route + ] + + # Routes for Decomposition Test (Test 6) + table.routes['provision:hardware'] = [all_available_routes[3]] + table.routes['provision:payroll'] = [all_available_routes[1]] + table.routes['contract:nda:generate'] = [all_available_routes[4]] + + return table + + +@pytest.fixture +def gateway(populated_agp_table) -> AgentGatewayProtocol: + """Provides a configured Gateway Agent instance for testing.""" + return AgentGatewayProtocol( + squad_name='Test_Gateway', agp_table=populated_agp_table + ) + + +# --- Test Scenarios (19 Total Tests) --- + + +def test_01_lowest_cost_compliant_route_with_sufficiency( + gateway: AgentGatewayProtocol, +): + """ + Verifies routing selects the lowest cost COMPLIANT route, checking for sufficiency (>=). + Constraint: security_level: 3, geo: US. Route 7 (Cost 0.00) is the cheapest compliant route. + Expected: Route 7 (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'item': 'Standard License'}, + policy_constraints={'security_level': 3, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_02_policy_filtering_sensitive_data(gateway: AgentGatewayProtocol): + """ + Verifies strict policy filtering excludes non-compliant routes regardless of cost. + Constraint: requires_pii: True. Only Route 2 complies (Cost 0.10). + Expected: Route 2 (Squad_Finance/payroll_service, Cost 0.10). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'item': 'Client Data License'}, + policy_constraints={'requires_pii': True}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Squad_Finance/payroll_service' + assert best_route.cost == 0.10 + + +def test_03_route_not_found(gateway: AgentGatewayProtocol): + """Tests routing failure when the target capability is not in the AGPTable.""" + intent = IntentPayload( + target_capability='unknown:capability', payload={'data': 'test'} + ) + best_route = gateway.route_intent(intent) + assert best_route is None + + +def test_04_policy_violation_unmatched_constraint( + gateway: AgentGatewayProtocol, +): + """ + Tests routing failure when the Intent imposes a constraint that no announced route can meet. + Constraint: security_level: 7. No route announces level 7 or higher. + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'item': 'Executive Access'}, + policy_constraints={'security_level': 7}, + ) + best_route = gateway.route_intent(intent) + assert best_route is None + + +def test_05_announcement_updates_table(gateway: AgentGatewayProtocol): + """Tests that announce_capability correctly adds a new entry to the AGPTable.""" + announcement = CapabilityAnnouncement( + capability='test:add:new', + version='1.0', + cost=1.0, + policy={'test': True, 'security_level': 1}, + ) + path = 'TestSquad/target' + + # Check table before announcement + assert 'test:add:new' not in gateway.agp_table.routes + + gateway.announce_capability(announcement, path) + + # Check table after announcement + assert 'test:add:new' in gateway.agp_table.routes + assert len(gateway.agp_table.routes['test:add:new']) == 1 + assert gateway.agp_table.routes['test:add:new'][0].path == path + + +def test_06_meta_intent_decomposition(gateway: AgentGatewayProtocol): + """ + Simulates the Corporate Enterprise flow: decomposition into three sub-intents + and verifies each sub-intent routes to the correct specialist squad based on policies. + """ + + # 1. Hardware Sub-Intent (Standard Engineering Task, requires level 3) + intent_hardware = IntentPayload( + target_capability='provision:hardware', + payload={'developer': 'Alice'}, + policy_constraints={'security_level': 3}, + ) + route_hw = gateway.route_intent(intent_hardware) + assert route_hw is not None + assert route_hw.path == 'Squad_Engineering/hardware_tool' + + # 2. Payroll Sub-Intent (Requires PII Handling - must go to secure Finance squad) + intent_payroll = IntentPayload( + target_capability='provision:payroll', + payload={'salary': 100000}, + policy_constraints={'requires_pii': True, 'security_level': 3}, + ) + route_payroll = gateway.route_intent(intent_payroll) + assert route_payroll is not None + assert route_payroll.path == 'Squad_Finance/payroll_service' + + # 3. Legal Sub-Intent (Simple route for contract:nda:generate, requires level 3) + intent_legal = IntentPayload( + target_capability='contract:nda:generate', + payload={'contract_type': 'NDA'}, + policy_constraints={'security_level': 3}, + ) + route_legal = gateway.route_intent(intent_legal) + assert route_legal is not None + assert route_legal.path == 'Squad_Legal/contracts_tool' + + +# --- NEW SECURITY AND COMPLIANCE TESTS --- + + +def test_07_geo_fencing_violation(gateway: AgentGatewayProtocol): + """ + Tests routing failure when an Intent requires US processing, but the cheapest route is EU-locked. + Constraint: geo: US. External Vendor (Cost 0.05, EU) fails geo-check. + Expected: Routed to cheapest compliant US vendor (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'data': 'US-user-request'}, + policy_constraints={'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_08_required_security_tier_sufficiency(gateway: AgentGatewayProtocol): + """ + Tests routing when a request requires a moderate security level (4). + The router must choose Route 2 (Level 5) because Route 1 (Level 3) and Route 6 (Level 2) fail the sufficiency check. + Constraint: security_level: 4. + Expected: Route 2 (Squad_Finance/payroll_service, Cost 0.10). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'data': 'moderate_access'}, + policy_constraints={'security_level': 4}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Squad_Finance/payroll_service' + assert best_route.cost == 0.10 + + +def test_09_policy_chaining_cost_after_geo(gateway: AgentGatewayProtocol): + """ + Tests routing for a complex chain: Intent requires US geo AND Level 2 security. + Compliant routes: Route 7 (0.00, L3), Route 6 (0.07, L2), Route 2 (0.10, L5), Route 1 (0.20, L3). + Expected: Cheapest compliant US route (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'simple_data_pull'}, + policy_constraints={'security_level': 2, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_10_zero_cost_priority(gateway: AgentGatewayProtocol): + """ + Tests that the absolute cheapest route (Cost 0.00) is prioritized when compliant. + Constraint: security_level: 3, geo: US. Route 7 (Cost 0.00) meets the need. + Expected: Route 7 (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'cache_check'}, + policy_constraints={'security_level': 3, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_11_minimum_security_level_one_selection(gateway: AgentGatewayProtocol): + """ + Tests routing for the absolute lowest security requirement. + Constraint: security_level: 1. Route 7 (Cost 0.00) is the cheapest compliant route. + Expected: Route 7 (Internal/Free_Cache, Cost 0.00). + """ + # NOTE: All routes are compliant (L1, L3, L5, L2, L3, L3, L4). Cheapest is Route 7 (Cost 0.00). + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'public_data_access'}, + policy_constraints={'security_level': 1}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_12_strict_geo_exclusion(gateway: AgentGatewayProtocol): + """ + Tests routing failure when requested geo (NA) is not available anywhere. + Constraint: geo: NA. No route advertises 'NA'. + Expected: Fails to route. + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'NA_access'}, + policy_constraints={'geo': 'NA'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is None + + +def test_13_cost_tie_breaker(gateway: AgentGatewayProtocol): + """ + Tests the tie-breaker mechanism when two compliant routes have the exact same cost. + Constraint: security_level: 5, geo: US. Only Route 2 (Cost 0.10, Level 5) is compliant. + Expected: Route 2 (Squad_Finance/payroll_service, Cost 0.10). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'high_security_check'}, + policy_constraints={'security_level': 5, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Squad_Finance/payroll_service' + assert best_route.cost == 0.10 + + +def test_14_no_constraint_default_cheapest(gateway: AgentGatewayProtocol): + """ + Tests routing when the Intent provides no constraints (empty metadata). + Expected: Router must select the absolute cheapest route available (Route 7, Cost 0.00). + """ + # NOTE: Route 7 (Cost 0.00) is the cheapest overall. + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'simple_unsecured'}, + policy_constraints={}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_15_compound_exclusion(gateway: AgentGatewayProtocol): + """ + Tests routing failure when two mandatory constraints cannot be met by the same route. + Constraint: geo: EU AND security_level: 5. + Expected: Failure (Route 8 is EU but only L4; Route 2 is L5 but US). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'EU_secure_data'}, + policy_constraints={'geo': 'EU', 'security_level': 5}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is None + + +def test_16_decomposition_check_pii_only_route(gateway: AgentGatewayProtocol): + """ + Verifies that the decomposition test logic for Payroll correctly chooses the PII-handling route. + This is a redundant check to ensure Test 06's complexity is fully stable. + """ + intent_payroll = IntentPayload( + target_capability='provision:payroll', + payload={'salary': 100000}, + policy_constraints={'requires_pii': True, 'security_level': 3}, + ) + route_payroll = gateway.route_intent(intent_payroll) + assert route_payroll is not None + assert route_payroll.path == 'Squad_Finance/payroll_service' + + +def test_17_cost_wins_after_sufficiency_filter(gateway: AgentGatewayProtocol): + """ + Tests that after filtering for sufficiency (Level >= 2), the cheapest route is chosen. + Compliant routes: Route 7 (0.00, L3), Route 6 (0.07, L2), Route 2 (0.10, L5), Route 1 (0.20, L3). + Expected: Cheapest compliant route (Internal/Free_Cache, Cost 0.00). + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'simple_data_pull'}, + policy_constraints={'security_level': 2}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_18_sufficiency_check_for_level_1_route_wins( + gateway: AgentGatewayProtocol, +): + """ + Tests that a request for L1 security is satisfied by the cheapest overall route (L1, 0.05). + Constraint: security_level: 1. + Expected: Router must select the absolute cheapest route available (Route 7, Cost 0.00). + """ + # NOTE: All routes are L1 or higher. Cheapest is Route 7 (Cost 0.00). + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'lowest_security'}, + policy_constraints={'security_level': 1}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Internal/Free_Cache' + assert best_route.cost == 0.00 + + +def test_19_compound_geo_and_sufficiency_win(gateway: AgentGatewayProtocol): + """ + Tests a chain of filters: Needs geo: US AND security_level: 5. + Expected: Route 2 (Cost 0.10) is the only one that meets both. + """ + intent = IntentPayload( + target_capability='procure:license', + payload={'request': 'US_secure_finance'}, + policy_constraints={'security_level': 5, 'geo': 'US'}, + ) + + best_route = gateway.route_intent(intent) + + assert best_route is not None + assert best_route.path == 'Squad_Finance/payroll_service' + assert best_route.cost == 0.10 diff --git a/extensions/secure-passport/HOWTORUN.md b/extensions/secure-passport/HOWTORUN.md new file mode 100644 index 000000000..0b20cbf9f --- /dev/null +++ b/extensions/secure-passport/HOWTORUN.md @@ -0,0 +1,97 @@ +# HOW TO RUN the Secure Passport Extension Sample + +This guide provides step-by-step instructions for setting up the environment and running the Python sample code for the **Secure Passport Extension v1**. + +The sample is located in the `samples/python/` directory. + +--- + +## 1. Prerequisites + +You need the following installed on your system: + +* **Python** (version 3.9 or higher) +* **Poetry** (Recommended for dependency management via `pyproject.toml`) + +--- + +## 2. Setup and Installation + +1. **From the repository root, navigate** to the sample project directory: + ```bash + cd extensions/secure-passport/v1/samples/python + ``` + +2. **Install Dependencies** using Poetry. This command reads `pyproject.toml`, creates a virtual environment, and installs `pydantic` and `pytest`. + ```bash + poetry install + ``` + +3. **Activate** the virtual environment: + ```bash + poetry shell + ``` + + *(Note: All subsequent commands are run from within this activated environment.)* + +--- + +## 3. Execution + +There are two ways to run the code: using the automated unit tests or using a manual script. + +### A. Run Unit Tests (Recommended) + +Running the tests is the most complete way to verify the extension's data modeling, integrity checks, and validation logic. + +```bash +# Execute Pytest against the test directory +pytest tests/ + +### B. Run Middleware Demo Script + +Execute `run.py` to see the full client/server middleware pipeline in action for all four use cases: + +```bash +python run.py + +### Expected Console Output + +The output below demonstrates the successful execution of the four use cases via the simulated middleware pipeline: + +========================================================= Secure Passport Extension Demo (Middleware) +--- Use Case: Efficient Currency Conversion (via Middleware) --- +[PIPELINE] Client Side: Middleware -> Transport +[Middleware: Client] Attaching Secure Passport for a2a://travel-orchestrator.com +[Transport] Message sent over the wire. +[PIPELINE] Server Side: Middleware -> Agent Core +[Middleware: Server] Extracted Secure Passport. Verified: True +[Agent Core] Task received for processing. +[Agent Core] Executing task with verified context: Currency=GBP, Tier=Silver + +--- Use Case: Personalized Travel Booking (via Middleware) --- +[PIPELINE] Client Side: Middleware -> Transport +[Middleware: Client] Attaching Secure Passport for a2a://travel-portal.com +[Transport] Message sent over the wire. +[PIPELINE] Server Side: Middleware -> Agent Core +[Middleware: Server] Extracted Secure Passport. Verified: True +[Agent Core] Task received for processing. +[Agent Core] Executing task with verified context: Currency=Unknown, Tier=Platinum + +--- Use Case: Proactive Retail Assistance (via Middleware) --- +[PIPELINE] Client Side: Middleware -> Transport +[Middleware: Client] Attaching Secure Passport for a2a://ecommerce-front.com +[Transport] Message sent over the wire. +[PIPELINE] Server Side: Middleware -> Agent Core +[Middleware: Server] Extracted Secure Passport. Verified: False +[Agent Core] Task received for processing. +[Agent Core] Executing task with unverified context (proceeding cautiously). + +--- Use Case: Marketing Agent seek insights (via Middleware) --- +[PIPELINE] Client Side: Middleware -> Transport +[Middleware: Client] Attaching Secure Passport for a2a://marketing-agent.com +[Transport] Message sent over the wire. +[PIPELINE] Server Side: Middleware -> Agent Core +[Middleware: Server] Extracted Secure Passport. Verified: True +[Agent Core] Task received for processing. +[Agent Core] Executing task with verified context: Currency=Unknown, Tier=Standard diff --git a/extensions/secure-passport/README.md b/extensions/secure-passport/README.md new file mode 100644 index 000000000..14712b39f --- /dev/null +++ b/extensions/secure-passport/README.md @@ -0,0 +1,23 @@ +# Secure Passport Extension + +This directory contains the specification and a Python sample implementation for the **Secure Passport Extension v1** for the Agent2Agent (A2A) protocol. + +## Purpose + +The Secure Passport extension introduces a **trusted, contextual layer** for A2A communication. It allows a calling agent to securely and voluntarily share a structured subset of its current contextual state with the callee agent. This is designed to transform anonymous, transactional calls into collaborative partnerships, enabling: + +* **Immediate Personalization:** Specialist agents can use context (like loyalty tier or preferred currency) immediately. +* **Reduced Overhead:** Eliminates the need for multi-turn conversations to establish context. +* **Enhanced Trust:** Includes a **`signature`** field for cryptographic verification of the data's origin and integrity. + +## Specification + +The full technical details, including data models, required fields, and security considerations, are documented here: + +➡️ **[Full Specification (v1)](./v1/spec.md)** + +## Sample Implementation + +A runnable example demonstrating the implementation of the `CallerContext` data model and the utility functions for integration with the A2A SDK is provided in the `samples` directory. + +➡️ **[Python Sample Usage Guide](./v1/samples/python/README.md)** \ No newline at end of file diff --git a/extensions/secure-passport/v1/samples/python/README.md b/extensions/secure-passport/v1/samples/python/README.md new file mode 100644 index 000000000..e2de5a1c2 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/README.md @@ -0,0 +1,148 @@ +# Secure Passport Python Sample + +This sample provides the runnable code for the **Secure Passport Extension v1** for the Agent2Agent (A2A) protocol, demonstrating its implementation and usage in a Python environment. + +## 1. Extension Overview + +The core of this extension is the **`CallerContext`** data model, which is attached to the A2A message metadata under the extension's unique URI. This enables the secure transfer of trusted contextual state between collaborating agents. + +### Extension URI + +The unique identifier for this extension is: +`https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport` + +--- + +## 2. Comprehensive Usage and Middleware Demonstration + +The `run.py` script demonstrates the full client-server pipeline using the conceptual **middleware layers** for seamless integration. + +### A. Use Case Code Demonstration + +The following code demonstrates how to create the specific `CallerContext` payloads for the four core use cases, verifying that the structure and integrity checks work as intended. + +```python +from secure_passport_ext import ( + CallerContext, + A2AMessage, + add_secure_passport, + get_secure_passport +) + +def demonstrate_use_case(title: str, client_id: str, state: dict, signature: str | None = None, session_id: str | None = None): + print(f"\n--- Demonstrating: {title} ---") + + passport = CallerContext( + client_id=client_id, + session_id=session_id, + signature=signature, + state=state + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + if retrieved: + print(f" Source: {retrieved.client_id}") + print(f" Verified: {retrieved.is_verified}") + print(f" Context: {retrieved.state}") + else: + print(" [ERROR] Passport retrieval failed.") + +# 1. Efficient Currency Conversion (Low Context, High Trust) + +demonstrate_use_case( + title="1. Currency Conversion (GBP)", + client_id="a2a://travel-orchestrator.com", + state={"user_preferred_currency": "GBP", "user_id": "U001"}, + signature="sig-currency-1" +) + +# 2. Personalized Travel Booking (High Context, Session Data) + +demonstrate_use_case( + title="2. Personalized Travel (Platinum Tier)", + client_id="a2a://travel-portal.com", + session_id="travel-session-999", + state={ + "destination": "Bali, Indonesia", + "loyalty_tier": "Platinum" + }, + signature="sig-travel-2" +) + +# 3. Proactive Retail Assistance (Unsigned, Quick Context) + +demonstrate_use_case( + title="3. Retail Assistance (Unverified)", + client_id="a2a://ecommerce-front.com", + state={"product_sku": "Nikon-Z-50mm-f1.8", "user_intent": "seeking_reviews"}, + signature=None +) + +# 4. Marketing Agent seek insights (High Trust, Secured Scope) + +demonstrate_use_case( + title="4. Secured DB Access (Finance)", + client_id="a2a://marketing-agent.com", + state={ + "query_type": "quarterly_revenue", + "access_scope": ["read:finance_db", "user:Gulli"] + }, + signature="sig-finance-4" +) +``` + +### B. Convenience Method: AgentCard Declaration + +The `SecurePassportExtension` class provides a static method to easily generate the necessary JSON structure for including this extension in an agent's `AgentCard`. This ensures the structure is always compliant. + +```python +from secure_passport_ext import SecurePassportExtension + +# Scenario 1: Agent supports basic Secure Passport +simple_declaration = SecurePassportExtension.get_agent_card_declaration() +# Output will be: {'uri': '...', 'params': {'receivesCallerContext': True}} + +# Scenario 2: Agent supports specific keys (e.g., the Travel Agent) +travel_keys = ["destination", "loyalty_tier", "dates"] +complex_declaration = SecurePassportExtension.get_agent_card_declaration(travel_keys) +# Output will include: 'supportedStateKeys': ['destination', 'loyalty_tier', 'dates'] +``` + +## 3. How to Run the Sample 🚀 + +To run the sample and execute the comprehensive unit tests, follow these steps. + +### A. Setup and Installation + +1. **Navigate** to the Python sample directory: + ```bash + cd extensions/secure-passport/v1/samples/python + ``` +2. **Install Dependencies** (using Poetry): + ```bash + poetry install + + # Activate the virtual environment + poetry shell + ``` + +### B. Verification and Execution + +#### 1. Run Unit Tests (Recommended) + +Confirm all 11 core logic and validation tests pass: + +```bash +pytest tests/ +``` + +#### 2. Run Middleware Demo Script + +Execute `run.py` to see the full client/server middleware pipeline in action for all four use cases: + +```bash +python run.py +``` diff --git a/extensions/secure-passport/v1/samples/python/poetry.lock b/extensions/secure-passport/v1/samples/python/poetry.lock new file mode 100644 index 000000000..1287032e2 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/poetry.lock @@ -0,0 +1,334 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pydantic" +version = "2.11.9" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, + {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.11\""} + +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[metadata] +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "cd3dd12d740734da980a168ae2b4bbe435aa15f03376e722545d5592844e475f" diff --git a/extensions/secure-passport/v1/samples/python/pyproject.toml b/extensions/secure-passport/v1/samples/python/pyproject.toml new file mode 100644 index 000000000..1106f9da1 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/pyproject.toml @@ -0,0 +1,25 @@ +# pyproject.toml + +[tool.poetry] +name = "secure-passport-ext" +version = "1.0.0" +description = "A2A Protocol Extension Sample: Secure Passport for Contextual State Sharing" +authors = ["Google Octo "] +license = "Apache-2.0" + +# --- FIX: 'packages' is a key directly under [tool.poetry] --- +packages = [ + { include = "secure_passport_ext", from = "src" } +] +# ----------------------------------------------------------- + +[tool.poetry.dependencies] +python = "^3.9" +pydantic = "^2.0.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/extensions/secure-passport/v1/samples/python/run.py b/extensions/secure-passport/v1/samples/python/run.py new file mode 100644 index 000000000..5337684d4 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/run.py @@ -0,0 +1,125 @@ +# run.py + +from secure_passport_ext import ( + CallerContext, + A2AMessage, # CORRECTED: Importing the standardized A2AMessage type + SecurePassportExtension # Import the extension utility class +) + +# --- Define Mock Handlers for the Pipeline --- + +def mock_transport_send(message: A2AMessage): # CORRECTED: Signature uses A2AMessage + """Mocks the final step of the client sending the message over the wire.""" + print(" [Transport] Message sent over the wire.") + return message # Returns the message the server would receive + +def mock_agent_core_handler(message: A2AMessage, passport: CallerContext | None): # CORRECTED: Signature uses A2AMessage + """ + Mocks the agent's core logic, which receives context from the Server Middleware. + """ + print(" [Agent Core] Task received for processing.") + + if passport and passport.is_verified: + # NOTE: Accessing the context attributes with snake_case + currency = passport.state.get("user_preferred_currency", "Unknown") + tier = passport.state.get("loyalty_tier", "Standard") + print(f" [Agent Core] Executing task with verified context: Currency={currency}, Tier={tier}") + elif passport and not passport.is_verified: + print(" [Agent Core] Executing task with unverified context (proceeding cautiously).") + else: + print(" [Agent Core] Executing task with no external context.") + + +def create_and_run_passport_test(client_id: str, session_id: str | None, state: dict, signature: str | None, use_case_title: str): + """ + Demonstrates a full communication cycle using the conceptual middleware. + """ + + print(f"\n--- Use Case: {use_case_title} (via Middleware) ---") + + # 1. Orchestrator (Client) creates the Passport + client_passport = CallerContext( + client_id=client_id, + session_id=session_id, + signature=signature, + state=state + ) + + # Mock A2A Message Container + client_message = A2AMessage() + + # --- CLIENT-SIDE PIPELINE --- + print(" [PIPELINE] Client Side: Middleware -> Transport") + + message_over_wire = SecurePassportExtension.client_middleware( + next_handler=mock_transport_send, + message=client_message, + context=client_passport + ) + + # --- SERVER-SIDE PIPELINE --- + print(" [PIPELINE] Server Side: Middleware -> Agent Core") + + # Server Middleware is executed, wrapping the Agent Core Handler. + SecurePassportExtension.server_middleware( + next_handler=mock_agent_core_handler, + message=message_over_wire + ) + + +def run_all_samples(): + print("=========================================================") + print(" Secure Passport Extension Demo (Middleware)") + print("=========================================================") + + # --- Use Case 1: Efficient Currency Conversion (High Trust Example) --- + create_and_run_passport_test( + client_id="a2a://travel-orchestrator.com", + session_id=None, + state={"user_preferred_currency": "GBP", "loyalty_tier": "Silver"}, + signature="sig-currency-1", + use_case_title="Efficient Currency Conversion" + ) + + # --- Use Case 2: Personalized Travel Booking (High Context Example) --- + create_and_run_passport_test( + client_id="a2a://travel-portal.com", + session_id="travel-booking-session-999", + state={ + "destination": "Bali, Indonesia", + "loyalty_tier": "Platinum" + }, + signature="sig-travel-2", + use_case_title="Personalized Travel Booking" + ) + + # --- Use Case 3: Proactive Retail Assistance (Unsigned/Low Trust Example) --- + create_and_run_passport_test( + client_id="a2a://ecommerce-front.com", + session_id="cart-session-404", + state={ + "product_sku": "Nikon-Z-50mm-f1.8", + "user_intent": "seeking_reviews" + }, + signature=None, # Explicitly missing signature + use_case_title="Proactive Retail Assistance" + ) + + # --- Use Case 4: Marketing Agent seek insights (Secured Scope Example) --- + create_and_run_passport_test( + client_id="a2a://marketing-agent.com", + session_id=None, + state={ + "query_type": "quarterly_revenue", + "access_scope": ["read:finance_db"] + }, + signature="sig-finance-4", + use_case_title="Marketing Agent seek insights" + ) + + +if __name__ == "__main__": + run_all_samples() + + + \ No newline at end of file diff --git a/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/__init__.py b/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/__init__.py new file mode 100644 index 000000000..918396f44 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/__init__.py @@ -0,0 +1,108 @@ +from typing import Optional, Dict, Any, List, Callable +from pydantic import BaseModel, Field, ValidationError, ConfigDict +from copy import deepcopy + +# --- Extension Definition --- + +SECURE_PASSPORT_URI = "https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport" + +class CallerContext(BaseModel): + """ + The Secure Passport payload containing contextual state shared by the calling agent. + """ + # *** CORE CHANGE: agent_id renamed to client_id *** + client_id: str = Field(..., alias='clientId', description="The verifiable unique identifier of the calling client.") + signature: Optional[str] = Field(None, alias='signature', description="A cryptographic signature of the 'state' payload.") + session_id: Optional[str] = Field(None, alias='sessionId', description="A session or conversation identifier for continuity.") + state: Dict[str, Any] = Field(..., description="A free-form JSON object containing the contextual data.") + + # Use ConfigDict for Pydantic V2 compatibility and configuration + model_config = ConfigDict( + populate_by_name=True, + extra='forbid' + ) + + @property + def is_verified(self) -> bool: + """ + Conceptually checks if the passport contains a valid signature. + """ + return self.signature is not None + +# --- Helper Functions (Core Protocol Interaction) --- + +class BaseA2AMessage(BaseModel): + metadata: Dict[str, Any] = Field(default_factory=dict) + +try: + from a2a.types import A2AMessage +except ImportError: + A2AMessage = BaseA2AMessage + +def add_secure_passport(message: A2AMessage, context: CallerContext) -> None: + """Adds the Secure Passport (CallerContext) to the message's metadata.""" + + message.metadata[SECURE_PASSPORT_URI] = context.model_dump(by_alias=True, exclude_none=True) + +def get_secure_passport(message: A2AMessage) -> Optional[CallerContext]: + """Retrieves and validates the Secure Passport from the message metadata.""" + passport_data = message.metadata.get(SECURE_PASSPORT_URI) + if not passport_data: + return None + + try: + return CallerContext.model_validate(deepcopy(passport_data)) + except ValidationError as e: + import logging + logging.warning(f"ERROR: Received malformed Secure Passport data. Ignoring payload: {e}") + return None + +# ====================================================================== +# Convenience and Middleware Concepts +# ====================================================================== + +class SecurePassportExtension: + """ + A conceptual class containing static methods for extension utilities + and defining middleware layers for seamless integration. + """ + @staticmethod + def get_agent_card_declaration(supported_state_keys: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Generates the JSON structure required to declare support for this + extension in an A2A AgentCard. + """ + declaration = { + "uri": SECURE_PASSPORT_URI, + "params": {} + } + if supported_state_keys: + declaration["params"]["supportedStateKeys"] = supported_state_keys + + return declaration + + @staticmethod + def client_middleware(next_handler: Callable[[A2AMessage], Any], message: A2AMessage, context: CallerContext): + """ + [Conceptual Middleware Layer: Client/Calling Agent] + """ + # ACCESS UPDATED: Use context.client_id + print(f"[Middleware: Client] Attaching Secure Passport for {context.client_id}") + add_secure_passport(message, context) + return next_handler(message) + + @staticmethod + def server_middleware(next_handler: Callable[[A2AMessage, Optional[CallerContext]], Any], message: A2AMessage): + """ + [Conceptual Middleware Layer: Server/Receiving Agent] + """ + passport = get_secure_passport(message) + + if passport: + print(f"[Middleware: Server] Extracted Secure Passport. Verified: {passport.is_verified}") + else: + print("[Middleware: Server] No Secure Passport found or validation failed.") + + return next_handler(message, passport) + + diff --git a/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/py.typed b/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/py.typed new file mode 100644 index 000000000..339827c3c --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/src/secure_passport_ext/py.typed @@ -0,0 +1 @@ +# This file is intentionally left empty. diff --git a/extensions/secure-passport/v1/samples/python/tests/test_secure_passport.py b/extensions/secure-passport/v1/samples/python/tests/test_secure_passport.py new file mode 100644 index 000000000..b082b1d38 --- /dev/null +++ b/extensions/secure-passport/v1/samples/python/tests/test_secure_passport.py @@ -0,0 +1,186 @@ +import pytest +from secure_passport_ext import ( + CallerContext, + A2AMessage, + add_secure_passport, + get_secure_passport, + SECURE_PASSPORT_URI, +) + +# ====================================================================== +## Fixtures for Core Tests +# ====================================================================== + +@pytest.fixture +def valid_passport_data(): + """ + Returns a dictionary for creating a valid CallerContext. + Keys are snake_case to align with the final CallerContext model attributes. + """ + return { + "client_id": "a2a://orchestrator.com", # CORRECTED: Changed agent_id to client_id + "session_id": "session-123", + "state": {"currency": "USD", "tier": "silver"}, + "signature": "mock-signature-xyz" + } + +# ====================================================================== +## Core Functionality Tests +# ====================================================================== + +def test_add_and_get_passport_success(valid_passport_data): + """Tests successful serialization and deserialization in a round trip.""" + passport = CallerContext(**valid_passport_data) + message = A2AMessage() + + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved is not None + assert retrieved.client_id == "a2a://orchestrator.com" # CORRECTED: Access via client_id + assert retrieved.state == {"currency": "USD", "tier": "silver"} + +def test_get_passport_when_missing(): + """Tests retrieving a passport from a message that doesn't have one.""" + message = A2AMessage() + retrieved = get_secure_passport(message) + assert retrieved is None + +def test_passport_validation_failure_missing_required_field(valid_passport_data): + """Tests validation fails when a required field (client_id) is missing.""" + invalid_data = valid_passport_data.copy() + del invalid_data['client_id'] # CORRECTED: Deleting client_id key + + message = A2AMessage() + message.metadata[SECURE_PASSPORT_URI] = invalid_data + + retrieved = get_secure_passport(message) + assert retrieved is None + +def test_passport_validation_failure_extra_field(valid_passport_data): + """Tests validation fails when an unknown field is present (due to extra='forbid').""" + invalid_data = valid_passport_data.copy() + invalid_data['extra_field'] = 'unsupported' + + message = A2AMessage() + message.metadata[SECURE_PASSPORT_URI] = invalid_data + + retrieved = get_secure_passport(message) + assert retrieved is None + +def test_passport_is_verified_with_signature(valid_passport_data): + """Tests that the is_verified property is True when a signature is present.""" + passport = CallerContext(**valid_passport_data) + assert passport.is_verified is True + +def test_passport_is_unverified_without_signature(valid_passport_data): + """Tests that the is_verified property is False when the signature is missing.""" + data_without_sig = valid_passport_data.copy() + data_without_sig['signature'] = None + passport = CallerContext(**data_without_sig) + assert passport.is_verified is False + +def test_retrieved_passport_is_immutable_from_message_data(valid_passport_data): + """Tests that modifying the retrieved copy's state does not change the original message metadata (due to deepcopy).""" + passport = CallerContext(**valid_passport_data) + message = A2AMessage() + add_secure_passport(message, passport) + + retrieved = get_secure_passport(message) + retrieved.state['new_key'] = 'changed_value' + + original_data = message.metadata[SECURE_PASSPORT_URI]['state'] + + assert 'new_key' not in original_data + assert original_data['currency'] == 'USD' + + +# ====================================================================== +## Use Case Integration Tests +# ====================================================================== + +def test_use_case_1_currency_conversion(): + """Verifies the structure for passing a user's currency preference.""" + state_data = { + "user_preferred_currency": "GBP", + "user_id": "U001" + } + + passport = CallerContext( + client_id="a2a://travel-orchestrator.com", # CORRECTED: Using client_id keyword + state=state_data, + signature="sig-currency-1" + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved.state.get("user_preferred_currency") == "GBP" + assert retrieved.is_verified is True + +def test_use_case_2_personalized_travel_booking(): + """Verifies the structure for passing detailed session and loyalty data.""" + state_data = { + "destination": "Bali, Indonesia", + "dates": "2025-12-01 to 2025-12-15", + "loyalty_tier": "Platinum" + } + + passport = CallerContext( + client_id="a2a://travel-portal.com", # CORRECTED: Using client_id keyword + session_id="travel-booking-session-999", + state=state_data, + signature="sig-travel-2" + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved.session_id == "travel-booking-session-999" + assert retrieved.state.get("loyalty_tier") == "Platinum" + assert retrieved.is_verified is True + +def test_use_case_3_proactive_retail_assistance(): + """Verifies the structure for passing product context for assistance.""" + state_data = { + "product_sku": "Nikon-Z-50mm-f1.8", + "cart_status": "in_cart", + "user_intent": "seeking_reviews" + } + + passport = CallerContext( + client_id="a2a://ecommerce-front.com", # CORRECTED: Using client_id keyword + state=state_data, + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved.state.get("product_sku") == "Nikon-Z-50mm-f1.8" + assert retrieved.is_verified is False + assert retrieved.session_id is None + +def test_use_case_4_secured_db_insights(): + """Verifies the structure for passing required request arguments for a secured DB/ERP agent.""" + state_data = { + "query_type": "quarterly_revenue", + "time_period": {"start": "2025-07-01", "end": "2025-09-30"}, + "access_scope": ["read:finance_db", "user:Gulli"] + } + + passport = CallerContext( + client_id="a2a://marketing-agent.com", # CORRECTED: Using client_id keyword + state=state_data, + signature="sig-finance-4" + ) + + message = A2AMessage() + add_secure_passport(message, passport) + retrieved = get_secure_passport(message) + + assert retrieved.state.get("query_type") == "quarterly_revenue" + assert "read:finance_db" in retrieved.state.get("access_scope") + assert retrieved.is_verified is True diff --git a/extensions/secure-passport/v1/spec.md b/extensions/secure-passport/v1/spec.md new file mode 100644 index 000000000..0e8c984d1 --- /dev/null +++ b/extensions/secure-passport/v1/spec.md @@ -0,0 +1,133 @@ +# A2A Protocol Extension: Secure Passport (v1) + +- **URI:** `https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport` +- **Type:** Profile Extension / Data-Only Extension +- **Version:** 1.0.0 + +## Abstract + +This extension enables an Agent2Agent (A2A) client to securely and optionally share a structured, verifiable contextual state—the **Secure Passport**—with the callee agent. This context is intended to transform anonymous A2A calls into trusted, context-aware partnerships. + +## 1. Structure and Flow Overview + +The Secure Passport is the core payload (`CallerContext`), which enables a simple, two-part request flow designed for efficiency and trust. + +### A. Primary Payload Fields and Significance + +The `CallerContext` object is placed in the message metadata and must contain the following fields: + +| Field | Significance | +| :--- | :--- | +| **`clientId`** | **Identity:** Uniquely identifies the client/agent originating the context. | +| **`state`** | **Context:** Contains the custom, structured data needed to fulfill the request without further questions. | +| **`signature`** | **Trust:** A digital signature over the `state`, allowing the receiver to cryptographically verify data integrity and origin. | + +### B. Expected Request Flow + +The extension defines two points of interaction (which should typically be handled by SDK middleware): + +1. **Client-Side (Attaching):** The client generates the `CallerContext` (including the signature, if required for high-trust) and inserts the entire payload into he A2A message's metadata map. +2. **Server-Side (Extracting):** The callee agent extracts the `CallerContext` from the metadata, validates the signature, and uses the `state` object to execute the task. + +*** + +## 2. Agent Declaration and Negotiation + +An A2A Agent that is capable of **receiving** and utilizing the Secure Passport context **MUST** declare its support in its `AgentCard` under the **`extensions`** part of the `AgentCapabilities` object. + +### Example AgentCard Declaration + +The callee agent uses the `supportedStateKeys` array to explicitly declare which contextual data keys it understands and is optimized to use. + +```json +{ + "uri": "https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport", + "params": { + "supportedStateKeys": ["user_preferred_currency", "loyalty_tier"] + } +} +``` + +## 3. Data Structure: CallerContext Payload + +The `callerContext` object is the Secure Passport payload. It is **optional** and is included in the `metadata` map of a core A2A message structure. + +| Field | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **`clientId`** | `string` | Yes | The verifiable unique identifier of the calling agent. | +| **`signature`** | `string` | No | A digital signature of the entire `state` object, signed by the calling agent's private key, used for cryptographic verification of trust. | +| **`sessionId`** | `string` | No | A session or conversation identifier to maintain thread continuity. | +| **`state`** | `object` | Yes | A free-form JSON object containing the contextual data (e.g., user preferences, loyalty tier). | + +### Example CallerContext Payload + +```json +{ + "clientId": "a2a://orchestrator-agent.com", + "sessionId": "travel-session-xyz", + "signature": "MOCK-SIG-123456...", + "state": { + "user_preferred_currency": "GBP", + "loyalty_tier": "Gold" + } +} +``` + +## 4. Message Augmentation and Example Usage + +The `CallerContext` payload is embedded directly into the `metadata` map of the A2A `Message` object. The key used **MUST** be the extension's URI: `https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport`. + +### Example A2A Message Request (Simplified) + +This example shows the request body for an A2A `tasks/send` RPC call. + +```json +{ + "jsonrpc": "2.0", + "id": "req-123", + "method": "tasks/send", + "params": { + "message": { + "messageId": "msg-456", + "role": "user", + "parts": [ + {"kind": "text", "content": "Book a flight for me."} + ], + "metadata": { + "https://github.com/a2aproject/a2a-samples/tree/main/samples/python/extensions/secure-passport": { + "clientId": "a2a://orchestrator-agent.com", + "sessionId": "travel-session-xyz", + "signature": "MOCK-SIG-123456...", + "state": { + "user_preferred_currency": "GBP", + "loyalty_tier": "Gold" + } + } + } + } + } +} +``` + +## 5. Implementation Notes and Best Practices + +This section addresses the use of SDK helpers and conceptual implementation patterns. + +### SDK Helper Methods + +For development efficiency, A2A SDKs **SHOULD** provide convenience methods for this extension, such as: + +* **AgentCard Utility:** A method to automatically generate the necessary JSON structure for the AgentCard declaration. +* **Attachment/Extraction:** Simple functions or methods to add (`add_secure_passport`) and retrieve (`get_secure_passport`) the payload from a message object. + +### Conceptual Middleware Layer + +The most robust integration for the Secure Passport involves a **middleware layer** in the A2A SDK: + +* **Client Middleware:** Executes immediately before transport, automatically **attaching** the signed `CallerContext` to the message metadata. +* **Server Middleware:** Executes immediately upon receiving the message, **extracting** the `CallerContext`, performing the cryptographic verification, and injecting the resulting context object into the client's execution environment. + +### Security and Callee Behavior + +1. **Verification:** A callee agent **SHOULD** verify the provided **`signature`** before relying on the `state` content for high-privilege actions. +2. **Sensitive Data:** Agents **MUST NOT** include sensitive or mutable data in the `state` object unless robust, end-to-end cryptographic verification is implemented and required by the callee. diff --git a/samples/dotnet/A2ACliDemo/CLIClient/CLIClient.csproj b/samples/dotnet/A2ACliDemo/CLIClient/CLIClient.csproj new file mode 100644 index 000000000..43f93ddac --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIClient/CLIClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/samples/dotnet/A2ACliDemo/CLIClient/Program.cs b/samples/dotnet/A2ACliDemo/CLIClient/Program.cs new file mode 100644 index 000000000..3df25a12b --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIClient/Program.cs @@ -0,0 +1,199 @@ +using A2A; +using System.Text.Json; + +namespace CLIClient; + +/// +/// Interactive CLI client that demonstrates how to send commands to the CLI Agent. +/// This shows how clients can interact with specialized agents. +/// +internal static class Program +{ + private static readonly string AgentUrl = "http://localhost:5003"; + + static async Task Main(string[] args) + { + Console.WriteLine("🖥️ CLI Agent Client"); + Console.WriteLine("=================="); + Console.WriteLine(); + + try + { + // Test connection and get agent info + await TestAgentConnection(); + + // Start interactive session + await StartInteractiveSession(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine("Make sure the CLI Agent server is running on http://localhost:5003"); + Console.WriteLine("Start it with: cd CLIServer && dotnet run"); + } + } + + /// + /// Tests the connection to the CLI Agent and displays its capabilities. + /// + private static async Task TestAgentConnection() + { + Console.WriteLine("🔍 Connecting to CLI Agent..."); + + // Create agent card resolver + var agentCardResolver = new A2ACardResolver(new Uri(AgentUrl)); + + // Get agent card to verify connection + var agentCard = await agentCardResolver.GetAgentCardAsync(); + + Console.WriteLine($"✅ Connected to: {agentCard.Name}"); + Console.WriteLine($" 📝 Description: {agentCard.Description}"); + Console.WriteLine($" 🔢 Version: {agentCard.Version}"); + Console.WriteLine($" 🎯 Streaming: {agentCard.Capabilities?.Streaming}"); + Console.WriteLine(); + } + + /// + /// Starts an interactive session where users can send commands to the agent. + /// + private static async Task StartInteractiveSession() + { + var agentClient = new A2AClient(new Uri(AgentUrl)); + + Console.WriteLine("🚀 Interactive CLI Session Started!"); + Console.WriteLine("Type commands to execute on the agent (e.g., 'dir', 'git status', 'dotnet --version')"); + Console.WriteLine("Type 'help' for examples, 'exit' to quit"); + Console.WriteLine(); + + while (true) + { + // Get user input + Console.Write("CLI> "); + var input = Console.ReadLine()?.Trim(); + + if (string.IsNullOrEmpty(input)) + continue; + + // Handle special commands + switch (input.ToLower()) + { + case "exit" or "quit": + Console.WriteLine("👋 Goodbye!"); + return; + + case "help": + ShowHelp(); + continue; + + case "examples": + await RunExamples(agentClient); + continue; + + default: + // Send command to agent + await ExecuteCommand(agentClient, input); + break; + } + } + } + + /// + /// Executes a single command through the CLI Agent. + /// + private static async Task ExecuteCommand(A2AClient agentClient, string command) + { + try + { + Console.WriteLine($"⏳ Executing: {command}"); + Console.WriteLine(); + + // Create the message + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = command }] + }; + + // Send to agent and get response + var response = await agentClient.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage) + { + var responseText = responseMessage.Parts?.OfType().FirstOrDefault()?.Text ?? "No response"; + Console.WriteLine(responseText); + } + else + { + Console.WriteLine("❌ Unexpected response type"); + } + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error executing command: {ex.Message}"); + } + + Console.WriteLine(); + } + + /// + /// Shows help information with available commands and examples. + /// + private static void ShowHelp() + { + Console.WriteLine("📚 CLI Agent Help"); + Console.WriteLine("================="); + Console.WriteLine(); + Console.WriteLine("🔧 Available Commands:"); + Console.WriteLine(" • File operations: dir, ls, pwd, cat, type"); + Console.WriteLine(" • System info: whoami, date, time"); + Console.WriteLine(" • Process info: ps, tasklist"); + Console.WriteLine(" • Network: ping, ipconfig, netstat"); + Console.WriteLine(" • Development: git, dotnet, node, npm, python"); + Console.WriteLine(); + Console.WriteLine("💡 Example Commands:"); + Console.WriteLine(" • dir - List current directory (Windows)"); + Console.WriteLine(" • ls -la - List directory with details (Linux/Mac)"); + Console.WriteLine(" • git status - Check git repository status"); + Console.WriteLine(" • dotnet --version - Check .NET version"); + Console.WriteLine(" • ping google.com - Test network connectivity"); + Console.WriteLine(); + Console.WriteLine("🎮 Special Commands:"); + Console.WriteLine(" • help - Show this help"); + Console.WriteLine(" • examples - Run pre-defined examples"); + Console.WriteLine(" • exit - Quit the application"); + Console.WriteLine(); + } + + /// + /// Runs a series of example commands to demonstrate the CLI Agent's capabilities. + /// + private static async Task RunExamples(A2AClient agentClient) + { + Console.WriteLine("🎯 Running Example Commands"); + Console.WriteLine("============================"); + Console.WriteLine(); + + var examples = new[] + { + "whoami", // Show current user + "date", // Show current date + "dotnet --version", // Check .NET version + "git --version", // Check Git version (if available) + System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( + System.Runtime.InteropServices.OSPlatform.Windows) ? "dir" : "ls" // List directory + }; + + foreach (var example in examples) + { + await ExecuteCommand(agentClient, example); + + // Small delay between commands for better readability + await Task.Delay(1000); + } + + Console.WriteLine("✅ Examples completed!"); + Console.WriteLine(); + } +} diff --git a/samples/dotnet/A2ACliDemo/CLIServer/CLIAgent.cs b/samples/dotnet/A2ACliDemo/CLIServer/CLIAgent.cs new file mode 100644 index 000000000..844115815 --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIServer/CLIAgent.cs @@ -0,0 +1,259 @@ +using A2A; +using System.Diagnostics; +using System.Text.Json; +using System.Runtime.InteropServices; + +namespace CLIServer; + +/// +/// Represents the result of a command execution with all relevant details. +/// +/// The full command that was executed +/// The exit code returned by the process +/// The standard output lines from the command +/// The standard error lines from the command +/// Whether the command executed successfully (exit code 0) +internal record CommandExecutionResult( + string Command, + int ExitCode, + IReadOnlyList Output, + IReadOnlyList Errors, + bool Success); + +/// +/// A CLI agent that can execute command-line tools and return results. +/// This demonstrates how to bridge AI agents with system-level operations. +/// +public class CLIAgent +{ + private static readonly HashSet AllowedCommands = new() + { + // Safe read-only commands + "dir", "ls", "pwd", "whoami", "date", "time", + "echo", "cat", "type", "head", "tail", + "ps", "tasklist", "netstat", "ipconfig", "ping", + "git", "dotnet", "node", "npm", "python" + }; + + /// + /// Gets the list of allowed commands that this agent can execute. + /// + public IReadOnlyCollection GetAllowedCommands() => AllowedCommands; + + public void Attach(ITaskManager taskManager) + { + taskManager.OnMessageReceived = ProcessMessageAsync; + taskManager.OnAgentCardQuery = GetAgentCardAsync; + } + + /// + /// Processes incoming messages and executes CLI commands safely. + /// + private async Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + var userText = GetTextFromMessage(messageSendParams.Message); + Console.WriteLine($"[CLI Agent] Received command: {userText}"); + + try + { + // Parse the command + var commandResult = await ExecuteCommandAsync(userText, cancellationToken); + + var responseMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = commandResult }] + }; + + Console.WriteLine($"[CLI Agent] Command executed successfully"); + return responseMessage; + } + catch (Exception ex) + { + var errorText = $"Error executing command '{userText}': {ex.Message}"; + + var errorMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = errorText }] + }; + + Console.WriteLine($"[CLI Agent] Error: {ex.Message}"); + return errorMessage; + } + } + + /// + /// Executes a CLI command safely with security checks. + /// This is the core functionality that makes this agent useful! + /// + private async Task ExecuteCommandAsync(string input, CancellationToken cancellationToken) + { + // Parse command and arguments + var parts = ParseCommand(input); + var command = parts.Command; + var arguments = parts.Arguments; + + // Security check: Only allow whitelisted commands + if (!IsCommandAllowed(command)) + { + return $"❌ Command '{command}' is not allowed for security reasons.\n" + + $"Allowed commands: {string.Join(", ", AllowedCommands)}"; + } + + // Execute the command + using var process = new Process(); + + // Configure process based on operating system + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + process.StartInfo.FileName = "cmd.exe"; + process.StartInfo.Arguments = $"/c {command} {arguments}"; + } + else + { + process.StartInfo.FileName = "/bin/bash"; + process.StartInfo.Arguments = $"-c \"{command} {arguments}\""; + } + + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.CreateNoWindow = true; + + var output = new List(); + var errors = new List(); + + // Capture output and errors + process.OutputDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + output.Add(e.Data); + }; + + process.ErrorDataReceived += (sender, e) => + { + if (!string.IsNullOrEmpty(e.Data)) + errors.Add(e.Data); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // Wait for completion with timeout + await process.WaitForExitAsync(cancellationToken); + + // Process has completed normally + + var result = new CommandExecutionResult( + Command: $"{command} {arguments}", + ExitCode: process.ExitCode, + Output: output.AsReadOnly(), + Errors: errors.AsReadOnly(), + Success: process.ExitCode == 0 + ); + + return FormatCommandResult(result); + } + + /// + /// Parses user input into command and arguments. + /// + private static (string Command, string Arguments) ParseCommand(string input) + { + var trimmed = input.Trim(); + var spaceIndex = trimmed.IndexOf(' '); + + if (spaceIndex == -1) + { + return (trimmed, string.Empty); + } + + return (trimmed.Substring(0, spaceIndex), trimmed.Substring(spaceIndex + 1)); + } + + /// + /// Security check: Ensures only safe commands are executed. + /// This is CRITICAL for security! + /// + private static bool IsCommandAllowed(string command) + { + return AllowedCommands.Contains(command.ToLowerInvariant()); + } + + /// + /// Formats the command execution result in a user-friendly way. + /// + private static string FormatCommandResult(CommandExecutionResult result) + { + var output = new List(); + + output.Add($"🖥️ Command: {result.Command}"); + output.Add($"✅ Exit Code: {result.ExitCode}"); + + if (result.Output.Count > 0) + { + output.Add("\n📤 Output:"); + foreach (string line in result.Output) + { + output.Add($" {line}"); + } + } + + if (result.Errors.Count > 0) + { + output.Add("\n❌ Errors:"); + foreach (string line in result.Errors) + { + output.Add($" {line}"); + } + } + + if (result.Output.Count == 0 && result.Errors.Count == 0) + { + output.Add("\n✅ Command completed successfully (no output)"); + } + + return string.Join("\n", output); + } + + /// + /// Retrieves the agent card information for the CLI Agent. + /// + private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(new AgentCard + { + Name = "CLI Agent", + Description = "Executes command-line tools safely. Supports common commands like 'dir', 'ls', 'git status', 'dotnet build', etc.", + Url = agentUrl, + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = new AgentCapabilities { Streaming = true } + }); + } + + /// + /// Helper method to extract text from a message. + /// + private static string GetTextFromMessage(Message message) + { + return message.Parts?.OfType().FirstOrDefault()?.Text ?? string.Empty; + } +} diff --git a/samples/dotnet/A2ACliDemo/CLIServer/CLIServer.csproj b/samples/dotnet/A2ACliDemo/CLIServer/CLIServer.csproj new file mode 100644 index 000000000..e70a88a9d --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIServer/CLIServer.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/samples/dotnet/A2ACliDemo/CLIServer/Program.cs b/samples/dotnet/A2ACliDemo/CLIServer/Program.cs new file mode 100644 index 000000000..30dd0c334 --- /dev/null +++ b/samples/dotnet/A2ACliDemo/CLIServer/Program.cs @@ -0,0 +1,46 @@ +using A2A; +using A2A.AspNetCore; +using CLIServer; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging for better visibility +builder.Logging.AddConsole(); +builder.Logging.SetMinimumLevel(LogLevel.Information); + +var app = builder.Build(); + +// Create the task manager +var taskManager = new TaskManager(); + +// Create and attach the CLI agent +var cliAgent = new CLIAgent(); +cliAgent.Attach(taskManager); + +// Map the A2A endpoints +app.MapA2A(taskManager, "/"); // JSON-RPC endpoint + +// Add a simple health check +app.MapGet("/health", () => Results.Ok(new +{ + Status = "Healthy", + Agent = "CLI Agent", + Timestamp = DateTimeOffset.UtcNow, + AllowedCommands = cliAgent.GetAllowedCommands() +})); + +// Add a welcome message +app.MapGet("/", () => Results.Ok(new +{ + Message = "🖥️ CLI Agent is running!", + Description = "Send CLI commands like 'dir', 'ls', 'git status', 'dotnet --version'", + Endpoint = "/", + Health = "/health" +})); + +Console.WriteLine("🖥️ CLI Agent starting..."); +Console.WriteLine("📍 Available at: http://localhost:5003"); +Console.WriteLine($"🔧 Allowed commands: {string.Join(", ", cliAgent.GetAllowedCommands())}"); +Console.WriteLine("⚠️ Security: Only whitelisted commands are allowed"); + +app.Run("http://localhost:5003"); diff --git a/samples/dotnet/A2ACliDemo/README.md b/samples/dotnet/A2ACliDemo/README.md new file mode 100644 index 000000000..638b52b3a --- /dev/null +++ b/samples/dotnet/A2ACliDemo/README.md @@ -0,0 +1,149 @@ +# CLI Agent Demo + +This demo shows how to build an A2A agent that can execute command-line tools safely. It demonstrates bridging AI agents with system-level operations. + +## What's Included + +- **CLIServer**: An agent that executes CLI commands with security restrictions +- **CLIClient**: An interactive console client that sends commands to the agent + +## Key Features + +### 🔒 Security First +- **Whitelist approach**: Only safe commands are allowed +- **No dangerous operations**: Commands like `rm`, `del`, `format` are blocked +- **Input validation**: All commands are parsed and validated + +### 🖥️ Cross-Platform Support +- **Windows**: Uses `cmd.exe` for command execution +- **Linux/Mac**: Uses `/bin/bash` for command execution +- **Automatic detection**: Determines the OS at runtime + +### 📊 Rich Output +- **Structured results**: Shows command, exit code, output, and errors +- **Real-time feedback**: Displays execution status +- **Error handling**: Graceful handling of failed commands + +## Getting Started + +### Option 1: Quick Start (Windows) +```bash +run-demo.bat +``` + +### Option 2: Manual Setup + +#### 1. Start the CLI Agent Server +```bash +cd CLIServer +dotnet run +``` +The server will start on `http://localhost:5003` + +#### 2. Run the Interactive Client +```bash +cd CLIClient +dotnet run +``` + +## Example Commands + +### File Operations +```bash +CLI> dir # List directory (Windows) +CLI> ls -la # List directory with details (Linux/Mac) +CLI> pwd # Show current directory +``` + +### System Information +```bash +CLI> whoami # Show current user +CLI> date # Show current date and time +``` + +### Development Tools +```bash +CLI> git status # Check git repository status +CLI> dotnet --version # Check .NET version +CLI> node --version # Check Node.js version +CLI> npm --version # Check npm version +``` + +### Network Commands +```bash +CLI> ping google.com # Test network connectivity +CLI> ipconfig # Show network configuration (Windows) +``` + +## Security Considerations + +### Allowed Commands +The agent only allows these command categories: +- **File operations**: `dir`, `ls`, `pwd`, `cat`, `type`, `head`, `tail` +- **System info**: `whoami`, `date`, `time` +- **Process info**: `ps`, `tasklist` +- **Network**: `ping`, `ipconfig`, `netstat` +- **Development tools**: `git`, `dotnet`, `node`, `npm`, `python` + +### Blocked Commands +Dangerous commands are blocked for security: +- File deletion: `rm`, `del`, `rmdir` +- System modification: `format`, `fdisk`, `sudo` +- Process control: `kill`, `killall` + +### Best Practices +1. **Run with limited privileges**: Don't run as administrator/root +2. **Network isolation**: Consider running in a sandboxed environment +3. **Audit logging**: Monitor command execution in production +4. **Regular updates**: Keep the whitelist updated as needed + +## Project Structure + +```text +CLIAgent/ +├── README.md # This file +├── CLIServer/ +│ ├── CLIServer.csproj # Server project file +│ ├── Program.cs # Server startup code +│ └── CLIAgent.cs # CLI agent implementation +└── CLIClient/ + ├── CLIClient.csproj # Client project file + └── Program.cs # Interactive client implementation +``` + +## Extending the Agent + +### Adding New Commands +1. Add the command to the `AllowedCommands` set in `CLIAgent.cs` +2. Test thoroughly to ensure security +3. Update documentation + +### Custom Command Handlers +You can add special handling for specific commands: + +```csharp +private async Task ExecuteCommandAsync(string input, CancellationToken cancellationToken) +{ + // Special handling for specific commands + if (input.StartsWith("git")) + { + return await ExecuteGitCommand(input, cancellationToken); + } + + // Default command execution + return await ExecuteGenericCommand(input, cancellationToken); +} +``` + +### Output Formatting +Customize how results are presented: + +```csharp +private static string FormatCommandResult(dynamic result) +{ + // Custom formatting based on command type + // Add JSON output, markdown formatting, etc. +} +``` + +This demo provides a foundation for understanding how to safely integrate system-level operations with A2A agents while maintaining security and usability. diff --git a/samples/dotnet/A2ACliDemo/run-demo.bat b/samples/dotnet/A2ACliDemo/run-demo.bat new file mode 100644 index 000000000..2d593ab8d --- /dev/null +++ b/samples/dotnet/A2ACliDemo/run-demo.bat @@ -0,0 +1,19 @@ +@echo off +echo Starting CLI Agent Demo... +echo. + +echo Starting CLI Agent Server... +start "CLI Agent Server" cmd /c "cd CLIServer && dotnet run && pause" + +echo Waiting for server to start... +timeout /t 3 /nobreak > nul + +echo Starting CLI Agent Client... +start "CLI Agent Client" cmd /c "cd CLIClient && dotnet run && pause" + +echo. +echo Both CLI Agent Server and Client are starting in separate windows. +echo The server runs on http://localhost:5003 +echo. +echo Press any key to exit this script... +pause > nul diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIClient/AIClient.csproj b/samples/dotnet/A2ASemanticKernelDemo/AIClient/AIClient.csproj new file mode 100644 index 000000000..07d1efaa4 --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIClient/AIClient.csproj @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIClient/Program.cs b/samples/dotnet/A2ASemanticKernelDemo/AIClient/Program.cs new file mode 100644 index 000000000..3e855fac3 --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIClient/Program.cs @@ -0,0 +1,396 @@ +using A2A; + +namespace AIClient; + +class Program +{ + private const string AI_AGENT_URL = "http://localhost:5000"; + + static async Task Main(string[] args) + { + Console.WriteLine("🤖 A2A Semantic Kernel AI Client"); + Console.WriteLine("=================================="); + Console.WriteLine(); + + try + { + // Test connection by getting capabilities + await TestConnection(); + + Console.WriteLine("✅ Connected successfully!"); + Console.WriteLine(); + + // Show help menu + await ShowHelp(); + + // Main interaction loop + await InteractionLoop(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine("🔧 Troubleshooting:"); + Console.WriteLine(" 1. Make sure the AI Server is running"); + Console.WriteLine(" 2. Check if port 5000 is available"); + Console.WriteLine(" 3. Verify the server URL is correct"); + } + } + + static async Task TestConnection() + { + var client = new A2AClient(new Uri(AI_AGENT_URL)); + + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = "help" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + if (response is not Message) + { + throw new Exception("Failed to connect to AI Agent"); + } + + Console.WriteLine($"📡 Connected to AI Agent at {AI_AGENT_URL}..."); + } + + static async Task InteractionLoop() + { + while (true) + { + Console.Write("\n🎯 Choose an option (1-6, 'help', or 'quit'): "); + var input = Console.ReadLine()?.Trim().ToLower(); + + try + { + switch (input) + { + case "1" or "summarize": + await HandleSummarize(); + break; + case "2" or "sentiment": + await HandleSentiment(); + break; + case "3" or "ideas": + await HandleIdeas(); + break; + case "4" or "translate": + await HandleTranslate(); + break; + case "5" or "demo": + await RunDemoScenarios(); + break; + case "6" or "capabilities": + await ShowCapabilities(); + break; + case "help" or "h" or "?": + await ShowHelp(); + break; + case "quit" or "exit" or "q": + Console.WriteLine("👋 Goodbye!"); + return; + case "": + continue; + default: + Console.WriteLine("❓ Unknown option. Type 'help' for available commands."); + break; + } + } + catch (Exception ex) + { + Console.WriteLine($"❌ Error: {ex.Message}"); + } + } + } + + static async Task HandleSummarize() + { + Console.WriteLine("\n📝 Text Summarization"); + Console.WriteLine("Enter the text you want to summarize (press Enter twice to finish):"); + + var text = ReadMultilineInput(); + if (string.IsNullOrWhiteSpace(text)) + { + Console.WriteLine("❌ No text provided."); + return; + } + + Console.WriteLine("\n🔄 Summarizing..."); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"summarize: {text}" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("\n✅ Summary Result:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Summarization failed."); + } + } + + static async Task HandleSentiment() + { + Console.WriteLine("\n😊 Sentiment Analysis"); + Console.WriteLine("Enter the text to analyze:"); + + var text = ReadMultilineInput(); + if (string.IsNullOrWhiteSpace(text)) + { + Console.WriteLine("❌ No text provided."); + return; + } + + Console.WriteLine("\n🔄 Analyzing sentiment..."); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"sentiment: {text}" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("\n✅ Sentiment Analysis Result:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Sentiment analysis failed."); + } + } + + static async Task HandleIdeas() + { + Console.WriteLine("\n💡 Idea Generation"); + Console.Write("Enter a topic or challenge: "); + var topic = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(topic)) + { + Console.WriteLine("❌ No topic provided."); + return; + } + + Console.WriteLine("\n🔄 Generating ideas..."); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"ideas: {topic}" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("\n✅ Generated Ideas:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Idea generation failed."); + } + } + + static async Task HandleTranslate() + { + Console.WriteLine("\n🌍 Text Translation"); + Console.WriteLine("Enter the text to translate:"); + + var text = ReadMultilineInput(); + if (string.IsNullOrWhiteSpace(text)) + { + Console.WriteLine("❌ No text provided."); + return; + } + + Console.WriteLine("\n🔄 Translating to Spanish..."); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"translate: {text}" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("\n✅ Translation Result:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Translation failed."); + } + } + + static async Task RunDemoScenarios() + { + Console.WriteLine("\n🎬 Running Demo Scenarios..."); + Console.WriteLine("====================================="); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + + // Demo 1: Text Summarization + Console.WriteLine("\n1️⃣ Text Summarization Demo"); + var demoText = "Artificial Intelligence has rapidly evolved over the past decade, transforming industries and reshaping how we work and live. Machine learning algorithms can now process vast amounts of data, recognize patterns, and make predictions with unprecedented accuracy."; + + var message1 = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"summarize: {demoText}" }] + }; + var response1 = await client.SendMessageAsync(new MessageSendParams { Message = message1 }); + if (response1 is Message responseMessage1 && responseMessage1.Parts.Count > 0) + { + var textPart1 = responseMessage1.Parts.OfType().FirstOrDefault(); + if (textPart1 != null) + { + Console.WriteLine("✅ Summary Result:"); + Console.WriteLine(textPart1.Text); + } + } + + // Demo 2: Sentiment Analysis + Console.WriteLine("\n2️⃣ Sentiment Analysis Demo"); + var sentimentText = "I absolutely love working with this new technology! It's incredibly powerful and makes our development process so much more efficient. The team is excited about the possibilities."; + + var message2 = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = $"sentiment: {sentimentText}" }] + }; + var response2 = await client.SendMessageAsync(new MessageSendParams { Message = message2 }); + if (response2 is Message responseMessage2 && responseMessage2.Parts.Count > 0) + { + var textPart2 = responseMessage2.Parts.OfType().FirstOrDefault(); + if (textPart2 != null) + { + Console.WriteLine("✅ Sentiment Result:"); + Console.WriteLine(textPart2.Text); + } + } + + // Demo 3: Idea Generation + Console.WriteLine("\n3️⃣ Idea Generation Demo"); + var message3 = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = "ideas: sustainable software development" }] + }; + var response3 = await client.SendMessageAsync(new MessageSendParams { Message = message3 }); + if (response3 is Message responseMessage3 && responseMessage3.Parts.Count > 0) + { + var textPart3 = responseMessage3.Parts.OfType().FirstOrDefault(); + if (textPart3 != null) + { + Console.WriteLine("✅ Ideas Result:"); + Console.WriteLine(textPart3.Text); + } + } + + Console.WriteLine("\n✅ Demo completed!"); + } + + static async Task ShowCapabilities() + { + Console.WriteLine("\n🔍 AI Agent Capabilities"); + + var client = new A2AClient(new Uri(AI_AGENT_URL)); + var message = new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = "help" }] + }; + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + + if (response is Message responseMessage && responseMessage.Parts.Count > 0) + { + var textPart = responseMessage.Parts.OfType().FirstOrDefault(); + if (textPart != null) + { + Console.WriteLine("✅ Available functions:"); + Console.WriteLine(textPart.Text); + } + } + else + { + Console.WriteLine("❌ Failed to get capabilities."); + } + } + + static Task ShowHelp() + { + Console.WriteLine("🎯 Available Options:"); + Console.WriteLine(); + Console.WriteLine(" 1. 📝 Summarize Text - Condense long text into key points"); + Console.WriteLine(" 2. 😊 Sentiment Analysis - Analyze emotional tone of text"); + Console.WriteLine(" 3. 💡 Generate Ideas - Create brainstorming suggestions"); + Console.WriteLine(" 4. 🌍 Translate Text - Convert text to Spanish"); + Console.WriteLine(" 5. 🎬 Run Demo - See all features in action"); + Console.WriteLine(" 6. 🔍 Show Capabilities - List all AI agent functions"); + Console.WriteLine(); + Console.WriteLine("Commands: help, quit"); + Console.WriteLine(); + + return Task.CompletedTask; + } + + static string ReadMultilineInput() + { + var lines = new List(); + string? line; + + while ((line = Console.ReadLine()) != null) + { + if (string.IsNullOrEmpty(line)) + break; + lines.Add(line); + } + + return string.Join(" ", lines); + } +} diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIAgent.cs b/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIAgent.cs new file mode 100644 index 000000000..29b52607a --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIAgent.cs @@ -0,0 +1,416 @@ +using A2A; +using A2A.AspNetCore; +using Microsoft.SemanticKernel; +using System.ComponentModel; +using System.Text.Json; + +namespace AIServer; + +/// +/// AI Agent that uses Semantic Kernel for intelligent text processing and analysis +/// +public class AIAgent +{ + private readonly Kernel _kernel; + private readonly ILogger _logger; + + public AIAgent(Kernel kernel, ILogger logger) + { + _kernel = kernel; + _logger = logger; + } + + public void Attach(ITaskManager taskManager) + { + taskManager.OnMessageReceived = ProcessMessageAsync; + taskManager.OnAgentCardQuery = GetAgentCardAsync; + } + + /// + /// Handles incoming messages and routes them to the appropriate AI function + /// + private async Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + var userText = GetTextFromMessage(messageSendParams.Message); + _logger.LogInformation("Processing AI request: {UserText}", userText); + + try + { + // Parse the command - for now we'll use simple text parsing + // In production, you'd want more sophisticated parsing + var lowerText = userText.ToLower(); + + if (lowerText.StartsWith("summarize:")) + { + var text = userText.Substring(userText.IndexOf(':') + 1).Trim(); + return await SummarizeTextAsync(text); + } + else if (lowerText.StartsWith("sentiment:")) + { + var text = userText.Substring(userText.IndexOf(':') + 1).Trim(); + return await AnalyzeSentimentAsync(text); + } + else if (lowerText.StartsWith("ideas:")) + { + var topic = userText.Substring(userText.IndexOf(':') + 1).Trim(); + return await GenerateIdeasAsync(topic); + } + else if (lowerText.StartsWith("translate:")) + { + var text = userText.Substring(userText.IndexOf(':') + 1).Trim(); + return await TranslateTextAsync(text, "Spanish"); // Default target language + } + else if (lowerText.Contains("help")) + { + return await GetCapabilitiesAsync(); + } + else + { + return await GetCapabilitiesAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing AI request"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = $"Error processing request: {ex.Message}" }] + }; + } + } + + /// + /// Extracts text content from a message + /// + private string GetTextFromMessage(Message message) + { + if (message.Parts?.Any() == true) + { + foreach (var part in message.Parts) + { + if (part is TextPart textPart && !string.IsNullOrEmpty(textPart.Text)) + { + return textPart.Text; + } + } + } + return string.Empty; + } + + /// + /// Summarizes the provided text using AI + /// + private async Task SummarizeTextAsync(string text) + { + try + { + if (string.IsNullOrWhiteSpace(text)) + { + var errorMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = contextId, + Parts = [new TextPart { Text = "Error: No text provided for summarization" }] + }; + return errorMessage; + } + + _logger.LogInformation("Summarizing text of length: {Length}", text.Length); + + var prompt = $@" +Summarize the following text in 2-3 sentences: + +{text} + +Summary:"; + + var result = await _kernel.InvokePromptAsync(prompt); + var summary = result.GetValue() ?? "Unable to generate summary"; + + var response = new + { + OriginalLength = text.Length, + Summary = summary.Trim(), + CompressionRatio = Math.Round((double)summary.Length / text.Length * 100, 1), + Function = "Text Summarization" + }; + + var responseText = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error summarizing text"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = $"Error: Summarization failed: {ex.Message}" }] + }; + } + } + + /// + /// Analyzes the sentiment of the provided text + /// + private async Task AnalyzeSentimentAsync(string text) + { + try + { + if (string.IsNullOrWhiteSpace(text)) + { + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = "Error: No text provided for sentiment analysis" }] + }; + } + + _logger.LogInformation("Analyzing sentiment for text of length: {Length}", text.Length); + + var prompt = $@" +Analyze the sentiment of the following text and provide: +1. Overall sentiment (Positive, Negative, or Neutral) +2. Confidence score (0-100) +3. Key emotional indicators found in the text + +Text: {text} + +Respond in this format: +Sentiment: [Positive/Negative/Neutral] +Confidence: [0-100] +Emotions: [emotion1, emotion2, ...] +Explanation: [Brief explanation]"; + + var result = await _kernel.InvokePromptAsync(prompt); + var analysis = result.GetValue() ?? "Analysis unavailable"; + + var response = new + { + OriginalText = text, + Analysis = analysis, + Function = "Sentiment Analysis" + }; + + var responseText = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analyzing sentiment"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = $"Error: Sentiment analysis failed: {ex.Message}" }] + }; + } + } + + /// + /// Generates creative ideas based on a topic or prompt + /// + private async Task GenerateIdeasAsync(string topic) + { + try + { + if (string.IsNullOrWhiteSpace(topic)) + { + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = "Error: No topic provided for idea generation" }] + }; + } + + _logger.LogInformation("Generating ideas for topic: {Topic}", topic); + + var prompt = $@" +Generate 5 creative and practical ideas related to: {topic} + +Format each idea as: +- **Idea Name**: Brief description + +Ideas:"; + + var result = await _kernel.InvokePromptAsync(prompt); + var ideas = result.GetValue() ?? "No ideas generated"; + + var response = new + { + Topic = topic, + Ideas = ideas.Trim(), + Function = "Idea Generation" + }; + + var responseText = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating ideas"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = $"Error: Idea generation failed: {ex.Message}" }] + }; + } + } + + /// + /// Translates text between languages + /// + private async Task TranslateTextAsync(string text, string targetLanguage = "Spanish") + { + try + { + if (string.IsNullOrWhiteSpace(text)) + { + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = "Error: No text provided for translation" }] + }; + } + + _logger.LogInformation("Translating text to: {Language}", targetLanguage); + + var prompt = $@" +Translate the following text to {targetLanguage}: + +{text} + +Translation:"; + + var result = await _kernel.InvokePromptAsync(prompt); + var translation = result.GetValue() ?? "Translation unavailable"; + + var response = new + { + OriginalText = text, + TranslatedText = translation.Trim(), + TargetLanguage = targetLanguage, + Function = "Text Translation" + }; + + var responseText = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = true }); + + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error translating text"); + return new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = $"Error: Translation failed: {ex.Message}" }] + }; + } + } + + /// + /// Returns the capabilities of this AI agent + /// + private Task GetCapabilitiesAsync() + { + var capabilities = new + { + Agent = "AI Agent powered by Semantic Kernel", + Functions = new[] + { + "📝 summarize:[text] - Summarizes long text into key points", + "😊 sentiment:[text] - Analyzes emotional sentiment of text", + "💡 ideas:[topic] - Generates creative ideas for any topic", + "🌍 translate:[text] - Translates text to Spanish", + "❓ help - Shows this help information" + }, + Examples = new[] + { + "summarize: Artificial intelligence is revolutionizing the way we work...", + "sentiment: I love this new technology! It's amazing and so helpful.", + "ideas: sustainable software development", + "translate: Hello, how are you today?", + "help" + }, + PoweredBy = "Microsoft Semantic Kernel", + Version = "1.0.0" + }; + + var responseText = JsonSerializer.Serialize(capabilities, new JsonSerializerOptions { WriteIndented = true }); + + return Task.FromResult(new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = "", + Parts = [new TextPart { Text = responseText }] + }); + } + + /// + /// Returns agent information for discovery + /// + private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + { + var agentCard = new AgentCard + { + Name = "AI Agent", + Description = "AI-powered agent using Semantic Kernel for text processing and analysis", + Url = agentUrl, + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = new AgentCapabilities { Streaming = false } + }; + + return Task.FromResult(agentCard); + } +} diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIServer.csproj b/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIServer.csproj new file mode 100644 index 000000000..a39a22ace --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIServer/AIServer.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + + + + + + + + + + diff --git a/samples/dotnet/A2ASemanticKernelDemo/AIServer/Program.cs b/samples/dotnet/A2ASemanticKernelDemo/AIServer/Program.cs new file mode 100644 index 000000000..6596eac2a --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/AIServer/Program.cs @@ -0,0 +1,104 @@ +using A2A; +using A2A.AspNetCore; +using AIServer; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging for better visibility +builder.Logging.AddConsole(); + +// Configure Semantic Kernel +builder.Services.AddKernel(); + +// Configure AI model (you'll need to set up your preferred AI service) +// For Azure OpenAI: +// builder.Services.AddAzureOpenAIChatCompletion( +// deploymentName: "your-deployment-name", +// endpoint: "your-azure-openai-endpoint", +// apiKey: "your-api-key"); + +// For OpenAI: +// builder.Services.AddOpenAIChatCompletion( +// modelId: "gpt-3.5-turbo", +// apiKey: "your-openai-api-key"); + +// For development/testing, you can use a mock service +builder.Services.AddSingleton(provider => +{ + // This is a simple mock for demonstration - replace with real AI service + return new MockChatCompletionService(); +}); + +// Register the AI Agent +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Create the task manager - this handles A2A protocol operations +var taskManager = new TaskManager(); + +// Create and attach the AI agent +var aiAgent = app.Services.GetRequiredService(); +aiAgent.Attach(taskManager); + +// Map the A2A endpoints +app.MapA2A(taskManager, "/"); // JSON-RPC endpoint + +app.Run(); + +/// +/// Mock chat completion service for demonstration purposes +/// Replace this with a real AI service in production +/// +public class MockChatCompletionService : IChatCompletionService +{ + public IReadOnlyDictionary Attributes { get; } = new Dictionary(); + + public Task> GetChatMessageContentsAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + // Simple mock responses based on prompt content + var lastMessage = chatHistory.LastOrDefault()?.Content ?? ""; + + string response = lastMessage.ToLower() switch + { + var msg when msg.Contains("summarize") => "This is a brief summary of the provided text with key points highlighted.", + var msg when msg.Contains("sentiment") => """{"sentiment": "Positive", "confidence": 75, "emotions": ["optimism", "enthusiasm"], "explanation": "The text shows generally positive sentiment with optimistic language."}""", + var msg when msg.Contains("translate") => "Ceci est la traduction française du texte fourni.", + var msg when msg.Contains("ideas") => +""" +- **Digital Innovation**: Leverage technology to create new solutions +- **Sustainable Practices**: Implement eco-friendly approaches +- **Community Engagement**: Build stronger connections with stakeholders +- **Creative Collaboration**: Foster cross-functional teamwork +- **Data-Driven Insights**: Use analytics to guide decision making +""", + _ => "I'm a mock AI service. Please configure a real AI provider (Azure OpenAI, OpenAI, etc.) for full functionality." + }; + + var result = new List + { + new(AuthorRole.Assistant, response) + }; + + return Task.FromResult>(result); + } + + public async IAsyncEnumerable GetStreamingChatMessageContentsAsync( + ChatHistory chatHistory, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var messages = await GetChatMessageContentsAsync(chatHistory, executionSettings, kernel, cancellationToken); + foreach (var message in messages) + { + yield return new StreamingChatMessageContent(message.Role, message.Content); + } + } +} diff --git a/samples/dotnet/A2ASemanticKernelDemo/README.md b/samples/dotnet/A2ASemanticKernelDemo/README.md new file mode 100644 index 000000000..082f37400 --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/README.md @@ -0,0 +1,129 @@ +# A2A Semantic Kernel AI Demo + +This demo showcases how to build **AI-powered agents** using the A2A framework with Microsoft Semantic Kernel. The demo includes intelligent text processing capabilities like summarization, sentiment analysis, idea generation, and translation. + +## 🎯 What You'll Learn + +- **AI Agent Integration**: How to combine A2A with Semantic Kernel +- **Intelligent Functions**: Building agents that understand and process natural language +- **AI Service Configuration**: Setting up different AI providers (Azure OpenAI, OpenAI, etc.) +- **Advanced Scenarios**: Real-world AI agent use cases + +## 🚀 Quick Start + +### Option 1: One-Click Demo +```bash +run_demo.bat +``` + +### Option 2: Manual Setup + +**Terminal 1 - AI Server:** +```bash +cd AIServer +dotnet run --urls=http://localhost:5000 +``` + +**Terminal 2 - AI Client:** +```bash +cd AIClient +dotnet run +``` + +## 🤖 Available AI Functions + +### 📝 Text Summarization +- **Function**: `summarize_text` +- **Purpose**: Condenses long text into key points +- **Example**: Summarize articles, reports, or documentation + +### 😊 Sentiment Analysis +- **Function**: `analyze_sentiment` +- **Purpose**: Analyzes emotional tone and sentiment +- **Example**: Evaluate customer feedback or social media content + +### 💡 Idea Generation +- **Function**: `generate_ideas` +- **Purpose**: Generates creative suggestions for any topic +- **Example**: Brainstorming, problem-solving, innovation + +### 🌍 Text Translation +- **Function**: `translate_text` +- **Purpose**: Translates between different languages +- **Example**: Multilingual communication and content localization + +### 🔍 Capabilities Discovery +- **Function**: `get_capabilities` +- **Purpose**: Lists all available AI functions +- **Example**: Dynamic discovery of agent capabilities + +## 🛠️ Configuration + +### AI Service Setup + +The demo includes a **mock AI service** for immediate testing. For production use, configure a real AI provider. + +### Environment Variables +```bash +# For Azure OpenAI +AZURE_OPENAI_ENDPOINT=your-endpoint +AZURE_OPENAI_API_KEY=your-key +AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment + +# For OpenAI +OPENAI_API_KEY=your-key +``` + +## 🎬 Demo Scenarios + +### 1. Document Summarization +```text +Input: Long research paper or article +Output: Concise 2-3 sentence summary with key insights +``` + +### 2. Customer Feedback Analysis +```text +Input: Customer reviews or feedback +Output: Sentiment classification with confidence scores +``` + +### 3. Creative Brainstorming +```text +Input: Business challenge or topic +Output: Multiple creative solutions and approaches +``` + +### 4. Multilingual Content +```text +Input: Text in any language +Output: Professional translation to target language +``` + +## 🏗️ Architecture + +```text +┌─────────────────┐ HTTP/A2A ┌─────────────────┐ +│ AI Client │ ──────────────► │ AI Server │ +│ │ │ │ +│ • Interactive │ │ • AIAgent │ +│ • Demonstrations│ │ • Semantic │ +│ • Examples │ │ Kernel │ +└─────────────────┘ │ • AI Functions │ + └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ AI Provider │ + │ │ + │ • Azure OpenAI │ + │ • OpenAI │ + │ • Other Models │ + └─────────────────┘ +``` + +## 🎓 Learning Resources + +- **[Semantic Kernel Documentation](https://learn.microsoft.com/en-us/semantic-kernel/)** +- **[A2A Framework Guide](../README.md)** +- **[Azure OpenAI Service](https://azure.microsoft.com/en-us/products/ai-services/openai-service)** diff --git a/samples/dotnet/A2ASemanticKernelDemo/run_demo.bat b/samples/dotnet/A2ASemanticKernelDemo/run_demo.bat new file mode 100644 index 000000000..4d444ae07 --- /dev/null +++ b/samples/dotnet/A2ASemanticKernelDemo/run_demo.bat @@ -0,0 +1,37 @@ +@echo off +echo =================================== +echo A2A Semantic Kernel AI Demo +echo =================================== +echo. + +echo Starting AI Server (Semantic Kernel)... +echo. + +cd /d "%~dp0AIServer" +start "AI Server" cmd /k "echo AI Server starting... && dotnet run --urls=http://localhost:5000" + +echo Waiting for server to start... +timeout /t 5 /nobreak >nul + +echo. +echo Starting AI Client... +echo. + +cd /d "%~dp0AIClient" +start "AI Client" cmd /k "echo AI Client starting... && dotnet run" + +echo. +echo Both applications are starting in separate windows. +echo. +echo What's running: +echo AI Server: http://localhost:5000 (Semantic Kernel AI Agent) +echo AI Client: Interactive console application +echo. +echo Try these features: +echo Text summarization +echo Sentiment analysis +echo Idea generation +echo Text translation +echo. +echo Press any key to close this window... +pause >nul diff --git a/samples/dotnet/BasicA2ADemo/A2ADotnetSample.sln b/samples/dotnet/BasicA2ADemo/A2ADotnetSample.sln new file mode 100644 index 000000000..b673dad7d --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/A2ADotnetSample.sln @@ -0,0 +1,62 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CalculatorServer", "CalculatorServer\CalculatorServer.csproj", "{7A1CD73A-1874-4E77-95B6-F7ED658E5273}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleClient", "SimpleClient\SimpleClient.csproj", "{78BDD62D-4849-4AE8-AB56-5DEE8AB23333}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoServer", "EchoServer\EchoServer.csproj", "{BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|x64.Build.0 = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Debug|x86.Build.0 = Debug|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|Any CPU.Build.0 = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|x64.ActiveCfg = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|x64.Build.0 = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|x86.ActiveCfg = Release|Any CPU + {7A1CD73A-1874-4E77-95B6-F7ED658E5273}.Release|x86.Build.0 = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|x64.ActiveCfg = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|x64.Build.0 = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|x86.ActiveCfg = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Debug|x86.Build.0 = Debug|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|Any CPU.Build.0 = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|x64.ActiveCfg = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|x64.Build.0 = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|x86.ActiveCfg = Release|Any CPU + {78BDD62D-4849-4AE8-AB56-5DEE8AB23333}.Release|x86.Build.0 = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|x64.ActiveCfg = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|x64.Build.0 = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|x86.ActiveCfg = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Debug|x86.Build.0 = Debug|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|Any CPU.Build.0 = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|x64.ActiveCfg = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|x64.Build.0 = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|x86.ActiveCfg = Release|Any CPU + {BFD3E0BD-3F2E-4145-AE8B-C661F99E1D47}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorAgent.cs b/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorAgent.cs new file mode 100644 index 000000000..86efd1c57 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorAgent.cs @@ -0,0 +1,129 @@ +using A2A; +using System.Text.RegularExpressions; + +namespace CalculatorServer; + +/// +/// A simple calculator agent that can perform basic math operations. +/// This demonstrates how to implement business logic in an A2A agent. +/// +public class CalculatorAgent +{ + public void Attach(ITaskManager taskManager) + { + taskManager.OnMessageReceived = ProcessMessageAsync; + taskManager.OnAgentCardQuery = GetAgentCardAsync; + } + + /// + /// Handles incoming messages and performs calculations. + /// + private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + var userText = GetTextFromMessage(messageSendParams.Message); + + Console.WriteLine($"[Calculator Agent] Received expression: {userText}"); + + try + { + var result = EvaluateExpression(userText); + var responseText = $"{userText} = {result}"; + + var responseMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = responseText }] + }; + + Console.WriteLine($"[Calculator Agent] Calculated result: {responseText}"); + + return Task.FromResult(responseMessage); + } + catch (Exception ex) + { + var errorText = $"Sorry, I couldn't calculate '{userText}'. Error: {ex.Message}. Please try a simple expression like '5 + 3' or '10 * 2'."; + + var errorMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = errorText }] + }; + + Console.WriteLine($"[Calculator Agent] Error: {ex.Message}"); + + return Task.FromResult(errorMessage); + } + } + + /// + /// Retrieves the agent card information for the Calculator Agent. + /// + private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(new AgentCard + { + Name = "Simple Calculator Agent", + Description = "A basic calculator that can perform addition, subtraction, multiplication, and division. Send math expressions like '5 + 3' or '10 * 2'.", + Url = agentUrl, + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = new AgentCapabilities { Streaming = true } + }); + } + + /// + /// Helper method to extract text from a message. + /// + private static string GetTextFromMessage(Message message) + { + var textPart = message.Parts.OfType().FirstOrDefault(); + return textPart?.Text ?? ""; + } + + /// + /// Evaluates a simple math expression. + /// Supports +, -, *, / operations with decimal numbers. + /// + private static double EvaluateExpression(string expression) + { + // Clean up the expression + expression = expression.Trim(); + + // Use regex to parse simple expressions like "5 + 3" or "10.5 * 2" + var pattern = @"^\s*(-?\d+(?:\.\d+)?)\s*([+\-*/])\s*(-?\d+(?:\.\d+)?)\s*$"; + var match = Regex.Match(expression, pattern); + + if (!match.Success) + { + throw new ArgumentException("Please use format like '5 + 3' or '10.5 * 2'. I support +, -, *, / operations."); + } + + var leftOperand = double.Parse(match.Groups[1].Value); + var operation = match.Groups[2].Value; + var rightOperand = double.Parse(match.Groups[3].Value); + + return operation switch + { + "+" => leftOperand + rightOperand, + "-" => leftOperand - rightOperand, + "*" => leftOperand * rightOperand, + "/" => rightOperand == 0 ? throw new DivideByZeroException("Cannot divide by zero") : leftOperand / rightOperand, + _ => throw new ArgumentException($"Unsupported operation: {operation}") + }; + } +} diff --git a/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorServer.csproj b/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorServer.csproj new file mode 100644 index 000000000..e70a88a9d --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/CalculatorServer/CalculatorServer.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/samples/dotnet/BasicA2ADemo/CalculatorServer/Program.cs b/samples/dotnet/BasicA2ADemo/CalculatorServer/Program.cs new file mode 100644 index 000000000..a116a721e --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/CalculatorServer/Program.cs @@ -0,0 +1,55 @@ +using A2A; +using A2A.AspNetCore; +using CalculatorServer; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging for better visibility +builder.Logging.AddConsole(); + +var app = builder.Build(); + +// Create the task manager - this handles A2A protocol operations +var taskManager = new TaskManager(); + +// Create and attach the calculator agent +var calculatorAgent = new CalculatorAgent(); +calculatorAgent.Attach(taskManager); + +// Map the A2A endpoints +app.MapA2A(taskManager, "/"); // JSON-RPC endpoint + +// Add a simple health check +app.MapGet("/health", () => Results.Ok(new +{ + Status = "Healthy", + Agent = "Calculator Agent", + Timestamp = DateTimeOffset.UtcNow +})); + +// Add a welcome message +app.MapGet("/", () => Results.Ok(new +{ + Message = "Calculator Agent Server is running!", + Examples = new[] { + "5 + 3", + "10 - 4", + "7 * 8", + "15 / 3" + }, + Endpoints = new + { + AgentCard = "/.well-known/agent.json", + A2A = "/ (POST for JSON-RPC)", + Health = "/health" + } +})); + +Console.WriteLine("🧮 Calculator Agent Server starting..."); +Console.WriteLine("🌐 Server will be available at: http://localhost:5002"); +Console.WriteLine("📋 Agent card: http://localhost:5002/.well-known/agent.json"); +Console.WriteLine("🔍 Health check: http://localhost:5002/health"); +Console.WriteLine("💬 Send math expressions via A2A protocol to: http://localhost:5002/"); +Console.WriteLine("📝 Example expressions: '5 + 3', '10 * 2', '15 / 3'"); + +app.Run("http://localhost:5002"); diff --git a/samples/dotnet/BasicA2ADemo/EchoServer/EchoAgent.cs b/samples/dotnet/BasicA2ADemo/EchoServer/EchoAgent.cs new file mode 100644 index 000000000..1df4dc349 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/EchoServer/EchoAgent.cs @@ -0,0 +1,78 @@ +using A2A; + +namespace EchoServer; + +/// +/// A simple echo agent that responds with the same message it receives. +/// This demonstrates the basic structure of an A2A agent. +/// +public class EchoAgent +{ + public void Attach(ITaskManager taskManager) + { + taskManager.OnMessageReceived = ProcessMessageAsync; + taskManager.OnAgentCardQuery = GetAgentCardAsync; + } + + /// + /// Handles incoming messages and echoes them back. + /// + private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + // Extract the text from the incoming message + var userText = GetTextFromMessage(messageSendParams.Message); + + Console.WriteLine($"[Echo Agent] Received message: {userText}"); + + // Create an echo response + var responseText = $"Echo: {userText}"; + + var responseMessage = new Message + { + Role = MessageRole.Agent, + MessageId = Guid.NewGuid().ToString(), + ContextId = messageSendParams.Message.ContextId, + Parts = [new TextPart { Text = responseText }] + }; + + Console.WriteLine($"[Echo Agent] Sending response: {responseText}"); + + return Task.FromResult(responseMessage); + } + + /// + /// Retrieves the agent card information for the Echo Agent. + /// + private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(new AgentCard + { + Name = "Simple Echo Agent", + Description = "A basic agent that echoes back any message you send to it. Perfect for testing A2A communication.", + Url = agentUrl, + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = new AgentCapabilities { Streaming = true } + }); + } + + /// + /// Helper method to extract text from a message. + /// + private static string GetTextFromMessage(Message message) + { + var textPart = message.Parts.OfType().FirstOrDefault(); + return textPart?.Text ?? "[No text content]"; + } +} diff --git a/samples/dotnet/BasicA2ADemo/EchoServer/EchoServer.csproj b/samples/dotnet/BasicA2ADemo/EchoServer/EchoServer.csproj new file mode 100644 index 000000000..e70a88a9d --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/EchoServer/EchoServer.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/samples/dotnet/BasicA2ADemo/EchoServer/Program.cs b/samples/dotnet/BasicA2ADemo/EchoServer/Program.cs new file mode 100644 index 000000000..2fa57da0a --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/EchoServer/Program.cs @@ -0,0 +1,48 @@ +using A2A; +using A2A.AspNetCore; +using EchoServer; + +var builder = WebApplication.CreateBuilder(args); + +// Add logging for better visibility +builder.Logging.AddConsole(); + +var app = builder.Build(); + +// Create the task manager - this handles A2A protocol operations +var taskManager = new TaskManager(); + +// Create and attach the echo agent +var echoAgent = new EchoAgent(); +echoAgent.Attach(taskManager); + +// Map the A2A endpoints +app.MapA2A(taskManager, "/"); // JSON-RPC endpoint + +// Add a simple health check +app.MapGet("/health", () => Results.Ok(new +{ + Status = "Healthy", + Agent = "Echo Agent", + Timestamp = DateTimeOffset.UtcNow +})); + +// Add a welcome message +app.MapGet("/", () => Results.Ok(new +{ + Message = "Echo Agent Server is running!", + Endpoints = new + { + AgentCard = "/.well-known/agent.json", + A2A = "/ (POST for JSON-RPC)", + Health = "/health" + } +})); + +Console.WriteLine("🔊 Echo Agent Server starting..."); +Console.WriteLine("🌐 Server will be available at: http://localhost:5001"); +Console.WriteLine("📋 Agent card: http://localhost:5001/.well-known/agent.json"); +Console.WriteLine("🔍 Health check: http://localhost:5001/health"); +Console.WriteLine("💬 Send messages via A2A protocol to: http://localhost:5001/"); + +app.Run("http://localhost:5001"); diff --git a/samples/dotnet/BasicA2ADemo/README.md b/samples/dotnet/BasicA2ADemo/README.md new file mode 100644 index 000000000..eeb8bbe8e --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/README.md @@ -0,0 +1,92 @@ +# Basic A2A .NET Demo + +This is a simple demonstration of the A2A (Agent-to-Agent) .NET SDK that shows the basics of agent communication. + +## What's Included + +- **EchoServer**: A simple agent that echoes back any message you send to it +- **CalculatorServer**: A basic calculator agent that can perform simple math operations +- **SimpleClient**: A console application that demonstrates how to communicate with both agents + +## Getting Started + +### Option 1: Quick Start (Windows) + +Use the provided batch script to run everything automatically: + +```bash +run-demo.bat +``` + +This will start both agent servers and the client in separate windows. + +### Option 2: Manual Setup + +#### 1. Start the Agent Servers + +In separate terminals, start each server: + +```bash +# Start the Echo Agent (runs on port 5001) +cd EchoServer +dotnet run + +# Start the Calculator Agent (runs on port 5002) +cd CalculatorServer +dotnet run +``` + +#### 2. Run the Client + +In another terminal: + +```bash +cd SimpleClient +dotnet run +``` + +The client will automatically discover and communicate with both agents, demonstrating: +- Agent discovery via agent cards +- Message-based communication +- Task-based communication +- Error handling + +## Key Concepts Demonstrated + +### Agent Discovery +- How agents advertise their capabilities through agent cards +- How clients discover and connect to agents + +### Message-Based Communication +- Simple request-response pattern +- Immediate responses without task tracking + +### Task-Based Communication +- Creating persistent tasks +- Tracking task progress and status +- Retrieving task results + +### Multiple Agents +- Running multiple agents simultaneously +- Client communicating with different agent types +- Agent-specific functionality + +## Project Structure + +```text +BasicA2ADemo/ +├── README.md # This file +├── EchoServer/ +│ ├── EchoServer.csproj # Echo agent project +│ ├── Program.cs # Echo server startup +│ └── EchoAgent.cs # Echo agent implementation +├── CalculatorServer/ +│ ├── CalculatorServer.csproj # Calculator agent project +│ ├── Program.cs # Calculator server startup +│ └── CalculatorAgent.cs # Calculator agent implementation +└── SimpleClient/ + ├── SimpleClient.csproj # Client project + └── Program.cs # Client implementation +``` + +This demo provides a foundation for understanding how to build agent-to-agent communication systems with the A2A .NET SDK. diff --git a/samples/dotnet/BasicA2ADemo/SimpleClient/Program.cs b/samples/dotnet/BasicA2ADemo/SimpleClient/Program.cs new file mode 100644 index 000000000..b0545d305 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/SimpleClient/Program.cs @@ -0,0 +1,186 @@ +using A2A; + +namespace SimpleClient; + +/// +/// A simple client that demonstrates how to communicate with A2A agents. +/// This shows the basic patterns for agent discovery and communication. +/// +class Program +{ + static async Task Main(string[] args) + { + Console.WriteLine("🤖 Basic A2A .NET Demo Client"); + Console.WriteLine("=============================="); + Console.WriteLine(); + + try + { + // Demonstrate agent discovery and communication + await DemonstrateAgentCommunication(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ An error occurred: {ex.Message}"); + Console.WriteLine("💡 Make sure both agent servers are running:"); + Console.WriteLine(" - Echo Agent: http://localhost:5001"); + Console.WriteLine(" - Calculator Agent: http://localhost:5002"); + } + + Console.WriteLine(); + Console.WriteLine("Demo completed! Press any key to exit."); + Console.ReadKey(); + } + + /// + /// Demonstrates the complete workflow of discovering and communicating with agents. + /// + static async Task DemonstrateAgentCommunication() + { + // Define our agent endpoints + var agents = new[] + { + new { Name = "Echo Agent", Url = "http://localhost:5001/" }, + new { Name = "Calculator Agent", Url = "http://localhost:5002/" } + }; + + foreach (var agentInfo in agents) + { + Console.WriteLine($"🔍 Discovering {agentInfo.Name}..."); + + try + { + // Step 1: Discover the agent and get its capabilities + var agentCard = await DiscoverAgent(agentInfo.Url); + + // Step 2: Communicate with the agent + await CommunicateWithAgent(agentInfo.Name, agentInfo.Url, agentCard); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to communicate with {agentInfo.Name}: {ex.Message}"); + } + + Console.WriteLine(); + } + } + + /// + /// Discovers an agent by fetching its agent card. + /// + static async Task DiscoverAgent(string agentUrl) + { + var cardResolver = new A2ACardResolver(new Uri(agentUrl)); + var agentCard = await cardResolver.GetAgentCardAsync(); + + Console.WriteLine($"✅ Found agent: {agentCard.Name}"); + Console.WriteLine($" 📝 Description: {agentCard.Description}"); + Console.WriteLine($" 🔢 Version: {agentCard.Version}"); + Console.WriteLine($" 🎯 Capabilities: Streaming = {agentCard.Capabilities?.Streaming}"); + + // Show additional agent information + if (agentCard.DefaultInputModes?.Count > 0) + { + Console.WriteLine($" 📥 Input modes: {string.Join(", ", agentCard.DefaultInputModes)}"); + } + + if (agentCard.DefaultOutputModes?.Count > 0) + { + Console.WriteLine($" 📤 Output modes: {string.Join(", ", agentCard.DefaultOutputModes)}"); + } + + return agentCard; + } + + /// + /// Demonstrates communication with a discovered agent. + /// + static async Task CommunicateWithAgent(string agentName, string agentUrl, AgentCard agentCard) + { + Console.WriteLine($"💬 Communicating with {agentName}..."); + + // Create an A2A client for this agent + var client = new A2AClient(new Uri(agentUrl)); + + // Send appropriate messages based on the agent type + if (agentName.Contains("Echo")) + { + await SendEchoMessages(client); + } + else if (agentName.Contains("Calculator")) + { + await SendCalculatorMessages(client); + } + } + + /// + /// Sends test messages to the Echo Agent. + /// + static async Task SendEchoMessages(A2AClient client) + { + var testMessages = new[] + { + "Hello, Echo Agent!", + "Can you repeat this message?", + "Testing A2A communication 🚀" + }; + + foreach (var testMessage in testMessages) + { + var message = CreateMessage(testMessage); + Console.WriteLine($" 📤 Sending: {testMessage}"); + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + var responseText = GetTextFromMessage((Message)response); + + Console.WriteLine($" 📥 Received: {responseText}"); + } + } + + /// + /// Sends math expressions to the Calculator Agent. + /// + static async Task SendCalculatorMessages(A2AClient client) + { + var mathExpressions = new[] + { + "5 + 3", + "10 * 7", + "15 / 3", + "20.5 - 5.3" + }; + + foreach (var expression in mathExpressions) + { + var message = CreateMessage(expression); + Console.WriteLine($" 📤 Calculating: {expression}"); + + var response = await client.SendMessageAsync(new MessageSendParams { Message = message }); + var responseText = GetTextFromMessage((Message)response); + + Console.WriteLine($" 📥 Result: {responseText}"); + } + } + + /// + /// Creates a message with the specified text. + /// + static Message CreateMessage(string text) + { + return new Message + { + Role = MessageRole.User, + MessageId = Guid.NewGuid().ToString(), + Parts = [new TextPart { Text = text }] + }; + } + + /// + /// Extracts text from a message response. + /// + static string GetTextFromMessage(Message message) + { + var textPart = message.Parts.OfType().FirstOrDefault(); + return textPart?.Text ?? "[No text content]"; + } +} diff --git a/samples/dotnet/BasicA2ADemo/SimpleClient/SimpleClient.csproj b/samples/dotnet/BasicA2ADemo/SimpleClient/SimpleClient.csproj new file mode 100644 index 000000000..3ae9727fa --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/SimpleClient/SimpleClient.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/samples/dotnet/BasicA2ADemo/run-demo.bat b/samples/dotnet/BasicA2ADemo/run-demo.bat new file mode 100644 index 000000000..89b94be25 --- /dev/null +++ b/samples/dotnet/BasicA2ADemo/run-demo.bat @@ -0,0 +1,41 @@ +@echo off +echo. +echo Basic A2A .NET Demo +echo ====================== +echo. +echo This script will start both agent servers and then run the client. +echo. +echo Starting agents in separate windows... +echo. + +REM Start Echo Agent in a new window +start "Echo Agent Server" cmd /k "cd EchoServer && dotnet run && pause" + +REM Wait a moment for the first server to start +timeout /t 3 /nobreak > nul + +REM Start Calculator Agent in a new window +start "Calculator Agent Server" cmd /k "cd CalculatorServer && dotnet run && pause" + +REM Wait a moment for servers to fully start +echo Waiting for servers to start... +timeout /t 5 /nobreak > nul + +echo. +echo Agent servers should be starting in separate windows +echo Echo Agent: http://localhost:5001 +echo Calculator Agent: http://localhost:5002 +echo. +echo Press any key to run the client demo... +pause > nul + +REM Run the client +echo. +echo Running client demo... +cd SimpleClient +dotnet run + +echo. +echo Demo completed! The agent servers are still running in separate windows. +echo Close those windows when you're done experimenting. +pause diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md new file mode 100644 index 000000000..6306b29d8 --- /dev/null +++ b/samples/dotnet/README.md @@ -0,0 +1,54 @@ +# .NET A2A Samples + +This folder contains .NET demonstrations of the A2A (Agent-to-Agent) SDK, showcasing different patterns and use cases for building intelligent agents. + +## Available Demos + +### 🏗️ BasicA2ADemo +A foundational example demonstrating core A2A concepts with simple agents. + +**What's included:** +- **EchoServer**: Basic message echoing agent +- **CalculatorServer**: Simple math operations agent +- **SimpleClient**: Interactive client for both agents + +### 🖥️ CLIAgent (A2ACliDemo) +Shows how to build agents that can execute system commands safely. + +**What's included:** +- **CLIServer**: Agent that executes whitelisted CLI commands +- **CLIClient**: Interactive command-line interface + +### 🤖 AI-Powered Agent (A2ASemanticKernelDemo) +Demonstrates intelligent agents using Microsoft Semantic Kernel for AI capabilities. + +**What's included:** +- **AIServer**: Intelligent agent with text processing capabilities +- **AIClient**: Interactive client for AI features + +**AI Capabilities:** +- 📝 Text summarization +- 😊 Sentiment analysis +- 💡 Idea generation +- 🌍 Text translation + +## Getting Started + +Each demo includes: +- 📖 Detailed README with setup instructions +- 🚀 Quick-start batch scripts for Windows +- 💡 Example commands and use cases +- 🔧 Complete source code with comments + +## Requirements + +- .NET 9.0 SDK +- Windows, Linux, or macOS +- A2A SDK (included via NuGet) + +## Quick Start + +1. **Choose a demo** from the list above +2. **Follow the README** in that demo's folder +3. **Run the batch script** for instant setup, or +4. **Manual setup** with `dotnet run` commands diff --git a/samples/java/agents/README.md b/samples/java/agents/README.md index 6027374ee..d405553a3 100644 --- a/samples/java/agents/README.md +++ b/samples/java/agents/README.md @@ -15,6 +15,13 @@ Each agent can be run as its own A2A server with the instructions in its README. * [**Weather Agent**](weather_mcp/README.md) Sample agent to provide weather information. To make use of this agent in a multi-language, multi-agent system, check out the [weather_and_airbnb_planner](../../python/hosts/weather_and_airbnb_planner/README.md) sample. +* [**Dice Agent (Multi-Transport)**](dice_agent_multi_transport/README.md) + Sample agent that can roll dice of different sizes and check if numbers are prime. This agent demonstrates + multi-transport capabilities. + +* [**Magic 8 Ball Agent (Security)**](magic_8_ball_security/README.md) + Sample agent that can respond to yes/no questions by consulting a Magic 8 Ball. This sample demonstrates how to secure an A2A server with Keycloak using bearer token authentication and it shows how to configure an A2A client to specify the token when sending requests. + ## Disclaimer Important: The sample code provided is for demonstration purposes and illustrates the diff --git a/samples/java/agents/content_editor/pom.xml b/samples/java/agents/content_editor/pom.xml index 099b19d3c..afe616fa4 100644 --- a/samples/java/agents/content_editor/pom.xml +++ b/samples/java/agents/content_editor/pom.xml @@ -4,31 +4,13 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.samples.a2a - content-editor - 0.1.0 - - - 17 - 17 - UTF-8 - 0.3.0.Alpha1 - 4.1.0 - 3.22.3 - 1.0.0 - + + com.samples.a2a + agents-parent + 0.1.0 + - - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - - - + content-editor @@ -57,25 +39,10 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 - - 17 - io.quarkus quarkus-maven-plugin - ${quarkus.platform.version} - true - - - - build - generate-code - generate-code-tests - - - diff --git a/samples/java/agents/content_editor/src/main/resources/application.properties b/samples/java/agents/content_editor/src/main/resources/application.properties index 4f5881909..be82040f4 100644 --- a/samples/java/agents/content_editor/src/main/resources/application.properties +++ b/samples/java/agents/content_editor/src/main/resources/application.properties @@ -1,2 +1,3 @@ quarkus.http.port=10003 -quarkus.langchain4j.ai.gemini.timeout=40000 \ No newline at end of file +quarkus.langchain4j.ai.gemini.timeout=40000 +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash diff --git a/samples/java/agents/content_writer/pom.xml b/samples/java/agents/content_writer/pom.xml index 5c248990e..2a371e694 100644 --- a/samples/java/agents/content_writer/pom.xml +++ b/samples/java/agents/content_writer/pom.xml @@ -4,31 +4,13 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.samples.a2a - content-writer - 0.1.0 - - - 17 - 17 - UTF-8 - 0.3.0.Alpha1 - 4.1.0 - 3.22.3 - 1.0.0 - + + com.samples.a2a + agents-parent + 0.1.0 + - - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - - - + content-writer @@ -57,25 +39,10 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 - - 17 - io.quarkus quarkus-maven-plugin - ${quarkus.platform.version} - true - - - - build - generate-code - generate-code-tests - - - diff --git a/samples/java/agents/content_writer/src/main/resources/application.properties b/samples/java/agents/content_writer/src/main/resources/application.properties index 22aec47ea..0b9a82220 100644 --- a/samples/java/agents/content_writer/src/main/resources/application.properties +++ b/samples/java/agents/content_writer/src/main/resources/application.properties @@ -1,2 +1,3 @@ quarkus.http.port=10002 -quarkus.langchain4j.ai.gemini.timeout=40000 \ No newline at end of file +quarkus.langchain4j.ai.gemini.timeout=40000 +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash diff --git a/samples/java/agents/dice_agent_multi_transport/README.md b/samples/java/agents/dice_agent_multi_transport/README.md new file mode 100644 index 000000000..0ed3176b5 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/README.md @@ -0,0 +1,123 @@ +# Dice Agent (Multi-Transport) + +This sample agent can roll dice of different sizes and check if numbers are prime. This agent demonstrates +multi-transport capabilities, supporting both gRPC and JSON-RPC transport protocols. The agent is written +using Quarkus LangChain4j and makes use of the [A2A Java](https://github.com/a2aproject/a2a-java) SDK. + +## Prerequisites + +- Java 17 or higher +- Access to an LLM and API Key + +## Running the Sample + +This sample consists of an A2A server agent, which is in the `server` directory, and an A2A client, +which is in the `client` directory. + +### Running the A2A Server Agent + +1. Navigate to the `dice_agent_multi_transport` sample directory: + + ```bash + cd samples/java/agents/dice_agent_multi_transport/server + ``` + +2. Set your Google AI Studio API Key as an environment variable: + + ```bash + export QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here + ``` + + Alternatively, you can create a `.env` file in the `dice_agent_multi_transport` directory: + + ```bash + QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here + ``` + +3. Start the A2A server agent + + **NOTE:** + By default, the agent will start on port 11000. To override this, add the `-Dquarkus.http.port=YOUR_PORT` + option at the end of the command below. + + ```bash + mvn quarkus:dev + ``` + +### Running the A2A Java Client + +The Java `TestClient` communicates with the Dice Agent using the A2A Java SDK. + +Since the A2A server agent's [preferred transport](server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java) is gRPC and since our client +also [supports](client/src/main/java/com/samples/a2a/TestClient.java) gRPC, the gRPC transport will be used. + +1. Make sure you have [JBang installed](https://www.jbang.dev/documentation/guide/latest/installation.html) + +2. Run the client using the JBang script: + ```bash + cd samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client + jbang TestClientRunner.java + ``` + + Or specify a custom server URL: + ```bash + jbang TestClientRunner.java --server-url http://localhost:11001 + ``` + + Or specify a custom message: + ```bash + jbang TestClientRunner.java --message "Can you roll a 12-sided die and check if the result is prime?" + ``` + +### Running the A2A Python Client + +You can also use a Python client to communicate with the Dice Agent using the A2A +Python SDK. + +Since the A2A server agent's [preferred transport](server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java) is gRPC and since our [client](client/src/main/java/com/samples/a2a/TestClient.java) also supports gRPC, the gRPC +transport will be used. + +1. In a separate terminal, run the A2A client and use it to send a message to the + agent: + + ```bash + cd samples/python/agents/dice_agent_grpc + uv run test_client.py + ``` + +## Expected Client Output + +Both the Java and Python A2A clients will: +1. Connect to the dice agent +2. Fetch the agent card +3. Automatically select gRPC as the transport to be used +4. Send the message "Can you roll a 5 sided die?" +5. Display the dice roll result from the agent + +## Multi-Transport Support + +This sample demonstrates multi-transport capabilities by supporting both gRPC and +JSON-RPC protocols. The A2A server agent is configured to use a unified port +(11000 by default) for both transport protocols, as specified in the +`application.properties` file with `quarkus.grpc.server.use-separate-server=false`. + +You can tweak the transports supported by the server or the client to experiment +with different transport protocols. + +## Disclaimer +Important: The sample code provided is for demonstration purposes and illustrates the +mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, +it is critical to treat any agent operating outside of your direct control as a +potentially untrusted entity. + +All data received from an external agent—including but not limited to its AgentCard, +messages, artifacts, and task statuses—should be handled as untrusted input. For +example, a malicious agent could provide an AgentCard containing crafted data in its +fields (e.g., description, name, skills.description). If this data is used without +sanitization to construct prompts for a Large Language Model (LLM), it could expose +your application to prompt injection attacks. Failure to properly validate and +sanitize this data before use can introduce security vulnerabilities into your +application. + +Developers are responsible for implementing appropriate security measures, such as +input validation and secure handling of credentials to protect their systems and users. diff --git a/samples/java/agents/dice_agent_multi_transport/client/pom.xml b/samples/java/agents/dice_agent_multi_transport/client/pom.xml new file mode 100644 index 000000000..c323ee531 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/client/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + + com.samples.a2a + dice-agent-multi-transport + 0.1.0 + + + dice-agent-client + Dice Agent Client + A2A Dice Agent Test Client + + + + io.github.a2asdk + a2a-java-sdk-client + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-grpc + ${io.a2a.sdk.version} + + + com.fasterxml.jackson.core + jackson-databind + + + io.grpc + grpc-netty-shaded + runtime + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.codehaus.mojo + exec-maven-plugin + + com.samples.a2a.TestClient + + + + + diff --git a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClient.java b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClient.java new file mode 100644 index 000000000..0267d19cf --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClient.java @@ -0,0 +1,232 @@ +package com.samples.a2a.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.a2a.A2A; +import io.a2a.client.Client; +import io.a2a.client.ClientEvent; +import io.a2a.client.MessageEvent; +import io.a2a.client.TaskEvent; +import io.a2a.client.TaskUpdateEvent; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.http.A2ACardResolver; +import io.a2a.client.transport.grpc.GrpcTransport; +import io.a2a.client.transport.grpc.GrpcTransportConfig; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Artifact; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskStatusUpdateEvent; +import io.a2a.spec.TextPart; +import io.a2a.spec.UpdateEvent; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +/** Creates an A2A client that sends a test message to the A2A server agent. */ +public final class TestClient { + + /** The default server URL to use. */ + private static final String DEFAULT_SERVER_URL = "http://localhost:11000"; + + /** The default message text to send. */ + private static final String MESSAGE_TEXT = "Can you roll a 5 sided die?"; + + /** Object mapper to use. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private TestClient() { + // this avoids a lint issue + } + + /** + * Client entry point. + * @param args can optionally contain the --server-url and --message to use + */ + public static void main(final String[] args) { + String serverUrl = DEFAULT_SERVER_URL; + String messageText = MESSAGE_TEXT; + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--server-url": + if (i + 1 < args.length) { + serverUrl = args[i + 1]; + i++; + } else { + System.err.println("Error: --server-url requires a value"); + printUsageAndExit(); + } + break; + case "--message": + if (i + 1 < args.length) { + messageText = args[i + 1]; + i++; + } else { + System.err.println("Error: --message requires a value"); + printUsageAndExit(); + } + break; + case "--help": + case "-h": + printUsageAndExit(); + break; + default: + System.err.println("Error: Unknown argument: " + args[i]); + printUsageAndExit(); + } + } + + try { + System.out.println("Connecting to dice agent at: " + serverUrl); + + // Fetch the public agent card + AgentCard publicAgentCard = + new A2ACardResolver(serverUrl).getAgentCard(); + System.out.println("Successfully fetched public agent card:"); + System.out.println(OBJECT_MAPPER.writeValueAsString(publicAgentCard)); + System.out.println("Using public agent card for client initialization."); + + // Create a CompletableFuture to handle async response + final CompletableFuture messageResponse + = new CompletableFuture<>(); + + // Create consumers for handling client events + List> consumers + = getConsumers(messageResponse); + + // Create error handler for streaming errors + Consumer streamingErrorHandler = (error) -> { + System.out.println("Streaming error occurred: " + error.getMessage()); + error.printStackTrace(); + messageResponse.completeExceptionally(error); + }; + + // Create channel factory for gRPC transport + Function channelFactory = agentUrl -> { + return ManagedChannelBuilder.forTarget(agentUrl).usePlaintext().build(); + }; + + ClientConfig clientConfig = new ClientConfig.Builder() + .setAcceptedOutputModes(List.of("Text")) + .build(); + + // Create the client with both JSON-RPC and gRPC transport support. + // The A2A server agent's preferred transport is gRPC, since the client + // also supports gRPC, this is the transport that will get used + Client client = Client.builder(publicAgentCard) + .addConsumers(consumers) + .streamingErrorHandler(streamingErrorHandler) + .withTransport(GrpcTransport.class, + new GrpcTransportConfig(channelFactory)) + .withTransport(JSONRPCTransport.class, + new JSONRPCTransportConfig()) + .clientConfig(clientConfig) + .build(); + + // Create and send the message + Message message = A2A.toUserMessage(messageText); + + System.out.println("Sending message: " + messageText); + client.sendMessage(message); + System.out.println("Message sent successfully. Waiting for response..."); + + try { + // Wait for response with timeout + String responseText = messageResponse.get(); + System.out.println("Final response: " + responseText); + } catch (Exception e) { + System.err.println("Failed to get response: " + e.getMessage()); + e.printStackTrace(); + } + + } catch (Exception e) { + System.err.println("An error occurred: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static List> getConsumers( + final CompletableFuture messageResponse) { + List> consumers = new ArrayList<>(); + consumers.add( + (event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + Message responseMessage = messageEvent.getMessage(); + String text = extractTextFromParts(responseMessage.getParts()); + System.out.println("Received message: " + text); + messageResponse.complete(text); + } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { + UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); + if (updateEvent + instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { + System.out.println( + "Received status-update: " + + taskStatusUpdateEvent.getStatus().state().asString()); + if (taskStatusUpdateEvent.isFinal()) { + StringBuilder textBuilder = new StringBuilder(); + List artifacts + = taskUpdateEvent.getTask().getArtifacts(); + for (Artifact artifact : artifacts) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + String text = textBuilder.toString(); + messageResponse.complete(text); + } + } else if (updateEvent instanceof TaskArtifactUpdateEvent + taskArtifactUpdateEvent) { + List> parts = taskArtifactUpdateEvent + .getArtifact() + .parts(); + String text = extractTextFromParts(parts); + System.out.println("Received artifact-update: " + text); + } + } else if (event instanceof TaskEvent taskEvent) { + System.out.println("Received task event: " + + taskEvent.getTask().getId()); + } + }); + return consumers; + } + + private static String extractTextFromParts(final List> parts) { + final StringBuilder textBuilder = new StringBuilder(); + if (parts != null) { + for (final Part part : parts) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } + + private static void printUsageAndExit() { + System.out.println("Usage: TestClient [OPTIONS]"); + System.out.println(); + System.out.println("Options:"); + System.out.println(" --server-url URL " + + "The URL of the A2A server agent (default: " + + DEFAULT_SERVER_URL + ")"); + System.out.println(" --message TEXT " + + "The message to send to the agent " + + "(default: \"" + MESSAGE_TEXT + "\")"); + System.out.println(" --help, -h " + + "Show this help message and exit"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" TestClient --server-url http://localhost:11001"); + System.out.println(" TestClient --message " + + "\"Can you roll a 12-sided die?\""); + System.out.println(" TestClient --server-url http://localhost:11001 " + + "--message \"Is 17 prime?\""); + System.exit(0); + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java new file mode 100644 index 000000000..a945106d4 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/TestClientRunner.java @@ -0,0 +1,43 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Final +//DEPS com.fasterxml.jackson.core:jackson-databind:2.15.2 +//DEPS io.grpc:grpc-netty-shaded:1.69.1 +//SOURCES TestClient.java + +/** + * JBang script to run the A2A TestClient example for the Dice Agent. This + * script automatically handles the dependencies and runs the client. + * + *

+ * Prerequisites: - JBang installed (see + * https://www.jbang.dev/documentation/guide/latest/installation.html) - A + * running Dice Agent server (see README.md for instructions on setting up the + * agent) + * + *

+ * Usage: $ jbang TestClientRunner.java + * + *

+ * Or with a custom server URL: $ jbang TestClientRunner.java + * --server-url=http://localhost:10000 + * + *

+ * The script will communicate with the Dice Agent server and send the message + * "Can you roll a 5 sided die" to demonstrate the A2A protocol interaction. + */ +public final class TestClientRunner { + + private TestClientRunner() { + // this avoids a lint issue + } + + /** + * Client entry point. + * @param args can optionally contain the --server-url and --message to use + */ + public static void main(final String[] args) { + com.samples.a2a.client.TestClient.main(args); + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/package-info.java b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/package-info.java new file mode 100644 index 000000000..74a05d0c9 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/client/src/main/java/com/samples/a2a/client/package-info.java @@ -0,0 +1,4 @@ +/** + * Dice Agent package. + */ +package com.samples.a2a.client; diff --git a/samples/java/agents/dice_agent_multi_transport/pom.xml b/samples/java/agents/dice_agent_multi_transport/pom.xml new file mode 100644 index 000000000..a2cc4a331 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + + com.samples.a2a + agents-parent + 0.1.0 + + + dice-agent-multi-transport + pom + + + server + client + + diff --git a/samples/java/agents/dice_agent_multi_transport/server/.env.example b/samples/java/agents/dice_agent_multi_transport/server/.env.example new file mode 100644 index 000000000..cb2fe8913 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/.env.example @@ -0,0 +1 @@ +QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here diff --git a/samples/java/agents/dice_agent_multi_transport/server/pom.xml b/samples/java/agents/dice_agent_multi_transport/server/pom.xml new file mode 100644 index 000000000..d21b1e223 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + com.samples.a2a + dice-agent-multi-transport + 0.1.0 + + + dice-agent-server + Dice Agent Server + A2A Dice Agent Server Implementation + + + + io.github.a2asdk + a2a-java-sdk-reference-grpc + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-reference-jsonrpc + ${io.a2a.sdk.version} + + + io.quarkus + quarkus-rest-jackson + + + jakarta.enterprise + jakarta.enterprise.cdi-api + ${jakarta.enterprise.cdi-api.version} + + + io.quarkiverse.langchain4j + quarkus-langchain4j-ai-gemini + ${quarkus.langchain4j.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.quarkus + quarkus-maven-plugin + + + + diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgent.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgent.java new file mode 100644 index 000000000..fb8f060e6 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgent.java @@ -0,0 +1,48 @@ +package com.samples.a2a; + +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; + +/** Dice agent. */ +@RegisterAiService(tools = DiceTools.class) +@ApplicationScoped +public interface DiceAgent { + + /** + * Rolls dice and provides information about the outcome of dice roles. + * + * @param question the users' question + * @return the answer + */ + @SystemMessage( + """ + You roll dice and answer questions about the outcome of the dice rolls. + You can roll dice of different sizes. The only things you do are roll + dice for the user and discuss the outcomes. + It is ok to discuss previous dice roles, and comment on the dice rolls. + When you are asked to roll a die, you must call the rollDice tool with + the number of sides. + Be sure to pass in an integer. Do not pass in a string. + You should never roll a die on your own. + When checking prime numbers, call the checkPrime tool + with a list of integers. + Be sure to pass in a list of integers. You should never pass in a + string. + You should not check prime numbers before calling the tool. + When you are asked to roll a die and check prime numbers, + you should always make the following two function calls: + 1. You should first call the rollDice tool to get a roll. + Wait for the function response before calling the checkPrime tool. + 2. After you get the function response from rollDice tool, you + should call the checkPrime tool with the rollDice result. + 2.1 If user asks you to check primes based on previous rolls, + make sure you include the previous rolls in the list. + 3. When you respond, you must include the rollDice result from step 1. + You should always perform the previous 3 steps when asking for a roll + and checking prime numbers. + You should not rely on the previous history on prime results. + """) + String rollAndAnswer(@UserMessage String question); +} diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java new file mode 100644 index 000000000..3ba7adbaf --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentCardProducer.java @@ -0,0 +1,82 @@ +package com.samples.a2a; + +import io.a2a.server.PublicAgentCard; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.AgentSkill; +import io.a2a.spec.TransportProtocol; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.List; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * Producer for dice agent card configuration. + */ +@ApplicationScoped +public final class DiceAgentCardProducer { + + /** The HTTP port for the agent service. */ + @Inject + @ConfigProperty(name = "quarkus.http.port") + private int httpPort; + + /** + * Produces the agent card for the dice agent. + * + * @return the configured agent card + */ + @Produces + @PublicAgentCard + public AgentCard agentCard() { + return new AgentCard.Builder() + .name("Dice Agent") + .description( + "Rolls an N-sided dice and answers questions about the " + + "outcome of the dice rolls. Can also answer questions " + + "about prime numbers.") + .preferredTransport(TransportProtocol.GRPC.asString()) + .url("localhost:" + httpPort) + .version("1.0.0") + .documentationUrl("http://example.com/docs") + .capabilities( + new AgentCapabilities.Builder() + .streaming(true) + .pushNotifications(false) + .stateTransitionHistory(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills( + List.of( + new AgentSkill.Builder() + .id("dice_roller") + .name("Roll dice") + .description("Rolls dice and discusses outcomes") + .tags(List.of("dice", "games", "random")) + .examples( + List.of("Can you roll a 6-sided die?")) + .build(), + new AgentSkill.Builder() + .id("prime_checker") + .name("Check prime numbers") + .description("Checks if given numbers are prime") + .tags(List.of("math", "prime", "numbers")) + .examples( + List.of( + "Is 17 a prime number?", + "Which of these numbers are prime: 1, 4, 6, 7")) + .build())) + .protocolVersion("0.3.0") + .additionalInterfaces( + List.of( + new AgentInterface(TransportProtocol.GRPC.asString(), + "localhost:" + httpPort), + new AgentInterface( + TransportProtocol.JSONRPC.asString(), + "http://localhost:" + httpPort))) + .build(); + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentExecutorProducer.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentExecutorProducer.java new file mode 100644 index 000000000..46c5a6f02 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceAgentExecutorProducer.java @@ -0,0 +1,111 @@ +package com.samples.a2a; + +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.server.agentexecution.RequestContext; +import io.a2a.server.events.EventQueue; +import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.JSONRPCError; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.Task; +import io.a2a.spec.TaskNotCancelableError; +import io.a2a.spec.TaskState; +import io.a2a.spec.TextPart; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.List; + +/** Producer for dice agent executor. */ +@ApplicationScoped +public final class DiceAgentExecutorProducer { + + /** The dice agent instance. */ + @Inject private DiceAgent diceAgent; + + /** + * Produces the agent executor for the dice agent. + * + * @return the configured agent executor + */ + @Produces + public AgentExecutor agentExecutor() { + return new DiceAgentExecutor(diceAgent); + } + + /** Dice agent executor implementation. */ + private static class DiceAgentExecutor implements AgentExecutor { + + /** The dice agent instance. */ + private final DiceAgent agent; + + /** + * Constructor for DiceAgentExecutor. + * + * @param diceAgentInstance the dice agent instance + */ + DiceAgentExecutor(final DiceAgent diceAgentInstance) { + this.agent = diceAgentInstance; + } + + @Override + public void execute(final RequestContext context, + final EventQueue eventQueue) + throws JSONRPCError { + final TaskUpdater updater = new TaskUpdater(context, eventQueue); + + // mark the task as submitted and start working on it + if (context.getTask() == null) { + updater.submit(); + } + updater.startWork(); + + // extract the text from the message + final String assignment = extractTextFromMessage(context.getMessage()); + + // call the dice agent with the message + final String response = agent.rollAndAnswer(assignment); + + // create the response part + final TextPart responsePart = new TextPart(response, null); + final List> parts = List.of(responsePart); + + // add the response as an artifact and complete the task + updater.addArtifact(parts, null, null, null); + updater.complete(); + } + + private String extractTextFromMessage(final Message message) { + final StringBuilder textBuilder = new StringBuilder(); + if (message.getParts() != null) { + for (final Part part : message.getParts()) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } + + @Override + public void cancel(final RequestContext context, + final EventQueue eventQueue) + throws JSONRPCError { + final Task task = context.getTask(); + + if (task.getStatus().state() == TaskState.CANCELED) { + // task already cancelled + throw new TaskNotCancelableError(); + } + + if (task.getStatus().state() == TaskState.COMPLETED) { + // task already completed + throw new TaskNotCancelableError(); + } + + // cancel the task + final TaskUpdater updater = new TaskUpdater(context, eventQueue); + updater.cancel(); + } + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceTools.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceTools.java new file mode 100644 index 000000000..0c15886fd --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/DiceTools.java @@ -0,0 +1,78 @@ +package com.samples.a2a; + +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** Service class that provides dice rolling and prime number functionality. */ +@ApplicationScoped +public class DiceTools { + + /** For generating rolls. */ + private final Random random = new Random(); + + /** Default number of sides to use. */ + private static final int DEFAULT_NUM_SIDES = 6; + + /** + * Rolls an N sided dice. If number of sides aren't given, uses 6. + * + * @param n the number of the side of the dice to roll + * @return A number between 1 and N, inclusive + */ + @Tool("Rolls an n sided dice. If number of sides aren't given, uses 6.") + public int rollDice(final int n) { + int sides = n; + if (sides <= 0) { + sides = DEFAULT_NUM_SIDES; // Default to 6 sides if invalid input + } + return random.nextInt(sides) + 1; + } + + /** + * Check if a given list of numbers are prime. + * + * @param nums The list of numbers to check + * @return A string indicating which number is prime + */ + @Tool("Check if a given list of numbers are prime.") + public String checkPrime(final List nums) { + Set primes = new HashSet<>(); + + for (Integer number : nums) { + if (number == null) { + continue; + } + + int num = number.intValue(); + if (num <= 1) { + continue; + } + + boolean isPrime = true; + for (int i = 2; i <= Math.sqrt(num); i++) { + if (num % i == 0) { + isPrime = false; + break; + } + } + + if (isPrime) { + primes.add(num); + } + } + + if (primes.isEmpty()) { + return "No prime numbers found."; + } else { + return primes.stream() + .sorted() + .map(String::valueOf) + .collect(java.util.stream.Collectors.joining(", ")) + + " are prime numbers."; + } + } +} diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/package-info.java b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/package-info.java new file mode 100644 index 000000000..502874d14 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/java/com/samples/a2a/package-info.java @@ -0,0 +1,4 @@ +/** + * Dice Agent package. + */ +package com.samples.a2a; diff --git a/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties b/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties new file mode 100644 index 000000000..b36c87904 --- /dev/null +++ b/samples/java/agents/dice_agent_multi_transport/server/src/main/resources/application.properties @@ -0,0 +1,6 @@ +# Use the same port for gRPC and HTTP +quarkus.grpc.server.use-separate-server=false +quarkus.http.port=11000 +quarkus.langchain4j.ai.gemini.timeout=40000 +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash + diff --git a/samples/java/agents/magic_8_ball_security/README.md b/samples/java/agents/magic_8_ball_security/README.md new file mode 100644 index 000000000..fe42084f6 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/README.md @@ -0,0 +1,120 @@ +# Magic 8-Ball Security Agent + +This sample agent responds to yes/no questions by consulting a Magic 8-Ball. + +This sample demonstrates how to secure an A2A server with Keycloak using bearer token authentication and it shows how to configure an A2A client to specify the token when +sending requests. The agent is written using Quarkus LangChain4j and makes use of the +[A2A Java](https://github.com/a2aproject/a2a-java) SDK. + +## Prerequisites + +- Java 17 or higher +- Access to an LLM and API Key +- A working container runtime (Docker or [Podman](https://quarkus.io/guides/podman)) + +>**NOTE**: We'll be making use of Quarkus Dev Services in this sample to automatically create and configure a Keycloak instance that we'll use as our OAuth2 provider. For more details on using Podman with Quarkus, see this [guide](https://quarkus.io/guides/podman). + +## Running the Sample + +This sample consists of an A2A server agent, which is in the `server` directory, and an A2A client, +which is in the `client` directory. + +### Running the A2A Server Agent + +1. Navigate to the `magic-8-ball-security` sample directory: + + ```bash + cd samples/java/agents/magic-8-ball-security/server + ``` + +2. Set your Google AI Studio API Key as an environment variable: + + ```bash + export QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here + ``` + + Alternatively, you can create a `.env` file in the `magic-8-ball-security/server` directory: + + ```bash + QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here + ``` + +3. Start the A2A server agent + + **NOTE:** + By default, the agent will start on port 11000. To override this, add the `-Dquarkus.http.port=YOUR_PORT` + option at the end of the command below. + + ```bash + mvn quarkus:dev + ``` + +### Running the A2A Java Client + +The Java `TestClient` communicates with the Magic 8-Ball Agent using the A2A Java SDK. + +The client supports specifying which transport protocol to use ("jsonrpc", "rest", or "grpc"). By default, it uses JSON-RPC. + +1. Make sure you have [JBang installed](https://www.jbang.dev/documentation/jbang/latest/installation.html) + +2. Run the client using the JBang script: + ```bash + cd samples/java/agents/magic-8-ball-security/client/src/main/java/com/samples/a2a/client + jbang TestClientRunner.java + ``` + + Or specify a custom server URL: + ```bash + jbang TestClientRunner.java --server-url http://localhost:11000 + ``` + + Or specify a custom message: + ```bash + jbang TestClientRunner.java --message "Should I refactor this code?" + ``` + + Or specify a specific transport (jsonrpc, grpc, or rest): + ```bash + jbang TestClientRunner.java --transport grpc + ``` + + Or combine multiple options: + ```bash + jbang TestClientRunner.java --server-url http://localhost:11000 --message "Will my tests pass?" --transport rest + ``` + +## Expected Client Output + +The Java A2A client will: +1. Connect to the Magic 8-Ball agent +2. Fetch the agent card +3. Use the specified transport (JSON-RPC by default, or as specified via --transport option) +4. Send the message "Should I deploy this code on Friday?" (or your custom message) +5. Display the Magic 8-Ball's mystical response from the agent + +## Keycloak OAuth2 Authentication + +This sample includes a `KeycloakOAuth2CredentialService` that implements the `CredentialService` interface from the A2A Java SDK to retrieve tokens from Keycloak +using Keycloak `AuthzClient`. + +## Multi-Transport Support + +This sample demonstrates multi-transport capabilities by supporting the JSON-RPC, HTTP+JSON/REST, and gRPC transports. The A2A server agent is configured to use a unified port for all three transports. + +## Disclaimer +Important: The sample code provided is for demonstration purposes and illustrates the +mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, +it is critical to treat any agent operating outside of your direct control as a +potentially untrusted entity. + +All data received from an external agent—including but not limited to its AgentCard, +messages, artifacts, and task statuses—should be handled as untrusted input. For +example, a malicious agent could provide an AgentCard containing crafted data in its +fields (e.g., description, name, skills.description). If this data is used without +sanitization to construct prompts for a Large Language Model (LLM), it could expose +your application to prompt injection attacks. Failure to properly validate and +sanitize this data before use can introduce security vulnerabilities into your +application. + +Developers are responsible for implementing appropriate security measures, such as +input validation and secure handling of credentials to protect their systems and users. diff --git a/samples/java/agents/magic_8_ball_security/client/pom.xml b/samples/java/agents/magic_8_ball_security/client/pom.xml new file mode 100644 index 000000000..0aac53880 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + com.samples.a2a + magic-8-ball-security + 0.1.0 + + + magic-8-ball-security-client + Magic 8-Ball Security Agent Client + A2A Magic 8-Ball Security Agent Test Client + + + + io.github.a2asdk + a2a-java-sdk-client + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-rest + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-client-transport-grpc + ${io.a2a.sdk.version} + + + io.grpc + grpc-netty-shaded + runtime + + + com.fasterxml.jackson.core + jackson-databind + + + com.google.auth + google-auth-library-oauth2-http + 1.19.0 + + + com.google.http-client + google-http-client-jackson2 + 1.43.3 + + + org.keycloak + keycloak-authz-client + 25.0.1 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.codehaus.mojo + exec-maven-plugin + + com.samples.a2a.TestClient + + + + + diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/KeycloakOAuth2CredentialService.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/KeycloakOAuth2CredentialService.java new file mode 100644 index 000000000..e553beef0 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/KeycloakOAuth2CredentialService.java @@ -0,0 +1,55 @@ +package com.samples.a2a.client; + +import com.samples.a2a.client.util.CachedToken; +import com.samples.a2a.client.util.KeycloakUtil; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; +import io.a2a.client.transport.spi.interceptors.auth.CredentialService; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.keycloak.authorization.client.AuthzClient; + +/** + * A CredentialService implementation that provides OAuth2 access tokens + * using Keycloak. This service is used by the A2A client transport + * authentication interceptors. + */ +public final class KeycloakOAuth2CredentialService implements CredentialService { + + /** OAuth2 scheme name. */ + private static final String OAUTH2_SCHEME_NAME = "oauth2"; + + /** Token cache. */ + private final ConcurrentMap tokenCache + = new ConcurrentHashMap<>(); + + /** Keycloak authz client. */ + private final AuthzClient authzClient; + + /** + * Creates a new KeycloakOAuth2CredentialService using the + * default keycloak.json file. + * + * @throws IllegalArgumentException if keycloak.json cannot be found/loaded + */ + public KeycloakOAuth2CredentialService() { + this.authzClient = KeycloakUtil.createAuthzClient(); + } + + @Override + public String getCredential(final String securitySchemeName, + final ClientCallContext clientCallContext) { + if (!OAUTH2_SCHEME_NAME.equals(securitySchemeName)) { + throw new IllegalArgumentException("Unsupported security scheme: " + + securitySchemeName); + } + + try { + return KeycloakUtil.getAccessToken(securitySchemeName, + tokenCache, authzClient); + } catch (Exception e) { + throw new RuntimeException( + "Failed to obtain OAuth2 access token for scheme: " + + securitySchemeName, e); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClient.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClient.java new file mode 100644 index 000000000..429084e54 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClient.java @@ -0,0 +1,119 @@ +package com.samples.a2a.client; + +import com.samples.a2a.client.util.EventHandlerUtil; +import io.a2a.client.Client; +import io.a2a.client.ClientEvent; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.transport.grpc.GrpcTransport; +import io.a2a.client.transport.grpc.GrpcTransportConfigBuilder; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import io.a2a.client.transport.rest.RestTransport; +import io.a2a.client.transport.rest.RestTransportConfigBuilder; +import io.a2a.client.transport.spi.interceptors.auth.AuthInterceptor; +import io.a2a.client.transport.spi.interceptors.auth.CredentialService; +import io.a2a.spec.AgentCard; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Test client utility for creating A2A clients with HTTP-based transports + * and OAuth2 authentication. + * + *

This class encapsulates the complexity of setting up A2A clients with + * multiple transport options (gRPC, REST, JSON-RPC) and Keycloak OAuth2 + * authentication, providing simple methods to create configured clients + * for testing and development. + */ +public final class TestClient { + + private TestClient() { + } + + /** + * Creates an A2A client with the specified transport and + * OAuth2 authentication. + * + * @param agentCard the agent card to connect to + * @param messageResponse CompletableFuture for handling responses + * @param transport the transport type to use ("grpc", "rest", or "jsonrpc") + * @return configured A2A client + */ + public static Client createClient( + final AgentCard agentCard, + final CompletableFuture messageResponse, + final String transport) { + + // Create consumers for handling client events + List> consumers = + EventHandlerUtil.createEventConsumers(messageResponse); + + // Create error handler for streaming errors + Consumer streamingErrorHandler = + EventHandlerUtil.createStreamingErrorHandler(messageResponse); + + // Create credential service for OAuth2 authentication + CredentialService credentialService + = new KeycloakOAuth2CredentialService(); + + // Create shared auth interceptor for all transports + AuthInterceptor authInterceptor = new AuthInterceptor(credentialService); + + // Create channel factory for gRPC transport + Function channelFactory = + agentUrl -> { + return ManagedChannelBuilder + .forTarget(agentUrl) + .usePlaintext() + .build(); + }; + + // Create the A2A client with the specified transport + try { + var builder = + Client.builder(agentCard) + .addConsumers(consumers) + .streamingErrorHandler(streamingErrorHandler); + + // Configure only the specified transport + switch (transport.toLowerCase()) { + case "grpc": + builder.withTransport( + GrpcTransport.class, + new GrpcTransportConfigBuilder() + .channelFactory(channelFactory) + .addInterceptor(authInterceptor) // auth config + .build()); + break; + case "rest": + builder.withTransport( + RestTransport.class, + new RestTransportConfigBuilder() + .addInterceptor(authInterceptor) // auth config + .build()); + break; + case "jsonrpc": + builder.withTransport( + JSONRPCTransport.class, + new JSONRPCTransportConfigBuilder() + .addInterceptor(authInterceptor) // auth config + .build()); + break; + default: + throw new IllegalArgumentException( + "Unsupported transport type: " + + transport + + ". Supported types are: grpc, rest, jsonrpc"); + } + + return builder.clientConfig(new ClientConfig.Builder().build()).build(); + } catch (Exception e) { + throw new RuntimeException("Failed to create A2A client", e); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java new file mode 100644 index 000000000..7ee5a2083 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/TestClientRunner.java @@ -0,0 +1,231 @@ +/// usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-grpc:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-rest:0.3.0.Final +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-spi:0.3.0.Final +//DEPS com.fasterxml.jackson.core:jackson-databind:2.15.2 +//DEPS io.grpc:grpc-netty-shaded:1.69.1 +//DEPS org.keycloak:keycloak-authz-client:25.0.1 +//SOURCES TestClient.java +//SOURCES util/KeycloakUtil.java +//SOURCES util/EventHandlerUtil.java +//SOURCES util/CachedToken.java +//SOURCES KeycloakOAuth2CredentialService.java +//FILES ../../../../../resources/keycloak.json + +package com.samples.a2a.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.a2a.A2A; +import io.a2a.client.Client; +import io.a2a.client.http.A2ACardResolver; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Message; +import java.util.concurrent.CompletableFuture; + +/** + * JBang script to run the A2A HTTP TestClient example with OAuth2 + * authentication. This script automatically handles the dependencies + * and runs the client with a specified transport. + * + *

This is a self-contained script that demonstrates how to: + * + *

    + *
  • Connect to an A2A agent using a specific transport + * (gRPC, REST, or JSON-RPC) with OAuth2 authentication + *
  • Send messages and receive responses + *
  • Handle agent interactions + *
+ * + *

Prerequisites: + * + *

    + *
  • JBang installed + * (see https://www.jbang.dev/documentation/guide/latest/installation.html) + *
  • A running Magic 8 Ball A2A server agent that supports the specified + * transport with OAuth2 authentication + *
  • A valid keycloak.json configuration file in the classpath + *
  • A running Keycloak server with properly configured client + *
+ * + *

Usage: + * + *

{@code
+ * $ jbang TestClientRunner.java
+ * }
+ * + *

Or with custom parameters: + * + *

{@code
+ * $ jbang TestClientRunner.java --server-url http://localhost:11001
+ * $ jbang TestClientRunner.java --message "Should I refactor this code?"
+ * $ jbang TestClientRunner.java --transport grpc
+ * $ jbang TestClientRunner.java --server-url http://localhost:11001
+ *  --message "Will my tests pass?" --transport rest
+ * }
+ * + *

The script will: + * + *

    + *
  • Create the specified transport config with auth config + *
  • Communicate with the Magic 8 Ball A2A server agent + *
  • Automatically include OAuth2 Bearer tokens in all requests + *
  • Handle A2A protocol interactions and display responses + *
+ * + *

The heavy lifting for client setup is handled by {@link TestClient}. + */ +public final class TestClientRunner { + + /** The default server URL to use. */ + private static final String DEFAULT_SERVER_URL = "http://localhost:11000"; + + /** The default message text to send. */ + private static final String MESSAGE_TEXT + = "Should I deploy this code on Friday?"; + + /** The default transport to use. */ + private static final String DEFAULT_TRANSPORT = "jsonrpc"; + + /** Object mapper to use. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private TestClientRunner() { + // Utility class, prevent instantiation + } + + /** Prints usage information and exits. */ + private static void printUsageAndExit() { + System.out.println("Usage: jbang TestClientRunner.java [OPTIONS]"); + System.out.println(); + System.out.println("Options:"); + System.out.println( + " --server-url URL The URL of the A2A server agent (default: " + + DEFAULT_SERVER_URL + + ")"); + System.out.println( + " --message TEXT The message to send to the agent " + + "(default: \"" + + MESSAGE_TEXT + + "\")"); + System.out.println( + " --transport TYPE " + + "The transport type to use: jsonrpc, grpc, or rest " + + "(default: " + + DEFAULT_TRANSPORT + + ")"); + System.out.println(" --help, -h Show this help message and exit"); + System.out.println(); + System.out.println("Examples:"); + System.out.println(" jbang TestClientRunner.java " + + "--server-url http://localhost:11001"); + System.out.println(" jbang TestClientRunner.java " + + "--message \"Should I refactor this code?\""); + System.out.println(" jbang TestClientRunner.java --transport grpc"); + System.out.println( + " jbang TestClientRunner.java --server-url http://localhost:11001 " + + "--message \"Will my tests pass?\" --transport rest"); + System.exit(0); + } + + /** + * Client entry point. + * + * @param args can optionally contain the --server-url, + * --message, and --transport to use + */ + public static void main(final String[] args) { + System.out.println("=== A2A Client with OAuth2 Authentication Example ==="); + + String serverUrl = DEFAULT_SERVER_URL; + String messageText = MESSAGE_TEXT; + String transport = DEFAULT_TRANSPORT; + + // Parse command line arguments + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--server-url": + if (i + 1 < args.length) { + serverUrl = args[i + 1]; + i++; + } else { + System.err.println("Error: --server-url requires a value"); + printUsageAndExit(); + } + break; + case "--message": + if (i + 1 < args.length) { + messageText = args[i + 1]; + i++; + } else { + System.err.println("Error: --message requires a value"); + printUsageAndExit(); + } + break; + case "--transport": + if (i + 1 < args.length) { + transport = args[i + 1]; + i++; + } else { + System.err.println("Error: --transport requires a value"); + printUsageAndExit(); + } + break; + case "--help": + case "-h": + printUsageAndExit(); + break; + default: + System.err.println("Error: Unknown argument: " + args[i]); + printUsageAndExit(); + } + } + + try { + System.out.println("Connecting to agent at: " + serverUrl); + System.out.println("Using transport: " + transport); + + // Fetch the public agent card + AgentCard publicAgentCard = new A2ACardResolver(serverUrl).getAgentCard(); + System.out.println("Successfully fetched public agent card:"); + System.out.println(OBJECT_MAPPER.writeValueAsString(publicAgentCard)); + System.out.println("Using public agent card for client initialization."); + + // Create a CompletableFuture to handle async response + final CompletableFuture messageResponse + = new CompletableFuture<>(); + + // Create the A2A client with the specified transport using TestClient + Client client = TestClient.createClient(publicAgentCard, + messageResponse, transport); + + // Create and send the message + Message message = A2A.toUserMessage(messageText); + + System.out.println("Sending message: " + messageText); + System.out.println("Using " + transport + + " transport with OAuth2 Bearer token"); + try { + client.sendMessage(message); + } catch (A2AClientException e) { + messageResponse.completeExceptionally(e); + } + System.out.println("Message sent successfully. Waiting for response..."); + + try { + // Wait for response + String responseText = messageResponse.get(); + System.out.println("Final response: " + responseText); + } catch (Exception e) { + System.err.println("Failed to get response: " + e.getMessage()); + e.printStackTrace(); + } + + } catch (Exception e) { + System.err.println("An error occurred: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/package-info.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/package-info.java new file mode 100644 index 000000000..183f90b63 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/package-info.java @@ -0,0 +1,2 @@ +/** A2A Client examples and utilities with OAuth2 authentication support. */ +package com.samples.a2a.client; diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/CachedToken.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/CachedToken.java new file mode 100644 index 000000000..7ee6d1bb5 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/CachedToken.java @@ -0,0 +1,91 @@ +package com.samples.a2a.client.util; + +/** + * Represents a cached token with expiration information. + * + *

This utility class is used to cache OAuth2 access tokens and + * provides expiration checking to avoid using expired tokens. + */ +public final class CachedToken { + /** Expiration buffer. */ + private static final long EXPIRATION_BUFFER_MS = 5 * 60 * 1000; // 5 minutes + + /** Converstion to milliseconds. */ + private static final long SECONDS_TO_MS = 1000; + + /** Cached token. */ + private final String token; + + /** Expiration time. */ + private final long expirationTime; + + /** + * Creates a new CachedToken with the specified token and expiration time. + * + * @param token the access token string + * @param expirationTime the expiration time in milliseconds since epoch + */ + public CachedToken(final String token, final long expirationTime) { + this.token = token; + this.expirationTime = expirationTime; + } + + /** + * Gets the cached token. + * + * @return the access token string + */ + public String getToken() { + return token; + } + + /** + * Gets the expiration time. + * + * @return the expiration time in milliseconds since epoch + */ + public long getExpirationTime() { + return expirationTime; + } + + /** + * Checks if the token is expired or will expire soon. + * + *

Returns true if the token will expire within 5 minutes to provide + * a buffer for token refresh. + * + * @return true if the token is expired or will expire soon + */ + public boolean isExpired() { + // Consider token expired if it expires within 5 minutes (300,000 ms) + return System.currentTimeMillis() + >= (expirationTime - EXPIRATION_BUFFER_MS); + } + + /** + * Creates a CachedToken from an access token response + * with expires_in seconds. + * + * @param token the access token string + * @param expiresInSeconds the number of seconds until expiration + * @return a new CachedToken instance + */ + public static CachedToken fromExpiresIn(final String token, + final long expiresInSeconds) { + long expirationTime = System.currentTimeMillis() + + (expiresInSeconds * SECONDS_TO_MS); + return new CachedToken(token, expirationTime); + } + + @Override + public String toString() { + return "CachedToken{" + + "token=***" + + // Don't log the actual token for security + ", expirationTime=" + + expirationTime + + ", expired=" + + isExpired() + + '}'; + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/EventHandlerUtil.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/EventHandlerUtil.java new file mode 100644 index 000000000..1e434c39a --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/EventHandlerUtil.java @@ -0,0 +1,118 @@ +package com.samples.a2a.client.util; + +import io.a2a.client.ClientEvent; +import io.a2a.client.MessageEvent; +import io.a2a.client.TaskEvent; +import io.a2a.client.TaskUpdateEvent; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Artifact; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskStatusUpdateEvent; +import io.a2a.spec.TextPart; +import io.a2a.spec.UpdateEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** Utility class for handling A2A client events and responses. */ +public final class EventHandlerUtil { + + private EventHandlerUtil() { + } + + /** + * Creates event consumers for handling A2A client events. + * + * @param messageResponse CompletableFuture to complete + * @return list of event consumers + */ + public static List> createEventConsumers( + final CompletableFuture messageResponse) { + List> consumers = new ArrayList<>(); + consumers.add( + (event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + Message responseMessage = messageEvent.getMessage(); + String text = extractTextFromParts(responseMessage.getParts()); + System.out.println("Received message: " + text); + messageResponse.complete(text); + } else if (event instanceof TaskUpdateEvent taskUpdateEvent) { + UpdateEvent updateEvent = taskUpdateEvent.getUpdateEvent(); + if (updateEvent + instanceof TaskStatusUpdateEvent taskStatusUpdateEvent) { + System.out.println( + "Received status-update: " + + taskStatusUpdateEvent.getStatus().state().asString()); + if (taskStatusUpdateEvent.isFinal()) { + String text = extractTextFromArtifacts( + taskUpdateEvent.getTask().getArtifacts()); + messageResponse.complete(text); + } + } else if (updateEvent + instanceof + TaskArtifactUpdateEvent taskArtifactUpdateEvent) { + List> parts = taskArtifactUpdateEvent + .getArtifact() + .parts(); + String text = extractTextFromParts(parts); + System.out.println("Received artifact-update: " + text); + } + } else if (event instanceof TaskEvent taskEvent) { + System.out.println("Received task event: " + + taskEvent.getTask().getId()); + if (taskEvent.getTask().getStatus().state().isFinal()) { + String text = extractTextFromArtifacts( + taskEvent.getTask().getArtifacts()); + messageResponse.complete(text); + } + } + }); + return consumers; + } + + private static String extractTextFromArtifacts( + final List artifacts) { + StringBuilder textBuilder = new StringBuilder(); + for (Artifact artifact : artifacts) { + textBuilder.append(extractTextFromParts(artifact.parts())); + } + return textBuilder.toString(); + } + + /** + * Creates a streaming error handler for A2A client. + * + * @param messageResponse CompletableFuture to complete exceptionally on error + * @return error handler + */ + public static Consumer createStreamingErrorHandler( + final CompletableFuture messageResponse) { + return (error) -> { + System.out.println("Streaming error occurred: " + error.getMessage()); + error.printStackTrace(); + messageResponse.completeExceptionally(error); + }; + } + + /** + * Extracts text content from a list of parts. + * + * @param parts the parts to extract text from + * @return concatenated text content + */ + public static String extractTextFromParts(final List> parts) { + final StringBuilder textBuilder = new StringBuilder(); + if (parts != null) { + for (final Part part : parts) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/KeycloakUtil.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/KeycloakUtil.java new file mode 100644 index 000000000..c6c4cb443 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/KeycloakUtil.java @@ -0,0 +1,95 @@ +package com.samples.a2a.client.util; + +import java.io.InputStream; +import java.util.concurrent.ConcurrentMap; +import org.keycloak.authorization.client.AuthzClient; +import org.keycloak.representations.AccessTokenResponse; + +/** Utility class for common Keycloak operations and token caching. */ +public final class KeycloakUtil { + + private KeycloakUtil() { + // Utility class, prevent instantiation + } + + /** + * Creates a Keycloak AuthzClient from the default keycloak.json + * configuration file. + * + * @return a configured AuthzClient + * @throws IllegalArgumentException if keycloak.json cannot be found/loaded + */ + public static AuthzClient createAuthzClient() { + return createAuthzClient("keycloak.json"); + } + + private static AuthzClient createAuthzClient(final String configFileName) { + try { + InputStream configStream = null; + + // First try to load from current directory (for JBang) + try { + java.io.File configFile = new java.io.File(configFileName); + if (configFile.exists()) { + configStream = new java.io.FileInputStream(configFile); + } + } catch (Exception ignored) { + // Fall back to classpath + } + + // If not found in current directory, try classpath + if (configStream == null) { + configStream = KeycloakUtil.class + .getClassLoader() + .getResourceAsStream(configFileName); + } + + if (configStream == null) { + throw new IllegalArgumentException("Config file not found: " + + configFileName); + } + + return AuthzClient.create(configStream); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to load Keycloak configuration from " + configFileName, e); + } + } + + /** + * Gets a valid access token for the specified cache key, using the + * provided cache and AuthzClient. Uses caching to avoid unnecessary + * token requests. + * + * @param cacheKey the cache key to use for storing/retrieving the token + * @param tokenCache the concurrent map to use for token caching + * @param authzClient the Keycloak AuthzClient to use for token requests + * @return a valid access token + * @throws RuntimeException if token acquisition fails + */ + public static String getAccessToken( + final String cacheKey, + final ConcurrentMap tokenCache, final AuthzClient authzClient) { + CachedToken cached = tokenCache.get(cacheKey); + + // Check if we have a valid cached token + if (cached != null && !cached.isExpired()) { + return cached.getToken(); + } + + try { + // Obtain a new access token from Keycloak + AccessTokenResponse tokenResponse = authzClient.obtainAccessToken(); + + // Cache the token with expiration info + CachedToken newToken = + CachedToken.fromExpiresIn(tokenResponse.getToken(), + tokenResponse.getExpiresIn()); + tokenCache.put(cacheKey, newToken); + + return tokenResponse.getToken(); + } catch (Exception e) { + throw new RuntimeException("Failed to obtain token from Keycloak", e); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/package-info.java b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/package-info.java new file mode 100644 index 000000000..745afb8ee --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/java/com/samples/a2a/client/util/package-info.java @@ -0,0 +1,2 @@ +/** Auth utilities. */ +package com.samples.a2a.client.util; diff --git a/samples/java/agents/magic_8_ball_security/client/src/main/resources/keycloak.json b/samples/java/agents/magic_8_ball_security/client/src/main/resources/keycloak.json new file mode 100644 index 000000000..8ed695b1c --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/client/src/main/resources/keycloak.json @@ -0,0 +1,9 @@ +{ + "realm": "quarkus", + "auth-server-url": "http://localhost:11001/", + "resource": "quarkus-app", + "credentials": { + "secret": "secret" + }, + "ssl-required": "external" +} diff --git a/samples/java/agents/magic_8_ball_security/pom.xml b/samples/java/agents/magic_8_ball_security/pom.xml new file mode 100644 index 000000000..e23a15576 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + + com.samples.a2a + agents-parent + 0.1.0 + + + magic-8-ball-security + pom + + + server + client + + diff --git a/samples/java/agents/magic_8_ball_security/server/.env.example b/samples/java/agents/magic_8_ball_security/server/.env.example new file mode 100644 index 000000000..cb2fe8913 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/.env.example @@ -0,0 +1 @@ +QUARKUS_LANGCHAIN4J_AI_GEMINI_API_KEY=your_api_key_here diff --git a/samples/java/agents/magic_8_ball_security/server/pom.xml b/samples/java/agents/magic_8_ball_security/server/pom.xml new file mode 100644 index 000000000..796b46082 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + com.samples.a2a + magic-8-ball-security + 0.1.0 + + + magic-8-ball-security-server + Magic 8-Ball Security Agent Server + A2A Magic 8-Ball Security Agent Server Implementation + + + + io.github.a2asdk + a2a-java-sdk-reference-jsonrpc + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-reference-rest + ${io.a2a.sdk.version} + + + io.github.a2asdk + a2a-java-sdk-reference-grpc + ${io.a2a.sdk.version} + + + jakarta.enterprise + jakarta.enterprise.cdi-api + ${jakarta.enterprise.cdi-api.version} + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-security + + + io.quarkiverse.langchain4j + quarkus-langchain4j-ai-gemini + ${quarkus.langchain4j.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.quarkus + quarkus-maven-plugin + + + + diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgent.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgent.java new file mode 100644 index 000000000..acef32c34 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgent.java @@ -0,0 +1,41 @@ +package com.samples.a2a; + +import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.SystemMessage; +import dev.langchain4j.service.UserMessage; +import io.quarkiverse.langchain4j.RegisterAiService; +import jakarta.enterprise.context.ApplicationScoped; + +/** Magic 8 Ball fortune-telling agent. */ +@RegisterAiService(tools = Magic8BallTools.class) +@ApplicationScoped +public interface Magic8BallAgent { + + /** + * Answers questions using the mystical powers of the Magic 8 Ball. + * + * @param memoryId unique identifier for this conversation + * @param question the users' question + * @return the Magic 8 Ball's response + */ + @SystemMessage( + """ + You shake a Magic 8 Ball to answer questions. + The only thing you do is shake the Magic 8 Ball to answer + the user's question and then discuss the response. + When you are asked to answer a question, you must call the + shakeMagic8Ball tool with the user's question. + You should never rely on the previous history for Magic 8 Ball + responses. Call the shakeMagic8Ball tool for each question. + You should never shake the Magic 8 Ball on your own. + You must always call the tool. + When you are asked a question, you should always make the following + function call: + 1. You should first call the shakeMagic8Ball tool to get the response. + Wait for the function response. + 2. After you get the function response, relay the response to the user. + You should not rely on the previous history for Magic 8 Ball responses. + """) + String answerQuestion(@MemoryId String memoryId, + @UserMessage String question); +} diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentCardProducer.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentCardProducer.java new file mode 100644 index 000000000..5bb5decff --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentCardProducer.java @@ -0,0 +1,96 @@ +package com.samples.a2a; + +import io.a2a.server.PublicAgentCard; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.AgentSkill; +import io.a2a.spec.ClientCredentialsOAuthFlow; +import io.a2a.spec.OAuth2SecurityScheme; +import io.a2a.spec.OAuthFlows; +import io.a2a.spec.TransportProtocol; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.List; +import java.util.Map; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** Producer for Magic 8 Ball agent card configuration. */ +@ApplicationScoped +public final class Magic8BallAgentCardProducer { + + /** The HTTP port for the agent service. */ + @Inject + @ConfigProperty(name = "quarkus.http.port") + private int httpPort; + + /** The HTTP port for Keycloak. */ + @Inject + @ConfigProperty(name = "quarkus.keycloak.devservices.port") + private int keycloakPort; + + /** + * Produces the agent card for the Magic 8 Ball agent. + * + * @return the configured agent card + */ + @Produces + @PublicAgentCard + public AgentCard agentCard() { + ClientCredentialsOAuthFlow clientCredentialsOAuthFlow = new ClientCredentialsOAuthFlow( + null, + Map.of("openid", "openid", "profile", "profile"), + "http://localhost:" + keycloakPort + "/realms/quarkus/protocol/openid-connect/token"); + OAuth2SecurityScheme securityScheme = new OAuth2SecurityScheme.Builder() + .flows(new OAuthFlows.Builder().clientCredentials(clientCredentialsOAuthFlow).build()) + .build(); + + return new AgentCard.Builder() + .name("Magic 8 Ball Agent") + .description( + "A mystical fortune-telling agent that answers your yes/no " + + "questions by asking the all-knowing Magic 8 Ball oracle.") + .preferredTransport(TransportProtocol.JSONRPC.asString()) + .url("http://localhost:" + httpPort) + .version("1.0.0") + .documentationUrl("http://example.com/docs") + .capabilities( + new AgentCapabilities.Builder() + .streaming(true) + .pushNotifications(false) + .stateTransitionHistory(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .security(List.of(Map.of(OAuth2SecurityScheme.OAUTH2, + List.of("profile")))) + .securitySchemes(Map.of(OAuth2SecurityScheme.OAUTH2, securityScheme)) + .skills( + List.of( + new AgentSkill.Builder() + .id("magic_8_ball") + .name("Magic 8 Ball Fortune Teller") + .description("Uses a Magic 8 Ball to answer" + + " yes/no questions") + .tags(List.of("fortune", "magic-8-ball", "oracle")) + .examples( + List.of( + "Should I deploy this code on Friday?", + "Will my tests pass?", + "Is this a good idea?")) + .build())) + .protocolVersion("0.3.0") + .additionalInterfaces( + List.of( + new AgentInterface( + TransportProtocol.JSONRPC.asString(), + "http://localhost:" + httpPort), + new AgentInterface( + TransportProtocol.HTTP_JSON.asString(), + "http://localhost:" + httpPort), + new AgentInterface(TransportProtocol.GRPC.asString(), + "localhost:" + httpPort))) + .build(); + } +} diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentExecutorProducer.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentExecutorProducer.java new file mode 100644 index 000000000..3711128c8 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallAgentExecutorProducer.java @@ -0,0 +1,118 @@ +package com.samples.a2a; + +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.server.agentexecution.RequestContext; +import io.a2a.server.events.EventQueue; +import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.JSONRPCError; +import io.a2a.spec.Message; +import io.a2a.spec.Part; +import io.a2a.spec.Task; +import io.a2a.spec.TaskNotCancelableError; +import io.a2a.spec.TaskState; +import io.a2a.spec.TextPart; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import java.util.List; +import java.util.UUID; + +/** Producer for Magic 8 Ball agent executor. */ +@ApplicationScoped +public final class Magic8BallAgentExecutorProducer { + + /** The Magic 8 Ball agent instance. */ + @Inject private Magic8BallAgent magic8BallAgent; + + /** + * Produces the agent executor for the Magic 8 Ball agent. + * + * @return the configured agent executor + */ + @Produces + public AgentExecutor agentExecutor() { + return new Magic8BallAgentExecutor(magic8BallAgent); + } + + /** Magic 8 Ball agent executor implementation. */ + private static class Magic8BallAgentExecutor implements AgentExecutor { + + /** The Magic 8 Ball agent instance. */ + private final Magic8BallAgent agent; + + /** + * Constructor for Magic8BallAgentExecutor. + * + * @param magic8BallAgentInstance the Magic 8 Ball agent instance + */ + Magic8BallAgentExecutor(final Magic8BallAgent magic8BallAgentInstance) { + this.agent = magic8BallAgentInstance; + } + + @Override + public void execute(final RequestContext context, + final EventQueue eventQueue) + throws JSONRPCError { + final TaskUpdater updater = new TaskUpdater(context, eventQueue); + + // mark the task as submitted and start working on it + if (context.getTask() == null) { + updater.submit(); + } + updater.startWork(); + + // extract the text from the message + final String question = extractTextFromMessage(context.getMessage()); + + // Generate a unique memory ID for this request for fresh chat memory + final String memoryId = UUID.randomUUID().toString(); + System.out.println( + "=== EXECUTOR === Using memory ID: " + + memoryId + " for question: " + question); + + // call the Magic 8 Ball agent with the question + final String response = agent.answerQuestion(memoryId, question); + + // create the response part + final TextPart responsePart = new TextPart(response, null); + final List> parts = List.of(responsePart); + + // add the response as an artifact and complete the task + updater.addArtifact(parts, null, null, null); + updater.complete(); + } + + private String extractTextFromMessage(final Message message) { + final StringBuilder textBuilder = new StringBuilder(); + if (message.getParts() != null) { + for (final Part part : message.getParts()) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } + } + } + return textBuilder.toString(); + } + + @Override + public void cancel(final RequestContext context, + final EventQueue eventQueue) + throws JSONRPCError { + final Task task = context.getTask(); + + if (task.getStatus().state() == TaskState.CANCELED) { + // task already cancelled + throw new TaskNotCancelableError(); + } + + if (task.getStatus().state() == TaskState.COMPLETED) { + // task already completed + throw new TaskNotCancelableError(); + } + + // cancel the task + final TaskUpdater updater = new TaskUpdater(context, eventQueue); + updater.cancel(); + } + } +} diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallTools.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallTools.java new file mode 100644 index 000000000..3e5be00a0 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/Magic8BallTools.java @@ -0,0 +1,59 @@ +package com.samples.a2a; + +import dev.langchain4j.agent.tool.Tool; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.concurrent.ThreadLocalRandom; + +/** Service class that provides Magic 8 Ball fortune-telling functionality. */ +@ApplicationScoped +public class Magic8BallTools { + + /** All possible Magic 8 Ball responses. */ + private static final String[] RESPONSES = { + // Positive responses (10) + "It is certain", + "It is decidedly so", + "Without a doubt", + "Yes definitely", + "You may rely on it", + "As I see it, yes", + "Most likely", + "Outlook good", + "Yes", + "Signs point to yes", + + // Negative responses (5) + "Don't count on it", + "My reply is no", + "My sources say no", + "Outlook not so good", + "Very doubtful", + + // Non-committal responses (5) + "Better not tell you now", + "Cannot predict now", + "Concentrate and ask again", + "Ask again later", + "Reply hazy, try again" + }; + + /** + * Get the response from the Magic 8 Ball. + * + * @param question the user's question + * @return A random Magic 8 Ball response + */ + @Tool("Get the response to the user's question from the Magic 8 Ball") + public String shakeMagic8Ball(final String question) { + int index = ThreadLocalRandom.current().nextInt(RESPONSES.length); + String response = RESPONSES[index]; + System.out.println( + "=== TOOL CALLED === Question: " + + question + + ", Index: " + + index + + ", Response: " + + response); + return response; + } +} diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/package-info.java b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/package-info.java new file mode 100644 index 000000000..7079e6f4d --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/java/com/samples/a2a/package-info.java @@ -0,0 +1,2 @@ +/** Magic 8 Ball package. */ +package com.samples.a2a; diff --git a/samples/java/agents/magic_8_ball_security/server/src/main/resources/application.properties b/samples/java/agents/magic_8_ball_security/server/src/main/resources/application.properties new file mode 100644 index 000000000..d1ac51233 --- /dev/null +++ b/samples/java/agents/magic_8_ball_security/server/src/main/resources/application.properties @@ -0,0 +1,7 @@ +# Use the same port for gRPC and HTTP +quarkus.grpc.server.use-separate-server=false +quarkus.http.port=11000 +quarkus.langchain4j.ai.gemini.timeout=42543 +quarkus.keycloak.devservices.port=11001 +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash + diff --git a/samples/java/agents/pom.xml b/samples/java/agents/pom.xml new file mode 100644 index 000000000..90ccf5a53 --- /dev/null +++ b/samples/java/agents/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + com.samples.a2a + agents-parent + 0.1.0 + pom + + + content_editor + content_writer + dice_agent_multi_transport + magic_8_ball_security + weather_mcp + + + + 17 + 17 + UTF-8 + + + 0.3.0.Final + + + 4.1.0 + 3.26.1 + 1.3.1 + 4.31.1 + + + + + + io.quarkus + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + + + diff --git a/samples/java/agents/weather_mcp/pom.xml b/samples/java/agents/weather_mcp/pom.xml index 351b32068..44cdf755f 100644 --- a/samples/java/agents/weather_mcp/pom.xml +++ b/samples/java/agents/weather_mcp/pom.xml @@ -4,31 +4,13 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.samples.a2a - weather - 0.1.0 - - - 17 - 17 - UTF-8 - 0.3.0.Alpha1 - 4.1.0 - 3.22.3 - 1.0.0 - + + com.samples.a2a + agents-parent + 0.1.0 + - - - - io.quarkus - quarkus-bom - ${quarkus.platform.version} - pom - import - - - + weather @@ -59,18 +41,6 @@ quarkus-langchain4j-mcp ${quarkus.langchain4j.version} - - io.quarkiverse.langchain4j - quarkus-langchain4j-mcp-deployment - ${quarkus.langchain4j.version} - pom - - - * - * - - - @@ -78,25 +48,10 @@ org.apache.maven.plugins maven-compiler-plugin - 3.13.0 - - 17 - io.quarkus quarkus-maven-plugin - ${quarkus.platform.version} - true - - - - build - generate-code - generate-code-tests - - - diff --git a/samples/java/agents/weather_mcp/src/main/resources/application.properties b/samples/java/agents/weather_mcp/src/main/resources/application.properties index 5e1433e9d..a4d0cb3c7 100644 --- a/samples/java/agents/weather_mcp/src/main/resources/application.properties +++ b/samples/java/agents/weather_mcp/src/main/resources/application.properties @@ -1,3 +1,4 @@ quarkus.http.port=10001 quarkus.langchain4j.mcp.weather.transport-type=stdio -quarkus.langchain4j.mcp.weather.command=uv,--directory,mcp,run,weather_mcp.py \ No newline at end of file +quarkus.langchain4j.mcp.weather.command=uv,--directory,mcp,run,weather_mcp.py +quarkus.langchain4j.ai.gemini.chat-model.model-id=gemini-2.5-flash diff --git a/samples/java/koog/.gitignore b/samples/java/koog/.gitignore new file mode 100644 index 000000000..00a5ece38 --- /dev/null +++ b/samples/java/koog/.gitignore @@ -0,0 +1,3 @@ +/.gradle +/build +/.kotlin diff --git a/samples/java/koog/README.md b/samples/java/koog/README.md new file mode 100644 index 000000000..b30319466 --- /dev/null +++ b/samples/java/koog/README.md @@ -0,0 +1,114 @@ +# Agent-to-Agent (A2A) with Koog Framework Examples + +This project demonstrates how to build A2A-enabled agents using [Koog](https://github.com/JetBrains/koog), the official JetBrains' framework for building predictable, +fault-tolerant, and enterprise-ready AI agents, targeting JVM backend, Android, iOS, JS, and WasmJS. + +## What is Koog? + +Koog is JetBrains' open-source agentic framework that empowers developers to build AI agents using Kotlin. It provides: + +- **Graph-based agent architecture**: Define agent behavior as a graph of nodes and edges with type-safe inputs and outputs, + making complex workflows easier to understand and maintain +- **Multi-platform support**: Deploy agents across JVM, Android, native iOS, JS, and WasmJS using Kotlin Multiplatform +- **Fault tolerance**: Built-in retry mechanisms and agent state persistence for reliable execution, allowing to recover + crashed agents even on another machine. +- **Prompt DSL**: Clean, type-safe DSL for building LLM prompts and automatically managing conversation context +- **Enterprise integrations**: Works seamlessly with Spring Boot, Ktor, and other JVM frameworks +- **Advanced Observability**: Built-in integrations with enterprise observability tools like Langfuse and W&B Weave via OpenTelemetry +- **A2A protocol support**: Built-in support for Agent-to-Agent communication via the A2A protocol + +Learn more at [koog.ai](https://koog.ai/) + +## Prerequisites + +- JDK 17 or higher +- Set `GOOGLE_API_KEY` environment variable (or configure other LLM providers in the code) + +## Examples + +### Simple Joke Agent: [simplejoke](./src/main/kotlin/ai/koog/example/simplejoke) + +A basic example demonstrating message-based A2A communication without task workflows. + +**What it demonstrates:** +- Creating an `AgentExecutor` that wraps LLM calls using Koog's prompt DSL +- Setting up an A2A server with an `AgentCard` that describes agent capabilities +- Managing conversation context with message storage +- Simple request-response pattern using `sendMessage()` + +**Run:** +```bash +# Terminal 1: Start server (port 9998) +./gradlew runExampleSimpleJokeServer + +# Terminal 2: Run client +./gradlew runExampleSimpleJokeClient +``` + +### Advanced Joke Agent: [advancedjoke](./src/main/kotlin/ai/koog/example/advancedjoke) + +A sophisticated example showcasing task-based A2A workflows using Koog's graph-based agent architecture. + +**What it demonstrates:** +- **Graph-based agent design**: Uses Koog's `GraphAIAgent` with nodes and edges to create a maintainable workflow +- **Task lifecycle management**: Full A2A task states (Submitted → Working → InputRequired → Completed) +- **Interactive clarification**: Agent can request additional information using the InputRequired state +- **Structured LLM outputs**: Uses sealed interfaces with `nodeLLMRequestStructured` for type-safe agent decisions +- **Artifact delivery**: Returns final results as A2A artifacts +- **Streaming events**: Sends real-time task updates via `sendTaskEvent()` + +**Run:** +```bash +# Terminal 1: Start server (port 9999) +./gradlew runExampleAdvancedJokeServer + +# Terminal 2: Run client +./gradlew runExampleAdvancedJokeClient +``` + +## Key Patterns & Koog Concepts + +### A2A Communication Patterns + +**Simple Agent:** `sendMessage()` → single response +**Advanced Agent:** `sendMessageStreaming()` → Flow of events (Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent) + +**Task States:** Submitted → Working → InputRequired (optional) → Completed + +### Koog Framework Concepts Used + +**AgentExecutor**: The entry point for A2A requests. Receives the request context and event processor for sending responses. + +**GraphAIAgent**: Koog's graph-based agent implementation. Define your agent logic as nodes (processing steps) connected by edges (transitions). + +**Prompt DSL**: Type-safe Kotlin DSL for building prompts: +```kotlin +prompt("joke-generation") { + system { +"You are a helpful assistant" } + user { +"Tell me a joke" } +} +``` + +**MultiLLMPromptExecutor**: Unified interface for executing prompts across different LLM providers (OpenAI, Anthropic, Google, etc.). + +**nodeLLMRequestStructured**: Creates a graph node that calls the LLM and parses the response into a structured Kotlin data class using the `@LLMDescription` annotation. + +**A2AAgentServer plugin**: Koog plugin that integrates A2A functionality into your GraphAIAgent, providing access to message storage, task storage, and event processors. + +### Getting Started with Koog + +To build your own A2A agent with Koog: + +1. **Add Koog dependencies** (see [build.gradle.kts](./build.gradle.kts)) +2. **Create an AgentExecutor** to handle incoming A2A requests +3. **Define an AgentCard** describing your agent's capabilities +4. **Set up the A2A server** with HTTP transport +5. **For simple agents**: Use prompt executor directly with message storage +6. **For complex agents**: Use GraphAIAgent with the A2AAgentServer plugin + +See the code comments in `JokeWriterAgentExecutor.kt` for detailed implementation guidance. + +## Learn More + +- [Koog GitHub Repository](https://github.com/JetBrains/koog) +- [Koog Documentation](https://koog.ai/) diff --git a/samples/java/koog/build.gradle.kts b/samples/java/koog/build.gradle.kts new file mode 100644 index 000000000..15fb86834 --- /dev/null +++ b/samples/java/koog/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ktlint) +} + +dependencies { + implementation(platform(libs.kotlin.bom)) + implementation(platform(libs.kotlinx.coroutines.bom)) + + implementation(libs.koog.agents) + implementation(libs.koog.agents.features.a2a.server) + implementation(libs.koog.agents.features.a2a.client) + implementation(libs.koog.a2a.transport.server.jsonrpc.http) + implementation(libs.koog.a2a.transport.client.jsonrpc.http) + + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.coroutines.core) + + implementation(libs.ktor.server.cio) + + runtimeOnly(libs.logback.classic) +} + +fun registerRunExampleTask( + name: String, + mainClassName: String, +) = tasks.register(name) { + doFirst { + standardInput = System.`in` + standardOutput = System.out + } + + mainClass.set(mainClassName) + classpath = sourceSets["main"].runtimeClasspath +} +// Simple joke generation +registerRunExampleTask("runExampleSimpleJokeServer", "ai.koog.example.simplejoke.ServerKt") +registerRunExampleTask("runExampleSimpleJokeClient", "ai.koog.example.simplejoke.ClientKt") + +// Advanced joke generation +registerRunExampleTask("runExampleAdvancedJokeServer", "ai.koog.example.advancedjoke.ServerKt") +registerRunExampleTask("runExampleAdvancedJokeClient", "ai.koog.example.advancedjoke.ClientKt") diff --git a/samples/java/koog/gradle.properties b/samples/java/koog/gradle.properties new file mode 100644 index 000000000..4db6c1cfc --- /dev/null +++ b/samples/java/koog/gradle.properties @@ -0,0 +1,7 @@ +#Kotlin +kotlin.code.style=official + +#Gradle +org.gradle.jvmargs=-Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/samples/java/koog/gradle/libs.versions.toml b/samples/java/koog/gradle/libs.versions.toml new file mode 100644 index 000000000..1150725e3 --- /dev/null +++ b/samples/java/koog/gradle/libs.versions.toml @@ -0,0 +1,29 @@ +[versions] +kotlin = "2.2.20" +kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" +kotlinx-serialization = "1.8.1" +ktor3 = "3.2.2" +koog = "0.5.0" +logback = "1.5.13" +oshai-logging = "7.0.7" +ktlint = "13.1.0" + +[libraries] +kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } +kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor3" } +koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" } +koog-agents-features-a2a-server = { module = "ai.koog:agents-features-a2a-server", version.ref = "koog" } +koog-agents-features-a2a-client = { module = "ai.koog:agents-features-a2a-client", version.ref = "koog" } +koog-a2a-transport-server-jsonrpc-http = { module = "ai.koog:a2a-transport-server-jsonrpc-http", version.ref = "koog" } +koog-a2a-transport-client-jsonrpc-http = { module = "ai.koog:a2a-transport-client-jsonrpc-http", version.ref = "koog" } +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +oshai-kotlin-logging = { module = "io.github.oshai:kotlin-logging", version.ref = "oshai-logging" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/samples/java/koog/gradle/wrapper/gradle-wrapper.jar b/samples/java/koog/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..1b33c55ba Binary files /dev/null and b/samples/java/koog/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/java/koog/gradle/wrapper/gradle-wrapper.properties b/samples/java/koog/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..ca025c83a --- /dev/null +++ b/samples/java/koog/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/samples/java/koog/gradlew b/samples/java/koog/gradlew new file mode 100755 index 000000000..556ff1614 --- /dev/null +++ b/samples/java/koog/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$(ls -ld "$app_path") + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$(cd -P "${APP_HOME:-./}" >/dev/null && printf '%s\n' "$PWD") || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn() { + echo "$*" +} >&2 + +die() { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$(uname)" in #( +CYGWIN*) cygwin=true ;; #( +Darwin*) darwin=true ;; #( +MSYS* | MINGW*) msys=true ;; #( +NONSTOP*) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ]; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop"; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$(ulimit -H -n) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) : ;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys"; then + APP_HOME=$(cygpath --path --mixed "$APP_HOME") + CLASSPATH=$(cygpath --path --mixed "$CLASSPATH") + + JAVACMD=$(cygpath --unix "$JAVACMD") + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg; do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) + t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] + ;; #( + *) false ;; + esac + then + arg=$(cygpath --path --ignore --mixed "$arg") + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1; then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' +)" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/java/koog/gradlew.bat b/samples/java/koog/gradlew.bat new file mode 100644 index 000000000..db3a6ac20 --- /dev/null +++ b/samples/java/koog/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/samples/java/koog/settings.gradle.kts b/samples/java/koog/settings.gradle.kts new file mode 100644 index 000000000..248587cfa --- /dev/null +++ b/samples/java/koog/settings.gradle.kts @@ -0,0 +1,15 @@ +rootProject.name = "koog" + +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + google() + } +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Client.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Client.kt new file mode 100644 index 000000000..3cdbdcde1 --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Client.kt @@ -0,0 +1,160 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package ai.koog.example.advancedjoke + +import ai.koog.a2a.client.A2AClient +import ai.koog.a2a.client.UrlAgentCardResolver +import ai.koog.a2a.model.Artifact +import ai.koog.a2a.model.Message +import ai.koog.a2a.model.MessageSendParams +import ai.koog.a2a.model.Role +import ai.koog.a2a.model.Task +import ai.koog.a2a.model.TaskArtifactUpdateEvent +import ai.koog.a2a.model.TaskState +import ai.koog.a2a.model.TaskStatusUpdateEvent +import ai.koog.a2a.model.TextPart +import ai.koog.a2a.transport.Request +import ai.koog.a2a.transport.client.jsonrpc.http.HttpJSONRPCClientTransport +import kotlinx.serialization.json.Json +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +private const val CYAN = "\u001B[36m" +private const val YELLOW = "\u001B[33m" +private const val MAGENTA = "\u001B[35m" +private const val GREEN = "\u001B[32m" +private const val RED = "\u001B[31m" +private const val BLUE = "\u001B[34m" +private const val RESET = "\u001B[0m" + +private val json = Json { prettyPrint = true } + +@OptIn(ExperimentalUuidApi::class) +suspend fun main() { + println("\n${YELLOW}Starting Advanced Joke Generator A2A Client$RESET\n") + + val transport = HttpJSONRPCClientTransport(url = "http://localhost:9999${ADVANCED_JOKE_AGENT_PATH}") + val agentCardResolver = + UrlAgentCardResolver(baseUrl = "http://localhost:9999", path = ADVANCED_JOKE_AGENT_CARD_PATH) + val client = A2AClient(transport = transport, agentCardResolver = agentCardResolver) + + client.connect() + val agentCard = client.cachedAgentCard() + println("${YELLOW}Connected: ${agentCard.name}$RESET\n") + + if (agentCard.capabilities.streaming != true) { + println("${RED}Error: Streaming not supported$RESET") + transport.close() + return + } + + println("${CYAN}Context ID:$RESET") + val contextId = readln() + println() + + var currentTaskId: String? = null + val artifacts = mutableMapOf() + + while (true) { + println("${CYAN}Request (/q to quit):$RESET") + val request = readln() + println() + + if (request == "/q") break + + val message = + Message( + messageId = Uuid.random().toString(), + role = Role.User, + parts = listOf(TextPart(request)), + contextId = contextId, + taskId = currentTaskId, + ) + + try { + client.sendMessageStreaming(Request(MessageSendParams(message = message))).collect { response -> + val event = response.data + println("$BLUE[${event.kind}]$RESET") + println("${json.encodeToString(event)}\n") + + when (event) { + is Task -> { + currentTaskId = event.id + event.artifacts?.forEach { artifacts[it.artifactId] = it } + } + + is Message -> { + val textContent = event.parts.filterIsInstance().joinToString("\n") { it.text } + if (textContent.isNotBlank()) { + println("${MAGENTA}Message:$RESET\n$textContent\n") + } + } + + is TaskStatusUpdateEvent -> { + when (event.status.state) { + TaskState.InputRequired -> { + val question = + event.status.message + ?.parts + ?.filterIsInstance() + ?.joinToString("\n") { it.text } + if (!question.isNullOrBlank()) { + println("${MAGENTA}Question:$RESET\n$question\n") + } + } + + TaskState.Completed -> { + if (artifacts.isNotEmpty()) { + println("$GREEN=== Artifacts ===$RESET") + artifacts.values.forEach { artifact -> + val content = + artifact.parts + .filterIsInstance() + .joinToString("\n") { it.text } + if (content.isNotBlank()) { + println("$GREEN[${artifact.artifactId}]$RESET\n$content\n") + } + } + } + if (event.final) { + currentTaskId = null + artifacts.clear() + } + } + + TaskState.Failed, TaskState.Canceled, TaskState.Rejected -> { + if (event.final) { + currentTaskId = null + artifacts.clear() + } + } + + else -> {} + } + } + + is TaskArtifactUpdateEvent -> { + if (event.append == true) { + val existing = artifacts[event.artifact.artifactId] + if (existing != null) { + artifacts[event.artifact.artifactId] = + existing.copy( + parts = existing.parts + event.artifact.parts, + ) + } else { + artifacts[event.artifact.artifactId] = event.artifact + } + } else { + artifacts[event.artifact.artifactId] = event.artifact + } + } + } + } + } catch (e: Exception) { + println("${RED}Error: ${e.message}$RESET\n") + } + } + + println("${YELLOW}Done$RESET") + transport.close() +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/JokeWriterAgentExecutor.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/JokeWriterAgentExecutor.kt new file mode 100644 index 000000000..27aca8204 --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/JokeWriterAgentExecutor.kt @@ -0,0 +1,425 @@ +package ai.koog.example.advancedjoke + +import ai.koog.a2a.exceptions.A2AUnsupportedOperationException +import ai.koog.a2a.model.Artifact +import ai.koog.a2a.model.MessageSendParams +import ai.koog.a2a.model.Role +import ai.koog.a2a.model.Task +import ai.koog.a2a.model.TaskArtifactUpdateEvent +import ai.koog.a2a.model.TaskState +import ai.koog.a2a.model.TaskStatus +import ai.koog.a2a.model.TaskStatusUpdateEvent +import ai.koog.a2a.model.TextPart +import ai.koog.a2a.server.agent.AgentExecutor +import ai.koog.a2a.server.session.RequestContext +import ai.koog.a2a.server.session.SessionEventProcessor +import ai.koog.agents.a2a.core.A2AMessage +import ai.koog.agents.a2a.core.toKoogMessage +import ai.koog.agents.a2a.server.feature.A2AAgentServer +import ai.koog.agents.a2a.server.feature.withA2AAgentServer +import ai.koog.agents.core.agent.GraphAIAgent +import ai.koog.agents.core.agent.config.AIAgentConfig +import ai.koog.agents.core.agent.context.agentInput +import ai.koog.agents.core.dsl.builder.forwardTo +import ai.koog.agents.core.dsl.builder.strategy +import ai.koog.agents.core.dsl.extension.nodeLLMRequestStructured +import ai.koog.agents.core.dsl.extension.onIsInstance +import ai.koog.agents.core.tools.ToolRegistry +import ai.koog.agents.core.tools.annotations.LLMDescription +import ai.koog.prompt.dsl.prompt +import ai.koog.prompt.executor.clients.google.GoogleLLMClient +import ai.koog.prompt.executor.clients.google.GoogleModels +import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor +import ai.koog.prompt.executor.model.PromptExecutor +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.message.Message +import ai.koog.prompt.text.text +import ai.koog.prompt.xml.xml +import kotlinx.datetime.Clock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.reflect.typeOf +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * An advanced A2A agent that demonstrates: + * - Task-based conversation flow with state management + * - Interactive clarification questions (InputRequired state) + * - Structured output via sealed interfaces + * - Artifact delivery for final results + */ +class JokeWriterAgentExecutor : AgentExecutor { + private val promptExecutor = + MultiLLMPromptExecutor( +// LLMProvider.OpenAI to OpenAILLMClient(System.getenv("OPENAI_API_KEY")), +// LLMProvider.Anthropic to AnthropicLLMClient(System.getenv("ANTHROPIC_API_KEY")), + LLMProvider.Google to GoogleLLMClient(System.getenv("GOOGLE_API_KEY")), + ) + + @OptIn(ExperimentalUuidApi::class) + override suspend fun execute( + context: RequestContext, + eventProcessor: SessionEventProcessor, + ) { + val agent = jokeWriterAgent(promptExecutor, context, eventProcessor) + agent.run(context.params.message) + } +} + +private fun jokeWriterAgent( + promptExecutor: PromptExecutor, + context: RequestContext, + eventProcessor: SessionEventProcessor, +): GraphAIAgent { + val agentConfig = + AIAgentConfig( + prompt = + prompt("joke-generation") { + system { + +"You are a very funny sarcastic assistant. You must help users generate funny jokes." + +( + "When asked for something else, sarcastically decline the request because you can only" + + " assist with jokes." + ) + } + }, + model = GoogleModels.Gemini2_5Flash, + maxAgentIterations = 20, + ) + + return GraphAIAgent( + inputType = typeOf(), + outputType = typeOf(), + promptExecutor = promptExecutor, + strategy = jokeWriterStrategy(), + agentConfig = agentConfig, + toolRegistry = ToolRegistry.EMPTY, + ) { + install(A2AAgentServer) { + this.context = context + this.eventProcessor = eventProcessor + } + } +} + +@OptIn(ExperimentalUuidApi::class) +private fun jokeWriterStrategy() = + strategy("joke-writer") { + // Node: Load conversation history from message storage + val setupMessageContext by node { userInput -> + if (!userInput.referenceTaskIds.isNullOrEmpty()) { + throw A2AUnsupportedOperationException( + "This agent doesn't understand task references in referenceTaskIds yet." + ) + } + + // Load current context messages + val contextMessages: List = + withA2AAgentServer { + context.messageStorage.getAll() + } + + // Update the prompt with the current context messages + llm.writeSession { + updatePrompt { + messages(contextMessages.map { it.toKoogMessage() }) + } + } + + userInput + } + + // Node: Load existing task (if continuing) or prepare for new task creation + val setupTaskContext by node { userInput -> + // Check if the message continues the task that already exists + val currentTask: Task? = + withA2AAgentServer { + context.task?.id?.let { id -> + // Load task with full conversation history to continue working on it + context.taskStorage.get(id, historyLength = null) + } + } + + currentTask?.let { task -> + val currentTaskMessages = + (task.history.orEmpty() + listOfNotNull(task.status.message) + userInput) + .map { it.toKoogMessage() } + + llm.writeSession { + updatePrompt { + user { + +"There's an ongoing task, the next messages contain conversation history for this task" + } + + messages(currentTaskMessages) + } + } + } + + // If task exists then the message belongs to the task, send event to update the task. + // Otherwise, put it in general message storage for the current context. + withA2AAgentServer { + if (currentTask != null) { + val updateEvent = + TaskStatusUpdateEvent( + taskId = currentTask.id, + contextId = currentTask.contextId, + status = + TaskStatus( + state = TaskState.Working, + message = userInput, + timestamp = Clock.System.now(), + ), + final = false, + ) + + eventProcessor.sendTaskEvent(updateEvent) + } else { + context.messageStorage.save(userInput) + } + } + + currentTask + } + + // Node: Ask LLM to classify if this is a joke request or something else + val classifyNewRequest by nodeLLMRequestStructured() + + // Node: Send a polite decline message if the request is not about jokes + val respondFallbackMessage by node { classification -> + withA2AAgentServer { + val message = + A2AMessage( + messageId = Uuid.random().toString(), + role = Role.Agent, + parts = + listOf( + TextPart(classification.response), + ), + contextId = context.contextId, + taskId = context.taskId, + ) + + // Store reply in message storage to preserve context + context.messageStorage.save(message) + // Reply with message + eventProcessor.sendMessage(message) + } + } + + // Node: Create a new task for the joke request + val createTask by node { + val userInput = agentInput() + + withA2AAgentServer { + val task = + Task( + id = context.taskId, + contextId = context.contextId, + status = + TaskStatus( + state = TaskState.Submitted, + message = userInput, + timestamp = Clock.System.now(), + ), + ) + + eventProcessor.sendTaskEvent(task) + } + } + + // Node: Ask LLM to classify joke details (or request clarification) + val classifyJokeRequest by nodeLLMRequestStructured() + + // Node: Generate the actual joke based on classified parameters + val generateJoke by node { request -> + llm.writeSession { + updatePrompt { + user { + +text { + +"Generate a joke based on the following user request:" + xml { + tag("subject") { + +request.subject + } + tag("targetAudience") { + +request.targetAudience + } + tag("isSwearingAllowed") { + +request.isSwearingAllowed.toString() + } + } + } + } + } + + val message = requestLLMWithoutTools() + message as? Message.Assistant ?: throw IllegalStateException("Unexpected message type: $message") + } + } + + // Node: Send InputRequired event to ask the user for more information + val askMoreInfo by node { clarification -> + withA2AAgentServer { + val taskUpdate = + TaskStatusUpdateEvent( + taskId = context.taskId, + contextId = context.contextId, + status = + TaskStatus( + state = TaskState.InputRequired, + message = + A2AMessage( + role = Role.Agent, + parts = + listOf( + TextPart(clarification.question), + ), + messageId = Uuid.random().toString(), + taskId = context.taskId, + contextId = context.contextId, + ), + timestamp = Clock.System.now(), + ), + final = true, + ) + + eventProcessor.sendTaskEvent(taskUpdate) + } + } + + // Node: Send the joke as an artifact and mark task as completed + val respondWithJoke by node { jokeMessage -> + withA2AAgentServer { + val artifactUpdate = + TaskArtifactUpdateEvent( + taskId = context.taskId, + contextId = context.contextId, + artifact = + Artifact( + artifactId = "joke", + parts = + listOf( + TextPart(jokeMessage.content), + ), + ), + ) + + eventProcessor.sendTaskEvent(artifactUpdate) + + val taskStatusUpdate = + TaskStatusUpdateEvent( + taskId = context.taskId, + contextId = context.contextId, + status = + TaskStatus( + state = TaskState.Completed, + ), + final = true, + ) + + eventProcessor.sendTaskEvent(taskStatusUpdate) + } + } + + // --- Graph Flow Definition --- + + // Always start by loading context and checking for existing tasks + nodeStart then setupMessageContext then setupTaskContext + + // If no task exists, classify whether this is a joke request + edge( + setupTaskContext forwardTo classifyNewRequest + onCondition { task -> task == null } + transformed { agentInput().content() }, + ) + // If task exists, continue processing the joke request + edge( + setupTaskContext forwardTo classifyJokeRequest + onCondition { task -> task != null } + transformed { agentInput().content() }, + ) + + // New request classification: If not a joke request, decline politely + edge( + classifyNewRequest forwardTo respondFallbackMessage + transformed { it.getOrThrow().structure } + onCondition { !it.isJokeRequest }, + ) + // New request classification: If joke request, create a task + edge( + classifyNewRequest forwardTo createTask + transformed { it.getOrThrow().structure } + onCondition { it.isJokeRequest }, + ) + + edge(respondFallbackMessage forwardTo nodeFinish) + + // After creating task, classify the joke details + edge( + createTask forwardTo classifyJokeRequest + transformed { agentInput().content() }, + ) + + // Joke classification: Ask for clarification if needed + edge( + classifyJokeRequest forwardTo askMoreInfo + transformed { it.getOrThrow().structure } + onIsInstance JokeRequestClassification.NeedsClarification::class, + ) + // Joke classification: Generate joke if we have all details + edge( + classifyJokeRequest forwardTo generateJoke + transformed { it.getOrThrow().structure } + onIsInstance JokeRequestClassification.Ready::class, + ) + + // After asking for info, wait for user response (finish this iteration) + edge(askMoreInfo forwardTo nodeFinish) + + // After generating joke, send it as an artifact + edge(generateJoke forwardTo respondWithJoke) + edge(respondWithJoke forwardTo nodeFinish) + } + +private fun A2AMessage.content(): String = parts.filterIsInstance().joinToString(separator = "\n") { it.text } + +// --- Structured Output Models --- + +@Serializable +@LLMDescription("Initial incoming user message classification, to determine if this is a joke request or not.") +private data class UserRequestClassification( + @property:LLMDescription("Whether the incoming message is a joke request or not") + val isJokeRequest: Boolean, + @property:LLMDescription( + "In case the message is not a joke request, polite reply to the user that the agent cannot assist." + + "Default is empty", + ) + val response: String = "", +) + +@LLMDescription("The classification of the joke request") +@Serializable +@SerialName("JokeRequestClassification") +private sealed interface JokeRequestClassification { + @Serializable + @SerialName("NeedsClarification") + @LLMDescription("The joke request needs clarification") + data class NeedsClarification( + @property:LLMDescription("The question that needs clarification") + val question: String, + ) : JokeRequestClassification + + @LLMDescription("The joke request is ready to be processed") + @Serializable + @SerialName("Ready") + data class Ready( + @property:LLMDescription("The joke subject") + val subject: String, + @property:LLMDescription("The joke target audience") + val targetAudience: String, + @property:LLMDescription("Whether the swearing is allowed in the joke") + val isSwearingAllowed: Boolean, + ) : JokeRequestClassification +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Server.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Server.kt new file mode 100644 index 000000000..6adc29a7f --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/advancedjoke/Server.kt @@ -0,0 +1,87 @@ +package ai.koog.example.advancedjoke + +import ai.koog.a2a.model.AgentCapabilities +import ai.koog.a2a.model.AgentCard +import ai.koog.a2a.model.AgentInterface +import ai.koog.a2a.model.AgentSkill +import ai.koog.a2a.model.TransportProtocol +import ai.koog.a2a.server.A2AServer +import ai.koog.a2a.transport.server.jsonrpc.http.HttpJSONRPCServerTransport +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.server.cio.CIO + +private val logger = KotlinLogging.logger {} + +const val ADVANCED_JOKE_AGENT_PATH = "/advanced-joke-agent" +const val ADVANCED_JOKE_AGENT_CARD_PATH = "$ADVANCED_JOKE_AGENT_PATH/agent-card.json" + +suspend fun main() { + logger.info { "Starting Advanced Joke A2A Agent on http://localhost:9999" } + + // Create agent card with capabilities - this agent supports streaming and tasks + val agentCard = + AgentCard( + protocolVersion = "0.3.0", + name = "Advanced Joke Generator", + description = + "A sophisticated AI agent that generates jokes with clarifying questions and structured task flow", + version = "1.0.0", + url = "http://localhost:9999$ADVANCED_JOKE_AGENT_PATH", + preferredTransport = TransportProtocol.JSONRPC, + additionalInterfaces = + listOf( + AgentInterface( + url = "http://localhost:9999$ADVANCED_JOKE_AGENT_PATH", + transport = TransportProtocol.JSONRPC, + ), + ), + capabilities = + AgentCapabilities( + streaming = true, // Supports streaming responses + pushNotifications = false, + stateTransitionHistory = false, + ), + defaultInputModes = listOf("text"), + defaultOutputModes = listOf("text"), + skills = + listOf( + AgentSkill( + id = "advanced_joke_generation", + name = "Advanced Joke Generation", + description = + "Generates humorous jokes with interactive clarification and customization options", + examples = + listOf( + "Tell me a joke about programming", + "Generate a funny joke for teenagers", + "Make me laugh with a dad joke about cats", + ), + tags = listOf("humor", "jokes", "entertainment", "interactive"), + ), + ), + supportsAuthenticatedExtendedCard = false, + ) + + // Create agent executor + val agentExecutor = JokeWriterAgentExecutor() + + // Create A2A server + val a2aServer = + A2AServer( + agentExecutor = agentExecutor, + agentCard = agentCard, + ) + + // Create and start server transport + val serverTransport = HttpJSONRPCServerTransport(a2aServer) + + logger.info { "Advanced Joke Generator Agent ready at http://localhost:9999/$ADVANCED_JOKE_AGENT_PATH" } + serverTransport.start( + engineFactory = CIO, + port = 9999, + path = ADVANCED_JOKE_AGENT_PATH, + wait = true, // Block until server stops + agentCard = agentCard, + agentCardPath = ADVANCED_JOKE_AGENT_CARD_PATH, + ) +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Client.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Client.kt new file mode 100644 index 000000000..32bdf03e3 --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Client.kt @@ -0,0 +1,94 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package ai.koog.example.simplejoke + +import ai.koog.a2a.client.A2AClient +import ai.koog.a2a.client.UrlAgentCardResolver +import ai.koog.a2a.model.Message +import ai.koog.a2a.model.MessageSendParams +import ai.koog.a2a.model.Role +import ai.koog.a2a.model.TextPart +import ai.koog.a2a.transport.Request +import ai.koog.a2a.transport.client.jsonrpc.http.HttpJSONRPCClientTransport +import ai.koog.agents.a2a.core.toKoogMessage +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +private const val BRIGHT_CYAN = "\u001B[1;36m" +private const val YELLOW = "\u001B[33m" +private const val BRIGHT_MAGENTA = "\u001B[1;35m" +private const val RED = "\u001B[31m" +private const val RESET = "\u001B[0m" + +@OptIn(ExperimentalUuidApi::class) +suspend fun main() { + println() + println("${YELLOW}Starting Joke Generator A2A Client$RESET\n") + + // Set up the HTTP JSON-RPC transport + val transport = + HttpJSONRPCClientTransport( + url = "http://localhost:9998${JOKE_GENERATOR_AGENT_PATH}", + ) + + // Set up the agent card resolver + val agentCardResolver = + UrlAgentCardResolver( + baseUrl = "http://localhost:9998", + path = JOKE_GENERATOR_AGENT_CARD_PATH, + ) + + // Create the A2A client + val client = + A2AClient( + transport = transport, + agentCardResolver = agentCardResolver, + ) + + // Connect and fetch agent card + client.connect() + val agentCard = client.cachedAgentCard() + println("${YELLOW}Connected to agent:$RESET\n${agentCard.name} (${agentCard.description})\n") + + // Read context ID + println("${BRIGHT_CYAN}Context ID (which chat to start/continue):$RESET") + val contextId = readln() + println() + + // Start chat loop + while (true) { + println("${BRIGHT_CYAN}Request (/q to quit):$RESET") + val request = readln() + println() + + if (request == "/q") { + break + } + + val message = + Message( + messageId = Uuid.random().toString(), + role = Role.User, + parts = listOf(TextPart(request)), + contextId = contextId, + ) + + val response = + client.sendMessage( + Request(MessageSendParams(message = message)), + ) + + val replyMessage = response.data as? Message + if (replyMessage != null) { + val reply = replyMessage.toKoogMessage().content + println("${BRIGHT_MAGENTA}Agent response:${RESET}\n$reply\n") + } else { + println("${RED}Error: Unexpected response type from agent.$RESET\n") + } + } + + println("${RED}Conversation complete!$RESET") + + // Clean up + transport.close() +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Server.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Server.kt new file mode 100644 index 000000000..9aa837c5e --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/Server.kt @@ -0,0 +1,85 @@ +package ai.koog.example.simplejoke + +import ai.koog.a2a.model.AgentCapabilities +import ai.koog.a2a.model.AgentCard +import ai.koog.a2a.model.AgentInterface +import ai.koog.a2a.model.AgentSkill +import ai.koog.a2a.model.TransportProtocol +import ai.koog.a2a.server.A2AServer +import ai.koog.a2a.transport.server.jsonrpc.http.HttpJSONRPCServerTransport +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.server.cio.CIO + +private val logger = KotlinLogging.logger {} + +const val JOKE_GENERATOR_AGENT_PATH = "/joke-generator-agent" +const val JOKE_GENERATOR_AGENT_CARD_PATH = "$JOKE_GENERATOR_AGENT_PATH/agent-card.json" + +suspend fun main() { + logger.info { "Starting Joke A2A Agent on http://localhost:9998" } + + // Create agent card with capabilities + val agentCard = + AgentCard( + protocolVersion = "0.3.0", + name = "Joke Generator", + description = "A helpful AI agent that generates jokes based on user requests", + version = "1.0.0", + url = "http://localhost:9998$JOKE_GENERATOR_AGENT_PATH", + preferredTransport = TransportProtocol.JSONRPC, + additionalInterfaces = + listOf( + AgentInterface( + url = "http://localhost:9998$JOKE_GENERATOR_AGENT_PATH", + transport = TransportProtocol.JSONRPC, + ), + ), + capabilities = + AgentCapabilities( + streaming = false, + pushNotifications = false, + stateTransitionHistory = false, + ), + defaultInputModes = listOf("text"), + defaultOutputModes = listOf("text"), + skills = + listOf( + AgentSkill( + id = "joke_generation", + name = "Joke Generation", + description = "Generates humorous jokes on various topics", + examples = + listOf( + "Tell me a joke", + "Generate a funny joke about programming", + "Make me laugh with a dad joke", + ), + tags = listOf("humor", "jokes", "entertainment"), + ), + ), + supportsAuthenticatedExtendedCard = false, + ) + + // Create agent executor + val agentExecutor = SimpleJokeAgentExecutor() + + // Create A2A server + val a2aServer = + A2AServer( + agentExecutor = agentExecutor, + agentCard = agentCard, + ) + + // Create and start server transport + val serverTransport = HttpJSONRPCServerTransport(a2aServer) + + logger.info { "Joke Generator Agent ready at http://localhost:9998/$JOKE_GENERATOR_AGENT_PATH" } + serverTransport.start( + engineFactory = CIO, + port = 9998, + path = JOKE_GENERATOR_AGENT_PATH, + wait = true, // Block until server stops + agentCard = agentCard, + agentCardPath = JOKE_GENERATOR_AGENT_CARD_PATH, + ) +} diff --git a/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/SimpleJokeAgentExecutor.kt b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/SimpleJokeAgentExecutor.kt new file mode 100644 index 000000000..eb688ddbb --- /dev/null +++ b/samples/java/koog/src/main/kotlin/ai/koog/example/simplejoke/SimpleJokeAgentExecutor.kt @@ -0,0 +1,79 @@ +package ai.koog.example.simplejoke + +import ai.koog.a2a.exceptions.A2AUnsupportedOperationException +import ai.koog.a2a.model.MessageSendParams +import ai.koog.a2a.server.agent.AgentExecutor +import ai.koog.a2a.server.session.RequestContext +import ai.koog.a2a.server.session.SessionEventProcessor +import ai.koog.agents.a2a.core.MessageA2AMetadata +import ai.koog.agents.a2a.core.toA2AMessage +import ai.koog.agents.a2a.core.toKoogMessage +import ai.koog.prompt.dsl.prompt +import ai.koog.prompt.executor.clients.google.GoogleLLMClient +import ai.koog.prompt.executor.clients.google.GoogleModels +import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.message.Message +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * This is a simple example of an agent executor that wraps LLM calls using prompt executor to generate jokes. + */ +class SimpleJokeAgentExecutor : AgentExecutor { + private val promptExecutor = + MultiLLMPromptExecutor( +// LLMProvider.OpenAI to OpenAILLMClient(System.getenv("OPENAI_API_KEY")), +// LLMProvider.Anthropic to AnthropicLLMClient(System.getenv("ANTHROPIC_API_KEY")), + LLMProvider.Google to GoogleLLMClient(System.getenv("GOOGLE_API_KEY")), + ) + + @OptIn(ExperimentalUuidApi::class) + override suspend fun execute( + context: RequestContext, + eventProcessor: SessionEventProcessor, + ) { + val userMessage = context.params.message + + if (context.task != null || !userMessage.referenceTaskIds.isNullOrEmpty()) { + throw A2AUnsupportedOperationException("This agent doesn't support tasks") + } + + // Save incoming message to the current context + context.messageStorage.save(userMessage) + + // Load all messages from the current context + val contextMessages = context.messageStorage.getAll().map { it.toKoogMessage() } + + val prompt = + prompt("joke-generation") { + system { + +"You are an assistant helping user to generate jokes" + } + + // Append current message context + messages(contextMessages) + } + + // Get a response from the LLM + val responseMessage = + promptExecutor + .execute(prompt, GoogleModels.Gemini2_5Flash) + .single() + .let { message -> + message as? Message.Assistant ?: throw IllegalStateException("Unexpected message type: $message") + }.toA2AMessage( + a2aMetadata = + MessageA2AMetadata( + messageId = Uuid.random().toString(), + contextId = context.contextId, + ), + ) + + // Save the response to the current context + context.messageStorage.save(responseMessage) + + // Reply with message + eventProcessor.sendMessage(responseMessage) + } +} diff --git a/samples/java/koog/src/main/resources/logback.xml b/samples/java/koog/src/main/resources/logback.xml new file mode 100644 index 000000000..24a99c370 --- /dev/null +++ b/samples/java/koog/src/main/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/samples/python/agents/dice_agent_grpc/test_client.py b/samples/python/agents/dice_agent_grpc/test_client.py index a5e02a49d..038121b1b 100644 --- a/samples/python/agents/dice_agent_grpc/test_client.py +++ b/samples/python/agents/dice_agent_grpc/test_client.py @@ -46,15 +46,18 @@ async def main(agent_card_url: str, grpc_endpoint: str | None) -> None: # specifies if authenticated card should be fetched. # If an authenticated agent card is provided, client should use it for interacting with the gRPC service try: - logger.info( - 'Attempting to fetch authenticated agent card from grpc endpoint' - ) - proto_card = await stub.GetAgentCard(a2a_pb2.GetAgentCardRequest()) - logger.info('Successfully fetched agent card:') - logger.info(proto_card) - final_agent_card_to_use = proto_utils.FromProto.agent_card( - proto_card - ) + if agent_card.supports_authenticated_extended_card: + logger.info( + 'Attempting to fetch authenticated agent card from grpc endpoint' + ) + proto_card = await stub.GetAgentCard(a2a_pb2.GetAgentCardRequest()) + logger.info('Successfully fetched agent card:') + logger.info(proto_card) + final_agent_card_to_use = proto_utils.FromProto.agent_card( + proto_card + ) + else: + final_agent_card_to_use = agent_card except Exception: logging.exception('Failed to get authenticated agent card. Exiting.') return diff --git a/samples/python/agents/github-agent/README.md b/samples/python/agents/github-agent/README.md index d9e0ce1fc..805f582bd 100644 --- a/samples/python/agents/github-agent/README.md +++ b/samples/python/agents/github-agent/README.md @@ -96,7 +96,7 @@ git clone https://github.com/a2aproject/a2a-samples.git cd a2a-samples/samples/python/hosts/cli/ # run cli -uv run . http://localhost:10007 +uv run . --agent http://localhost:10007 ``` This will start an interactive CLI that connects to your GitHub agent server. diff --git a/samples/python/agents/github-agent/pyproject.toml b/samples/python/agents/github-agent/pyproject.toml index c0d6dc72e..92ad81dcd 100644 --- a/samples/python/agents/github-agent/pyproject.toml +++ b/samples/python/agents/github-agent/pyproject.toml @@ -5,7 +5,7 @@ description = "A2A GitHub agent" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk>=0.3.0", + "a2a-sdk[http-server]>=0.3.0", "click>=8.1.8", "dotenv>=0.9.9", "httpx>=0.28.1", @@ -15,6 +15,7 @@ dependencies = [ "uvicorn>=0.34.2", "pygithub>=2.5.0", "requests>=2.31.0", + "fastapi>=0.121.0", ] [tool.hatch.build.targets.wheel] diff --git a/samples/python/hosts/weather_and_airbnb_planner/README.md b/samples/python/hosts/weather_and_airbnb_planner/README.md index e75f12195..f293638dd 100644 --- a/samples/python/hosts/weather_and_airbnb_planner/README.md +++ b/samples/python/hosts/weather_and_airbnb_planner/README.md @@ -136,7 +136,7 @@ Run the airbnb agent server: Open a new terminal and run the weather agent: ```bash - cd samples/multi_language/python_and_java_multiagent/weather_agent + cd samples/java/agents/weather_mcp mvn quarkus:dev ``` @@ -152,7 +152,7 @@ Open a new terminal and run the host agent server: ## 5. Test using the UI -From your browser, navigate to . +From your browser, navigate to . Here are example questions: