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>
133 lines
4.1 KiB
Go
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()
|
|
}
|