Skip to content

feat: serve A2A ListTasks from the task store#2187

Open
QuentinBisson wants to merge 3 commits into
kagent-dev:mainfrom
QuentinBisson:feat/a2a-store-backed-task-queries
Open

feat: serve A2A ListTasks from the task store#2187
QuentinBisson wants to merge 3 commits into
kagent-dev:mainfrom
QuentinBisson:feat/a2a-store-backed-task-queries

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

The conformance gap

kagent advertises an A2A server but is not conformant for ListTasks. The gateway's PassthroughRequestHandler.ListTasks (go/core/internal/a2a/passthrough_handler.go) delegates to a downstream *a2aclient.Client that the registrar builds filtered to the agent pod's legacy 0.3 interface. The v0 compat transport's ListTasks returns ErrUnsupportedOperation, so a client asking the gateway for a task list gets an error — on both wires (the registrar pins the 0.3 client regardless of the wire the client negotiates).

Proxying the query to the runtime is the wrong target: kagent persists tasks and is their source of truth. In fact the runtime doesn't own task state at all — its task store (KAgentTaskStore) is a REST client of kagent's own /api/tasks, so a proxied query would round-trip out to the pod and straight back to kagent's database.

The fix

A store-backed request handler answers ListTasks from the persistent task store, wrapping the passthrough for every other method. It honors the A2A v1.0.0 request/response semantics on both wires:

  • contextId, status, statusTimestampAfter filtering
  • historyLength truncation (most-recent messages) and includeArtifacts shaping (default false omits artifacts entirely)
  • page-size default (50) and cap (100), opaque page token
  • nextPageToken always present, empty string on the final page; totalSize/pageSize set

Tasks are ordered by task id so the page-token offset stays stable across calls (session updated_at reorders on writes; task ids do not). Status is filtered in Go from the stored Task JSON rather than with a JSONB query.

User scoping and session sharing

Reads are scoped to the caller's user_id — a caller cannot list another user's tasks. Session sharing is honored the same way the session handlers do it (getEffectiveUserIDForSession): a share token grants one session, so when the request carries a ShareContext for exactly the requested contextId, the owner's id is used instead. A share token never widens an all-sessions query (no contextId) to the owner's whole account.

Scope: ListTasks only, GetTask left on the passthrough

GetTask already works on both wires today and resolves to the same persisted rows the store-backed handler would read (runtime KAgentTaskStore.get()GET /api/tasks/{id} → the same Postgres row). Store-backing it would change a currently-working path for no functional gain, so this PR leaves GetTask on the passthrough. (Separately, the existing GetTask read path is not user-scoped; tightening that is a deliberate security change, tracked on its own, not bundled here.)

Wire split: v1 conformant, v0 bridged

  • v1 (a2asrv) is the spec-native, fully A2A-1.0-conformant surface. Method ListTasks, uppercase TaskState (TASK_STATE_INPUT_REQUIRED, ...).
  • v0 (a2av0, legacy 0.3) has no native tasks/list (0.3 predates the task-query methods). It is bridged as a deliberate kagent compatibility extension: tasks/list is intercepted and served by the same store-backed impl with lowercase TaskState (input-required, ...) via the a2acompat/a2av0 conversion helpers. Every other v0 method (and the well-known agent-card route) delegates to the upstream a2av0 handler unchanged.

Both wires filter and paginate identically; only the enum casing differs. Serving tasks/list on v0 is purely additive (the method errored before), so no existing v0 client behavior changes.

Tests

Conformance-first, covering both wires with an in-memory store: multi-page pagination, nextPageToken always present/empty on last page, includeArtifacts on/off, status (incl. input-required) and contextId filters, statusTimestampAfter, historyLength truncation, cross-user isolation, share-token access to an owner's session (and that it doesn't widen an all-sessions query), all-sessions aggregation, and end-to-end JSON-RPC drives asserting uppercase TaskState on v1 vs lowercase on v0 with identical filtering. go test ./core/internal/a2a/... green; gofmt/vet/golangci-lint clean.

@github-actions github-actions Bot added the enhancement New feature or request label Jul 9, 2026
@QuentinBisson QuentinBisson force-pushed the feat/a2a-store-backed-task-queries branch from 0f862ef to 834dad4 Compare July 9, 2026 13:30
@QuentinBisson QuentinBisson changed the title feat: serve A2A ListTasks/GetTask from the task store feat: serve A2A ListTasks from the task store Jul 9, 2026
kagent advertises an A2A server but is not conformant for ListTasks: the
gateway's PassthroughRequestHandler proxies task queries to the agent
runtime, whose legacy 0.3 transport returns ErrUnsupportedOperation on
both wires. ListTasks has the wrong target: kagent persists tasks and is
their source of truth, so it should answer this method itself.

Add a store-backed request handler that answers ListTasks from the
persistent task store, honoring the A2A v1.0.0 request/response
semantics: contextId/status/statusTimestampAfter filtering, historyLength
and includeArtifacts shaping, page-size default and cap, and a page token
that is always present (empty on the final page). Every other method
delegates to the passthrough proxy unchanged.

