Files
Algis DumbrisandClaude Opus 4.6 24a7a33a2c feat: A2A inbound gateway — external agents can send tasks to SynapBus agents
Add JSON-RPC 2.0 endpoint at POST /a2a with three methods:
- message.send: validates target agent, creates tracked task, delivers DM
- tasks.get: returns task state, auto-completes when target agent replies
- tasks.cancel: transitions non-terminal tasks to CANCELED

Includes SQLite migration (010_a2a_tasks), task store, gateway with
interface-based dependencies, and 9 tests covering happy paths and
error cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:31:16 +02:00

133 lines
4.1 KiB
Go

// Package a2a provides the A2A (Agent-to-Agent) inbound gateway for SynapBus.
// External A2A-compliant agents can send tasks to SynapBus agents via JSON-RPC.
package a2a
import (
"context"
"database/sql"
"fmt"
"time"
)
// Task states following the A2A protocol.
const (
StateSubmitted = "SUBMITTED"
StateCompleted = "COMPLETED"
StateCanceled = "CANCELED"
)
// A2ATask represents an inbound A2A task tracked by the gateway.
type A2ATask struct {
ID string `json:"id"`
ContextID string `json:"context_id"`
TargetAgent string `json:"target_agent"`
SourceAgent string `json:"source_agent"`
ConversationID *int64 `json:"conversation_id,omitempty"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// A2ATaskStore provides CRUD operations for A2A tasks backed by SQLite.
type A2ATaskStore struct {
db *sql.DB
}
// NewA2ATaskStore creates a new task store.
func NewA2ATaskStore(db *sql.DB) *A2ATaskStore {
return &A2ATaskStore{db: db}
}
// CreateTask inserts a new A2A task into the database.
func (s *A2ATaskStore) CreateTask(ctx context.Context, task *A2ATask) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO a2a_tasks (id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
task.ID, task.ContextID, task.TargetAgent, task.SourceAgent, task.ConversationID, task.State,
)
if err != nil {
return fmt.Errorf("insert a2a task: %w", err)
}
return nil
}
// GetTask returns an A2A task by its ID.
func (s *A2ATaskStore) GetTask(ctx context.Context, id string) (*A2ATask, error) {
var task A2ATask
var conversationID sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
FROM a2a_tasks WHERE id = ?`, id,
).Scan(&task.ID, &task.ContextID, &task.TargetAgent, &task.SourceAgent,
&conversationID, &task.State, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("a2a task not found: %s", id)
}
return nil, fmt.Errorf("get a2a task: %w", err)
}
if conversationID.Valid {
task.ConversationID = &conversationID.Int64
}
return &task, nil
}
// UpdateTaskState transitions a task to a new state.
func (s *A2ATaskStore) UpdateTaskState(ctx context.Context, id, state string) error {
result, err := s.db.ExecContext(ctx,
`UPDATE a2a_tasks SET state = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
state, id,
)
if err != nil {
return fmt.Errorf("update a2a task state: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("get rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("a2a task not found: %s", id)
}
return nil
}
// ListTasks returns tasks for a target agent, optionally filtered by state.
func (s *A2ATaskStore) ListTasks(ctx context.Context, targetAgent, state string) ([]*A2ATask, error) {
var query string
var args []any
if state != "" {
query = `SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
FROM a2a_tasks WHERE target_agent = ? AND state = ? ORDER BY created_at DESC`
args = []any{targetAgent, state}
} else {
query = `SELECT id, context_id, target_agent, source_agent, conversation_id, state, created_at, updated_at
FROM a2a_tasks WHERE target_agent = ? ORDER BY created_at DESC`
args = []any{targetAgent}
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list a2a tasks: %w", err)
}
defer rows.Close()
var tasks []*A2ATask
for rows.Next() {
var task A2ATask
var conversationID sql.NullInt64
if err := rows.Scan(&task.ID, &task.ContextID, &task.TargetAgent, &task.SourceAgent,
&conversationID, &task.State, &task.CreatedAt, &task.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan a2a task: %w", err)
}
if conversationID.Valid {
task.ConversationID = &conversationID.Int64
}
tasks = append(tasks, &task)
}
if tasks == nil {
tasks = []*A2ATask{}
}
return tasks, rows.Err()
}