diff --git a/docs/a2a-v1-migration/README.md b/docs/a2a-v1-migration/README.md new file mode 100644 index 0000000000..64bb7524b6 --- /dev/null +++ b/docs/a2a-v1-migration/README.md @@ -0,0 +1,31 @@ +# A2A v1 Migration + +This directory contains documentation for migrating from the legacy `trpc-a2a-go` protocol to the official A2A v1 protocol. + +## Documents + +| Document | Description | +|----------|-------------| +| [Migration Guide](./migration-guide.md) | **User-facing guide** with step-by-step upgrade instructions | +| [Migration Plan](./a2a-migration-plan.md) | Internal technical plan for the migration implementation | + +## Quick Links + +- [What's Changing](./migration-guide.md#whats-changing) +- [Upgrade Steps](./migration-guide.md#upgrade-steps) +- [Custom Clients](./migration-guide.md#for-users-with-custom-a2a-clients-or-ui) +- [FAQ](./migration-guide.md#faq) + +## Summary + +The migration from legacy A2A to A2A v1 follows this path: + +``` +Current → 0.10.0 → 0.11.0 → (migrate data) → 0.12.0 +``` + +1. **0.10.0**: Controller learns to read both formats (bridge release) +2. **0.11.0**: Controller starts writing v1 format; requires historical data migration +3. **0.12.0**: Legacy support removed; v1 only + +For detailed instructions, see the [Migration Guide](./migration-guide.md). diff --git a/docs/a2a-v1-migration/a2a-migration-plan.md b/docs/a2a-v1-migration/a2a-migration-plan.md new file mode 100644 index 0000000000..ab15f380e4 --- /dev/null +++ b/docs/a2a-v1-migration/a2a-migration-plan.md @@ -0,0 +1,171 @@ +# A2A v1 Migration Plan + +## Goal + +Move kagent from the current `trpc-a2a-go` / A2A 0.3-era protocol shape to official A2A v1 without requiring a maintenance window. + +The final target state is: + +- Go ADK, Python ADK, UI, and `go/core` speak official A2A v1 on the wire. +- New `task` and `push_notification` rows are stored as official A2A v1 JSON. +- Existing legacy rows remain readable during the migration. +- Users upgrade normally across releases (`0.10.0` → `0.11.0` → migrate on `0.11.0` → `0.12.0`) instead of requiring a maintenance-window storage flip. + +## Version Signals + +The migration uses two separate version signals: + +- **Wire version:** selected by request metadata. + - Missing `A2A-Version` header means legacy/current kagent A2A behavior. + - `A2A-Version: 1.0` means official A2A v1 behavior. + - Unknown versions fail with a clear unsupported-version error. +- **Storage version:** selected by DB row metadata. + - `protocol_version IS NULL` or legacy value means stored `trpc-a2a-go` JSON. + - `protocol_version = "1.0"` means stored official A2A v1 JSON. + +This keeps wire compatibility independent from persisted-data migration. + +## Required Upgrade Path + +The supported zero-downtime path is: + +```text +0.9.x -> 0.10.0 -> 0.11.0 -> (run migrate a2a-v1) -> 0.12.0 +``` + +Direct upgrades from `0.9.x` to `0.11.0` or `0.12.0` should be rejected or documented as unsupported unless the installation first passes through the `0.10.0` bridge release. + +Before upgrading to `0.12.0`, every installation that upgraded from a prior kagent release must run the historical storage migration CLI while still on `0.11.0` (see [Optional Historical Migration](#optional-historical-migration)). Fresh installs of `0.11.0` or later that never had legacy rows may skip the CLI. + +## Release 0.10.0: Bridge Release + +`0.10.0` makes every running controller capable of understanding both legacy and v1 data before any v1 storage writes begin. + +High-level behavior: + +- Controller can read legacy and v1 `task` / `push_notification` rows. +- Controller always writes legacy `trpc-a2a-go` storage. +- Controller uses official A2A SDK compatibility for public A2A wire handling where possible; it should not add custom JSON-RPC decoding for official v0.3 traffic. +- Controller serves missing-header or v0.3 callers with legacy A2A wire responses. +- Controller serves `A2A-Version: 1.0` callers with v1 wire responses. +- Controller continues selecting A2A `0.3` interfaces when proxying to managed agents. +- First-party UI and managed agent runtimes stay on the existing legacy/v0.3 behavior in this release. +- Controller can convert in both directions as needed: + - legacy storage -> v1 wire, + - legacy storage -> legacy wire, + - v1 storage -> v1 wire, + - v1 storage -> legacy wire. +- AgentCards advertise both legacy and v1 interfaces, preferably with the same URL and different `protocolVersion` values. +- CORS, proxies, and gRPC metadata preserve `A2A-Version`. + +Why this is safe: + +- Old controller pods may exist during rollout. +- New controller pods still write legacy storage. +- Therefore old controller pods never need to read rows written in v1 format. + +## Release 0.11.0: v1 Write Release + +`0.11.0` assumes the installation already passed through `0.10.0`, so all controllers that may read new rows are compatibility-capable. Alternatively, you can do a fresh install of `0.11.0` if you do not have kagent running already. + +High-level behavior: + +- Controller still dual-reads legacy and v1 rows. +- Controller writes new `task` and `push_notification` rows as official A2A v1 JSON. +- New v1 rows get `protocol_version = "1.0"`. +- UI moves to the A2A v1 SDK/types and sends/selects `protocolVersion: 1.0` / `A2A-Version: 1.0`. +- Managed Go and Python agent runtimes move to v1 interfaces. +- Controller switches upstream managed-agent client selection from A2A `0.3` interfaces to A2A `1.0` interfaces. +- Legacy wire compatibility remains available for missing-header callers through this release; it is removed in `0.12.0`. +- Historical legacy rows do not need to be rewritten to serve traffic on `0.11.0`, but must be migrated via `kagent migrate a2a-v1` before upgrading to `0.12.0`. + +Why this is safe: + +- Any controller remaining after the `0.11.0` upgrade has the dual-read compatibility introduced in `0.10.0`. +- New v1 writes are readable by all supported controllers in this upgrade path. +- Existing legacy rows continue to be converted on read until `0.12.0`. + +## Optional Historical Migration + +Historical row migration is not required to serve traffic on `0.11.0`, but it **is required** before upgrading to `0.12.0` for any installation that still has legacy `task` or `push_notification` rows. Run it while still on `0.11.0`: + +```bash +kagent migrate a2a-v1 --dry-run +kagent migrate a2a-v1 +``` + +The command converts legacy `task` and `push_notification` rows to official A2A v1 JSON and sets `protocol_version = "1.0"`. + +It should be: + +- batch-based, +- idempotent, +- restartable, +- safe against concurrent row changes, +- explicit about migrated/skipped/failed counts. + +The controller keeps dual-read compatibility through `0.11.0` so traffic continues while the CLI runs. `0.12.0` removes that compatibility; do not upgrade until migrated-row count is zero (or the installation never had legacy rows). + +## Component Changes + +### Controller / Core + +- Add nullable `protocol_version` columns for `task` and `push_notification`. +- Centralize conversion between legacy `trpc-a2a-go` data and official A2A v1 types. +- Use official A2A SDK compatibility for official v0.3/v1 wire handling where possible. +- Negotiate wire format from AgentCard interface selection and `A2A-Version`. +- Select storage parser from `protocol_version`. +- In `0.10.0`, write legacy storage only. +- In `0.10.0`, continue selecting managed-agent A2A `0.3` interfaces. +- In `0.11.0`, write v1 storage by default. +- In `0.11.0`, switch managed-agent interface selection to A2A `1.0`. +- Keep dual-read compatibility through `0.11.0`; remove legacy storage parsers and dual-read in `0.12.0`. +- In `0.12.0`, remove legacy wire handling and `trpc-a2a-go` dependencies from `go/core` (see [Release 0.12.0](#release-0120-cleanup-release)). + +### UI + +- Stay on legacy/v0.3 behavior in `0.10.0`. +- Move to the A2A v1 SDK/types in `0.11.0`. +- Send/select `protocolVersion: 1.0` / `A2A-Version: 1.0` in `0.11.0`. +- Consume v1 task/message/event shapes in `0.11.0`. +- Rely on the controller for legacy persisted-data compatibility through `0.11.0` only. + +### Go And Python Runtimes + +- Stay on legacy/v0.3 behavior in `0.10.0`. +- Move runtime A2A servers/clients to official A2A v1 in `0.11.0`. +- In `0.12.0`, drop legacy/v0.3 wire paths; v1 only. +- Preserve kagent behavior for HITL, ask-user, tool calls, subagent activity, usage metadata, tracing, and session IDs. +- Avoid runtime-specific compatibility with historical DB formats; that belongs in `go/core`. + +## Release 0.12.0: Cleanup Release + +`0.12.0` assumes the installation already passed through `0.11.0` and, for any upgrade from a prior release, that `kagent migrate a2a-v1` was run on `0.11.0` so no legacy `task` or `push_notification` rows remain (`protocol_version IS NULL` count is zero). Fresh installs of `0.11.0` or later with no legacy history may upgrade directly. + +High-level behavior: + +- Controller reads and writes official A2A v1 storage only; legacy `trpc-a2a-go` parsers and dual-read paths are removed. +- Legacy wire compatibility for missing-header or A2A `0.3` callers is removed (or reduced to an explicit opt-in compatibility flag if product support still requires it). +- AgentCards and managed-agent client selection use A2A `1.0` interfaces only. +- `trpc-a2a-go` runtime dependencies are removed from `go/core` where no longer needed for serving. +- `protocol_version` remains the persisted storage format marker (`"1.0"`). + +Why this is safe: + +- `0.11.0` introduced v1 writes and dual-read so all controllers in the supported path can read v1 rows. +- Requiring `kagent migrate a2a-v1` on `0.11.0` ensures historical legacy rows are rewritten before `0.12.0` drops legacy storage support. +- One release (`0.11.0`) with dual-read gives operators time to run the CLI without a maintenance window. + +## Alternatives Considered + +1. Deploying v1 agents and UI alongside the new controller in 0.10.0 release + +This would reduce some compatibility code and simplify some `go/core` changes, but this would not be strictly zero-downtime since the new UI cannot talk to the old controller. Similarly, if there are multiple controller instances, new instances will start upgrading agents to v1 and it will fail to talk to old controllers. + +2. Start writing v1 data in 0.10.0 release + +This does not work because if there are multiple controller instances, old instances will crash if there are v1 data in the database. We must wait until all instances have been upgraded to the compatible code, which is in the next release 0.11.0. + +3. Simple migration with a maintenance window + +Would be simpler (just a data migration script + direct changes to v1 code in agent, UI, controller) but would not be zero-downtime. \ No newline at end of file diff --git a/docs/a2a-v1-migration/migration-guide.md b/docs/a2a-v1-migration/migration-guide.md new file mode 100644 index 0000000000..901a392134 --- /dev/null +++ b/docs/a2a-v1-migration/migration-guide.md @@ -0,0 +1,213 @@ +# Migrating to A2A v1 Protocol + +## Overview + +This guide explains how to migrate your kagent installation from the legacy A2A protocol (`trpc-a2a-go`) to the official A2A v1 protocol. The migration is designed to be **zero-downtime** and allows you to upgrade your agents, UI, and custom clients incrementally. + +## What's Changing + +| Before | After | +|--------|-------| +| Legacy `trpc-a2a-go` protocol | Official A2A v1 protocol | +| Legacy storage format | Official A2A v1 JSON format | +| Missing `A2A-Version` header | Required `A2A-Version: 1.0` header | +| Protocol version not tracked | Explicit `protocol_version` field in storage | + +### Benefits of A2A v1 + +- **Standardized protocol**: Uses the official Google A2A specification +- **Better interoperability**: Works with standard A2A clients and tools +- **Future-proof**: Aligns with the evolving A2A ecosystem +- **Improved wire format**: Cleaner JSON structure with explicit versioning + +## Migration Timeline + +The migration follows a phased release approach: + +``` +Current → 0.10.0 → 0.11.0 → (run migration) → 0.12.0 +``` + +| Release | What Happens | Your Action Required | +|---------|--------------|---------------------| +| **0.10.0** | Controller learns to read both legacy and v1 formats | Upgrade normally | +| **0.11.0** | Controller starts writing v1 format; UI and runtimes switch to v1 | Upgrade normally | +| **0.11.0** (post-upgrade) | Historical data migration | **Run CLI command** | +| **0.12.0** | Legacy support removed; v1 only | Upgrade after migration complete | + +## Upgrade Steps + +### Step 1: Upgrade to 0.10.0 (Bridge Release) + +This release prepares your system for the v1 migration: + +```bash +# Upgrade to 0.10.0 +helm upgrade kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent-crds --version 0.10.0 +helm upgrade kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent --version 0.10.0 +``` + +**What to expect:** +- Controller can now read both legacy and v1 data formats +- All writes still use legacy format +- UI and agent runtimes remain on legacy behavior +- No breaking changes to your agents or clients + +**Validation:** +```bash +# Check all pods are running +kubectl get pods -n kagent + +# Verify controller logs show successful startup +kubectl logs -n kagent deployment/kagent-controller +``` + +### Step 2: Upgrade to 0.11.0 (v1 Write Release) + +This release switches to v1 for all new data: + +```bash +# Upgrade to 0.11.0 +helm upgrade kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent-crds --version 0.11.0 +helm upgrade kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent --version 0.11.0 +``` + +**What to expect:** +- All new tasks and notifications are stored in A2A v1 format +- UI switches to v1 protocol (`A2A-Version: 1.0`) +- Managed agent runtimes use v1 interfaces +- Legacy data is still readable through conversion + +**Validation:** +```bash +# Verify UI is accessible and functional +kubectl port-forward -n kagent svc/kagent-ui 3000:8080 + +# Chat with agents with CLI +kagent invoke --agent "your-agent" --task "hi" +``` + +### Step 3: Migrate Historical Data (Required before 0.12.0) + +Before upgrading to 0.12.0, you **must** migrate any existing legacy data: + +```bash +# First, do a dry run to see what will be migrated +kagent migrate a2a-v1 --dry-run + +# Run the actual migration +kagent migrate a2a-v1 + +# You can set a custom connection string for db +kubectl port-forward svc/kagent-postgres 5432:5432 +kagent migrate a2a-v1 --postgres-database-url "postgres://kagent@localhost:5432/kagent?sslmode=disable" +``` + +**Migration characteristics:** +- **Batch-based**: Processes data in chunks +- **Idempotent**: Safe to run multiple times +- **Restartable**: Can be interrupted and resumed +- **Concurrent-safe**: Won't interfere with ongoing operations + +**Monitor progress:** +The command will report counts of migrated, skipped, and failed rows. Keep running it until the "remaining legacy rows" count is zero. + +**Validation:** +```bash +# Check migration status +kagent migrate a2a-v1 --status + +# Or query the database directly (if you have access) +# Look for tasks with protocol_version IS NULL or not equal to "1.0" +``` + +### Step 4: Upgrade to 0.12.0 (Cleanup Release) + +Once historical migration is complete: + +```bash +# Upgrade to 0.12.0 +helm upgrade kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent-crds --version 0.12.0 +helm upgrade kagent oci://ghcr.io/kagent-dev/kagent/helm/kagent --version 0.12.0 +``` + +**What to expect:** +- Legacy storage parsers removed +- Legacy wire handling removed (unless explicitly enabled) +- All traffic uses A2A v1 exclusively +- `trpc-a2a-go` dependencies removed from controller + +## Fresh Installations + +If you're doing a fresh install of kagent 0.11.0 or later (no existing data): + +1. **Install 0.11.0 or later directly** +2. **No data migration required** (no legacy rows exist) +3. **Skip Step 3** above and proceed directly to 0.12.0 when ready + +## For Users with Custom A2A Clients or UI + +If you're using your own A2A client implementation or a custom UI: + +### Library Update Required + +**Swap out your A2A library** from the v0.3 version to v1 version. The following are official migration guides for each SDK: + +| A2A SDK | Migration Guide | +|----------|---------------| +| Python | [a2a-python migration guide](https://github.com/a2aproject/a2a-python/blob/main/docs/migrations/v1_0/README.md) | +| TypeScript | Legacy types | [a2a-js migration guide](https://github.com/a2aproject/a2a-js/blob/v1.0.0-alpha.0/docs/migration-guide.md) | + +### Timing for Custom Clients + +| When | What to Do | +|------|-----------| +| During 0.10.0 | Start updating your client code to use A2A v1 library | +| During 0.11.0 | Deploy your updated client with `A2A-Version: 1.0` header | +| Before 0.12.0 | Ensure all your clients are updated to v1 | +| 0.12.0+ | Legacy wire support removed; v1 clients only | + +### Backwards Compatibility During Migration + +**During 0.10.0 and 0.11.0:** +- Missing `A2A-Version` header → Legacy protocol response +- `A2A-Version: 1.0` → v1 protocol response + +**After 0.12.0:** +- Missing or legacy version header → Error or opt-in compatibility mode +- `A2A-Version: 1.0` → v1 protocol response only + +## FAQ + +**Q: Can I rollback from 0.11.0 to 0.10.0?** +A: Yes, but new v1 tasks created in 0.11.0 won't be readable by 0.10.0 controller. If you have active tasks, wait for them to complete before rolling back + +**Q: Can I rollback from 0.12.0?** +A: No, once you upgrade to 0.12.0 and the migration is complete, you cannot rollback. Legacy code paths are removed in 0.12.0 + +**Q: Do I need to update my agents?** +A: No, the controller handles protocol conversion. However, updating to use native A2A v1 improves performance and removes conversion overhead. + +**Q: What happens to my existing task history?** +A: All historical tasks are preserved. The migration command converts them to the new format while keeping all data intact. + +**Q: Can I skip 0.10.0 and go directly to 0.11.0?** +A: Only if doing a fresh install. For upgrades, you must pass through 0.10.0 to ensure all controllers can handle v1 data. + +**Q: Do I need to run the migration command on fresh installs?** +A: No, only if upgrading from a version prior to 0.11.0. + +**Q: Will my HITL (Human-in-the-Loop) workflows still work?** +A: Yes, HITL, ask-user, tool calls, subagent activity, and tracing are all preserved with the same behavior in A2A v1. + +--- + +**Migration Checklist:** + +- [ ] Upgraded to 0.10.0 +- [ ] Upgraded to 0.11.0 +- [ ] Ran `kagent migrate a2a-v1` (dry-run first) +- [ ] Verified migration completed (no legacy rows remaining) +- [ ] Updated custom clients to A2A v1 (if applicable) +- [ ] Upgraded to 0.12.0 +- [ ] Verified all agents and clients working diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index ad0d28d804..5a709e7f4f 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -8,7 +8,7 @@ import ( "strings" "time" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/go-logr/logr" "github.com/go-logr/zapr" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" @@ -195,11 +195,13 @@ func main() { Name: "go-adk-agent", Description: "Go-based Agent Development Kit", Version: "0.2.0", + SupportedInterfaces: []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("/", a2atype.TransportProtocolJSONRPC), + }, } } agentCard.Capabilities = a2atype.AgentCapabilities{ - Streaming: stream, - StateTransitionHistory: true, + Streaming: stream, } // Delegate server, task store, and remaining infrastructure to app.New. diff --git a/go/adk/examples/byo/main.go b/go/adk/examples/byo/main.go index 1de80c0c26..d382d2d14d 100644 --- a/go/adk/examples/byo/main.go +++ b/go/adk/examples/byo/main.go @@ -41,7 +41,7 @@ import ( "log" "os" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/go-logr/zapr" "github.com/kagent-dev/kagent/go/adk/pkg/app" "github.com/kagent-dev/kagent/go/adk/pkg/models" @@ -50,7 +50,7 @@ import ( "google.golang.org/adk/agent/llmagent" "google.golang.org/adk/agent/workflowagents/parallelagent" "google.golang.org/adk/runner" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" adksession "google.golang.org/adk/session" ) @@ -127,10 +127,11 @@ func main() { Name: "byo-parallel-agent", Description: "A BYO agent that runs creative and technical writers in parallel", Version: "1.0.0", - URL: "http://localhost:8082", + SupportedInterfaces: []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("http://localhost:8082", a2atype.TransportProtocolJSONRPC), + }, Capabilities: a2atype.AgentCapabilities{ - Streaming: stream, - StateTransitionHistory: true, + Streaming: stream, }, DefaultInputModes: []string{"text/plain"}, DefaultOutputModes: []string{"text/plain"}, diff --git a/go/adk/pkg/a2a/agentcard.go b/go/adk/pkg/a2a/agentcard.go index 6dfc7771be..4d6da1a1e2 100644 --- a/go/adk/pkg/a2a/agentcard.go +++ b/go/adk/pkg/a2a/agentcard.go @@ -1,9 +1,9 @@ package a2a import ( - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" adkagent "google.golang.org/adk/agent" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" ) // EnrichAgentCard populates the agent card with skills derived from the ADK @@ -22,8 +22,10 @@ func EnrichAgentCard(card *a2atype.AgentCard, agent adkagent.Agent) { card.Description = agent.Description() } - // Default to JSONRPC when no transport is explicitly configured. - if card.PreferredTransport == "" { - card.PreferredTransport = a2atype.TransportProtocolJSONRPC + // Default to JSONRPC when no interface is explicitly configured. + if len(card.SupportedInterfaces) == 0 { + card.SupportedInterfaces = []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("/", a2atype.TransportProtocolJSONRPC), + } } } diff --git a/go/adk/pkg/a2a/consts.go b/go/adk/pkg/a2a/consts.go index 950b8cd1ef..1087cea8f1 100644 --- a/go/adk/pkg/a2a/consts.go +++ b/go/adk/pkg/a2a/consts.go @@ -1,6 +1,6 @@ package a2a -import "google.golang.org/adk/server/adka2a" +import "google.golang.org/adk/server/adka2a/v2" const ( StateKeySessionName = "session_name" diff --git a/go/adk/pkg/a2a/converter.go b/go/adk/pkg/a2a/converter.go index c74dfe7784..8db33f1bb4 100644 --- a/go/adk/pkg/a2a/converter.go +++ b/go/adk/pkg/a2a/converter.go @@ -5,8 +5,8 @@ import ( "encoding/json" "maps" - a2atype "github.com/a2aproject/a2a-go/a2a" - "google.golang.org/adk/server/adka2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "google.golang.org/adk/server/adka2a/v2" adksession "google.golang.org/adk/session" "google.golang.org/genai" ) @@ -14,16 +14,16 @@ import ( // isEmptyDataPart returns true if the part is a DataPart with nil or empty Data. // The ADK processor emits such parts as cleanup signals for streaming partial // artifacts and as a fallback for unrecognized GenAI part types. -func isEmptyDataPart(part a2atype.Part) bool { - dp, ok := part.(a2atype.DataPart) - return ok && len(dp.Data) == 0 +func isEmptyDataPart(part *a2atype.Part) bool { + dp := asDataPart(part) + return dp != nil && len(dp) == 0 } // filterTextParts returns only TextParts from the given parts. func filterTextParts(parts a2atype.ContentParts) a2atype.ContentParts { var out a2atype.ContentParts for _, p := range parts { - if _, ok := p.(a2atype.TextPart); ok { + if p != nil && p.Text() != "" { out = append(out, p) } } @@ -56,7 +56,7 @@ func messageToGenAIContent(ctx context.Context, msg *a2atype.Message) (*genai.Co } // a2aPartConverter converts inbound A2A parts to GenAI parts. -func a2aPartConverter(_ context.Context, _ a2atype.Event, part a2atype.Part) (*genai.Part, error) { +func a2aPartConverter(_ context.Context, _ a2atype.Event, part *a2atype.Part) (*genai.Part, error) { dp := asDataPart(part) if dp == nil { // Text and file parts: delegate to ADK default. @@ -64,15 +64,15 @@ func a2aPartConverter(_ context.Context, _ a2atype.Event, part a2atype.Part) (*g } // DataPart with kagent_type metadata: convert explicitly. - if dp.Metadata != nil { - if _, has := dp.Metadata[GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)]; has { - return convertDataPartToGenAI(dp, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) + if part != nil && part.Metadata != nil { + if _, has := part.Metadata[GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)]; has { + return convertDataPartToGenAI(dp, part.Metadata, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) } } // DataPart with adk_type metadata (produced by the ADK itself): delegate. - if dp.Metadata != nil { - if _, has := dp.Metadata[adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey)]; has { + if part != nil && part.Metadata != nil { + if _, has := part.Metadata[adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey)]; has { return adka2a.ToGenAIPart(part) } } @@ -84,58 +84,55 @@ func a2aPartConverter(_ context.Context, _ a2atype.Event, part a2atype.Part) (*g // convertDataPartToGenAI converts a DataPart with a type metadata key // (either adk_type or kagent_type) back to GenAI for inbound message processing. -func convertDataPartToGenAI(p *a2atype.DataPart, typeKey string) (*genai.Part, error) { - if p == nil { +func convertDataPartToGenAI(data map[string]any, metadata map[string]any, typeKey string) (*genai.Part, error) { + if data == nil { return nil, nil } - partType, _ := p.Metadata[typeKey].(string) + partType, _ := metadata[typeKey].(string) switch partType { case A2ADataPartMetadataTypeFunctionCall: - name, _ := p.Data[PartKeyName].(string) - funcArgs, _ := p.Data[PartKeyArgs].(map[string]any) + name, _ := data[PartKeyName].(string) + funcArgs, _ := data[PartKeyArgs].(map[string]any) if name != "" { genaiPart := genai.NewPartFromFunctionCall(name, funcArgs) - if id, ok := p.Data[PartKeyID].(string); ok && id != "" { + if id, ok := data[PartKeyID].(string); ok && id != "" { genaiPart.FunctionCall.ID = id } return genaiPart, nil } case A2ADataPartMetadataTypeFunctionResponse: - name, _ := p.Data[PartKeyName].(string) - response, _ := p.Data[PartKeyResponse].(map[string]any) + name, _ := data[PartKeyName].(string) + response, _ := data[PartKeyResponse].(map[string]any) if name != "" { genaiPart := genai.NewPartFromFunctionResponse(name, response) - if id, ok := p.Data[PartKeyID].(string); ok && id != "" { + if id, ok := data[PartKeyID].(string); ok && id != "" { genaiPart.FunctionResponse.ID = id } return genaiPart, nil } } - return adka2a.ToGenAIPart(p) + return adka2a.ToGenAIPart(a2atype.NewDataPart(data)) } // stampSubagentSessionID adds kagent_subagent_session_id to function_call // DataParts when the tool name is present in subagentSessionIDs. // Part can be either a *a2atype.DataPart or a2atype.DataPart. -func stampSubagentSessionID(part a2atype.Part, subagentSessionIDs map[string]string) a2atype.Part { - switch p := part.(type) { - case *a2atype.DataPart: - cp := *p - stampSubagentSessionIDOnDataPart(&cp, subagentSessionIDs) - return cp - case a2atype.DataPart: - cp := p - stampSubagentSessionIDOnDataPart(&cp, subagentSessionIDs) - return cp - default: - return part +func stampSubagentSessionID(part *a2atype.Part, subagentSessionIDs map[string]string) *a2atype.Part { + if part == nil { + return nil } + stampSubagentSessionIDOnDataPart(part, subagentSessionIDs) + return part } -func stampSubagentSessionIDOnDataPart(dp *a2atype.DataPart, subagentSessionIDs map[string]string) { +func stampSubagentSessionIDOnDataPart(dp *a2atype.Part, subagentSessionIDs map[string]string) { if dp == nil || len(subagentSessionIDs) == 0 { return } + view := asDataPart(dp) + if view == nil { + return + } if dp.Metadata == nil { dp.Metadata = map[string]any{} } @@ -143,7 +140,7 @@ func stampSubagentSessionIDOnDataPart(dp *a2atype.DataPart, subagentSessionIDs m if partType != A2ADataPartMetadataTypeFunctionCall { return } - toolName, _ := dp.Data[PartKeyName].(string) + toolName, _ := view[PartKeyName].(string) if toolName == "" { return } diff --git a/go/adk/pkg/a2a/converter_test.go b/go/adk/pkg/a2a/converter_test.go index 43df600982..8f0c64b4e6 100644 --- a/go/adk/pkg/a2a/converter_test.go +++ b/go/adk/pkg/a2a/converter_test.go @@ -4,28 +4,33 @@ import ( "context" "testing" - a2atype "github.com/a2aproject/a2a-go/a2a" - "google.golang.org/adk/server/adka2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "google.golang.org/adk/server/adka2a/v2" "google.golang.org/genai" ) +func convDataPart(data map[string]any, metadata map[string]any) *a2atype.Part { + p := a2atype.NewDataPart(data) + if metadata != nil { + p.Metadata = metadata + } + return p +} + // --------------------------------------------------------------------------- // convertDataPartToGenAI // --------------------------------------------------------------------------- func TestConvertDataPartToGenAI_FunctionCall_KagentPrefix(t *testing.T) { - dp := &a2atype.DataPart{ - Data: map[string]any{ - "name": "my_func", - "args": map[string]any{"key": "value"}, - "id": "call_1", - }, - Metadata: map[string]any{ - GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall, - }, + data := map[string]any{ + "name": "my_func", + "args": map[string]any{"key": "value"}, + "id": "call_1", } - - part, err := convertDataPartToGenAI(dp, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) + meta := map[string]any{ + GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall, + } + part, err := convertDataPartToGenAI(data, meta, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -41,18 +46,15 @@ func TestConvertDataPartToGenAI_FunctionCall_KagentPrefix(t *testing.T) { } func TestConvertDataPartToGenAI_FunctionCall_AdkPrefix(t *testing.T) { - dp := &a2atype.DataPart{ - Data: map[string]any{ - "name": "my_func", - "args": map[string]any{"key": "value"}, - "id": "call_1", - }, - Metadata: map[string]any{ - adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall, - }, + data := map[string]any{ + "name": "my_func", + "args": map[string]any{"key": "value"}, + "id": "call_1", } - - part, err := convertDataPartToGenAI(dp, adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey)) + meta := map[string]any{ + adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionCall, + } + part, err := convertDataPartToGenAI(data, meta, adka2a.ToA2AMetaKey(A2ADataPartMetadataTypeKey)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -65,18 +67,15 @@ func TestConvertDataPartToGenAI_FunctionCall_AdkPrefix(t *testing.T) { } func TestConvertDataPartToGenAI_FunctionResponse(t *testing.T) { - dp := &a2atype.DataPart{ - Data: map[string]any{ - "name": "my_func", - "response": map[string]any{"result": "ok"}, - "id": "call_2", - }, - Metadata: map[string]any{ - GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, - }, + data := map[string]any{ + "name": "my_func", + "response": map[string]any{"result": "ok"}, + "id": "call_2", } - - part, err := convertDataPartToGenAI(dp, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) + meta := map[string]any{ + GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, + } + part, err := convertDataPartToGenAI(data, meta, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -92,7 +91,7 @@ func TestConvertDataPartToGenAI_FunctionResponse(t *testing.T) { } func TestConvertDataPartToGenAI_Nil(t *testing.T) { - part, err := convertDataPartToGenAI(nil, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) + part, err := convertDataPartToGenAI(nil, nil, GetKAgentMetadataKey(A2ADataPartMetadataTypeKey)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -102,14 +101,16 @@ func TestConvertDataPartToGenAI_Nil(t *testing.T) { } func TestConvertDataPartToGenAI_UnknownType(t *testing.T) { - dp := &a2atype.DataPart{ - Data: map[string]any{"foo": "bar"}, - Metadata: map[string]any{"kagent_type": "unknown_type"}, + part, err := convertDataPartToGenAI( + map[string]any{"foo": "bar"}, + map[string]any{"kagent_type": "unknown_type"}, + "kagent_type", + ) + if err != nil { + t.Fatalf("unexpected error for unknown part type: %v", err) } - - _, err := convertDataPartToGenAI(dp, "kagent_type") - if err == nil { - t.Fatal("expected error for unknown part type") + if part == nil { + t.Fatal("expected fallback GenAI part for unknown type") } } @@ -118,7 +119,7 @@ func TestConvertDataPartToGenAI_UnknownType(t *testing.T) { // --------------------------------------------------------------------------- func TestMessageToGenAIContent_TextPart(t *testing.T) { - msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.TextPart{Text: "hello"}) + msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hello")) content, err := messageToGenAIContent(context.Background(), msg) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -139,8 +140,8 @@ func TestMessageToGenAIContent_DropsUnrecognisedDataPart(t *testing.T) { // A DataPart with no recognised kagent_type metadata (e.g. a HITL decision // payload like {decision_type: "approve"}) should be dropped silently. msg := a2atype.NewMessage(a2atype.MessageRoleUser, - a2atype.TextPart{Text: "approving"}, - &a2atype.DataPart{Data: map[string]any{"decision_type": "approve"}}, + a2atype.NewTextPart("approving"), + convDataPart(map[string]any{"decision_type": "approve"}, nil), ) content, err := messageToGenAIContent(context.Background(), msg) if err != nil { @@ -157,16 +158,13 @@ func TestMessageToGenAIContent_DropsUnrecognisedDataPart(t *testing.T) { func TestMessageToGenAIContent_KagentTypeFunctionResponse(t *testing.T) { // A DataPart with kagent_type=function_response should be converted to GenAI. - dp := &a2atype.DataPart{ - Data: map[string]any{ - "name": "my_func", - "id": "call_1", - "response": map[string]any{"result": "ok"}, - }, - Metadata: map[string]any{ - GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, - }, - } + dp := convDataPart(map[string]any{ + "name": "my_func", + "id": "call_1", + "response": map[string]any{"result": "ok"}, + }, map[string]any{ + GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, + }) msg := a2atype.NewMessage(a2atype.MessageRoleUser, dp) content, err := messageToGenAIContent(context.Background(), msg) if err != nil { @@ -200,22 +198,18 @@ func TestMessageToGenAIContent_NilMessage(t *testing.T) { func TestStampSubagentSessionID_FunctionCallPart(t *testing.T) { subagentIDs := map[string]string{"k8s_agent": "session-abc"} - dp := &a2atype.DataPart{ - Data: map[string]any{ - PartKeyName: "k8s_agent", - PartKeyArgs: map[string]any{"request": "list pods"}, - }, - Metadata: map[string]any{ - adka2a.ToA2AMetaKey("type"): A2ADataPartMetadataTypeFunctionCall, - }, - } + dp := convDataPart(map[string]any{ + PartKeyName: "k8s_agent", + PartKeyArgs: map[string]any{"request": "list pods"}, + }, map[string]any{ + adka2a.ToA2AMetaKey("type"): A2ADataPartMetadataTypeFunctionCall, + }) updated := stampSubagentSessionID(dp, subagentIDs) - updatedDP, ok := updated.(a2atype.DataPart) - if !ok { - t.Fatalf("updated part type = %T, want a2atype.DataPart", updated) + if updated == nil { + t.Fatal("updated part is nil") } - sessionID, has := updatedDP.Metadata[GetKAgentMetadataKey("subagent_session_id")] + sessionID, has := updated.Metadata[GetKAgentMetadataKey("subagent_session_id")] if !has { t.Fatal("expected kagent_subagent_session_id in metadata, not found") } @@ -227,21 +221,17 @@ func TestStampSubagentSessionID_FunctionCallPart(t *testing.T) { func TestStampSubagentSessionID_UnknownTool(t *testing.T) { subagentIDs := map[string]string{"k8s_agent": "session-abc"} - dp := &a2atype.DataPart{ - Data: map[string]any{ - PartKeyName: "unknown_tool", - }, - Metadata: map[string]any{ - adka2a.ToA2AMetaKey("type"): A2ADataPartMetadataTypeFunctionCall, - }, - } + dp := convDataPart(map[string]any{ + PartKeyName: "unknown_tool", + }, map[string]any{ + adka2a.ToA2AMetaKey("type"): A2ADataPartMetadataTypeFunctionCall, + }) updated := stampSubagentSessionID(dp, subagentIDs) - updatedDP, ok := updated.(a2atype.DataPart) - if !ok { - t.Fatalf("updated part type = %T, want a2atype.DataPart", updated) + if updated == nil { + t.Fatal("updated part is nil") } - if _, ok := updatedDP.Metadata[GetKAgentMetadataKey("subagent_session_id")]; ok { + if _, ok := updated.Metadata[GetKAgentMetadataKey("subagent_session_id")]; ok { t.Error("expected no subagent_session_id for unknown tool") } } diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index 8ae11d3d69..8686ae96ed 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -3,13 +3,13 @@ package a2a import ( "context" "fmt" + "iter" "maps" "os" "strings" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" - "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/go-logr/logr" "github.com/kagent-dev/kagent/go/adk/pkg/auth" "github.com/kagent-dev/kagent/go/adk/pkg/models" @@ -19,7 +19,7 @@ import ( "go.opentelemetry.io/otel/attribute" adkagent "google.golang.org/adk/agent" "google.golang.org/adk/runner" - "google.golang.org/adk/server/adka2a" + "google.golang.org/adk/server/adka2a/v2" ) const ( @@ -83,321 +83,327 @@ type userIDInterceptor struct { a2asrv.PassthroughCallInterceptor } -func (u *userIDInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallContext, _ *a2asrv.Request) (context.Context, error) { +func (u *userIDInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallContext, _ *a2asrv.Request) (context.Context, any, error) { if callCtx == nil { - return ctx, nil + return ctx, nil, nil } - meta := callCtx.RequestMeta() + meta := callCtx.ServiceParams() if meta == nil { - return ctx, nil + return ctx, nil, nil } vals, ok := meta.Get("x-user-id") if !ok || len(vals) == 0 || vals[0] == "" { - return ctx, nil + return ctx, nil, nil } // Set the authenticated user so downstream code picks up the real identity. - callCtx.User = &a2asrv.AuthenticatedUser{UserName: vals[0]} - return ctx, nil + callCtx.User = a2asrv.NewAuthenticatedUser(vals[0], nil) + return ctx, nil, nil } // Execute implements a2asrv.AgentExecutor. // It follows the Python _handle_request pattern: set up session, handle HITL, // convert inbound message, run the agent loop, and emit A2A events. -func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - if reqCtx.Message == nil { - return fmt.Errorf("A2A request message cannot be nil") - } +func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + if reqCtx.Message == nil { + yield(nil, fmt.Errorf("A2A request message cannot be nil")) + return + } - // 1. Derive userID / sessionID. - userID := "A2A_USER_" + reqCtx.ContextID - if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { - if callCtx.User != nil && callCtx.User.Name() != "" { - userID = callCtx.User.Name() + // 1. Derive userID / sessionID. + userID := "A2A_USER_" + reqCtx.ContextID + if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { + if callCtx.User != nil && callCtx.User.Name != "" { + userID = callCtx.User.Name + } } - } - sessionID := reqCtx.ContextID - - ctx = withBearerToken(ctx) - ctx = auth.WithUserID(ctx, userID) - - e.logger.Info("Execute", - "taskID", reqCtx.TaskID, - "contextID", reqCtx.ContextID, - "appName", e.appName, - "userID", userID, - ) - - // 2. Set up telemetry span attributes. - spanAttributes := map[string]string{ - "kagent.user_id": userID, - "gen_ai.task.id": string(reqCtx.TaskID), - "gen_ai.conversation.id": sessionID, - } - if e.appName != "" { - spanAttributes["kagent.app_name"] = e.appName - } - ctx = telemetry.SetKAgentSpanAttributes(ctx, spanAttributes) - ctx, invocationSpan := telemetry.StartInvocationSpan(ctx) - defer invocationSpan.End() + sessionID := reqCtx.ContextID + + ctx = withBearerToken(ctx) + ctx = auth.WithUserID(ctx, userID) + + e.logger.Info("Execute", + "taskID", reqCtx.TaskID, + "contextID", reqCtx.ContextID, + "appName", e.appName, + "userID", userID, + ) + + // 2. Set up telemetry span attributes. + spanAttributes := map[string]string{ + "kagent.user_id": userID, + "gen_ai.task.id": string(reqCtx.TaskID), + "gen_ai.conversation.id": sessionID, + } + if e.appName != "" { + spanAttributes["kagent.app_name"] = e.appName + } + ctx = telemetry.SetKAgentSpanAttributes(ctx, spanAttributes) + ctx, invocationSpan := telemetry.StartInvocationSpan(ctx) + defer invocationSpan.End() - telemetry.SetMessageMetadataAttributes(ctx, reqCtx.Message.Metadata) + telemetry.SetMessageMetadataAttributes(ctx, reqCtx.Message.Metadata) - // 3. Initialize skills session path. - if e.skillsDirectory != "" && sessionID != "" { - if _, err := skills.InitializeSessionPath(sessionID, e.skillsDirectory); err != nil { - e.logger.V(1).Info("Skills session path init failed (continuing)", - "error", err, "sessionID", sessionID) + // 3. Initialize skills session path. + if e.skillsDirectory != "" && sessionID != "" { + if _, err := skills.InitializeSessionPath(sessionID, e.skillsDirectory); err != nil { + e.logger.V(1).Info("Skills session path init failed (continuing)", + "error", err, "sessionID", sessionID) + } } - } - // 4. Create / lookup session via sessionService. - if e.sessionService != nil { - sess, err := e.sessionService.GetSession(ctx, e.appName, userID, sessionID) - if err != nil { - e.logger.V(1).Info("Session lookup failed, will create", "error", err, "sessionID", sessionID) - sess = nil - } - if sess == nil { - sessionName := extractSessionName(reqCtx.Message) - state := make(map[string]any) - if sessionName != "" { - state[StateKeySessionName] = sessionName + // 4. Create / lookup session via sessionService. + if e.sessionService != nil { + sess, err := e.sessionService.GetSession(ctx, e.appName, userID, sessionID) + if err != nil { + e.logger.V(1).Info("Session lookup failed, will create", "error", err, "sessionID", sessionID) + sess = nil } - // Propagate x-kagent-source so the session is tagged in the DB. - if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { - if meta := callCtx.RequestMeta(); meta != nil { - if vals, ok := meta.Get("x-kagent-source"); ok && len(vals) > 0 && vals[0] != "" { - state[StateKeySource] = vals[0] + if sess == nil { + sessionName := extractSessionName(reqCtx.Message) + state := make(map[string]any) + if sessionName != "" { + state[StateKeySessionName] = sessionName + } + // Propagate x-kagent-source so the session is tagged in the DB. + if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { + if meta := callCtx.ServiceParams(); meta != nil { + if vals, ok := meta.Get("x-kagent-source"); ok && len(vals) > 0 && vals[0] != "" { + state[StateKeySource] = vals[0] + } } } - } - if err = e.sessionService.CreateSession(ctx, e.appName, userID, state, sessionID); err != nil { - return fmt.Errorf("failed to create session: %w", err) + if err = e.sessionService.CreateSession(ctx, e.appName, userID, state, sessionID); err != nil { + yield(nil, fmt.Errorf("failed to create session: %w", err)) + return + } } } - } - - // 5. Detect HITL decision and build the resume message if needed. - inboundMessage := reqCtx.Message - if resumeMessage := BuildResumeHITLMessage(reqCtx.StoredTask, inboundMessage); resumeMessage != nil { - inboundMessage = resumeMessage - } - - // 6. Convert inbound message to *genai.Content using kagent a2aPartConverter. - content, err := messageToGenAIContent(ctx, inboundMessage) - if err != nil { - return fmt.Errorf("inbound message conversion failed: %w", err) - } - // 7. Use pre-built subagent session ID map (built by runner bundle). - subagentSessionIDs := e.subagentSessionIDs - - // 8. Create runner. - r, err := runner.New(e.runnerConfig) - if err != nil { - return fmt.Errorf("failed to create runner: %w", err) - } - - // 9. Emit initial events. - if reqCtx.StoredTask == nil { - // New task — emit submitted with the user's message - submitted := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateSubmitted, reqCtx.Message) - if err := queue.Write(ctx, submitted); err != nil { - return fmt.Errorf("failed to write submitted event: %w", err) + // 5. Detect HITL decision and build the resume message if needed. + inboundMessage := reqCtx.Message + if resumeMessage := BuildResumeHITLMessage(reqCtx.StoredTask, inboundMessage); resumeMessage != nil { + inboundMessage = resumeMessage } - } else if ExtractDecisionFromMessage(reqCtx.Message) != "" { - // a2a-go appends incoming message to task history before executor runs. - // See https://github.com/a2aproject/a2a-go/blob/v0.3.13/a2asrv/agentexec.go#L188 - // Remove the pre-appended copy and emit one decision status event. - dropPreAppendedDecisionFromHistory(reqCtx.StoredTask, reqCtx.Message) - decision := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, reqCtx.Message) - if err := queue.Write(ctx, decision); err != nil { - return fmt.Errorf("failed to write HITL decision status event: %w", err) + + // 6. Convert inbound message to *genai.Content using kagent a2aPartConverter. + content, err := messageToGenAIContent(ctx, inboundMessage) + if err != nil { + yield(nil, fmt.Errorf("inbound message conversion failed: %w", err)) + return } - } - // Base metadata carried on every event (app_name, user_id, session_id). - baseMeta := map[string]any{ - adka2a.ToA2AMetaKey("app_name"): e.appName, - adka2a.ToA2AMetaKey("user_id"): userID, - adka2a.ToA2AMetaKey("session_id"): sessionID, - } + // 7. Use pre-built subagent session ID map (built by runner bundle). + subagentSessionIDs := e.subagentSessionIDs - working := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, nil) - working.Metadata = maps.Clone(baseMeta) - if err := queue.Write(ctx, working); err != nil { - return fmt.Errorf("failed to write working event: %w", err) - } + // 8. Create runner. + r, err := runner.New(e.runnerConfig) + if err != nil { + yield(nil, fmt.Errorf("failed to create runner: %w", err)) + return + } - // 10. Run the agent event loop. - var runConfig adkagent.RunConfig - if e.stream { - runConfig.StreamingMode = adkagent.StreamingModeSSE - } + // 9. Emit initial events. + if reqCtx.StoredTask == nil { + // New task — first event must be a *Task (not *TaskStatusUpdateEvent). + submitted := a2atype.NewSubmittedTask(reqCtx, reqCtx.Message) + if !yield(submitted, nil) { + return + } + } else if ExtractDecisionFromMessage(reqCtx.Message) != "" { + // a2a-go appends incoming message to task history before executor runs. + // See https://github.com/a2aproject/a2a-go/blob/v0.3.13/a2asrv/agentexec.go#L188 + // Remove the pre-appended copy and emit one decision status event. + dropPreAppendedDecisionFromHistory(reqCtx.StoredTask, reqCtx.Message) + decision := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, reqCtx.Message) + if !yield(decision, nil) { + return + } + } - // State tracked across the event loop. - var ( - invocationID string - lastNonPartialParts a2atype.ContentParts - hitlParts a2atype.ContentParts - runErr error - ) - - for adkEvent, adkErr := range r.Run(ctx, userID, sessionID, content, runConfig) { - if adkErr != nil { - runErr = adkErr - break + // Base metadata carried on every event (app_name, user_id, session_id). + baseMeta := map[string]any{ + adka2a.ToA2AMetaKey("app_name"): e.appName, + adka2a.ToA2AMetaKey("user_id"): userID, + adka2a.ToA2AMetaKey("session_id"): sessionID, } - if adkEvent == nil { - continue + + working := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, nil) + working.Metadata = maps.Clone(baseMeta) + if !yield(working, nil) { + return } - // Track invocation ID from the first event that has one. - if adkEvent.InvocationID != "" && invocationID == "" { - invocationID = adkEvent.InvocationID - invocationSpan.SetAttributes(attribute.String("gcp.vertex.agent.invocation_id", invocationID)) + // 10. Run the agent event loop. + var runConfig adkagent.RunConfig + if e.stream { + runConfig.StreamingMode = adkagent.StreamingModeSSE } - // Build per-event metadata (inherits baseMeta + adds invocation_id, usage etc.). - eventMeta := buildEventMeta(baseMeta, adkEvent) + // State tracked across the event loop. + var ( + invocationID string + lastNonPartialParts a2atype.ContentParts + hitlParts a2atype.ContentParts + runErr error + ) + + for adkEvent, adkErr := range r.Run(ctx, userID, sessionID, content, runConfig) { + if adkErr != nil { + runErr = adkErr + break + } + if adkEvent == nil { + continue + } + + // Track invocation ID from the first event that has one. + if adkEvent.InvocationID != "" && invocationID == "" { + invocationID = adkEvent.InvocationID + invocationSpan.SetAttributes(attribute.String("gcp.vertex.agent.invocation_id", invocationID)) + } + + // Build per-event metadata (inherits baseMeta + adds invocation_id, usage etc.). + eventMeta := buildEventMeta(baseMeta, adkEvent) + + // Convert GenAI parts → A2A parts (with kagent stamping). + if adkEvent.Content == nil || len(adkEvent.Content.Parts) == 0 { + // Events with no content carry metadata only; still track invocationID/usage. + // Check for LLM error. + if adkEvent.ErrorCode != "" { + errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, + a2atype.NewTextPart(fmt.Sprintf("LLM error: %s %s", adkEvent.ErrorCode, adkEvent.ErrorMessage))) + failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) + failed.Metadata = eventMeta + yield(failed, nil) + return + } + continue + } - // Convert GenAI parts → A2A parts (with kagent stamping). - if adkEvent.Content == nil || len(adkEvent.Content.Parts) == 0 { - // Events with no content carry metadata only; still track invocationID/usage. - // Check for LLM error. + // Check for LLM error (even with content present). if adkEvent.ErrorCode != "" { errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, - a2atype.TextPart{Text: fmt.Sprintf("LLM error: %s %s", adkEvent.ErrorCode, adkEvent.ErrorMessage)}) + a2atype.NewTextPart(fmt.Sprintf("LLM error: %s %s", adkEvent.ErrorCode, adkEvent.ErrorMessage))) failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) - failed.Final = true failed.Metadata = eventMeta - return queue.Write(ctx, failed) + yield(failed, nil) + return } - continue - } - // Check for LLM error (even with content present). - if adkEvent.ErrorCode != "" { - errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, - a2atype.TextPart{Text: fmt.Sprintf("LLM error: %s %s", adkEvent.ErrorCode, adkEvent.ErrorMessage)}) - failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) - failed.Final = true - failed.Metadata = eventMeta - return queue.Write(ctx, failed) - } + // Convert parts. + var a2aParts a2atype.ContentParts + for _, genaiPart := range adkEvent.Content.Parts { + if genaiPart == nil { + continue + } + a2aPart, err := adka2a.ToA2APart(genaiPart, adkEvent.LongRunningToolIDs) + if err != nil { + continue + } + if isEmptyDataPart(a2aPart) { + continue + } + // Stamp kagent_subagent_session_id onto function_call DataParts. + if len(subagentSessionIDs) > 0 { + a2aPart = stampSubagentSessionID(a2aPart, subagentSessionIDs) + } + a2aParts = append(a2aParts, a2aPart) + } - // Convert parts. - var a2aParts a2atype.ContentParts - for _, genaiPart := range adkEvent.Content.Parts { - if genaiPart == nil { - continue + // Collect HITL (input_required) parts from LongRunningToolIDs. + isHITLEvent := len(adkEvent.LongRunningToolIDs) > 0 + if isHITLEvent { + hitlParts = append(hitlParts, a2aParts...) } - a2aPart, err := adka2a.ToA2APart(genaiPart, adkEvent.LongRunningToolIDs) - if err != nil { + + if len(a2aParts) == 0 { continue } - if isEmptyDataPart(a2aPart) { - continue + + if adkEvent.Partial { + // Partial event: emit as working status (text-only) for UI streaming. + // Note: Go ADK executor uses TaskArtifactUpdateEvent for partial events, + // so we don't need to emit a separate partial artifact update. + // However, this is done here in order to match the Python executor's behavior. + // Go ADK executor also uses different A2A response formats than Python ADK. + textOnly := filterTextParts(a2aParts) + if len(textOnly) > 0 { + mirrorMeta := maps.Clone(eventMeta) + mirrorMeta[adka2a.ToA2AMetaKey("partial")] = true + msg := a2atype.NewMessage(a2atype.MessageRoleAgent, textOnly...) + msg.Metadata = mirrorMeta + statusEv := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) + statusEv.Metadata = mirrorMeta + if !yield(statusEv, nil) { + return + } + } + } else { + mirrorParts := a2aParts + if len(hitlParts) == 0 { + // Only mirror when not accumulating HITL parts (those go into input_required). + msg := a2atype.NewMessage(a2atype.MessageRoleAgent, mirrorParts...) + msg.Metadata = maps.Clone(eventMeta) + statusEv := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) + statusEv.Metadata = maps.Clone(eventMeta) + if !yield(statusEv, nil) { + return + } + lastNonPartialParts = mirrorParts + } } - // Stamp kagent_subagent_session_id onto function_call DataParts. - if len(subagentSessionIDs) > 0 { - a2aPart = stampSubagentSessionID(a2aPart, subagentSessionIDs) + + // Break on confirmation events that have long-running tool IDs. + if isHITLEvent { + break } - a2aParts = append(a2aParts, a2aPart) } - // Collect HITL (input_required) parts from LongRunningToolIDs. - isHITLEvent := len(adkEvent.LongRunningToolIDs) > 0 - if isHITLEvent { - hitlParts = append(hitlParts, a2aParts...) + // 11. Emit final event. + finalMeta := maps.Clone(baseMeta) + if invocationID != "" { + finalMeta[adka2a.ToA2AMetaKey("invocation_id")] = invocationID } - if len(a2aParts) == 0 { - continue + if runErr != nil { + errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.NewTextPart(runErr.Error())) + failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) + failed.Metadata = finalMeta + yield(failed, nil) + return } - if adkEvent.Partial { - // Partial event: emit as working status (text-only) for UI streaming. - // Note: Go ADK executor uses TaskArtifactUpdateEvent for partial events, - // so we don't need to emit a separate partial artifact update. - // However, this is done here in order to match the Python executor's behavior. - // Go ADK executor also uses different A2A response formats than Python ADK. - textOnly := filterTextParts(a2aParts) - if len(textOnly) > 0 { - mirrorMeta := maps.Clone(eventMeta) - mirrorMeta[adka2a.ToA2AMetaKey("partial")] = true - msg := a2atype.NewMessage(a2atype.MessageRoleAgent, textOnly...) - msg.Metadata = mirrorMeta - statusEv := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) - statusEv.Metadata = mirrorMeta - if err := queue.Write(ctx, statusEv); err != nil { - return fmt.Errorf("failed to write partial status event: %w", err) - } - } - } else { - mirrorParts := a2aParts - if len(hitlParts) == 0 { - // Only mirror when not accumulating HITL parts (those go into input_required). - msg := a2atype.NewMessage(a2atype.MessageRoleAgent, mirrorParts...) - msg.Metadata = maps.Clone(eventMeta) - statusEv := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateWorking, msg) - statusEv.Metadata = maps.Clone(eventMeta) - if err := queue.Write(ctx, statusEv); err != nil { - return fmt.Errorf("failed to write mirror status event: %w", err) - } - lastNonPartialParts = mirrorParts - } + if len(hitlParts) > 0 { + // input_required: the agent is waiting for HITL decisions. + hitlMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, hitlParts...) + inputRequired := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateInputRequired, hitlMsg) + inputRequired.Metadata = finalMeta + yield(inputRequired, nil) + return } - // Break on confirmation events that have long-running tool IDs. - if isHITLEvent { - break + // Final artifact update with lastChunk=true (if we have parts) and final completed status update (no message payload). + if len(lastNonPartialParts) > 0 { + finalArtifact := a2atype.NewArtifactEvent(reqCtx, lastNonPartialParts...) + finalArtifact.LastChunk = true + if !yield(finalArtifact, nil) { + return + } } - } - // 11. Emit final event. - finalMeta := maps.Clone(baseMeta) - if invocationID != "" { - finalMeta[adka2a.ToA2AMetaKey("invocation_id")] = invocationID + completed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCompleted, nil) + completed.Metadata = finalMeta + yield(completed, nil) } - - if runErr != nil { - errMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, a2atype.TextPart{Text: runErr.Error()}) - failed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, errMsg) - failed.Final = true - failed.Metadata = finalMeta - return queue.Write(ctx, failed) - } - - if len(hitlParts) > 0 { - // input_required: the agent is waiting for HITL decisions. - hitlMsg := a2atype.NewMessage(a2atype.MessageRoleAgent, hitlParts...) - inputRequired := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateInputRequired, hitlMsg) - inputRequired.Final = true - inputRequired.Metadata = finalMeta - return queue.Write(ctx, inputRequired) - } - - // Final artifact update with lastChunk=true (if we have parts) and final completed status update (no message payload). - if len(lastNonPartialParts) > 0 { - finalArtifact := a2atype.NewArtifactEvent(reqCtx, lastNonPartialParts...) - finalArtifact.LastChunk = true - if err := queue.Write(ctx, finalArtifact); err != nil { - return fmt.Errorf("failed to write final artifact event: %w", err) - } - } - - completed := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCompleted, nil) - completed.Final = true - completed.Metadata = finalMeta - return queue.Write(ctx, completed) } // Cancel implements a2asrv.AgentExecutor. -func (e *KAgentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.RequestContext, queue eventqueue.Queue) error { - event := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCanceled, nil) - event.Final = true - return queue.Write(ctx, event) +func (e *KAgentExecutor) Cancel(ctx context.Context, reqCtx *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + event := a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCanceled, nil) + yield(event, nil) + } } // extractSessionName extracts session name from the first text part of a message. @@ -406,11 +412,14 @@ func extractSessionName(message *a2atype.Message) string { return "" } for _, part := range message.Parts { - if tp, ok := part.(a2atype.TextPart); ok && tp.Text != "" { - if len(tp.Text) > sessionNameMaxLength { - return tp.Text[:sessionNameMaxLength] + "..." + if part == nil { + continue + } + if text := part.Text(); text != "" { + if len(text) > sessionNameMaxLength { + return text[:sessionNameMaxLength] + "..." } - return tp.Text + return text } } return "" @@ -423,7 +432,7 @@ func withBearerToken(ctx context.Context) context.Context { if !ok { return ctx } - meta := callCtx.RequestMeta() + meta := callCtx.ServiceParams() if meta == nil { return ctx } diff --git a/go/adk/pkg/a2a/hitl.go b/go/adk/pkg/a2a/hitl.go index e8e716b72e..58421b5ed5 100644 --- a/go/adk/pkg/a2a/hitl.go +++ b/go/adk/pkg/a2a/hitl.go @@ -4,7 +4,7 @@ import ( "encoding/json" "maps" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "google.golang.org/adk/tool/toolconfirmation" ) @@ -161,17 +161,16 @@ func GetKAgentMetadataKey(key string) string { return KAgentMetadataKeyPrefix + key } -// asDataPart extracts a *DataPart from an A2A Part, handling both value and -// pointer types. The a2a-go library may deserialize parts as either -// a2atype.DataPart (value) or *a2atype.DataPart (pointer). -func asDataPart(part a2atype.Part) *a2atype.DataPart { - switch p := part.(type) { - case *a2atype.DataPart: - return p - case a2atype.DataPart: - return &p +// asDataPart extracts map-backed data content from an A2A part. +func asDataPart(part *a2atype.Part) map[string]any { + if part == nil { + return nil } - return nil + data, ok := part.Data().(map[string]any) + if !ok { + return nil + } + return data } // ExtractDecisionFromMessage extracts a decision from an A2A message. @@ -182,7 +181,7 @@ func ExtractDecisionFromMessage(message *a2atype.Message) DecisionType { } for _, part := range message.Parts { if dataPart := asDataPart(part); dataPart != nil { - if decision, ok := dataPart.Data[KAgentHitlDecisionTypeKey].(string); ok { + if decision, ok := dataPart[KAgentHitlDecisionTypeKey].(string); ok { switch decision { case KAgentHitlDecisionTypeApprove: return DecisionApprove @@ -205,10 +204,10 @@ func ExtractBatchDecisionsFromMessage(message *a2atype.Message) map[string]Decis } for _, part := range message.Parts { dp := asDataPart(part) - if dp == nil || dp.Data[KAgentHitlDecisionTypeKey] != string(DecisionBatch) { + if dp == nil || dp[KAgentHitlDecisionTypeKey] != string(DecisionBatch) { continue } - return parseDecisionMap(dp.Data[KAgentHitlDecisionsKey]) + return parseDecisionMap(dp[KAgentHitlDecisionsKey]) } return nil } @@ -224,11 +223,11 @@ func ExtractRejectionReasonsFromMessage(message *a2atype.Message) map[string]str if dp == nil { continue } - decision, _ := dp.Data[KAgentHitlDecisionTypeKey].(string) + decision, _ := dp[KAgentHitlDecisionTypeKey].(string) if decision == string(DecisionBatch) { - return parseStringMap(dp.Data[KAgentHitlRejectionReasonsKey]) + return parseStringMap(dp[KAgentHitlRejectionReasonsKey]) } else if decision == KAgentHitlDecisionTypeReject { - if reason, _ := dp.Data["rejection_reason"].(string); reason != "" { + if reason, _ := dp["rejection_reason"].(string); reason != "" { return map[string]string{"*": reason} } } @@ -246,7 +245,7 @@ func ExtractAskUserAnswersFromMessage(message *a2atype.Message) []map[string]any if dp == nil { continue } - answers := parseAskUserAnswersValue(dp.Data[KAgentAskUserAnswersKey]) + answers := parseAskUserAnswersValue(dp[KAgentAskUserAnswersKey]) if len(answers) == 0 { continue } @@ -293,14 +292,14 @@ func HitlPartInfoFromDataPartData(data map[string]any) HitlPartInfo { func ExtractHitlInfoFromParts(parts a2atype.ContentParts) []HitlPartInfo { var result []HitlPartInfo for _, part := range parts { - dp := asDataPart(part) - if dp == nil || dp.Metadata == nil { + dpData := asDataPart(part) + if dpData == nil || part.Metadata == nil { continue } - partType, _ := ReadMetadataValue(dp.Metadata, A2ADataPartMetadataTypeKey) - isLR, _ := ReadMetadataValue(dp.Metadata, A2ADataPartMetadataIsLongRunningKey) + partType, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataTypeKey) + isLR, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataIsLongRunningKey) if partType == A2ADataPartMetadataTypeFunctionCall && isLR == true { - result = append(result, HitlPartInfoFromDataPartData(dp.Data)) + result = append(result, HitlPartInfoFromDataPartData(dpData)) } } return result @@ -322,30 +321,30 @@ func BuildConfirmationPayload(originalPayload, extra map[string]any) map[string] func ExtractPendingConfirmationsFromParts(parts a2atype.ContentParts) map[string]PendingConfirmation { pending := make(map[string]PendingConfirmation) for _, part := range parts { - dp := asDataPart(part) - if dp == nil || dp.Metadata == nil || dp.Data == nil { + dpData := asDataPart(part) + if dpData == nil || part.Metadata == nil { continue } - partType, _ := ReadMetadataValue(dp.Metadata, A2ADataPartMetadataTypeKey) - isLongRunning, _ := ReadMetadataValue(dp.Metadata, A2ADataPartMetadataIsLongRunningKey) + partType, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataTypeKey) + isLongRunning, _ := ReadMetadataValue(part.Metadata, A2ADataPartMetadataIsLongRunningKey) if partType != A2ADataPartMetadataTypeFunctionCall || isLongRunning != true { continue } - name, _ := dp.Data[PartKeyName].(string) + name, _ := dpData[PartKeyName].(string) if name != toolconfirmation.FunctionCallName { continue } - confirmationID, _ := dp.Data[PartKeyID].(string) + confirmationID, _ := dpData[PartKeyID].(string) if confirmationID == "" { continue } - info := HitlPartInfoFromDataPartData(dp.Data) + info := HitlPartInfoFromDataPartData(dpData) var originalPayload map[string]any - if args, ok := dp.Data[PartKeyArgs].(map[string]any); ok { + if args, ok := dpData[PartKeyArgs].(map[string]any); ok { if tc, ok := args["toolConfirmation"].(map[string]any); ok { if payload, ok := tc["payload"].(map[string]any); ok { originalPayload = payload @@ -395,14 +394,14 @@ func ProcessHitlDecision( pending map[string]PendingConfirmation, decision DecisionType, message *a2atype.Message, -) []a2atype.Part { +) []*a2atype.Part { if len(pending) == 0 { return nil } // Ask-user answers take priority. if askUserAnswers := parseAskUserAnswersValue(extractMessageField(message, KAgentAskUserAnswersKey)); len(askUserAnswers) > 0 { - var parts []a2atype.Part + var parts []*a2atype.Part for fcID, pc := range pending { payload := ParseHitlConfirmationPayload(pc.OriginalPayload) payload.Answers = askUserAnswers @@ -418,7 +417,7 @@ func ProcessHitlDecision( if batchDecisions == nil { batchDecisions = map[string]DecisionType{} } - var parts []a2atype.Part + var parts []*a2atype.Part for fcID, pc := range pending { payload := ParseHitlConfirmationPayload(pc.OriginalPayload) var confirmed bool @@ -451,7 +450,7 @@ func ProcessHitlDecision( // Uniform approve/reject. confirmed := decision == DecisionApprove - var parts []a2atype.Part + var parts []*a2atype.Part for fcID, pc := range pending { payload := ParseHitlConfirmationPayload(pc.OriginalPayload) if !confirmed { @@ -463,22 +462,21 @@ func ProcessHitlDecision( } // buildConfirmationResponsePart builds the A2A DataPart for a ToolConfirmation FunctionResponse. -func buildConfirmationResponsePart(fcID string, confirmed bool, payload map[string]any) a2atype.Part { +func buildConfirmationResponsePart(fcID string, confirmed bool, payload map[string]any) *a2atype.Part { tc := toolconfirmation.ToolConfirmation{ Confirmed: confirmed, Payload: payload, } serialized, _ := json.Marshal(tc) - return a2atype.DataPart{ - Data: map[string]any{ - PartKeyName: toolconfirmation.FunctionCallName, - PartKeyID: fcID, - PartKeyResponse: map[string]any{"response": string(serialized)}, - }, - Metadata: map[string]any{ - GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, - }, - } + p := a2atype.NewDataPart(map[string]any{ + PartKeyName: toolconfirmation.FunctionCallName, + PartKeyID: fcID, + PartKeyResponse: map[string]any{"response": string(serialized)}, + }) + p.Metadata = map[string]any{ + GetKAgentMetadataKey(A2ADataPartMetadataTypeKey): A2ADataPartMetadataTypeFunctionResponse, + } + return p } func extractMessageField(message *a2atype.Message, key string) any { @@ -490,7 +488,7 @@ func extractMessageField(message *a2atype.Message, key string) any { if dp == nil { continue } - if value, ok := dp.Data[key]; ok { + if value, ok := dp[key]; ok { return value } } diff --git a/go/adk/pkg/a2a/hitl_test.go b/go/adk/pkg/a2a/hitl_test.go index 1a7416bf14..56094a7f74 100644 --- a/go/adk/pkg/a2a/hitl_test.go +++ b/go/adk/pkg/a2a/hitl_test.go @@ -3,28 +3,36 @@ package a2a import ( "testing" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" ) +func dataPart(data map[string]any, metadata map[string]any) *a2atype.Part { + p := a2atype.NewDataPart(data) + if metadata != nil { + p.Metadata = metadata + } + return p +} + // --------------------------------------------------------------------------- // ExtractDecisionFromMessage // --------------------------------------------------------------------------- func TestExtractDecisionFromMessage_DataPart(t *testing.T) { approveData := map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove} - msg := a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: approveData}) + msg := a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(approveData, nil)) if got := ExtractDecisionFromMessage(msg); got != DecisionApprove { t.Errorf("approve DataPart = %q, want %q", got, DecisionApprove) } rejectData := map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject} - msg = a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: rejectData}) + msg = a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(rejectData, nil)) if got := ExtractDecisionFromMessage(msg); got != DecisionReject { t.Errorf("reject DataPart = %q, want %q", got, DecisionReject) } batchData := map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch} - msg = a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: batchData}) + msg = a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(batchData, nil)) if got := ExtractDecisionFromMessage(msg); got != DecisionBatch { t.Errorf("batch DataPart = %q, want %q", got, DecisionBatch) } @@ -39,13 +47,13 @@ func TestExtractDecisionFromMessage_EdgeCases(t *testing.T) { t.Errorf("empty parts = %q, want empty", got) } // Text-only message — no decision (text extraction removed) - msg = a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.TextPart{Text: "approve"}) + msg = a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("approve")) if got := ExtractDecisionFromMessage(msg); got != "" { t.Errorf("text-only message = %q, want empty (text extraction removed)", got) } // Unknown decision type msg = a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{KAgentHitlDecisionTypeKey: "unknown"}}) + dataPart(map[string]any{KAgentHitlDecisionTypeKey: "unknown"}, nil)) if got := ExtractDecisionFromMessage(msg); got != "" { t.Errorf("unknown decision = %q, want empty", got) } @@ -123,25 +131,25 @@ func TestExtractBatchDecisionsFromMessage(t *testing.T) { {name: "nil message", message: nil, want: nil}, { name: "valid batch", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch, KAgentHitlDecisionsKey: map[string]any{"call_1": "approve", "call_2": "reject"}, - }}), + }, nil)), want: map[string]DecisionType{"call_1": DecisionApprove, "call_2": DecisionReject}, }, { name: "invalid values filtered", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch, KAgentHitlDecisionsKey: map[string]any{"call_1": "approve", "call_2": "bad"}, - }}), + }, nil)), want: map[string]DecisionType{"call_1": DecisionApprove}, }, { name: "non-batch type returns nil", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove, - }}), + }, nil)), want: nil, }, } @@ -174,25 +182,25 @@ func TestExtractRejectionReasonsFromMessage(t *testing.T) { {name: "nil message", message: nil, want: nil}, { name: "uniform reject with reason", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject, "rejection_reason": "too dangerous", - }}), + }, nil)), want: map[string]string{"*": "too dangerous"}, }, { name: "uniform reject without reason returns nil", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject, - }}), + }, nil)), want: nil, }, { name: "batch with reasons", - message: a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + message: a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch, KAgentHitlRejectionReasonsKey: map[string]any{"call_1": "policy violation"}, - }}), + }, nil)), want: map[string]string{"call_1": "policy violation"}, }, } @@ -217,18 +225,18 @@ func TestExtractRejectionReasonsFromMessage(t *testing.T) { // --------------------------------------------------------------------------- func TestExtractAskUserAnswersFromMessage(t *testing.T) { - msg := a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + msg := a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentAskUserAnswersKey: []any{map[string]any{"answer": []any{"yes"}}}, - }}) + }, nil)) got := ExtractAskUserAnswersFromMessage(msg) if len(got) != 1 { t.Errorf("len = %d, want 1", len(got)) } // Non-list value returns nil - msg = a2atype.NewMessage(a2atype.MessageRoleUser, &a2atype.DataPart{Data: map[string]any{ + msg = a2atype.NewMessage(a2atype.MessageRoleUser, dataPart(map[string]any{ KAgentAskUserAnswersKey: "not a list", - }}) + }, nil)) if got := ExtractAskUserAnswersFromMessage(msg); got != nil { t.Errorf("non-list = %v, want nil", got) } @@ -357,8 +365,8 @@ func TestBuildConfirmationPayload(t *testing.T) { func TestExtractPendingConfirmationsFromParts(t *testing.T) { parts := a2atype.ContentParts{ - &a2atype.DataPart{ - Data: map[string]any{ + dataPart( + map[string]any{ "name": "adk_request_confirmation", "id": "confirm_1", "args": map[string]any{ @@ -378,11 +386,11 @@ func TestExtractPendingConfirmationsFromParts(t *testing.T) { }, }, }, - Metadata: map[string]any{ + map[string]any{ "kagent_type": "function_call", "kagent_is_long_running": true, }, - }, + ), } pending := ExtractPendingConfirmationsFromParts(parts) @@ -408,8 +416,8 @@ func TestExtractPendingConfirmationsFromParts(t *testing.T) { func TestExtractHitlInfoFromParts_PointerDataPart(t *testing.T) { parts := a2atype.ContentParts{ - &a2atype.DataPart{ - Data: map[string]any{ + dataPart( + map[string]any{ "name": "adk_request_confirmation", "id": "confirm_1", "args": map[string]any{ @@ -420,11 +428,11 @@ func TestExtractHitlInfoFromParts_PointerDataPart(t *testing.T) { }, }, }, - Metadata: map[string]any{ + map[string]any{ "kagent_type": "function_call", "kagent_is_long_running": true, }, - }, + ), } got := ExtractHitlInfoFromParts(parts) @@ -448,8 +456,8 @@ func TestBuildResumeHITLMessage(t *testing.T) { State: a2atype.TaskStateInputRequired, Message: a2atype.NewMessage( a2atype.MessageRoleAgent, - &a2atype.DataPart{ - Data: map[string]any{ + dataPart( + map[string]any{ "name": "adk_request_confirmation", "id": "confirm_1", "args": map[string]any{ @@ -464,18 +472,18 @@ func TestBuildResumeHITLMessage(t *testing.T) { }, }, }, - Metadata: map[string]any{ + map[string]any{ "kagent_type": "function_call", "kagent_is_long_running": true, }, - }, + ), ), }, } incoming := a2atype.NewMessage( a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}}, + dataPart(map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}, nil), ) resume := BuildResumeHITLMessage(storedTask, incoming) @@ -491,11 +499,11 @@ func TestBuildResumeHITLMessage(t *testing.T) { t.Fatal("resume part is not a DataPart") return } - if dp.Data[PartKeyName] != "adk_request_confirmation" { - t.Fatalf("resume FunctionResponse name = %#v", dp.Data[PartKeyName]) + if dp[PartKeyName] != "adk_request_confirmation" { + t.Fatalf("resume FunctionResponse name = %#v", dp[PartKeyName]) } - if dp.Data[PartKeyID] != "confirm_1" { - t.Fatalf("resume FunctionResponse id = %#v", dp.Data[PartKeyID]) + if dp[PartKeyID] != "confirm_1" { + t.Fatalf("resume FunctionResponse id = %#v", dp[PartKeyID]) } } @@ -510,7 +518,7 @@ func TestProcessHitlDecision(t *testing.T) { t.Run("uniform approve", func(t *testing.T) { msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}}) + dataPart(map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}, nil)) parts := ProcessHitlDecision(pending, DecisionApprove, msg) if len(parts) != 1 { t.Fatalf("len = %d, want 1", len(parts)) @@ -520,17 +528,17 @@ func TestProcessHitlDecision(t *testing.T) { t.Fatal("part is not DataPart") return } - if dp.Data[PartKeyName] != "adk_request_confirmation" { - t.Errorf("name = %v", dp.Data[PartKeyName]) + if dp[PartKeyName] != "adk_request_confirmation" { + t.Errorf("name = %v", dp[PartKeyName]) } }) t.Run("uniform reject with reason", func(t *testing.T) { msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{ + dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeReject, "rejection_reason": "not safe", - }}) + }, nil)) parts := ProcessHitlDecision(pending, DecisionReject, msg) if len(parts) != 1 { t.Fatalf("len = %d, want 1", len(parts)) @@ -539,7 +547,7 @@ func TestProcessHitlDecision(t *testing.T) { t.Run("empty pending returns nil", func(t *testing.T) { msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}}) + dataPart(map[string]any{KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove}, nil)) if parts := ProcessHitlDecision(map[string]PendingConfirmation{}, DecisionApprove, msg); parts != nil { t.Errorf("empty pending = %v, want nil", parts) } @@ -547,10 +555,10 @@ func TestProcessHitlDecision(t *testing.T) { t.Run("ask-user answers take priority", func(t *testing.T) { msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{ + dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeApprove, KAgentAskUserAnswersKey: []any{map[string]any{"answer": []any{"yes"}}}, - }}) + }, nil)) parts := ProcessHitlDecision(pending, DecisionApprove, msg) if len(parts) != 1 { t.Fatalf("ask-user len = %d, want 1", len(parts)) @@ -563,10 +571,10 @@ func TestProcessHitlDecision(t *testing.T) { "fc_2": {OriginalID: "orig_2"}, } msg := a2atype.NewMessage(a2atype.MessageRoleUser, - &a2atype.DataPart{Data: map[string]any{ + dataPart(map[string]any{ KAgentHitlDecisionTypeKey: KAgentHitlDecisionTypeBatch, KAgentHitlDecisionsKey: map[string]any{"orig_1": "approve", "orig_2": "reject"}, - }}) + }, nil)) parts := ProcessHitlDecision(pendingBatch, DecisionBatch, msg) if len(parts) != 2 { t.Fatalf("batch len = %d, want 2", len(parts)) diff --git a/go/adk/pkg/a2a/server/server.go b/go/adk/pkg/a2a/server/server.go index 9f2bb9bcfa..d1e7df94b6 100644 --- a/go/adk/pkg/a2a/server/server.go +++ b/go/adk/pkg/a2a/server/server.go @@ -10,8 +10,8 @@ import ( "syscall" "time" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/go-logr/logr" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) diff --git a/go/adk/pkg/app/app.go b/go/adk/pkg/app/app.go index 36d5fec69f..0618c3728c 100644 --- a/go/adk/pkg/app/app.go +++ b/go/adk/pkg/app/app.go @@ -8,8 +8,8 @@ import ( "strings" "time" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/go-logr/logr" "github.com/go-logr/zapr" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" @@ -121,7 +121,7 @@ func New(cfg AppConfig, executor a2asrv.AgentExecutor) (*KAgentApp, error) { } // Append the user-ID interceptor - handlerOpts = append(handlerOpts, a2asrv.WithCallInterceptor(a2a.UserIDCallInterceptor())) + handlerOpts = append(handlerOpts, a2asrv.WithCallInterceptors(a2a.UserIDCallInterceptor())) // Append any caller-supplied handler options. handlerOpts = append(handlerOpts, cfg.HandlerOpts...) @@ -195,11 +195,12 @@ func applyDefaults(cfg AppConfig) AppConfig { cfg.Logger = newDefaultLogger() } - // Ensure the agent card always advertises a transport so that A2A clients - // can select a compatible one. Without this, NewFromCard fails with - // "no compatible transports found: available transports - []". - if cfg.AgentCard.PreferredTransport == "" { - cfg.AgentCard.PreferredTransport = a2atype.TransportProtocolJSONRPC + // Ensure the agent card always advertises at least one interface so A2A + // clients can select a compatible endpoint/transport. + if len(cfg.AgentCard.SupportedInterfaces) == 0 { + cfg.AgentCard.SupportedInterfaces = []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("/", a2atype.TransportProtocolJSONRPC), + } } return cfg diff --git a/go/adk/pkg/app/app_test.go b/go/adk/pkg/app/app_test.go index 899fc0946f..0a08d0d89f 100644 --- a/go/adk/pkg/app/app_test.go +++ b/go/adk/pkg/app/app_test.go @@ -2,23 +2,23 @@ package app import ( "context" + "iter" "testing" "time" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2asrv" - "github.com/a2aproject/a2a-go/a2asrv/eventqueue" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" ) // fakeExecutor implements a2asrv.AgentExecutor for testing. type fakeExecutor struct{} -func (f *fakeExecutor) Execute(_ context.Context, _ *a2asrv.RequestContext, _ eventqueue.Queue) error { - return nil +func (f *fakeExecutor) Execute(_ context.Context, _ *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) {} } -func (f *fakeExecutor) Cancel(_ context.Context, _ *a2asrv.RequestContext, _ eventqueue.Queue) error { - return nil +func (f *fakeExecutor) Cancel(_ context.Context, _ *a2asrv.ExecutorContext) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) {} } var _ a2asrv.AgentExecutor = (*fakeExecutor)(nil) diff --git a/go/adk/pkg/config/config_loader.go b/go/adk/pkg/config/config_loader.go index 908f31247d..7aa20d6581 100644 --- a/go/adk/pkg/config/config_loader.go +++ b/go/adk/pkg/config/config_loader.go @@ -6,7 +6,7 @@ import ( "os" "path/filepath" - "github.com/a2aproject/a2a-go/a2a" + "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/adk" ) diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index 97cf20ea41..c4c07f94db 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -9,7 +9,7 @@ import ( "os" "time" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/go-logr/logr" "github.com/kagent-dev/kagent/go/adk/pkg/constants" "github.com/kagent-dev/kagent/go/api/adk" @@ -46,7 +46,7 @@ func allowedRequestHeaders(ctx context.Context, allowed []string) map[string]str if !ok { return nil } - meta := callCtx.RequestMeta() + meta := callCtx.ServiceParams() if meta == nil { return nil } @@ -280,7 +280,7 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro // A2A request independently of allowedHeaders. if rt.propagateToken { if callCtx, ok := a2asrv.CallContextFrom(req.Context()); ok { - if meta := callCtx.RequestMeta(); meta != nil { + if meta := callCtx.ServiceParams(); meta != nil { if vals, ok := meta.Get(constants.AuthorizationHeader); ok && len(vals) > 0 && vals[0] != "" { req.Header.Set(constants.AuthorizationHeader, vals[0]) } diff --git a/go/adk/pkg/mcp/registry_test.go b/go/adk/pkg/mcp/registry_test.go index 2931a94bd3..dd6e8cb48a 100644 --- a/go/adk/pkg/mcp/registry_test.go +++ b/go/adk/pkg/mcp/registry_test.go @@ -6,15 +6,14 @@ import ( "net/http/httptest" "testing" - "github.com/a2aproject/a2a-go/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv" ) // a2aCtx builds a context that carries an A2A CallContext with the given headers. // Keys are stored case-insensitively by NewRequestMeta, matching the behaviour // of a real A2A server. func a2aCtx(headers map[string][]string) context.Context { - meta := a2asrv.NewRequestMeta(headers) - ctx, _ := a2asrv.WithCallContext(context.Background(), meta) + ctx, _ := a2asrv.NewCallContext(context.Background(), a2asrv.NewServiceParams(headers)) return ctx } diff --git a/go/adk/pkg/taskstore/store.go b/go/adk/pkg/taskstore/store.go index 4b3bab2453..aed67259d7 100644 --- a/go/adk/pkg/taskstore/store.go +++ b/go/adk/pkg/taskstore/store.go @@ -9,7 +9,8 @@ import ( "net/http" "net/url" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2ataskstore "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore" ) // Constants for partial-event metadata keys (inlined to avoid import cycle). @@ -90,10 +91,9 @@ func cleanPartialArtifacts(artifacts []*a2atype.Artifact) []*a2atype.Artifact { return cleaned } -// Save implements a2asrv.TaskStore. -func (s *KAgentTaskStore) Save(ctx context.Context, task *a2atype.Task, _ a2atype.Event, _ *a2atype.Task, _ a2atype.TaskVersion) (a2atype.TaskVersion, error) { +func (s *KAgentTaskStore) saveTask(ctx context.Context, task *a2atype.Task) (a2ataskstore.TaskVersion, error) { if task == nil { - return a2atype.TaskVersionMissing, fmt.Errorf("task cannot be nil") + return a2ataskstore.TaskVersionMissing, fmt.Errorf("task cannot be nil") } // Work on a shallow copy so the caller's task is not mutated. @@ -107,59 +107,77 @@ func (s *KAgentTaskStore) Save(ctx context.Context, task *a2atype.Task, _ a2atyp taskJSON, err := json.Marshal(&taskCopy) if err != nil { - return a2atype.TaskVersionMissing, fmt.Errorf("failed to marshal task: %w", err) + return a2ataskstore.TaskVersionMissing, fmt.Errorf("failed to marshal task: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", s.BaseURL+"/api/tasks", bytes.NewReader(taskJSON)) if err != nil { - return a2atype.TaskVersionMissing, fmt.Errorf("failed to create save request: %w", err) + return a2ataskstore.TaskVersionMissing, fmt.Errorf("failed to create save request: %w", err) } req.Header.Set(headerContentType, contentTypeJSON) + req.Header.Set(a2atype.SvcParamVersion, string(a2atype.Version)) resp, err := s.Client.Do(req) if err != nil { - return a2atype.TaskVersionMissing, fmt.Errorf("failed to execute save task request: %w", err) + return a2ataskstore.TaskVersionMissing, fmt.Errorf("failed to execute save task request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) - return a2atype.TaskVersionMissing, fmt.Errorf("failed to save task: status %d, body: %s", resp.StatusCode, string(body)) + return a2ataskstore.TaskVersionMissing, fmt.Errorf("failed to save task: status %d, body: %s", resp.StatusCode, string(body)) } - return a2atype.TaskVersion(1), nil + return a2ataskstore.TaskVersionMissing, nil } -// Get implements a2asrv.TaskStore. -func (s *KAgentTaskStore) Get(ctx context.Context, taskID a2atype.TaskID) (*a2atype.Task, a2atype.TaskVersion, error) { +// Create implements taskstore.Store. +func (s *KAgentTaskStore) Create(ctx context.Context, task *a2atype.Task) (a2ataskstore.TaskVersion, error) { + return s.saveTask(ctx, task) +} + +// Update implements taskstore.Store. +func (s *KAgentTaskStore) Update(ctx context.Context, update *a2ataskstore.UpdateRequest) (a2ataskstore.TaskVersion, error) { + if update == nil { + return a2ataskstore.TaskVersionMissing, fmt.Errorf("update request cannot be nil") + } + return s.saveTask(ctx, update.Task) +} + +// Get implements taskstore.Store. +func (s *KAgentTaskStore) Get(ctx context.Context, taskID a2atype.TaskID) (*a2ataskstore.StoredTask, error) { req, err := http.NewRequestWithContext(ctx, "GET", s.BaseURL+"/api/tasks/"+url.PathEscape(string(taskID)), nil) if err != nil { - return nil, a2atype.TaskVersionMissing, fmt.Errorf("failed to create get request: %w", err) + return nil, fmt.Errorf("failed to create get request: %w", err) } + req.Header.Set(a2atype.SvcParamVersion, string(a2atype.Version)) resp, err := s.Client.Do(req) if err != nil { - return nil, a2atype.TaskVersionMissing, fmt.Errorf("failed to execute get task request: %w", err) + return nil, fmt.Errorf("failed to execute get task request: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, a2atype.TaskVersionMissing, a2atype.ErrTaskNotFound + return nil, a2atype.ErrTaskNotFound } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, a2atype.TaskVersionMissing, fmt.Errorf("failed to get task: status %d, body: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("failed to get task: status %d, body: %s", resp.StatusCode, string(body)) } var wrapped KAgentTaskResponse if err := json.NewDecoder(resp.Body).Decode(&wrapped); err != nil { - return nil, a2atype.TaskVersionMissing, fmt.Errorf("failed to decode response: %w", err) + return nil, fmt.Errorf("failed to decode response: %w", err) } if wrapped.Data == nil { - return nil, a2atype.TaskVersionMissing, a2atype.ErrTaskNotFound + return nil, a2atype.ErrTaskNotFound } - return wrapped.Data, a2atype.TaskVersion(1), nil + return &a2ataskstore.StoredTask{ + Task: wrapped.Data, + Version: a2ataskstore.TaskVersionMissing, + }, nil } // List implements a2asrv.TaskStore. Listing is not supported against the KAgent task API. diff --git a/go/adk/pkg/tools/remote_a2a_tool.go b/go/adk/pkg/tools/remote_a2a_tool.go index 87afe88a8b..dd2c7bf2e2 100644 --- a/go/adk/pkg/tools/remote_a2a_tool.go +++ b/go/adk/pkg/tools/remote_a2a_tool.go @@ -8,10 +8,10 @@ import ( "strings" "sync" - a2atype "github.com/a2aproject/a2a-go/a2a" - "github.com/a2aproject/a2a-go/a2aclient" - "github.com/a2aproject/a2a-go/a2aclient/agentcard" - "github.com/a2aproject/a2a-go/a2asrv" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/kagent-dev/kagent/go/adk/pkg/a2a" "github.com/kagent-dev/kagent/go/adk/pkg/constants" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -27,11 +27,11 @@ type userIDForwardingInterceptor struct { a2aclient.PassthroughInterceptor } -func (u *userIDForwardingInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, error) { +func (u *userIDForwardingInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { if uid, ok := ctx.Value(userIDContextKey{}).(string); ok && uid != "" { - req.Meta.Append("x-user-id", uid) + req.ServiceParams.Append("x-user-id", uid) } - return ctx, nil + return ctx, nil, nil } // authzForwardingInterceptor forwards the Authorization header from the @@ -40,22 +40,37 @@ type authzForwardingInterceptor struct { a2aclient.PassthroughInterceptor } -func (a *authzForwardingInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, error) { +func (a *authzForwardingInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { callCtx, ok := a2asrv.CallContextFrom(ctx) if !ok { - return ctx, nil + return ctx, nil, nil } - meta := callCtx.RequestMeta() + meta := callCtx.ServiceParams() if meta == nil { - return ctx, nil + return ctx, nil, nil } - if len(req.Meta.Get(constants.AuthorizationHeader)) > 0 { - return ctx, nil + if len(req.ServiceParams.Get(constants.AuthorizationHeader)) > 0 { + return ctx, nil, nil } if vals, ok := meta.Get(constants.AuthorizationHeader); ok && len(vals) > 0 && vals[0] != "" { - req.Meta.Append(constants.AuthorizationHeader, vals[0]) + req.ServiceParams.Append(constants.AuthorizationHeader, vals[0]) } - return ctx, nil + return ctx, nil, nil +} + +// staticHeadersInterceptor appends static request headers to every call. +type staticHeadersInterceptor struct { + a2aclient.PassthroughInterceptor + headers map[string]string +} + +func (s *staticHeadersInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { + for k, v := range s.headers { + if v != "" { + req.ServiceParams.Append(k, v) + } + } + return ctx, nil, nil } // remoteA2AInput is the typed argument for the remote A2A function tool. @@ -142,19 +157,17 @@ func (s *remoteA2AState) ensureClient(ctx context.Context) (*a2aclient.Client, e a2aclient.WithJSONRPCTransport(s.httpClient), } // Always inject x-kagent-source: agent to mark this as an agent-originated call. - meta := a2aclient.CallMeta{} - meta.Append("x-kagent-source", "agent") - for k, v := range s.extraHeaders { - meta.Append(k, v) - } interceptors := []a2aclient.CallInterceptor{ - a2aclient.NewStaticCallMetaInjector(meta), + &staticHeadersInterceptor{headers: map[string]string{"x-kagent-source": "agent"}}, &userIDForwardingInterceptor{}, } + if len(s.extraHeaders) > 0 { + interceptors = append(interceptors, &staticHeadersInterceptor{headers: s.extraHeaders}) + } if s.propagateToken { interceptors = append(interceptors, &authzForwardingInterceptor{}) } - opts = append(opts, a2aclient.WithInterceptors(interceptors...)) + opts = append(opts, a2aclient.WithCallInterceptors(interceptors...)) client, err := a2aclient.NewFromCard(ctx, card, opts...) if err != nil { @@ -187,12 +200,12 @@ func (s *remoteA2AState) handleFirstCall(ctx tool.Context, requestText string) ( message := a2atype.NewMessage( a2atype.MessageRoleUser, - a2atype.TextPart{Text: requestText}, + a2atype.NewTextPart(requestText), ) message.ContextID = s.lastContextID sendCtx := context.WithValue(ctx, userIDContextKey{}, ctx.UserID()) - result, err := client.SendMessage(sendCtx, &a2atype.MessageSendParams{Message: message}) + result, err := client.SendMessage(sendCtx, &a2atype.SendMessageRequest{Message: message}) if err != nil { slog.Error("Remote agent request failed", "tool", s.name, "error", err) return map[string]any{"error": fmt.Sprintf("Remote agent '%s' request failed: %v", s.name, err)}, nil @@ -226,7 +239,7 @@ func (s *remoteA2AState) handleResume(ctx tool.Context) (map[string]any, error) TaskID: a2atype.TaskID(taskID), ContextID: contextID, Role: a2atype.MessageRoleUser, - Parts: a2atype.ContentParts{a2atype.DataPart{Data: decisionData}}, + Parts: a2atype.ContentParts{a2atype.NewDataPart(decisionData)}, } decisionType, _ := decisionData[a2a.KAgentHitlDecisionTypeKey].(string) @@ -242,7 +255,7 @@ func (s *remoteA2AState) handleResume(ctx tool.Context) (map[string]any, error) } sendCtx := context.WithValue(ctx, userIDContextKey{}, ctx.UserID()) - result, err := client.SendMessage(sendCtx, &a2atype.MessageSendParams{Message: message}) + result, err := client.SendMessage(sendCtx, &a2atype.SendMessageRequest{Message: message}) if err != nil { slog.Error("Remote agent resume failed", "tool", subagentName, "error", err) return map[string]any{"error": fmt.Sprintf("Remote agent '%s' resume failed: %v", subagentName, err)}, nil @@ -425,8 +438,11 @@ func extractTextFromTask(task *a2atype.Task) string { var texts []string for _, artifact := range task.Artifacts { for _, part := range artifact.Parts { - if tp, ok := part.(a2atype.TextPart); ok && tp.Text != "" { - texts = append(texts, tp.Text) + if part == nil { + continue + } + if text := part.Text(); text != "" { + texts = append(texts, text) } } } @@ -448,8 +464,11 @@ func extractTextFromMessage(message *a2atype.Message) string { } var texts []string for _, part := range message.Parts { - if tp, ok := part.(a2atype.TextPart); ok && tp.Text != "" { - texts = append(texts, tp.Text) + if part == nil { + continue + } + if text := part.Text(); text != "" { + texts = append(texts, text) } } return strings.Join(texts, "\n") diff --git a/go/adk/pkg/tools/skills_test.go b/go/adk/pkg/tools/skills_test.go index 1e4758c9ef..f22a54aecd 100644 --- a/go/adk/pkg/tools/skills_test.go +++ b/go/adk/pkg/tools/skills_test.go @@ -21,8 +21,12 @@ func TestResolveReadPath_AllowsSymlinkedSkillsDirectory(t *testing.T) { if err != nil { t.Fatalf("resolveReadPath() error = %v", err) } - if resolved != skillFile { - t.Fatalf("resolveReadPath() = %q, want %q", resolved, skillFile) + want, err := filepath.EvalSymlinks(skillFile) + if err != nil { + t.Fatalf("EvalSymlinks() error = %v", err) + } + if resolved != want { + t.Fatalf("resolveReadPath() = %q, want %q", resolved, want) } } diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index f54a5879d1..7759d174c5 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -7696,23 +7696,25 @@ spec: description: Examples are optional usage examples. items: type: string + maxItems: 20 type: array id: description: ID is the unique identifier for the skill. type: string inputModes: - description: InputModes are the supported input data - modes/types. + description: InputModes are the supported input MIME + types for this skill, overriding the agent's defaults. items: type: string type: array name: description: Name is the human-readable name of the skill. + minLength: 1 type: string outputModes: - description: OutputModes are the supported output data - modes/types. + description: OutputModes are the supported output MIME + types for this skill, overriding the agent's defaults. items: type: string type: array @@ -7720,11 +7722,10 @@ spec: description: Tags are optional tags for categorization. items: type: string + maxItems: 20 type: array required: - - id - name - - tags type: object minItems: 1 type: array diff --git a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml index b8bc8dce7a..2bdbe111d2 100644 --- a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml +++ b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml @@ -5354,23 +5354,25 @@ spec: description: Examples are optional usage examples. items: type: string + maxItems: 20 type: array id: description: ID is the unique identifier for the skill. type: string inputModes: - description: InputModes are the supported input data - modes/types. + description: InputModes are the supported input MIME + types for this skill, overriding the agent's defaults. items: type: string type: array name: description: Name is the human-readable name of the skill. + minLength: 1 type: string outputModes: - description: OutputModes are the supported output data - modes/types. + description: OutputModes are the supported output MIME + types for this skill, overriding the agent's defaults. items: type: string type: array @@ -5378,11 +5380,10 @@ spec: description: Tags are optional tags for categorization. items: type: string + maxItems: 20 type: array required: - - id - name - - tags type: object minItems: 1 type: array diff --git a/go/api/database/client.go b/go/api/database/client.go index 43943da678..312db52d95 100644 --- a/go/api/database/client.go +++ b/go/api/database/client.go @@ -4,9 +4,9 @@ import ( "context" "time" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/pgvector/pgvector-go" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) type QueryOptions struct { @@ -24,8 +24,8 @@ type Client interface { StoreFeedback(ctx context.Context, feedback *Feedback) error StoreSession(ctx context.Context, session *Session) error StoreAgent(ctx context.Context, agent *Agent) error - StoreTask(ctx context.Context, task *protocol.Task) error - StorePushNotification(ctx context.Context, config *protocol.TaskPushNotificationConfig) error + StoreTask(ctx context.Context, task *a2a.Task) error + StorePushNotification(ctx context.Context, config *a2a.PushConfig) error StoreToolServer(ctx context.Context, toolServer *ToolServer) (*ToolServer, error) StoreEvents(ctx context.Context, messages ...*Event) error @@ -40,15 +40,15 @@ type Client interface { // Get methods GetSession(ctx context.Context, sessionID string, userID string) (*Session, error) GetAgent(ctx context.Context, name string) (*Agent, error) - GetTask(ctx context.Context, id string) (*protocol.Task, error) + GetTask(ctx context.Context, id string) (*a2a.Task, error) GetTool(ctx context.Context, name string) (*Tool, error) GetToolServer(ctx context.Context, name string) (*ToolServer, error) - GetPushNotification(ctx context.Context, taskID string, configID string) (*protocol.TaskPushNotificationConfig, error) + GetPushNotification(ctx context.Context, taskID string, configID string) (*a2a.PushConfig, error) // List methods ListTools(ctx context.Context) ([]Tool, error) ListFeedback(ctx context.Context, userID string) ([]Feedback, error) - ListTasksForSession(ctx context.Context, sessionID string) ([]*protocol.Task, error) + ListTasksForSession(ctx context.Context, sessionID string) ([]*a2a.Task, error) ListSessions(ctx context.Context, userID string) ([]Session, error) ListSessionsForAgent(ctx context.Context, agentID string, userID string) ([]Session, error) ListSessionsForAgentAllUsers(ctx context.Context, agentID string) ([]Session, error) @@ -56,7 +56,7 @@ type Client interface { ListToolServers(ctx context.Context) ([]ToolServer, error) ListToolsForServer(ctx context.Context, serverName string, groupKind string) ([]Tool, error) ListEventsForSession(ctx context.Context, sessionID, userID string, options QueryOptions) ([]*Event, error) - ListPushNotifications(ctx context.Context, taskID string) ([]*protocol.TaskPushNotificationConfig, error) + ListPushNotifications(ctx context.Context, taskID string) ([]*a2a.PushConfig, error) // Helper methods RefreshToolsForServer(ctx context.Context, serverName string, groupKind string, tools ...*v1alpha2.MCPTool) error diff --git a/go/api/database/models.go b/go/api/database/models.go index 7bb7be9daa..d0852fe922 100644 --- a/go/api/database/models.go +++ b/go/api/database/models.go @@ -4,10 +4,10 @@ import ( "encoding/json" "time" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/pgvector/pgvector-go" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) type Agent struct { @@ -32,16 +32,16 @@ type Event struct { Data string `json:"data"` // JSON-serialized protocol.Message } -func (m *Event) Parse() (protocol.Message, error) { - var data protocol.Message +func (m *Event) Parse() (a2a.Message, error) { + var data a2a.Message if err := json.Unmarshal([]byte(m.Data), &data); err != nil { - return protocol.Message{}, err + return a2a.Message{}, err } return data, nil } -func ParseMessages(messages []Event) ([]*protocol.Message, error) { - result := make([]*protocol.Message, 0, len(messages)) +func ParseMessages(messages []Event) ([]*a2a.Message, error) { + result := make([]*a2a.Message, 0, len(messages)) for _, message := range messages { parsed, err := message.Parse() if err != nil { @@ -77,24 +77,25 @@ type Session struct { } type Task struct { - ID string `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` - Data string `json:"data"` // JSON-serialized task data - SessionID string `json:"session_id"` + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + Data string `json:"data"` // JSON-serialized task data + ProtocolVersion *string `json:"protocol_version,omitempty"` + SessionID string `json:"session_id"` } -func (t *Task) Parse() (protocol.Task, error) { - var data protocol.Task +func (t *Task) Parse() (a2a.Task, error) { + var data a2a.Task if err := json.Unmarshal([]byte(t.Data), &data); err != nil { - return protocol.Task{}, err + return a2a.Task{}, err } return data, nil } -func ParseTasks(tasks []Task) ([]*protocol.Task, error) { - result := make([]*protocol.Task, 0, len(tasks)) +func ParseTasks(tasks []Task) ([]*a2a.Task, error) { + result := make([]*a2a.Task, 0, len(tasks)) for _, task := range tasks { parsed, err := task.Parse() if err != nil { @@ -106,12 +107,13 @@ func ParseTasks(tasks []Task) ([]*protocol.Task, error) { } type PushNotification struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` - Data string `json:"data"` // JSON-serialized push notification config + ID string `json:"id"` + TaskID string `json:"task_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + Data string `json:"data"` // JSON-serialized push notification config + ProtocolVersion *string `json:"protocol_version,omitempty"` } // FeedbackIssueType represents the category of feedback issue diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 661643f54a..1aa0d33799 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -26,8 +26,6 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" - - "trpc.group/trpc-go/trpc-a2a-go/server" ) // AgentType represents the agent type @@ -529,7 +527,33 @@ type A2AConfig struct { Skills []AgentSkill `json:"skills,omitempty"` } -type AgentSkill server.AgentSkill +// AgentSkill describes a specific capability or function of the agent. +type AgentSkill struct { + // ID is the unique identifier for the skill. + // +optional + ID string `json:"id,omitempty"` + // Name is the human-readable name of the skill. + // +kubebuilder:validation:MinLength=1 + // +required + Name string `json:"name"` + // Description is an optional detailed description of the skill. + // +optional + Description string `json:"description,omitempty"` + // Tags are optional tags for categorization. + // +optional + // +kubebuilder:validation:MaxItems=20 + Tags []string `json:"tags,omitempty"` + // Examples are optional usage examples. + // +optional + // +kubebuilder:validation:MaxItems=20 + Examples []string `json:"examples,omitempty"` + // InputModes are the supported input MIME types for this skill, overriding the agent's defaults. + // +optional + InputModes []string `json:"inputModes,omitempty"` + // OutputModes are the supported output MIME types for this skill, overriding the agent's defaults. + // +optional + OutputModes []string `json:"outputModes,omitempty"` +} const ( AgentConditionTypeAccepted = "Accepted" diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 52d10ed714..c621ca56c0 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -434,11 +434,6 @@ func (in *AgentList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentSkill) DeepCopyInto(out *AgentSkill) { *out = *in - if in.Description != nil { - in, out := &in.Description, &out.Description - *out = new(string) - **out = **in - } if in.Tags != nil { in, out := &in.Tags, &out.Tags *out = make([]string, len(*in)) diff --git a/go/core/cli/cmd/kagent/main.go b/go/core/cli/cmd/kagent/main.go index 78b6c062e5..66e8e33c8d 100644 --- a/go/core/cli/cmd/kagent/main.go +++ b/go/core/cli/cmd/kagent/main.go @@ -10,6 +10,7 @@ import ( cli "github.com/kagent-dev/kagent/go/core/cli/internal/cli/agent" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/envdoc" "github.com/kagent-dev/kagent/go/core/cli/internal/cli/mcp" + "github.com/kagent-dev/kagent/go/core/cli/internal/cli/migrate" "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/profiles" "github.com/kagent-dev/kagent/go/core/cli/internal/tui" @@ -449,7 +450,7 @@ Examples: runCmd.Flags().StringVar(&runCfg.ProjectDir, "project-dir", "", "Project directory (default: current directory)") runCmd.Flags().BoolVar(&runCfg.Build, "build", false, "Rebuild the Docker image before running") - rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, initCmd, buildCmd, deployCmd, addMcpCmd, runCmd, mcp.NewMCPCmd(), envdoc.NewEnvCmd()) + rootCmd.AddCommand(installCmd, uninstallCmd, invokeCmd, bugReportCmd, versionCmd, dashboardCmd, getCmd, initCmd, buildCmd, deployCmd, addMcpCmd, runCmd, migrate.NewCommand(), mcp.NewMCPCmd(), envdoc.NewEnvCmd()) return rootCmd } diff --git a/go/core/cli/internal/a2a/client.go b/go/core/cli/internal/a2a/client.go new file mode 100644 index 0000000000..396957afa0 --- /dev/null +++ b/go/core/cli/internal/a2a/client.go @@ -0,0 +1,79 @@ +package a2a + +import ( + "context" + "maps" + "net/http" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" + corea2a "github.com/kagent-dev/kagent/go/core/internal/a2a" +) + +// ClientOptions configures an official A2A v1 client for CLI use. +type ClientOptions struct { + HTTPClient *http.Client + Headers map[string]string + Timeout time.Duration +} + +// NewClient returns an A2A v1 client pointed directly at baseURL without resolving +// the agent card. The card's published URL contains the in-cluster hostname which is +// unreachable from outside the cluster; skipping resolution mirrors the pre-v1 behaviour +// of constructing the endpoint URL directly from the user's config. +func NewClient(ctx context.Context, baseURL string, opts ClientOptions) (*a2aclient.Client, error) { + headers := make(map[string]string, len(opts.Headers)+1) + maps.Copy(headers, opts.Headers) + if _, ok := headers[a2atype.SvcParamVersion]; !ok { + headers[a2atype.SvcParamVersion] = string(a2atype.Version) + } + + httpClient := opts.HTTPClient + if httpClient == nil { + timeout := opts.Timeout + if timeout == 0 { + timeout = 60 * time.Second + } + httpClient = &http.Client{Timeout: timeout} + } + + endpoints := []*a2atype.AgentInterface{ + {URL: baseURL, ProtocolBinding: a2atype.TransportProtocolJSONRPC}, + } + + return a2aclient.NewFromEndpoints( + ctx, + endpoints, + a2aclient.WithJSONRPCTransport(httpClient), + a2aclient.WithCallInterceptors(corea2a.NewStaticHeadersInterceptor(headers)), + ) +} + +// StreamToChannel adapts a streaming A2A response to a channel for TUI consumption. +func StreamToChannel(ctx context.Context, client *a2aclient.Client, req *a2atype.SendMessageRequest) (<-chan a2atype.Event, error) { + ch := make(chan a2atype.Event) + go func() { + defer close(ch) + for event, err := range client.SendStreamingMessage(ctx, req) { + if err != nil { + return + } + if event != nil { + select { + case ch <- event: + case <-ctx.Done(): + return + } + } + } + }() + return ch, nil +} + +// V1RequestHeaders returns HTTP headers that select official A2A v1 wire format. +func V1RequestHeaders() map[string]string { + return map[string]string{ + a2atype.SvcParamVersion: string(a2atype.Version), + } +} diff --git a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent-card.json.tmpl b/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent-card.json.tmpl index 0f5b594708..d6c64d6da7 100644 --- a/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent-card.json.tmpl +++ b/go/core/cli/internal/agent/frameworks/adk/python/templates/agent/agent-card.json.tmpl @@ -1,7 +1,6 @@ { "name": "{{.Name}}", "description": "A {{.Name}} agent", - "url": "localhost:8080", "version": "0.0.1", "capabilities": { "streaming": true diff --git a/go/core/cli/internal/cli/agent/invoke.go b/go/core/cli/internal/cli/agent/invoke.go index 067e6c8175..43e5b62a01 100644 --- a/go/core/cli/internal/cli/agent/invoke.go +++ b/go/core/cli/internal/cli/agent/invoke.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -9,11 +10,11 @@ import ( "strings" "time" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/api/v1alpha2" + clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" "github.com/kagent-dev/kagent/go/core/cli/internal/config" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) type InvokeCfg struct { @@ -80,26 +81,20 @@ func InvokeCmd(ctx context.Context, cfg *InvokeCfg) { return } - var a2aClientOpts []a2aclient.Option - a2aClientOpts = append(a2aClientOpts, a2aclient.WithTimeout(cfg.Config.Timeout)) - + clientOpts := clia2a.ClientOptions{Timeout: cfg.Config.Timeout} if cfg.Token != "" { - a2aClientOpts = append(a2aClientOpts, a2aclient.WithHTTPClient(&http.Client{ + clientOpts.HTTPClient = &http.Client{ + Timeout: cfg.Config.Timeout, Transport: &bearerTokenTransport{ base: http.DefaultTransport, token: cfg.Token, }, - })) + } } - var a2aClient *a2aclient.A2AClient - var err error + var a2aURL string if cfg.URLOverride != "" { - a2aClient, err = a2aclient.NewA2AClient(cfg.URLOverride, a2aClientOpts...) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating A2A client: %v\n", err) - return - } + a2aURL = cfg.URLOverride } else { if cfg.Agent == "" { fmt.Fprintln(os.Stderr, "Agent is required") @@ -118,55 +113,43 @@ func InvokeCmd(ctx context.Context, cfg *InvokeCfg) { return } - a2aURL := buildA2AURL(cfg.Config.KAgentURL, cfg.Config.Namespace, cfg.Agent, agentResponse.Data) - a2aClient, err = a2aclient.NewA2AClient(a2aURL, a2aClientOpts...) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating A2A client: %v\n", err) - return - } + a2aURL = buildA2AURL(cfg.Config.KAgentURL, cfg.Config.Namespace, cfg.Agent, agentResponse.Data) + } + + a2aClient, err := clia2a.NewClient(ctx, a2aURL, clientOpts) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating A2A client: %v\n", err) + return } - var sessionID *string + msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(task)) if cfg.Session != "" { - sessionID = &cfg.Session + msg.ContextID = cfg.Session } + req := &a2atype.SendMessageRequest{Message: msg} // Use A2A client to send message if cfg.Stream { ctx, cancel := context.WithTimeout(ctx, 300*time.Second) defer cancel() - result, err := a2aClient.StreamMessage(ctx, protocol.SendMessageParams{ - Message: protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - ContextID: sessionID, - Parts: []protocol.Part{protocol.NewTextPart(task)}, - }, - }) + ch, err := clia2a.StreamToChannel(ctx, a2aClient, req) if err != nil { fmt.Fprintf(os.Stderr, "Error invoking session: %v\n", err) return } - StreamA2AEvents(result, cfg.Config.Verbose) + StreamA2AEvents(ch, cfg.Config.Verbose) } else { ctx, cancel := context.WithTimeout(ctx, 300*time.Second) defer cancel() - result, err := a2aClient.SendMessage(ctx, protocol.SendMessageParams{ - Message: protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - ContextID: sessionID, - Parts: []protocol.Part{protocol.NewTextPart(task)}, - }, - }) + result, err := a2aClient.SendMessage(ctx, req) if err != nil { fmt.Fprintf(os.Stderr, "Error invoking session: %v\n", err) return } - jsn, err := result.MarshalJSON() + jsn, err := json.Marshal(result) if err != nil { fmt.Fprintf(os.Stderr, "Error marshaling result: %v\n", err) return diff --git a/go/core/cli/internal/cli/agent/run.go b/go/core/cli/internal/cli/agent/run.go index 3adb7c390c..9668deaf6b 100644 --- a/go/core/cli/internal/cli/agent/run.go +++ b/go/core/cli/internal/cli/agent/run.go @@ -9,11 +9,11 @@ import ( "path/filepath" "time" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" commonexec "github.com/kagent-dev/kagent/go/core/cli/internal/common/exec" "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/tui" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) type RunCfg struct { @@ -106,20 +106,16 @@ func RunCmd(ctx context.Context, cfg *RunCfg) error { fmt.Println("Launching chat interface...") // Generate a new session ID - sessionID := protocol.GenerateContextID() + sessionID := a2atype.NewContextID() // Create A2A client for local agent - a2aClient, err := a2aclient.NewA2AClient(agentURL, a2aclient.WithTimeout(cfg.Config.Timeout)) + a2aClient, err := clia2a.NewClient(ctx, agentURL, clia2a.ClientOptions{Timeout: cfg.Config.Timeout}) if err != nil { return fmt.Errorf("failed to create A2A client: %v", err) } - sendFn := func(ctx context.Context, params protocol.SendMessageParams) (<-chan protocol.StreamingMessageEvent, error) { - ch, err := a2aClient.StreamMessage(ctx, params) - if err != nil { - return nil, err - } - return ch, err + sendFn := func(ctx context.Context, req *a2atype.SendMessageRequest) (<-chan a2atype.Event, error) { + return clia2a.StreamToChannel(ctx, a2aClient, req) } // Launch TUI chat directly diff --git a/go/core/cli/internal/cli/agent/utils.go b/go/core/cli/internal/cli/agent/utils.go index 4ec6b53253..79dfdf3d34 100644 --- a/go/core/cli/internal/cli/agent/utils.go +++ b/go/core/cli/internal/cli/agent/utils.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "fmt" "io/fs" "os" @@ -16,7 +17,8 @@ import ( pygen "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/adk/python" "github.com/kagent-dev/kagent/go/core/cli/internal/agent/frameworks/common" "github.com/kagent-dev/kagent/go/core/cli/internal/config" - "trpc.group/trpc-go/trpc-a2a-go/protocol" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" ) var ( @@ -89,23 +91,15 @@ func (p *PortForward) Stop() { // The kubectl process will terminate when the context is canceled } -func StreamA2AEvents(ch <-chan protocol.StreamingMessageEvent, verbose bool) { +func StreamA2AEvents(ch <-chan a2atype.Event, verbose bool) { + _ = verbose for event := range ch { - if verbose { - json, err := event.MarshalJSON() - if err != nil { - fmt.Fprintf(os.Stderr, "Error marshaling A2A event: %v\n", err) - continue - } - fmt.Fprintf(os.Stdout, "%+v\n", string(json)) - } else { - json, err := event.MarshalJSON() - if err != nil { - fmt.Fprintf(os.Stderr, "Error marshaling A2A event: %v\n", err) - continue - } - fmt.Fprintf(os.Stdout, "%+v\n", string(json)) + json, err := json.Marshal(event) + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling A2A event: %v\n", err) + continue } + fmt.Fprintf(os.Stdout, "%+v\n", string(json)) } fmt.Fprintln(os.Stdout) } diff --git a/go/core/cli/internal/cli/migrate/migrate.go b/go/core/cli/internal/cli/migrate/migrate.go new file mode 100644 index 0000000000..11eb557fa3 --- /dev/null +++ b/go/core/cli/internal/cli/migrate/migrate.go @@ -0,0 +1,81 @@ +package migrate + +import ( + "fmt" + + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/pkg/a2amigration" + "github.com/spf13/cobra" +) + +const ( + defaultPostgresURL = "postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres" +) + +type A2ADataOptions struct { + PostgresDatabaseURL string + PostgresDatabaseURLFile string + BatchSize int + DryRun bool +} + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Run kagent data migrations", + } + cmd.AddCommand(newA2ADataCommand()) + return cmd +} + +func newA2ADataCommand() *cobra.Command { + opts := A2ADataOptions{ + PostgresDatabaseURL: defaultPostgresURL, + BatchSize: 100, + } + + cmd := &cobra.Command{ + Use: "a2a-v1", + Short: "Migrate stored A2A task and push notification data to v1", + Long: `Migrate stored A2A task and push notification JSON blobs from the legacy +trpc-a2a-go shape to the official A2A v1 shape. Take a database backup before running without --dry-run.`, + RunE: func(cmd *cobra.Command, args []string) error { + url, err := database.ResolveURL(opts.PostgresDatabaseURL, opts.PostgresDatabaseURLFile) + if err != nil { + return err + } + db, err := database.Connect(cmd.Context(), &database.PostgresConfig{URL: url}) + if err != nil { + return err + } + defer db.Close() + + stats, err := a2amigration.Run(cmd.Context(), db, a2amigration.Options{ + BatchSize: opts.BatchSize, + DryRun: opts.DryRun, + Out: cmd.OutOrStdout(), + }) + if err != nil { + return err + } + + mode := "migration" + rowVerb := "migrated" + if opts.DryRun { + mode = "dry-run" + rowVerb = "would migrate" + } + fmt.Fprintf(cmd.OutOrStdout(), "A2A data migration complete (%s):\n", mode) + fmt.Fprintf(cmd.OutOrStdout(), " tasks: %d %s, %d skipped, %d failed\n", stats.TasksMigrated, rowVerb, stats.TasksSkipped, stats.TasksFailed) + fmt.Fprintf(cmd.OutOrStdout(), " push notifications: %d %s, %d skipped, %d failed\n", stats.PushNotificationsMigrated, rowVerb, stats.PushNotificationsSkipped, stats.PushNotificationsFailed) + fmt.Fprintf(cmd.OutOrStdout(), " already v1: %d\n", stats.AlreadyV1) + return nil + }, + } + + cmd.Flags().StringVar(&opts.PostgresDatabaseURL, "postgres-database-url", opts.PostgresDatabaseURL, "The URL of the PostgreSQL database.") + cmd.Flags().StringVar(&opts.PostgresDatabaseURLFile, "postgres-database-url-file", "", "Path to a file containing the PostgreSQL database URL. Takes precedence over --postgres-database-url.") + cmd.Flags().IntVar(&opts.BatchSize, "batch-size", opts.BatchSize, "Number of legacy rows to process per batch.") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Convert rows and report counts without writing changes.") + return cmd +} diff --git a/go/core/cli/internal/tui/chat.go b/go/core/cli/internal/tui/chat.go index 78ce5daad7..2c1913079b 100644 --- a/go/core/cli/internal/tui/chat.go +++ b/go/core/cli/internal/tui/chat.go @@ -7,6 +7,7 @@ import ( "strings" "time" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" @@ -15,11 +16,10 @@ import ( "github.com/kagent-dev/kagent/go/api/utils" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/theme" "github.com/muesli/reflow/wordwrap" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) -// SendMessageFn abstracts the A2A client's StreamMessage method for easier testing. -type SendMessageFn func(ctx context.Context, params protocol.SendMessageParams) (<-chan protocol.StreamingMessageEvent, error) +// SendMessageFn abstracts the A2A client's SendStreamingMessage method for easier testing. +type SendMessageFn func(ctx context.Context, req *a2atype.SendMessageRequest) (<-chan a2atype.Event, error) // RunChat starts the TUI chat, blocking until the user exits. func RunChat(agentRef string, sessionID string, sendFn SendMessageFn, verbose bool) error { @@ -30,7 +30,7 @@ func RunChat(agentRef string, sessionID string, sendFn SendMessageFn, verbose bo } type a2aEventMsg struct { - Event protocol.StreamingMessageEvent + Event a2atype.Event } type streamDoneMsg struct{} @@ -63,7 +63,7 @@ type chatModel struct { spin spinner.Model send SendMessageFn - streamCh <-chan protocol.StreamingMessageEvent + streamCh <-chan a2atype.Event cancel context.CancelFunc streaming bool @@ -223,16 +223,11 @@ func (m *chatModel) submit(text string) tea.Cmd { ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel - params := protocol.SendMessageParams{ - Message: protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - ContextID: &m.sessionID, - Parts: []protocol.Part{protocol.NewTextPart(text)}, - }, - } + msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(text)) + msg.ContextID = m.sessionID + req := &a2atype.SendMessageRequest{Message: msg} - ch, err := m.send(ctx, params) + ch, err := m.send(ctx, req) if err != nil { m.appendError(err) m.streaming = false @@ -261,44 +256,39 @@ func (m *chatModel) appendUser(text string) { m.appendLine(theme.UserStyle().Render("You:") + " " + text) } -func (m *chatModel) appendEvent(ev protocol.StreamingMessageEvent) { - switch res := ev.Result.(type) { - case *protocol.TaskStatusUpdateEvent: - if res.Final { +func (m *chatModel) appendEvent(ev a2atype.Event) { + switch res := ev.(type) { + case *a2atype.TaskStatusUpdateEvent: + final := res.Status.State.Terminal() + if final { m.working = false m.updateStatus() + } else if res.Status.Timestamp != nil { + m.setWorkingTime(*res.Status.Timestamp) } else { - // Timestamp is RFC3339 string; parse to time for consistent elapsed display - if ts, err := time.Parse(time.RFC3339Nano, res.Status.Timestamp); err == nil { - m.setWorkingTime(ts) - } else { - m.setWorkingTime(time.Time{}) - } + m.setWorkingTime(time.Time{}) } if res.Status.Message != nil { - // Handle tool calls and results in the message - m.handleMessageParts(*res.Status.Message, res.Final) + m.handleMessageParts(res.Status.Message, final) } - case *protocol.TaskArtifactUpdateEvent: - // Render artifact content when the last chunk arrives - if res.LastChunk != nil && *res.LastChunk { + case *a2atype.TaskArtifactUpdateEvent: + if res.LastChunk { text := extractTextFromParts(res.Artifact.Parts) if strings.TrimSpace(text) != "" { m.appendLine(theme.AgentStyle().Render("Agent:") + "\n" + text) } } - case *protocol.Message: - m.handleMessageParts(*res, true) + case *a2atype.Message: + m.handleMessageParts(res, true) - case *protocol.Task: - // Show the last message in the task history + case *a2atype.Task: if len(res.History) > 0 { last := res.History[len(res.History)-1] m.handleMessageParts(last, true) } default: if m.verbose { - if b, err := ev.MarshalJSON(); err == nil { + if b, err := json.Marshal(ev); err == nil { m.appendLine(theme.AgentStyle().Render("Agent (raw):") + "\n" + string(b)) } } @@ -310,66 +300,74 @@ func (m *chatModel) appendError(err error) { } // handleMessageParts processes a message and displays text, tool calls, and tool results -func (m *chatModel) handleMessageParts(msg protocol.Message, shouldDisplay bool) { +func (m *chatModel) handleMessageParts(msg *a2atype.Message, shouldDisplay bool) { + if msg == nil { + return + } + var textParts []string var toolCalls []toolCall var toolResults []toolResult - // Process all parts for _, part := range msg.Parts { - if tp, ok := part.(*protocol.TextPart); ok { - textParts = append(textParts, tp.Text) - } else if dp, ok := part.(*protocol.DataPart); ok { - // Debug: log what we're seeing - if m.verbose { - if metaJSON, err := json.Marshal(dp.Metadata); err == nil { - m.appendLine(theme.DimStyle().Render(fmt.Sprintf("DEBUG: DataPart metadata: %s", string(metaJSON)))) - } - if dataJSON, err := json.Marshal(dp.Data); err == nil { - m.appendLine(theme.DimStyle().Render(fmt.Sprintf("DEBUG: DataPart data: %s", string(dataJSON)))) - } - } + if part == nil { + continue + } + if text := part.Text(); text != "" { + textParts = append(textParts, text) + continue + } - // Check if this is a tool call or tool result - if dp.Metadata == nil { - continue - } + data := part.Data() + if data == nil { + continue + } - typeVal, found := utils.GetMetadataValue(dp.Metadata, "type") - if !found { - continue + if m.verbose { + if metaJSON, err := json.Marshal(part.Metadata); err == nil { + m.appendLine(theme.DimStyle().Render(fmt.Sprintf("DEBUG: DataPart metadata: %s", string(metaJSON)))) } - kagentType, ok := typeVal.(string) - if !ok { - continue + if dataJSON, err := json.Marshal(data); err == nil { + m.appendLine(theme.DimStyle().Render(fmt.Sprintf("DEBUG: DataPart data: %s", string(dataJSON)))) } + } - dataMap, ok := dp.Data.(map[string]any) - if !ok { - continue - } + if part.Metadata == nil { + continue + } - switch kagentType { - case "function_call": - call := toolCall{ - Name: getString(dataMap, "name"), - ID: getString(dataMap, "id"), - Args: dataMap["args"], - } - toolCalls = append(toolCalls, call) - case "function_response": - result := toolResult{ - Name: getString(dataMap, "name"), - ID: getString(dataMap, "id"), - Response: dataMap["response"], - } - toolResults = append(toolResults, result) + typeVal, found := utils.GetMetadataValue(part.Metadata, "type") + if !found { + continue + } + kagentType, ok := typeVal.(string) + if !ok { + continue + } + + dataMap, ok := data.(map[string]any) + if !ok { + continue + } + + switch kagentType { + case "function_call": + call := toolCall{ + Name: getString(dataMap, "name"), + ID: getString(dataMap, "id"), + Args: dataMap["args"], } + toolCalls = append(toolCalls, call) + case "function_response": + result := toolResult{ + Name: getString(dataMap, "name"), + ID: getString(dataMap, "id"), + Response: dataMap["response"], + } + toolResults = append(toolResults, result) } } - // Always display tool calls and results as they happen (even if not final) - // Display tool calls for _, call := range toolCalls { var argsStr string if call.Args != nil { @@ -390,7 +388,6 @@ func (m *chatModel) handleMessageParts(msg protocol.Message, shouldDisplay bool) m.appendLine(display) } - // Display tool results for _, result := range toolResults { var responseStr string if result.Response != nil { @@ -411,12 +408,11 @@ func (m *chatModel) handleMessageParts(msg protocol.Message, shouldDisplay bool) m.appendLine(display) } - // Display text content (only on final or if explicitly requested) if shouldDisplay { text := strings.Join(textParts, "") if strings.TrimSpace(text) != "" { style := theme.UserStyle() - if msg.Role == protocol.MessageRoleAgent { + if msg.Role == a2atype.MessageRoleAgent { style = theme.AgentStyle() } m.appendLine(style.Render(fmt.Sprintf("%s:", msg.Role)) + "\n" + text) @@ -451,27 +447,25 @@ func (m *chatModel) SetInputVisible(visible bool) { m.showInput = visible } -// extractTextFromParts concatenates text from a slice of protocol.Part, stringifying non-text when reasonable. -func extractTextFromParts(parts []protocol.Part) string { +func extractTextFromParts(parts a2atype.ContentParts) string { b := strings.Builder{} for _, p := range parts { - if tp, ok := p.(*protocol.TextPart); ok { - b.WriteString(tp.Text) + if p == nil { continue } - - if dp, ok := p.(*protocol.DataPart); ok { - if jp, err := json.Marshal(dp.Data); err == nil { + if text := p.Text(); text != "" { + b.WriteString(text) + continue + } + if data := p.Data(); data != nil { + if jp, err := json.Marshal(data); err == nil { b.WriteString(string(jp)) } - continue } } return b.String() } -// styles now provided by theme package - type tickMsg time.Time func (m *chatModel) tick() tea.Cmd { @@ -502,7 +496,6 @@ func (m *chatModel) updateStatus() { } } -// getString safely extracts a string value from a map func getString(m map[string]any, key string) string { if val, ok := m[key]; ok { if str, ok := val.(string); ok { diff --git a/go/core/cli/internal/tui/workspace.go b/go/core/cli/internal/tui/workspace.go index 073dcc63d4..c644e0b25f 100644 --- a/go/core/cli/internal/tui/workspace.go +++ b/go/core/cli/internal/tui/workspace.go @@ -8,6 +8,7 @@ import ( "slices" "strings" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" @@ -17,14 +18,13 @@ import ( "github.com/kagent-dev/kagent/go/api/client" api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/api/v1alpha2" + clia2a "github.com/kagent-dev/kagent/go/core/cli/internal/a2a" "github.com/kagent-dev/kagent/go/core/cli/internal/config" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/dialogs" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/keys" "github.com/kagent-dev/kagent/go/core/cli/internal/tui/theme" "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/internal/version" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) // RunWorkspace launches a split-pane TUI: sessions (left), chat (center), details (toggleable right). @@ -53,7 +53,7 @@ type loadAgentMsg struct { } type sessionSelectedMsg struct{ session *api.Session } type sessionHistoryLoadedMsg struct { - items []*protocol.Task + items []*a2atype.Task err error } type agentChosenMsg struct{ agent api.AgentResponse } @@ -291,14 +291,13 @@ func (m *workspaceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { continue } for _, mmsg := range task.History { - if mmsg.MessageID != "" { - if _, ok := seen[mmsg.MessageID]; ok { + if mmsg.ID != "" { + if _, ok := seen[mmsg.ID]; ok { continue } - seen[mmsg.MessageID] = struct{}{} + seen[mmsg.ID] = struct{}{} } - ev := protocol.StreamingMessageEvent{Result: &mmsg} - m.chat.appendEvent(ev) + m.chat.appendEvent(mmsg) } } } @@ -467,15 +466,13 @@ func (m *workspaceModel) startChat(loadHistory bool) tea.Cmd { a2aPath = "api/a2a-sandboxes" } a2aURL := fmt.Sprintf("%s/%s/%s", m.cfg.KAgentURL, a2aPath, m.agentRef) - client, err := a2aclient.NewA2AClient(a2aURL, - a2aclient.WithTimeout(m.cfg.Timeout), - ) + client, err := clia2a.NewClient(context.Background(), a2aURL, clia2a.ClientOptions{Timeout: m.cfg.Timeout}) if err != nil { m.details.WriteString("\nA2A error\n") return nil } - sendFn := func(ctx context.Context, params protocol.SendMessageParams) (<-chan protocol.StreamingMessageEvent, error) { - return client.StreamMessage(ctx, params) + sendFn := func(ctx context.Context, req *a2atype.SendMessageRequest) (<-chan a2atype.Event, error) { + return clia2a.StreamToChannel(ctx, client, req) } // Reset chat for new session if m.chat == nil { @@ -496,13 +493,20 @@ func (m *workspaceModel) startChat(loadHistory bool) tea.Cmd { func (m *workspaceModel) fetchSessionHistoryCmd(sessionID string) tea.Cmd { return func() tea.Msg { tasksURL := fmt.Sprintf("%s/api/sessions/%s/tasks?user_id=%s", m.cfg.KAgentURL, sessionID, "admin@kagent.dev") - resp, err := http.Get(tasksURL) //nolint:gosec + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, tasksURL, nil) + if err != nil { + return sessionHistoryLoadedMsg{items: nil, err: err} + } + for k, v := range clia2a.V1RequestHeaders() { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) //nolint:gosec if err != nil { return sessionHistoryLoadedMsg{items: nil, err: err} } defer resp.Body.Close() var payload struct { - Data []*protocol.Task `json:"data"` + Data []*a2atype.Task `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return sessionHistoryLoadedMsg{items: nil, err: err} diff --git a/go/core/internal/a2a/a2a_handler_mux.go b/go/core/internal/a2a/a2a_handler_mux.go index 3d4bb7722e..1bd767e41d 100644 --- a/go/core/internal/a2a/a2a_handler_mux.go +++ b/go/core/internal/a2a/a2a_handler_mux.go @@ -3,24 +3,27 @@ package a2a import ( "fmt" "net/http" + "slices" "strings" "sync" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/gorilla/mux" authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" common "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/auth" - "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/server" ) // A2AHandlerMux is an interface that defines methods for adding, getting, and removing agentic task handlers. type A2AHandlerMux interface { SetAgentHandler( agentRef string, - client *client.A2AClient, - card server.AgentCard, - tracing server.Middleware, + client *a2aclient.Client, + card a2atype.AgentCard, + tracing middleware, ) error RemoveAgentHandler( agentRef string, @@ -38,6 +41,10 @@ type handlerMux struct { var _ A2AHandlerMux = &handlerMux{} +type middleware interface { + Wrap(next http.Handler) http.Handler +} + func NewA2AHttpMux(agentPathPrefix, sandboxPathPrefix string, authenticator auth.AuthProvider) *handlerMux { return &handlerMux{ handlers: make(map[string]http.Handler), @@ -49,23 +56,47 @@ func NewA2AHttpMux(agentPathPrefix, sandboxPathPrefix string, authenticator auth func (a *handlerMux) SetAgentHandler( agentRef string, - client *client.A2AClient, - card server.AgentCard, - tracing server.Middleware, + client *a2aclient.Client, + card a2atype.AgentCard, + tracing middleware, ) error { - middlewares := []server.Middleware{authimpl.NewA2AAuthenticator(a.authenticator)} + requestHandler := NewPassthroughRequestHandler(client, &card) + legacyJSONRPCHandler := a2av0.NewJSONRPCHandler(requestHandler) + v1JSONRPCHandler := a2asrv.NewJSONRPCHandler(requestHandler) + cardHandler := a2asrv.NewAgentCardHandler(a2av0.NewStaticAgentCardProducer(&card)) + wellKnownPath := "/" + strings.TrimPrefix(a2asrv.WellKnownAgentCardPath, "/") + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, wellKnownPath) { + cardHandler.ServeHTTP(w, r) + return + } + wireVersion, err := common.NegotiateA2AWireVersion(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch wireVersion { + case common.A2AWireVersionLegacy: + legacyJSONRPCHandler.ServeHTTP(w, r) + case common.A2AWireVersionV1: + v1JSONRPCHandler.ServeHTTP(w, r) + default: + http.Error(w, fmt.Sprintf("unknown negotiated A2A wire version %q", wireVersion), http.StatusBadRequest) + } + }) + middlewares := []middleware{authimpl.NewA2AAuthenticator(a.authenticator)} if tracing != nil { middlewares = append(middlewares, tracing) } - srv, err := server.NewA2AServer(card, NewPassthroughManager(client), server.WithMiddleWare(middlewares...)) - if err != nil { - return fmt.Errorf("failed to create A2A server: %w", err) + for _, middleware := range slices.Backward(middlewares) { + handler = middleware.Wrap(handler) } a.lock.Lock() defer a.lock.Unlock() - a.handlers[agentRef] = srv.Handler() + a.handlers[agentRef] = handler return nil } diff --git a/go/core/internal/a2a/a2a_registrar.go b/go/core/internal/a2a/a2a_registrar.go index f4430e4d3c..3ca16ee9b8 100644 --- a/go/core/internal/a2a/a2a_registrar.go +++ b/go/core/internal/a2a/a2a_registrar.go @@ -6,12 +6,13 @@ import ( "net" "net/http" "reflect" - "time" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" "github.com/go-logr/logr" "github.com/kagent-dev/kagent/go/api/v1alpha2" agent_translator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" - authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" common "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/auth" "github.com/kagent-dev/kagent/go/core/pkg/env" @@ -20,7 +21,6 @@ import ( crcache "sigs.k8s.io/controller-runtime/pkg/cache" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" ) type A2ARegistrar struct { @@ -30,7 +30,6 @@ type A2ARegistrar struct { a2aBaseURL string sandboxA2AURL string authenticator auth.AuthProvider - a2aBaseOptions []a2aclient.Option } var _ manager.Runnable = (*A2ARegistrar)(nil) @@ -42,9 +41,6 @@ func NewA2ARegistrar( a2aBaseUrl string, sandboxA2ABaseURL string, authenticator auth.AuthProvider, - streamingMaxBuf int, - streamingInitialBuf int, - streamingTimeout time.Duration, ) (*A2ARegistrar, error) { if clientRegistry == nil { return nil, fmt.Errorf("clientRegistry must not be nil") @@ -56,11 +52,6 @@ func NewA2ARegistrar( a2aBaseURL: a2aBaseUrl, sandboxA2AURL: sandboxA2ABaseURL, authenticator: authenticator, - a2aBaseOptions: []a2aclient.Option{ - a2aclient.WithTimeout(streamingTimeout), - a2aclient.WithBuffer(streamingInitialBuf, streamingMaxBuf), - debugOpt(), - }, } return reg, nil @@ -168,26 +159,33 @@ func (a *A2ARegistrar) upsertAgentHandler(ctx context.Context, agent v1alpha2.Ag provider := resolveProviderName(ctx, a.cache, agent) - client, err := a2aclient.NewA2AClient( - card.URL, - append( - a.a2aBaseOptions, - a2aclient.WithHTTPReqHandler( - &traceInjectHandler{ - next: authimpl.A2ARequestHandler( - a.authenticator, - agentRef, - ), - }, - ), - )..., + httpClient := debugHTTPClient() + client, err := a2aclient.NewFromEndpoints( + ctx, + card.SupportedInterfaces, + a2aclient.WithJSONRPCTransport(httpClient), + // TODO(0.12.0): Remove the compat transport after legacy runtimes are unsupported. + a2aclient.WithCompatTransport( + a2atype.ProtocolVersion("0.3"), + a2atype.TransportProtocolJSONRPC, + // This creates a legacy JSON-RPC transport that is used to forward traffic to agents that are still on the legacy A2A wire. + a2aclient.TransportFactoryFn(func(_ context.Context, _ *a2atype.AgentCard, iface *a2atype.AgentInterface) (a2aclient.Transport, error) { + return a2av0.NewJSONRPCTransport(a2av0.JSONRPCTransportConfig{ + URL: iface.URL, + Client: httpClient, + }), nil + }), + ), + a2aclient.WithCallInterceptors( + NewUpstreamAuthInterceptor(a.authenticator, agentRef), + ), ) if err != nil { return fmt.Errorf("create A2A client for %s: %w", agentRef, err) } cardCopy := *card - cardCopy.URL = a.a2aRouteURL(agent) + cardCopy.SupportedInterfaces = cloneInterfacesWithURL(card.SupportedInterfaces, a.a2aRouteURL(agent)) routeRef := a2aRouteKey(agent) if err := a.handlerMux.SetAgentHandler(routeRef, client, cardCopy, newA2ATracingMiddleware(agentRef, provider)); err != nil { @@ -200,19 +198,21 @@ func (a *A2ARegistrar) upsertAgentHandler(ctx context.Context, agent v1alpha2.Ag return nil } -func debugOpt() a2aclient.Option { +// debugHTTPClient returns nil in normal operation, letting the a2aclient SDK apply its +// default 3-minute request timeout. In debug mode it overrides the dial target so all +// A2A traffic is redirected to a fixed address (e.g. a local proxy). +func debugHTTPClient() *http.Client { debugAddr := env.KagentA2ADebugAddr.Get() - if debugAddr != "" { - client := new(http.Client) - client.Transport = &http.Transport{ + if debugAddr == "" { + return nil + } + return &http.Client{ + Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { var zeroDialer net.Dialer return zeroDialer.DialContext(ctx, network, debugAddr) }, - } - return a2aclient.WithHTTPClient(client) - } else { - return func(*a2aclient.A2AClient) {} + }, } } @@ -232,3 +232,29 @@ func a2aRoutePath(agent v1alpha2.AgentObject) string { agentRef := types.NamespacedName{Namespace: agent.GetNamespace(), Name: agent.GetName()} return routeKey(agent.GetWorkloadMode() == v1alpha2.WorkloadModeSandbox, agentRef.Namespace, agentRef.Name) } + +// cloneInterfacesWithURL clones the interfaces and sets the URL to the given value. +func cloneInterfacesWithURL(interfaces []*a2atype.AgentInterface, url string) []*a2atype.AgentInterface { + if len(interfaces) == 0 { + return []*a2atype.AgentInterface{ + { + URL: url, + ProtocolBinding: a2atype.TransportProtocolJSONRPC, + ProtocolVersion: a2atype.Version, + }, + } + } + result := make([]*a2atype.AgentInterface, 0, len(interfaces)) + for _, i := range interfaces { + if i == nil { + continue + } + copied := *i + copied.URL = url + if copied.ProtocolVersion == "" { + copied.ProtocolVersion = a2atype.Version + } + result = append(result, &copied) + } + return result +} diff --git a/go/core/internal/a2a/a2a_utils.go b/go/core/internal/a2a/a2a_utils.go index bdd69663a8..99d90aa7d7 100644 --- a/go/core/internal/a2a/a2a_utils.go +++ b/go/core/internal/a2a/a2a_utils.go @@ -3,15 +3,18 @@ package a2a import ( "strings" - "trpc.group/trpc-go/trpc-a2a-go/protocol" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" ) // ExtractText extracts the text content from a message. -func ExtractText(message protocol.Message) string { +func ExtractText(message *a2atype.Message) string { + if message == nil { + return "" + } builder := strings.Builder{} for _, part := range message.Parts { - if textPart, ok := part.(*protocol.TextPart); ok { - builder.WriteString(textPart.Text) + if part != nil { + builder.WriteString(part.Text()) } } return builder.String() diff --git a/go/core/internal/a2a/agent_client_registry.go b/go/core/internal/a2a/agent_client_registry.go index 1e5b7b6bc4..e4b799e36e 100644 --- a/go/core/internal/a2a/agent_client_registry.go +++ b/go/core/internal/a2a/agent_client_registry.go @@ -5,8 +5,8 @@ import ( "fmt" "sync" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" ) // AgentClientRegistry maps agent route keys to their A2A clients. @@ -14,39 +14,32 @@ import ( // agents without an HTTP round trip through the controller's own A2A listener. type AgentClientRegistry struct { mu sync.RWMutex - clients map[string]*a2aclient.A2AClient + clients map[string]*a2aclient.Client } func NewAgentClientRegistry() *AgentClientRegistry { - return &AgentClientRegistry{clients: make(map[string]*a2aclient.A2AClient)} + return &AgentClientRegistry{clients: make(map[string]*a2aclient.Client)} } -// set stores the client under the agent's route key (e.g. "namespace/name" or -// "sandboxes/namespace/name"). -func (r *AgentClientRegistry) set(agentRef string, c *a2aclient.A2AClient) { +func (r *AgentClientRegistry) set(agentRef string, c *a2aclient.Client) { r.mu.Lock() defer r.mu.Unlock() r.clients[agentRef] = c } -// delete removes the client for the given agent route key. func (r *AgentClientRegistry) delete(agentRef string) { r.mu.Lock() defer r.mu.Unlock() delete(r.clients, agentRef) } -// Register adds or replaces the A2A client for the given agent. It is the -// exported counterpart of set, intended for use in tests and explicit -// registrations outside the A2ARegistrar lifecycle. -func (r *AgentClientRegistry) Register(namespace, name string, c *a2aclient.A2AClient) { +// Register adds or replaces the A2A client for the given agent. +func (r *AgentClientRegistry) Register(namespace, name string, c *a2aclient.Client) { r.set(namespace+"/"+name, c) } // SendMessage invokes an agent directly via its cached A2A client. -// namespace and name must identify a non-sandbox agent; sandbox agents use a -// different route key and are not yet reachable via this method. -func (r *AgentClientRegistry) SendMessage(ctx context.Context, namespace, name string, params protocol.SendMessageParams) (*protocol.MessageResult, error) { +func (r *AgentClientRegistry) SendMessage(ctx context.Context, namespace, name string, req *a2atype.SendMessageRequest) (a2atype.SendMessageResult, error) { key := namespace + "/" + name r.mu.RLock() c, ok := r.clients[key] @@ -54,5 +47,5 @@ func (r *AgentClientRegistry) SendMessage(ctx context.Context, namespace, name s if !ok { return nil, fmt.Errorf("agent %s/%s not found or not ready", namespace, name) } - return c.SendMessage(ctx, params) + return c.SendMessage(ctx, req) } diff --git a/go/core/internal/a2a/client_interceptors.go b/go/core/internal/a2a/client_interceptors.go new file mode 100644 index 0000000000..acc902e8ef --- /dev/null +++ b/go/core/internal/a2a/client_interceptors.go @@ -0,0 +1,75 @@ +package a2a + +import ( + "context" + "net/http" + + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + "go.opentelemetry.io/otel/propagation" + "k8s.io/apimachinery/pkg/types" +) + +// staticHeadersInterceptor injects agent-level static headers (e.g. API keys, tenant IDs) +// into every outgoing A2A call. Headers are fixed at construction time so they are never +// re-resolved per request, which makes the interceptor safe for concurrent calls. +// Currently this is only used for testing in invoke_api_test.go +type staticHeadersInterceptor struct { + a2aclient.PassthroughInterceptor + headers map[string]string +} + +func NewStaticHeadersInterceptor(headers map[string]string) a2aclient.CallInterceptor { + return &staticHeadersInterceptor{headers: headers} +} + +func (s *staticHeadersInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { + for k, v := range s.headers { + if v != "" { + req.ServiceParams.Append(k, v) + } + } + return ctx, nil, nil +} + +// upstreamAuthInterceptor applies per-request auth when the controller proxies an A2A call +// to a managed agent. Auth must be evaluated per request because the session principal is only +// available in the call context, not at agent registration time. It also propagates W3C +// TraceContext so distributed traces span across the controller→agent hop without agents +// needing to handle propagation themselves. +type upstreamAuthInterceptor struct { + a2aclient.PassthroughInterceptor + authProvider auth.AuthProvider + agentRef types.NamespacedName +} + +func NewUpstreamAuthInterceptor(authProvider auth.AuthProvider, agentRef types.NamespacedName) a2aclient.CallInterceptor { + return &upstreamAuthInterceptor{ + authProvider: authProvider, + agentRef: agentRef, + } +} + +func (u *upstreamAuthInterceptor) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, req.BaseURL, nil) + if err != nil { + return ctx, nil, err + } + if session, ok := auth.AuthSessionFrom(ctx); ok { + upstreamPrincipal := auth.Principal{ + Agent: auth.Agent{ + ID: u.agentRef.String(), + }, + } + if err := u.authProvider.UpstreamAuth(httpReq, session, upstreamPrincipal); err != nil { + return ctx, nil, err + } + } + propagation.TraceContext{}.Inject(ctx, propagation.HeaderCarrier(httpReq.Header)) + for k, values := range httpReq.Header { + for _, value := range values { + req.ServiceParams.Append(k, value) + } + } + return ctx, nil, nil +} diff --git a/go/core/internal/a2a/client_interceptors_test.go b/go/core/internal/a2a/client_interceptors_test.go new file mode 100644 index 0000000000..bf76addd6c --- /dev/null +++ b/go/core/internal/a2a/client_interceptors_test.go @@ -0,0 +1,56 @@ +package a2a + +import ( + "context" + "testing" + + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + "k8s.io/apimachinery/pkg/types" +) + +func TestUpstreamAuthInterceptor_InjectsTraceContext(t *testing.T) { + const rawTraceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + + ctx := propagation.TraceContext{}.Extract( + context.Background(), + propagation.MapCarrier{"traceparent": rawTraceparent}, + ) + + req := &a2aclient.Request{ + BaseURL: "http://agent.default:8080", + ServiceParams: a2aclient.ServiceParams{}, + } + interceptor := NewUpstreamAuthInterceptor(nil, types.NamespacedName{}) + if _, _, err := interceptor.Before(ctx, req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + gotValues := req.ServiceParams.Get("traceparent") + if len(gotValues) == 0 { + t.Fatal("expected traceparent service param on outgoing request, got none") + } + + outCtx := propagation.TraceContext{}.Extract(context.Background(), propagation.MapCarrier{"traceparent": gotValues[0]}) + wantTraceID := trace.SpanContextFromContext(ctx).TraceID() + gotTraceID := trace.SpanContextFromContext(outCtx).TraceID() + if wantTraceID != gotTraceID { + t.Errorf("trace ID: want %s, got %s", wantTraceID, gotTraceID) + } +} + +func TestUpstreamAuthInterceptor_NoTraceContext(t *testing.T) { + req := &a2aclient.Request{ + BaseURL: "http://agent.default:8080", + ServiceParams: a2aclient.ServiceParams{}, + } + interceptor := NewUpstreamAuthInterceptor(nil, types.NamespacedName{}) + if _, _, err := interceptor.Before(context.Background(), req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := req.ServiceParams.Get("traceparent"); len(got) != 0 { + t.Errorf("expected no traceparent service param, got %q", got) + } +} diff --git a/go/core/internal/a2a/manager.go b/go/core/internal/a2a/manager.go deleted file mode 100644 index 97141e4a0e..0000000000 --- a/go/core/internal/a2a/manager.go +++ /dev/null @@ -1,59 +0,0 @@ -package a2a - -import ( - "context" - - "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" - "trpc.group/trpc-go/trpc-a2a-go/taskmanager" -) - -type PassthroughManager struct { - client *client.A2AClient -} - -func NewPassthroughManager(client *client.A2AClient) taskmanager.TaskManager { - return &PassthroughManager{ - client: client, - } -} - -func (m *PassthroughManager) OnSendMessage(ctx context.Context, request protocol.SendMessageParams) (*protocol.MessageResult, error) { - if request.Message.MessageID == "" { - request.Message.MessageID = protocol.GenerateMessageID() - } - if request.Message.Kind == "" { - request.Message.Kind = protocol.KindMessage - } - return m.client.SendMessage(ctx, request) -} - -func (m *PassthroughManager) OnSendMessageStream(ctx context.Context, request protocol.SendMessageParams) (<-chan protocol.StreamingMessageEvent, error) { - if request.Message.MessageID == "" { - request.Message.MessageID = protocol.GenerateMessageID() - } - if request.Message.Kind == "" { - request.Message.Kind = protocol.KindMessage - } - return m.client.StreamMessage(ctx, request) -} - -func (m *PassthroughManager) OnGetTask(ctx context.Context, params protocol.TaskQueryParams) (*protocol.Task, error) { - return m.client.GetTasks(ctx, params) -} - -func (m *PassthroughManager) OnCancelTask(ctx context.Context, params protocol.TaskIDParams) (*protocol.Task, error) { - return m.client.CancelTasks(ctx, params) -} - -func (m *PassthroughManager) OnPushNotificationSet(ctx context.Context, params protocol.TaskPushNotificationConfig) (*protocol.TaskPushNotificationConfig, error) { - return m.client.SetPushNotification(ctx, params) -} - -func (m *PassthroughManager) OnPushNotificationGet(ctx context.Context, params protocol.TaskIDParams) (*protocol.TaskPushNotificationConfig, error) { - return m.client.GetPushNotification(ctx, params) -} - -func (m *PassthroughManager) OnResubscribe(ctx context.Context, params protocol.TaskIDParams) (<-chan protocol.StreamingMessageEvent, error) { - return m.client.ResubscribeTask(ctx, params) -} diff --git a/go/core/internal/a2a/passthrough_handler.go b/go/core/internal/a2a/passthrough_handler.go new file mode 100644 index 0000000000..a9a412321e --- /dev/null +++ b/go/core/internal/a2a/passthrough_handler.go @@ -0,0 +1,79 @@ +package a2a + +import ( + "context" + "iter" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2asrv" +) + +type PassthroughRequestHandler struct { + client *a2aclient.Client + card *a2atype.AgentCard +} + +var _ a2asrv.RequestHandler = (*PassthroughRequestHandler)(nil) + +// NewPassthroughRequestHandler returns a transport-level proxy for controller +// A2A endpoints. It delegates each request directly to the selected upstream +// agent client and intentionally bypasses a2asrv.NewHandler, which would create +// local task state and apply v1 task-processing invariants to legacy streams. +func NewPassthroughRequestHandler(client *a2aclient.Client, card *a2atype.AgentCard) *PassthroughRequestHandler { + return &PassthroughRequestHandler{ + client: client, + card: card, + } +} + +func (h *PassthroughRequestHandler) GetTask(ctx context.Context, req *a2atype.GetTaskRequest) (*a2atype.Task, error) { + return h.client.GetTask(ctx, req) +} + +func (h *PassthroughRequestHandler) ListTasks(ctx context.Context, req *a2atype.ListTasksRequest) (*a2atype.ListTasksResponse, error) { + return h.client.ListTasks(ctx, req) +} + +func (h *PassthroughRequestHandler) CancelTask(ctx context.Context, req *a2atype.CancelTaskRequest) (*a2atype.Task, error) { + return h.client.CancelTask(ctx, req) +} + +func (h *PassthroughRequestHandler) SendMessage(ctx context.Context, req *a2atype.SendMessageRequest) (a2atype.SendMessageResult, error) { + return h.client.SendMessage(ctx, req) +} + +func (h *PassthroughRequestHandler) SubscribeToTask(ctx context.Context, req *a2atype.SubscribeToTaskRequest) iter.Seq2[a2atype.Event, error] { + return h.client.SubscribeToTask(ctx, req) +} + +func (h *PassthroughRequestHandler) SendStreamingMessage(ctx context.Context, req *a2atype.SendMessageRequest) iter.Seq2[a2atype.Event, error] { + return h.client.SendStreamingMessage(ctx, req) +} + +func (h *PassthroughRequestHandler) GetTaskPushConfig(ctx context.Context, req *a2atype.GetTaskPushConfigRequest) (*a2atype.PushConfig, error) { + return h.client.GetTaskPushConfig(ctx, req) +} + +func (h *PassthroughRequestHandler) ListTaskPushConfigs(ctx context.Context, req *a2atype.ListTaskPushConfigRequest) (*a2atype.ListTaskPushConfigResponse, error) { + configs, err := h.client.ListTaskPushConfigs(ctx, req) + if err != nil { + return nil, err + } + return &a2atype.ListTaskPushConfigResponse{Configs: configs}, nil +} + +func (h *PassthroughRequestHandler) CreateTaskPushConfig(ctx context.Context, req *a2atype.PushConfig) (*a2atype.PushConfig, error) { + return h.client.CreateTaskPushConfig(ctx, req) +} + +func (h *PassthroughRequestHandler) DeleteTaskPushConfig(ctx context.Context, req *a2atype.DeleteTaskPushConfigRequest) error { + return h.client.DeleteTaskPushConfig(ctx, req) +} + +func (h *PassthroughRequestHandler) GetExtendedAgentCard(ctx context.Context, req *a2atype.GetExtendedAgentCardRequest) (*a2atype.AgentCard, error) { + if h.card != nil && !h.card.Capabilities.ExtendedAgentCard { + return h.card, nil + } + return h.client.GetExtendedAgentCard(ctx, req) +} diff --git a/go/core/internal/a2a/trace.go b/go/core/internal/a2a/trace.go index bf8d7c94ad..943407e1d8 100644 --- a/go/core/internal/a2a/trace.go +++ b/go/core/internal/a2a/trace.go @@ -6,20 +6,18 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/propagation" semconv "go.opentelemetry.io/otel/semconv/v1.39.0" "go.opentelemetry.io/otel/trace" "k8s.io/apimachinery/pkg/types" crcache "sigs.k8s.io/controller-runtime/pkg/cache" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" "github.com/kagent-dev/kagent/go/api/v1alpha2" ) // a2aTracingMiddleware is an A2A server middleware that creates an invoke_agent // span for each inbound A2A request, annotated with GenAI semantic convention -// attributes. The span becomes the parent of any outbound proxy calls made by -// traceInjectHandler, giving a clean agent-invocation span hierarchy in Jaeger. +// attributes. Outbound client interceptors inject that span into proxied agent +// calls, giving a clean agent-invocation span hierarchy in Jaeger. type a2aTracingMiddleware struct { agentRef types.NamespacedName provider attribute.KeyValue @@ -45,18 +43,6 @@ func (m *a2aTracingMiddleware) Wrap(next http.Handler) http.Handler { }) } -// traceInjectHandler wraps an HTTPReqHandler and injects W3C TraceContext -// headers (traceparent, tracestate) from the Go context into every outgoing -// proxy request, so the downstream agent receives the active span as its parent. -type traceInjectHandler struct { - next a2aclient.HTTPReqHandler -} - -func (h *traceInjectHandler) Handle(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) { - propagation.TraceContext{}.Inject(ctx, propagation.HeaderCarrier(req.Header)) - return h.next.Handle(ctx, client, req) -} - // resolveProviderName looks up the ModelConfig for a declarative agent and // returns the corresponding gen_ai.provider.name attribute. Falls back to "kagent" // for BYO agents or if the ModelConfig cannot be fetched. diff --git a/go/core/internal/a2a/trace_test.go b/go/core/internal/a2a/trace_test.go index a7ee45c482..3e7c8de698 100644 --- a/go/core/internal/a2a/trace_test.go +++ b/go/core/internal/a2a/trace_test.go @@ -7,68 +7,12 @@ import ( "testing" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/propagation" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" semconv "go.opentelemetry.io/otel/semconv/v1.39.0" - "go.opentelemetry.io/otel/trace" "k8s.io/apimachinery/pkg/types" ) -// mockHTTPReqHandler captures the request passed to Handle for inspection. -type mockHTTPReqHandler struct { - capturedReq *http.Request -} - -func (m *mockHTTPReqHandler) Handle(_ context.Context, _ *http.Client, req *http.Request) (*http.Response, error) { - m.capturedReq = req - return &http.Response{StatusCode: http.StatusOK}, nil -} - -func TestTraceInjectHandler_InjectsHeader(t *testing.T) { - const rawTraceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" - - ctx := propagation.TraceContext{}.Extract( - context.Background(), - propagation.MapCarrier{"traceparent": rawTraceparent}, - ) - - mock := &mockHTTPReqHandler{} - h := &traceInjectHandler{next: mock} - - req := httptest.NewRequest(http.MethodPost, "/", nil) - if _, err := h.Handle(ctx, nil, req); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - got := mock.capturedReq.Header.Get("traceparent") - if got == "" { - t.Fatal("expected traceparent header on outgoing request, got none") - } - - // The injected header must carry the same trace ID as the incoming context. - outCtx := propagation.TraceContext{}.Extract(context.Background(), propagation.HeaderCarrier(mock.capturedReq.Header)) - wantTraceID := trace.SpanContextFromContext(ctx).TraceID() - gotTraceID := trace.SpanContextFromContext(outCtx).TraceID() - if wantTraceID != gotTraceID { - t.Errorf("trace ID: want %s, got %s", wantTraceID, gotTraceID) - } -} - -func TestTraceInjectHandler_NoHeaderWhenNoTrace(t *testing.T) { - mock := &mockHTTPReqHandler{} - h := &traceInjectHandler{next: mock} - - req := httptest.NewRequest(http.MethodPost, "/", nil) - if _, err := h.Handle(context.Background(), nil, req); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if got := mock.capturedReq.Header.Get("traceparent"); got != "" { - t.Errorf("expected no traceparent header, got %q", got) - } -} - func TestA2ATracingMiddleware_SetsGenAIAttributes(t *testing.T) { exporter := tracetest.NewInMemoryExporter() tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) diff --git a/go/core/internal/controller/translator/agent/compiler.go b/go/core/internal/controller/translator/agent/compiler.go index 0232a859e6..c0f22eee8b 100644 --- a/go/core/internal/controller/translator/agent/compiler.go +++ b/go/core/internal/controller/translator/agent/compiler.go @@ -5,10 +5,10 @@ import ( "fmt" "slices" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/utils" - "trpc.group/trpc-go/trpc-a2a-go/server" ) // AgentManifestInputs holds the translated data needed to emit Kubernetes resources. @@ -16,7 +16,7 @@ type AgentManifestInputs struct { Config *adk.AgentConfig Sandbox *v1alpha2.SandboxConfig Deployment *resolvedDeployment - AgentCard *server.AgentCard + AgentCard *a2a.AgentCard SecretHashBytes []byte } diff --git a/go/core/internal/controller/translator/agent/manifest_builder.go b/go/core/internal/controller/translator/agent/manifest_builder.go index 3b059bbef5..19afc7f5c4 100644 --- a/go/core/internal/controller/translator/agent/manifest_builder.go +++ b/go/core/internal/controller/translator/agent/manifest_builder.go @@ -6,6 +6,7 @@ import ( "fmt" "maps" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/controller/translator/labels" @@ -18,7 +19,6 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "trpc.group/trpc-go/trpc-a2a-go/server" ) type manifestContext struct { @@ -128,7 +128,7 @@ func (a *adkApiTranslator) buildConfigSecret( manifestCtx manifestContext, cfg *adk.AgentConfig, sandboxCfg *v1alpha2.SandboxConfig, - card *server.AgentCard, + card *a2a.AgentCard, modelConfigSecretHashBytes []byte, ) (*configSecretInputs, error) { cfgJSON := "" @@ -146,11 +146,11 @@ func (a *adkApiTranslator) buildConfigSecret( cfgJSON = string(bCfg) } if card != nil { - bCard, err := json.Marshal(card) + cardJSON, err := json.Marshal(card) if err != nil { return nil, err } - agentCard = string(bCard) + agentCard = string(cardJSON) } if needsSRTSettings(manifestCtx.agent, sandboxCfg) { bSRTSettings, err := buildSRTSettingsJSON(sandboxCfg) diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_a2a_config.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_a2a_config.json index 3a467bf506..ab62c2c46a 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_a2a_config.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_a2a_config.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,7 +11,6 @@ ], "description": "", "name": "a2a_agent", - "preferredTransport": "JSONRPC", "skills": [ { "description": "Summarizes text", @@ -21,7 +18,18 @@ "tags": null } ], - "url": "http://a2a-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://a2a-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://a2a-agent.test:8080" + } + ], "version": "" }, "config": { @@ -60,7 +68,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"a2a_agent\",\"description\":\"\",\"url\":\"http://a2a-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[{\"id\":\"summarize\",\"name\":\"Summarize\",\"description\":\"Summarizes text\",\"tags\":null}],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://a2a-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://a2a-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"a2a_agent\",\"skills\":[{\"description\":\"Summarizes text\",\"id\":\"summarize\",\"name\":\"Summarize\",\"tags\":null}],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -130,7 +138,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "16405455094195710426" + "kagent.dev/config-hash": "14615753446055302326" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_allowed_headers.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_allowed_headers.json index 6c94d2f86d..16f9373e03 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_allowed_headers.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_allowed_headers.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent.test:8080" + } + ], "version": "" }, "config": { @@ -70,7 +78,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent\",\"description\":\"\",\"url\":\"http://agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"http_tools\":[{\"params\":{\"url\":\"http://mcp-server.test:8080/mcp\",\"headers\":{}},\"tools\":[\"tool1\",\"tool2\"],\"allowed_headers\":[\"x-user-email\",\"x-tenant-id\"]}],\"stream\":false}" } }, @@ -140,7 +148,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "5387783918115122395" + "kagent.dev/config-hash": "16269820581163860186" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_code.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_code.json index 597478474b..df1cd717f8 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_code.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_code.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_code", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-code.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-code.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-code.test:8080" + } + ], "version": "" }, "config": { @@ -62,7 +70,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_code\",\"description\":\"\",\"url\":\"http://agent-with-code.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-code.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-code.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_code\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"execute_code\":true,\"stream\":false}", "srt-settings.json": "{\"filesystem\":{\"allowWrite\":[\".\",\"/tmp\"],\"denyRead\":[],\"denyWrite\":[]},\"network\":{\"allowedDomains\":[],\"deniedDomains\":[]}}" } @@ -133,7 +141,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "17724008177186270332" + "kagent.dev/config-hash": "13760545813001232268" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_context_config.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_context_config.json index 1ac7356e6d..e16da08dc6 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_context_config.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_context_config.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "Agent with context management", "name": "agent_with_context", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-context.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-context.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-context.test:8080" + } + ], "version": "" }, "config": { @@ -72,7 +80,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_context\",\"description\":\"Agent with context management\",\"url\":\"http://agent-with-context.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-context.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-context.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"Agent with context management\",\"name\":\"agent_with_context\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"Agent with context management\",\"instruction\":\"You are a helpful assistant with context management enabled.\",\"stream\":false,\"context_config\":{\"compaction\":{\"compaction_interval\":5,\"overlap_size\":2,\"summarizer_model\":{\"type\":\"anthropic\",\"model\":\"claude-3-haiku\"},\"prompt_template\":\"Summarize the following conversation events concisely.\",\"token_threshold\":50000,\"event_retention_size\":10}}}" } }, @@ -142,7 +150,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "16891892632874106599" + "kagent.dev/config-hash": "499782941718855709" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_cross_namespace_tools.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_cross_namespace_tools.json index edaedd9719..4fd63133b4 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_cross_namespace_tools.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_cross_namespace_tools.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "An agent that uses cross-namespace tools", "name": "source_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://source-agent.source-ns:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://source-agent.source-ns:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://source-agent.source-ns:8080" + } + ], "version": "" }, "config": { @@ -76,7 +84,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"source_agent\",\"description\":\"An agent that uses cross-namespace tools\",\"url\":\"http://source-agent.source-ns:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://source-agent.source-ns:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://source-agent.source-ns:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"An agent that uses cross-namespace tools\",\"name\":\"source_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"An agent that uses cross-namespace tools\",\"instruction\":\"You are an assistant with access to shared tools.\",\"http_tools\":[{\"params\":{\"url\":\"http://tools.tools-ns.svc:8080/mcp\",\"headers\":{\"Authorization\":\"tool-secret-token\"},\"timeout\":30},\"tools\":[\"list_resources\",\"get_resource\"]}],\"remote_agents\":[{\"name\":\"tools_ns__NS__tools_agent\",\"url\":\"http://tools-agent.tools-ns:8080\",\"description\":\"An agent that can be used as a cross-namespace tool\"}],\"stream\":false}" } }, @@ -146,7 +154,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "6527476808794662414" + "kagent.dev/config-hash": "15797809111184542787" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_custom_sa.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_custom_sa.json index d2ecefd3a6..2bd119ad18 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_custom_sa.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_custom_sa.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_custom_sa", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-custom-sa.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-custom-sa.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-custom-sa.test:8080" + } + ], "version": "" }, "config": { @@ -54,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_custom_sa\",\"description\":\"\",\"url\":\"http://agent-with-custom-sa.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-custom-sa.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-custom-sa.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_custom_sa\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -99,7 +107,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "13887560842969010876" + "kagent.dev/config-hash": "10485794827683908468" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_default_sa.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_default_sa.json index 19548c5d01..98512b82a3 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_default_sa.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_default_sa.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_default_sa", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-default-sa.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-default-sa.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-default-sa.test:8080" + } + ], "version": "" }, "config": { @@ -54,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_default_sa\",\"description\":\"\",\"url\":\"http://agent-with-default-sa.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-default-sa.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-default-sa.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_default_sa\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -99,7 +107,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "8931708887213057908" + "kagent.dev/config-hash": "5199784065421269511" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_embedding_provider.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_embedding_provider.json index f2a9de95bf..fa7e5ed271 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_embedding_provider.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_embedding_provider.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_cross_provider_memory", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-cross-provider-memory.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-cross-provider-memory.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-cross-provider-memory.test:8080" + } + ], "version": "" }, "config": { @@ -60,7 +68,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_cross_provider_memory\",\"description\":\"\",\"url\":\"http://agent-cross-provider-memory.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-cross-provider-memory.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-cross-provider-memory.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_cross_provider_memory\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an assistant.\",\"stream\":false,\"memory\":{\"embedding\":{\"provider\":\"gemini_vertex_ai\",\"model\":\"text-embedding-005\"}}}" } }, @@ -130,7 +138,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "10448813021284432574" + "kagent.dev/config-hash": "15901675277527457660" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_extra_containers.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_extra_containers.json index 6f9d1fa0bf..d9744ddbe5 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_extra_containers.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_extra_containers.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_extra_containers", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-extra-containers.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-extra-containers.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-extra-containers.test:8080" + } + ], "version": "" }, "config": { @@ -54,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_extra_containers\",\"description\":\"\",\"url\":\"http://agent-with-extra-containers.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-extra-containers.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-extra-containers.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_extra_containers\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -124,7 +132,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "6067225395749761191" + "kagent.dev/config-hash": "6471144613458889352" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json index 98cc97beab..d60a6eefdf 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "git_skills_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://git-skills-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://git-skills-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://git-skills-agent.test:8080" + } + ], "version": "" }, "config": { @@ -61,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"git_skills_agent\",\"description\":\"\",\"url\":\"http://git-skills-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://git-skills-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://git-skills-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"git_skills_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant with skills from git.\",\"stream\":false}", "srt-settings.json": "{\"filesystem\":{\"allowWrite\":[\".\",\"/tmp\"],\"denyRead\":[],\"denyWrite\":[]},\"network\":{\"allowedDomains\":[],\"deniedDomains\":[]}}" } @@ -132,7 +140,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "9443054578640766875" + "kagent.dev/config-hash": "14672456712258872975" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json index 838ef2dbc2..a0dd115d21 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_http_toolserver.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent.test:8080" + } + ], "version": "" }, "config": { @@ -69,7 +77,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent\",\"description\":\"\",\"url\":\"http://agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a math toolserver. Focus on solving mathematical problems step by step.\",\"http_tools\":[{\"params\":{\"url\":\"http://localhost:8084/mcp\",\"headers\":{\"MATH\":\"sk-test-api-key\"},\"timeout\":30,\"sse_read_timeout\":300},\"tools\":[\"k8s_get_resources\"]}],\"stream\":false}" } }, @@ -139,7 +147,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "4534869863837852487" + "kagent.dev/config-hash": "18136359414159044696" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json index 852ebeeafd..3989c705a2 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_mcp_service.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent.test:8080" + } + ], "version": "" }, "config": { @@ -65,7 +73,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent\",\"description\":\"\",\"url\":\"http://agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a math toolserver. Focus on solving mathematical problems step by step.\",\"http_tools\":[{\"params\":{\"url\":\"http://toolserver.test:8084/mcp\",\"headers\":{}},\"tools\":[\"k8s_get_resources\"]}],\"stream\":false}" } }, @@ -135,7 +143,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "12595169530626754046" + "kagent.dev/config-hash": "16462134276610745687" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_memory.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_memory.json index c8b70f08b4..5aa1832bd5 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_memory.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_memory.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_memory", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-memory.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-memory.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-memory.test:8080" + } + ], "version": "" }, "config": { @@ -65,7 +73,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_memory\",\"description\":\"\",\"url\":\"http://agent-with-memory.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-memory.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-memory.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_memory\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant with memory. Save important findings and use past context when relevant.\",\"stream\":false,\"memory\":{\"embedding\":{\"provider\":\"openai\",\"model\":\"text-embedding-3-small\"}}}" } }, @@ -135,7 +143,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "11340664467578377382" + "kagent.dev/config-hash": "13924285912897494970" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json index caba32bec9..9eaa756965 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_nested_agent.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "parent_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://parent-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://parent-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://parent-agent.test:8080" + } + ], "version": "" }, "config": { @@ -63,7 +71,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"parent_agent\",\"description\":\"\",\"url\":\"http://parent-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://parent-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://parent-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"parent_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a coordinating agent that can delegate tasks to specialists.\",\"remote_agents\":[{\"name\":\"test__NS__specialist_agent\",\"url\":\"http://specialist-agent.test:8080\",\"headers\":{\"FOO\":\"sup3rs3cr3t\"}}],\"stream\":false}" } }, @@ -133,7 +141,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "963887663212034528" + "kagent.dev/config-hash": "9621822481042047309" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_passthrough.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_passthrough.json index 0a9a7fcda8..3272f322ce 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_passthrough.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_passthrough.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "passthrough_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://passthrough-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://passthrough-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://passthrough-agent.test:8080" + } + ], "version": "" }, "config": { @@ -56,7 +64,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"passthrough_agent\",\"description\":\"\",\"url\":\"http://passthrough-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://passthrough-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://passthrough-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"passthrough_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"api_key_passthrough\":true,\"base_url\":\"\",\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -126,7 +134,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "2436412068147442351" + "kagent.dev/config-hash": "9280821291572766210" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_prompt_template.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_prompt_template.json index e13ba958fe..abdeead23b 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_prompt_template.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_prompt_template.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "A Kubernetes troubleshooting agent", "name": "agent_with_prompt_template", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-prompt-template.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-prompt-template.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-prompt-template.test:8080" + } + ], "version": "" }, "config": { @@ -67,7 +75,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_prompt_template\",\"description\":\"A Kubernetes troubleshooting agent\",\"url\":\"http://agent-with-prompt-template.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-prompt-template.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-prompt-template.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"A Kubernetes troubleshooting agent\",\"name\":\"agent_with_prompt_template\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"A Kubernetes troubleshooting agent\",\"instruction\":\"## Preamble\\nYou are a helpful Kubernetes assistant.\\n\\n\\nYou are agent-with-prompt-template, operating in test.\\nYour purpose: A Kubernetes troubleshooting agent\\n\\nAvailable tools: k8s_get_resources, k8s_describe_resource, \\n\\n## Safety Guidelines\\nNever delete resources without explicit user confirmation.\\n\\n\",\"http_tools\":[{\"params\":{\"url\":\"http://localhost:8084/mcp\",\"headers\":{},\"timeout\":30},\"tools\":[\"k8s_get_resources\",\"k8s_describe_resource\"]}],\"stream\":false}" } }, @@ -137,7 +145,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "17922600608856796760" + "kagent.dev/config-hash": "567473640196735869" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json index 901ce90339..c6a739d193 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_proxy", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-proxy.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-proxy.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-proxy.test:8080" + } + ], "version": "" }, "config": { @@ -76,7 +84,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_proxy\",\"description\":\"\",\"url\":\"http://agent-with-proxy.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"x-kagent-host\":\"nested-agent.test\"}}],\"stream\":false}" } }, @@ -146,7 +154,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1327907664326337797" + "kagent.dev/config-hash": "11893707133067697787" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json index 53cf7f4bc7..8cb667a74d 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_proxy_external", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-proxy-external.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-proxy-external.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-proxy-external.test:8080" + } + ], "version": "" }, "config": { @@ -65,7 +73,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_proxy_external\",\"description\":\"\",\"url\":\"http://agent-with-proxy-external.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy-external.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy-external.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy_external\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"https://external-mcp.example.com/mcp\",\"headers\":{}},\"tools\":[\"test-tool\"]}],\"stream\":false}" } }, @@ -135,7 +143,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "8764617675044151097" + "kagent.dev/config-hash": "9390285492022682518" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json index e80a0bf78e..c356088b35 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_proxy_mcpserver", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-proxy-mcpserver.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-proxy-mcpserver.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-proxy-mcpserver.test:8080" + } + ], "version": "" }, "config": { @@ -68,7 +76,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_proxy_mcpserver\",\"description\":\"\",\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy_mcpserver\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"test-mcp-server.test\"},\"timeout\":30},\"tools\":[\"test-tool\"]}],\"stream\":false}" } }, @@ -138,7 +146,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "17237679247457206561" + "kagent.dev/config-hash": "18073633349913596204" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver_custom_timeout.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver_custom_timeout.json index 150bc253d0..b740d4644c 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver_custom_timeout.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver_custom_timeout.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_proxy_mcpserver_timeout", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-proxy-mcpserver-timeout.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-proxy-mcpserver-timeout.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-proxy-mcpserver-timeout.test:8080" + } + ], "version": "" }, "config": { @@ -68,7 +76,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_proxy_mcpserver_timeout\",\"description\":\"\",\"url\":\"http://agent-with-proxy-mcpserver-timeout.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy-mcpserver-timeout.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy-mcpserver-timeout.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy_mcpserver_timeout\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"test-mcp-server.test\"},\"timeout\":60},\"tools\":[\"test-tool\"]}],\"stream\":false}" } }, @@ -138,7 +146,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "7011830610500130414" + "kagent.dev/config-hash": "16803121309212337797" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json index 0010bc16d9..3066e8dc50 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_proxy_service", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-proxy-service.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-proxy-service.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-proxy-service.test:8080" + } + ], "version": "" }, "config": { @@ -67,7 +75,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_proxy_service\",\"description\":\"\",\"url\":\"http://agent-with-proxy-service.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-proxy-service.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-proxy-service.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_proxy_service\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"stream\":false}" } }, @@ -137,7 +145,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "17657014285076291438" + "kagent.dev/config-hash": "3987687246013681331" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_require_approval.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_require_approval.json index e48937b0f2..da0d5d9047 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_require_approval.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_require_approval.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent.test:8080" + } + ], "version": "" }, "config": { @@ -71,7 +79,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent\",\"description\":\"\",\"url\":\"http://agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You help users manage files.\",\"http_tools\":[{\"params\":{\"url\":\"http://toolserver.test:8084/mcp\",\"headers\":{}},\"tools\":[\"read_file\",\"write_file\",\"delete_file\"],\"require_approval\":[\"delete_file\",\"write_file\"]}],\"stream\":false}" } }, @@ -141,7 +149,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "2233830583481326333" + "kagent.dev/config-hash": "7171063378866703186" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_scheduling_attributes.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_scheduling_attributes.json index c1f32a0921..5638102bf2 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_scheduling_attributes.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_scheduling_attributes.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_scheduling_attributes", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-scheduling-attributes.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-scheduling-attributes.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-scheduling-attributes.test:8080" + } + ], "version": "" }, "config": { @@ -61,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_scheduling_attributes\",\"description\":\"\",\"url\":\"http://agent-with-scheduling-attributes.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-scheduling-attributes.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-scheduling-attributes.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_scheduling_attributes\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -131,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "7052837506672819306" + "kagent.dev/config-hash": "13449223961033623786" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_security_context.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_security_context.json index b1372c0b3b..bbc3654ee1 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_security_context.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_security_context.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_security_context", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-security-context.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-security-context.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-security-context.test:8080" + } + ], "version": "" }, "config": { @@ -61,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_security_context\",\"description\":\"\",\"url\":\"http://agent-with-security-context.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-security-context.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-security-context.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_security_context\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -131,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "3665823864130830967" + "kagent.dev/config-hash": "14133779909752802977" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json index e65fa19d57..20f4ed4a18 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "skills_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://skills-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://skills-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://skills-agent.test:8080" + } + ], "version": "" }, "config": { @@ -61,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"skills_agent\",\"description\":\"\",\"url\":\"http://skills-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://skills-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://skills-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"skills_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}", "srt-settings.json": "{\"filesystem\":{\"allowWrite\":[\".\",\"/tmp\"],\"denyRead\":[],\"denyWrite\":[]},\"network\":{\"allowedDomains\":[],\"deniedDomains\":[]}}" } @@ -132,7 +140,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "4496754913718271849" + "kagent.dev/config-hash": "7512006228141610661" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_streaming.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_streaming.json index e0d08cd124..5acc570174 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_streaming.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_streaming.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "basic_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://basic-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://basic-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://basic-agent.test:8080" + } + ], "version": "" }, "config": { @@ -61,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"basic_agent\",\"description\":\"\",\"url\":\"http://basic-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://basic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://basic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"basic_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":true}" } }, @@ -131,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "4834502221619930746" + "kagent.dev/config-hash": "14170329863092758119" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json index de6ed10545..40b52f1e01 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_configmap.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_configmap_system_message", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-configmap-system-message.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-configmap-system-message.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-configmap-system-message.test:8080" + } + ], "version": "" }, "config": { @@ -54,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_configmap_system_message\",\"description\":\"\",\"url\":\"http://agent-with-configmap-system-message.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-configmap-system-message.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-configmap-system-message.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_configmap_system_message\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"Speak in the style of Shakespeare.\",\"stream\":false}" } }, @@ -124,7 +132,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "6732733949178246074" + "kagent.dev/config-hash": "4606283336682815811" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json index a03bbbea85..3d37d08997 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_system_message_from_secret.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "agent_with_secret_system_message", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://agent-with-secret-system-message.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://agent-with-secret-system-message.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://agent-with-secret-system-message.test:8080" + } + ], "version": "" }, "config": { @@ -54,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"agent_with_secret_system_message\",\"description\":\"\",\"url\":\"http://agent-with-secret-system-message.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://agent-with-secret-system-message.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://agent-with-secret-system-message.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"agent_with_secret_system_message\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You will speak in the style of Shakespeare.\\n\",\"stream\":false}" } }, @@ -124,7 +132,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1798322143389478148" + "kagent.dev/config-hash": "14985943387941658931" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json index b17be6ab43..f5c2f09d87 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/anthropic_agent.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "anthropic_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://anthropic-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://anthropic-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://anthropic-agent.test:8080" + } + ], "version": "" }, "config": { @@ -53,7 +61,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"anthropic_agent\",\"description\":\"\",\"url\":\"http://anthropic-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://anthropic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://anthropic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"anthropic_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"anthropic\",\"model\":\"claude-3-sonnet-20240229\"},\"description\":\"\",\"instruction\":\"You are Claude, an AI assistant created by Anthropic.\",\"stream\":false}" } }, @@ -123,7 +131,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "12361184581110359614" + "kagent.dev/config-hash": "7371281520668963991" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/basic_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/basic_agent.json index 81915807b9..763b964ec2 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/basic_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/basic_agent.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "basic_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://basic-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://basic-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://basic-agent.test:8080" + } + ], "version": "" }, "config": { @@ -61,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"basic_agent\",\"description\":\"\",\"url\":\"http://basic-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://basic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://basic-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"basic_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant.\",\"stream\":false}" } }, @@ -131,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "7118705820066737230" + "kagent.dev/config-hash": "10516081583410512" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/bedrock_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/bedrock_agent.json index c610f58ca2..b06ecf30ba 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/bedrock_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/bedrock_agent.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "bedrock_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://bedrock-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://bedrock-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://bedrock-agent.test:8080" + } + ], "version": "" }, "config": { @@ -54,7 +62,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"bedrock_agent\",\"description\":\"\",\"url\":\"http://bedrock-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://bedrock-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://bedrock-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"bedrock_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"bedrock\",\"model\":\"us.anthropic.claude-sonnet-4-20250514-v1:0\",\"region\":\"us-east-1\"},\"description\":\"\",\"instruction\":\"You are a helpful AI assistant running on AWS Bedrock.\",\"stream\":false}" } }, @@ -124,7 +132,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "11640375396581880325" + "kagent.dev/config-hash": "17339702527694201771" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/ollama_agent.json b/go/core/internal/controller/translator/agent/testdata/outputs/ollama_agent.json index 02a0ddae91..63a3590246 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/ollama_agent.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/ollama_agent.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "ollama_agent", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://ollama-agent.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://ollama-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://ollama-agent.test:8080" + } + ], "version": "" }, "config": { @@ -61,7 +69,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"ollama_agent\",\"description\":\"\",\"url\":\"http://ollama-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://ollama-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://ollama-agent.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"ollama_agent\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"ollama\",\"model\":\"llama3.2:latest\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"options\":{\"num_ctx\":\"2048\",\"temperature\":\"0.8\",\"top_p\":\"0.9\"}},\"description\":\"\",\"instruction\":\"You are a helpful AI assistant running locally via Ollama.\",\"stream\":false}" } }, @@ -131,7 +139,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1298797653455180865" + "kagent.dev/config-hash": "16802652292623058054" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-custom-ca.json b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-custom-ca.json index 62e996c083..8185499488 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-custom-ca.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-custom-ca.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "tls_agent_with_custom_ca", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://tls-agent-with-custom-ca.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://tls-agent-with-custom-ca.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://tls-agent-with-custom-ca.test:8080" + } + ], "version": "" }, "config": { @@ -59,7 +67,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"tls_agent_with_custom_ca\",\"description\":\"\",\"url\":\"http://tls-agent-with-custom-ca.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://tls-agent-with-custom-ca.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://tls-agent-with-custom-ca.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"tls_agent_with_custom_ca\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"tls_insecure_skip_verify\":false,\"tls_ca_cert_path\":\"/etc/ssl/certs/custom/ca.crt\",\"tls_disable_system_cas\":false,\"base_url\":\"https://internal-litellm.company.com\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant with custom CA support.\",\"stream\":false}" } }, @@ -129,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "7617586729040135348" + "kagent.dev/config-hash": "1681567908902132752" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-disabled-verify.json b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-disabled-verify.json index 5da58bfdcd..a04e9e2d55 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-disabled-verify.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-disabled-verify.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "tls_agent_with_disabled_verify", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://tls-agent-with-disabled-verify.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://tls-agent-with-disabled-verify.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://tls-agent-with-disabled-verify.test:8080" + } + ], "version": "" }, "config": { @@ -58,7 +66,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"tls_agent_with_disabled_verify\",\"description\":\"\",\"url\":\"http://tls-agent-with-disabled-verify.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://tls-agent-with-disabled-verify.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://tls-agent-with-disabled-verify.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"tls_agent_with_disabled_verify\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"tls_insecure_skip_verify\":true,\"tls_disable_system_cas\":false,\"base_url\":\"https://dev-litellm.local\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant in a development environment.\",\"stream\":false}" } }, @@ -128,7 +136,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "11053867675743064086" + "kagent.dev/config-hash": "1238192155064720782" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-system-cas-disabled.json b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-system-cas-disabled.json index 620c29c9f5..99a8befbe3 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-system-cas-disabled.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/tls-with-system-cas-disabled.json @@ -1,8 +1,6 @@ { "agentCard": { "capabilities": { - "pushNotifications": false, - "stateTransitionHistory": true, "streaming": true }, "defaultInputModes": [ @@ -13,9 +11,19 @@ ], "description": "", "name": "tls_agent_with_system_cas_disabled", - "preferredTransport": "JSONRPC", "skills": null, - "url": "http://tls-agent-with-system-cas-disabled.test:8080", + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://tls-agent-with-system-cas-disabled.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://tls-agent-with-system-cas-disabled.test:8080" + } + ], "version": "" }, "config": { @@ -59,7 +67,7 @@ ] }, "stringData": { - "agent-card.json": "{\"name\":\"tls_agent_with_system_cas_disabled\",\"description\":\"\",\"url\":\"http://tls-agent-with-system-cas-disabled.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[],\"preferredTransport\":\"JSONRPC\"}", + "agent-card.json": "{\"supportedInterfaces\":[{\"url\":\"http://tls-agent-with-system-cas-disabled.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"1.0\"},{\"url\":\"http://tls-agent-with-system-cas-disabled.test:8080\",\"protocolBinding\":\"JSONRPC\",\"protocolVersion\":\"0.3\"}],\"capabilities\":{\"streaming\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"description\":\"\",\"name\":\"tls_agent_with_system_cas_disabled\",\"skills\":[],\"version\":\"\"}", "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"tls_insecure_skip_verify\":false,\"tls_ca_cert_path\":\"/etc/ssl/certs/custom/ca.crt\",\"tls_disable_system_cas\":true,\"base_url\":\"https://corp-llm-gateway.internal\",\"max_tokens\":1024,\"temperature\":0.7},\"description\":\"\",\"instruction\":\"You are a helpful assistant in a corporate environment.\",\"stream\":false}" } }, @@ -129,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1527591075664209989" + "kagent.dev/config-hash": "1420314934818105053" }, "labels": { "app": "kagent", diff --git a/go/core/internal/controller/translator/agent/utils.go b/go/core/internal/controller/translator/agent/utils.go index 83b9bbd680..143da5daab 100644 --- a/go/core/internal/controller/translator/agent/utils.go +++ b/go/core/internal/controller/translator/agent/utils.go @@ -2,38 +2,51 @@ package agent import ( "fmt" - "slices" "strings" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/v1alpha2" - "github.com/kagent-dev/kagent/go/core/internal/utils" - "trpc.group/trpc-go/trpc-a2a-go/server" ) -func GetA2AAgentCard(agent v1alpha2.AgentObject) *server.AgentCard { +func GetA2AAgentCard(agent v1alpha2.AgentObject) *a2atype.AgentCard { spec := agent.GetAgentSpec() - preferredTransport := string(a2atype.TransportProtocolJSONRPC) - card := server.AgentCard{ - Name: strings.ReplaceAll(agent.GetName(), "-", "_"), - Description: spec.Description, - URL: fmt.Sprintf("http://%s.%s:8080", agent.GetName(), agent.GetNamespace()), - PreferredTransport: &preferredTransport, - Capabilities: server.AgentCapabilities{ - Streaming: new(true), - PushNotifications: new(false), - StateTransitionHistory: new(true), + card := a2atype.AgentCard{ + Name: strings.ReplaceAll(agent.GetName(), "-", "_"), + Description: spec.Description, + SupportedInterfaces: []*a2atype.AgentInterface{ + { + URL: fmt.Sprintf("http://%s.%s:8080", agent.GetName(), agent.GetNamespace()), + ProtocolBinding: a2atype.TransportProtocolJSONRPC, + ProtocolVersion: a2atype.Version, + }, + { + URL: fmt.Sprintf("http://%s.%s:8080", agent.GetName(), agent.GetNamespace()), + ProtocolBinding: a2atype.TransportProtocolJSONRPC, + ProtocolVersion: a2atype.ProtocolVersion("0.3"), + }, }, - // Can't be null for Python, so set to empty list - Skills: []server.AgentSkill{}, + Capabilities: a2atype.AgentCapabilities{ + Streaming: true, + PushNotifications: false, + }, + // Can't be null for Python, so set to empty list. + Skills: []a2atype.AgentSkill{}, DefaultInputModes: []string{"text"}, DefaultOutputModes: []string{"text"}, } if spec.Type == v1alpha2.AgentType_Declarative && spec.Declarative != nil && spec.Declarative.A2AConfig != nil { - decl := spec.Declarative - card.Skills = slices.Collect(utils.Map(slices.Values(decl.A2AConfig.Skills), func(skill v1alpha2.AgentSkill) server.AgentSkill { - return server.AgentSkill(skill) - })) + card.Skills = make([]a2atype.AgentSkill, 0, len(spec.Declarative.A2AConfig.Skills)) + for _, skill := range spec.Declarative.A2AConfig.Skills { + card.Skills = append(card.Skills, a2atype.AgentSkill{ + ID: skill.ID, + Name: skill.Name, + Description: skill.Description, + Tags: skill.Tags, + Examples: skill.Examples, + InputModes: skill.InputModes, + OutputModes: skill.OutputModes, + }) + } } return &card } diff --git a/go/core/internal/controller/translator/agent/utils_test.go b/go/core/internal/controller/translator/agent/utils_test.go index 5cdc2b3db5..e78aea1a27 100644 --- a/go/core/internal/controller/translator/agent/utils_test.go +++ b/go/core/internal/controller/translator/agent/utils_test.go @@ -3,11 +3,10 @@ package agent_test import ( "testing" - a2atype "github.com/a2aproject/a2a-go/a2a" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "trpc.group/trpc-go/trpc-a2a-go/server" "github.com/kagent-dev/kagent/go/api/v1alpha2" translator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" @@ -20,7 +19,7 @@ func TestGetA2AAgentCard(t *testing.T) { wantName string wantDescription string wantURL string - wantSkills []server.AgentSkill + wantSkills []a2atype.AgentSkill }{ { name: "declarative agent with a2a config and skills", @@ -45,7 +44,7 @@ func TestGetA2AAgentCard(t *testing.T) { wantName: "test_agent", wantDescription: "A test agent", wantURL: "http://test-agent.default:8080", - wantSkills: []server.AgentSkill{{Name: "skill-1"}, {Name: "skill-2"}}, + wantSkills: []a2atype.AgentSkill{{Name: "skill-1"}, {Name: "skill-2"}}, }, { name: "declarative agent with nil declarative spec", @@ -62,7 +61,7 @@ func TestGetA2AAgentCard(t *testing.T) { wantName: "nil_declarative", wantDescription: "", wantURL: "http://nil-declarative.default:8080", - wantSkills: []server.AgentSkill{}, + wantSkills: []a2atype.AgentSkill{}, }, { name: "declarative agent with nil a2a config", @@ -81,7 +80,7 @@ func TestGetA2AAgentCard(t *testing.T) { wantName: "no_a2a", wantDescription: "", wantURL: "http://no-a2a.default:8080", - wantSkills: []server.AgentSkill{}, + wantSkills: []a2atype.AgentSkill{}, }, { name: "BYO agent", @@ -97,7 +96,7 @@ func TestGetA2AAgentCard(t *testing.T) { wantName: "byo_agent", wantDescription: "", wantURL: "http://byo-agent.default:8080", - wantSkills: []server.AgentSkill{}, + wantSkills: []a2atype.AgentSkill{}, }, } @@ -108,15 +107,18 @@ func TestGetA2AAgentCard(t *testing.T) { assert.NotNil(t, card) assert.Equal(t, tt.wantName, card.Name) assert.Equal(t, tt.wantDescription, card.Description) - assert.Equal(t, tt.wantURL, card.URL) + require.Len(t, card.SupportedInterfaces, 2) + assert.Equal(t, tt.wantURL, card.SupportedInterfaces[0].URL) + assert.Equal(t, a2atype.TransportProtocolJSONRPC, card.SupportedInterfaces[0].ProtocolBinding) + assert.Equal(t, a2atype.Version, card.SupportedInterfaces[0].ProtocolVersion) + assert.Equal(t, tt.wantURL, card.SupportedInterfaces[1].URL) + assert.Equal(t, a2atype.TransportProtocolJSONRPC, card.SupportedInterfaces[1].ProtocolBinding) + assert.Equal(t, a2atype.ProtocolVersion("0.3"), card.SupportedInterfaces[1].ProtocolVersion) assert.Equal(t, tt.wantSkills, card.Skills) assert.Equal(t, []string{"text"}, card.DefaultInputModes) assert.Equal(t, []string{"text"}, card.DefaultOutputModes) - require.NotNil(t, card.PreferredTransport) - assert.Equal(t, string(a2atype.TransportProtocolJSONRPC), *card.PreferredTransport) - assert.True(t, *card.Capabilities.Streaming) - assert.False(t, *card.Capabilities.PushNotifications) - assert.True(t, *card.Capabilities.StateTransitionHistory) + assert.True(t, card.Capabilities.Streaming) + assert.False(t, card.Capabilities.PushNotifications) }) } } diff --git a/go/core/internal/database/client_postgres.go b/go/core/internal/database/client_postgres.go index 15a2dbacd7..4749932313 100644 --- a/go/core/internal/database/client_postgres.go +++ b/go/core/internal/database/client_postgres.go @@ -8,11 +8,13 @@ import ( "strings" "time" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" dbpkg "github.com/kagent-dev/kagent/go/api/database" "github.com/kagent-dev/kagent/go/api/v1alpha2" dbgen "github.com/kagent-dev/kagent/go/core/internal/database/gen" + "github.com/kagent-dev/kagent/go/core/pkg/a2acompat/trpcv0" "github.com/pgvector/pgvector-go" "trpc.group/trpc-go/trpc-a2a-go/protocol" ) @@ -197,40 +199,42 @@ func (c *postgresClient) ListEventsForSession(ctx context.Context, sessionID, us // ── Tasks ───────────────────────────────────────────────────────────────────── -func (c *postgresClient) StoreTask(ctx context.Context, task *protocol.Task) error { +func (c *postgresClient) StoreTask(ctx context.Context, task *a2a.Task) error { data, err := json.Marshal(task) if err != nil { return fmt.Errorf("failed to serialize task: %w", err) } + protocolVersion := trpcv0.ProtocolVersionV1 return c.q.UpsertTask(ctx, dbgen.UpsertTaskParams{ - ID: task.ID, - Data: string(data), - SessionID: strPtrIfNotEmpty(task.ContextID), + ID: string(task.ID), + Data: string(data), + SessionID: strPtrIfNotEmpty(task.ContextID), + ProtocolVersion: &protocolVersion, }) } -func (c *postgresClient) GetTask(ctx context.Context, taskID string) (*protocol.Task, error) { +func (c *postgresClient) GetTask(ctx context.Context, taskID string) (*a2a.Task, error) { row, err := c.q.GetTask(ctx, taskID) if err != nil { return nil, fmt.Errorf("failed to get task %s: %w", taskID, err) } - var task protocol.Task - if err := json.Unmarshal([]byte(row.Data), &task); err != nil { - return nil, fmt.Errorf("failed to deserialize task: %w", err) - } - return &task, nil + return parseVersionedTask(row.Data, row.ProtocolVersion) } -func (c *postgresClient) ListTasksForSession(ctx context.Context, sessionID string) ([]*protocol.Task, error) { +func (c *postgresClient) ListTasksForSession(ctx context.Context, sessionID string) ([]*a2a.Task, error) { rows, err := c.q.ListTasksForSession(ctx, &sessionID) if err != nil { return nil, fmt.Errorf("failed to list tasks for session: %w", err) } - tasks := make([]dbpkg.Task, len(rows)) + tasks := make([]*a2a.Task, 0, len(rows)) for i, r := range rows { - tasks[i] = *toTask(r) + task, err := parseVersionedTask(r.Data, r.ProtocolVersion) + if err != nil { + return nil, fmt.Errorf("failed to parse task row %d: %w", i, err) + } + tasks = append(tasks, task) } - return dbpkg.ParseTasks(tasks) + return tasks, nil } func (c *postgresClient) DeleteTask(ctx context.Context, taskID string) error { @@ -239,42 +243,40 @@ func (c *postgresClient) DeleteTask(ctx context.Context, taskID string) error { // ── Push Notifications ──────────────────────────────────────────────────────── -func (c *postgresClient) StorePushNotification(ctx context.Context, config *protocol.TaskPushNotificationConfig) error { +func (c *postgresClient) StorePushNotification(ctx context.Context, config *a2a.PushConfig) error { data, err := json.Marshal(config) if err != nil { return fmt.Errorf("failed to serialize push notification: %w", err) } + protocolVersion := trpcv0.ProtocolVersionV1 return c.q.UpsertPushNotification(ctx, dbgen.UpsertPushNotificationParams{ - ID: config.PushNotificationConfig.ID, - TaskID: config.TaskID, - Data: string(data), + ID: config.ID, + TaskID: string(config.TaskID), + Data: string(data), + ProtocolVersion: &protocolVersion, }) } -func (c *postgresClient) GetPushNotification(ctx context.Context, taskID, configID string) (*protocol.TaskPushNotificationConfig, error) { +func (c *postgresClient) GetPushNotification(ctx context.Context, taskID, configID string) (*a2a.PushConfig, error) { row, err := c.q.GetPushNotification(ctx, dbgen.GetPushNotificationParams{TaskID: taskID, ID: configID}) if err != nil { return nil, fmt.Errorf("failed to get push notification: %w", err) } - var cfg protocol.TaskPushNotificationConfig - if err := json.Unmarshal([]byte(row.Data), &cfg); err != nil { - return nil, fmt.Errorf("failed to deserialize push notification: %w", err) - } - return &cfg, nil + return parseVersionedPushConfig(row.Data, row.ProtocolVersion) } -func (c *postgresClient) ListPushNotifications(ctx context.Context, taskID string) ([]*protocol.TaskPushNotificationConfig, error) { +func (c *postgresClient) ListPushNotifications(ctx context.Context, taskID string) ([]*a2a.PushConfig, error) { rows, err := c.q.ListPushNotifications(ctx, taskID) if err != nil { return nil, fmt.Errorf("failed to list push notifications: %w", err) } - result := make([]*protocol.TaskPushNotificationConfig, 0, len(rows)) - for _, row := range rows { - var cfg protocol.TaskPushNotificationConfig - if err := json.Unmarshal([]byte(row.Data), &cfg); err != nil { - return nil, fmt.Errorf("failed to deserialize push notification: %w", err) + result := make([]*a2a.PushConfig, 0, len(rows)) + for i, row := range rows { + cfg, err := parseVersionedPushConfig(row.Data, row.ProtocolVersion) + if err != nil { + return nil, fmt.Errorf("failed to deserialize push notification row %d: %w", i, err) } - result = append(result, &cfg) + result = append(result, cfg) } return result, nil } @@ -737,14 +739,16 @@ func toEvent(r dbgen.Event) *dbpkg.Event { } } +//nolint:unused // Kept for parity with other row mappers and future raw task DB APIs. func toTask(r dbgen.Task) *dbpkg.Task { return &dbpkg.Task{ - ID: r.ID, - CreatedAt: derefTime(r.CreatedAt), - UpdatedAt: derefTime(r.UpdatedAt), - DeletedAt: r.DeletedAt, - Data: r.Data, - SessionID: derefStr(r.SessionID), + ID: r.ID, + CreatedAt: derefTime(r.CreatedAt), + UpdatedAt: derefTime(r.UpdatedAt), + DeletedAt: r.DeletedAt, + Data: r.Data, + ProtocolVersion: r.ProtocolVersion, + SessionID: derefStr(r.SessionID), } } @@ -866,6 +870,50 @@ func strPtrIfNotEmpty(s string) *string { return &s } +// parseVersionedTask parses a task from a string and a version, handles conversion from legacy to v1 format. +func parseVersionedTask(data string, version *string) (*a2a.Task, error) { + switch { + case version == nil || *version == "": + var legacyTask protocol.Task + if err := json.Unmarshal([]byte(data), &legacyTask); err != nil { + return nil, fmt.Errorf("failed to deserialize legacy task: %w", err) + } + task, err := trpcv0.ToV1Task(&legacyTask) + if err != nil { + return nil, fmt.Errorf("failed to convert legacy task to v1: %w", err) + } + return task, nil + case *version == trpcv0.ProtocolVersionV1: + var task a2a.Task + if err := json.Unmarshal([]byte(data), &task); err != nil { + return nil, fmt.Errorf("failed to deserialize v1 task: %w", err) + } + return &task, nil + default: + return nil, fmt.Errorf("unsupported task protocol_version %q", *version) + } +} + +// parseVersionedPushConfig parses a push notification config from a string and a version, handles conversion from legacy to v1 format. +func parseVersionedPushConfig(data string, version *string) (*a2a.PushConfig, error) { + switch { + case version == nil || *version == "": + var legacyCfg protocol.TaskPushNotificationConfig + if err := json.Unmarshal([]byte(data), &legacyCfg); err != nil { + return nil, fmt.Errorf("failed to deserialize legacy push notification: %w", err) + } + return trpcv0.ToV1PushConfig(&legacyCfg), nil + case *version == trpcv0.ProtocolVersionV1: + var cfg a2a.PushConfig + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + return nil, fmt.Errorf("failed to deserialize v1 push notification: %w", err) + } + return &cfg, nil + default: + return nil, fmt.Errorf("unsupported push_notification protocol_version %q", *version) + } +} + func derefStr(s *string) string { if s != nil { return *s diff --git a/go/core/internal/database/client_test.go b/go/core/internal/database/client_test.go index b8493396ec..0768cddb97 100644 --- a/go/core/internal/database/client_test.go +++ b/go/core/internal/database/client_test.go @@ -7,13 +7,13 @@ import ( "testing" "time" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/jackc/pgx/v5/pgxpool" dbpkg "github.com/kagent-dev/kagent/go/api/database" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/pgvector/pgvector-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) // TestConcurrentAgentUpserts verifies that concurrent StoreAgent calls @@ -325,7 +325,7 @@ func TestStoreTaskTouchesSessionActivity(t *testing.T) { require.NoError(t, err) time.Sleep(10 * time.Millisecond) - err = client.StoreTask(ctx, &protocol.Task{ + err = client.StoreTask(ctx, &a2a.Task{ ID: "task-1", ContextID: sessionID, }) diff --git a/go/core/internal/database/gen/migrations.sql.go b/go/core/internal/database/gen/migrations.sql.go new file mode 100644 index 0000000000..1fd059c136 --- /dev/null +++ b/go/core/internal/database/gen/migrations.sql.go @@ -0,0 +1,65 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: migrations.sql + +package dbgen + +import ( + "context" +) + +const countAlreadyV1Rows = `-- name: CountAlreadyV1Rows :one +SELECT + (SELECT COUNT(*) FROM task WHERE task.protocol_version = $1) + + (SELECT COUNT(*) FROM push_notification WHERE push_notification.protocol_version = $1) +AS count +` + +func (q *Queries) CountAlreadyV1Rows(ctx context.Context, protocolVersion *string) (int32, error) { + row := q.db.QueryRow(ctx, countAlreadyV1Rows, protocolVersion) + var count int32 + err := row.Scan(&count) + return count, err +} + +const listUnknownProtocolVersions = `-- name: ListUnknownProtocolVersions :many +SELECT table_name, protocol_version, row_count FROM ( + SELECT 'task' AS table_name, task.protocol_version, COUNT(*) AS row_count + FROM task + WHERE task.protocol_version IS NOT NULL AND task.protocol_version <> $1 + GROUP BY task.protocol_version + UNION ALL + SELECT 'push_notification' AS table_name, push_notification.protocol_version, COUNT(*) AS row_count + FROM push_notification + WHERE push_notification.protocol_version IS NOT NULL AND push_notification.protocol_version <> $1 + GROUP BY push_notification.protocol_version +) unknown_versions +ORDER BY table_name, protocol_version +` + +type ListUnknownProtocolVersionsRow struct { + TableName string + ProtocolVersion *string + RowCount int64 +} + +func (q *Queries) ListUnknownProtocolVersions(ctx context.Context, protocolVersion *string) ([]ListUnknownProtocolVersionsRow, error) { + rows, err := q.db.Query(ctx, listUnknownProtocolVersions, protocolVersion) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnknownProtocolVersionsRow + for rows.Next() { + var i ListUnknownProtocolVersionsRow + if err := rows.Scan(&i.TableName, &i.ProtocolVersion, &i.RowCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/go/core/internal/database/gen/models.go b/go/core/internal/database/gen/models.go index e05efa2ae8..20b8fb7a39 100644 --- a/go/core/internal/database/gen/models.go +++ b/go/core/internal/database/gen/models.go @@ -106,12 +106,13 @@ type Memory struct { } type PushNotification struct { - ID string - TaskID string - CreatedAt *time.Time - UpdatedAt *time.Time - DeletedAt *time.Time - Data string + ID string + TaskID string + CreatedAt *time.Time + UpdatedAt *time.Time + DeletedAt *time.Time + Data string + ProtocolVersion *string } type Session struct { @@ -126,12 +127,13 @@ type Session struct { } type Task struct { - ID string - CreatedAt *time.Time - UpdatedAt *time.Time - DeletedAt *time.Time - Data string - SessionID *string + ID string + CreatedAt *time.Time + UpdatedAt *time.Time + DeletedAt *time.Time + Data string + SessionID *string + ProtocolVersion *string } type Tool struct { diff --git a/go/core/internal/database/gen/push_notifications.sql.go b/go/core/internal/database/gen/push_notifications.sql.go index 73a7a00691..e7c6908af3 100644 --- a/go/core/internal/database/gen/push_notifications.sql.go +++ b/go/core/internal/database/gen/push_notifications.sql.go @@ -7,10 +7,12 @@ package dbgen import ( "context" + + "github.com/jackc/pgx/v5/pgconn" ) const getPushNotification = `-- name: GetPushNotification :one -SELECT id, task_id, created_at, updated_at, deleted_at, data FROM push_notification +SELECT id, task_id, created_at, updated_at, deleted_at, data, protocol_version FROM push_notification WHERE task_id = $1 AND id = $2 AND deleted_at IS NULL LIMIT 1 ` @@ -30,12 +32,50 @@ func (q *Queries) GetPushNotification(ctx context.Context, arg GetPushNotificati &i.UpdatedAt, &i.DeletedAt, &i.Data, + &i.ProtocolVersion, ) return i, err } +const listLegacyPushNotifications = `-- name: ListLegacyPushNotifications :many +SELECT id, data FROM push_notification +WHERE protocol_version IS NULL AND id > $1 +ORDER BY id +LIMIT $2 +` + +type ListLegacyPushNotificationsParams struct { + ID string + Limit int32 +} + +type ListLegacyPushNotificationsRow struct { + ID string + Data string +} + +func (q *Queries) ListLegacyPushNotifications(ctx context.Context, arg ListLegacyPushNotificationsParams) ([]ListLegacyPushNotificationsRow, error) { + rows, err := q.db.Query(ctx, listLegacyPushNotifications, arg.ID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLegacyPushNotificationsRow + for rows.Next() { + var i ListLegacyPushNotificationsRow + if err := rows.Scan(&i.ID, &i.Data); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listPushNotifications = `-- name: ListPushNotifications :many -SELECT id, task_id, created_at, updated_at, deleted_at, data FROM push_notification +SELECT id, task_id, created_at, updated_at, deleted_at, data, protocol_version FROM push_notification WHERE task_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC ` @@ -56,6 +96,7 @@ func (q *Queries) ListPushNotifications(ctx context.Context, taskID string) ([]P &i.UpdatedAt, &i.DeletedAt, &i.Data, + &i.ProtocolVersion, ); err != nil { return nil, err } @@ -67,6 +108,28 @@ func (q *Queries) ListPushNotifications(ctx context.Context, taskID string) ([]P return items, nil } +const migratePushNotification = `-- name: MigratePushNotification :execresult +UPDATE push_notification +SET data = $1, protocol_version = $2, updated_at = NOW() +WHERE id = $3 AND data = $4 AND protocol_version IS NULL +` + +type MigratePushNotificationParams struct { + Data string + ProtocolVersion *string + ID string + Data_2 string +} + +func (q *Queries) MigratePushNotification(ctx context.Context, arg MigratePushNotificationParams) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, migratePushNotification, + arg.Data, + arg.ProtocolVersion, + arg.ID, + arg.Data_2, + ) +} + const softDeletePushNotification = `-- name: SoftDeletePushNotification :exec UPDATE push_notification SET deleted_at = NOW() WHERE task_id = $1 AND deleted_at IS NULL @@ -78,20 +141,27 @@ func (q *Queries) SoftDeletePushNotification(ctx context.Context, taskID string) } const upsertPushNotification = `-- name: UpsertPushNotification :exec -INSERT INTO push_notification (id, task_id, data, created_at, updated_at) -VALUES ($1, $2, $3, NOW(), NOW()) +INSERT INTO push_notification (id, task_id, data, protocol_version, created_at, updated_at) +VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET - data = EXCLUDED.data, - updated_at = NOW() + data = EXCLUDED.data, + protocol_version = EXCLUDED.protocol_version, + updated_at = NOW() ` type UpsertPushNotificationParams struct { - ID string - TaskID string - Data string + ID string + TaskID string + Data string + ProtocolVersion *string } func (q *Queries) UpsertPushNotification(ctx context.Context, arg UpsertPushNotificationParams) error { - _, err := q.db.Exec(ctx, upsertPushNotification, arg.ID, arg.TaskID, arg.Data) + _, err := q.db.Exec(ctx, upsertPushNotification, + arg.ID, + arg.TaskID, + arg.Data, + arg.ProtocolVersion, + ) return err } diff --git a/go/core/internal/database/gen/querier.go b/go/core/internal/database/gen/querier.go index e58850e004..aff26bfa2b 100644 --- a/go/core/internal/database/gen/querier.go +++ b/go/core/internal/database/gen/querier.go @@ -6,9 +6,12 @@ package dbgen import ( "context" + + "github.com/jackc/pgx/v5/pgconn" ) type Querier interface { + CountAlreadyV1Rows(ctx context.Context, protocolVersion *string) (int32, error) DeleteAgentMemory(ctx context.Context, arg DeleteAgentMemoryParams) error DeleteExpiredMemories(ctx context.Context) error ExtendMemoryTTL(ctx context.Context) error @@ -38,6 +41,8 @@ type Querier interface { ListEventsForSessionDesc(ctx context.Context, arg ListEventsForSessionDescParams) ([]Event, error) ListEventsForSessionDescLimit(ctx context.Context, arg ListEventsForSessionDescLimitParams) ([]Event, error) ListFeedback(ctx context.Context, userID string) ([]Feedback, error) + ListLegacyPushNotifications(ctx context.Context, arg ListLegacyPushNotificationsParams) ([]ListLegacyPushNotificationsRow, error) + ListLegacyTasks(ctx context.Context, arg ListLegacyTasksParams) ([]ListLegacyTasksRow, error) ListPushNotifications(ctx context.Context, taskID string) ([]PushNotification, error) ListSessions(ctx context.Context, userID string) ([]Session, error) ListSessionsForAgent(ctx context.Context, arg ListSessionsForAgentParams) ([]Session, error) @@ -46,6 +51,9 @@ type Querier interface { ListToolServers(ctx context.Context) ([]Toolserver, error) ListTools(ctx context.Context) ([]Tool, error) ListToolsForServer(ctx context.Context, arg ListToolsForServerParams) ([]Tool, error) + ListUnknownProtocolVersions(ctx context.Context, protocolVersion *string) ([]ListUnknownProtocolVersionsRow, error) + MigratePushNotification(ctx context.Context, arg MigratePushNotificationParams) (pgconn.CommandTag, error) + MigrateTask(ctx context.Context, arg MigrateTaskParams) (pgconn.CommandTag, error) // Memory uses hard DELETE (not soft deletes), so no deleted_at filter is needed. // COALESCE guards against NULL embeddings (score=0 rather than NULL); rows are still ordered last by the ORDER BY clause. SearchAgentMemory(ctx context.Context, arg SearchAgentMemoryParams) ([]SearchAgentMemoryRow, error) diff --git a/go/core/internal/database/gen/tasks.sql.go b/go/core/internal/database/gen/tasks.sql.go index e91be6daaf..aec822289c 100644 --- a/go/core/internal/database/gen/tasks.sql.go +++ b/go/core/internal/database/gen/tasks.sql.go @@ -7,10 +7,12 @@ package dbgen import ( "context" + + "github.com/jackc/pgx/v5/pgconn" ) const getTask = `-- name: GetTask :one -SELECT id, created_at, updated_at, deleted_at, data, session_id FROM task +SELECT id, created_at, updated_at, deleted_at, data, session_id, protocol_version FROM task WHERE id = $1 AND deleted_at IS NULL LIMIT 1 ` @@ -25,12 +27,50 @@ func (q *Queries) GetTask(ctx context.Context, id string) (Task, error) { &i.DeletedAt, &i.Data, &i.SessionID, + &i.ProtocolVersion, ) return i, err } +const listLegacyTasks = `-- name: ListLegacyTasks :many +SELECT id, data FROM task +WHERE protocol_version IS NULL AND id > $1 +ORDER BY id +LIMIT $2 +` + +type ListLegacyTasksParams struct { + ID string + Limit int32 +} + +type ListLegacyTasksRow struct { + ID string + Data string +} + +func (q *Queries) ListLegacyTasks(ctx context.Context, arg ListLegacyTasksParams) ([]ListLegacyTasksRow, error) { + rows, err := q.db.Query(ctx, listLegacyTasks, arg.ID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLegacyTasksRow + for rows.Next() { + var i ListLegacyTasksRow + if err := rows.Scan(&i.ID, &i.Data); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTasksForSession = `-- name: ListTasksForSession :many -SELECT id, created_at, updated_at, deleted_at, data, session_id FROM task +SELECT id, created_at, updated_at, deleted_at, data, session_id, protocol_version FROM task WHERE session_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC ` @@ -51,6 +91,7 @@ func (q *Queries) ListTasksForSession(ctx context.Context, sessionID *string) ([ &i.DeletedAt, &i.Data, &i.SessionID, + &i.ProtocolVersion, ); err != nil { return nil, err } @@ -62,6 +103,28 @@ func (q *Queries) ListTasksForSession(ctx context.Context, sessionID *string) ([ return items, nil } +const migrateTask = `-- name: MigrateTask :execresult +UPDATE task +SET data = $1, protocol_version = $2, updated_at = NOW() +WHERE id = $3 AND data = $4 AND protocol_version IS NULL +` + +type MigrateTaskParams struct { + Data string + ProtocolVersion *string + ID string + Data_2 string +} + +func (q *Queries) MigrateTask(ctx context.Context, arg MigrateTaskParams) (pgconn.CommandTag, error) { + return q.db.Exec(ctx, migrateTask, + arg.Data, + arg.ProtocolVersion, + arg.ID, + arg.Data_2, + ) +} + const softDeleteTask = `-- name: SoftDeleteTask :exec UPDATE task SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL ` @@ -86,12 +149,13 @@ func (q *Queries) TaskExists(ctx context.Context, id string) (bool, error) { const upsertTask = `-- name: UpsertTask :exec WITH upserted_task AS ( -INSERT INTO task (id, data, session_id, created_at, updated_at) -VALUES ($1, $2, $3, NOW(), NOW()) +INSERT INTO task (id, data, session_id, protocol_version, created_at, updated_at) +VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET - data = EXCLUDED.data, - session_id = EXCLUDED.session_id, - updated_at = NOW() + data = EXCLUDED.data, + session_id = EXCLUDED.session_id, + protocol_version = EXCLUDED.protocol_version, + updated_at = NOW() RETURNING session_id ) UPDATE session @@ -103,12 +167,18 @@ WHERE upserted_task.session_id IS NOT NULL ` type UpsertTaskParams struct { - ID string - Data string - SessionID *string + ID string + Data string + SessionID *string + ProtocolVersion *string } func (q *Queries) UpsertTask(ctx context.Context, arg UpsertTaskParams) error { - _, err := q.db.Exec(ctx, upsertTask, arg.ID, arg.Data, arg.SessionID) + _, err := q.db.Exec(ctx, upsertTask, + arg.ID, + arg.Data, + arg.SessionID, + arg.ProtocolVersion, + ) return err } diff --git a/go/core/internal/database/queries/migrations.sql b/go/core/internal/database/queries/migrations.sql new file mode 100644 index 0000000000..01e8e862a9 --- /dev/null +++ b/go/core/internal/database/queries/migrations.sql @@ -0,0 +1,19 @@ +-- name: CountAlreadyV1Rows :one +SELECT + (SELECT COUNT(*) FROM task WHERE task.protocol_version = $1) + + (SELECT COUNT(*) FROM push_notification WHERE push_notification.protocol_version = $1) +AS count; + +-- name: ListUnknownProtocolVersions :many +SELECT table_name, protocol_version, row_count FROM ( + SELECT 'task' AS table_name, task.protocol_version, COUNT(*) AS row_count + FROM task + WHERE task.protocol_version IS NOT NULL AND task.protocol_version <> $1 + GROUP BY task.protocol_version + UNION ALL + SELECT 'push_notification' AS table_name, push_notification.protocol_version, COUNT(*) AS row_count + FROM push_notification + WHERE push_notification.protocol_version IS NOT NULL AND push_notification.protocol_version <> $1 + GROUP BY push_notification.protocol_version +) unknown_versions +ORDER BY table_name, protocol_version; diff --git a/go/core/internal/database/queries/push_notifications.sql b/go/core/internal/database/queries/push_notifications.sql index ccc7553f69..52ad9e6ded 100644 --- a/go/core/internal/database/queries/push_notifications.sql +++ b/go/core/internal/database/queries/push_notifications.sql @@ -9,12 +9,24 @@ WHERE task_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC; -- name: UpsertPushNotification :exec -INSERT INTO push_notification (id, task_id, data, created_at, updated_at) -VALUES ($1, $2, $3, NOW(), NOW()) +INSERT INTO push_notification (id, task_id, data, protocol_version, created_at, updated_at) +VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET - data = EXCLUDED.data, - updated_at = NOW(); + data = EXCLUDED.data, + protocol_version = EXCLUDED.protocol_version, + updated_at = NOW(); -- name: SoftDeletePushNotification :exec UPDATE push_notification SET deleted_at = NOW() WHERE task_id = $1 AND deleted_at IS NULL; + +-- name: ListLegacyPushNotifications :many +SELECT id, data FROM push_notification +WHERE protocol_version IS NULL AND id > $1 +ORDER BY id +LIMIT $2; + +-- name: MigratePushNotification :execresult +UPDATE push_notification +SET data = $1, protocol_version = $2, updated_at = NOW() +WHERE id = $3 AND data = $4 AND protocol_version IS NULL; diff --git a/go/core/internal/database/queries/tasks.sql b/go/core/internal/database/queries/tasks.sql index 66105c6f3a..37e1469870 100644 --- a/go/core/internal/database/queries/tasks.sql +++ b/go/core/internal/database/queries/tasks.sql @@ -15,12 +15,13 @@ ORDER BY created_at ASC; -- name: UpsertTask :exec WITH upserted_task AS ( -INSERT INTO task (id, data, session_id, created_at, updated_at) -VALUES ($1, $2, $3, NOW(), NOW()) +INSERT INTO task (id, data, session_id, protocol_version, created_at, updated_at) +VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET - data = EXCLUDED.data, - session_id = EXCLUDED.session_id, - updated_at = NOW() + data = EXCLUDED.data, + session_id = EXCLUDED.session_id, + protocol_version = EXCLUDED.protocol_version, + updated_at = NOW() RETURNING session_id ) UPDATE session @@ -32,3 +33,14 @@ WHERE upserted_task.session_id IS NOT NULL -- name: SoftDeleteTask :exec UPDATE task SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL; + +-- name: ListLegacyTasks :many +SELECT id, data FROM task +WHERE protocol_version IS NULL AND id > $1 +ORDER BY id +LIMIT $2; + +-- name: MigrateTask :execresult +UPDATE task +SET data = $1, protocol_version = $2, updated_at = NOW() +WHERE id = $3 AND data = $4 AND protocol_version IS NULL; diff --git a/go/core/internal/httpserver/handlers/sessions.go b/go/core/internal/httpserver/handlers/sessions.go index 1ff768077d..83cddb3623 100644 --- a/go/core/internal/httpserver/handlers/sessions.go +++ b/go/core/internal/httpserver/handlers/sessions.go @@ -6,13 +6,14 @@ import ( "strconv" "time" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/database" api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" "github.com/kagent-dev/kagent/go/core/internal/utils" + "github.com/kagent-dev/kagent/go/core/pkg/a2acompat/trpcv0" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) // SessionsHandler handles session-related requests @@ -119,7 +120,7 @@ func (h *SessionsHandler) HandleCreateSession(w ErrorResponseWriter, r *http.Req } log = log.WithValues("agentRef", *sessionRequest.AgentRef) - id := protocol.GenerateContextID() + id := a2a.NewContextID() if sessionRequest.ID != nil && *sessionRequest.ID != "" { id = *sessionRequest.ID } @@ -346,10 +347,34 @@ func (h *SessionsHandler) HandleListTasksForSession(w ErrorResponseWriter, r *ht w.RespondWithError(errors.NewInternalServerError("Failed to get session runs", err)) return } + wireVersion, err := utils.NegotiateA2AWireVersion(r) + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Unsupported A2A version", err)) + return + } log.Info("Successfully retrieved session tasks", "count", len(tasks)) - data := api.NewResponse(tasks, "Successfully retrieved session tasks", false) - RespondWithJSON(w, http.StatusOK, data) + + // TODO(0.12.0): Remove legacy API conversion after legacy wire support is no longer supported. + switch wireVersion { + case utils.A2AWireVersionLegacy: + legacyTasks := make([]any, 0, len(tasks)) + for i := range tasks { + legacyTask, convErr := trpcv0.ToLegacyTask(tasks[i]) + if convErr != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to convert task", convErr)) + return + } + legacyTasks = append(legacyTasks, legacyTask) + } + data := api.NewResponse(legacyTasks, "Successfully retrieved session tasks", false) + RespondWithJSON(w, http.StatusOK, data) + case utils.A2AWireVersionV1: + data := api.NewResponse(tasks, "Successfully retrieved session tasks", false) + RespondWithJSON(w, http.StatusOK, data) + default: + w.RespondWithError(errors.NewBadRequestError("Unsupported A2A version", fmt.Errorf("unknown negotiated wire version %q", wireVersion))) + } } func (h *SessionsHandler) HandleAddEventToSession(w ErrorResponseWriter, r *http.Request) { diff --git a/go/core/internal/httpserver/handlers/sessions_test.go b/go/core/internal/httpserver/handlers/sessions_test.go index 02ee3243d2..3e5627564d 100644 --- a/go/core/internal/httpserver/handlers/sessions_test.go +++ b/go/core/internal/httpserver/handlers/sessions_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/gorilla/mux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,7 +25,6 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/auth" "github.com/kagent-dev/kmcp/api/v1alpha1" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) func setUser(req *http.Request, userID string) *http.Request { @@ -532,24 +532,25 @@ func TestSessionsHandler(t *testing.T) { agentID := "1" createTestSession(t, dbClient, sessionID, userID, agentID) - require.NoError(t, dbClient.StoreTask(context.Background(), &protocol.Task{ + require.NoError(t, dbClient.StoreTask(context.Background(), &a2a.Task{ ID: "task-1", ContextID: sessionID, })) - require.NoError(t, dbClient.StoreTask(context.Background(), &protocol.Task{ + require.NoError(t, dbClient.StoreTask(context.Background(), &a2a.Task{ ID: "task-2", ContextID: sessionID, })) req := httptest.NewRequest("GET", "/api/sessions/"+sessionID+"/tasks", nil) req = mux.SetURLVars(req, map[string]string{"session_id": sessionID}) + req.Header.Set("A2A-Version", "1.0") req = setUser(req, userID) handler.HandleListTasksForSession(responseRecorder, req) assert.Equal(t, http.StatusOK, responseRecorder.Code) - var response api.StandardResponse[[]*protocol.Task] + var response api.StandardResponse[[]*a2a.Task] err := json.Unmarshal(responseRecorder.Body.Bytes(), &response) require.NoError(t, err) assert.Len(t, response.Data, 2) diff --git a/go/core/internal/httpserver/handlers/tasks.go b/go/core/internal/httpserver/handlers/tasks.go index ccf0ee6e21..8cdbe2ea8a 100644 --- a/go/core/internal/httpserver/handlers/tasks.go +++ b/go/core/internal/httpserver/handlers/tasks.go @@ -1,10 +1,14 @@ package handlers import ( + "fmt" "net/http" + a2a "github.com/a2aproject/a2a-go/v2/a2a" api "github.com/kagent-dev/kagent/go/api/httpapi" "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + "github.com/kagent-dev/kagent/go/core/internal/utils" + "github.com/kagent-dev/kagent/go/core/pkg/a2acompat/trpcv0" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" "trpc.group/trpc-go/trpc-a2a-go/protocol" ) @@ -34,22 +38,71 @@ func (h *TasksHandler) HandleGetTask(w ErrorResponseWriter, r *http.Request) { w.RespondWithError(errors.NewNotFoundError("Task not found", err)) return } + wireVersion, err := utils.NegotiateA2AWireVersion(r) + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Unsupported A2A version", err)) + return + } log.Info("Successfully retrieved task") - data := api.NewResponse(task, "Successfully retrieved task", false) - RespondWithJSON(w, http.StatusOK, data) + // TODO(0.12.0): Remove legacy API conversion after legacy wire support is no longer supported. + // Currently this will return either legacy or v1 task depending on the wire version + var data any + switch wireVersion { + case utils.A2AWireVersionLegacy: + legacyTask, convErr := trpcv0.ToLegacyTask(task) + if convErr != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to convert task", convErr)) + return + } + data = legacyTask + case utils.A2AWireVersionV1: + data = task + default: + w.RespondWithError(errors.NewBadRequestError("Unsupported A2A version", fmt.Errorf("unknown negotiated wire version %q", wireVersion))) + return + } + response := api.NewResponse(data, "Successfully retrieved task", false) + RespondWithJSON(w, http.StatusOK, response) } func (h *TasksHandler) HandleCreateTask(w ErrorResponseWriter, r *http.Request) { log := ctrllog.FromContext(r.Context()).WithName("tasks-handler").WithValues("operation", "create-task") - task := protocol.Task{} - if err := DecodeJSONBody(r, &task); err != nil { - w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) + wireVersion, err := utils.NegotiateA2AWireVersion(r) + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Unsupported A2A version", err)) + return + } + + task := a2a.Task{} + // TODO(0.12.0): Remove legacy API conversion after legacy wire support is no longer supported. + switch wireVersion { + case utils.A2AWireVersionLegacy: + legacyTask := protocol.Task{} + if err := DecodeJSONBody(r, &legacyTask); err != nil { + w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) + return + } + converted, convErr := trpcv0.ToV1Task(&legacyTask) + if convErr != nil { + w.RespondWithError(errors.NewBadRequestError("Invalid legacy task payload", convErr)) + return + } + if converted != nil { + task = *converted + } + case utils.A2AWireVersionV1: + if err := DecodeJSONBody(r, &task); err != nil { + w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) + return + } + default: + w.RespondWithError(errors.NewBadRequestError("Unsupported A2A version", fmt.Errorf("unknown negotiated wire version %q", wireVersion))) return } if task.ID == "" { - task.ID = protocol.GenerateTaskID() + task.ID = a2a.NewTaskID() } log = log.WithValues("task_id", task.ID) @@ -59,8 +112,23 @@ func (h *TasksHandler) HandleCreateTask(w ErrorResponseWriter, r *http.Request) } log.Info("Successfully created task") - data := api.NewResponse(task, "Successfully created task", false) - RespondWithJSON(w, http.StatusCreated, data) + var data any + switch wireVersion { + case utils.A2AWireVersionLegacy: + legacyTask, convErr := trpcv0.ToLegacyTask(&task) + if convErr != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to convert task", convErr)) + return + } + data = legacyTask + case utils.A2AWireVersionV1: + data = task + default: + w.RespondWithError(errors.NewBadRequestError("Unsupported A2A version", fmt.Errorf("unknown negotiated wire version %q", wireVersion))) + return + } + response := api.NewResponse(data, "Successfully created task", false) + RespondWithJSON(w, http.StatusCreated, response) } func (h *TasksHandler) HandleDeleteTask(w ErrorResponseWriter, r *http.Request) { diff --git a/go/core/internal/mcp/mcp_handler.go b/go/core/internal/mcp/mcp_handler.go index c6ed5f5cb3..81e598043c 100644 --- a/go/core/internal/mcp/mcp_handler.go +++ b/go/core/internal/mcp/mcp_handler.go @@ -2,10 +2,12 @@ package mcp import ( "context" + "encoding/json" "fmt" "net/http" "strings" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/jsonschema-go/jsonschema" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/a2a" @@ -15,7 +17,6 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "sigs.k8s.io/controller-runtime/pkg/client" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) // MCPHandler handles MCP requests and bridges them to A2A endpoints @@ -196,24 +197,13 @@ func (h *MCPHandler) handleInvokeAgent(ctx context.Context, req *mcpsdk.CallTool agentNS, agentName := parts[0], parts[1] agentRef := agentNS + "/" + agentName - // Get context ID from client request (stateless mode) - // If not provided, contextIDPtr will be nil and a new conversation will start - var contextIDPtr *string + message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(input.Task)) if input.ContextID != "" { - contextIDPtr = &input.ContextID + message.ContextID = input.ContextID log.V(1).Info("Using context_id from client request", "context_id", input.ContextID) } - // Send message directly via the agent's A2A client, bypassing the - // controller's own HTTP A2A listener. - result, err := h.agentClients.SendMessage(ctx, agentNS, agentName, protocol.SendMessageParams{ - Message: protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - ContextID: contextIDPtr, - Parts: []protocol.Part{protocol.NewTextPart(input.Task)}, - }, - }) + result, err := h.agentClients.SendMessage(ctx, agentNS, agentName, &a2atype.SendMessageRequest{Message: message}) if err != nil { log.Error(err, "Failed to send A2A message", "agent", agentRef) return &mcpsdk.CallToolResult{ @@ -226,25 +216,22 @@ func (h *MCPHandler) handleInvokeAgent(ctx context.Context, req *mcpsdk.CallTool // Extract response text and context ID var responseText, newContextID string - switch a2aResult := result.Result.(type) { - case *protocol.Message: - responseText = a2a.ExtractText(*a2aResult) - if a2aResult.ContextID != nil { - newContextID = *a2aResult.ContextID - } - // Kagent A2A only returns Task type for now - case *protocol.Task: + switch a2aResult := result.(type) { + case *a2atype.Message: + responseText = a2a.ExtractText(a2aResult) + newContextID = a2aResult.ContextID + case *a2atype.Task: newContextID = a2aResult.ContextID if a2aResult.Status.Message != nil { - responseText = a2a.ExtractText(*a2aResult.Status.Message) + responseText = a2a.ExtractText(a2aResult.Status.Message) } for _, artifact := range a2aResult.Artifacts { - responseText += a2a.ExtractText(protocol.Message{Parts: artifact.Parts}) + responseText += a2a.ExtractText(&a2atype.Message{Parts: artifact.Parts}) } } if responseText == "" { - raw, err := result.MarshalJSON() + raw, err := json.Marshal(result) if err != nil { return &mcpsdk.CallToolResult{ Content: []mcpsdk.Content{ diff --git a/go/core/internal/mcp/mcp_handler_test.go b/go/core/internal/mcp/mcp_handler_test.go index bd3dfc688c..d274461f3a 100644 --- a/go/core/internal/mcp/mcp_handler_test.go +++ b/go/core/internal/mcp/mcp_handler_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/a2a" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -16,7 +18,6 @@ import ( "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" ) // TestListAgentsInputSchemaHasProperties asserts that the list_agents tool @@ -101,14 +102,17 @@ func newA2ABackend(t *testing.T) *a2aBackend { b.mu.Lock() b.called = true b.mu.Unlock() + var rpcReq map[string]any + _ = json.NewDecoder(r.Body).Decode(&rpcReq) resp := map[string]any{ "jsonrpc": "2.0", - "id": "", + "id": rpcReq["id"], "result": map[string]any{ - "kind": "message", - "messageId": "test-msg", - "role": "agent", - "parts": []any{map[string]any{"kind": "text", "text": "hello from agent"}}, + "message": map[string]any{ + "messageId": "test-msg", + "role": "ROLE_AGENT", + "parts": []any{map[string]any{"text": "hello from agent"}}, + }, }, } w.Header().Set("Content-Type", "application/json") @@ -123,7 +127,14 @@ func newA2ABackend(t *testing.T) *a2aBackend { // newTestRegistry builds an AgentClientRegistry with a single agent pre-registered. func newTestRegistry(t *testing.T, namespace, name, backendURL string) *a2a.AgentClientRegistry { t.Helper() - c, err := a2aclient.NewA2AClient(backendURL + "/" + namespace + "/" + name + "/") + interfaces := []*a2atype.AgentInterface{ + { + URL: backendURL + "/" + namespace + "/" + name + "/", + ProtocolBinding: a2atype.TransportProtocolJSONRPC, + ProtocolVersion: a2atype.Version, + }, + } + c, err := a2aclient.NewFromEndpoints(context.Background(), interfaces, a2aclient.WithJSONRPCTransport(&http.Client{})) require.NoError(t, err) registry := a2a.NewAgentClientRegistry() registry.Register(namespace, name, c) diff --git a/go/core/internal/utils/a2a_version.go b/go/core/internal/utils/a2a_version.go new file mode 100644 index 0000000000..4369a2db99 --- /dev/null +++ b/go/core/internal/utils/a2a_version.go @@ -0,0 +1,31 @@ +package utils + +import ( + "fmt" + "net/http" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" +) + +type A2AWireVersion string + +const ( + A2AWireVersionLegacy A2AWireVersion = "v0" + A2AWireVersionV1 A2AWireVersion = "v1" +) + +// NegotiateA2AWireVersion returns the A2A wire version requested by the client. +// Missing or explicit 0.3 headers use the legacy/current kagent A2A wire shape. +// TODO(0.12.0): Revisit missing-header behavior once legacy wire clients are unsupported. +func NegotiateA2AWireVersion(r *http.Request) (A2AWireVersion, error) { + version := r.Header.Get(a2atype.SvcParamVersion) + switch version { + case "", string(a2av0.Version): + return A2AWireVersionLegacy, nil + case string(a2atype.Version): + return A2AWireVersionV1, nil + default: + return "", fmt.Errorf("unsupported A2A version %q", version) + } +} diff --git a/go/core/pkg/a2acompat/trpcv0/convert.go b/go/core/pkg/a2acompat/trpcv0/convert.go new file mode 100644 index 0000000000..dabc1ca0fe --- /dev/null +++ b/go/core/pkg/a2acompat/trpcv0/convert.go @@ -0,0 +1,561 @@ +package trpcv0 + +import ( + "encoding/json" + "fmt" + "maps" + "time" + + legacya2a "github.com/a2aproject/a2a-go/a2a" + a2av1 "github.com/a2aproject/a2a-go/v2/a2a" + a2av0 "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" + trpc "trpc.group/trpc-go/trpc-a2a-go/protocol" +) + +const ProtocolVersionV1 = string(a2av1.Version) + +// TaskJSONToV1JSON converts a persisted trpc-a2a-go task blob to official A2A v1 JSON. +func TaskJSONToV1JSON(data []byte) ([]byte, error) { + var task trpc.Task + if err := json.Unmarshal(data, &task); err != nil { + return nil, fmt.Errorf("unmarshal trpc task: %w", err) + } + v1, err := ToV1Task(&task) + if err != nil { + return nil, err + } + return json.Marshal(v1) +} + +// PushNotificationJSONToV1JSON converts a persisted trpc-a2a-go push config blob to official A2A v1 JSON. +func PushNotificationJSONToV1JSON(data []byte) ([]byte, error) { + var cfg trpc.TaskPushNotificationConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("unmarshal trpc push notification config: %w", err) + } + v1 := ToV1PushConfig(&cfg) + return json.Marshal(v1) +} + +func ToV1Task(task *trpc.Task) (*a2av1.Task, error) { + v0, err := ToOfficialV0Task(task) + if err != nil { + return nil, err + } + return a2av0.ToV1Task(v0) +} + +func ToV1PushConfig(cfg *trpc.TaskPushNotificationConfig) *a2av1.PushConfig { + return a2av0.ToV1PushConfig(ToOfficialV0PushConfig(cfg)) +} + +func ToLegacyTask(task *a2av1.Task) (*trpc.Task, error) { + if task == nil { + return nil, nil + } + message, err := toLegacyMessage(task.Status.Message) + if err != nil { + return nil, fmt.Errorf("convert status message: %w", err) + } + + result := &trpc.Task{ + ID: string(task.ID), + ContextID: task.ContextID, + Metadata: task.Metadata, + Status: trpc.TaskStatus{ + State: toLegacyTaskState(task.Status.State), + Message: message, + Timestamp: formatTimestamp(task.Status.Timestamp), + }, + } + + if len(task.History) > 0 { + result.History = make([]trpc.Message, 0, len(task.History)) + for i := range task.History { + msg, convErr := toLegacyMessage(task.History[i]) + if convErr != nil { + return nil, fmt.Errorf("convert history message %d: %w", i, convErr) + } + if msg != nil { + result.History = append(result.History, *msg) + } + } + } + if len(task.Artifacts) > 0 { + result.Artifacts = make([]trpc.Artifact, 0, len(task.Artifacts)) + for i := range task.Artifacts { + artifact, convErr := toLegacyArtifact(task.Artifacts[i]) + if convErr != nil { + return nil, fmt.Errorf("convert artifact %d: %w", i, convErr) + } + if artifact != nil { + result.Artifacts = append(result.Artifacts, *artifact) + } + } + } + return result, nil +} + +func ToLegacyPushConfig(cfg *a2av1.PushConfig) *trpc.TaskPushNotificationConfig { + if cfg == nil { + return nil + } + result := &trpc.TaskPushNotificationConfig{ + TaskID: string(cfg.TaskID), + PushNotificationConfig: trpc.PushNotificationConfig{ + ID: cfg.ID, + URL: cfg.URL, + Token: cfg.Token, + }, + } + if cfg.Auth != nil { + credentials := cfg.Auth.Credentials + schemes := []string{} + if cfg.Auth.Scheme != "" { + schemes = append(schemes, cfg.Auth.Scheme) + } + result.PushNotificationConfig.Authentication = &trpc.AuthenticationInfo{ + Credentials: &credentials, + Schemes: schemes, + } + } + return result +} + +func ToOfficialV0Task(task *trpc.Task) (*legacya2a.Task, error) { + if task == nil { + return nil, nil + } + status, err := ToOfficialV0TaskStatus(task.Status) + if err != nil { + return nil, err + } + result := &legacya2a.Task{ + ID: legacya2a.TaskID(task.ID), + ContextID: task.ContextID, + Metadata: task.Metadata, + Status: status, + } + if len(task.History) > 0 { + result.History = make([]*legacya2a.Message, len(task.History)) + for i := range task.History { + result.History[i], err = ToOfficialV0Message(&task.History[i]) + if err != nil { + return nil, fmt.Errorf("convert task history message %d: %w", i, err) + } + } + } + if len(task.Artifacts) > 0 { + result.Artifacts = make([]*legacya2a.Artifact, len(task.Artifacts)) + for i := range task.Artifacts { + result.Artifacts[i], err = ToOfficialV0Artifact(&task.Artifacts[i]) + if err != nil { + return nil, fmt.Errorf("convert task artifact %d: %w", i, err) + } + } + } + return result, nil +} + +func toLegacyMessage(message *a2av1.Message) (*trpc.Message, error) { + if message == nil { + return nil, nil + } + parts, err := toLegacyParts(message.Parts) + if err != nil { + return nil, err + } + + taskID := "" + if message.TaskID != "" { + taskID = string(message.TaskID) + } + contextID := message.ContextID + + result := &trpc.Message{ + Kind: trpc.KindMessage, + MessageID: message.ID, + ContextID: new(contextID), + Extensions: message.Extensions, + Metadata: message.Metadata, + Parts: parts, + ReferenceTaskIDs: toLegacyTaskIDs(message.ReferenceTasks), + Role: toLegacyMessageRole(message.Role), + TaskID: new(taskID), + } + return result, nil +} + +func toLegacyArtifact(artifact *a2av1.Artifact) (*trpc.Artifact, error) { + if artifact == nil { + return nil, nil + } + parts, err := toLegacyParts(artifact.Parts) + if err != nil { + return nil, err + } + id := string(artifact.ID) + name := artifact.Name + description := artifact.Description + + return &trpc.Artifact{ + ArtifactID: id, + Name: new(name), + Description: new(description), + Metadata: artifact.Metadata, + Extensions: artifact.Extensions, + Parts: parts, + }, nil +} + +func toLegacyParts(parts a2av1.ContentParts) ([]trpc.Part, error) { + if len(parts) == 0 { + return nil, nil + } + result := make([]trpc.Part, 0, len(parts)) + for i := range parts { + part, err := toLegacyPart(parts[i]) + if err != nil { + return nil, fmt.Errorf("convert part %d: %w", i, err) + } + result = append(result, part) + } + return result, nil +} + +func toLegacyPart(part *a2av1.Part) (trpc.Part, error) { + if part == nil { + return trpc.TextPart{Kind: trpc.KindText, Text: ""}, nil + } + if text := part.Text(); text != "" { + return trpc.TextPart{ + Kind: trpc.KindText, + Text: text, + Metadata: part.Metadata, + }, nil + } + if data := part.Data(); data != nil { + return trpc.DataPart{ + Kind: trpc.KindData, + Data: data, + Metadata: part.Metadata, + }, nil + } + if url := part.URL(); url != "" { + urlString := string(url) + fileName := part.Filename + mimeType := part.MediaType + return trpc.FilePart{ + Kind: trpc.KindFile, + File: &trpc.FileWithURI{ + Name: new(fileName), + MimeType: new(mimeType), + URI: urlString, + }, + Metadata: part.Metadata, + }, nil + } + raw := part.Raw() + if len(raw) > 0 { + fileName := part.Filename + mimeType := part.MediaType + return trpc.FilePart{ + Kind: trpc.KindFile, + File: &trpc.FileWithBytes{ + Name: new(fileName), + MimeType: new(mimeType), + Bytes: string(raw), + }, + Metadata: part.Metadata, + }, nil + } + return trpc.DataPart{ + Kind: trpc.KindData, + Data: map[string]any{}, + Metadata: part.Metadata, + }, nil +} + +func toLegacyTaskState(state a2av1.TaskState) trpc.TaskState { + switch state { + case a2av1.TaskStateSubmitted: + return trpc.TaskStateSubmitted + case a2av1.TaskStateWorking: + return trpc.TaskStateWorking + case a2av1.TaskStateInputRequired: + return trpc.TaskStateInputRequired + case a2av1.TaskStateCompleted: + return trpc.TaskStateCompleted + case a2av1.TaskStateCanceled: + return trpc.TaskStateCanceled + case a2av1.TaskStateFailed: + return trpc.TaskStateFailed + case a2av1.TaskStateRejected: + return trpc.TaskStateRejected + case a2av1.TaskStateAuthRequired: + return trpc.TaskStateAuthRequired + default: + return trpc.TaskStateUnknown + } +} + +func toLegacyMessageRole(role a2av1.MessageRole) trpc.MessageRole { + switch role { + case a2av1.MessageRoleAgent: + return trpc.MessageRoleAgent + case a2av1.MessageRoleUser: + return trpc.MessageRoleUser + default: + return trpc.MessageRoleAgent + } +} + +func toLegacyTaskIDs(ids []a2av1.TaskID) []string { + if len(ids) == 0 { + return nil + } + result := make([]string, len(ids)) + for i := range ids { + result[i] = string(ids[i]) + } + return result +} + +func formatTimestamp(timestamp *time.Time) string { + if timestamp == nil { + return "" + } + return timestamp.UTC().Format(time.RFC3339Nano) +} + +func ToOfficialV0TaskStatus(status trpc.TaskStatus) (legacya2a.TaskStatus, error) { + var msg *legacya2a.Message + var err error + if status.Message != nil { + msg, err = ToOfficialV0Message(status.Message) + if err != nil { + return legacya2a.TaskStatus{}, fmt.Errorf("convert task status message: %w", err) + } + } + timestamp, err := parseTimestamp(status.Timestamp) + if err != nil { + return legacya2a.TaskStatus{}, err + } + return legacya2a.TaskStatus{ + Message: msg, + State: ToOfficialV0TaskState(status.State), + Timestamp: timestamp, + }, nil +} + +func ToOfficialV0Message(message *trpc.Message) (*legacya2a.Message, error) { + if message == nil { + return nil, nil + } + parts, err := ToOfficialV0Parts(message.Parts) + if err != nil { + return nil, err + } + return &legacya2a.Message{ + ID: message.MessageID, + ContextID: derefString(message.ContextID), + Extensions: message.Extensions, + Metadata: message.Metadata, + Parts: parts, + ReferenceTasks: toOfficialV0TaskIDs(message.ReferenceTaskIDs), + Role: ToOfficialV0MessageRole(message.Role), + TaskID: legacya2a.TaskID(derefString(message.TaskID)), + }, nil +} + +func ToOfficialV0Artifact(artifact *trpc.Artifact) (*legacya2a.Artifact, error) { + if artifact == nil { + return nil, nil + } + parts, err := ToOfficialV0Parts(artifact.Parts) + if err != nil { + return nil, err + } + return &legacya2a.Artifact{ + ID: legacya2a.ArtifactID(artifact.ArtifactID), + Description: derefString(artifact.Description), + Extensions: artifact.Extensions, + Metadata: artifact.Metadata, + Name: derefString(artifact.Name), + Parts: parts, + }, nil +} + +func ToOfficialV0Parts(parts []trpc.Part) (legacya2a.ContentParts, error) { + if len(parts) == 0 { + return nil, nil + } + result := make(legacya2a.ContentParts, len(parts)) + for i, part := range parts { + converted, err := ToOfficialV0Part(part) + if err != nil { + return nil, fmt.Errorf("convert part %d: %w", i, err) + } + result[i] = converted + } + return result, nil +} + +func ToOfficialV0Part(part trpc.Part) (legacya2a.Part, error) { + switch p := part.(type) { + case nil: + return nil, nil + case trpc.TextPart: + return legacya2a.TextPart{Text: p.Text, Metadata: p.Metadata}, nil + case *trpc.TextPart: + return legacya2a.TextPart{Text: p.Text, Metadata: p.Metadata}, nil + case trpc.DataPart: + return toOfficialV0DataPart(p), nil + case *trpc.DataPart: + return toOfficialV0DataPart(*p), nil + case trpc.FilePart: + return ToOfficialV0FilePart(p) + case *trpc.FilePart: + return ToOfficialV0FilePart(*p) + default: + return nil, fmt.Errorf("unsupported trpc part type %T", part) + } +} + +func ToOfficialV0FilePart(part trpc.FilePart) (legacya2a.Part, error) { + switch file := part.File.(type) { + case nil: + return nil, fmt.Errorf("file part missing file payload") + case *trpc.FileWithBytes: + return officialV0FileBytes(*file, part.Metadata), nil + case *trpc.FileWithURI: + return officialV0FileURI(*file, part.Metadata), nil + default: + return nil, fmt.Errorf("unsupported trpc file payload type %T", part.File) + } +} + +func ToOfficialV0PushConfig(cfg *trpc.TaskPushNotificationConfig) *legacya2a.TaskPushConfig { + if cfg == nil { + return nil + } + pushConfig := legacya2a.PushConfig{ + ID: cfg.PushNotificationConfig.ID, + Token: cfg.PushNotificationConfig.Token, + URL: cfg.PushNotificationConfig.URL, + } + if cfg.PushNotificationConfig.Authentication != nil { + pushConfig.Auth = &legacya2a.PushAuthInfo{ + Credentials: derefString(cfg.PushNotificationConfig.Authentication.Credentials), + Schemes: cfg.PushNotificationConfig.Authentication.Schemes, + } + } + return &legacya2a.TaskPushConfig{ + Config: pushConfig, + TaskID: legacya2a.TaskID(cfg.TaskID), + } +} + +func ToOfficialV0TaskState(state trpc.TaskState) legacya2a.TaskState { + switch state { + case trpc.TaskStateSubmitted: + return legacya2a.TaskStateSubmitted + case trpc.TaskStateWorking: + return legacya2a.TaskStateWorking + case trpc.TaskStateInputRequired: + return legacya2a.TaskStateInputRequired + case trpc.TaskStateCompleted: + return legacya2a.TaskStateCompleted + case trpc.TaskStateCanceled: + return legacya2a.TaskStateCanceled + case trpc.TaskStateFailed: + return legacya2a.TaskStateFailed + case trpc.TaskStateRejected: + return legacya2a.TaskStateRejected + case trpc.TaskStateAuthRequired: + return legacya2a.TaskStateAuthRequired + case trpc.TaskStateUnknown: + return legacya2a.TaskStateUnknown + default: + return legacya2a.TaskStateUnspecified + } +} + +func ToOfficialV0MessageRole(role trpc.MessageRole) legacya2a.MessageRole { + switch role { + case trpc.MessageRoleAgent: + return legacya2a.MessageRoleAgent + case trpc.MessageRoleUser: + return legacya2a.MessageRoleUser + default: + return legacya2a.MessageRoleUnspecified + } +} + +func toOfficialV0DataPart(part trpc.DataPart) legacya2a.DataPart { + data, ok := part.Data.(map[string]any) + metadata := maps.Clone(part.Metadata) + if !ok { + data = map[string]any{"value": part.Data} + if metadata == nil { + metadata = map[string]any{} + } + metadata["data_part_compat"] = true + } + return legacya2a.DataPart{Data: data, Metadata: metadata} +} + +func officialV0FileBytes(file trpc.FileWithBytes, metadata map[string]any) legacya2a.FilePart { + return legacya2a.FilePart{ + File: legacya2a.FileBytes{ + FileMeta: legacya2a.FileMeta{ + MimeType: derefString(file.MimeType), + Name: derefString(file.Name), + }, + Bytes: file.Bytes, + }, + Metadata: metadata, + } +} + +func officialV0FileURI(file trpc.FileWithURI, metadata map[string]any) legacya2a.FilePart { + return legacya2a.FilePart{ + File: legacya2a.FileURI{ + FileMeta: legacya2a.FileMeta{ + MimeType: derefString(file.MimeType), + Name: derefString(file.Name), + }, + URI: file.URI, + }, + Metadata: metadata, + } +} + +func parseTimestamp(raw string) (*time.Time, error) { + if raw == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return nil, fmt.Errorf("parse task status timestamp %q: %w", raw, err) + } + return &parsed, nil +} + +func toOfficialV0TaskIDs(ids []string) []legacya2a.TaskID { + if len(ids) == 0 { + return nil + } + result := make([]legacya2a.TaskID, len(ids)) + for i, id := range ids { + result[i] = legacya2a.TaskID(id) + } + return result +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/go/core/pkg/a2acompat/trpcv0/convert_test.go b/go/core/pkg/a2acompat/trpcv0/convert_test.go new file mode 100644 index 0000000000..1c974454ff --- /dev/null +++ b/go/core/pkg/a2acompat/trpcv0/convert_test.go @@ -0,0 +1,308 @@ +package trpcv0 + +import ( + "encoding/json" + "testing" + "time" + + a2av1 "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/google/go-cmp/cmp" + trpc "trpc.group/trpc-go/trpc-a2a-go/protocol" +) + +func TestTaskJSONToV1JSON_ClusterTextTask(t *testing.T) { + task := mustConvertTaskJSONToV1(t, LegacyTextTaskFixture()) + assertForwardTextTaskFixture(t, task) +} + +func TestTaskJSONToV1JSON_ClusterDataTask(t *testing.T) { + task := mustConvertTaskJSONToV1(t, LegacyDataTaskFixture()) + assertForwardDataTaskFixture(t, task) +} + +func TestPushNotificationJSONToV1JSON(t *testing.T) { + cfg := mustConvertPushNotificationJSONToV1(t, LegacyPushConfigFixture()) + + want := a2av1.PushConfig{ + TaskID: "task-1", + ID: "cfg-1", + URL: "https://callback.example", + Token: "tok", + Auth: &a2av1.PushAuthInfo{ + Credentials: "cred", + Scheme: "Bearer", + }, + } + if diff := cmp.Diff(want, cfg); diff != "" { + t.Fatalf("unexpected push config (-want +got):\n%s", diff) + } +} + +func TestToLegacyTask_FromV1RichFixture(t *testing.T) { + v1Task := buildV1RichTaskFixture() + got := mustConvertToLegacyTask(t, v1Task) + assertBackwardTaskFixture(t, got) +} + +func TestToLegacyPushConfig_FromV1(t *testing.T) { + got := ToLegacyPushConfig(buildV1PushConfigFixture()) + if got == nil { + t.Fatal("expected non-nil config") + } + if got.TaskID != "task-v1-rich" || got.PushNotificationConfig.ID != "cfg-v1" || got.PushNotificationConfig.URL != "https://callback.example/v1" || got.PushNotificationConfig.Token != "token-v1" { + t.Fatalf("unexpected legacy push config: %+v", got) + } + if got.PushNotificationConfig.Authentication == nil { + t.Fatal("expected authentication") + } + if got.PushNotificationConfig.Authentication.Credentials == nil || *got.PushNotificationConfig.Authentication.Credentials != "secret" { + t.Fatalf("credentials = %v", got.PushNotificationConfig.Authentication.Credentials) + } + if len(got.PushNotificationConfig.Authentication.Schemes) != 1 || got.PushNotificationConfig.Authentication.Schemes[0] != "Bearer" { + t.Fatalf("schemes = %+v", got.PushNotificationConfig.Authentication.Schemes) + } +} + +func mustConvertTaskJSONToV1(t *testing.T, fixture trpc.Task) a2av1.Task { + t.Helper() + input, err := json.Marshal(fixture) + if err != nil { + t.Fatalf("marshal legacy task fixture: %v", err) + } + data, err := TaskJSONToV1JSON(input) + if err != nil { + t.Fatalf("TaskJSONToV1JSON() error = %v", err) + } + var task a2av1.Task + if err := json.Unmarshal(data, &task); err != nil { + t.Fatalf("unmarshal v1 task: %v\njson: %s", err, data) + } + return task +} + +func mustConvertPushNotificationJSONToV1(t *testing.T, fixture trpc.TaskPushNotificationConfig) a2av1.PushConfig { + t.Helper() + input, err := json.Marshal(fixture) + if err != nil { + t.Fatalf("marshal legacy push fixture: %v", err) + } + data, err := PushNotificationJSONToV1JSON(input) + if err != nil { + t.Fatalf("PushNotificationJSONToV1JSON() error = %v", err) + } + var cfg a2av1.PushConfig + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal v1 push config: %v", err) + } + return cfg +} + +func mustConvertToLegacyTask(t *testing.T, fixture *a2av1.Task) *trpc.Task { + t.Helper() + got, err := ToLegacyTask(fixture) + if err != nil { + t.Fatalf("ToLegacyTask() error = %v", err) + } + return got +} + +func assertForwardTextTaskFixture(t *testing.T, task a2av1.Task) { + t.Helper() + if task.ID != "019d49ab-6830-763c-9db6-1b6359228c4c" { + t.Fatalf("task ID = %q", task.ID) + } + if task.Status.State != a2av1.TaskStateCompleted { + t.Fatalf("task state = %q", task.Status.State) + } + if got := task.History[0].Role; got != a2av1.MessageRoleUser { + t.Fatalf("history role = %q", got) + } + if got := task.History[0].Parts[0].Text(); got != "hi" { + t.Fatalf("history text part = %q", got) + } + if got := task.Artifacts[0].Parts[0].Text(); got != "Hello! How can I assist you with Kubernetes today?" { + t.Fatalf("artifact text part = %q", got) + } +} + +func assertForwardDataTaskFixture(t *testing.T, task a2av1.Task) { + t.Helper() + if task.Status.State != a2av1.TaskStateInputRequired { + t.Fatalf("task state = %q", task.Status.State) + } + if task.Status.Message == nil { + t.Fatal("expected status message") + } + dataPart, ok := task.Status.Message.Parts[0].Data().(map[string]any) + if !ok { + t.Fatalf("status message part data type = %T", task.Status.Message.Parts[0].Data()) + } + if got := dataPart["name"]; got != "adk_request_confirmation" { + t.Fatalf("status message data name = %v", got) + } +} + +func assertBackwardTaskFixture(t *testing.T, got *trpc.Task) { + t.Helper() + if got.ID != "task-v1-rich" { + t.Fatalf("task ID = %q", got.ID) + } + if got.ContextID != "ctx-bridge" { + t.Fatalf("context ID = %q", got.ContextID) + } + if got.Status.State != trpc.TaskStateWorking { + t.Fatalf("status state = %q", got.Status.State) + } + wantTS := time.Date(2026, time.January, 2, 3, 4, 5, 123456000, time.UTC).Format(time.RFC3339Nano) + if got.Status.Timestamp != wantTS { + t.Fatalf("status timestamp = %q", got.Status.Timestamp) + } + if got.Status.Message == nil { + t.Fatal("expected status message") + } + if got.Status.Message.MessageID == "" { + t.Fatal("expected non-empty status message ID") + } + if got.Status.Message.TaskID == nil || *got.Status.Message.TaskID != "task-v1-rich" { + t.Fatalf("status message task ID = %v", got.Status.Message.TaskID) + } + + if len(got.History) != 1 { + t.Fatalf("history length = %d", len(got.History)) + } + if len(got.History[0].Parts) != 4 { + t.Fatalf("history parts length = %d", len(got.History[0].Parts)) + } + + textPart := mustTextPart(t, got.History[0].Parts[0]) + if textPart.Kind != trpc.KindText || textPart.Text != "hello" { + t.Fatalf("unexpected text part: %+v", textPart) + } + dataPart := mustDataPart(t, got.History[0].Parts[1]) + dataMap, ok := dataPart.Data.(map[string]any) + if !ok { + t.Fatalf("data part payload type = %T", dataPart.Data) + } + if dataMap["step"] != float64(1) { + t.Fatalf("data part step = %v", dataMap["step"]) + } + urlFilePart := mustFilePart(t, got.History[0].Parts[2]) + urlFile, ok := urlFilePart.File.(*trpc.FileWithURI) + if !ok { + t.Fatalf("expected FileWithURI, got %T", urlFilePart.File) + } + if urlFile.URI != "https://example.com/doc.md" { + t.Fatalf("file URI = %q", urlFile.URI) + } + rawFilePart := mustFilePart(t, got.History[0].Parts[3]) + rawFile, ok := rawFilePart.File.(*trpc.FileWithBytes) + if !ok { + t.Fatalf("expected FileWithBytes, got %T", rawFilePart.File) + } + if rawFile.Bytes != "RAW_BYTES" { + t.Fatalf("raw bytes = %q", rawFile.Bytes) + } + + if len(got.Artifacts) != 1 || len(got.Artifacts[0].Parts) != 1 { + t.Fatalf("unexpected artifacts: %+v", got.Artifacts) + } + if gotArtifactText := mustTextPart(t, got.Artifacts[0].Parts[0]).Text; gotArtifactText != "artifact-text" { + t.Fatalf("artifact text = %q", gotArtifactText) + } +} + +func mustTextPart(t *testing.T, part trpc.Part) trpc.TextPart { + t.Helper() + switch p := part.(type) { + case trpc.TextPart: + return p + case *trpc.TextPart: + return *p + default: + t.Fatalf("expected TextPart, got %T", part) + return trpc.TextPart{} + } +} + +func mustDataPart(t *testing.T, part trpc.Part) trpc.DataPart { + t.Helper() + switch p := part.(type) { + case trpc.DataPart: + return p + case *trpc.DataPart: + return *p + default: + t.Fatalf("expected DataPart, got %T", part) + return trpc.DataPart{} + } +} + +func mustFilePart(t *testing.T, part trpc.Part) trpc.FilePart { + t.Helper() + switch p := part.(type) { + case trpc.FilePart: + return p + case *trpc.FilePart: + return *p + default: + t.Fatalf("expected FilePart, got %T", part) + return trpc.FilePart{} + } +} + +func buildV1RichTaskFixture() *a2av1.Task { + ts := time.Date(2026, time.January, 2, 3, 4, 5, 123456000, time.UTC) + taskID := a2av1.TaskID("task-v1-rich") + fileURLPart := a2av1.NewFileURLPart(a2av1.URL("https://example.com/doc.md"), "text/markdown") + fileURLPart.Filename = "doc.md" + fileURLPart.Metadata = map[string]any{"source": "url"} + rawPart := a2av1.NewRawPart([]byte("RAW_BYTES")) + rawPart.Filename = "blob.bin" + rawPart.MediaType = "application/octet-stream" + rawPart.Metadata = map[string]any{"source": "raw"} + + statusMessage := a2av1.NewMessage(a2av1.MessageRoleAgent, a2av1.NewTextPart("working")) + statusMessage.TaskID = taskID + statusMessage.ContextID = "ctx-bridge" + + historyMessage := a2av1.NewMessage( + a2av1.MessageRoleUser, + a2av1.NewTextPart("hello"), + a2av1.NewDataPart(map[string]any{"step": float64(1)}), + fileURLPart, + rawPart, + ) + historyMessage.TaskID = taskID + historyMessage.ContextID = "ctx-bridge" + + return &a2av1.Task{ + ID: taskID, + ContextID: "ctx-bridge", + Metadata: map[string]any{"kagent": "true"}, + Status: a2av1.TaskStatus{ + State: a2av1.TaskStateWorking, + Timestamp: &ts, + Message: statusMessage, + }, + History: []*a2av1.Message{historyMessage}, + Artifacts: []*a2av1.Artifact{ + { + ID: "artifact-v1", + Parts: a2av1.ContentParts{a2av1.NewTextPart("artifact-text")}, + }, + }, + } +} + +func buildV1PushConfigFixture() *a2av1.PushConfig { + return &a2av1.PushConfig{ + TaskID: "task-v1-rich", + ID: "cfg-v1", + URL: "https://callback.example/v1", + Token: "token-v1", + Auth: &a2av1.PushAuthInfo{ + Credentials: "secret", + Scheme: "Bearer", + }, + } +} diff --git a/go/core/pkg/a2acompat/trpcv0/fixtures.go b/go/core/pkg/a2acompat/trpcv0/fixtures.go new file mode 100644 index 0000000000..732723ff79 --- /dev/null +++ b/go/core/pkg/a2acompat/trpcv0/fixtures.go @@ -0,0 +1,83 @@ +package trpcv0 + +import trpc "trpc.group/trpc-go/trpc-a2a-go/protocol" + +// LegacyTextTaskFixture returns a representative trpc-a2a-go task used in migration tests. +func LegacyTextTaskFixture() trpc.Task { + return trpc.Task{ + ID: "019d49ab-6830-763c-9db6-1b6359228c4c", + Kind: trpc.KindTask, + ContextID: "ctx-text", + Status: trpc.TaskStatus{ + State: trpc.TaskStateCompleted, + Message: &trpc.Message{ + Kind: trpc.KindMessage, + MessageID: "msg-status-1", + Role: trpc.MessageRoleAgent, + Parts: []trpc.Part{ + trpc.TextPart{Kind: trpc.KindText, Text: "done"}, + }, + }, + }, + History: []trpc.Message{ + { + Kind: trpc.KindMessage, + MessageID: "msg-user-1", + Role: trpc.MessageRoleUser, + Parts: []trpc.Part{ + trpc.TextPart{Kind: trpc.KindText, Text: "hi"}, + }, + }, + }, + Artifacts: []trpc.Artifact{ + { + ArtifactID: "artifact-1", + Parts: []trpc.Part{ + trpc.TextPart{Kind: trpc.KindText, Text: "Hello! How can I assist you with Kubernetes today?"}, + }, + }, + }, + } +} + +// LegacyDataTaskFixture returns a trpc-a2a-go task with a data status message. +func LegacyDataTaskFixture() trpc.Task { + return trpc.Task{ + ID: "task-data-1", + Kind: trpc.KindTask, + ContextID: "ctx-data", + Status: trpc.TaskStatus{ + State: trpc.TaskStateInputRequired, + Message: &trpc.Message{ + Kind: trpc.KindMessage, + MessageID: "msg-status-data-1", + Role: trpc.MessageRoleAgent, + Parts: []trpc.Part{ + trpc.DataPart{ + Kind: trpc.KindData, + Data: map[string]any{ + "name": "adk_request_confirmation", + }, + }, + }, + }, + }, + } +} + +// LegacyPushConfigFixture returns a representative trpc-a2a-go push notification config. +func LegacyPushConfigFixture() trpc.TaskPushNotificationConfig { + cred := "cred" + return trpc.TaskPushNotificationConfig{ + TaskID: "task-1", + PushNotificationConfig: trpc.PushNotificationConfig{ + ID: "cfg-1", + URL: "https://callback.example", + Token: "tok", + Authentication: &trpc.AuthenticationInfo{ + Credentials: &cred, + Schemes: []string{"Bearer"}, + }, + }, + } +} diff --git a/go/core/pkg/a2amigration/runner.go b/go/core/pkg/a2amigration/runner.go new file mode 100644 index 0000000000..45d1862be3 --- /dev/null +++ b/go/core/pkg/a2amigration/runner.go @@ -0,0 +1,191 @@ +package a2amigration + +import ( + "context" + "errors" + "fmt" + "io" + "log" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + dbgen "github.com/kagent-dev/kagent/go/core/internal/database/gen" + "github.com/kagent-dev/kagent/go/core/pkg/a2acompat/trpcv0" +) + +const defaultBatchSize = 100 + +type Options struct { + BatchSize int + DryRun bool + Out io.Writer +} + +type Stats struct { + TasksMigrated int + TasksSkipped int + TasksFailed int + PushNotificationsMigrated int + PushNotificationsSkipped int + PushNotificationsFailed int + AlreadyV1 int +} + +func Run(ctx context.Context, db *pgxpool.Pool, opts Options) (Stats, error) { + if opts.BatchSize <= 0 { + opts.BatchSize = defaultBatchSize + } + + q := dbgen.New(db) + v1 := trpcv0.ProtocolVersionV1 + limit := int32(opts.BatchSize) + + alreadyV1, err := q.CountAlreadyV1Rows(ctx, &v1) + if err != nil { + return Stats{}, fmt.Errorf("count v1 rows: %w", err) + } + stats := Stats{AlreadyV1: int(alreadyV1)} + + if err := rejectUnknownVersions(ctx, q, &v1); err != nil { + return stats, err + } + + taskStats, err := migrateTable(ctx, tableConfig{ + name: "task", + list: func(ctx context.Context, lastID string) ([]rowData, error) { + rows, err := q.ListLegacyTasks(ctx, dbgen.ListLegacyTasksParams{ID: lastID, Limit: limit}) + if err != nil { + return nil, err + } + result := make([]rowData, len(rows)) + for i, r := range rows { + result[i] = rowData{id: r.ID, data: r.Data} + } + return result, nil + }, + update: func(ctx context.Context, newData, oldData, id string) (pgconn.CommandTag, error) { + return q.MigrateTask(ctx, dbgen.MigrateTaskParams{ + Data: newData, + ProtocolVersion: &v1, + ID: id, + Data_2: oldData, + }) + }, + convert: func(data string) ([]byte, error) { + return trpcv0.TaskJSONToV1JSON([]byte(data)) + }, + }, opts) + if err != nil { + return stats, err + } + stats.TasksMigrated = taskStats.migrated + stats.TasksSkipped = taskStats.skipped + stats.TasksFailed = taskStats.failed + + pushStats, err := migrateTable(ctx, tableConfig{ + name: "push_notification", + list: func(ctx context.Context, lastID string) ([]rowData, error) { + rows, err := q.ListLegacyPushNotifications(ctx, dbgen.ListLegacyPushNotificationsParams{ID: lastID, Limit: limit}) + if err != nil { + return nil, err + } + result := make([]rowData, len(rows)) + for i, r := range rows { + result[i] = rowData{id: r.ID, data: r.Data} + } + return result, nil + }, + update: func(ctx context.Context, newData, oldData, id string) (pgconn.CommandTag, error) { + return q.MigratePushNotification(ctx, dbgen.MigratePushNotificationParams{ + Data: newData, + ProtocolVersion: &v1, + ID: id, + Data_2: oldData, + }) + }, + convert: func(data string) ([]byte, error) { + return trpcv0.PushNotificationJSONToV1JSON([]byte(data)) + }, + }, opts) + if err != nil { + return stats, err + } + stats.PushNotificationsMigrated = pushStats.migrated + stats.PushNotificationsSkipped = pushStats.skipped + stats.PushNotificationsFailed = pushStats.failed + + return stats, nil +} + +type tableConfig struct { + name string + list func(ctx context.Context, lastID string) ([]rowData, error) + update func(ctx context.Context, newData, oldData, id string) (pgconn.CommandTag, error) + convert func(data string) ([]byte, error) +} + +type tableStats struct { + migrated int + skipped int + failed int +} + +func migrateTable(ctx context.Context, cfg tableConfig, opts Options) (tableStats, error) { + var stats tableStats + lastID := "" + + for { + batch, err := cfg.list(ctx, lastID) + if err != nil { + return stats, fmt.Errorf("list %s rows: %w", cfg.name, err) + } + if len(batch) == 0 { + return stats, nil + } + + for _, row := range batch { + lastID = row.id + converted, err := cfg.convert(row.data) + if err != nil { + log.Printf("skipping %s row %s: conversion failed: %v", cfg.name, row.id, err) + stats.failed++ + continue + } + if opts.DryRun { + stats.migrated++ + continue + } + tag, err := cfg.update(ctx, string(converted), row.data, row.id) + if err != nil { + return stats, fmt.Errorf("update %s row %s: %w", cfg.name, row.id, err) + } + if tag.RowsAffected() == 0 { + stats.skipped++ + continue + } + stats.migrated++ + } + } +} + +type rowData struct { + id string + data string +} + +func rejectUnknownVersions(ctx context.Context, q *dbgen.Queries, v1version *string) error { + rows, err := q.ListUnknownProtocolVersions(ctx, v1version) + if err != nil { + return fmt.Errorf("check protocol versions: %w", err) + } + + var unknown []error + for _, row := range rows { + version := "" + if row.ProtocolVersion != nil { + version = *row.ProtocolVersion + } + unknown = append(unknown, fmt.Errorf("%s has %d row(s) with unsupported protocol_version %q", row.TableName, row.RowCount, version)) + } + return errors.Join(unknown...) +} diff --git a/go/core/pkg/a2amigration/runner_test.go b/go/core/pkg/a2amigration/runner_test.go new file mode 100644 index 0000000000..9418b5b126 --- /dev/null +++ b/go/core/pkg/a2amigration/runner_test.go @@ -0,0 +1,155 @@ +package a2amigration + +import ( + "context" + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/kagent-dev/kagent/go/core/internal/dbtest" + "github.com/kagent-dev/kagent/go/core/pkg/a2acompat/trpcv0" +) + +func TestRunMigratesA2AData(t *testing.T) { + if testing.Short() { + t.Skip("skipping database integration test in short mode") + } + + ctx := context.Background() + connStr := dbtest.StartT(ctx, t) + dbtest.MigrateT(t, connStr, false) + + db, err := pgxpool.New(ctx, connStr) + if err != nil { + t.Fatalf("connect database: %v", err) + } + t.Cleanup(db.Close) + + taskData := mustMarshalLegacyFixture(t, trpcv0.LegacyTextTaskFixture()) + pushData := mustMarshalLegacyFixture(t, trpcv0.LegacyPushConfigFixture()) + + _, err = db.Exec(ctx, `INSERT INTO task (id, data, session_id) VALUES ($1, $2, $3)`, "task-1", string(taskData), "session-1") + if err != nil { + t.Fatalf("insert task: %v", err) + } + _, err = db.Exec(ctx, `INSERT INTO push_notification (id, task_id, data) VALUES ($1, $2, $3)`, "push-1", "task-1", string(pushData)) + if err != nil { + t.Fatalf("insert push notification: %v", err) + } + + dryRunStats, err := Run(ctx, db, Options{DryRun: true, BatchSize: 1}) + if err != nil { + t.Fatalf("dry-run migration: %v", err) + } + if dryRunStats.TasksMigrated != 1 || dryRunStats.PushNotificationsMigrated != 1 { + t.Fatalf("dry-run stats = %+v", dryRunStats) + } + assertProtocolVersion(t, db, "task", "task-1", nil) + assertProtocolVersion(t, db, "push_notification", "push-1", nil) + + stats, err := Run(ctx, db, Options{BatchSize: 1}) + if err != nil { + t.Fatalf("migration: %v", err) + } + if stats.TasksMigrated != 1 || stats.PushNotificationsMigrated != 1 { + t.Fatalf("stats = %+v", stats) + } + wantV1 := trpcv0.ProtocolVersionV1 + assertProtocolVersion(t, db, "task", "task-1", &wantV1) + assertProtocolVersion(t, db, "push_notification", "push-1", &wantV1) + assertTaskLooksV1(t, db) + assertPushNotificationLooksV1(t, db) + + secondStats, err := Run(ctx, db, Options{BatchSize: 1}) + if err != nil { + t.Fatalf("second migration: %v", err) + } + if secondStats.TasksMigrated != 0 || secondStats.PushNotificationsMigrated != 0 || secondStats.AlreadyV1 != 2 { + t.Fatalf("second stats = %+v", secondStats) + } +} + +func TestRunRejectsUnknownProtocolVersion(t *testing.T) { + if testing.Short() { + t.Skip("skipping database integration test in short mode") + } + + ctx := context.Background() + connStr := dbtest.StartT(ctx, t) + dbtest.MigrateT(t, connStr, false) + + db, err := pgxpool.New(ctx, connStr) + if err != nil { + t.Fatalf("connect database: %v", err) + } + t.Cleanup(db.Close) + + _, err = db.Exec(ctx, `INSERT INTO task (id, data, protocol_version) VALUES ($1, $2, $3)`, "task-unknown", `{}`, "2.0") + if err != nil { + t.Fatalf("insert task: %v", err) + } + + _, err = Run(ctx, db, Options{}) + if err == nil { + t.Fatal("expected unknown protocol version error") + } +} + +func assertProtocolVersion(t *testing.T, db *pgxpool.Pool, table, id string, want *string) { + t.Helper() + var got *string + err := db.QueryRow(context.Background(), `SELECT protocol_version FROM `+table+` WHERE id = $1`, id).Scan(&got) + if err != nil { + t.Fatalf("query protocol_version: %v", err) + } + if want == nil { + if got != nil { + t.Fatalf("%s protocol_version = %q, want nil", table, *got) + } + return + } + if got == nil || *got != *want { + t.Fatalf("%s protocol_version = %v, want %q", table, got, *want) + } +} + +func assertTaskLooksV1(t *testing.T, db *pgxpool.Pool) { + t.Helper() + var text string + err := db.QueryRow(context.Background(), `SELECT data::jsonb #>> '{history,0,parts,0,text}' FROM task WHERE id = 'task-1'`).Scan(&text) + if err != nil { + t.Fatalf("query v1 task text part: %v", err) + } + if text != "hi" { + t.Fatalf("v1 task text part = %q", text) + } + var role string + err = db.QueryRow(context.Background(), `SELECT data::jsonb #>> '{history,0,role}' FROM task WHERE id = 'task-1'`).Scan(&role) + if err != nil { + t.Fatalf("query v1 task role: %v", err) + } + if role != "ROLE_USER" { + t.Fatalf("v1 task role = %q", role) + } +} + +func assertPushNotificationLooksV1(t *testing.T, db *pgxpool.Pool) { + t.Helper() + var scheme string + err := db.QueryRow(context.Background(), `SELECT data::jsonb #>> '{authentication,scheme}' FROM push_notification WHERE id = 'push-1'`).Scan(&scheme) + if err != nil { + t.Fatalf("query v1 push notification auth scheme: %v", err) + } + if scheme != "Bearer" { + t.Fatalf("v1 push notification auth scheme = %q", scheme) + } +} + +func mustMarshalLegacyFixture(t *testing.T, fixture any) []byte { + t.Helper() + data, err := json.Marshal(fixture) + if err != nil { + t.Fatalf("marshal legacy fixture: %v", err) + } + return data +} diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index ddad07d546..d9b534336b 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -34,7 +34,6 @@ import ( "github.com/hashicorp/go-multierror" "github.com/kagent-dev/kagent/go/core/internal/version" - "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/types" "github.com/kagent-dev/kagent/go/core/internal/a2a" @@ -114,11 +113,6 @@ type Config struct { CertName string CertKey string } - Streaming struct { - MaxBufSize resource.QuantityValue `default:"1Mi"` - InitialBufSize resource.QuantityValue `default:"4Ki"` - Timeout time.Duration `default:"60s"` - } Proxy struct { URL string } @@ -180,10 +174,6 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.StringVar(&cfg.WatchNamespaces, "watch-namespaces", "", "The namespaces to watch for .") - commandLine.Var(&cfg.Streaming.MaxBufSize, "streaming-max-buf-size", "The maximum size of the streaming buffer.") - commandLine.Var(&cfg.Streaming.InitialBufSize, "streaming-initial-buf-size", "The initial size of the streaming buffer.") - commandLine.DurationVar(&cfg.Streaming.Timeout, "streaming-timeout", 60*time.Second, "The timeout for the streaming connection.") - commandLine.StringVar(&cfg.Proxy.URL, "proxy-url", "", "Proxy URL for internally-built k8s URLs (e.g., http://proxy.kagent.svc.cluster.local:8080)") commandLine.StringVar(&cfg.Auth.Mode, "auth-mode", "unsecure", "Authentication mode: unsecure or trusted-proxy") @@ -621,9 +611,6 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne cfg.A2ABaseUrl+httpserver.APIPathA2A, cfg.A2ABaseUrl+httpserver.APIPathA2ASandboxes, extensionCfg.Authenticator, - int(cfg.Streaming.MaxBufSize.Value()), - int(cfg.Streaming.InitialBufSize.Value()), - cfg.Streaming.Timeout, ) if err != nil { setupLog.Error(err, "unable to create a2a registrar") diff --git a/go/core/pkg/app/app_test.go b/go/core/pkg/app/app_test.go index 7b1a558ab6..24c9b7c3da 100644 --- a/go/core/pkg/app/app_test.go +++ b/go/core/pkg/app/app_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/api/resource" ) func TestFilterValidNamespaces(t *testing.T) { @@ -402,9 +401,6 @@ func TestLoadFromEnvIntegration(t *testing.T) { "PROXY_URL": "http://proxy.kagent.svc.cluster.local:8080", "POSTGRES_DATABASE_URL": "postgres://localhost:5432/testdb", "WATCH_NAMESPACES": "ns1,ns2,ns3", - "STREAMING_TIMEOUT": "120s", - "STREAMING_MAX_BUF_SIZE": "2Mi", - "STREAMING_INITIAL_BUF_SIZE": "8Ki", } for k, v := range envVars { @@ -456,18 +452,4 @@ func TestLoadFromEnvIntegration(t *testing.T) { if cfg.WatchNamespaces != "ns1,ns2,ns3" { t.Errorf("WatchNamespaces = %v, want ns1,ns2,ns3", cfg.WatchNamespaces) } - if cfg.Streaming.Timeout != 120*time.Second { - t.Errorf("Streaming.Timeout = %v, want 120s", cfg.Streaming.Timeout) - } - - // Check quantity values - expectedMaxBuf := resource.MustParse("2Mi") - if cfg.Streaming.MaxBufSize.Cmp(expectedMaxBuf) != 0 { - t.Errorf("Streaming.MaxBufSize = %v, want 2Mi", cfg.Streaming.MaxBufSize) - } - - expectedInitBuf := resource.MustParse("8Ki") - if cfg.Streaming.InitialBufSize.Cmp(expectedInitBuf) != 0 { - t.Errorf("Streaming.InitialBufSize = %v, want 8Ki", cfg.Streaming.InitialBufSize) - } } diff --git a/go/core/pkg/migrations/core/000005_a2a_protocol_version.down.sql b/go/core/pkg/migrations/core/000005_a2a_protocol_version.down.sql new file mode 100644 index 0000000000..ad866c8d26 --- /dev/null +++ b/go/core/pkg/migrations/core/000005_a2a_protocol_version.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE task DROP COLUMN IF EXISTS protocol_version; +ALTER TABLE push_notification DROP COLUMN IF EXISTS protocol_version; diff --git a/go/core/pkg/migrations/core/000005_a2a_protocol_version.up.sql b/go/core/pkg/migrations/core/000005_a2a_protocol_version.up.sql new file mode 100644 index 0000000000..123820c133 --- /dev/null +++ b/go/core/pkg/migrations/core/000005_a2a_protocol_version.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE task ADD COLUMN IF NOT EXISTS protocol_version TEXT; +ALTER TABLE push_notification ADD COLUMN IF NOT EXISTS protocol_version TEXT; diff --git a/go/core/pkg/translator/adk_api_translator_types.go b/go/core/pkg/translator/adk_api_translator_types.go index 07fd4268d5..df07f70546 100644 --- a/go/core/pkg/translator/adk_api_translator_types.go +++ b/go/core/pkg/translator/adk_api_translator_types.go @@ -3,17 +3,17 @@ package translator import ( "context" + a2a "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha2" "sigs.k8s.io/controller-runtime/pkg/client" - "trpc.group/trpc-go/trpc-a2a-go/server" ) type AgentOutputs struct { Manifest []client.Object `json:"manifest,omitempty"` Config *adk.AgentConfig `json:"config,omitempty"` - AgentCard server.AgentCard `json:"agentCard"` + AgentCard a2a.AgentCard `json:"agentCard"` } type TranslatorPlugin interface { diff --git a/go/core/test/e2e/agents/kebab/kebab/agent-card.json b/go/core/test/e2e/agents/kebab/kebab/agent-card.json index 15df0efef0..d2001c70c3 100644 --- a/go/core/test/e2e/agents/kebab/kebab/agent-card.json +++ b/go/core/test/e2e/agents/kebab/kebab/agent-card.json @@ -1,7 +1,6 @@ { "name": "kebab", "description": "A kebab agent", - "url": "localhost:8080", "version": "0.0.1", "capabilities": { "streaming": true diff --git a/go/core/test/e2e/invoke_api_test.go b/go/core/test/e2e/invoke_api_test.go index 528fce2882..a958f1f66a 100644 --- a/go/core/test/e2e/invoke_api_test.go +++ b/go/core/test/e2e/invoke_api_test.go @@ -21,6 +21,8 @@ import ( "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2aclient "github.com/a2aproject/a2a-go/v2/a2aclient" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/a2a" "github.com/kagent-dev/kagent/go/core/internal/utils" @@ -29,8 +31,6 @@ import ( "github.com/kagent-dev/mockllm" "github.com/stretchr/testify/require" "sigs.k8s.io/controller-runtime/pkg/client/config" - a2aclient "trpc.group/trpc-go/trpc-a2a-go/client" - "trpc.group/trpc-go/trpc-a2a-go/protocol" ) //go:embed mocks @@ -231,28 +231,57 @@ func setupSandboxAgentWithOptions(t *testing.T, cli client.Client, modelConfigNa return agent } -// setupA2AClient creates an A2A client for the test agent -func setupA2AClient(t *testing.T, agent *v1alpha2.Agent) *a2aclient.A2AClient { - a2aURL := a2aURL(agent.Namespace, agent.Name, false) - a2aClient, err := a2aclient.NewA2AClient(a2aURL) +// newA2AClient creates a v2 A2A client targeting baseURL directly via JSON-RPC. +// The controller always serves both wire versions at the agent's path prefix; the +// A2A-Version header (default: 1.0) tells the mux which handler to use. +func newA2AClient(t *testing.T, baseURL string, httpClient *http.Client, headers map[string]string) *a2aclient.Client { + t.Helper() + if httpClient == nil { + httpClient = &http.Client{Timeout: 60 * time.Second} + } + if headers == nil { + headers = map[string]string{} + } + if _, ok := headers["A2A-Version"]; !ok { + headers["A2A-Version"] = string(a2atype.Version) + } + + // Use NewFromEndpoints with the explicit base URL rather than NewFromCard: + // the card's SupportedInterfaces contain the controller's internal cluster URL, + // which is unreachable from the test machine via port-forward. + endpointURL := strings.TrimRight(baseURL, "/") + "/" + a2aClient, err := a2aclient.NewFromEndpoints( + t.Context(), + []*a2atype.AgentInterface{{ + URL: endpointURL, + ProtocolVersion: a2atype.Version, + ProtocolBinding: a2atype.TransportProtocolJSONRPC, + }}, + a2aclient.WithJSONRPCTransport(httpClient), + a2aclient.WithCallInterceptors(a2a.NewStaticHeadersInterceptor(headers)), + ) require.NoError(t, err) + return a2aClient } +// setupA2AClient creates an A2A client for the test agent +func setupA2AClient(t *testing.T, agent *v1alpha2.Agent) *a2aclient.Client { + return newA2AClient(t, a2aURL(agent.Namespace, agent.Name, false), nil, nil) +} + // setupSandboxA2AClient creates an A2A client for the test sandbox agent. -func setupSandboxA2AClient(t *testing.T, agent *v1alpha2.SandboxAgent) *a2aclient.A2AClient { - a2aClient, err := a2aclient.NewA2AClient(a2aURL(agent.Namespace, agent.Name, true)) - require.NoError(t, err) - return a2aClient +func setupSandboxA2AClient(t *testing.T, agent *v1alpha2.SandboxAgent) *a2aclient.Client { + return newA2AClient(t, a2aURL(agent.Namespace, agent.Name, true), nil, nil) } // extractTextFromArtifacts extracts all text content from task artifacts -func extractTextFromArtifacts(taskResult *protocol.Task) string { +func extractTextFromArtifacts(taskResult *a2atype.Task) string { var text strings.Builder for _, artifact := range taskResult.Artifacts { for _, part := range artifact.Parts { - if textPart, ok := part.(*protocol.TextPart); ok { - text.WriteString(textPart.Text) + if part != nil { + text.WriteString(part.Text()) } } } @@ -269,22 +298,18 @@ var defaultRetry = wait.Backoff{ // runSyncTest runs a synchronous message test // useArtifacts: if true, check artifacts; if false or nil, check history; // contextID: optional context ID to maintain conversation context -func runSyncTest(t *testing.T, a2aClient *a2aclient.A2AClient, userMessage, expectedText string, useArtifacts *bool, contextID ...string) *protocol.Task { +func runSyncTest(t *testing.T, a2aClient *a2aclient.Client, userMessage, expectedText string, useArtifacts *bool, contextID ...string) *a2atype.Task { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - msg := protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - Parts: []protocol.Part{protocol.NewTextPart(userMessage)}, - } + msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(userMessage)) // If contextID is provided, set it to maintain conversation context if len(contextID) > 0 && contextID[0] != "" { - msg.ContextID = &contextID[0] + msg.ContextID = contextID[0] } - var result *protocol.MessageResult + var result a2atype.SendMessageResult err := retry.OnError(defaultRetry, func(err error) bool { return err != nil }, func() error { @@ -294,13 +319,13 @@ func runSyncTest(t *testing.T, a2aClient *a2aclient.A2AClient, userMessage, expe ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() t.Logf("%s trying to send message", time.Now().Format(time.RFC3339)) - result, retryErr = a2aClient.SendMessage(ctx, protocol.SendMessageParams{Message: msg}) + result, retryErr = a2aClient.SendMessage(ctx, &a2atype.SendMessageRequest{Message: msg}) t.Logf("%s finished trying sending message. success = %v", time.Now().Format(time.RFC3339), retryErr == nil) return retryErr }) require.NoError(t, err) - taskResult, ok := result.Result.(*protocol.Task) + taskResult, ok := result.(*a2atype.Task) require.True(t, ok) // Extract text based on useArtifacts flag @@ -322,22 +347,18 @@ func runSyncTest(t *testing.T, a2aClient *a2aclient.A2AClient, userMessage, expe // runStreamingTest runs a streaming message test // If contextID is provided, it will be included in the message to maintain conversation context // Checks the full JSON output to support both artifacts and history from different agent types -func runStreamingTest(t *testing.T, a2aClient *a2aclient.A2AClient, userMessage, expectedText string, contextID ...string) { - msg := protocol.Message{ - Kind: protocol.KindMessage, - Role: protocol.MessageRoleUser, - Parts: []protocol.Part{protocol.NewTextPart(userMessage)}, - } +func runStreamingTest(t *testing.T, a2aClient *a2aclient.Client, userMessage, expectedText string, contextID ...string) { + msg := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart(userMessage)) // If contextID is provided, set it to maintain conversation context if len(contextID) > 0 && contextID[0] != "" { - msg.ContextID = &contextID[0] + msg.ContextID = contextID[0] } // Retry the entire stream-connect-read-check cycle. // The most common failure mode is: stream connects but yields zero events // (agent not ready, stream closes early), so we need to retry the whole operation. - var lastJSON string + var lastText string err := retry.OnError(defaultRetry, func(err error) bool { return err != nil }, func() error { @@ -345,35 +366,51 @@ func runStreamingTest(t *testing.T, a2aClient *a2aclient.A2AClient, userMessage, defer cancel() t.Logf("%s trying to open stream", time.Now().Format(time.RFC3339)) - stream, streamErr := a2aClient.StreamMessage(ctx, protocol.SendMessageParams{Message: msg}) - if streamErr != nil { - t.Logf("%s stream connection failed: %v", time.Now().Format(time.RFC3339), streamErr) - return streamErr - } - - resultList := []protocol.StreamingMessageEvent{} - for event := range stream { - if _, ok := event.Result.(*protocol.TaskStatusUpdateEvent); !ok { + stream := a2aClient.SendStreamingMessage(ctx, &a2atype.SendMessageRequest{Message: msg}) + texts := make([]string, 0) + eventCount := 0 + for event, streamErr := range stream { + if streamErr != nil { + t.Logf("%s stream read failed: %v", time.Now().Format(time.RFC3339), streamErr) + return streamErr + } + eventCount++ + if event == nil { continue } - resultList = append(resultList, event) - } - - jsn, marshalErr := json.Marshal(resultList) - if marshalErr != nil { - return marshalErr + texts = append(texts, extractTextFromEvent(event)) } - lastJSON = string(jsn) + lastText = strings.Join(texts, "\n") - if !strings.Contains(lastJSON, expectedText) { - t.Logf("%s stream completed but expected text %q not found in response (got %d events)", time.Now().Format(time.RFC3339), expectedText, len(resultList)) - return fmt.Errorf("expected text %q not found in streaming response (%d events)", expectedText, len(resultList)) + if !strings.Contains(lastText, expectedText) { + t.Logf("%s stream completed but expected text %q not found in response (got %d events)", time.Now().Format(time.RFC3339), expectedText, eventCount) + return fmt.Errorf("expected text %q not found in streaming response (%d events)", expectedText, eventCount) } - t.Logf("%s stream completed successfully with %d events", time.Now().Format(time.RFC3339), len(resultList)) + t.Logf("%s stream completed successfully with %d events", time.Now().Format(time.RFC3339), eventCount) return nil }) - require.NoError(t, err, lastJSON) + require.NoError(t, err, lastText) +} + +func extractTextFromEvent(event a2atype.Event) string { + switch e := event.(type) { + case *a2atype.TaskStatusUpdateEvent: + return a2a.ExtractText(e.Status.Message) + case *a2atype.TaskArtifactUpdateEvent: + return a2a.ExtractText(&a2atype.Message{Parts: e.Artifact.Parts}) + case *a2atype.Message: + return a2a.ExtractText(e) + case *a2atype.Task: + text := strings.Builder{} + if e.Status.Message != nil { + text.WriteString(a2a.ExtractText(e.Status.Message)) + } + text.WriteString(extractTextFromArtifacts(e)) + return text.String() + default: + return "" + } } func a2aURL(namespace, name string, sandbox bool) string { @@ -660,7 +697,7 @@ func TestE2EInvokeSandboxAgent(t *testing.T) { agent := setupSandboxAgentWithOptions(t, cli, modelCfg.Name, tools, AgentOptions{Stream: true}) a2aClient := setupSandboxA2AClient(t, agent) - var taskResult *protocol.Task + var taskResult *a2atype.Task t.Run("sync_invocation", func(t *testing.T) { taskResult = runSyncTest(t, a2aClient, "List all nodes in the cluster", "kagent-control-plane", nil) @@ -674,8 +711,7 @@ func TestE2EInvokeSandboxAgent(t *testing.T) { func TestE2EInvokeExternalAgent(t *testing.T) { // Setup A2A client for external agent a2aURL := a2aUrl("kagent", "kebab-agent") - a2aClient, err := a2aclient.NewA2AClient(a2aURL) - require.NoError(t, err) + a2aClient := newA2AClient(t, a2aURL, nil, nil) // Run tests t.Run("sync_invocation", func(t *testing.T) { @@ -688,8 +724,7 @@ func TestE2EInvokeExternalAgent(t *testing.T) { t.Run("invocation with different user", func(t *testing.T) { // Setup A2A client with authentication - authClient, err := a2aclient.NewA2AClient(a2aURL, a2aclient.WithAPIKeyAuth("user@example.com", "x-user-id")) - require.NoError(t, err) + authClient := newA2AClient(t, a2aURL, nil, map[string]string{"x-user-id": "user@example.com"}) runSyncTest(t, authClient, "What can you do?", "kebab for user@example.com", nil) }) @@ -887,8 +922,7 @@ func TestE2EInvokeOpenAIAgent(t *testing.T) { // Setup A2A client - use the agent's actual name a2aURL := a2aUrl("kagent", "basic-openai-test-agent") - a2aClient, err := a2aclient.NewA2AClient(a2aURL) - require.NoError(t, err) + a2aClient := newA2AClient(t, a2aURL, nil, nil) useArtifacts := true t.Run("sync_invocation_calculator", func(t *testing.T) { @@ -950,8 +984,7 @@ func TestE2EInvokeLangGraphAgent(t *testing.T) { // Setup A2A client a2aURL := a2aUrl(agent.Namespace, agent.Name) - a2aClient, err := a2aclient.NewA2AClient(a2aURL) - require.NoError(t, err) + a2aClient := newA2AClient(t, a2aURL, nil, nil) t.Run("sync_invocation", func(t *testing.T) { runSyncTest(t, a2aClient, "make me a kebab", "kebab is ready", nil) @@ -1024,8 +1057,7 @@ func TestE2EInvokeCrewAIAgent(t *testing.T) { // Setup A2A client a2aURL := a2aUrl(agent.Namespace, agent.Name) - a2aClient, err := a2aclient.NewA2AClient(a2aURL) - require.NoError(t, err) + a2aClient := newA2AClient(t, a2aURL, nil, nil) t.Run("two_turn_conversation", func(t *testing.T) { // First turn: Generate initial poem @@ -1116,10 +1148,7 @@ func runE2EInvokeSTSIntegration(t *testing.T, runtimeName string, runtimeOverrid } a2aURL := a2aUrl(agent.Namespace, agent.Name) - a2aClient, err := a2aclient.NewA2AClient(a2aURL, - a2aclient.WithTimeout(60*time.Second), - a2aclient.WithHTTPClient(httpClient)) - require.NoError(t, err) + a2aClient := newA2AClient(t, a2aURL, httpClient, nil) t.Run(runtimeName+"/sts_exchange_sync_invocation", func(t *testing.T) { runSyncTest(t, a2aClient, "add 3 and 5", "8", nil) @@ -1324,10 +1353,7 @@ func TestE2EInvokePassthroughAgent(t *testing.T) { }, } a2aURL := a2aUrl(agent.Namespace, agent.Name) - a2aClient, err := a2aclient.NewA2AClient(a2aURL, - a2aclient.WithTimeout(60*time.Second), - a2aclient.WithHTTPClient(httpClient)) - require.NoError(t, err) + a2aClient := newA2AClient(t, a2aURL, httpClient, nil) // The mock server will only match if it receives the exact // Authorization header "Bearer passthrough-test-token-12345". @@ -1390,7 +1416,7 @@ func runMemoryAgentTest(t *testing.T, extraOpts AgentOptions) { agent := setupAgentWithOptions(t, cli, llmModelCfg.Name, nil, opts) a2aClient := setupA2AClient(t, agent) - var saveResult *protocol.Task + var saveResult *a2atype.Task t.Run("save_memory", func(t *testing.T) { saveResult = runSyncTest(t, a2aClient, "Remember that I prefer dark mode and Go over Python", diff --git a/go/go.mod b/go/go.mod index 1b498ee8d8..d9b6138246 100644 --- a/go/go.mod +++ b/go/go.mod @@ -8,6 +8,7 @@ require ( // adk dependencies github.com/a2aproject/a2a-go v0.3.15 + github.com/a2aproject/a2a-go/v2 v2.3.1 github.com/abiosoft/ishell/v2 v2.0.2 github.com/anthropics/anthropic-sdk-go v1.43.0 github.com/aws/aws-sdk-go-v2/config v1.32.17 @@ -18,7 +19,6 @@ require ( github.com/fatih/color v1.19.0 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 // api dependencies github.com/google/uuid v1.6.0 @@ -47,7 +47,7 @@ require ( go.uber.org/goleak v1.3.0 go.uber.org/zap v1.28.0 golang.org/x/text v0.37.0 - google.golang.org/adk v1.2.0 + google.golang.org/adk v1.3.0 google.golang.org/genai v1.57.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 @@ -63,7 +63,9 @@ require ( require ( github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/protobuf v1.5.4 + github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.4.3 github.com/jackc/pgx/v5 v5.9.2 github.com/ollama/ollama v0.24.0 @@ -85,7 +87,7 @@ require ( cel.dev/expr v0.25.1 // indirect charm.land/lipgloss/v2 v2.0.3 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect codeberg.org/chavacava/garif v0.2.0 // indirect @@ -236,11 +238,10 @@ require ( github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/safehtml v0.1.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.18.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gordonklaus/ineffassign v0.2.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect @@ -391,7 +392,7 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect @@ -417,9 +418,9 @@ require ( golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.45.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/api v0.272.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/api v0.279.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect honnef.co/go/tools v0.7.0 // indirect diff --git a/go/go.sum b/go/go.sum index 7392185179..8212e922e9 100644 --- a/go/go.sum +++ b/go/go.sum @@ -8,8 +8,8 @@ charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= -cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -64,6 +64,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= +github.com/a2aproject/a2a-go/v2 v2.3.1 h1:QWMdOX2UsJ8BJmjs952eo1FRyGsOVl0gFCKeM76AgGE= +github.com/a2aproject/a2a-go/v2 v2.3.1/go.mod h1:mkZr8y2bUgAVQsjs/5fHK7xrRlAHDybMEyxWh2tKRC8= github.com/abiosoft/ishell v2.0.0+incompatible h1:zpwIuEHc37EzrsIYah3cpevrIc8Oma7oZPxr03tlmmw= github.com/abiosoft/ishell v2.0.0+incompatible/go.mod h1:HQR9AqF2R3P4XXpMpI0NAzgHf/aS6+zVXRj14cVk9qg= github.com/abiosoft/ishell/v2 v2.0.2 h1:5qVfGiQISaYM8TkbBl7RFO6MddABoXpATrsFbVI+SNo= @@ -424,10 +426,10 @@ github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8 github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= -github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -869,8 +871,8 @@ go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk= go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -1027,14 +1029,18 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/adk v1.2.0 h1:MfQD1/GqPfIsFNBcozNykkjdqNIdCrPH/SNqKPZF/yM= google.golang.org/adk v1.2.0/go.mod h1:6QY5jQI7awU4WYtJqvyIkJQheCvqsGWweU6BX63USEc= -google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= -google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/adk v1.3.0 h1:paUr9uM2qANnMUAQ4ydMXMCnM1HtymhDYl8y7gnKvqs= +google.golang.org/adk v1.3.0/go.mod h1:R8tNFnI/eiBXHn7zJPJtqdiK/WXC+tVkyuZsXyNZXN4= +google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= +google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/genai v1.57.0 h1:qTyG2ynz5dQy2jF4CvZdLHHVslhR0heMue+zM1a4GNM= google.golang.org/genai v1.57.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index f54a5879d1..7759d174c5 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -7696,23 +7696,25 @@ spec: description: Examples are optional usage examples. items: type: string + maxItems: 20 type: array id: description: ID is the unique identifier for the skill. type: string inputModes: - description: InputModes are the supported input data - modes/types. + description: InputModes are the supported input MIME + types for this skill, overriding the agent's defaults. items: type: string type: array name: description: Name is the human-readable name of the skill. + minLength: 1 type: string outputModes: - description: OutputModes are the supported output data - modes/types. + description: OutputModes are the supported output MIME + types for this skill, overriding the agent's defaults. items: type: string type: array @@ -7720,11 +7722,10 @@ spec: description: Tags are optional tags for categorization. items: type: string + maxItems: 20 type: array required: - - id - name - - tags type: object minItems: 1 type: array diff --git a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml index b8bc8dce7a..2bdbe111d2 100644 --- a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml @@ -5354,23 +5354,25 @@ spec: description: Examples are optional usage examples. items: type: string + maxItems: 20 type: array id: description: ID is the unique identifier for the skill. type: string inputModes: - description: InputModes are the supported input data - modes/types. + description: InputModes are the supported input MIME + types for this skill, overriding the agent's defaults. items: type: string type: array name: description: Name is the human-readable name of the skill. + minLength: 1 type: string outputModes: - description: OutputModes are the supported output data - modes/types. + description: OutputModes are the supported output MIME + types for this skill, overriding the agent's defaults. items: type: string type: array @@ -5378,11 +5380,10 @@ spec: description: Tags are optional tags for categorization. items: type: string + maxItems: 20 type: array required: - - id - name - - tags type: object minItems: 1 type: array diff --git a/helm/kagent/templates/NOTES.txt b/helm/kagent/templates/NOTES.txt index 60059cb8a6..fb2e3d79be 100644 --- a/helm/kagent/templates/NOTES.txt +++ b/helm/kagent/templates/NOTES.txt @@ -60,6 +60,15 @@ TROUBLESHOOTING: DOCUMENTATION: Visit https://kagent.dev for comprehensive documentation and examples. +{{- if .Values.controller.streaming }} +################################################################################ +# DEPRECATED VALUES DETECTED # +################################################################################ + controller.streaming.* (maxBufSize, initialBufSize, timeout) were removed in + 0.10.0. The A2A SDK now handles SSE buffering and timeouts internally. + These values are ignored. Please remove them from your values override. + +{{- end }} {{ if .Values.database.postgres.bundled.enabled -}} ################################################################################ {{- if and (eq .Values.database.postgres.url "") (eq .Values.database.postgres.urlFile "") }} diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index aedd314a48..d0d341e295 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -52,9 +52,6 @@ data: PROXY_URL: {{ .Values.proxy.url | quote }} {{- end }} DATABASE_VECTOR_ENABLED: {{ .Values.database.postgres.vectorEnabled | quote }} - STREAMING_INITIAL_BUF_SIZE: {{ .Values.controller.streaming.initialBufSize | quote }} - STREAMING_MAX_BUF_SIZE: {{ .Values.controller.streaming.maxBufSize | quote }} - STREAMING_TIMEOUT: {{ .Values.controller.streaming.timeout | quote }} WATCH_NAMESPACES: {{ include "kagent.watchNamespaces" . | quote }} ZAP_LOG_LEVEL: {{ .Values.controller.loglevel | quote }} {{- $agentHost := "" }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index ebd3f6987c..babb02f505 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -187,10 +187,10 @@ controller: repository: kagent-dev/kagent/skills-init tag: "" # Will default to global, then Chart version pullPolicy: "" - streaming: # Streaming buffer size for A2A communication - maxBufSize: 1Mi # 1024 * 1024 - initialBufSize: 4Ki # 4 * 1024 - timeout: 600s # 600 seconds + # -- @deprecated Removed in 0.10.0. The A2A SDK now handles SSE buffering and timeouts + # internally. These values have no effect and will be removed in a future release. + streaming: null + # -- Namespaces the controller should watch. # If empty, the controller will watch ALL available namespaces. # @default -- [] (watches all available namespaces) diff --git a/python/packages/kagent-adk/pyproject.toml b/python/packages/kagent-adk/pyproject.toml index 55ef9047e1..e41b7fa2e9 100644 --- a/python/packages/kagent-adk/pyproject.toml +++ b/python/packages/kagent-adk/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "pydantic>=2.5.0", "typing-extensions>=4.8.0", "jsonref>=1.1.0", - "a2a-sdk>=0.3.23", + "a2a-sdk>=1.0.0", # Security: pin minimum versions for CVE fixes in transitive dependencies "urllib3>=2.6.3", # CVE-2025-66418, CVE-2025-66471, CVE-2026-21441: unbounded decompression DoS "filelock>=3.20.3", # CVE-2025-68146, CVE-2026-22701: TOCTOU symlink race condition diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py index 7b08e6f179..1f399f323a 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py +++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py @@ -5,11 +5,13 @@ from typing import Any, Callable, List, Optional import httpx -from a2a.server.apps import A2AFastAPIApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import ( + create_agent_card_routes, + create_jsonrpc_routes, +) from a2a.server.tasks import InMemoryTaskStore from a2a.types import AgentCard -from agentsts.adk import ADKSTSIntegration, ADKTokenPropagationPlugin from fastapi import FastAPI, Request from fastapi.responses import PlainTextResponse from google.adk.agents import BaseAgent @@ -147,15 +149,12 @@ def create_runner() -> Runner: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - max_content_length = get_a2a_max_content_length() - a2a_app = A2AFastAPIApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() faulthandler.enable() @@ -169,7 +168,8 @@ def create_runner() -> Runner: # Health check/readiness probe app.add_route("/health", methods=["GET"], route=health_check) app.add_route("/thread_dump", methods=["GET"], route=thread_dump) - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app diff --git a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py index 0ed10a3177..c24478a292 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py +++ b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py @@ -5,27 +5,21 @@ import logging import uuid from contextlib import suppress -from datetime import datetime, timezone from typing import Any, Awaitable, Callable, Optional +from a2a.server.agent_execution import AgentExecutor from a2a.server.agent_execution.context import RequestContext -from a2a.server.events.event_queue import EventQueue +from a2a.server.events.event_queue_v2 import EventQueue from a2a.types import ( Artifact, Message, Part, Role, + Task, TaskArtifactUpdateEvent, TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, -) -from google.adk.a2a.executor.a2a_agent_executor import ( - A2aAgentExecutor as UpstreamA2aAgentExecutor, -) -from google.adk.a2a.executor.a2a_agent_executor import ( - A2aAgentExecutorConfig as UpstreamA2aAgentExecutorConfig, ) from google.adk.events import Event, EventActions from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME @@ -34,6 +28,7 @@ from google.adk.tools.tool_confirmation import ToolConfirmation from google.adk.utils.context_utils import Aclosing from google.genai import types as genai_types +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import ( KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, @@ -44,67 +39,31 @@ extract_rejection_reasons_from_message, get_kagent_metadata_key, ) -from kagent.core.tracing._span_processor import ( - clear_kagent_span_attributes, - set_kagent_span_attributes, -) +from kagent.core.tracing._span_processor import clear_kagent_span_attributes, set_kagent_span_attributes from pydantic import BaseModel -from typing_extensions import override from ._mcp_toolset import is_anyio_cross_task_cancel_scope_error from ._remote_a2a_tool import SubagentSessionProvider from .converters.event_converter import convert_event_to_a2a_events, serialize_metadata_value -from .converters.part_converter import convert_a2a_part_to_genai_part, convert_genai_part_to_a2a_part from .converters.request_converter import convert_a2a_request_to_adk_run_args logger = logging.getLogger("kagent_adk." + __name__) +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + class A2aAgentExecutorConfig(BaseModel): """Configuration for the KAgent A2aAgentExecutor.""" stream: bool = False -def _kagent_request_converter(request, _part_converter=None): - """Adapter to match the upstream A2ARequestToAgentRunRequestConverter signature. - - Upstream expects (RequestContext, A2APartToGenAIPartConverter) -> AgentRunRequest. - Kagent's converter has a different signature, so this wraps it to satisfy - the upstream config type while still using kagent's own conversion logic. - """ - from google.adk.a2a.converters.request_converter import AgentRunRequest - - run_args = convert_a2a_request_to_adk_run_args(request, stream=False) - return AgentRunRequest( - user_id=run_args["user_id"], - session_id=run_args["session_id"], - new_message=run_args["new_message"], - run_config=run_args["run_config"], - ) - - -def _kagent_event_converter(event, invocation_context, task_id=None, context_id=None, _part_converter=None): - """Adapter to match the upstream AdkEventToA2AEventsConverter signature. - - Upstream expects (Event, InvocationContext, task_id, context_id, GenAIPartToA2APartConverter). - Kagent's converter doesn't take a part_converter arg, so this wraps it. - """ - return convert_event_to_a2a_events(event, invocation_context, task_id, context_id) - - -class A2aAgentExecutor(UpstreamA2aAgentExecutor): - """KAgent's A2A agent executor. - - Extends the upstream google-adk A2aAgentExecutor with: - - Per-request runner lifecycle (created fresh and closed after each request) - - OpenTelemetry span attribute management - - Enhanced error handling (Ollama-specific JSON parse errors, CancelledError) - - Partial event filtering to avoid duplicate aggregation during streaming - - Session naming from first message text - - Request header forwarding to session state - - Invocation ID tracking in final event metadata - """ +class A2aAgentExecutor(AgentExecutor): + """KAgent-owned A2A v1 agent executor bridge for Google ADK.""" def __init__( self, @@ -113,26 +72,11 @@ def __init__( config: Optional[A2aAgentExecutorConfig] = None, task_store=None, ): - # Build upstream config with kagent's custom converters - upstream_config = UpstreamA2aAgentExecutorConfig( - a2a_part_converter=convert_a2a_part_to_genai_part, - gen_ai_part_converter=convert_genai_part_to_a2a_part, - request_converter=_kagent_request_converter, - event_converter=_kagent_event_converter, - ) - super().__init__(runner=runner, config=upstream_config) + self._runner = runner self._kagent_config = config self._task_store = task_store - @override async def _resolve_runner(self) -> Runner: - """Resolve the runner from the callable. - - Unlike the upstream executor which caches a single Runner instance, - kagent always creates a fresh Runner per request. This is necessary - because MCP toolset connections are not shared between requests and - must be cleaned up after each execution. - """ if callable(self._runner): result = self._runner() @@ -150,18 +94,16 @@ async def _resolve_runner(self) -> Runner: f"Runner must be a Runner instance or a callable that returns a Runner, got {type(self._runner)}" ) - @override async def cancel(self, context: RequestContext, event_queue: EventQueue): """Cancel the execution.""" # TODO: Implement proper cancellation logic if needed raise NotImplementedError("Cancellation is not supported") - @override async def execute( self, context: RequestContext, event_queue: EventQueue, - ): + ) -> None: """Executes an A2A request and publishes updates to the event queue specified. It runs as following: * Takes the input from the A2A request @@ -203,7 +145,7 @@ async def _execute_impl( self, context: RequestContext, event_queue: EventQueue, - ): + ) -> None: if not context.message: raise ValueError("A2A request must have a message") @@ -227,15 +169,14 @@ async def _execute_impl( # for new task, create a task submitted event if not context.current_task: await event_queue.enqueue_event( - TaskStatusUpdateEvent( - task_id=context.task_id, + Task( + id=context.task_id, + context_id=context.context_id, status=TaskStatus( - state=TaskState.submitted, + state=TaskState.TASK_STATE_SUBMITTED, message=context.message, - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), ), - context_id=context.context_id, - final=False, ) ) @@ -327,16 +268,15 @@ async def _publish_failed_status_event( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=error_message))], + role=Role.ROLE_AGENT, + parts=[Part(text=error_message)], ), ), context_id=context.context_id, - final=True, ) ) except BaseException as enqueue_error: @@ -526,7 +466,7 @@ async def _handle_request( event_queue: EventQueue, runner: Runner, run_args: dict[str, Any], - ): + ) -> None: # ensure the session exists session = await self._prepare_session(context, run_args, runner) @@ -572,11 +512,10 @@ async def _handle_request( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, metadata=run_metadata.copy(), ) ) @@ -633,7 +572,7 @@ async def _handle_request( # publish the task result event - this is final if ( - task_result_aggregator.task_state == TaskState.working + task_result_aggregator.task_state == TaskState.TASK_STATE_WORKING and task_result_aggregator.task_status_message is not None and task_result_aggregator.task_status_message.parts ): @@ -655,11 +594,10 @@ async def _handle_request( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_COMPLETED, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=True, metadata=run_metadata, ) ) @@ -669,11 +607,10 @@ async def _handle_request( task_id=context.task_id, status=TaskStatus( state=task_result_aggregator.task_state, - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), message=task_result_aggregator.task_status_message, ), context_id=context.context_id, - final=True, metadata=run_metadata, ) ) @@ -693,14 +630,11 @@ async def _prepare_session(self, context: RequestContext, run_args: dict[str, An session_name = None if context.message and context.message.parts: for part in context.message.parts: - # A2A parts have a .root property that contains the actual part (TextPart, FilePart, etc.) - if isinstance(part, Part): - root_part = part.root - if isinstance(root_part, TextPart) and root_part.text: - # Take first 20 chars + "..." if longer (matching UI behavior) - text = root_part.text.strip() - session_name = text[:20] + ("..." if len(text) > 20 else "") - break + if part.HasField("text") and part.text: + # Take first 20 chars + "..." if longer (matching UI behavior) + text = part.text.strip() + session_name = text[:20] + ("..." if len(text) > 20 else "") + break state: dict[str, Any] = {"session_name": session_name} # Propagate source (e.g. "agent") so the session is tagged in the DB. diff --git a/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py b/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py index 3cbaabcb23..c182d68c6d 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py +++ b/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py @@ -18,18 +18,16 @@ import httpx from a2a.client import Client as A2AClient -from a2a.client.card_resolver import A2ACardResolver -from a2a.client.client import ClientConfig as A2AClientConfig -from a2a.client.client_factory import ClientFactory as A2AClientFactory -from a2a.client.errors import A2AClientHTTPError -from a2a.client.middleware import ClientCallContext, ClientCallInterceptor +from a2a.client import ClientCallContext, create_client +from a2a.client import ClientConfig as A2AClientConfig +from a2a.client.errors import A2AClientError from a2a.types import ( AgentCard, - DataPart, Role, + SendMessageRequest, + StreamResponse, Task, TaskState, - TextPart, ) from a2a.types import ( Message as A2AMessage, @@ -37,14 +35,13 @@ from a2a.types import ( Part as A2APart, ) -from a2a.types import ( - TransportProtocol as A2ATransport, -) from google.adk.agents.readonly_context import ReadonlyContext from google.adk.tools.base_tool import BaseTool from google.adk.tools.base_toolset import BaseToolset from google.adk.tools.tool_context import ToolContext from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict, ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, @@ -62,28 +59,6 @@ _EXTRA_HEADERS_CONTEXT_KEY = "_a2a_extra_headers" -class _SubagentInterceptor(ClientCallInterceptor): - """ - Injects the authenticated user's ID as an ``x-user-id`` HTTP header, - marks the request as originating from an agent call via - ``x-kagent-source: agent``, and forwards any pre-computed propagation - headers stored in the call context state under ``_EXTRA_HEADERS_CONTEXT_KEY``. - """ - - async def intercept(self, method_name, request_payload, http_kwargs, agent_card, context): - headers = dict(http_kwargs.get("headers", {})) - headers[_SOURCE_HEADER] = _SOURCE_SUBAGENT - - if context: - if _USER_ID_CONTEXT_KEY in context.state: - headers["x-user-id"] = context.state[_USER_ID_CONTEXT_KEY] - extra = context.state.get(_EXTRA_HEADERS_CONTEXT_KEY) - if extra: - headers.update(extra) - http_kwargs["headers"] = headers - return request_payload, http_kwargs - - def _extract_text_from_task(task: Task) -> str: """Extract text content from a completed task's artifacts or status message.""" # Prefer artifacts (the canonical result) @@ -92,9 +67,8 @@ def _extract_text_from_task(task: Task) -> str: for artifact in task.artifacts: if artifact.parts: for part in artifact.parts: - root = part.root if hasattr(part, "root") else part - if isinstance(root, TextPart) and root.text: - texts.append(root.text) + if part.HasField("text") and part.text: + texts.append(part.text) if texts: return "\n".join(texts) @@ -102,9 +76,8 @@ def _extract_text_from_task(task: Task) -> str: if task.status and task.status.message and task.status.message.parts: texts = [] for part in task.status.message.parts: - root = part.root if hasattr(part, "root") else part - if isinstance(root, TextPart) and root.text: - texts.append(root.text) + if part.HasField("text") and part.text: + texts.append(part.text) if texts: return "\n".join(texts) @@ -112,15 +85,10 @@ def _extract_text_from_task(task: Task) -> str: def _extract_usage_from_task(task: Task) -> Optional[dict]: - """Extract kagent_usage_metadata from a completed task. - - The A2A task_manager merges the final TaskStatusUpdateEvent.metadata into - task.metadata. The agent executor now adds the last LLM invocation's - usage_metadata to run_metadata before publishing the final event, so it - is available here for non-streaming callers like KAgentRemoteA2ATool. - """ + """Extract kagent_usage_metadata from a completed task.""" if task.metadata: - usage = task.metadata.get("kagent_usage_metadata") + metadata = MessageToDict(task.metadata) + usage = metadata.get("kagent_usage_metadata") if usage and isinstance(usage, dict): return usage return None @@ -164,7 +132,7 @@ def subagent_session_id(self) -> str | None: return self._last_context_id async def _ensure_client(self) -> A2AClient: - """Lazily resolve the agent card and initialize the A2A client.""" + """Lazily initialize the A2A client.""" if self._a2a_client is not None: return self._a2a_client @@ -174,30 +142,23 @@ async def _ensure_client(self) -> A2AClient: "Use KAgentRemoteA2AToolset to manage the client lifecycle." ) - # Resolve the agent card from URL - parsed = urlparse(self._agent_card_url) - base_url = f"{parsed.scheme}://{parsed.netloc}" - resolver = A2ACardResolver(httpx_client=self._httpx_client, base_url=base_url) - self._agent_card = await resolver.get_agent_card(relative_card_path=parsed.path) - - if not self._agent_card.url: - raise ValueError(f"Agent card for {self.name} has no RPC URL") - - # Auto-populate description from agent card if we don't have one - if not self.description and self._agent_card.description: - self.description = self._agent_card.description - - # Create the A2A client. config = A2AClientConfig( httpx_client=self._httpx_client, streaming=False, polling=False, - supported_transports=[A2ATransport.jsonrpc], ) - factory = A2AClientFactory(config=config) - self._a2a_client = factory.create( - self._agent_card, - interceptors=[_SubagentInterceptor()], + parsed = urlparse(self._agent_card_url) + if parsed.scheme and parsed.netloc: + base_url = f"{parsed.scheme}://{parsed.netloc}" + relative_card_path = parsed.path or None + else: + base_url = self._agent_card_url + relative_card_path = None + + self._a2a_client = await create_client( + base_url, + client_config=config, + relative_card_path=relative_card_path, ) return self._a2a_client @@ -216,12 +177,18 @@ def _get_declaration(self) -> genai_types.FunctionDeclaration: ) def _build_call_context(self, tool_context: ToolContext) -> ClientCallContext: - state: dict[str, Any] = {_USER_ID_CONTEXT_KEY: tool_context.session.user_id} + headers: dict[str, str] = { + _SOURCE_HEADER: _SOURCE_SUBAGENT, + _USER_ID_CONTEXT_KEY: tool_context.session.user_id, + } if self._header_provider: extra_headers = self._header_provider(tool_context) if extra_headers: - state[_EXTRA_HEADERS_CONTEXT_KEY] = extra_headers - return ClientCallContext(state=state) + headers.update(extra_headers) + return ClientCallContext( + state={_USER_ID_CONTEXT_KEY: tool_context.session.user_id}, + service_parameters=headers, + ) async def run_async(self, *, args: dict[str, Any], tool_context: ToolContext) -> Any: """Execute the remote agent tool. @@ -248,11 +215,11 @@ async def _handle_first_call(self, args: dict[str, Any], tool_context: ToolConte request_text = args.get("request", "") message = A2AMessage( message_id=str(uuid.uuid4()), - parts=[A2APart(root=TextPart(text=request_text))], - role=Role.user, - # Pass context_id for session continuity with stateful remote agents + parts=[A2APart(text=request_text)], + role=Role.ROLE_USER, context_id=self._last_context_id, ) + send_request = SendMessageRequest(message=message) # Forward the authenticated user ID so the subagent session is scoped # to the same user as the parent agent session. @@ -260,13 +227,20 @@ async def _handle_first_call(self, args: dict[str, Any], tool_context: ToolConte task: Optional[Task] = None try: - async for response in client.send_message(request=message, context=call_context): - if isinstance(response, tuple): - # ClientEvent: (Task, UpdateEvent | None) - task = response[0] - elif isinstance(response, A2AMessage): - return self._extract_text_from_message(response) - except A2AClientHTTPError as e: + async for chunk in client.send_message(request=send_request, context=call_context): + if not isinstance(chunk, StreamResponse): + continue + if chunk.HasField("task"): + task = chunk.task + elif chunk.HasField("status_update"): + task = Task( + id=chunk.status_update.task_id, + context_id=chunk.status_update.context_id, + status=chunk.status_update.status, + ) + elif chunk.HasField("message"): + return self._extract_text_from_message(chunk.message) + except A2AClientError as e: return f"Remote agent '{self.name}' request failed: {e}" except Exception as e: logger.error("Error calling remote agent %s: %s", self.name, e, exc_info=True) @@ -277,10 +251,10 @@ async def _handle_first_call(self, args: dict[str, Any], tool_context: ToolConte state = task.status.state if task.status else None - if state == TaskState.input_required: + if state == TaskState.TASK_STATE_INPUT_REQUIRED: return self._handle_input_required(task, tool_context) - if state == TaskState.failed: + if state == TaskState.TASK_STATE_FAILED: error_text = _extract_text_from_task(task) return error_text or f"Remote agent '{self.name}' failed." @@ -386,9 +360,10 @@ async def _handle_resume(self, tool_context: ToolContext) -> Any: message_id=str(uuid.uuid4()), task_id=task_id, context_id=context_id, - role=Role.user, - parts=[A2APart(root=DataPart(data=decision_data))], + role=Role.ROLE_USER, + parts=[A2APart(data=ParseDict(decision_data, Value()))], ) + send_request = SendMessageRequest(message=decision_message) logger.info( "Forwarding %s decision to subagent %s task %s", @@ -401,12 +376,20 @@ async def _handle_resume(self, tool_context: ToolContext) -> Any: call_context = self._build_call_context(tool_context) task: Optional[Task] = None try: - async for response in client.send_message(request=decision_message, context=call_context): - if isinstance(response, tuple): - task = response[0] - elif isinstance(response, A2AMessage): - return self._extract_text_from_message(response) - except A2AClientHTTPError as e: + async for chunk in client.send_message(request=send_request, context=call_context): + if not isinstance(chunk, StreamResponse): + continue + if chunk.HasField("task"): + task = chunk.task + elif chunk.HasField("status_update"): + task = Task( + id=chunk.status_update.task_id, + context_id=chunk.status_update.context_id, + status=chunk.status_update.status, + ) + elif chunk.HasField("message"): + return self._extract_text_from_message(chunk.message) + except A2AClientError as e: return f"Remote agent '{subagent_name}' resume failed: {e}" except Exception as e: logger.error("Error resuming remote agent %s: %s", subagent_name, e, exc_info=True) @@ -417,12 +400,10 @@ async def _handle_resume(self, tool_context: ToolContext) -> Any: state = task.status.state if task.status else None - if state == TaskState.input_required: - # The subagent has another HITL request (e.g. multiple tools needing - # approval in sequence). Surface it again. + if state == TaskState.TASK_STATE_INPUT_REQUIRED: return self._handle_input_required(task, tool_context) - if state == TaskState.failed: + if state == TaskState.TASK_STATE_FAILED: error_text = _extract_text_from_task(task) return error_text or f"Remote agent '{subagent_name}' failed after resume." @@ -444,9 +425,8 @@ def _extract_text_from_message(message: A2AMessage) -> str: return "" texts: list[str] = [] for part in message.parts: - root = part.root if hasattr(part, "root") else part - if isinstance(root, TextPart) and root.text: - texts.append(root.text) + if part.HasField("text") and part.text: + texts.append(part.text) return "\n".join(texts) diff --git a/python/packages/kagent-adk/src/kagent/adk/_token.py b/python/packages/kagent-adk/src/kagent/adk/_token.py index 07fe74adbe..5cfaeed14a 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_token.py +++ b/python/packages/kagent-adk/src/kagent/adk/_token.py @@ -64,7 +64,7 @@ async def _refresh_token(self): async def _add_headers(self, request: httpx.Request): token = await self._get_token() - headers = {"X-Agent-Name": self.app_name} + headers = {"X-Agent-Name": self.app_name, "A2A-Version": "1.0"} if token: headers["Authorization"] = f"Bearer {token}" if user_id := get_request_user_id(): diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index e32d0aacbf..014f10e095 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -11,6 +11,7 @@ from agentsts.adk import ADKSTSIntegration, ADKTokenPropagationPlugin from google.adk.agents import BaseAgent from google.adk.cli.utils.agent_loader import AgentLoader +from google.protobuf.json_format import ParseDict from kagent.core import KAgentConfig, configure_logging, configure_tracing from . import AgentConfig, KAgentApp @@ -50,6 +51,10 @@ def maybe_add_skills_with_config(root_agent: BaseAgent, agent_config: Optional[A add_skills_tool_to_agent(skills_directory, root_agent) +def parse_agent_card(data: dict) -> AgentCard: + return ParseDict(data, AgentCard()) + + @app.command() def static( host: str = "127.0.0.1", @@ -65,7 +70,7 @@ def static( agent_config = AgentConfig.model_validate(config) with open(os.path.join(filepath, "agent-card.json"), "r") as f: agent_card = json.load(f) - agent_card = AgentCard.model_validate(agent_card) + agent_card = parse_agent_card(agent_card) plugins = None sts_integration = create_sts_integration() if sts_integration: @@ -171,7 +176,7 @@ def root_agent_factory() -> BaseAgent: with open(os.path.join(working_dir, name, "agent-card.json"), "r") as f: agent_card = json.load(f) - agent_card = AgentCard.model_validate(agent_card) + agent_card = parse_agent_card(agent_card) # Attempt to import optional user-defined lifespan(app) from the agent package lifespan = None @@ -239,7 +244,7 @@ def test( with open(os.path.join(filepath, "agent-card.json"), "r") as f: agent_card = json.load(f) - agent_card = AgentCard.model_validate(agent_card) + agent_card = parse_agent_card(agent_card) agent_config = AgentConfig.model_validate(config) asyncio.run(test_agent(agent_config, agent_card, task)) diff --git a/python/packages/kagent-adk/src/kagent/adk/converters/event_converter.py b/python/packages/kagent-adk/src/kagent/adk/converters/event_converter.py index 92c4b664d6..c23b0b3a27 100644 --- a/python/packages/kagent-adk/src/kagent/adk/converters/event_converter.py +++ b/python/packages/kagent-adk/src/kagent/adk/converters/event_converter.py @@ -2,16 +2,17 @@ import logging import uuid -from datetime import datetime, timezone from typing import Any, Dict, List, Optional from a2a.server.events import Event as A2AEvent -from a2a.types import DataPart, Message, Role, Task, TaskState, TaskStatus, TaskStatusUpdateEvent, TextPart +from a2a.types import Message, Role, Task, TaskState, TaskStatus, TaskStatusUpdateEvent from a2a.types import Part as A2APart from google.adk.agents.invocation_context import InvocationContext from google.adk.events.event import Event from google.adk.flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import ( A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, @@ -32,15 +33,23 @@ logger = logging.getLogger("kagent_adk." + __name__) -def serialize_metadata_value(value: Any) -> str: +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + +def serialize_metadata_value(value: Any) -> Any: """Safely serializes metadata values to string format. Args: value: The value to serialize. Returns: - String representation of the value. + JSON-serializable representation of the value. """ + if hasattr(value, "DESCRIPTOR"): + return MessageToDict(value) if hasattr(value, "model_dump"): try: return value.model_dump(exclude_none=True, by_alias=True) @@ -69,8 +78,11 @@ def _get_context_metadata(event: Event, invocation_context: InvocationContext) - raise ValueError("Invocation context cannot be None") try: + partial = getattr(event, "partial", False) + if not isinstance(partial, bool): + partial = False metadata: Dict[str, Any] = { - get_kagent_metadata_key("adk_partial"): event.partial, + get_kagent_metadata_key("adk_partial"): partial, get_kagent_metadata_key("app_name"): invocation_context.app_name, get_kagent_metadata_key("user_id"): invocation_context.user_id, get_kagent_metadata_key("session_id"): invocation_context.session.id, @@ -122,15 +134,21 @@ def _process_long_running_tool(a2a_part: A2APart, event: Event) -> None: a2a_part: The A2A part to potentially mark as long-running. event: The ADK event containing long-running tool information. """ + if not event.long_running_tool_ids: + return + if not a2a_part.HasField("data"): + return + + metadata = MessageToDict(a2a_part.metadata) if a2a_part.metadata else {} if ( - isinstance(a2a_part.root, DataPart) - and event.long_running_tool_ids - and a2a_part.root.metadata - and a2a_part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) - == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - and a2a_part.root.data.get("id") in event.long_running_tool_ids + metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) + != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL ): - a2a_part.root.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)] = True + return + + part_data = MessageToDict(a2a_part.data) + if isinstance(part_data, dict) and part_data.get("id") in event.long_running_tool_ids: + a2a_part.metadata.update({get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True}) def _process_subagent_session_id(a2a_part: A2APart, subagent_session_ids: Dict[str, str]) -> None: @@ -144,22 +162,24 @@ def _process_subagent_session_id(a2a_part: A2APart, subagent_session_ids: Dict[s a2a_part: The A2A part to potentially stamp. subagent_session_ids: Mapping of tool name to pre-generated session ID. """ - if not isinstance(a2a_part.root, DataPart) or not a2a_part.root.metadata: + if not a2a_part.HasField("data"): return + metadata = MessageToDict(a2a_part.metadata) if a2a_part.metadata else {} if ( - a2a_part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) + metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL ): return - tool_name = a2a_part.root.data.get("name") if isinstance(a2a_part.root.data, dict) else None + part_data = MessageToDict(a2a_part.data) + tool_name = part_data.get("name") if isinstance(part_data, dict) else None if tool_name and tool_name in subagent_session_ids: - a2a_part.root.metadata[get_kagent_metadata_key("subagent_session_id")] = subagent_session_ids[tool_name] + a2a_part.metadata.update({get_kagent_metadata_key("subagent_session_id"): subagent_session_ids[tool_name]}) def convert_event_to_a2a_message( event: Event, invocation_context: InvocationContext, - role: Role = Role.agent, + role: Role = Role.ROLE_AGENT, subagent_session_ids: Optional[Dict[str, str]] = None, ) -> Optional[Message]: """Converts an ADK event to an A2A message. @@ -239,16 +259,15 @@ def _create_error_status_event( context_id=context_id, metadata=event_metadata, status=TaskStatus( - state=TaskState.failed, + state=TaskState.TASK_STATE_FAILED, message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(TextPart(text=error_message))], + role=Role.ROLE_AGENT, + parts=[A2APart(text=error_message)], metadata={get_kagent_metadata_key("error_code"): str(event.error_code)} if event.error_code else {}, ), - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), ), - final=False, ) @@ -273,35 +292,40 @@ def _create_status_update_event( A TaskStatusUpdateEvent with RUNNING state. """ status = TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), ) - if any( - part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) - == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - and part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)) is True - and part.root.data.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME - for part in message.parts - if part.root.metadata - ): - status.state = TaskState.auth_required - elif any( - part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) - == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - and part.root.metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)) is True - for part in message.parts - if part.root.metadata - ): - status.state = TaskState.input_required + has_auth_required = False + has_input_required = False + for part in message.parts: + if not part.HasField("data"): + continue + metadata = MessageToDict(part.metadata) if part.metadata else {} + if ( + metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)) + != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + ): + continue + if metadata.get(get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)) is not True: + continue + payload = MessageToDict(part.data) + if isinstance(payload, dict) and payload.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME: + has_auth_required = True + break + has_input_required = True + + if has_auth_required: + status.state = TaskState.TASK_STATE_AUTH_REQUIRED + elif has_input_required: + status.state = TaskState.TASK_STATE_INPUT_REQUIRED return TaskStatusUpdateEvent( task_id=task_id, context_id=context_id, status=status, metadata=_get_context_metadata(event, invocation_context), - final=False, ) diff --git a/python/packages/kagent-adk/src/kagent/adk/converters/part_converter.py b/python/packages/kagent-adk/src/kagent/adk/converters/part_converter.py index c0e70e031e..f5fef90764 100644 --- a/python/packages/kagent-adk/src/kagent/adk/converters/part_converter.py +++ b/python/packages/kagent-adk/src/kagent/adk/converters/part_converter.py @@ -25,6 +25,8 @@ from a2a import types as a2a_types from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict, ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT, A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE, @@ -37,73 +39,85 @@ logger = logging.getLogger("kagent_adk." + __name__) +def _metadata_to_dict(part: a2a_types.Part) -> dict: + if not part.metadata: + return {} + metadata = MessageToDict(part.metadata) + return metadata if isinstance(metadata, dict) else {} + + +def _data_value_to_python(part: a2a_types.Part): + if not part.HasField("data"): + return None + return MessageToDict(part.data) + + def convert_a2a_part_to_genai_part( a2a_part: a2a_types.Part, ) -> Optional[genai_types.Part]: """Convert an A2A Part to a Google GenAI Part.""" - part = a2a_part.root - if isinstance(part, a2a_types.TextPart): - return genai_types.Part(text=part.text) - - if isinstance(part, a2a_types.FilePart): - if isinstance(part.file, a2a_types.FileWithUri): - return genai_types.Part( - file_data=genai_types.FileData(file_uri=part.file.uri, mime_type=part.file.mime_type) - ) + if a2a_part.HasField("text"): + return genai_types.Part(text=a2a_part.text) - elif isinstance(part.file, a2a_types.FileWithBytes): - return genai_types.Part( - inline_data=genai_types.Blob( - data=base64.b64decode(part.file.bytes), - mime_type=part.file.mime_type, - ) - ) - else: - logger.warning( - "Cannot convert unsupported file type: %s for A2A part: %s", - type(part.file), - a2a_part, + if a2a_part.HasField("url"): + return genai_types.Part( + file_data=genai_types.FileData(file_uri=a2a_part.url, mime_type=a2a_part.media_type or None) + ) + + if a2a_part.HasField("raw"): + return genai_types.Part( + inline_data=genai_types.Blob( + data=bytes(a2a_part.raw), + mime_type=a2a_part.media_type or None, ) - return None + ) - if isinstance(part, a2a_types.DataPart): + if a2a_part.HasField("data"): # Convert the Data Part to funcall and function response. # This is mainly for converting human in the loop and auth request and # response. # TODO once A2A defined how to suervice such information, migrate below # logic accordinlgy - if part.metadata and get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY) in part.metadata: + data_value = _data_value_to_python(a2a_part) + metadata = _metadata_to_dict(a2a_part) + if metadata and get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY) in metadata: if ( - part.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] + metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL ): - return genai_types.Part(function_call=genai_types.FunctionCall.model_validate(part.data, by_alias=True)) + if isinstance(data_value, dict): + return genai_types.Part( + function_call=genai_types.FunctionCall.model_validate(data_value, by_alias=True) + ) if ( - part.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] + metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] == A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE ): - return genai_types.Part( - function_response=genai_types.FunctionResponse.model_validate(part.data, by_alias=True) - ) + if isinstance(data_value, dict): + return genai_types.Part( + function_response=genai_types.FunctionResponse.model_validate(data_value, by_alias=True) + ) if ( - part.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] + metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] == A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT ): - return genai_types.Part( - code_execution_result=genai_types.CodeExecutionResult.model_validate(part.data, by_alias=True) - ) + if isinstance(data_value, dict): + return genai_types.Part( + code_execution_result=genai_types.CodeExecutionResult.model_validate(data_value, by_alias=True) + ) if ( - part.metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] + metadata[get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY)] == A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE ): - return genai_types.Part( - executable_code=genai_types.ExecutableCode.model_validate(part.data, by_alias=True) - ) - return genai_types.Part(text=json.dumps(part.data)) + if isinstance(data_value, dict): + return genai_types.Part( + executable_code=genai_types.ExecutableCode.model_validate(data_value, by_alias=True) + ) + return genai_types.Part(text=json.dumps(data_value)) logger.warning( "Cannot convert unsupported part type: %s for A2A part: %s", - type(part), + type(a2a_part), a2a_part, ) return None @@ -115,37 +129,26 @@ def convert_genai_part_to_a2a_part( """Convert a Google GenAI Part to an A2A Part.""" if part.text: - a2a_part = a2a_types.TextPart(text=part.text) + a2a_part = a2a_types.Part(text=part.text) if part.thought is not None: - a2a_part.metadata = {get_kagent_metadata_key("thought"): part.thought} - return a2a_types.Part(root=a2a_part) + a2a_part.metadata.update({get_kagent_metadata_key("thought"): part.thought}) + return a2a_part if part.file_data: - return a2a_types.Part( - root=a2a_types.FilePart( - file=a2a_types.FileWithUri( - uri=part.file_data.file_uri, - mime_type=part.file_data.mime_type, - ) - ) - ) + return a2a_types.Part(url=part.file_data.file_uri, media_type=part.file_data.mime_type) if part.inline_data: - a2a_part = a2a_types.FilePart( - file=a2a_types.FileWithBytes( - bytes=base64.b64encode(part.inline_data.data).decode("utf-8"), - mime_type=part.inline_data.mime_type, - ) - ) + a2a_part = a2a_types.Part(raw=part.inline_data.data, media_type=part.inline_data.mime_type) if part.video_metadata: - a2a_part.metadata = { - get_kagent_metadata_key("video_metadata"): part.video_metadata.model_dump( - by_alias=True, exclude_none=True - ) - } - - return a2a_types.Part(root=a2a_part) + a2a_part.metadata.update( + { + get_kagent_metadata_key("video_metadata"): part.video_metadata.model_dump( + by_alias=True, exclude_none=True + ) + } + ) + return a2a_part # Convert the funcall and function response to A2A DataPart. # This is mainly for converting human in the loop and auth request and @@ -153,49 +156,41 @@ def convert_genai_part_to_a2a_part( # TODO once A2A defined how to suervice such information, migrate below # logic accordinlgy if part.function_call: + payload = part.function_call.model_dump(by_alias=True, exclude_none=True) return a2a_types.Part( - root=a2a_types.DataPart( - data=part.function_call.model_dump(by_alias=True, exclude_none=True), - metadata={ - get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - }, - ) + data=ParseDict(payload, Value()), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + }, ) if part.function_response: + payload = part.function_response.model_dump(by_alias=True, exclude_none=True) return a2a_types.Part( - root=a2a_types.DataPart( - data=part.function_response.model_dump(by_alias=True, exclude_none=True), - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE - }, - ) + data=ParseDict(payload, Value()), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE + }, ) if part.code_execution_result: + payload = part.code_execution_result.model_dump(by_alias=True, exclude_none=True) return a2a_types.Part( - root=a2a_types.DataPart( - data=part.code_execution_result.model_dump(by_alias=True, exclude_none=True), - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT - }, - ) + data=ParseDict(payload, Value()), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT + }, ) if part.executable_code: + payload = part.executable_code.model_dump(by_alias=True, exclude_none=True) return a2a_types.Part( - root=a2a_types.DataPart( - data=part.executable_code.model_dump(by_alias=True, exclude_none=True), - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE - }, - ) + data=ParseDict(payload, Value()), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE + }, ) logger.warning( diff --git a/python/packages/kagent-adk/src/kagent/adk/converters/request_converter.py b/python/packages/kagent-adk/src/kagent/adk/converters/request_converter.py index 88844daf68..8baa5f218a 100644 --- a/python/packages/kagent-adk/src/kagent/adk/converters/request_converter.py +++ b/python/packages/kagent-adk/src/kagent/adk/converters/request_converter.py @@ -29,7 +29,7 @@ def convert_a2a_request_to_adk_run_args( "session_id": request.context_id, "new_message": genai_types.Content( role="user", - parts=[convert_a2a_part_to_genai_part(part) for part in request.message.parts], + parts=[converted for part in request.message.parts if (converted := convert_a2a_part_to_genai_part(part))], ), "run_config": RunConfig(streaming_mode=StreamingMode.SSE if stream else StreamingMode.NONE), } diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 5e2f4a97af..e684e84da2 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -2,12 +2,12 @@ from typing import Any, Callable, Literal, Optional, Union import httpx +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH from agentsts.adk import ADKTokenPropagationPlugin from google.adk.agents import Agent from google.adk.agents.callback_context import CallbackContext from google.adk.agents.llm_agent import ToolUnion from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_TIMEOUT from google.adk.models.anthropic_llm import Claude as ClaudeLLM from google.adk.models.google_llm import Gemini as GeminiLLM from google.adk.tools.mcp_tool import SseConnectionParams, StreamableHTTPConnectionParams @@ -30,6 +30,8 @@ # Proxy host header used for Gateway API routing when using a proxy PROXY_HOST_HEADER = "x-kagent-host" +DEFAULT_TIMEOUT = 30.0 + # Key used to store headers in session state HEADERS_STATE_KEY = "headers" diff --git a/python/packages/kagent-adk/tests/unittests/converters/test_event_converter.py b/python/packages/kagent-adk/tests/unittests/converters/test_event_converter.py index 6040a7e047..35b9f1a9fb 100644 --- a/python/packages/kagent-adk/tests/unittests/converters/test_event_converter.py +++ b/python/packages/kagent-adk/tests/unittests/converters/test_event_converter.py @@ -48,10 +48,12 @@ def test_convert_event_to_a2a_events(self): event1, invocation_context, task_id="test_task_1", context_id="test_context_1" ) error_events1 = [ - e for e in result1 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.failed + e for e in result1 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_FAILED ] working_events1 = [ - e for e in result1 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.working + e + for e in result1 + if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_WORKING ] assert len(error_events1) == 0, ( f"Expected no error events for STOP with empty content, got {len(error_events1)}" @@ -70,10 +72,12 @@ def test_convert_event_to_a2a_events(self): event2, invocation_context, task_id="test_task_2", context_id="test_context_2" ) error_events2 = [ - e for e in result2 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.failed + e for e in result2 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_FAILED ] working_events2 = [ - e for e in result2 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.working + e + for e in result2 + if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_WORKING ] assert len(error_events2) == 0, f"Expected no error events for STOP with empty parts, got {len(error_events2)}" assert len(working_events2) == 0, ( @@ -88,10 +92,12 @@ def test_convert_event_to_a2a_events(self): event3, invocation_context, task_id="test_task_3", context_id="test_context_3" ) error_events3 = [ - e for e in result3 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.failed + e for e in result3 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_FAILED ] working_events3 = [ - e for e in result3 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.working + e + for e in result3 + if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_WORKING ] assert len(error_events3) == 0, ( f"Expected no error events for STOP with missing content, got {len(error_events3)}" @@ -108,7 +114,7 @@ def test_convert_event_to_a2a_events(self): event4, invocation_context, task_id="test_task_4", context_id="test_context_4" ) error_events4 = [ - e for e in result4 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.failed + e for e in result4 if isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_FAILED ] assert len(error_events4) == 1, f"Expected 1 error event for MALFORMED_FUNCTION_CALL, got {len(error_events4)}" diff --git a/python/packages/kagent-adk/tests/unittests/models/test_sap_ai_core.py b/python/packages/kagent-adk/tests/unittests/models/test_sap_ai_core.py index aace9575a9..fab636b547 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_sap_ai_core.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_sap_ai_core.py @@ -17,7 +17,6 @@ _parse_orchestration_chunk, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/python/packages/kagent-adk/tests/unittests/test_hitl.py b/python/packages/kagent-adk/tests/unittests/test_hitl.py index 4ad2371de0..14782903bd 100644 --- a/python/packages/kagent-adk/tests/unittests/test_hitl.py +++ b/python/packages/kagent-adk/tests/unittests/test_hitl.py @@ -3,11 +3,13 @@ import json from unittest.mock import MagicMock -from a2a.types import DataPart, Message, Part, Role +from a2a.types import Message, Part, Role from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME from google.adk.sessions import Session from google.adk.tools.tool_confirmation import ToolConfirmation from google.genai import types as genai_types +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( KAGENT_ASK_USER_ANSWERS_KEY, KAGENT_HITL_DECISION_TYPE_APPROVE, @@ -266,7 +268,7 @@ def test_find_pending_confirmations_with_payload(): def _make_simple_message(parts=None) -> Message: """Create a minimal real Message for testing.""" return Message( - role=Role.user, + role=Role.ROLE_USER, message_id="test-msg", task_id="test-task", context_id="test-ctx", @@ -356,20 +358,21 @@ def test_process_hitl_decision_batch(): ] message = Message( - role=Role.user, + role=Role.ROLE_USER, message_id="msg1", task_id="task1", context_id="ctx1", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_BATCH, KAGENT_HITL_DECISIONS_KEY: { "orig123": KAGENT_HITL_DECISION_TYPE_APPROVE, "orig456": KAGENT_HITL_DECISION_TYPE_REJECT, }, - } + }, + Value(), ) ) ], @@ -408,17 +411,18 @@ def test_process_hitl_decision_uniform_reject_with_reason(): ] message = Message( - role=Role.user, + role=Role.ROLE_USER, message_id="msg1", task_id="task1", context_id="ctx1", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_REJECT, "rejection_reason": "Too risky", - } + }, + Value(), ) ) ], @@ -456,14 +460,14 @@ def test_process_hitl_decision_batch_with_per_tool_reason(): ] message = Message( - role=Role.user, + role=Role.ROLE_USER, message_id="msg1", task_id="task1", context_id="ctx1", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_BATCH, KAGENT_HITL_DECISIONS_KEY: { "orig123": KAGENT_HITL_DECISION_TYPE_APPROVE, @@ -472,7 +476,8 @@ def test_process_hitl_decision_batch_with_per_tool_reason(): KAGENT_HITL_REJECTION_REASONS_KEY: { "orig456": "Wrong environment", }, - } + }, + Value(), ) ) ], @@ -543,17 +548,18 @@ def test_process_hitl_decision_ask_user_answers(): answers = [{"answer": ["PostgreSQL"]}, {"answer": ["Auth", "Caching"]}] message = Message( - role=Role.user, + role=Role.ROLE_USER, message_id="msg1", task_id="task1", context_id="ctx1", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { KAGENT_HITL_DECISION_TYPE_KEY: KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_ASK_USER_ANSWERS_KEY: answers, - } + }, + Value(), ) ) ], diff --git a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py index 8566b3db6c..798a18dbfa 100644 --- a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -7,7 +7,7 @@ import httpx import pytest -from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH from kagent.adk._remote_a2a_tool import KAgentRemoteA2AToolset from kagent.adk.types import PROXY_HOST_HEADER, AgentConfig, OpenAI, RemoteAgentConfig diff --git a/python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py b/python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py index 8682741bb2..04e7821cb7 100644 --- a/python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py +++ b/python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py @@ -4,17 +4,18 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx +from a2a.types import Message as A2AMessage +from a2a.types import Part as A2APart from a2a.types import ( - DataPart, Role, + StreamResponse, Task, TaskState, TaskStatus, - TextPart, ) -from a2a.types import Message as A2AMessage -from a2a.types import Part as A2APart from google.adk.tools.tool_confirmation import ToolConfirmation +from google.protobuf.json_format import MessageToDict, ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( KAGENT_HITL_DECISION_TYPE_APPROVE, KAGENT_HITL_DECISION_TYPE_BATCH, @@ -26,7 +27,6 @@ KAgentRemoteA2ATool, KAgentRemoteA2AToolset, SubagentSessionProvider, - _SubagentInterceptor, ) # --------------------------------------------------------------------------- @@ -68,16 +68,14 @@ def _make_task(state: TaskState, text: str = "", hitl_data: list[dict] | None = for d in hitl_data: parts.append( A2APart( - root=DataPart( - data=d, - metadata={"adk_type": "function_call", "adk_is_long_running": True}, - ) + data=ParseDict(d, Value()), + metadata={"adk_type": "function_call", "adk_is_long_running": True}, ) ) elif text: - parts.append(A2APart(root=TextPart(text=text))) + parts.append(A2APart(text=text)) - status_message = A2AMessage(role=Role.agent, message_id="msg-1", parts=parts) if parts else None + status_message = A2AMessage(role=Role.ROLE_AGENT, message_id="msg-1", parts=parts) if parts else None return Task( id="task-1", context_id="ctx-1", @@ -100,13 +98,21 @@ def _make_hitl_task(tool_name: str = "delete_file", tool_call_id: str = "call_1" }, } ] - return _make_task(TaskState.input_required, hitl_data=hitl_data) + return _make_task(TaskState.TASK_STATE_INPUT_REQUIRED, hitl_data=hitl_data) async def _async_yield(*items) -> AsyncIterator: """Yield items from an async generator (simulates client.send_message).""" for item in items: - yield item + if isinstance(item, tuple): + task, _ = item + yield StreamResponse(task=task) + elif isinstance(item, A2AMessage): + yield StreamResponse(message=item) + elif isinstance(item, Task): + yield StreamResponse(task=item) + else: + yield item def _make_tool(*, httpx_client: httpx.AsyncClient | None = None) -> KAgentRemoteA2ATool: @@ -127,10 +133,27 @@ def _patch_client(tool: KAgentRemoteA2ATool, send_side_effect): p = patch.object(tool, "_ensure_client") mock_ensure = p.start() mock_client = MagicMock() + + async def _wrap_stream(iterable): + async for item in iterable: + if isinstance(item, tuple): + task, _ = item + yield StreamResponse(task=task) + elif isinstance(item, A2AMessage): + yield StreamResponse(message=item) + elif isinstance(item, Task): + yield StreamResponse(task=item) + else: + yield item + if callable(send_side_effect) and not isinstance(send_side_effect, MagicMock): - mock_client.send_message = send_side_effect + + def _invoke(*args, **kwargs): + return _wrap_stream(send_side_effect(*args, **kwargs)) + + mock_client.send_message = _invoke else: - mock_client.send_message = MagicMock(return_value=send_side_effect) + mock_client.send_message = MagicMock(return_value=_wrap_stream(send_side_effect)) mock_ensure.return_value = mock_client return p, mock_client @@ -141,40 +164,27 @@ def _approval_ctx(confirmed: bool, payload: dict | None = None, **kwargs) -> Moc # --------------------------------------------------------------------------- -# _SubagentInterceptor header propagation tests +# Call context header propagation tests # --------------------------------------------------------------------------- class TestSubagentInterceptorHeaderPropagation: - """Tests for header propagation in _SubagentInterceptor via context state.""" - - async def _call_intercept(self, interceptor, state: dict) -> dict: - from a2a.client.middleware import ClientCallContext - - ctx = ClientCallContext(state=state) - _, http_kwargs = await interceptor.intercept( - method_name="message/send", - request_payload={}, - http_kwargs={}, - agent_card=None, - context=ctx, - ) - return http_kwargs.get("headers", {}) - - async def test_forwards_extra_headers_from_context_state(self): - interceptor = _SubagentInterceptor() - headers = await self._call_intercept( - interceptor, - state={"x-user-id": "user1", "_a2a_extra_headers": {"authorization": "Bearer test-jwt"}}, + """Tests for header propagation via ClientCallContext.service_parameters.""" + + async def test_forwards_extra_headers_from_header_provider(self): + tool = KAgentRemoteA2ATool( + name="k8s_agent", + description="K8s subagent", + agent_card_url="http://k8s-agent/.well-known/agent.json", + header_provider=lambda _: {"authorization": "Bearer test-jwt"}, ) + headers = tool._build_call_context(MockToolContext(user_id="user1")).service_parameters or {} assert headers.get("authorization") == "Bearer test-jwt" + assert headers.get("x-user-id") == "user1" - async def test_no_extra_headers_without_state_key(self): - interceptor = _SubagentInterceptor() - headers = await self._call_intercept( - interceptor, - state={"x-user-id": "user1", "authorization": "Bearer test-jwt"}, - ) + async def test_no_extra_headers_without_header_provider(self): + tool = _make_tool() + headers = tool._build_call_context(MockToolContext(user_id="user1")).service_parameters or {} assert "authorization" not in headers @@ -189,7 +199,7 @@ class TestFirstCall: async def test_completed_task_returns_result_with_session_id(self): """Completed task returns dict with result text and subagent_session_id.""" tool = _make_tool() - task = _make_task(TaskState.completed, text="all done") + task = _make_task(TaskState.TASK_STATE_COMPLETED, text="all done") p, _ = _patch_client(tool, _async_yield((task, None))) try: result = await tool.run_async(args={"request": "do something"}, tool_context=MockToolContext()) @@ -204,9 +214,9 @@ async def test_direct_message_response_returns_text(self): """When remote agent returns an A2AMessage directly, result is plain text.""" tool = _make_tool() msg = A2AMessage( - role=Role.agent, + role=Role.ROLE_AGENT, message_id="m1", - parts=[A2APart(root=TextPart(text="direct reply"))], + parts=[A2APart(text="direct reply")], ) p, _ = _patch_client(tool, _async_yield(msg)) try: @@ -230,7 +240,7 @@ async def test_no_result_returns_fallback_string(self): async def test_failed_task_returns_error_text(self): """Failed tasks return the error text from the task status message.""" tool = _make_tool() - task = _make_task(TaskState.failed, text="something broke") + task = _make_task(TaskState.TASK_STATE_FAILED, text="something broke") p, _ = _patch_client(tool, _async_yield((task, None))) try: result = await tool.run_async(args={"request": "go"}, tool_context=MockToolContext()) @@ -242,7 +252,7 @@ async def test_failed_task_returns_error_text(self): async def test_context_id_sent_in_outgoing_message(self): """The tool's pre-generated context_id is sent on the outgoing A2A message.""" tool = _make_tool() - task = _make_task(TaskState.completed, text="ok") + task = _make_task(TaskState.TASK_STATE_COMPLETED, text="ok") sent: list[A2AMessage] = [] async def capture(*, request, **kw): @@ -255,12 +265,12 @@ async def capture(*, request, **kw): finally: p.stop() - assert sent[0].context_id == tool._last_context_id + assert sent[0].message.context_id == tool._last_context_id async def test_user_id_forwarded_in_call_context(self): """The parent session's user_id is forwarded via ClientCallContext.""" tool = _make_tool() - task = _make_task(TaskState.completed, text="ok") + task = _make_task(TaskState.TASK_STATE_COMPLETED, text="ok") captured_contexts: list = [] async def capture(*, request, context=None, **kw): @@ -341,7 +351,7 @@ async def _resume( ) -> tuple[Any, list[A2AMessage]]: """Run a resume and return (result, sent_messages).""" if response_task is None: - response_task = _make_task(TaskState.completed, text="ok") + response_task = _make_task(TaskState.TASK_STATE_COMPLETED, text="ok") sent: list[A2AMessage] = [] async def capture(*, request, **kw): @@ -362,26 +372,26 @@ async def test_approve_sends_approve_decision(self): tool, confirmed=True, payload=_RESUME_PAYLOAD, - response_task=_make_task(TaskState.completed, text="approved"), + response_task=_make_task(TaskState.TASK_STATE_COMPLETED, text="approved"), ) assert result["result"] == "approved" - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data[KAGENT_HITL_DECISION_TYPE_KEY] == KAGENT_HITL_DECISION_TYPE_APPROVE # Verify task_id and context_id are routed correctly - assert sent[0].task_id == "task-1" - assert sent[0].context_id == "ctx-1" + assert sent[0].message.task_id == "task-1" + assert sent[0].message.context_id == "ctx-1" async def test_reject_sends_reject_decision(self): tool = _make_tool() _, sent = await self._resume(tool, confirmed=False, payload=_RESUME_PAYLOAD) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data[KAGENT_HITL_DECISION_TYPE_KEY] == KAGENT_HITL_DECISION_TYPE_REJECT async def test_reject_with_reason(self): tool = _make_tool() payload = {**_RESUME_PAYLOAD, "rejection_reason": "Too risky"} _, sent = await self._resume(tool, confirmed=False, payload=payload) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data["rejection_reason"] == "Too risky" async def test_batch_decisions_forwarded(self): @@ -391,7 +401,7 @@ async def test_batch_decisions_forwarded(self): "batch_decisions": {"call_1": "approve", "call_2": "reject"}, } result, sent = await self._resume(tool, confirmed=True, payload=payload) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data[KAGENT_HITL_DECISION_TYPE_KEY] == KAGENT_HITL_DECISION_TYPE_BATCH assert data["decisions"] == {"call_1": "approve", "call_2": "reject"} @@ -403,7 +413,7 @@ async def test_batch_with_rejection_reasons(self): "rejection_reasons": {"call_2": "Too dangerous"}, } _, sent = await self._resume(tool, confirmed=True, payload=payload) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data["rejection_reasons"] == {"call_2": "Too dangerous"} async def test_ask_user_answers_forwarded(self): @@ -411,7 +421,7 @@ async def test_ask_user_answers_forwarded(self): tool = _make_tool() payload = {**_RESUME_PAYLOAD, "answers": ["yes", "42"]} _, sent = await self._resume(tool, confirmed=True, payload=payload) - data = sent[0].parts[0].root.data + data = MessageToDict(sent[0].message.parts[0].data) assert data[KAGENT_HITL_DECISION_TYPE_KEY] == KAGENT_HITL_DECISION_TYPE_APPROVE assert data["ask_user_answers"] == ["yes", "42"] diff --git a/python/packages/kagent-adk/tests/unittests/test_token_service.py b/python/packages/kagent-adk/tests/unittests/test_token_service.py new file mode 100644 index 0000000000..ae12d16a7b --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_token_service.py @@ -0,0 +1,19 @@ +import httpx +import pytest + +from kagent.adk._token import KAgentTokenService + + +@pytest.mark.asyncio +async def test_add_headers_includes_a2a_version_and_identity(monkeypatch): + service = KAgentTokenService("test-agent") + service.token = "test-token" + monkeypatch.setattr("kagent.adk._token.get_request_user_id", lambda: "user-1") + + request = httpx.Request("GET", "http://kagent.local/api/tasks") + await service._add_headers(request) + + assert request.headers["A2A-Version"] == "1.0" + assert request.headers["X-Agent-Name"] == "test-agent" + assert request.headers["X-User-Id"] == "user-1" + assert request.headers["Authorization"] == "Bearer test-token" diff --git a/python/packages/kagent-core/pyproject.toml b/python/packages/kagent-core/pyproject.toml index ce2333fdc7..260a4be69b 100644 --- a/python/packages/kagent-core/pyproject.toml +++ b/python/packages/kagent-core/pyproject.toml @@ -9,7 +9,7 @@ description = "kagent common library for kagent python packages" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "a2a-sdk[http-server]>=0.3.23", + "a2a-sdk[http-server]>=1.0.0", "opentelemetry-api>=1.38.0,<1.39.0", "opentelemetry-sdk>=1.38.0,<1.39.0", "opentelemetry-exporter-otlp-proto-grpc>=1.38.0,<1.39.0", diff --git a/python/packages/kagent-core/src/kagent/core/a2a/__init__.py b/python/packages/kagent-core/src/kagent/core/a2a/__init__.py index 3de48d6350..63c200a9fc 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/__init__.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/__init__.py @@ -1,5 +1,4 @@ from ._config import get_a2a_max_content_length -from ._context import get_request_user_id, set_request_user_id from ._consts import ( A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT, @@ -18,6 +17,7 @@ get_kagent_metadata_key, read_metadata_value, ) +from ._context import get_request_user_id, set_request_user_id from ._hitl_utils import ( DecisionType, HitlPartInfo, diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_consts.py b/python/packages/kagent-core/src/kagent/core/a2a/_consts.py index 46a8fb5c04..63d780c431 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_consts.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_consts.py @@ -13,6 +13,20 @@ ADK_METADATA_KEY_PREFIX = "adk_" +def _normalize_metadata(metadata): + """Normalize protobuf Struct metadata into a plain dict when needed.""" + if not metadata: + return {} + if isinstance(metadata, dict): + return metadata + # Protobuf Struct behaves like a message object; convert safely for key access. + if hasattr(metadata, "DESCRIPTOR"): + from google.protobuf.json_format import MessageToDict + + return MessageToDict(metadata) + return {} + + def get_kagent_metadata_key(key: str) -> str: """Gets the A2A event metadata key for the given key. @@ -50,14 +64,15 @@ def read_metadata_value(metadata: dict | None, key: str, default=None): """ if not key: raise ValueError("Metadata key cannot be empty or None") - if not metadata: + normalized = _normalize_metadata(metadata) + if not normalized: return default adk_key = f"{ADK_METADATA_KEY_PREFIX}{key}" - if adk_key in metadata: - return metadata[adk_key] + if adk_key in normalized: + return normalized[adk_key] kagent_key = f"{KAGENT_METADATA_KEY_PREFIX}{key}" - if kagent_key in metadata: - return metadata[kagent_key] + if kagent_key in normalized: + return normalized[kagent_key] return default diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_hitl_utils.py b/python/packages/kagent-core/src/kagent/core/a2a/_hitl_utils.py index 29ef107be5..4187874b73 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_hitl_utils.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_hitl_utils.py @@ -9,11 +9,8 @@ import logging from typing import Any, Literal -from a2a.types import ( - DataPart, - Message, - Task, -) +from a2a.types import Message, Task +from google.protobuf.json_format import MessageToDict from pydantic import BaseModel, ConfigDict, Field from ._consts import ( @@ -33,6 +30,32 @@ logger = logging.getLogger(__name__) +def _to_python(value: Any) -> Any: + """Convert protobuf messages/values into plain Python values.""" + if hasattr(value, "DESCRIPTOR"): + return MessageToDict(value) + return value + + +def _extract_data_payload(part: Any) -> dict[str, Any] | None: + """Extract a structured payload from v1 or transitional part shapes.""" + if hasattr(part, "HasField") and part.HasField("data"): + data = _to_python(part.data) + return data if isinstance(data, dict) else None + + return None + + +def _extract_metadata(part: Any) -> dict[str, Any]: + """Extract metadata from v1 or transitional part shapes.""" + if hasattr(part, "metadata"): + metadata = _to_python(part.metadata) + if isinstance(metadata, dict): + return metadata + + return {} + + class OriginalFunctionCall(BaseModel): """The original tool function call that requires human approval. @@ -152,16 +175,12 @@ def extract_decision_from_message(message: Message | None) -> DecisionType | Non return None for part in message.parts: - # Access .root for RootModel union types - if not hasattr(part, "root"): + data = _extract_data_payload(part) + if not isinstance(data, dict): continue - - inner = part.root - - if isinstance(inner, DataPart): - decision = extract_decision_from_data_part(inner.data) - if decision: - return decision + decision = extract_decision_from_data_part(data) + if decision: + return decision return None @@ -188,37 +207,33 @@ def extract_batch_decisions_from_message(message: Message | None) -> dict[str, D return None for part in message.parts: - if not hasattr(part, "root"): + data = _extract_data_payload(part) + if not isinstance(data, dict): + continue + if data.get(KAGENT_HITL_DECISION_TYPE_KEY) != KAGENT_HITL_DECISION_TYPE_BATCH: continue - inner = part.root - - if isinstance(inner, DataPart): - data = inner.data - if data.get(KAGENT_HITL_DECISION_TYPE_KEY) != KAGENT_HITL_DECISION_TYPE_BATCH: - continue - - decisions = data.get(KAGENT_HITL_DECISIONS_KEY) - if isinstance(decisions, dict): - # Filter out invalid decisions - filtered: dict[str, DecisionType] = {} - for call_id, decision in decisions.items(): - # Ensure key type and decision value are valid - if not isinstance(call_id, str): - logger.warning("Ignoring HITL batch decision with non-string key: %r", call_id) - continue - if decision in ( - KAGENT_HITL_DECISION_TYPE_APPROVE, - KAGENT_HITL_DECISION_TYPE_REJECT, - ): - filtered[call_id] = decision - else: - logger.warning( - "Ignoring HITL batch decision with invalid value %r for call_id %r", - decision, - call_id, - ) - return filtered or None + decisions = data.get(KAGENT_HITL_DECISIONS_KEY) + if isinstance(decisions, dict): + # Filter out invalid decisions + filtered: dict[str, DecisionType] = {} + for call_id, decision in decisions.items(): + # Ensure key type and decision value are valid + if not isinstance(call_id, str): + logger.warning("Ignoring HITL batch decision with non-string key: %r", call_id) + continue + if decision in ( + KAGENT_HITL_DECISION_TYPE_APPROVE, + KAGENT_HITL_DECISION_TYPE_REJECT, + ): + filtered[call_id] = decision + else: + logger.warning( + "Ignoring HITL batch decision with invalid value %r for call_id %r", + decision, + call_id, + ) + return filtered or None return None @@ -242,27 +257,23 @@ def extract_rejection_reasons_from_message(message: Message | None) -> dict[str, return None for part in message.parts: - if not hasattr(part, "root"): + data = _extract_data_payload(part) + if not isinstance(data, dict): continue - - inner = part.root - - if isinstance(inner, DataPart): - data = inner.data - decision = data.get(KAGENT_HITL_DECISION_TYPE_KEY) - - if decision == KAGENT_HITL_DECISION_TYPE_BATCH: - reasons = data.get(KAGENT_HITL_REJECTION_REASONS_KEY) - if isinstance(reasons, dict): - filtered: dict[str, str] = {} - for call_id, reason in reasons.items(): - if isinstance(call_id, str) and isinstance(reason, str) and reason: - filtered[call_id] = reason - return filtered or None - elif decision == KAGENT_HITL_DECISION_TYPE_REJECT: - reason = data.get("rejection_reason") - if isinstance(reason, str) and reason: - return {"*": reason} + decision = data.get(KAGENT_HITL_DECISION_TYPE_KEY) + + if decision == KAGENT_HITL_DECISION_TYPE_BATCH: + reasons = data.get(KAGENT_HITL_REJECTION_REASONS_KEY) + if isinstance(reasons, dict): + filtered: dict[str, str] = {} + for call_id, reason in reasons.items(): + if isinstance(call_id, str) and isinstance(reason, str) and reason: + filtered[call_id] = reason + return filtered or None + elif decision == KAGENT_HITL_DECISION_TYPE_REJECT: + reason = data.get("rejection_reason") + if isinstance(reason, str) and reason: + return {"*": reason} return None @@ -283,16 +294,12 @@ def extract_ask_user_answers_from_message(message: Message | None) -> list[dict] return None for part in message.parts: - if not hasattr(part, "root"): + data = _extract_data_payload(part) + if not isinstance(data, dict): continue - - inner = part.root - - if isinstance(inner, DataPart): - data = inner.data - answers = data.get(KAGENT_ASK_USER_ANSWERS_KEY) - if isinstance(answers, list): - return answers + answers = data.get(KAGENT_ASK_USER_ANSWERS_KEY) + if isinstance(answers, list): + return answers return None @@ -317,12 +324,13 @@ def extract_hitl_info_from_task(task: Task) -> list[HitlPartInfo] | None: hitl_parts: list[HitlPartInfo] = [] for part in task.status.message.parts: - root = part.root if hasattr(part, "root") else part - if not isinstance(root, DataPart) or not root.metadata: + metadata = _extract_metadata(part) + data = _extract_data_payload(part) + if not metadata or not isinstance(data, dict): continue - part_type = read_metadata_value(root.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) - is_long_running = read_metadata_value(root.metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) + part_type = read_metadata_value(metadata, A2A_DATA_PART_METADATA_TYPE_KEY) + is_long_running = read_metadata_value(metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) if part_type == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL and is_long_running is True: - hitl_parts.append(HitlPartInfo.from_data_part_data(root.data)) + hitl_parts.append(HitlPartInfo.from_data_part_data(data)) return hitl_parts or None diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_requests.py b/python/packages/kagent-core/src/kagent/core/a2a/_requests.py index 13b36ffa9a..ca229c615b 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_requests.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_requests.py @@ -4,7 +4,7 @@ from a2a.server.agent_execution import RequestContext, SimpleRequestContextBuilder from a2a.server.context import ServerCallContext from a2a.server.tasks import TaskStore -from a2a.types import MessageSendParams, Task +from a2a.types import SendMessageRequest, Task from ._context import set_request_user_id @@ -37,11 +37,11 @@ def __init__(self, task_store: TaskStore): async def build( self, - params: MessageSendParams | None = None, + context: ServerCallContext, + params: SendMessageRequest | None = None, task_id: str | None = None, context_id: str | None = None, task: Task | None = None, - context: ServerCallContext | None = None, ) -> RequestContext: if context: headers = context.state.get("headers", {}) @@ -55,5 +55,11 @@ async def build( source = headers.get("x-kagent-source", None) if source: context.state["kagent_source"] = source - request_context = await super().build(params, task_id, context_id, task, context) + request_context = await super().build( + context=context, + params=params, + task_id=task_id, + context_id=context_id, + task=task, + ) return request_context diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_task_result_aggregator.py b/python/packages/kagent-core/src/kagent/core/a2a/_task_result_aggregator.py index 403be62fd4..b1aa209a33 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_task_result_aggregator.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_task_result_aggregator.py @@ -6,7 +6,7 @@ class TaskResultAggregator: """Aggregates the task status updates and provides the final task state.""" def __init__(self): - self._task_state = TaskState.working + self._task_state = TaskState.TASK_STATE_WORKING self._task_status_message = None def process_event(self, event: Event): @@ -18,24 +18,27 @@ def process_event(self, event: Event): - working """ if isinstance(event, TaskStatusUpdateEvent): - if event.status.state == TaskState.failed: - self._task_state = TaskState.failed + if event.status.state == TaskState.TASK_STATE_FAILED: + self._task_state = TaskState.TASK_STATE_FAILED self._task_status_message = event.status.message - elif event.status.state == TaskState.auth_required and self._task_state != TaskState.failed: - self._task_state = TaskState.auth_required + elif ( + event.status.state == TaskState.TASK_STATE_AUTH_REQUIRED + and self._task_state != TaskState.TASK_STATE_FAILED + ): + self._task_state = TaskState.TASK_STATE_AUTH_REQUIRED self._task_status_message = event.status.message - elif event.status.state == TaskState.input_required and self._task_state not in ( - TaskState.failed, - TaskState.auth_required, + elif event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED and self._task_state not in ( + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_AUTH_REQUIRED, ): - self._task_state = TaskState.input_required + self._task_state = TaskState.TASK_STATE_INPUT_REQUIRED self._task_status_message = event.status.message # final state is already recorded and make sure the intermediate state is # always working because other state may terminate the event aggregation # in a2a request handler - elif self._task_state == TaskState.working: + elif self._task_state == TaskState.TASK_STATE_WORKING: self._task_status_message = event.status.message - event.status.state = TaskState.working + event.status.state = TaskState.TASK_STATE_WORKING @property def task_state(self) -> TaskState: diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py b/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py index 134fa25e9a..52ec7434a1 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py @@ -1,25 +1,17 @@ import asyncio +import logging +from datetime import timezone import httpx from a2a.server.tasks import TaskStore -from a2a.types import Message, Task -from pydantic import BaseModel +from a2a.types import ListTasksRequest, ListTasksResponse, Message, Task +from google.protobuf.json_format import MessageToDict, ParseDict from typing_extensions import override from kagent.core.a2a import read_metadata_value - -class KAgentTaskResponse(BaseModel): - """Wrapper for KAgent controller API responses. - - The KAgent Go controller wraps all task responses in a StandardResponse envelope - with the format: {"error": bool, "data": T, "message": str}. - This model unwraps that envelope to extract the actual Task object. - """ - - error: bool - data: Task | None = None - message: str | None = None +logger = logging.getLogger(__name__) +DEFAULT_LIST_TASKS_PAGE_SIZE = 50 class KAgentTaskStore(TaskStore): @@ -62,10 +54,16 @@ async def save(self, task: Task, context=None) -> None: httpx.HTTPStatusError: If the API request fails """ # Clean any partial events from history before saving - history = task.history or [] - task.history = self._clean_partial_events(history) - - response = await self.client.post("/api/tasks", json=task.model_dump(mode="json")) + history = list(task.history or []) + clean_history = self._clean_partial_events(history) + if len(clean_history) != len(history): + del task.history[:] + task.history.extend(clean_history) + + response = await self.client.post( + "/api/tasks", + json=MessageToDict(task), + ) response.raise_for_status() # Signal that save completed (event-based sync) @@ -92,8 +90,73 @@ async def get(self, task_id: str, context=None) -> Task | None: response.raise_for_status() # Unwrap the StandardResponse envelope from the Go controller - wrapped = KAgentTaskResponse.model_validate(response.json()) - return wrapped.data + wrapped = response.json() + data = wrapped.get("data") if isinstance(wrapped, dict) else None + if not isinstance(data, dict): + return None + return ParseDict(data, Task()) + + @override + async def list(self, params: ListTasksRequest, context=None) -> ListTasksResponse: + """List tasks for a context (session) from KAgent. + + The controller exposes task listing under the session-scoped endpoint, + so ``params.context_id`` is required to fetch tasks. + """ + page_size = params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE + if not params.context_id: + return ListTasksResponse(tasks=[], page_size=page_size, total_size=0) + + response = await self.client.get(f"/api/sessions/{params.context_id}/tasks") + if response.status_code == 404: + return ListTasksResponse(tasks=[], page_size=page_size, total_size=0) + response.raise_for_status() + + wrapped = response.json() + data = wrapped.get("data") if isinstance(wrapped, dict) else None + if not isinstance(data, list): + return ListTasksResponse(tasks=[], page_size=page_size, total_size=0) + + tasks: list[Task] = [] + for item in data: + if not isinstance(item, dict): + continue + try: + tasks.append(ParseDict(item, Task())) + except Exception as err: + logger.warning("Failed to parse task from list response: %s", err) + + if params.status: + tasks = [task for task in tasks if task.status and task.status.state == params.status] + + if params.HasField("status_timestamp_after"): + after = params.status_timestamp_after.ToDatetime().astimezone(timezone.utc) + filtered: list[Task] = [] + for task in tasks: + if not task.status or not task.status.HasField("timestamp"): + continue + task_ts = task.status.timestamp.ToDatetime().astimezone(timezone.utc) + if task_ts >= after: + filtered.append(task) + tasks = filtered + + start = 0 + if params.page_token: + try: + start = max(0, int(params.page_token)) + except ValueError: + start = 0 + if start >= len(tasks): + return ListTasksResponse(tasks=[], page_size=page_size, total_size=len(tasks)) + + end = min(start + page_size, len(tasks)) + next_page_token = str(end) if end < len(tasks) else "" + return ListTasksResponse( + tasks=tasks[start:end], + page_size=page_size, + total_size=len(tasks), + next_page_token=next_page_token, + ) @override async def delete(self, task_id: str, context=None) -> None: diff --git a/python/packages/kagent-core/tests/test_hitl_utils.py b/python/packages/kagent-core/tests/test_hitl_utils.py index aa980d883a..7c66d515c9 100644 --- a/python/packages/kagent-core/tests/test_hitl_utils.py +++ b/python/packages/kagent-core/tests/test_hitl_utils.py @@ -1,6 +1,8 @@ """Tests for HITL utility functions in kagent.core.a2a._hitl_utils.""" -from a2a.types import DataPart, Message, Part, Role, Task, TaskState, TaskStatus +from a2a.types import Message, Part, Role, Task, TaskState, TaskStatus +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value from kagent.core.a2a import ( KAGENT_HITL_DECISION_TYPE_APPROVE, @@ -25,11 +27,11 @@ def _make_message(*data_parts: dict) -> Message: """Build a Message with one or more DataPart dicts.""" return Message( - role=Role.user, + role=Role.ROLE_USER, message_id="test", task_id="task1", context_id="ctx1", - parts=[Part(DataPart(data=d)) for d in data_parts], + parts=[Part(data=ParseDict(d, Value())) for d in data_parts], ) @@ -39,22 +41,20 @@ def _make_hitl_task(*hitl_data_dicts: dict) -> Task: for d in hitl_data_dicts: parts.append( Part( - DataPart( - data=d, - metadata={ - "adk_type": "function_call", - "adk_is_long_running": True, - }, - ) + data=ParseDict(d, Value()), + metadata={ + "adk_type": "function_call", + "adk_is_long_running": True, + }, ) ) return Task( id="task-1", context_id="ctx-1", status=TaskStatus( - state=TaskState.input_required, + state=TaskState.TASK_STATE_INPUT_REQUIRED, message=Message( - role=Role.agent, + role=Role.ROLE_AGENT, message_id="msg-1", parts=parts, ), @@ -93,7 +93,7 @@ def test_none_message(self): assert extract_decision_from_message(None) is None def test_empty_parts(self): - msg = Message(role=Role.user, message_id="x", task_id="t", context_id="c", parts=[]) + msg = Message(role=Role.ROLE_USER, message_id="x", task_id="t", context_id="c", parts=[]) assert extract_decision_from_message(msg) is None def test_no_decision_key(self): @@ -465,7 +465,7 @@ def test_multiple_hitl_parts(self): assert result[1].tool_name == "write_file" def test_no_message(self): - task = Task(id="t", context_id="c", status=TaskStatus(state=TaskState.completed)) + task = Task(id="t", context_id="c", status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED)) assert extract_hitl_info_from_task(task) is None def test_no_parts(self): @@ -473,8 +473,8 @@ def test_no_parts(self): id="t", context_id="c", status=TaskStatus( - state=TaskState.input_required, - message=Message(role=Role.agent, message_id="m", parts=[]), + state=TaskState.TASK_STATE_INPUT_REQUIRED, + message=Message(role=Role.ROLE_AGENT, message_id="m", parts=[]), ), ) assert extract_hitl_info_from_task(task) is None @@ -485,16 +485,14 @@ def test_non_hitl_data_parts_skipped(self): id="t", context_id="c", status=TaskStatus( - state=TaskState.input_required, + state=TaskState.TASK_STATE_INPUT_REQUIRED, message=Message( - role=Role.agent, + role=Role.ROLE_AGENT, message_id="m", parts=[ Part( - DataPart( - data={"name": "some_function", "args": {}}, - metadata={"adk_type": "function_call"}, - ) + data=ParseDict({"name": "some_function", "args": {}}, Value()), + metadata={"adk_type": "function_call"}, ), ], ), @@ -508,25 +506,26 @@ def test_kagent_prefix_metadata(self): id="t", context_id="c", status=TaskStatus( - state=TaskState.input_required, + state=TaskState.TASK_STATE_INPUT_REQUIRED, message=Message( - role=Role.agent, + role=Role.ROLE_AGENT, message_id="m", parts=[ Part( - DataPart( - data={ + data=ParseDict( + { "name": "adk_request_confirmation", "id": "conf_1", "args": { "originalFunctionCall": {"name": "delete_file", "args": {}, "id": "c1"}, }, }, - metadata={ - "kagent_type": "function_call", - "kagent_is_long_running": True, - }, - ) + Value(), + ), + metadata={ + "kagent_type": "function_call", + "kagent_is_long_running": True, + }, ), ], ), diff --git a/python/packages/kagent-core/tests/test_task_store.py b/python/packages/kagent-core/tests/test_task_store.py new file mode 100644 index 0000000000..822a13f03d --- /dev/null +++ b/python/packages/kagent-core/tests/test_task_store.py @@ -0,0 +1,61 @@ +import httpx +import pytest +from a2a.types import ListTasksRequest, Task, TaskState, TaskStatus +from google.protobuf.json_format import MessageToDict +from google.protobuf.timestamp_pb2 import Timestamp + +from kagent.core.a2a import KAgentTaskStore + + +@pytest.mark.asyncio +async def test_list_requires_context_id(): + client = httpx.AsyncClient(base_url="http://kagent.local") + store = KAgentTaskStore(client) + + resp = await store.list(ListTasksRequest()) + assert len(resp.tasks) == 0 + assert resp.total_size == 0 + + await client.aclose() + + +@pytest.mark.asyncio +async def test_list_filters_status_and_supports_paging(): + ts = Timestamp() + ts.GetCurrentTime() + task_working = Task( + id="t-working", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_WORKING, timestamp=ts), + ) + task_done = Task( + id="t-done", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED, timestamp=ts), + ) + payload = { + "data": [MessageToDict(task_working), MessageToDict(task_done)], + } + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/sessions/ctx-1/tasks" + return httpx.Response(200, json=payload) + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(base_url="http://kagent.local", transport=transport) + store = KAgentTaskStore(client) + + resp = await store.list( + ListTasksRequest( + context_id="ctx-1", + status=TaskState.TASK_STATE_WORKING, + page_size=1, + ) + ) + + assert resp.total_size == 1 + assert resp.page_size == 1 + assert len(resp.tasks) == 1 + assert resp.tasks[0].id == "t-working" + + await client.aclose() diff --git a/python/packages/kagent-crewai/pyproject.toml b/python/packages/kagent-crewai/pyproject.toml index 69cb3eb5a6..ad38b27af9 100644 --- a/python/packages/kagent-crewai/pyproject.toml +++ b/python/packages/kagent-crewai/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "pydantic>=2.0.0", "typing-extensions>=4.0.0", "uvicorn>=0.20.0", - "a2a-sdk[http-server]>=0.3.23", + "a2a-sdk[http-server]>=1.0.0", "kagent-core>=0.1.0", "opentelemetry-instrumentation-crewai>=0.47.3", "google-genai>=1.21.1" diff --git a/python/packages/kagent-crewai/src/kagent/crewai/_a2a.py b/python/packages/kagent-crewai/src/kagent/crewai/_a2a.py index 957047c1d8..8a3dcc4638 100644 --- a/python/packages/kagent-crewai/src/kagent/crewai/_a2a.py +++ b/python/packages/kagent-crewai/src/kagent/crewai/_a2a.py @@ -4,11 +4,12 @@ from typing import Union import httpx -from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.types import AgentCard from fastapi import FastAPI, Request from fastapi.responses import PlainTextResponse +from google.protobuf.json_format import ParseDict from kagent.core import KAgentConfig, configure_tracing from kagent.core.a2a import ( KAgentRequestContextBuilder, @@ -42,19 +43,19 @@ def __init__( self, *, crew: Union[Crew, Flow], - agent_card: AgentCard, + agent_card: AgentCard | dict, config: KAgentConfig = KAgentConfig(), executor_config: CrewAIAgentExecutorConfig | None = None, tracing: bool = True, ): self._crew = crew - self.agent_card = AgentCard.model_validate(agent_card) + self.agent_card = ParseDict(agent_card, AgentCard()) if isinstance(agent_card, dict) else agent_card self.config = config self.executor_config = executor_config or CrewAIAgentExecutorConfig() self.tracing = tracing def build(self) -> FastAPI: - http_client = httpx.AsyncClient(base_url=self.config.url) + http_client = httpx.AsyncClient(base_url=self.config.url, headers={"A2A-Version": "1.0"}) agent_executor = CrewAIAgentExecutor( crew=self._crew, @@ -68,15 +69,12 @@ def build(self) -> FastAPI: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - max_content_length = get_a2a_max_content_length() - a2a_app = A2AStarletteApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() faulthandler.enable() app = FastAPI( @@ -94,6 +92,7 @@ def build(self) -> FastAPI: app.add_route("/health", methods=["GET"], route=def_health_check) app.add_route("/thread_dump", methods=["GET"], route=thread_dump) - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app diff --git a/python/packages/kagent-crewai/src/kagent/crewai/_executor.py b/python/packages/kagent-crewai/src/kagent/crewai/_executor.py index 56f8d3e017..5ab869d900 100644 --- a/python/packages/kagent-crewai/src/kagent/crewai/_executor.py +++ b/python/packages/kagent-crewai/src/kagent/crewai/_executor.py @@ -1,6 +1,5 @@ import logging import uuid -from datetime import datetime, timezone from typing import Any, Union try: @@ -14,7 +13,6 @@ from a2a.server.events.event_queue import EventQueue from a2a.types import ( Artifact, - DataPart, Message, Part, Role, @@ -22,8 +20,10 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) +from google.protobuf.json_format import MessageToDict +from google.protobuf.timestamp_pb2 import Timestamp +from kagent.core.a2a import get_kagent_metadata_key from kagent.core.tracing._span_processor import ( clear_kagent_span_attributes, set_kagent_span_attributes, @@ -40,6 +40,12 @@ logger = logging.getLogger(__name__) +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + class CrewAIAgentExecutorConfig(BaseModel): execution_timeout: float = 300.0 @@ -83,12 +89,11 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.submitted, + state=TaskState.TASK_STATE_SUBMITTED, message=context.message, - timestamp=datetime.now(timezone.utc).isoformat(), + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, ) ) @@ -96,14 +101,13 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, metadata={ - "app_name": self.app_name, - "session_id": context.context_id, + get_kagent_metadata_key("app_name"): self.app_name, + get_kagent_metadata_key("session_id"): context.context_id, }, ) ) @@ -115,8 +119,10 @@ async def execute( inputs = None if context.message and context.message.parts: for part in context.message.parts: - if isinstance(part, DataPart): - inputs = part.root.data + if part.HasField("data"): + data_payload = MessageToDict(part.data) + if isinstance(data_payload, dict): + inputs = data_payload break if inputs is None: user_input = context.get_user_input() @@ -161,7 +167,7 @@ async def execute( context_id=context.context_id, artifact=Artifact( artifact_id=str(uuid.uuid4()), - parts=[Part(TextPart(text=result_text))], + parts=[Part(text=result_text)], ), ) ) @@ -169,11 +175,10 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_COMPLETED, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=True, ) ) @@ -183,16 +188,15 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=str(e)))], + role=Role.ROLE_AGENT, + parts=[Part(text=str(e))], ), ), context_id=context.context_id, - final=True, ) ) finally: diff --git a/python/packages/kagent-crewai/src/kagent/crewai/_listeners.py b/python/packages/kagent-crewai/src/kagent/crewai/_listeners.py index 38378901c4..8d95bbd2f9 100644 --- a/python/packages/kagent-crewai/src/kagent/crewai/_listeners.py +++ b/python/packages/kagent-crewai/src/kagent/crewai/_listeners.py @@ -1,20 +1,20 @@ import asyncio import uuid -from datetime import datetime, timezone from typing import Any from a2a.server.agent_execution.context import RequestContext from a2a.server.events.event_queue import EventQueue from a2a.types import ( - DataPart, Message, Part, Role, TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import ( A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, @@ -35,6 +35,12 @@ ) +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + class A2ACrewAIListener(BaseEventListener): def __init__( self, @@ -58,16 +64,15 @@ def on_task_started(source: Any, event: TaskStartedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=f"Task started: {event.task.name}"))], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Task started: {event.task.name}")], ), ), context_id=self.context.context_id, - final=False, metadata={"app_name": self.app_name, "session_id": self.context.context_id}, ) ) @@ -79,16 +84,15 @@ def on_task_completed(source: Any, event: TaskCompletedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=f"Task completed: {event.task.name}\n"))], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Task completed: {event.task.name}\n")], ), ), context_id=self.context.context_id, - final=False, metadata={"app_name": self.app_name, "session_id": self.context.context_id}, ) ) @@ -99,22 +103,15 @@ def on_agent_execution_started(source: Any, event: AgentExecutionStartedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[ - Part( - TextPart( - text=f"Agent {event.agent.id} started working on task: {event.task_prompt}" - ) - ) - ], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Agent {event.agent.id} started working on task: {event.task_prompt}")], ), ), context_id=self.context.context_id, - final=False, metadata={"app_name": self.app_name, "session_id": self.context.context_id}, ) ) @@ -126,16 +123,15 @@ def on_agent_execution_completed(source: Any, event: AgentExecutionCompletedEven TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=str(event.output)))], + role=Role.ROLE_AGENT, + parts=[Part(text=str(event.output))], ), ), context_id=self.context.context_id, - final=False, metadata={"app_name": self.app_name, "session_id": self.context.context_id}, ) ) @@ -146,31 +142,31 @@ def on_tool_usage_started(source: Any, event: ToolUsageStartedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - DataPart( - data={ + data=ParseDict( + { "id": event.tool_class, "name": event.tool_name, "args": event.tool_args, }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + }, ) ], ), ), context_id=self.context.context_id, - final=False, metadata={"app_name": self.app_name, "session_id": self.context.context_id}, ) ) @@ -181,31 +177,31 @@ def on_tool_usage_finished(source: Any, event: ToolUsageFinishedEvent): TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - DataPart( - data={ + data=ParseDict( + { "id": event.tool_class, "name": event.tool_name, "response": event.output, }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, + }, ) ], ), ), context_id=self.context.context_id, - final=False, metadata={"app_name": self.app_name, "session_id": self.context.context_id}, ) ) @@ -216,22 +212,17 @@ def on_method_execution_started(source: Any, event: MethodExecutionStartedEvent) TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ - Part( - TextPart( - text=f"Method {event.method_name} from flow {event.flow_name} started execution." - ) - ) + Part(text=f"Method {event.method_name} from flow {event.flow_name} started execution.") ], ), ), context_id=self.context.context_id, - final=False, metadata={"app_name": self.app_name, "session_id": self.context.context_id}, ) ) @@ -242,22 +233,17 @@ def on_method_execution_finished(source: Any, event: MethodExecutionFinishedEven TaskStatusUpdateEvent( task_id=self.context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(timezone.utc).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ - Part( - TextPart( - text=f"Method {event.method_name} from flow {event.flow_name} finished execution." - ) - ) + Part(text=f"Method {event.method_name} from flow {event.flow_name} finished execution.") ], ), ), context_id=self.context.context_id, - final=False, metadata={"app_name": self.app_name, "session_id": self.context.context_id}, ) ) diff --git a/python/packages/kagent-langgraph/pyproject.toml b/python/packages/kagent-langgraph/pyproject.toml index d84417738b..4995cb2b5e 100644 --- a/python/packages/kagent-langgraph/pyproject.toml +++ b/python/packages/kagent-langgraph/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "pydantic>=2.0.0", "typing-extensions>=4.0.0", "uvicorn>=0.20.0", - "a2a-sdk>=0.3.23", + "a2a-sdk>=1.0.0", "kagent-core>=0.1.0", "langsmith[otel]>=0.4.30", ] diff --git a/python/packages/kagent-langgraph/src/kagent/langgraph/_a2a.py b/python/packages/kagent-langgraph/src/kagent/langgraph/_a2a.py index 0f3be75d54..6ae390e4de 100644 --- a/python/packages/kagent-langgraph/src/kagent/langgraph/_a2a.py +++ b/python/packages/kagent-langgraph/src/kagent/langgraph/_a2a.py @@ -8,11 +8,12 @@ import logging import httpx -from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.types import AgentCard from fastapi import FastAPI, Request from fastapi.responses import PlainTextResponse +from google.protobuf.json_format import ParseDict from kagent.core import KAgentConfig, configure_tracing from kagent.core.a2a import ( KAgentRequestContextBuilder, @@ -54,7 +55,7 @@ def __init__( self, *, graph: CompiledStateGraph, - agent_card: AgentCard, + agent_card: AgentCard | dict, config: KAgentConfig, executor_config: LangGraphAgentExecutorConfig | None = None, tracing: bool = True, @@ -70,7 +71,7 @@ def __init__( """ self._graph = graph - self.agent_card = AgentCard.model_validate(agent_card) + self.agent_card = ParseDict(agent_card, AgentCard()) if isinstance(agent_card, dict) else agent_card self.config = config self.executor_config = executor_config or LangGraphAgentExecutorConfig() @@ -102,16 +103,12 @@ def build(self) -> FastAPI: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - # Create A2A application - max_content_length = get_a2a_max_content_length() - a2a_app = A2AStarletteApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() # Enable fault handler for debugging faulthandler.enable() @@ -136,6 +133,7 @@ def build(self) -> FastAPI: app.add_route("/thread_dump", methods=["GET"], route=thread_dump) # Add A2A routes - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app diff --git a/python/packages/kagent-langgraph/src/kagent/langgraph/_converters.py b/python/packages/kagent-langgraph/src/kagent/langgraph/_converters.py index 9fb66358f7..a147533394 100644 --- a/python/packages/kagent-langgraph/src/kagent/langgraph/_converters.py +++ b/python/packages/kagent-langgraph/src/kagent/langgraph/_converters.py @@ -6,26 +6,19 @@ import hashlib import uuid -from datetime import datetime from typing import Any -try: - from datetime import UTC # Python 3.11+ -except ImportError: - from datetime import timezone - - UTC = timezone.utc - from a2a.types import ( - DataPart, Message, Part, Role, TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import ( A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, @@ -41,6 +34,12 @@ from ._metadata_utils import get_rich_event_metadata +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + async def _convert_langgraph_event_to_a2a( langgraph_event: dict[str, Any], task_id: str, @@ -76,27 +75,28 @@ async def _convert_langgraph_event_to_a2a( if isinstance(message, AIMessage): # Handle AI messages (assistant responses) - a2a_message = Message(message_id=str(uuid.uuid4()), role=Role.agent, parts=[]) + a2a_message = Message(message_id=str(uuid.uuid4()), role=Role.ROLE_AGENT, parts=[]) if message.content and isinstance(message.content, str) and message.content.strip(): - a2a_message.parts.append(Part(TextPart(text=message.content))) + a2a_message.parts.append(Part(text=message.content)) # Handle tool calls in AI messages if hasattr(message, "tool_calls") and message.tool_calls: for tool_call in message.tool_calls: a2a_message.parts.append( Part( - DataPart( - data={ + data=ParseDict( + { "id": tool_call["id"], "name": tool_call["name"], "args": tool_call["args"], }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + }, ) ) @@ -108,12 +108,11 @@ async def _convert_langgraph_event_to_a2a( TaskStatusUpdateEvent( task_id=task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=a2a_message, ), context_id=context_id, - final=False, metadata=get_rich_event_metadata( app_name=app_name, session_id=context_id, @@ -128,31 +127,31 @@ async def _convert_langgraph_event_to_a2a( TaskStatusUpdateEvent( task_id=task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=[ Part( - DataPart( - data={ + data=ParseDict( + { "id": message.tool_call_id, "name": message.name, "response": message.content, }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, + }, ) ], ), ), context_id=context_id, - final=False, metadata=get_rich_event_metadata( app_name=app_name, session_id=context_id, diff --git a/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py b/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py index 4663bd226a..fe472187b2 100644 --- a/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py +++ b/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py @@ -8,16 +8,8 @@ import logging import uuid from collections.abc import Mapping -from datetime import datetime from typing import Any -try: - from datetime import UTC # Python 3.11+ -except ImportError: - from datetime import timezone - - UTC = timezone.utc - try: from typing import override # Python 3.12+ except ImportError: @@ -28,7 +20,6 @@ from a2a.server.events.event_queue import EventQueue from a2a.types import ( Artifact, - DataPart, Message, Part, Role, @@ -36,8 +27,10 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import ( A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, @@ -67,6 +60,12 @@ logger = logging.getLogger(__name__) +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + class LangGraphAgentExecutorConfig(BaseModel): """Configuration for the LangGraphAgentExecutor.""" @@ -183,7 +182,7 @@ async def _stream_graph_events( # publish the task result event - this is final if ( - task_result_aggregator.task_state == TaskState.working + task_result_aggregator.task_state == TaskState.TASK_STATE_WORKING and task_result_aggregator.task_status_message is not None and task_result_aggregator.task_status_message.parts ): @@ -205,11 +204,10 @@ async def _stream_graph_events( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_COMPLETED, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=True, ) ) else: @@ -218,11 +216,10 @@ async def _stream_graph_events( task_id=context.task_id, status=TaskStatus( state=task_result_aggregator.task_state, - timestamp=datetime.now(UTC).isoformat(), + timestamp=_now_timestamp(), message=task_result_aggregator.task_status_message, ), context_id=context.context_id, - final=True, ) ) @@ -279,8 +276,8 @@ async def _handle_interrupt( parts.append( Part( - DataPart( - data={ + data=ParseDict( + { "name": "adk_request_confirmation", "id": confirmation_id, "args": { @@ -296,13 +293,14 @@ async def _handle_interrupt( }, }, }, - metadata={ - get_kagent_metadata_key( - A2A_DATA_PART_METADATA_TYPE_KEY - ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, - get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True, - }, - ) + Value(), + ), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + get_kagent_metadata_key(A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY): True, + }, ) ) @@ -310,16 +308,15 @@ async def _handle_interrupt( TaskStatusUpdateEvent( task_id=task_id, status=TaskStatus( - state=TaskState.input_required, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_INPUT_REQUIRED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, + role=Role.ROLE_AGENT, parts=parts, ), ), context_id=context_id, - final=False, ) ) @@ -335,7 +332,7 @@ def _is_resume_command(self, context: RequestContext) -> bool: if not context.current_task: return False - if context.current_task.status.state != TaskState.input_required: + if context.current_task.status.state != TaskState.TASK_STATE_INPUT_REQUIRED: return False # Check if message contains a decision @@ -442,11 +439,10 @@ async def _handle_resume( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, ) ) @@ -468,16 +464,15 @@ async def _handle_resume( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=f"Resume failed: {str(e)}"))], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Resume failed: {str(e)}")], ), ), context_id=context.context_id, - final=True, ) ) @@ -510,12 +505,11 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.submitted, + state=TaskState.TASK_STATE_SUBMITTED, message=context.message, - timestamp=datetime.now(UTC).isoformat(), + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, ) ) @@ -527,15 +521,14 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, metadata={ - "app_name": self.app_name, - "session_id": getattr(context, "session_id", context.context_id), - "thread_id": thread_id, # Store for resume! + get_kagent_metadata_key("app_name"): self.app_name, + get_kagent_metadata_key("session_id"): getattr(context, "session_id", context.context_id), + get_kagent_metadata_key("thread_id"): thread_id, }, ) ) @@ -561,16 +554,15 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text="Execution timed out"))], + role=Role.ROLE_AGENT, + parts=[Part(text="Execution timed out")], ), ), context_id=context.context_id, - final=True, ) ) except Exception as e: @@ -584,12 +576,12 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=user_message))], + role=Role.ROLE_AGENT, + parts=[Part(text=user_message)], metadata={ get_kagent_metadata_key("error_type"): error_meta["error_type"], get_kagent_metadata_key("error_detail"): error_meta["error_detail"], @@ -597,7 +589,6 @@ async def execute( ), ), context_id=context.context_id, - final=True, metadata={ get_kagent_metadata_key("error_type"): error_meta["error_type"], get_kagent_metadata_key("error_detail"): error_meta["error_detail"], diff --git a/python/packages/kagent-openai/pyproject.toml b/python/packages/kagent-openai/pyproject.toml index 9c0751e858..657b0f5f97 100644 --- a/python/packages/kagent-openai/pyproject.toml +++ b/python/packages/kagent-openai/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.10" dependencies = [ "openai>=1.72.0", "openai-agents>=0.4.0", - "a2a-sdk>=0.3.23", + "a2a-sdk>=1.0.0", "kagent-core>=0.1.0", "kagent-skills>=0.1.0", "httpx>=0.25.0", diff --git a/python/packages/kagent-openai/src/kagent/openai/_a2a.py b/python/packages/kagent-openai/src/kagent/openai/_a2a.py index 131a4283ac..e01585eb74 100644 --- a/python/packages/kagent-openai/src/kagent/openai/_a2a.py +++ b/python/packages/kagent-openai/src/kagent/openai/_a2a.py @@ -12,13 +12,14 @@ from collections.abc import Callable import httpx -from a2a.server.apps import A2AFastAPIApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.server.tasks import InMemoryTaskStore from a2a.types import AgentCard from agents import Agent, set_default_openai_api, set_default_openai_client, set_tracing_disabled from fastapi import FastAPI, Request from fastapi.responses import PlainTextResponse +from google.protobuf.json_format import ParseDict from kagent.core import KAgentConfig, configure_tracing from kagent.core.a2a import ( KAgentRequestContextBuilder, @@ -84,7 +85,7 @@ class KAgentApp: def __init__( self, agent: Agent | Callable[[], Agent], - agent_card: AgentCard, + agent_card: AgentCard | dict, config: KAgentConfig, executor_config: OpenAIAgentExecutorConfig | None = None, tracing: bool = True, @@ -93,13 +94,12 @@ def __init__( Args: agent: OpenAI Agent instance or factory function - agent_card: A2A agent card describing the agent's capabilities - kagent_url: URL of the KAgent backend server - app_name: Application name for identification - config: Optional executor configuration + agent_card: A2A agent card — either an AgentCard protobuf instance or a plain dict + config: KAgent configuration + executor_config: Optional executor configuration """ self.agent = agent - self.agent_card = AgentCard.model_validate(agent_card) + self.agent_card = ParseDict(agent_card, AgentCard()) if isinstance(agent_card, dict) else agent_card self.config = config self.executor_config = executor_config or OpenAIAgentExecutorConfig() self.tracing = tracing @@ -121,6 +121,7 @@ def build(self) -> FastAPI: # Create HTTP client with KAgent backend http_client = httpx.AsyncClient( base_url=kagent_url_override or self.config.kagent_url, + headers={"A2A-Version": "1.0"}, ) # Create session factory @@ -145,16 +146,12 @@ def build(self) -> FastAPI: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=kagent_task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - # Create A2A FastAPI application - max_content_length = get_a2a_max_content_length() - a2a_app = A2AFastAPIApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() # Enable fault handler faulthandler.enable() @@ -186,7 +183,8 @@ def build(self) -> FastAPI: app.add_route("/thread_dump", methods=["GET"], route=thread_dump) # Add A2A routes - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app @@ -218,16 +216,12 @@ def build_local(self) -> FastAPI: request_handler = DefaultRequestHandler( agent_executor=agent_executor, task_store=task_store, + agent_card=self.agent_card, request_context_builder=request_context_builder, ) - # Create A2A FastAPI application - max_content_length = get_a2a_max_content_length() - a2a_app = A2AFastAPIApplication( - agent_card=self.agent_card, - http_handler=request_handler, - max_content_length=max_content_length, - ) + # Keep the configured max body size value available for route/middleware evolution. + _ = get_a2a_max_content_length() # Enable fault handler faulthandler.enable() @@ -240,7 +234,8 @@ def build_local(self) -> FastAPI: app.add_route("/thread_dump", methods=["GET"], route=thread_dump) # Add A2A routes - a2a_app.add_routes_to_app(app) + app.router.routes.extend(create_agent_card_routes(self.agent_card)) + app.router.routes.extend(create_jsonrpc_routes(request_handler, rpc_url="/")) return app diff --git a/python/packages/kagent-openai/src/kagent/openai/_agent_executor.py b/python/packages/kagent-openai/src/kagent/openai/_agent_executor.py index d1711fb23b..0c319af12c 100644 --- a/python/packages/kagent-openai/src/kagent/openai/_agent_executor.py +++ b/python/packages/kagent-openai/src/kagent/openai/_agent_executor.py @@ -11,14 +11,6 @@ import uuid from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime - -try: - from datetime import UTC # Python 3.11+ -except ImportError: - from datetime import timezone - - UTC = timezone.utc try: from typing import override # Python 3.12+ @@ -37,10 +29,10 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) from agents.agent import Agent from agents.run import Runner +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import TaskResultAggregator, get_kagent_metadata_key from pydantic import BaseModel @@ -50,6 +42,12 @@ logger = logging.getLogger(__name__) +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + class OpenAIAgentExecutorConfig(BaseModel): """Configuration for the OpenAIAgentExecutor.""" @@ -139,8 +137,8 @@ async def _stream_agent_events( if hasattr(result, "final_output") and result.final_output: final_message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=str(result.final_output)))], + role=Role.ROLE_AGENT, + parts=[Part(text=str(result.final_output))], ) # Publish final artifact @@ -161,17 +159,16 @@ async def _stream_agent_events( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_COMPLETED, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=True, ) ) else: # No output - publish based on aggregator state if ( - task_result_aggregator.task_state == TaskState.working + task_result_aggregator.task_state == TaskState.TASK_STATE_WORKING and task_result_aggregator.task_status_message is not None and task_result_aggregator.task_status_message.parts ): @@ -190,11 +187,10 @@ async def _stream_agent_events( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.completed, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_COMPLETED, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=True, ) ) else: @@ -203,11 +199,10 @@ async def _stream_agent_events( task_id=context.task_id, status=TaskStatus( state=task_result_aggregator.task_state, - timestamp=datetime.now(UTC).isoformat(), + timestamp=_now_timestamp(), message=task_result_aggregator.task_status_message, ), context_id=context.context_id, - final=True, ) ) @@ -237,12 +232,11 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.submitted, + state=TaskState.TASK_STATE_SUBMITTED, message=context.message, - timestamp=datetime.now(UTC).isoformat(), + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, ) ) @@ -255,11 +249,10 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.working, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_WORKING, + timestamp=_now_timestamp(), ), context_id=context.context_id, - final=False, metadata={ get_kagent_metadata_key("app_name"): self.app_name, get_kagent_metadata_key("session_id"): session_id, @@ -298,16 +291,15 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text="Execution timed out"))], + role=Role.ROLE_AGENT, + parts=[Part(text="Execution timed out")], ), ), context_id=context.context_id, - final=True, ) ) except Exception as e: @@ -319,12 +311,12 @@ async def execute( TaskStatusUpdateEvent( task_id=context.task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=datetime.now(UTC).isoformat(), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), message=Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[Part(TextPart(text=f"Execution failed: {error_message}"))], + role=Role.ROLE_AGENT, + parts=[Part(text=f"Execution failed: {error_message}")], metadata={ get_kagent_metadata_key("error_type"): type(e).__name__, get_kagent_metadata_key("error_detail"): error_message, @@ -332,7 +324,6 @@ async def execute( ), ), context_id=context.context_id, - final=True, metadata={ get_kagent_metadata_key("error_type"): type(e).__name__, get_kagent_metadata_key("error_detail"): error_message, diff --git a/python/packages/kagent-openai/src/kagent/openai/_event_converter.py b/python/packages/kagent-openai/src/kagent/openai/_event_converter.py index b1c92c7839..ec12c1bd60 100644 --- a/python/packages/kagent-openai/src/kagent/openai/_event_converter.py +++ b/python/packages/kagent-openai/src/kagent/openai/_event_converter.py @@ -8,26 +8,18 @@ import json import logging import uuid -from datetime import datetime - -try: - from datetime import UTC # Python 3.11+ -except ImportError: - from datetime import timezone - - UTC = timezone.utc from a2a.server.events import Event as A2AEvent from a2a.types import ( - DataPart, Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) -from a2a.types import Part as A2APart +from a2a.types import ( + Part as A2APart, +) from agents.items import MessageOutputItem, ToolCallItem, ToolCallOutputItem from agents.stream_events import ( AgentUpdatedStreamEvent, @@ -35,6 +27,9 @@ RunItemStreamEvent, StreamEvent, ) +from google.protobuf.json_format import ParseDict +from google.protobuf.struct_pb2 import Value +from google.protobuf.timestamp_pb2 import Timestamp from kagent.core.a2a import ( A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, @@ -45,6 +40,12 @@ logger = logging.getLogger(__name__) +def _now_timestamp() -> Timestamp: + ts = Timestamp() + ts.GetCurrentTime() + return ts + + def convert_openai_event_to_a2a_events( event: StreamEvent, task_id: str, @@ -160,8 +161,8 @@ def _convert_message_output( message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(TextPart(text=text_content))], + role=Role.ROLE_AGENT, + parts=[A2APart(text=text_content)], metadata={ get_kagent_metadata_key("app_name"): app_name, get_kagent_metadata_key("event_type"): "message_output", @@ -172,14 +173,13 @@ def _convert_message_output( task_id=task_id, context_id=context_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, - timestamp=datetime.now(UTC).isoformat(), + timestamp=_now_timestamp(), ), metadata={ get_kagent_metadata_key("app_name"): app_name, }, - final=False, ) return [status_event] @@ -228,17 +228,17 @@ def _convert_tool_call( "args": tool_arguments, } - data_part = DataPart( - data=function_data, - metadata={ - get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, - }, - ) - message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(data_part)], + role=Role.ROLE_AGENT, + parts=[ + A2APart( + data=ParseDict(function_data, Value()), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + }, + ) + ], metadata={ get_kagent_metadata_key("app_name"): app_name, get_kagent_metadata_key("event_type"): "tool_call", @@ -249,14 +249,13 @@ def _convert_tool_call( task_id=task_id, context_id=context_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, - timestamp=datetime.now(UTC).isoformat(), + timestamp=_now_timestamp(), ), metadata={ get_kagent_metadata_key("app_name"): app_name, }, - final=False, ) return [status_event] @@ -289,17 +288,19 @@ def _convert_tool_output( "response": {"result": actual_output}, } - data_part = DataPart( - data=function_data, - metadata={ - get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, - }, - ) - message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(data_part)], + role=Role.ROLE_AGENT, + parts=[ + A2APart( + data=ParseDict(function_data, Value()), + metadata={ + get_kagent_metadata_key( + A2A_DATA_PART_METADATA_TYPE_KEY + ): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE, + }, + ) + ], metadata={ get_kagent_metadata_key("app_name"): app_name, get_kagent_metadata_key("event_type"): "tool_output", @@ -310,14 +311,13 @@ def _convert_tool_output( task_id=task_id, context_id=context_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, - timestamp=datetime.now(UTC).isoformat(), + timestamp=_now_timestamp(), ), metadata={ get_kagent_metadata_key("app_name"): app_name, }, - final=False, ) return [status_event] @@ -346,17 +346,17 @@ def _convert_agent_updated_event( "args": {"target_agent": agent_name}, } - data_part = DataPart( - data=function_data, - metadata={ - get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, - }, - ) - message = Message( message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[A2APart(data_part)], + role=Role.ROLE_AGENT, + parts=[ + A2APart( + data=ParseDict(function_data, Value()), + metadata={ + get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + }, + ) + ], metadata={ get_kagent_metadata_key("app_name"): app_name, get_kagent_metadata_key("event_type"): "agent_handoff", @@ -368,14 +368,13 @@ def _convert_agent_updated_event( task_id=task_id, context_id=context_id, status=TaskStatus( - state=TaskState.working, + state=TaskState.TASK_STATE_WORKING, message=message, - timestamp=datetime.now(UTC).isoformat(), + timestamp=_now_timestamp(), ), metadata={ get_kagent_metadata_key("app_name"): app_name, }, - final=False, ) return [status_event] diff --git a/python/packages/kagent-openai/src/kagent/openai/_session_service.py b/python/packages/kagent-openai/src/kagent/openai/_session_service.py index 54a3b5898d..7fee98d8d4 100644 --- a/python/packages/kagent-openai/src/kagent/openai/_session_service.py +++ b/python/packages/kagent-openai/src/kagent/openai/_session_service.py @@ -6,15 +6,24 @@ from __future__ import annotations +import json import logging +import uuid import httpx from agents.items import TResponseInputItem from agents.memory.session import SessionABC +from google.protobuf.timestamp_pb2 import Timestamp logger = logging.getLogger(__name__) +def _get_timestamp_string() -> str: + ts = Timestamp() + ts.GetCurrentTime() + return ts.isoformat() + + class KAgentSession(SessionABC): """A session implementation that uses the KAgent API. @@ -161,22 +170,12 @@ async def add_items(self, items: list[TResponseInputItem]) -> None: await self._ensure_session_exists() # Store items as an event in the session - import json - import uuid - from datetime import datetime - - try: - from datetime import UTC # Python 3.11+ - except ImportError: - from datetime import timezone - - UTC = timezone.utc event_data = { "id": str(uuid.uuid4()), "data": json.dumps( { - "timestamp": datetime.now(UTC).isoformat(), + "timestamp": _get_timestamp_string(), "items": items, "type": "conversation_items", } diff --git a/python/samples/adk/basic/basic/agent-card.json b/python/samples/adk/basic/basic/agent-card.json index 58f1b0920e..aa3ae8d3c5 100644 --- a/python/samples/adk/basic/basic/agent-card.json +++ b/python/samples/adk/basic/basic/agent-card.json @@ -1,7 +1,6 @@ { "name": "basic", "description": "A basic agent", - "url": "localhost:8080", "version": "0.0.1", "capabilities": { "streaming": true diff --git a/python/samples/crewai/poem_flow/src/poem_flow/agent-card.json b/python/samples/crewai/poem_flow/src/poem_flow/agent-card.json index 4834723ff9..0db48dd2b7 100644 --- a/python/samples/crewai/poem_flow/src/poem_flow/agent-card.json +++ b/python/samples/crewai/poem_flow/src/poem_flow/agent-card.json @@ -1,7 +1,6 @@ { "name": "poem-flow-agent", "description": "A flow that generates a poem of a random number of lines.", - "url": "localhost:8080", "version": "0.1.0", "capabilities": { "streaming": true diff --git a/python/samples/crewai/research-crew/src/research_crew/agent-card.json b/python/samples/crewai/research-crew/src/research_crew/agent-card.json index cd148f9727..f89bbe6af7 100644 --- a/python/samples/crewai/research-crew/src/research_crew/agent-card.json +++ b/python/samples/crewai/research-crew/src/research_crew/agent-card.json @@ -1,7 +1,6 @@ { "name": "research-crew", "description": "A research CrewAI agent that can conduct research and analysis", - "url": "localhost:8080", "version": "0.1.0", "capabilities": { "streaming": true diff --git a/python/samples/langgraph/currency/currency/agent-card.json b/python/samples/langgraph/currency/currency/agent-card.json index bc82b5a2f5..da96766482 100644 --- a/python/samples/langgraph/currency/currency/agent-card.json +++ b/python/samples/langgraph/currency/currency/agent-card.json @@ -1,7 +1,6 @@ { "name": "currency-converter", "description": "A currency converter LangGraph agent that can convert currencies", - "url": "localhost:8080", "version": "0.1.0", "capabilities": { "streaming": true diff --git a/python/samples/langgraph/hitl-tools/hitl_tools/agent-card.json b/python/samples/langgraph/hitl-tools/hitl_tools/agent-card.json index e9916d664e..a6319b3373 100644 --- a/python/samples/langgraph/hitl-tools/hitl_tools/agent-card.json +++ b/python/samples/langgraph/hitl-tools/hitl_tools/agent-card.json @@ -1,7 +1,6 @@ { "name": "hitl-tools-agent", "description": "A LangGraph agent demonstrating HITL tool approval. Has a safe tool (get_time) and a dangerous tool (delete_file) that requires human approval.", - "url": "localhost:8080", "version": "0.1.0", "capabilities": { "streaming": true diff --git a/python/samples/langgraph/kebab/kebab/agent-card.json b/python/samples/langgraph/kebab/kebab/agent-card.json index d3b25ad736..de9e40a883 100644 --- a/python/samples/langgraph/kebab/kebab/agent-card.json +++ b/python/samples/langgraph/kebab/kebab/agent-card.json @@ -1,7 +1,6 @@ { "name": "langgraph-kebab", "description": "Minimal LangGraph kebab sample", - "url": "localhost:8080", "version": "0.1.0", "capabilities": { "streaming": true diff --git a/python/samples/openai/basic_agent/basic_agent/agent-card.json b/python/samples/openai/basic_agent/basic_agent/agent-card.json index da1c46f489..d3ce67798c 100644 --- a/python/samples/openai/basic_agent/basic_agent/agent-card.json +++ b/python/samples/openai/basic_agent/basic_agent/agent-card.json @@ -1,7 +1,6 @@ { "name": "basic-openai-agent", "description": "A basic OpenAI agent with calculator and weather tools", - "url": "localhost:8000", "version": "0.1.0", "capabilities": { "streaming": true diff --git a/python/samples/openai/basic_agent/basic_agent/agent.py b/python/samples/openai/basic_agent/basic_agent/agent.py index 3148af3e53..07f006bc44 100644 --- a/python/samples/openai/basic_agent/basic_agent/agent.py +++ b/python/samples/openai/basic_agent/basic_agent/agent.py @@ -13,6 +13,7 @@ from a2a.types import AgentCard from agents.agent import Agent from agents.tool import function_tool +from google.protobuf.json_format import ParseDict from kagent.core import KAgentConfig from kagent.openai import KAgentApp @@ -77,22 +78,25 @@ def get_weather(location: str) -> str: # Agent card for A2A protocol -agent_card = AgentCard( - name="basic-openai-agent", - description="A basic OpenAI agent with calculator and weather tools", - url="localhost:8000", - version="0.1.0", - capabilities={"streaming": True}, - defaultInputModes=["text"], - defaultOutputModes=["text"], - skills=[ - { - "id": "basic", - "name": "Basic Assistant", - "description": "Can perform calculations and get weather information", - "tags": ["calculator", "weather", "assistant"], - } - ], +agent_card = ParseDict( + { + "name": "basic-openai-agent", + "description": "A basic OpenAI agent with calculator and weather tools", + "version": "0.1.0", + "supportedInterfaces": [{"url": "http://localhost:8080", "protocolBinding": "JSONRPC"}], + "capabilities": {"streaming": True}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "id": "basic", + "name": "Basic Assistant", + "description": "Can perform calculations and get weather information", + "tags": ["calculator", "weather", "assistant"], + } + ], + }, + AgentCard(), ) config = KAgentConfig() diff --git a/python/uv.lock b/python/uv.lock index b53ec683fa..f360c42b5b 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -48,23 +48,26 @@ dev = [ [[package]] name = "a2a-sdk" -version = "0.3.23" +version = "1.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, { name = "google-api-core" }, + { name = "googleapis-common-protos" }, { name = "httpx" }, { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, { name = "protobuf" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/2fe24e0a85240a651006c12f79bdb37156adc760a96c44bc002ebda77916/a2a_sdk-0.3.23.tar.gz", hash = "sha256:7c46b8572c4633a2b41fced2833e11e62871e8539a5b3c782ba2ba1e33d213c2", size = 255265, upload-time = "2026-02-17T08:34:34.648Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/35/8b7ac94f405f57c591925fa0afc105a0f797151876fffa666b57722eefa9/a2a_sdk-1.0.3.tar.gz", hash = "sha256:c57ddd910aece4a426ae26b8f0d0e8e2f3271a6adde974078075e4f600aaf628", size = 367155, upload-time = "2026-05-13T06:52:33.929Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/20/77d119f19ab03449d3e6bc0b1f11296d593dae99775c1d891ab1e290e416/a2a_sdk-0.3.23-py3-none-any.whl", hash = "sha256:8c2f01dffbfdd3509eafc15c4684743e6ae75e69a5df5d6f87be214c948e7530", size = 145689, upload-time = "2026-02-17T08:34:33.263Z" }, + { url = "https://files.pythonhosted.org/packages/53/6f/ae79f8210f1ecd70e1c37c310a523b26f1d6da458d4c1365914bf1ea58e0/a2a_sdk-1.0.3-py3-none-any.whl", hash = "sha256:068e5b2ceb4e962ac61d9e1fd43ca0c1016b64f0c80d901f6e23420bc8a31a93", size = 235705, upload-time = "2026-05-13T06:52:31.88Z" }, ] [package.optional-dependencies] http-server = [ - { name = "fastapi" }, { name = "sse-starlette" }, { name = "starlette" }, ] @@ -247,6 +250,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, ] +[[package]] +name = "aiologic" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6", size = 135193, upload-time = "2025-11-27T23:48:40.099Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -920,6 +937,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + [[package]] name = "currency" version = "0.1.0" @@ -2218,6 +2248,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/43/ac6691c7b5aa7191c964a04ae926d2bb06d9297dba1f2287df5b85cb3715/json_repair-0.25.2-py3-none-any.whl", hash = "sha256:51d67295c3184b6c41a3572689661c6128cef6cfc9fb04db63130709adfc5bf0", size = 12740, upload-time = "2024-06-27T16:26:13.823Z" }, ] +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, +] + [[package]] name = "json5" version = "0.12.1" @@ -2331,7 +2370,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.23" }, + { name = "a2a-sdk", specifier = ">=1.0.0" }, { name = "agentsts-adk", editable = "packages/agentsts-adk" }, { name = "agentsts-core", editable = "packages/agentsts-core" }, { name = "aiofiles", specifier = ">=24.1.0" }, @@ -2385,7 +2424,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3.23" }, + { name = "a2a-sdk", extras = ["http-server"], specifier = ">=1.0.0" }, { name = "opentelemetry-api", specifier = ">=1.38.0,<1.39.0" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.38.0,<1.39.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.38.0,<1.39.0" }, @@ -2425,7 +2464,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3.23" }, + { name = "a2a-sdk", extras = ["http-server"], specifier = ">=1.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "crewai", extras = ["tools"], specifier = ">=1.2.0" }, { name = "fastapi", specifier = ">=0.100.0" }, @@ -2469,7 +2508,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.23" }, + { name = "a2a-sdk", specifier = ">=1.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "fastapi", specifier = ">=0.100.0" }, { name = "httpx", specifier = ">=0.25.0" }, @@ -2514,7 +2553,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.23" }, + { name = "a2a-sdk", specifier = ">=1.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "fastapi", specifier = ">=0.100.0" }, { name = "httpx", specifier = ">=0.25.0" }, diff --git a/ui/jest.config.ts b/ui/jest.config.ts index 9f47bc29c0..903ad1d66b 100644 --- a/ui/jest.config.ts +++ b/ui/jest.config.ts @@ -25,7 +25,7 @@ const config: Config = { ], // Transform ESM modules that Jest can't handle by default transformIgnorePatterns: [ - '/node_modules/(?!(uuid|@a2a-js)/)', + '/node_modules/(?!(uuid|@a2a-js|jose)/)', ], }; diff --git a/ui/jest.setup.ts b/ui/jest.setup.ts index be96e4bc92..72540a73ff 100644 --- a/ui/jest.setup.ts +++ b/ui/jest.setup.ts @@ -6,6 +6,11 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => 'test-uuid-v4'), })); +// @a2a-js/sdk's CJS bundle requires `jose` at module-load time. +// In Jest (CJS) this can trip on jose's ESM-only entrypoint, so we provide +// a minimal mock since UI tests do not exercise agent-card signature codepaths. +jest.mock('jose', () => ({})); + // Polyfill TextEncoder/TextDecoder for Node.js test environment global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder as typeof global.TextDecoder; diff --git a/ui/package-lock.json b/ui/package-lock.json index 5b60f6076d..8b3a2b487d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,9 +8,8 @@ "name": "kagents-ui", "version": "0.1.0", "dependencies": { - "@a2a-js/sdk": "^0.3.13", + "@a2a-js/sdk": "^1.0.0-alpha.0", "@hookform/resolvers": "^5.4.0", - "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -94,15 +93,16 @@ } }, "node_modules/@a2a-js/sdk": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.13.tgz", - "integrity": "sha512-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A==", + "version": "1.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-1.0.0-alpha.0.tgz", + "integrity": "sha512-2IeAMlgO4Xu1QbmNUvwyZe5F/vMzA7tiEt/1nLvCHptVpC6L7bIBUVL71fIdNi9TIGjLVz0ctVYgx8UkzpxeZA==", "license": "Apache-2.0", "dependencies": { + "jose": "^6.2.3", "uuid": "^11.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", diff --git a/ui/package.json b/ui/package.json index e3db0289b2..df79680214 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,7 +17,7 @@ "chromatic": "chromatic --exit-zero-on-changes --storybook-build-dir storybook-static --project-token chpt_3e29f54d624610f" }, "dependencies": { - "@a2a-js/sdk": "^0.3.13", + "@a2a-js/sdk": "^1.0.0-alpha.0", "@hookform/resolvers": "^5.4.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts b/ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts index 238ca77823..09f0cfacae 100644 --- a/ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts +++ b/ui/src/app/a2a-sandboxes/[namespace]/[agentName]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getBackendUrl } from '@/lib/utils'; import { getAuthHeadersFromRequest, CORS_ALLOW_HEADERS } from '@/lib/auth'; +import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER } from '@a2a-js/sdk'; export async function POST( request: NextRequest, @@ -25,6 +26,7 @@ export async function POST( 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'User-Agent': 'kagent-ui', + [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION, }, body: JSON.stringify(a2aRequest), }); diff --git a/ui/src/app/a2a/[namespace]/[agentName]/route.ts b/ui/src/app/a2a/[namespace]/[agentName]/route.ts index 8f5eb9cc9b..7e92b4292a 100644 --- a/ui/src/app/a2a/[namespace]/[agentName]/route.ts +++ b/ui/src/app/a2a/[namespace]/[agentName]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getBackendUrl } from '@/lib/utils'; import { getAuthHeadersFromRequest, CORS_ALLOW_HEADERS } from '@/lib/auth'; +import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER } from '@a2a-js/sdk'; export async function POST( request: NextRequest, @@ -26,6 +27,7 @@ export async function POST( 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'User-Agent': 'kagent-ui', + [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION, }, body: JSON.stringify(a2aRequest), }); diff --git a/ui/src/app/actions/servers.ts b/ui/src/app/actions/servers.ts index 7441187b0c..99183b09fb 100644 --- a/ui/src/app/actions/servers.ts +++ b/ui/src/app/actions/servers.ts @@ -18,7 +18,7 @@ export async function getServers(): Promise> return { message: "MCP servers fetched successfully", - data: response.data, + data: response.data ?? [], }; } catch (error) { return createErrorResponse(error, "Error getting MCP servers"); diff --git a/ui/src/app/actions/sessions.ts b/ui/src/app/actions/sessions.ts index 3f987076f8..cadceb717a 100644 --- a/ui/src/app/actions/sessions.ts +++ b/ui/src/app/actions/sessions.ts @@ -4,7 +4,7 @@ import { BaseResponse, CreateSessionRequest } from "@/types"; import { Session } from "@/types"; import { revalidatePath } from "next/cache"; import { fetchApi, createErrorResponse } from "./utils"; -import { Task } from "@a2a-js/sdk"; +import type { Task } from "@a2a-js/sdk"; /** * Deletes a session diff --git a/ui/src/app/actions/utils.ts b/ui/src/app/actions/utils.ts index 200a2007bd..c4ce9d2d23 100644 --- a/ui/src/app/actions/utils.ts +++ b/ui/src/app/actions/utils.ts @@ -28,6 +28,7 @@ export async function fetchApi(path: string, options: ApiOptions = {}): Promi ...authHeaders, "Content-Type": "application/json", Accept: "application/json", + "A2A-Version": "1.0", ...options.headers, }, signal: AbortSignal.timeout(30000), // 30 second timeout diff --git a/ui/src/components/chat/AgentCallDisplay.tsx b/ui/src/components/chat/AgentCallDisplay.tsx index 0c8dcd1bee..8d1bd4d19b 100644 --- a/ui/src/components/chat/AgentCallDisplay.tsx +++ b/ui/src/components/chat/AgentCallDisplay.tsx @@ -6,7 +6,7 @@ import { ChevronDown, ChevronUp, MessageSquare, Loader2, AlertCircle, CheckCircl import KagentLogo from "../kagent-logo"; import TokenStatsTooltip from "@/components/chat/TokenStatsTooltip"; import { getSubagentSessionWithEvents } from "@/app/actions/sessions"; -import { Message, Task } from "@a2a-js/sdk"; +import type { Message, Task } from "@a2a-js/sdk"; import { extractMessagesFromTasks } from "@/lib/messageHandlers"; import ChatMessage from "@/components/chat/ChatMessage"; diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index 778c501632..18ac3803e8 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -27,10 +27,14 @@ import { kagentA2AClient } from "@/lib/a2aClient"; import { useChatRunInSandbox } from "@/components/chat/ChatAgentContext"; import { v4 as uuidv4 } from "uuid"; import { getStatusPlaceholder, mapA2AStateToStatus } from "@/lib/statusUtils"; -import { Message, DataPart, Task, TaskState } from "@a2a-js/sdk"; +import { Role, TaskState, taskStateFromJSON } from "@a2a-js/sdk"; +import type { Message, Task, StreamResponse } from "@a2a-js/sdk"; // Task states where the agent is actively processing — resubscribe to live stream. -const RESUBSCRIBE_TASK_STATES: TaskState[] = ["submitted", "working"]; +const RESUBSCRIBE_TASK_STATES: TaskState[] = [ + TaskState.TASK_STATE_SUBMITTED, + TaskState.TASK_STATE_WORKING, +]; interface ChatInterfaceProps { selectedAgentName: string; @@ -165,7 +169,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se // Check for a task still actively running (not input-required, not terminal). // input-required is excluded: it needs the approval UI, not a stream. activeTask = messagesResponse.data.findLast( - task => RESUBSCRIBE_TASK_STATES.includes(task.status?.state as TaskState) + task => RESUBSCRIBE_TASK_STATES.includes(taskStateFromJSON(task.status?.state)) ); } } @@ -180,7 +184,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se setIsLoading(false); if (activeTask) { - setChatStatus(mapA2AStateToStatus(activeTask.status?.state as TaskState)); + setChatStatus(mapA2AStateToStatus(activeTask.status?.state)); await streamResubscribedTask(activeTask.id); } } @@ -224,18 +228,9 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se pendingTurnStatsRef.current = undefined; // For new sessions or when no stored messages exist, show the user message immediately - const userMessage: Message = { - kind: "message", - messageId: uuidv4(), - role: "user", - parts: [{ - kind: "text", - text: userMessageText - }], - metadata: { - timestamp: Date.now() - } - }; + const userMessage: Message = createMessage(userMessageText, "user", { + additionalMetadata: { timestamp: Date.now() }, + }); // Add user message to streaming messages to show immediately // (will be replaced by server response that includes the user message) @@ -333,7 +328,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se for await (const event of stream) { startTimeout(); try { - handleMessageEvent(event as Message); + handleMessageEvent(event as StreamResponse); } catch (err) { console.error("Error handling stream event:", err); } @@ -549,18 +544,29 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const messageId = uuidv4(); const a2aMessage: Message = { - kind: "message", messageId, - role: "user", + role: Role.ROLE_USER, parts: [ - { kind: "data", data: decisionData, metadata: {} } as DataPart, - { kind: "text", text: displayText }, + { + content: { $case: "data", value: decisionData }, + metadata: {}, + filename: "", + mediaType: "application/json", + }, + { + content: { $case: "text", value: displayText }, + metadata: {}, + filename: "", + mediaType: "text/plain", + }, ], - contextId: currentSessionId, - taskId: approvalTaskId, + contextId: currentSessionId ?? "", + taskId: approvalTaskId ?? "", metadata: { timestamp: Date.now(), }, + extensions: [], + referenceTaskIds: [], }; await streamA2AMessage(a2aMessage, { @@ -686,20 +692,27 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const messageId = uuidv4(); const a2aMessage: Message = { - kind: "message", messageId, - role: "user", + role: Role.ROLE_USER, parts: [ { - kind: "data", - data: { decision_type: "approve", ask_user_answers: answers }, + content: { $case: "data", value: { decision_type: "approve", ask_user_answers: answers } }, + metadata: {}, + filename: "", + mediaType: "application/json", + }, + { + content: { $case: "text", value: "Answered questions" }, metadata: {}, - } as DataPart, - { kind: "text", text: "Answered questions" }, + filename: "", + mediaType: "text/plain", + }, ], - contextId: currentSessionId, - taskId: askUserTaskId, + contextId: currentSessionId ?? "", + taskId: askUserTaskId ?? "", metadata: { timestamp: Date.now() }, + extensions: [], + referenceTaskIds: [], }; streamA2AMessage(a2aMessage, { diff --git a/ui/src/components/chat/ChatMessage.stories.tsx b/ui/src/components/chat/ChatMessage.stories.tsx index 11a805ad9c..456c717e8f 100644 --- a/ui/src/components/chat/ChatMessage.stories.tsx +++ b/ui/src/components/chat/ChatMessage.stories.tsx @@ -21,20 +21,31 @@ const meta = { export default meta; type Story = StoryObj; -const createMessage = (overrides: Partial = {}): Message => ({ - kind: "message", +const makeTextPart = (text: string) => ({ + content: { $case: "text" as const, value: text }, + metadata: {}, + filename: "", + mediaType: "text/plain", +}); + +const createMessage = (overrides: Record = {}): Message => ({ messageId: "msg-123", - role: "agent", - parts: [{ kind: "text", text: "Default message content" }], + role: 2, + parts: [makeTextPart("Default message content")], + contextId: "", + taskId: "", + metadata: {}, + extensions: [], + referenceTaskIds: [], ...overrides, -}); +} as Message); export const UserMessage: Story = { args: { message: createMessage({ - role: "user", + role: 1, messageId: "user-msg-1", - parts: [{ kind: "text", text: "Hello, can you help me with this?" }], + parts: [makeTextPart("Hello, can you help me with this?")], }), allMessages: [], }, @@ -43,8 +54,8 @@ export const UserMessage: Story = { export const AgentMessage: Story = { args: { message: createMessage({ - role: "agent", - parts: [{ kind: "text", text: "Of course! I'd be happy to help you with that." }], + role: 2, + parts: [makeTextPart("Of course! I'd be happy to help you with that.")], }), allMessages: [], }, @@ -53,8 +64,8 @@ export const AgentMessage: Story = { export const AgentMessageWithTimestamp: Story = { args: { message: createMessage({ - role: "agent", - parts: [{ kind: "text", text: "Here's the response to your question." }], + role: 2, + parts: [makeTextPart("Here's the response to your question.")], metadata: { displaySource: "assistant", timestamp: Date.now(), @@ -67,18 +78,16 @@ export const AgentMessageWithTimestamp: Story = { export const MessageWithLongContent: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { - kind: "text", - text: `This is a much longer response that contains multiple paragraphs of information. + makeTextPart(`This is a much longer response that contains multiple paragraphs of information. The first paragraph explains the main concept. The second paragraph provides additional details and examples. The third paragraph concludes with a summary of the key points.`, - }, + ), ], }), allMessages: [], @@ -88,11 +97,9 @@ The third paragraph concludes with a summary of the key points.`, export const MessageWithMarkdown: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { - kind: "text", - text: `# Response Title + makeTextPart(`# Response Title Here's a **bold** statement and an *italic* one. @@ -106,7 +113,7 @@ const example = () => { return "code block"; }; \`\`\``, - }, + ), ], }), allMessages: [], @@ -116,11 +123,9 @@ const example = () => { export const MessageWithCodeBlocks: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { - kind: "text", - text: `Here's how to implement this feature: + makeTextPart(`Here's how to implement this feature: \`\`\`python def calculate_sum(numbers): @@ -140,7 +145,7 @@ const calculateSum = (numbers) => { const result = calculateSum([1, 2, 3, 4, 5]); console.log(result); \`\`\``, - }, + ), ], }), allMessages: [], @@ -150,8 +155,8 @@ console.log(result); export const MessageWithCustomDisplaySource: Story = { args: { message: createMessage({ - role: "agent", - parts: [{ kind: "text", text: "Response from custom agent" }], + role: 2, + parts: [makeTextPart("Response from custom agent")], metadata: { displaySource: "DataAnalyzer", }, @@ -163,8 +168,8 @@ export const MessageWithCustomDisplaySource: Story = { export const MessageWithAgentContext: Story = { args: { message: createMessage({ - role: "agent", - parts: [{ kind: "text", text: "Response from context agent" }], + role: 2, + parts: [makeTextPart("Response from context agent")], }), allMessages: [], agentContext: { @@ -177,9 +182,9 @@ export const MessageWithAgentContext: Story = { export const ShortUserMessage: Story = { args: { message: createMessage({ - role: "user", + role: 1, messageId: "user-msg-2", - parts: [{ kind: "text", text: "OK" }], + parts: [makeTextPart("OK")], }), allMessages: [], }, @@ -188,11 +193,9 @@ export const ShortUserMessage: Story = { export const AgentMessageWithTable: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { - kind: "text", - text: `Here's the data in table format: + makeTextPart(`Here's the data in table format: | Name | Score | Status | |------|-------|--------| @@ -200,7 +203,7 @@ export const AgentMessageWithTable: Story = { | Bob | 87 | Pass | | Charlie | 72 | Pass | | Diana | 65 | Fail |`, - }, + ), ], }), allMessages: [], @@ -210,10 +213,10 @@ export const AgentMessageWithTable: Story = { export const MessageWithMultipleParts: Story = { args: { message: createMessage({ - role: "agent", + role: 2, parts: [ - { kind: "text", text: "First part of the message." }, - { kind: "text", text: "Second part of the message." }, + makeTextPart("First part of the message."), + makeTextPart("Second part of the message."), ], }), allMessages: [], diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx index cf71a65b6c..62469858ba 100644 --- a/ui/src/components/chat/ChatMessage.tsx +++ b/ui/src/components/chat/ChatMessage.tsx @@ -1,4 +1,5 @@ -import { Message, TextPart } from "@a2a-js/sdk"; +import type { Message } from "@a2a-js/sdk"; +import { Role } from "@a2a-js/sdk"; import { TruncatableText } from "@/components/chat/TruncatableText"; import ToolCallDisplay from "@/components/chat/ToolCallDisplay"; import AskUserDisplay, { AskUserQuestion } from "@/components/chat/AskUserDisplay"; @@ -32,10 +33,10 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr if (!message) return null; - const textParts = message.parts?.filter(part => part.kind === "text") || []; - const content = textParts.map(part => (part as TextPart).text).join(""); + const textParts = message.parts?.filter(part => part.content?.$case === "text") || []; + const content = textParts.map(part => part.content?.$case === "text" ? part.content.value : "").join(""); - const source = message.role === "user" ? "user" : "assistant"; + const source = message.role === Role.ROLE_USER ? "user" : "assistant"; const tokenStats = (message.metadata as Record | undefined)?.tokenStats as TokenStats | undefined; const messageId = message.messageId; @@ -78,7 +79,7 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr // Check for tool call parts (works for both stored and streaming messages) const hasToolCallParts = message.parts?.some(part => { - if (part.kind === "data" && part.metadata) { + if (part.content?.$case === "data" && part.metadata) { const partType = getMetadataValue(part.metadata as Record, "type"); return partType === "function_call" || partType === "function_response"; } @@ -130,7 +131,7 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr if (originalType === "ToolCallSummaryMessage") { const hasToolCalls = allMessages.some(msg => { return msg.parts?.some(part => { - if (part.kind === "data" && part.metadata) { + if (part.content?.$case === "data" && part.metadata) { const partType = getMetadataValue(part.metadata as Record, "type"); return partType === "function_call" || partType === "function_response"; } diff --git a/ui/src/components/chat/ToolCallDisplay.tsx b/ui/src/components/chat/ToolCallDisplay.tsx index 9ebaec3e7e..7b8819f086 100644 --- a/ui/src/components/chat/ToolCallDisplay.tsx +++ b/ui/src/components/chat/ToolCallDisplay.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { Message, TextPart } from "@a2a-js/sdk"; +import type { Message } from "@a2a-js/sdk"; import ToolDisplay, { ToolCallStatus } from "@/components/ToolDisplay"; import AgentCallDisplay, { AgentCallStatus } from "@/components/chat/AgentCallDisplay"; import { isAgentToolName } from "@/lib/utils"; @@ -29,7 +29,7 @@ interface ToolCallState { const isToolCallRequestMessage = (message: Message): boolean => { // Check data parts for type metadata first const hasDataParts = message.parts?.some(part => { - if (part.kind === "data" && part.metadata) { + if (part.content?.$case === "data" && part.metadata) { return getMetadataValue(part.metadata as Record, "type") === "function_call"; } return false; @@ -46,7 +46,7 @@ const isToolCallRequestMessage = (message: Message): boolean => { const isToolCallExecutionMessage = (message: Message): boolean => { const hasDataParts = message.parts?.some(part => { - if (part.kind === "data" && part.metadata) { + if (part.content?.$case === "data" && part.metadata) { return getMetadataValue(part.metadata as Record, "type") === "function_response"; } return false; @@ -70,13 +70,14 @@ const extractToolCallRequests = (message: Message): FunctionCall[] => { if (!isToolCallRequestMessage(message)) return []; // Check for stored task format first (data parts) - const dataParts = message.parts?.filter(part => part.kind === "data") || []; + const dataParts = message.parts?.filter(part => part.content?.$case === "data") || []; const functionCalls: FunctionCall[] = []; for (const part of dataParts) { if (part.metadata) { if (getMetadataValue(part.metadata as Record, "type") === "function_call") { - const data = part.data as unknown as FunctionCall; + const data = part.content?.$case === "data" ? part.content.value as unknown as FunctionCall : undefined; + if (!data) continue; // Skip ADK internal function calls (confirmation/auth) and ask_user (has its own display) if ( data.name === "adk_request_confirmation" || @@ -100,8 +101,8 @@ const extractToolCallRequests = (message: Message): FunctionCall[] => { } // Try streaming format (metadata or text content) - const textParts = message.parts?.filter(part => part.kind === "text") || []; - const content = textParts.map(part => (part as TextPart).text).join(""); + const textParts = message.parts?.filter(part => part.content?.$case === "text") || []; + const content = textParts.map(part => part.content?.$case === "text" ? part.content.value : "").join(""); try { // Tool call data might be stored as JSON in content or metadata @@ -123,13 +124,14 @@ const extractToolCallResults = (message: Message): ProcessedToolResultData[] => if (!isToolCallExecutionMessage(message)) return []; // Check for stored task format first (data parts) - const dataParts = message.parts?.filter(part => part.kind === "data") || []; + const dataParts = message.parts?.filter(part => part.content?.$case === "data") || []; const toolResults: ProcessedToolResultData[] = []; for (const part of dataParts) { if (part.metadata) { if (getMetadataValue(part.metadata as Record, "type") === "function_response") { - const data = part.data as unknown as ToolResponseData; + const data = part.content?.$case === "data" ? part.content.value as unknown as ToolResponseData : undefined; + if (!data) continue; // For agent tool responses we receive { result, subagent_session_id } as FunctionResponse.response. const textContent = normalizeToolResultToText(data); @@ -158,9 +160,9 @@ const extractToolCallResults = (message: Message): ProcessedToolResultData[] => } // Try streaming format (metadata or text content) - const textParts = message.parts?.filter(part => part.kind === "text") || []; + const textParts = message.parts?.filter(part => part.content?.$case === "text") || []; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const content = textParts.map(part => (part as any).text).join(""); + const content = textParts.map(part => part.content?.$case === "text" ? part.content.value : "").join(""); try { const metadata = message.metadata as ADKMetadata; @@ -257,9 +259,9 @@ const ToolCallDisplay = ({ currentMessage, allMessages, onApprove, onReject, pen let subagentSessionId: string | undefined = matchingCallData?.subagent_session_id; if (!subagentSessionId && isAgentToolName(request.name)) { const fcDataPart = message.parts?.find(p => - p.kind === "data" && p.metadata && + p.content?.$case === "data" && p.metadata && getMetadataValue(p.metadata as Record, "type") === "function_call" && - (p.data as Record)?.id === request.id + (p.content.value as Record)?.id === request.id ); subagentSessionId = fcDataPart?.metadata ? getMetadataValue(fcDataPart.metadata as Record, "subagent_session_id") diff --git a/ui/src/lib/__tests__/auth.test.ts b/ui/src/lib/__tests__/auth.test.ts index 1f41e456fe..59333ec887 100644 --- a/ui/src/lib/__tests__/auth.test.ts +++ b/ui/src/lib/__tests__/auth.test.ts @@ -178,6 +178,6 @@ describe('getAuthHeadersFromRequest (env-driven)', () => { describe('CORS_ALLOW_HEADERS', () => { it('does not advertise additional forwarded headers cross-origin', () => { - expect(CORS_ALLOW_HEADERS).toBe('Content-Type, Authorization, Accept'); + expect(CORS_ALLOW_HEADERS).toBe('Content-Type, Authorization, Accept, A2A-Version'); }); }); diff --git a/ui/src/lib/__tests__/messageHandlers.test.ts b/ui/src/lib/__tests__/messageHandlers.test.ts index de95321abd..1ff108a07e 100644 --- a/ui/src/lib/__tests__/messageHandlers.test.ts +++ b/ui/src/lib/__tests__/messageHandlers.test.ts @@ -1,362 +1,129 @@ -import { describe, test, expect } from '@jest/globals'; -import { v4 as uuidv4 } from 'uuid'; -import { Message, Task } from '@a2a-js/sdk'; +import { describe, test, expect } from "@jest/globals"; +import type { Message, StreamResponse, Task } from "@a2a-js/sdk"; import { + createMessage, + createMessageHandlers, extractMessagesFromTasks, extractTokenStatsFromTasks, - createMessage, - normalizeToolResultToText, getMetadataValue, + normalizeToolResultToText, type ToolResponseData, - type ADKMetadata, - createMessageHandlers, -} from '@/lib/messageHandlers'; -import type { TokenStats } from '@/types'; - -describe('messageHandlers helpers', () => { - test('normalizeToolResultToText handles string result', () => { - const data: ToolResponseData = { id: '1', name: 'tool', response: { result: 'hello' } }; - expect(normalizeToolResultToText(data)).toBe('hello'); - }); - - test('normalizeToolResultToText handles content array', () => { - const data: ToolResponseData = { id: '1', name: 'tool', response: { result: { content: [{ text: 'a' }, { text: 'b' }] } } } as any; - expect(normalizeToolResultToText(data)).toBe('ab'); - }); - - test('normalizeToolResultToText handles object fallback', () => { - const data: ToolResponseData = { id: '1', name: 'tool', response: { result: { foo: 'bar' } } } as any; - expect(normalizeToolResultToText(data)).toContain('foo'); - }); - - test('createMessage builds a message with metadata', () => { - const msg = createMessage('hi', 'assistant', { originalType: 'TextMessage', contextId: 'ctx', taskId: 'task' }); - expect(msg.kind).toBe('message'); - expect(msg.parts[0]).toEqual({ kind: 'text', text: 'hi' }); - expect((msg.metadata as any).originalType).toBe('TextMessage'); - expect(msg.contextId).toBe('ctx'); - expect(msg.taskId).toBe('task'); - }); - - test('extractMessagesFromTasks deduplicates messageIds', () => { - const mId = uuidv4(); - const tasks: any = [ - { history: [{ kind: 'message', messageId: mId }, { kind: 'message', messageId: mId }] }, - ]; - const out = extractMessagesFromTasks(tasks); - expect(out.length).toBe(1); - expect(out[0].messageId).toBe(mId); - }); - - test('extractMessagesFromTasks injects tokenStats into non-user agent messages only', () => { - const tasks = [ - { - history: [ - { kind: 'message', messageId: 'a1', role: 'agent', parts: [], - metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 3, candidatesTokenCount: 7 } } }, - { kind: 'message', messageId: 'u1', role: 'user', parts: [], - metadata: { kagent_usage_metadata: { totalTokenCount: 5, promptTokenCount: 2, candidatesTokenCount: 3 } } }, - { kind: 'message', messageId: 'a2', role: 'agent', parts: [], metadata: {} }, - ], - }, - ] as unknown as Task[]; - const messages = extractMessagesFromTasks(tasks); - // Agent message with usage metadata gets tokenStats injected - expect((messages[0].metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats) - .toEqual({ total: 10, prompt: 3, completion: 7 }); - // User message is NOT enriched even if it carries usage metadata - expect((messages[1].metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats) - .toBeUndefined(); - // Agent message without usage metadata is passed through unchanged - expect((messages[2].metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats) - .toBeUndefined(); - }); - - test('extractTokenStatsFromTasks sums usage across all history messages', () => { - const tasks: any = [ - { history: [{ kind: 'message', metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 3, candidatesTokenCount: 7 } } }] }, - { history: [{ kind: 'message', metadata: { kagent_usage_metadata: { totalTokenCount: 12, promptTokenCount: 1, candidatesTokenCount: 9 } } }] }, - ]; - const stats = extractTokenStatsFromTasks(tasks); - expect(stats.total).toBe(22); - expect(stats.prompt).toBe(4); - expect(stats.completion).toBe(16); - }); - - test('extractTokenStatsFromTasks skips history items without usage metadata', () => { - const tasks = [ - { history: [{ kind: 'message', messageId: uuidv4(), role: 'agent', parts: [], metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 3, candidatesTokenCount: 7 } } }] }, - { history: [{ kind: 'message', messageId: uuidv4(), role: 'agent', parts: [], metadata: {} }] }, - ] as unknown as Task[]; - const stats = extractTokenStatsFromTasks(tasks); - expect(stats.total).toBe(10); - expect(stats.prompt).toBe(3); - expect(stats.completion).toBe(7); - }); -}); - -describe('createMessageHandlers test', () => { - test('emits ToolCallRequestEvent + ToolCallExecutionEvent for non-agent tool', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - setChatStatus: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - // Simulate status-update with function_call to an agent tool - const statusUpdateCall: any = { - kind: 'status-update', - contextId: 'ctx', - taskId: 'task', - final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [ - { - kind: 'data', - data: { id: 'call_1', name: 'kagent__NS__k8s_agent', args: { request: 'list' } }, - metadata: { kagent_type: 'function_call' }, - }, - ], +} from "@/lib/messageHandlers"; + +function textPart(text: string) { + return { + content: { $case: "text" as const, value: text }, + metadata: {}, + filename: "", + mediaType: "text/plain", + }; +} + +function dataPart(value: unknown, metadata: Record) { + return { + content: { $case: "data" as const, value }, + metadata, + filename: "", + mediaType: "application/json", + }; +} + +// Wire format for data parts stored in task.history (Message.fromJSON expects this). +function wireDataPart(value: unknown, metadata: Record) { + return { + data: value, + metadata, + filename: "", + mediaType: "application/json", + }; +} + +describe("messageHandlers (v1 native types)", () => { + test("createMessage builds a v1 message", () => { + const msg = createMessage("hi", "assistant", { contextId: "ctx", taskId: "task" }); + expect(msg.messageId).toBeDefined(); + expect(msg.parts[0].content?.$case).toBe("text"); + expect(msg.parts[0].content?.$case === "text" ? msg.parts[0].content.value : "").toBe("hi"); + expect(msg.contextId).toBe("ctx"); + expect(msg.taskId).toBe("task"); + }); + + test("normalizeToolResultToText handles string response", () => { + const data: ToolResponseData = { id: "1", name: "tool", response: { result: "hello" } }; + expect(normalizeToolResultToText(data)).toBe("hello"); + }); + + test("extractTokenStatsFromTasks sums adk/kagent usage metadata", () => { + const task = { + id: "t1", + contextId: "ctx", + status: undefined, + artifacts: [], + history: [ + { + messageId: "m1", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [textPart("a")], + metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 3, candidatesTokenCount: 7 } }, + extensions: [], + referenceTaskIds: [], }, - }, - }; - - handlers.handleMessageEvent(statusUpdateCall); - - // Simulate status-update with function_response from agent - const statusUpdateResp: any = { - kind: 'status-update', - contextId: 'ctx', - taskId: 'task', - final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [ - { - kind: 'data', - data: { id: 'call_1', name: 'kagent__NS__k8s_agent', response: { result: 'ok' } }, - metadata: { kagent_type: 'function_response' }, - }, - ], + { + messageId: "m2", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [textPart("b")], + metadata: { adk_usage_metadata: { totalTokenCount: 5, promptTokenCount: 2, candidatesTokenCount: 3 } }, + extensions: [], + referenceTaskIds: [], }, - }, - }; - - handlers.handleMessageEvent(statusUpdateResp); - - expect(emitted.length).toBe(2); - expect((emitted[0].metadata as any).originalType).toBe('ToolCallRequestEvent'); - expect((emitted[1].metadata as any).originalType).toBe('ToolCallExecutionEvent'); - }); - - test('emits ToolCallRequestEvent + ToolCallExecutionEvent for non-agent tool', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - const statusUpdateCall: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { state: 'working', message: { role: 'agent', parts: [{ kind: 'data', data: { id: 'call_2', name: 'some_tool', args: { a: 1 } }, metadata: { kagent_type: 'function_call' } }] } } - }; - handlers.handleMessageEvent(statusUpdateCall); - - const statusUpdateResp: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { state: 'working', message: { role: 'agent', parts: [{ kind: 'data', data: { id: 'call_2', name: 'some_tool', response: { result: 'tool ok' } }, metadata: { kagent_type: 'function_response' } }] } } - }; - handlers.handleMessageEvent(statusUpdateResp); - - expect(emitted.length).toBe(2); - expect((emitted[0].metadata as any).originalType).toBe('ToolCallRequestEvent'); - expect((emitted[1].metadata as any).originalType).toBe('ToolCallExecutionEvent'); - }); - - test('final text message on status-update with text part', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - const statusWithText: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: true, - status: { state: 'working', message: { role: 'agent', parts: [{ kind: 'text', text: 'hello' }] } } - }; - handlers.handleMessageEvent(statusWithText); - - expect(emitted.length).toBe(1); - expect((emitted[0].metadata as any).originalType).toBe('TextMessage'); - expect((emitted[0].parts[0] as any).text).toBe('hello'); - }); - - test('artifact-update converts tool parts and appends summary', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - const artifactEvent: any = { - kind: 'artifact-update', contextId: 'ctx', taskId: 'task', lastChunk: true, - artifact: { - parts: [ - { kind: 'data', data: { id: 'call_3', name: 'some_tool', args: { q: 1 } }, metadata: { kagent_type: 'function_call' } }, - { kind: 'data', data: { id: 'call_3', name: 'some_tool', response: { result: 'out' } }, metadata: { kagent_type: 'function_response' } }, - ] - } - }; - handlers.handleMessageEvent(artifactEvent); - - // Expect: request, execution, summary (no text message since no text part) - expect(emitted.length).toBe(3); - expect((emitted[0].metadata as any).originalType).toBe('ToolCallRequestEvent'); - expect((emitted[1].metadata as any).originalType).toBe('ToolCallExecutionEvent'); - expect((emitted[2].metadata as any).originalType).toBe('ToolCallSummaryMessage'); - }); - - test('each invocation keeps its own token stats and session total accumulates correctly', () => { - const emitted: Message[] = []; - let capturedSessionTotal = { total: 0, prompt: 0, completion: 0 }; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - setSessionStats: (updater) => { capturedSessionTotal = updater(capturedSessionTotal); }, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - // Invocation 1: LLM decides to call a tool (usage arrives with the function_call) - const toolCallUpdate = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - metadata: { kagent_usage_metadata: { totalTokenCount: 5, promptTokenCount: 3, candidatesTokenCount: 2 } }, - status: { - state: 'working', - message: { - role: 'agent', - parts: [{ kind: 'data', data: { id: 'call_1', name: 'my_tool', args: {} }, metadata: { kagent_type: 'function_call' } }] - } - } - } as unknown as Message; - handlers.handleMessageEvent(toolCallUpdate); - - // Tool executes and returns a result - const toolResponseUpdate = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [{ kind: 'data', data: { id: 'call_1', name: 'my_tool', response: { result: 'ok' } }, metadata: { kagent_type: 'function_response' } }] - } - } - } as unknown as Message; - handlers.handleMessageEvent(toolResponseUpdate); - - // Invocation 2: LLM generates the final text response - const finalUpdate = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: true, - metadata: { kagent_usage_metadata: { totalTokenCount: 10, promptTokenCount: 7, candidatesTokenCount: 3 } }, - status: { - state: 'completed', - message: { role: 'agent', parts: [{ kind: 'text', text: 'done' }] } - } - } as unknown as Message; - handlers.handleMessageEvent(finalUpdate); - - const toolCallMsg = emitted.find(m => (m.metadata as ADKMetadata)?.originalType === 'ToolCallRequestEvent'); - const textMsg = emitted.find(m => (m.metadata as ADKMetadata)?.originalType === 'TextMessage'); - // Each invocation keeps its own stats — the tool call is not overwritten by the text response - expect((toolCallMsg?.metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats).toEqual({ total: 5, prompt: 3, completion: 2 }); - expect((textMsg?.metadata as ADKMetadata & { tokenStats?: TokenStats })?.tokenStats).toEqual({ total: 10, prompt: 7, completion: 3 }); - // Session total accumulates both invocations - expect(capturedSessionTotal).toEqual({ total: 15, prompt: 10, completion: 5 }); - }); - - test('HITL interrupt accumulates pending turn stats and clears them', () => { - const emitted: Message[] = []; - let capturedSessionTotal: TokenStats = { total: 0, prompt: 0, completion: 0 }; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - setChatStatus: () => {}, - setSessionStats: (updater) => { capturedSessionTotal = updater(capturedSessionTotal); }, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - // Status update: LLM decides to call a confirmation tool (HITL), usage arrives here - const hitlUpdate = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - metadata: { kagent_usage_metadata: { totalTokenCount: 8, promptTokenCount: 5, candidatesTokenCount: 3 } }, - status: { - state: 'input-required', - message: { - role: 'agent', - parts: [{ - kind: 'data', - data: { - name: 'adk_request_confirmation', - id: 'confirm_1', - args: { originalFunctionCall: { name: 'my_tool', args: { x: 1 }, id: 'call_1' } }, - }, - metadata: { kagent_type: 'function_call', kagent_is_long_running: true }, - }], + ], + metadata: undefined, + } as unknown as Task; + + expect(extractTokenStatsFromTasks([task])).toEqual({ total: 15, prompt: 5, completion: 10 }); + }); + + test("extractMessagesFromTasks converts function_call and function_response", () => { + const task = { + id: "t1", + contextId: "ctx", + status: undefined, + artifacts: [], + history: [ + { + messageId: "m1", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [wireDataPart({ id: "call1", name: "my_tool", args: { a: 1 } }, { adk_type: "function_call" })], + metadata: {}, + extensions: [], + referenceTaskIds: [], }, - }, - } as unknown as Message; - handlers.handleMessageEvent(hitlUpdate); + { + messageId: "m2", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [wireDataPart({ id: "call1", name: "my_tool", response: { result: "ok" } }, { adk_type: "function_response" })], + metadata: {}, + extensions: [], + referenceTaskIds: [], + }, + ], + metadata: undefined, + } as unknown as Task; - // Session stats should be accumulated at the HITL boundary (not at stream end) - expect(capturedSessionTotal).toEqual({ total: 8, prompt: 5, completion: 3 }); - // A ToolApprovalRequest message should have been emitted - const approvalMsg = emitted.find(m => (m.metadata as ADKMetadata)?.originalType === 'ToolApprovalRequest'); - expect(approvalMsg).toBeDefined(); + const out = extractMessagesFromTasks([task]); + expect((out[0].metadata as Record)?.originalType).toBe("ToolCallRequestEvent"); + expect((out[1].metadata as Record)?.originalType).toBe("ToolCallExecutionEvent"); }); -}); -describe('subagent_session_id propagation', () => { - // Shared handler factory for status-update / artifact-update tests - function makeHandlers() { + test("createMessageHandlers processes v1 stream status updates", () => { const emitted: Message[] = []; const handlers = createMessageHandlers({ setMessages: (updater) => { @@ -367,206 +134,39 @@ describe('subagent_session_id propagation', () => { setIsStreaming: () => {}, setStreamingContent: () => {}, setChatStatus: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, + agentContext: { namespace: "kagent", agentName: "agent" }, }); - return { emitted, handlers }; - } - - test('status-update: agent function_call with kagent_subagent_session_id in DataPart metadata emits toolCallData with subagent_session_id', () => { - const { emitted, handlers } = makeHandlers(); - - const statusUpdateCall: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [{ - kind: 'data', - data: { id: 'agent_call_1', name: 'kagent__NS__k8s_agent', args: { request: 'list pods' } }, - metadata: { kagent_type: 'function_call', kagent_subagent_session_id: 'sess-abc-123' }, - }], - }, - }, - }; - handlers.handleMessageEvent(statusUpdateCall); - - expect(emitted.length).toBe(1); - const meta = emitted[0].metadata as ADKMetadata; - expect(meta.originalType).toBe('ToolCallRequestEvent'); - expect(meta.toolCallData).toHaveLength(1); - expect(meta.toolCallData![0].subagent_session_id).toBe('sess-abc-123'); - }); - - test('status-update: agent function_response with subagent_session_id in response dict emits toolResultData with subagent_session_id', () => { - const { emitted, handlers } = makeHandlers(); - const statusUpdateResp: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [{ - kind: 'data', - data: { - id: 'agent_call_1', - name: 'kagent__NS__k8s_agent', - response: { result: 'done', subagent_session_id: 'sess-abc-123' }, + const statusUpdate = { + payload: { + $case: "statusUpdate", + value: { + contextId: "ctx", + taskId: "t1", + status: { + state: 2, + message: { + messageId: "m1", + contextId: "ctx", + taskId: "t1", + role: 2, + parts: [dataPart({ id: "call1", name: "tool", args: {} }, { adk_type: "function_call" })], + metadata: {}, + extensions: [], + referenceTaskIds: [], }, - metadata: { kagent_type: 'function_response' }, - }], - }, - }, - }; - handlers.handleMessageEvent(statusUpdateResp); - - const execMsg = emitted.find(m => (m.metadata as ADKMetadata)?.originalType === 'ToolCallExecutionEvent'); - expect(execMsg).toBeDefined(); - const resultData = (execMsg!.metadata as ADKMetadata).toolResultData!; - expect(resultData).toHaveLength(1); - expect(resultData[0].subagent_session_id).toBe('sess-abc-123'); - }); - - test('extractMessagesFromTasks: agent function_call DataPart with kagent_subagent_session_id emits toolCallData with subagent_session_id', () => { - const tasks = [{ - contextId: 'ctx', - id: 'task', - history: [{ - kind: 'message', - messageId: 'msg-1', - role: 'agent', - parts: [{ - kind: 'data', - data: { id: 'agent_call_3', name: 'kagent__NS__k8s_agent', args: { request: 'list nodes' } }, - metadata: { kagent_type: 'function_call', kagent_subagent_session_id: 'sess-history-456' }, - }], - metadata: {}, - }], - }] as unknown as Task[]; - - const messages = extractMessagesFromTasks(tasks); - expect(messages).toHaveLength(1); - const meta = messages[0].metadata as ADKMetadata; - expect(meta.originalType).toBe('ToolCallRequestEvent'); - expect(meta.toolCallData).toHaveLength(1); - expect(meta.toolCallData![0].subagent_session_id).toBe('sess-history-456'); - }); - - test('extractMessagesFromTasks: agent function_response DataPart with subagent_session_id in response dict emits toolResultData with subagent_session_id', () => { - const tasks = [{ - contextId: 'ctx', - id: 'task', - history: [{ - kind: 'message', - messageId: 'msg-3', - role: 'agent', - parts: [{ - kind: 'data', - data: { - id: 'agent_call_3', - name: 'kagent__NS__k8s_agent', - response: { result: 'nodes listed', subagent_session_id: 'sess-history-456' }, + timestamp: undefined, }, - metadata: { kagent_type: 'function_response' }, - }], - metadata: {}, - }], - }] as unknown as Task[]; - - const messages = extractMessagesFromTasks(tasks); - expect(messages).toHaveLength(1); - const meta = messages[0].metadata as ADKMetadata; - expect(meta.originalType).toBe('ToolCallExecutionEvent'); - expect(meta.toolResultData).toHaveLength(1); - expect(meta.toolResultData![0].subagent_session_id).toBe('sess-history-456'); - }); -}); - -describe('getMetadataValue', () => { - test('reads kagent_ prefixed key', () => { - expect(getMetadataValue({ kagent_type: 'function_call' }, 'type')).toBe('function_call'); - }); - - test('reads adk_ prefixed key', () => { - expect(getMetadataValue({ adk_type: 'function_call' }, 'type')).toBe('function_call'); - }); - - test('adk_ takes priority over kagent_ when both present', () => { - expect(getMetadataValue({ adk_type: 'adk_val', kagent_type: 'kagent_val' }, 'type')).toBe('adk_val'); - }); - - test('returns undefined for missing key', () => { - expect(getMetadataValue({ other: 'x' }, 'type')).toBeUndefined(); - }); - - test('returns undefined for null/undefined metadata', () => { - expect(getMetadataValue(null, 'type')).toBeUndefined(); - expect(getMetadataValue(undefined, 'type')).toBeUndefined(); - }); - - test('returns falsy values correctly (not undefined)', () => { - expect(getMetadataValue({ kagent_flag: false }, 'flag')).toBe(false); - expect(getMetadataValue({ adk_count: 0 }, 'count')).toBe(0); - expect(getMetadataValue({ kagent_text: '' }, 'text')).toBe(''); - }); -}); - -describe('dual-prefix integration', () => { - test('extractTokenStatsFromTasks works with adk_usage_metadata', () => { - const tasks: any = [ - { history: [{ kind: 'message', metadata: { adk_usage_metadata: { totalTokenCount: 20, promptTokenCount: 8, candidatesTokenCount: 12 } } }] }, - ]; - const stats = extractTokenStatsFromTasks(tasks); - expect(stats.total).toBe(20); - expect(stats.prompt).toBe(8); - expect(stats.completion).toBe(12); - }); - - test('status-update handler works with adk_type metadata on parts', () => { - const emitted: Message[] = []; - const handlers = createMessageHandlers({ - setMessages: (updater) => { - const next = updater(emitted); - emitted.length = 0; - emitted.push(...next); - }, - setIsStreaming: () => {}, - setStreamingContent: () => {}, - setChatStatus: () => {}, - agentContext: { namespace: 'kagent', agentName: 'testagent' }, - }); - - const statusUpdateCall: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [ - { kind: 'data', data: { id: 'call_adk', name: 'my_tool', args: { x: 1 } }, metadata: { adk_type: 'function_call' } }, - ], + metadata: {}, }, }, - }; - handlers.handleMessageEvent(statusUpdateCall); + } as unknown as StreamResponse; - const statusUpdateResp: any = { - kind: 'status-update', contextId: 'ctx', taskId: 'task', final: false, - status: { - state: 'working', - message: { - role: 'agent', - parts: [ - { kind: 'data', data: { id: 'call_adk', name: 'my_tool', response: { result: 'done' } }, metadata: { adk_type: 'function_response' } }, - ], - }, - }, - }; - handlers.handleMessageEvent(statusUpdateResp); + handlers.handleMessageEvent(statusUpdate); + expect((emitted[0].metadata as Record)?.originalType).toBe("ToolCallRequestEvent"); + }); - expect(emitted.length).toBe(2); - expect((emitted[0].metadata as any).originalType).toBe('ToolCallRequestEvent'); - expect((emitted[1].metadata as any).originalType).toBe('ToolCallExecutionEvent'); + test("getMetadataValue checks adk_ first then kagent_", () => { + expect(getMetadataValue({ adk_type: "a", kagent_type: "k" }, "type")).toBe("a"); }); }); diff --git a/ui/src/lib/a2aClient.ts b/ui/src/lib/a2aClient.ts index 789f077b90..4165797f87 100644 --- a/ui/src/lib/a2aClient.ts +++ b/ui/src/lib/a2aClient.ts @@ -1,12 +1,23 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getBackendUrl } from "./utils"; import { v4 as uuidv4 } from 'uuid'; -import { MessageSendParams } from '@a2a-js/sdk'; +import { A2A_PROTOCOL_VERSION, A2A_VERSION_HEADER, Message, StreamResponse } from "@a2a-js/sdk"; +import type { Message as A2AMessage } from "@a2a-js/sdk"; + +// A2A JSON-RPC methods +// NOTE: These are not exported by @a2a-js/sdk, so we need to define them here. +export const A2A_JSONRPC_METHODS = { + sendStreamingMessage: "SendStreamingMessage", + subscribeToTask: "SubscribeToTask", +} as const; export interface A2AJsonRpcRequest { jsonrpc: "2.0"; method: string; - params: MessageSendParams; + params: { + message: unknown; + metadata?: Record; + }; id: string | number; } @@ -28,11 +39,14 @@ export class KagentA2AClient { /** * Create JSON-RPC request for message streaming */ - createStreamingRequest(params: MessageSendParams): A2AJsonRpcRequest { + createStreamingRequest(params: { message: A2AMessage; metadata?: Record }): A2AJsonRpcRequest { return { jsonrpc: "2.0", - method: "message/stream", - params, + method: A2A_JSONRPC_METHODS.sendStreamingMessage, + params: { + ...params, + message: Message.toJSON(params.message), + }, id: uuidv4(), // A2A server requires an id field }; } @@ -44,10 +58,10 @@ export class KagentA2AClient { async sendMessageStream( namespace: string, agentName: string, - params: MessageSendParams, + params: { message: A2AMessage; metadata?: Record }, signal?: AbortSignal, runInSandbox = false - ): Promise> { + ): Promise> { const request = this.createStreamingRequest(params); const proxyUrl = runInSandbox ? `/a2a-sandboxes/${namespace}/${agentName}` @@ -58,6 +72,7 @@ export class KagentA2AClient { headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', + [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION, }, body: JSON.stringify(request), signal, @@ -91,7 +106,7 @@ export class KagentA2AClient { ): Promise> { const request = { jsonrpc: "2.0" as const, - method: "tasks/resubscribe", + method: A2A_JSONRPC_METHODS.subscribeToTask, params: { id: taskId }, id: uuidv4(), }; @@ -105,6 +120,7 @@ export class KagentA2AClient { headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', + [A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION, }, body: JSON.stringify(request), signal, @@ -125,7 +141,7 @@ export class KagentA2AClient { /** * Process Server-Sent Events stream with proper event boundary detection */ - private async *processSSEStream(body: ReadableStream): AsyncIterable { + private async *processSSEStream(body: ReadableStream): AsyncIterable { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; @@ -156,12 +172,18 @@ export class KagentA2AClient { return; } + let eventData: Record; try { - const eventData = JSON.parse(dataString); - yield eventData.result || eventData; - } catch (error) { - console.error("❌ Failed to parse SSE data:", error, dataString); + eventData = JSON.parse(dataString); + } catch { + console.error("❌ Failed to parse SSE data:", dataString); + continue; + } + if (eventData.error) { + const err = eventData.error as { code?: number; message?: string }; + throw new Error(`A2A error ${err.code ?? "unknown"}: ${err.message ?? "unknown error"}`); } + yield StreamResponse.fromJSON(eventData.result || eventData); } } } diff --git a/ui/src/lib/auth.ts b/ui/src/lib/auth.ts index aeeb2a4567..cd615a0b1d 100644 --- a/ui/src/lib/auth.ts +++ b/ui/src/lib/auth.ts @@ -72,7 +72,7 @@ function getAllowedHeadersFromEnv(): Set { * accepted from cross-origin browsers, even when the backend trusts them * server-to-server. */ -export const CORS_ALLOW_HEADERS = "Content-Type, Authorization, Accept"; +export const CORS_ALLOW_HEADERS = "Content-Type, Authorization, Accept, A2A-Version"; /** * Copy headers named in `allowed` from `getHeader` into a forwardable record. diff --git a/ui/src/lib/messageHandlers.ts b/ui/src/lib/messageHandlers.ts index 638337ae98..0d705e92bd 100644 --- a/ui/src/lib/messageHandlers.ts +++ b/ui/src/lib/messageHandlers.ts @@ -1,9 +1,42 @@ -import { Message, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent, TextPart, Part, DataPart } from "@a2a-js/sdk"; +import type { + Task, + TaskStatusUpdateEvent, + TaskArtifactUpdateEvent, + Part, + StreamResponse, +} from "@a2a-js/sdk"; +import { TaskState, Role, Message, roleFromJSON, taskStateFromJSON } from "@a2a-js/sdk"; import { v4 as uuidv4 } from "uuid"; -import { convertToUserFriendlyName, isAgentToolName, messageUtils } from "@/lib/utils"; +import { convertToUserFriendlyName, isAgentToolName, a2aPartUtils } from "@/lib/utils"; import { ApprovalDecision, AdkRequestConfirmationData, HitlPartInfo, ToolDecision, TokenStats, ChatStatus } from "@/types"; import { mapA2AStateToStatus } from "@/lib/statusUtils"; +function isInputRequiredState(state: TaskState | string | undefined): boolean { + return taskStateFromJSON(state) === TaskState.TASK_STATE_INPUT_REQUIRED; +} + +function isUserRole(role: Role | string | number | undefined): boolean { + return roleFromJSON(role) === Role.ROLE_USER; +} + +function isTerminalState(state: TaskState | string | undefined): boolean { + const normalized = taskStateFromJSON(state); + return ( + normalized === TaskState.TASK_STATE_COMPLETED || + normalized === TaskState.TASK_STATE_FAILED || + normalized === TaskState.TASK_STATE_CANCELED || + normalized === TaskState.TASK_STATE_REJECTED + ); +} + +interface DataPart extends Part { + content: { $case: "data"; value: unknown }; +} + +interface TextPart extends Part { + content: { $case: "text"; value: string }; +} + // Helper functions for extracting data from stored tasks export function extractMessagesFromTasks(tasks: Task[]): Message[] { const messages: Message[] = []; @@ -18,8 +51,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { let lastSeenStats: TokenStats | undefined; for (let i = 0; i < task.history.length; i++) { - const historyItem = task.history[i]; - if (historyItem.kind !== "message") continue; + const historyItem = Message.fromJSON(task.history[i]); // Deduplicate by messageId to avoid showing the same message twice if (seenMessageIds.has(historyItem.messageId)) continue; @@ -31,7 +63,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { if (confirmationParts.length > 0) { // Find the decision that applies to THIS confirmation (first decision AFTER this message) const decision = findDecisionAfterIndex( - task.history as Array<{ kind?: string; role?: string; parts?: Part[] }>, + task.history as Array<{ role?: Role; parts?: Part[] }>, i ); @@ -52,7 +84,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { if (isUserDecisionMessage(historyItem)) continue; // User messages: push as-is (no tokenStats needed). - if (historyItem.role === "user") { + if (isUserRole(historyItem.role)) { messages.push(historyItem); continue; } @@ -69,17 +101,17 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { let hasConvertedParts = false; for (const part of historyItem.parts ?? []) { - if (part.kind !== "data") continue; - const dp = part as DataPart; + if (!isDataPart(part)) continue; + const dp = part; const partMeta = dp.metadata as Record | undefined; const partType = getMetadataValue(partMeta, "type"); if (partType === "function_call") { - const fcName = (dp.data as Record)?.name as string | undefined; + const fcName = (dp.content.value as Record)?.name as string | undefined; // Skip ADK internal calls — confirmations are handled above. if (fcName === "adk_request_confirmation" || fcName === "adk_request_credential") continue; - const toolData = dp.data as unknown as ToolCallData; + const toolData = dp.content.value as unknown as ToolCallData; // Agent calls get no initial tokenStats; child stats arrive later via // the function_response and are stamped on this card below. // Regular tool calls use the message's own invocation stats. @@ -104,7 +136,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { hasConvertedParts = true; } else if (partType === "function_response") { - const toolData = dp.data as unknown as ToolResponseData; + const toolData = dp.content.value as unknown as ToolResponseData; let frSubagentSessionId: string | undefined; if (isAgentToolName(toolData.name)) { const responseObj = toolData.response as Record | undefined; @@ -164,10 +196,10 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { /** Returns true if the message is a user HITL decision (approve/reject) or ask-user answer. */ function isUserDecisionMessage(message: Message): boolean { - if (message.role !== "user" || !message.parts) return false; + if (!isUserRole(message.role) || !message.parts) return false; return message.parts.some((p: Part) => { - if (p.kind !== "data") return false; - const data = (p as DataPart).data as Record | undefined; + if (!isDataPart(p)) return false; + const data = p.content.value as Record | undefined; return data?.decision_type != null; }); } @@ -186,9 +218,9 @@ export function extractApprovalMessagesFromTasks(tasks: Task[]): { messages: Mes for (const task of tasks) { const status = task.status; - if (status?.state !== "input-required" || !status?.message) continue; + if (!isInputRequiredState(status?.state) || !status?.message) continue; - const confirmationParts = findConfirmationParts(status.message as Message); + const confirmationParts = findConfirmationParts(Message.fromJSON(status.message)); if (confirmationParts.length === 0) continue; for (const confPart of confirmationParts) { @@ -204,13 +236,13 @@ export function extractApprovalMessagesFromTasks(tasks: Task[]): { messages: Mes function findConfirmationParts(message: Message): DataPart[] { if (!message.parts) return []; return message.parts.filter((part: Part) => { - if (part.kind !== "data") return false; - const dp = part as DataPart; + if (!isDataPart(part)) return false; + const dp = part; const meta = dp.metadata as Record | undefined; return ( getMetadataValue(meta, "type") === "function_call" && getMetadataValue(meta, "is_long_running") === true && - (dp.data as Record)?.name === "adk_request_confirmation" + (dp.content.value as Record)?.name === "adk_request_confirmation" ); }) as DataPart[]; } @@ -221,15 +253,15 @@ function findConfirmationParts(message: Message): DataPart[] { * if a task enters input-required multiple times. */ function findDecisionAfterIndex( - history: Array<{ kind?: string; role?: string; parts?: Part[] }>, + history: Array<{ role?: Role; parts?: Part[] }>, startIndex: number ): Record | undefined { for (let i = startIndex + 1; i < history.length; i++) { - const item = history[i]; - if (item.kind !== "message" || item.role !== "user" || !item.parts) continue; + const item = Message.fromJSON(history[i]); + if (!isUserRole(item.role) || !item.parts) continue; for (const p of item.parts) { - if (p.kind !== "data") continue; - const data = (p as DataPart).data as Record | undefined; + if (!isDataPart(p)) continue; + const data = p.content.value as Record | undefined; if (data?.decision_type != null) { return data; } @@ -271,7 +303,7 @@ export function buildApprovalMessage( decisionData?: Record, tokenStats?: TokenStats ): Message { - const data = confPart.data as unknown as AdkRequestConfirmationData; + const data = confPart.content.value as unknown as AdkRequestConfirmationData; const origFc = data.args.originalFunctionCall; const toolId = origFc.id || data.id; @@ -387,8 +419,8 @@ export function extractTokenStatsFromTasks(tasks: Task[]): TokenStats { let total = 0, prompt = 0, completion = 0; for (const task of tasks) { for (const item of task.history ?? []) { - const msg = item as unknown as { kind?: string; role?: string; metadata?: Record; parts?: Part[] }; - if (msg.kind !== "message" || msg.role === "user") continue; + const msg = Message.fromJSON(item); + if (isUserRole(msg.role)) continue; // Message-level usage (most agent messages carry this). const stats = getMessageTokenStats(msg.metadata); @@ -401,11 +433,11 @@ export function extractTokenStatsFromTasks(tasks: Task[]): TokenStats { // function_response from agent tools carries child-agent usage inside the // response dict rather than in message-level metadata — include it here. for (const part of msg.parts ?? []) { - if (part.kind !== "data") continue; - const dp = part as DataPart; + if (!isDataPart(part)) continue; + const dp = part; const partMeta = dp.metadata as Record | undefined; if (getMetadataValue(partMeta, "type") !== "function_response") continue; - const toolData = dp.data as unknown as ToolResponseData; + const toolData = dp.content.value as unknown as ToolResponseData; if (!isAgentToolName(toolData.name)) continue; const responseUsage = (toolData.response as Record | undefined)?.kagent_usage_metadata; if (!responseUsage) continue; @@ -537,11 +569,11 @@ export function normalizeToolResultToText(toolData: ToolResponseData): string { } function isTextPart(part: Part): part is TextPart { - return part.kind === "text"; + return a2aPartUtils.getCase(part) === "text"; } function isDataPart(part: Part): part is DataPart { - return part.kind === "data"; + return a2aPartUtils.getCase(part) === "data"; } function getSourceFromMetadata(metadata: ADKMetadata | undefined, fallback: string = "assistant"): string { @@ -577,20 +609,23 @@ export function createMessage( } = options; const message: Message = { - kind: "message", messageId, - role: source === "user" ? "user" : "agent", + role: source === "user" ? Role.ROLE_USER : Role.ROLE_AGENT, parts: [{ - kind: "text", - text: content + content: { $case: "text", value: content }, + metadata: undefined, + filename: "", + mediaType: "text/plain", }], - contextId, - taskId, + contextId: contextId ?? "", + taskId: taskId ?? "", metadata: { originalType, displaySource: source, ...additionalMetadata - } + }, + extensions: [], + referenceTaskIds: [], }; return message; } @@ -641,15 +676,15 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { const aggregatePartsToText = (parts: Part[]): string => { return parts.map((part: Part) => { if (isTextPart(part)) { - return part.text || ""; + return part.content.value || ""; } else if (isDataPart(part)) { try { - return JSON.stringify(part.data || ""); + return JSON.stringify(part.content.value || ""); } catch { - return String(part.data); + return String(part.content.value); } - } else if (part.kind === "file") { - return `[File: ${(part as { file?: { name?: string } }).file?.name || "unknown"}]`; + } else if (part.content?.$case === "raw" || part.content?.$case === "url") { + return `[File: ${part.filename || "unknown"}]`; } return String(part); }).join(""); @@ -765,7 +800,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } }; - const isUserMessage = (message: Message): boolean => message.role === "user"; + const isUserMessage = (message: Message): boolean => isUserRole(message.role); // Simple fallback source when metadata is not available const defaultAgentSource = handlers.agentContext @@ -800,7 +835,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { handlers.setMessages(prev => { const updated = [...prev]; for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].role === "user") break; + if (isUserRole(updated[i].role)) break; // Stop at an invocation boundary — everything before belongs to an // earlier LLM call and must not be tagged with this turn's stats. // ToolApprovalRequest: HITL boundary; ToolCallExecutionEvent: the @@ -817,11 +852,12 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } // Check for tool approval interrupt + const status = statusUpdate.status; if ( - statusUpdate.status.state === "input-required" && - statusUpdate.status.message + isInputRequiredState(status?.state) && + status?.message ) { - const confirmationParts = findConfirmationParts(statusUpdate.status.message as Message); + const confirmationParts = findConfirmationParts(status.message as Message); if (confirmationParts.length > 0) { for (const confPart of confirmationParts) { @@ -846,8 +882,8 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } // If the status update has a message, process it - if (statusUpdate.status.message) { - const message = statusUpdate.status.message; + if (status?.message) { + const message = status.message; // Skip user messages to avoid duplicates (they're already shown immediately) if (isUserMessage(message)) { @@ -857,10 +893,10 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { for (const part of message.parts) { if (isTextPart(part)) { - const textContent = part.text || ""; + const textContent = part.content.value || ""; const source = getSourceFromMetadata(adkMetadata, defaultAgentSource); - if (statusUpdate.final) { + if (isTerminalState(statusUpdate.status?.state)) { const displayMessage = createMessage( textContent, source, @@ -883,7 +919,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } } } else if (isDataPart(part)) { - const data = part.data; + const data = part.content.value; const partMetadata = part.metadata as ADKMetadata | undefined; const partType = getMetadataValue(partMetadata as Record, "type"); @@ -923,12 +959,12 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } } else { if (handlers.setChatStatus) { - const uiStatus = mapA2AStateToStatus(statusUpdate.status.state); + const uiStatus = mapA2AStateToStatus(status?.state); handlers.setChatStatus(uiStatus); } } - if (statusUpdate.final) { + if (isTerminalState(statusUpdate.status?.state)) { finalizeStreaming(); } } catch (error) { @@ -948,14 +984,15 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { // Add artifact content and convert tool parts to messages let artifactText = ""; const convertedMessages: Message[] = []; - for (const part of artifactUpdate.artifact.parts) { + const artifactParts = artifactUpdate.artifact?.parts ?? []; + for (const part of artifactParts) { if (isTextPart(part)) { - artifactText += part.text || ""; + artifactText += part.content.value || ""; continue; } if (isDataPart(part)) { const partMetadata = part.metadata as ADKMetadata | undefined; - const data = part.data; + const data = part.content.value; const source = getSourceFromMetadata(adkMetadata, defaultAgentSource); const partType = getMetadataValue(partMetadata as Record, "type"); @@ -1006,8 +1043,8 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { } continue; } - if (part.kind === "file") { - artifactText += `[File: ${(part as { file?: { name?: string } }).file?.name || "unknown"}]`; + if (part.content?.$case === "raw" || part.content?.$case === "url") { + artifactText += `[File: ${part.filename || "unknown"}]`; continue; } artifactText += String(part); @@ -1062,7 +1099,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { const handleA2AMessage = (message: Message) => { const content = aggregatePartsToText(message.parts); - if (message.role !== "user") { + if (!isUserRole(message.role)) { const source = getSourceFromMetadata(message.metadata as ADKMetadata, defaultAgentSource); const displayMessage = createMessage( content, @@ -1082,30 +1119,27 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { appendMessage(message); }; - const handleMessageEvent = (message: Message) => { - if (messageUtils.isA2ATask(message)) { - handlers.setIsStreaming(true); - return; - } - - if (messageUtils.isA2ATaskStatusUpdate(message)) { - handleA2ATaskStatusUpdate(message); - return; + const handleMessageEvent = (streamEvent: StreamResponse) => { + const payload = streamEvent.payload; + if (!payload) return; + + switch (payload.$case) { + case "task": + handlers.setIsStreaming(true); + return; + case "statusUpdate": + handleA2ATaskStatusUpdate(payload.value); + return; + case "artifactUpdate": + handleA2ATaskArtifactUpdate(payload.value); + return; + case "message": + handleA2AMessage(payload.value); + return; + default: + console.warn("🤔 Unknown message type from A2A stream:", streamEvent); + return; } - - if (messageUtils.isA2ATaskArtifactUpdate(message)) { - handleA2ATaskArtifactUpdate(message); - return; - } - - if (messageUtils.isA2AMessage(message)) { - handleA2AMessage(message); - return; - } - - // If we get here, it's an unknown message type from the A2A stream - console.warn("🤔 Unknown message type from A2A stream:", message); - handleOtherMessage(message); }; return { diff --git a/ui/src/lib/statusUtils.ts b/ui/src/lib/statusUtils.ts index 4df1003e7f..74c473d973 100644 --- a/ui/src/lib/statusUtils.ts +++ b/ui/src/lib/statusUtils.ts @@ -1,29 +1,39 @@ import type { ChatStatus } from "@/types"; -import { TaskState } from "@a2a-js/sdk"; +import { TaskState, taskStateFromJSON } from "@a2a-js/sdk"; export interface StatusInfo { text: string; placeholder: string; } -// Map A2A TaskState to our ChatStatus for UI purposes -export const mapA2AStateToStatus = (state: TaskState): ChatStatus => { - switch (state) { - case "submitted": - return "submitted"; - case "working": - return "working"; - case "input-required": - return "input_required"; - case "completed": - return "ready"; - case "canceled": - case "failed": - case "rejected": - return "error"; - case "auth-required": - return "auth_required"; - case "unknown": +// Map A2A TaskState (enum or raw string from API) to our ChatStatus. +export const mapA2AStateToStatus = (state: TaskState | string | undefined): ChatStatus => { + const normalized = taskStateFromJSON(state); + if (normalized === TaskState.TASK_STATE_SUBMITTED) { + return "submitted"; + } + if (normalized === TaskState.TASK_STATE_WORKING) { + return "working"; + } + if (normalized === TaskState.TASK_STATE_INPUT_REQUIRED) { + return "input_required"; + } + if (normalized === TaskState.TASK_STATE_COMPLETED) { + return "ready"; + } + if ( + normalized === TaskState.TASK_STATE_CANCELED || + normalized === TaskState.TASK_STATE_FAILED || + normalized === TaskState.TASK_STATE_REJECTED + ) { + return "error"; + } + if (normalized === TaskState.TASK_STATE_AUTH_REQUIRED) { + return "auth_required"; + } + + switch (normalized) { + case TaskState.TASK_STATE_UNSPECIFIED: default: return "thinking"; } diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index e2893d1960..89b60c3db7 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,7 +1,14 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { v4 as uuidv4 } from "uuid"; -import { Message as A2AMessage, Task as A2ATask, TaskStatusUpdateEvent as A2ATaskStatusUpdateEvent, TaskArtifactUpdateEvent as A2ATaskArtifactUpdateEvent } from "@a2a-js/sdk"; +import type { + Message as A2AMessage, + Task as A2ATask, + TaskStatusUpdateEvent as A2ATaskStatusUpdateEvent, + TaskArtifactUpdateEvent as A2ATaskArtifactUpdateEvent, + StreamResponse as A2AStreamResponse, + Part as A2APart, +} from "@a2a-js/sdk"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -149,20 +156,36 @@ export const createRFC1123ValidName = (parts: string[]): string => { }; export const messageUtils = { + isA2AStreamResponse(content: unknown): content is A2AStreamResponse { + return typeof content === "object" && content !== null && "payload" in content; + }, + isA2AMessage(content: unknown): content is A2AMessage { - return typeof content === "object" && content !== null && "kind" in content && content.kind === "message"; + return typeof content === "object" && content !== null && "messageId" in content && "parts" in content; }, isA2ATask(content: unknown): content is A2ATask { - return typeof content === "object" && content !== null && "kind" in content && content.kind === "task"; + return typeof content === "object" && content !== null && "id" in content && "history" in content; }, isA2ATaskStatusUpdate(content: unknown): content is A2ATaskStatusUpdateEvent { - return typeof content === "object" && content !== null && "kind" in content && content.kind === "status-update"; + return typeof content === "object" && content !== null && "taskId" in content && "status" in content; }, isA2ATaskArtifactUpdate(content: unknown): content is A2ATaskArtifactUpdateEvent { - return typeof content === "object" && content !== null && "kind" in content && content.kind === "artifact-update"; + return typeof content === "object" && content !== null && "taskId" in content && "artifact" in content; + }, +}; + +export const a2aPartUtils = { + getCase(part: A2APart): string | undefined { + return part.content?.$case; + }, + getText(part: A2APart): string { + return part.content?.$case === "text" ? part.content.value : ""; + }, + getData(part: A2APart): unknown | undefined { + return part.content?.$case === "data" ? part.content.value : undefined; }, }; diff --git a/ui/src/mocks/handlers.ts b/ui/src/mocks/handlers.ts index 66b737bbcb..6d796a7b41 100644 --- a/ui/src/mocks/handlers.ts +++ b/ui/src/mocks/handlers.ts @@ -1,6 +1,7 @@ import { http, HttpResponse, delay } from "msw"; import type { Session } from "@/types"; -import type { Task, TaskState } from "@a2a-js/sdk"; +import type { Task } from "@a2a-js/sdk"; +import type { TaskState } from "@a2a-js/sdk"; /** * The backend URL that fetchApi constructs requests against. @@ -39,25 +40,34 @@ export function createMockTask( messageId?: string; metadata?: Record; }>, - status: { state: TaskState } = { state: "completed" }, + status: { state: TaskState } = { state: 3 }, ): Task { return { id: taskId, contextId, - kind: "task", status, history: history.map((h, i) => ({ - kind: "message" as const, messageId: h.messageId ?? `${taskId}-msg-${i}`, - role: h.role, - parts: [{ kind: "text" as const, text: h.text }], + role: h.role === "user" ? 1 : 2, + parts: [{ + content: { $case: "text", value: h.text }, + metadata: {}, + filename: "", + mediaType: "text/plain", + }], + contextId, + taskId, metadata: { displaySource: h.role === "agent" ? "assistant" : undefined, timestamp: Date.now() - (history.length - i) * 60_000, ...h.metadata, }, + extensions: [], + referenceTaskIds: [], })), - }; + artifacts: [], + metadata: undefined, + } as unknown as Task; } /** @@ -75,73 +85,95 @@ export function createMockToolCallTask( return { id: taskId, contextId, - kind: "task", - status: { state: "completed" }, + status: { state: 3 }, history: [ // User message that triggered the tool call { - kind: "message" as const, messageId: `${taskId}-user`, - role: "user" as const, - parts: [{ kind: "text" as const, text: "Run the tool" }], + role: 1, + parts: [{ + content: { $case: "text", value: "Run the tool" }, + metadata: {}, + filename: "", + mediaType: "text/plain", + }], + contextId, + taskId, metadata: { timestamp: Date.now() - 120_000 }, + extensions: [], + referenceTaskIds: [], }, // Agent message with tool call request (DataPart) { - kind: "message" as const, messageId: `${taskId}-tool-call`, - role: "agent" as const, + role: 2, parts: [ { - kind: "data" as const, - data: { id: `call-${taskId}`, name: toolName, args: toolArgs }, + content: { $case: "data", value: { id: `call-${taskId}`, name: toolName, args: toolArgs } }, metadata: { adk_type: "function_call" }, + filename: "", + mediaType: "application/json", }, ], + contextId, + taskId, metadata: { displaySource: "assistant", timestamp: Date.now() - 90_000, }, + extensions: [], + referenceTaskIds: [], }, // Agent message with tool execution result (DataPart) { - kind: "message" as const, messageId: `${taskId}-tool-result`, - role: "agent" as const, + role: 2, parts: [ { - kind: "data" as const, - data: { + content: { $case: "data", value: { id: `call-${taskId}`, name: toolName, response: { result: toolResult, isError: false }, - }, + } }, metadata: { adk_type: "function_response" }, + filename: "", + mediaType: "application/json", }, ], + contextId, + taskId, metadata: { displaySource: "assistant", timestamp: Date.now() - 60_000, }, + extensions: [], + referenceTaskIds: [], }, // Final text response after tool execution { - kind: "message" as const, messageId: `${taskId}-final`, - role: "agent" as const, + role: 2, parts: [ { - kind: "text" as const, - text: `I used the **${toolName}** tool and here are the results:\n\n${toolResult}`, + content: { $case: "text", value: `I used the **${toolName}** tool and here are the results:\n\n${toolResult}` }, + metadata: {}, + filename: "", + mediaType: "text/plain", }, ], + contextId, + taskId, metadata: { displaySource: "assistant", timestamp: Date.now() - 30_000, }, + extensions: [], + referenceTaskIds: [], }, ], - }; + artifacts: [], + metadata: undefined, + } as unknown as Task; } // ---------------------------------------------------------------------------