Reads are scoped to the caller's user id so a caller cannot list another
user's tasks. A share token grants one session, so when the request
carries a ShareContext for exactly the requested contextId the owner's id
is used instead (matching getEffectiveUserIDForSession in the session
handlers); it never widens an all-sessions query.

GetTask is intentionally left on the passthrough: it already works on
both wires and resolves to the same persisted rows (the runtime's task
store is a REST client of kagent's own /api/tasks), so store-backing it
would change a working path for no functional gain.

ListTasks is exposed on both wires the gateway serves. The v1 (a2asrv)
wire is spec-native with uppercase TaskState. The legacy 0.3 (a2av0) wire
predates the task-query methods, so tasks/list is bridged as a kagent
compatibility extension answered by the same store-backed impl with
lowercase TaskState; every other v0 method delegates to the upstream
a2av0 handler unchanged, including the well-known agent-card route.

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@QuentinBisson QuentinBisson force-pushed the feat/a2a-store-backed-task-queries branch from 834dad4 to bda1598 Compare July 9, 2026 13:38
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Jul 9, 2026
@QuentinBisson QuentinBisson marked this pull request as ready for review July 9, 2026 20:01
@QuentinBisson QuentinBisson requested a review from a team as a code owner July 9, 2026 20:01
Copilot AI review requested due to automatic review settings July 9, 2026 20:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a store-backed implementation of A2A ListTasks at the gateway layer so task listing no longer depends on downstream runtime transports (including legacy v0.3), while keeping all other A2A methods delegated to the existing passthrough handler.

Changes:

  • Introduces storeTaskQueryHandler that serves ListTasks from the persistent task store with filtering, shaping, and pagination.
  • Adds a v0 (tasks/list) JSON-RPC interceptor that bridges to the same store-backed implementation with legacy enum casing.
  • Wires the task store into the A2A handler mux via NewA2AHttpMux(..., taskStore) and passes the DB client from app startup.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
go/core/pkg/app/app.go Passes the DB client into the A2A mux so handlers can read from the persistent task store.
go/core/internal/a2a/task_query_store.go Implements store-backed ListTasks with filtering, pagination tokening, and response shaping.
go/core/internal/a2a/task_query_store_test.go Adds conformance-focused tests for pagination/filtering/shaping and wire casing differences.
go/core/internal/a2a/a2av0_tasks_list.go Adds v0 tasks/list interceptor to serve store-backed listing on legacy wire.
go/core/internal/a2a/a2a_handler_mux.go Wraps request handling to use the store-backed ListTasks and installs the v0 interceptor.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/core/internal/a2a/a2a_handler_mux.go Outdated
Comment on lines +79 to +80
legacyJSONRPCHandler := newV0TasksListInterceptor(a2av0.NewJSONRPCHandler(taskHandler), taskHandler)
v1JSONRPCHandler := a2asrv.NewJSONRPCHandler(taskHandler)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7274937: the interceptor is only installed when the store-backed handler is in use, so a nil store keeps the legacy wire's native method-not-found for tasks/list. Covered by TestWire_V0TasksListWithoutStoreIsMethodNotFound.

Comment on lines +121 to +124
if _, err := h.store.GetSession(ctx, contextID, userID); err != nil {
// Session does not exist or is not the caller's: no tasks to return.
return nil, nil
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7274937: GetSession now wraps a database.ErrNotFound sentinel for missing rows, and collectUserTasks only maps that to an empty list; any other store error propagates. Covered by TestListTasks_BackendFailurePropagates.

Comment on lines +128 to +136
sessions, err := h.store.ListSessions(ctx, userID)
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
var all []*a2atype.Task
for _, s := range sessions {
tasks, err := h.store.ListTasksForSession(ctx, s.ID)
if err != nil {
return nil, fmt.Errorf("list tasks for session %s: %w", s.ID, err)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, but the proper fix wants a schema decision (task.data is TEXT, so SQL-side status filtering needs jsonb casts or generated columns). Split out as #2197 rather than growing this PR.

Without a task store the v0 interceptor is no longer installed, so the
legacy wire keeps its native method-not-found for tasks/list instead of
hitting the passthrough.

GetSession now wraps database.ErrNotFound for missing rows; ListTasks
only maps that sentinel to an empty result and propagates any other
store error instead of silently returning no tasks.

@EItanya EItanya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I I just have the one scope comment, but I would also like @supreme-gg-gg to take a look as he is the SME on this code

Comment thread go/api/database/errors.go
// ErrNotFound reports that the requested record does not exist (or is not
// visible to the given user). Match with errors.Is; implementations wrap it
// with call-site context.
var ErrNotFound = errors.New("record not found")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this makes sense overall since it's probably better to hide the pgx errors, however this would only be one instance of that with still many pgx errors around. Can you leave this change for a follow-up where we clean-up all pgx leakage?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants