mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Revert "feat: add Kubernetes manifests and A2A protocol support"
This reverts commit b943eb7ac1.
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
# ArgoCD Application for RoboCo
|
||||
# This defines how ArgoCD should deploy and manage RoboCo
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: roboco
|
||||
namespace: argocd
|
||||
finalizers:
|
||||
- resources-finalizer.argocd.argoproj.io
|
||||
spec:
|
||||
project: default
|
||||
|
||||
source:
|
||||
# GitHub repository containing K8s manifests
|
||||
repoURL: git@github.com:renzof/roboco.git
|
||||
targetRevision: main
|
||||
path: deploy/overlays/production
|
||||
|
||||
destination:
|
||||
# Deploy to local cluster
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: roboco
|
||||
|
||||
syncPolicy:
|
||||
automated:
|
||||
# Automatically prune resources that are no longer in git
|
||||
prune: true
|
||||
# Automatically sync when ArgoCD detects drift
|
||||
selfHeal: true
|
||||
# Don't allow empty directories
|
||||
allowEmpty: false
|
||||
syncOptions:
|
||||
# Create namespace if it doesn't exist
|
||||
- CreateNamespace=true
|
||||
# Use foreground deletion for proper cleanup
|
||||
- PrunePropagationPolicy=foreground
|
||||
retry:
|
||||
limit: 5
|
||||
backoff:
|
||||
duration: 5s
|
||||
factor: 2
|
||||
maxDuration: 3m
|
||||
|
||||
# Ignore differences that are expected (e.g., HPA manages replicas)
|
||||
ignoreDifferences:
|
||||
- group: apps
|
||||
kind: Deployment
|
||||
jsonPointers:
|
||||
- /spec/replicas
|
||||
|
||||
# Health checks
|
||||
info:
|
||||
- name: description
|
||||
value: "RoboCo AI Agentic Company"
|
||||
- name: maintainer
|
||||
value: "renzof"
|
||||
@@ -1,83 +0,0 @@
|
||||
# RoboCo API Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: roboco-api
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco-api
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: roboco-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: roboco-api
|
||||
app.kubernetes.io/name: roboco-api
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: ghcr.io/renzof/roboco-api:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
name: http
|
||||
env:
|
||||
# Database
|
||||
- name: ROBOCO_DATABASE_HOST
|
||||
value: postgres
|
||||
- name: ROBOCO_DATABASE_PORT
|
||||
value: "5432"
|
||||
- name: ROBOCO_DATABASE_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: roboco-secrets
|
||||
key: postgres-user
|
||||
- name: ROBOCO_DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: roboco-secrets
|
||||
key: postgres-password
|
||||
- name: ROBOCO_DATABASE_NAME
|
||||
value: roboco
|
||||
# Redis
|
||||
- name: ROBOCO_REDIS_HOST
|
||||
value: redis
|
||||
- name: ROBOCO_REDIS_PORT
|
||||
value: "6379"
|
||||
# API
|
||||
- name: ROBOCO_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: ROBOCO_PORT
|
||||
value: "8000"
|
||||
- name: ROBOCO_ENVIRONMENT
|
||||
value: production
|
||||
# LLM
|
||||
- name: ROBOCO_ANTHROPIC_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: roboco-secrets
|
||||
key: anthropic-api-key
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
@@ -1,36 +0,0 @@
|
||||
# RoboCo API Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: roboco-api
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco-api
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
name: http
|
||||
selector:
|
||||
app: roboco-api
|
||||
---
|
||||
# NodePort for external access (can be replaced with Ingress)
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: roboco-api-external
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco-api
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
nodePort: 30080
|
||||
name: http
|
||||
selector:
|
||||
app: roboco-api
|
||||
@@ -1,41 +0,0 @@
|
||||
# Base Kustomization for RoboCo
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: roboco
|
||||
|
||||
resources:
|
||||
# Namespace
|
||||
- namespace/namespace.yaml
|
||||
# Storage
|
||||
- storage/storageclass.yaml
|
||||
# PostgreSQL
|
||||
- postgres/statefulset.yaml
|
||||
- postgres/service.yaml
|
||||
# Redis
|
||||
- redis/deployment.yaml
|
||||
- redis/service.yaml
|
||||
- redis/pvc.yaml
|
||||
# API
|
||||
- api/deployment.yaml
|
||||
- api/service.yaml
|
||||
# Orchestrator
|
||||
- orchestrator/serviceaccount.yaml
|
||||
- orchestrator/role.yaml
|
||||
- orchestrator/rolebinding.yaml
|
||||
- orchestrator/deployment.yaml
|
||||
- orchestrator/service.yaml
|
||||
|
||||
# Common labels applied to all resources
|
||||
commonLabels:
|
||||
app.kubernetes.io/part-of: roboco
|
||||
app.kubernetes.io/managed-by: kustomize
|
||||
|
||||
# Image configurations (can be overridden in overlays)
|
||||
images:
|
||||
- name: ghcr.io/renzof/roboco-api
|
||||
newTag: latest
|
||||
- name: ghcr.io/renzof/roboco-orchestrator
|
||||
newTag: latest
|
||||
- name: ghcr.io/renzof/roboco-agent
|
||||
newTag: latest
|
||||
@@ -1,9 +0,0 @@
|
||||
# RoboCo Namespace
|
||||
# All RoboCo resources are deployed to this namespace
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco
|
||||
app.kubernetes.io/component: namespace
|
||||
@@ -1,110 +0,0 @@
|
||||
# RoboCo Orchestrator Deployment
|
||||
# Manages agent lifecycle and task dispatching
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: roboco-orchestrator
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco-orchestrator
|
||||
app.kubernetes.io/component: orchestrator
|
||||
spec:
|
||||
replicas: 1 # Single instance for now (state coordination needed for HA)
|
||||
selector:
|
||||
matchLabels:
|
||||
app: roboco-orchestrator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: roboco-orchestrator
|
||||
app.kubernetes.io/name: roboco-orchestrator
|
||||
spec:
|
||||
serviceAccountName: roboco-orchestrator
|
||||
containers:
|
||||
- name: orchestrator
|
||||
image: ghcr.io/renzof/roboco-orchestrator:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
name: http
|
||||
env:
|
||||
# Database
|
||||
- name: ROBOCO_DATABASE_HOST
|
||||
value: postgres
|
||||
- name: ROBOCO_DATABASE_PORT
|
||||
value: "5432"
|
||||
- name: ROBOCO_DATABASE_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: roboco-secrets
|
||||
key: postgres-user
|
||||
- name: ROBOCO_DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: roboco-secrets
|
||||
key: postgres-password
|
||||
- name: ROBOCO_DATABASE_NAME
|
||||
value: roboco
|
||||
# Redis
|
||||
- name: ROBOCO_REDIS_HOST
|
||||
value: redis
|
||||
- name: ROBOCO_REDIS_PORT
|
||||
value: "6379"
|
||||
# API
|
||||
- name: ROBOCO_HOST
|
||||
value: "0.0.0.0"
|
||||
- name: ROBOCO_PORT
|
||||
value: "8000"
|
||||
- name: ROBOCO_API_URL
|
||||
value: "http://roboco-api:8000"
|
||||
- name: ROBOCO_ENVIRONMENT
|
||||
value: production
|
||||
# K8s settings
|
||||
- name: ROBOCO_K8S_ENABLED
|
||||
value: "true"
|
||||
- name: ROBOCO_K8S_NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: ROBOCO_K8S_AGENT_IMAGE
|
||||
value: "ghcr.io/renzof/roboco-agent"
|
||||
# LLM
|
||||
- name: ROBOCO_ANTHROPIC_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: roboco-secrets
|
||||
key: anthropic-api-key
|
||||
volumeMounts:
|
||||
- name: blueprints
|
||||
mountPath: /app/agents/blueprints
|
||||
readOnly: true
|
||||
- name: claude-auth
|
||||
mountPath: /root/.claude
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
volumes:
|
||||
- name: blueprints
|
||||
configMap:
|
||||
name: roboco-blueprints
|
||||
- name: claude-auth
|
||||
secret:
|
||||
secretName: claude-auth
|
||||
optional: true
|
||||
@@ -1,31 +0,0 @@
|
||||
# Orchestrator Role
|
||||
# Permissions for spawning and managing agent Jobs
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: roboco-orchestrator
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco-orchestrator
|
||||
app.kubernetes.io/component: orchestrator
|
||||
rules:
|
||||
# Create/manage agent Jobs
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["create", "delete", "get", "list", "watch", "patch"]
|
||||
# Watch pods for job status
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
# Read pod logs for debugging
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
# Manage ConfigMaps for agent configs (MCP configs, prompts)
|
||||
- apiGroups: [""]
|
||||
resources: ["configmaps"]
|
||||
verbs: ["create", "delete", "get", "list", "update", "patch"]
|
||||
# Read secrets for agent environment (API keys)
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get"]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Orchestrator RoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: roboco-orchestrator
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco-orchestrator
|
||||
app.kubernetes.io/component: orchestrator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: roboco-orchestrator
|
||||
namespace: roboco
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: roboco-orchestrator
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -1,17 +0,0 @@
|
||||
# Orchestrator Service (internal only)
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: roboco-orchestrator
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco-orchestrator
|
||||
app.kubernetes.io/component: orchestrator
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
name: http
|
||||
selector:
|
||||
app: roboco-orchestrator
|
||||
@@ -1,10 +0,0 @@
|
||||
# Orchestrator ServiceAccount
|
||||
# Used by the orchestrator to interact with K8s API for spawning agent Jobs
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: roboco-orchestrator
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: roboco-orchestrator
|
||||
app.kubernetes.io/component: orchestrator
|
||||
@@ -1,17 +0,0 @@
|
||||
# PostgreSQL Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: postgres
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
name: postgres
|
||||
selector:
|
||||
app: postgres
|
||||
@@ -1,84 +0,0 @@
|
||||
# PostgreSQL StatefulSet with pgvector for RAG
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: postgres
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
serviceName: postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
app.kubernetes.io/name: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: pgvector/pgvector:pg16
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: postgres
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: roboco-secrets
|
||||
key: postgres-user
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: roboco-secrets
|
||||
key: postgres-password
|
||||
- name: POSTGRES_DB
|
||||
value: roboco
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- roboco
|
||||
- -d
|
||||
- roboco
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- roboco
|
||||
- -d
|
||||
- roboco
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgres-data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: nfs-roboco
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
@@ -1,64 +0,0 @@
|
||||
# Redis Deployment for cache, sessions, and event bus
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: redis
|
||||
app.kubernetes.io/component: cache
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
app.kubernetes.io/name: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:8-alpine
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --maxmemory
|
||||
- "256mb"
|
||||
- --maxmemory-policy
|
||||
- allkeys-lru
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: redis
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- redis-cli
|
||||
- ping
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- redis-cli
|
||||
- ping
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
volumes:
|
||||
- name: redis-data
|
||||
persistentVolumeClaim:
|
||||
claimName: redis-data
|
||||
@@ -1,16 +0,0 @@
|
||||
# Redis Persistent Volume Claim
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: redis-data
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: redis
|
||||
app.kubernetes.io/component: cache
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: nfs-roboco
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -1,17 +0,0 @@
|
||||
# Redis Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: roboco
|
||||
labels:
|
||||
app.kubernetes.io/name: redis
|
||||
app.kubernetes.io/component: cache
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
name: redis
|
||||
selector:
|
||||
app: redis
|
||||
@@ -1,19 +0,0 @@
|
||||
# NFS StorageClass for RoboCo
|
||||
# Uses NFS CSI driver for persistent storage on UGREEN NAS
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: nfs-roboco
|
||||
provisioner: nfs.csi.k8s.io
|
||||
parameters:
|
||||
# NFS server address (UGREEN NAS)
|
||||
server: 192.168.50.111
|
||||
# NFS share path
|
||||
share: /volume1/roboco/k8s-data
|
||||
reclaimPolicy: Retain
|
||||
volumeBindingMode: Immediate
|
||||
mountOptions:
|
||||
- nfsvers=4.1
|
||||
- hard
|
||||
- timeo=600
|
||||
- retrans=2
|
||||
@@ -1,56 +0,0 @@
|
||||
# Development Overlay for RoboCo
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: roboco
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
# Development-specific patches
|
||||
patches:
|
||||
# Single replica for development
|
||||
- patch: |-
|
||||
- op: replace
|
||||
path: /spec/replicas
|
||||
value: 1
|
||||
target:
|
||||
kind: Deployment
|
||||
name: roboco-api
|
||||
# Lower resource limits for development
|
||||
- patch: |-
|
||||
- op: replace
|
||||
path: /spec/template/spec/containers/0/resources/limits/memory
|
||||
value: "512Mi"
|
||||
- op: replace
|
||||
path: /spec/template/spec/containers/0/resources/limits/cpu
|
||||
value: "250m"
|
||||
target:
|
||||
kind: Deployment
|
||||
name: roboco-api
|
||||
# Development PostgreSQL resources
|
||||
- patch: |-
|
||||
- op: replace
|
||||
path: /spec/template/spec/containers/0/resources/limits/memory
|
||||
value: "1Gi"
|
||||
- op: replace
|
||||
path: /spec/template/spec/containers/0/resources/limits/cpu
|
||||
value: "500m"
|
||||
target:
|
||||
kind: StatefulSet
|
||||
name: postgres
|
||||
|
||||
# Image tags for development (use latest)
|
||||
images:
|
||||
- name: ghcr.io/renzof/roboco-api
|
||||
newTag: latest
|
||||
- name: ghcr.io/renzof/roboco-orchestrator
|
||||
newTag: latest
|
||||
|
||||
# Config overrides
|
||||
configMapGenerator:
|
||||
- name: roboco-config
|
||||
literals:
|
||||
- ENVIRONMENT=development
|
||||
- LOG_LEVEL=DEBUG
|
||||
- DEBUG=true
|
||||
@@ -1,58 +0,0 @@
|
||||
# Production Overlay for RoboCo
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: roboco
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
# Sealed secrets for production
|
||||
# - sealed-secrets.yaml
|
||||
|
||||
# Production-specific patches
|
||||
patches:
|
||||
# Increase API replicas
|
||||
- patch: |-
|
||||
- op: replace
|
||||
path: /spec/replicas
|
||||
value: 3
|
||||
target:
|
||||
kind: Deployment
|
||||
name: roboco-api
|
||||
# Increase resource limits for production
|
||||
- patch: |-
|
||||
- op: replace
|
||||
path: /spec/template/spec/containers/0/resources/limits/memory
|
||||
value: "2Gi"
|
||||
- op: replace
|
||||
path: /spec/template/spec/containers/0/resources/limits/cpu
|
||||
value: "1000m"
|
||||
target:
|
||||
kind: Deployment
|
||||
name: roboco-api
|
||||
# Production PostgreSQL resources
|
||||
- patch: |-
|
||||
- op: replace
|
||||
path: /spec/template/spec/containers/0/resources/limits/memory
|
||||
value: "4Gi"
|
||||
- op: replace
|
||||
path: /spec/template/spec/containers/0/resources/limits/cpu
|
||||
value: "2000m"
|
||||
target:
|
||||
kind: StatefulSet
|
||||
name: postgres
|
||||
|
||||
# Image tags for production
|
||||
images:
|
||||
- name: ghcr.io/renzof/roboco-api
|
||||
newTag: stable
|
||||
- name: ghcr.io/renzof/roboco-orchestrator
|
||||
newTag: stable
|
||||
|
||||
# Config overrides
|
||||
configMapGenerator:
|
||||
- name: roboco-config
|
||||
literals:
|
||||
- ENVIRONMENT=production
|
||||
- LOG_LEVEL=INFO
|
||||
- DEBUG=false
|
||||
@@ -48,10 +48,6 @@ dependencies = [
|
||||
"passlib[bcrypt]", # Password hashing
|
||||
"tenacity", # Retry logic
|
||||
"structlog", # Structured logging
|
||||
|
||||
# Container Orchestration
|
||||
"kubernetes", # K8s API client for agent Jobs
|
||||
"sse-starlette", # Server-Sent Events for A2A streaming
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -142,7 +138,6 @@ select = [
|
||||
"roboco/mcp/**/*.py" = ["PLC0415"]
|
||||
"roboco/services/*.py" = ["PLC0415"]
|
||||
"roboco/api/routes/*.py" = ["PLC0415"]
|
||||
"roboco/runtime/*.py" = ["PLC0415"]
|
||||
|
||||
# =============================================================================
|
||||
# MyPy Configuration
|
||||
@@ -172,8 +167,6 @@ module = [
|
||||
"tiktoken.*",
|
||||
"piragi.*",
|
||||
"toon.*",
|
||||
"kubernetes.*",
|
||||
"sse_starlette.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from roboco.api.middleware import setup_middleware
|
||||
from roboco.api.routes.a2a import router as a2a_router
|
||||
from roboco.api.routes.a2a import wellknown_router as a2a_wellknown_router
|
||||
from roboco.api.routes.agents import router as agents_router
|
||||
from roboco.api.routes.channels import router as channels_router
|
||||
from roboco.api.routes.dashboard import router as dashboard_router
|
||||
@@ -148,10 +146,6 @@ def create_app() -> FastAPI:
|
||||
# Health check
|
||||
app.include_router(health_router, tags=["Health"])
|
||||
|
||||
# A2A Protocol: Well-known endpoints at root level
|
||||
# (/.well-known/agent.json, /agents/{id}/.well-known/agent.json)
|
||||
app.include_router(a2a_wellknown_router, tags=["A2A Protocol"])
|
||||
|
||||
# API v1
|
||||
api_prefix = "/api/v1"
|
||||
|
||||
@@ -237,13 +231,6 @@ def create_app() -> FastAPI:
|
||||
tags=["Orchestrator"],
|
||||
)
|
||||
|
||||
# A2A Protocol: API endpoints
|
||||
app.include_router(
|
||||
a2a_router,
|
||||
prefix=f"{api_prefix}/a2a",
|
||||
tags=["A2A Protocol"],
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# WebSocket
|
||||
# ==========================================================================
|
||||
|
||||
@@ -1,816 +0,0 @@
|
||||
"""
|
||||
A2A (Agent-to-Agent) Protocol Routes
|
||||
|
||||
Implements Google's A2A protocol for agent interoperability.
|
||||
See: https://a2a-protocol.org/latest/specification/
|
||||
|
||||
Endpoints:
|
||||
- GET /.well-known/agent.json: System Agent Card
|
||||
- GET /agents/{agent_id}/.well-known/agent.json: Per-agent Agent Card
|
||||
- POST /api/v1/a2a/message/send: Send message and create/update task
|
||||
- POST /api/v1/a2a/message/stream: Send message with SSE streaming
|
||||
- GET /api/v1/a2a/tasks/{task_id}: Get task state
|
||||
- GET /api/v1/a2a/tasks: List tasks
|
||||
- POST /api/v1/a2a/tasks/{task_id}/cancel: Cancel task
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sse_starlette import EventSourceResponse
|
||||
|
||||
from roboco.api.deps import DbSession
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.models.a2a import (
|
||||
A2AArtifact,
|
||||
A2AMessage,
|
||||
A2ATask,
|
||||
A2ATaskStatus,
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentProvider,
|
||||
AgentSkill,
|
||||
CancelTaskRequest,
|
||||
ListTasksResponse,
|
||||
SecurityScheme,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
TextPart,
|
||||
task_status_to_a2a_state,
|
||||
)
|
||||
from roboco.models.base import TaskStatus
|
||||
|
||||
# Router for A2A API endpoints (mounted at /api/v1/a2a)
|
||||
router = APIRouter()
|
||||
|
||||
# Router for well-known endpoints (mounted at root level)
|
||||
wellknown_router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_service_endpoint() -> str:
|
||||
"""Build service endpoint URL from settings."""
|
||||
connect_host = "127.0.0.1" if settings.host == "0.0.0.0" else settings.host
|
||||
return f"http://{connect_host}:{settings.port}"
|
||||
|
||||
|
||||
def _build_system_agent_card() -> AgentCard:
|
||||
"""Build the system-level Agent Card for RoboCo."""
|
||||
return AgentCard(
|
||||
id="roboco-system",
|
||||
name="RoboCo System",
|
||||
description=(
|
||||
"RoboCo is an AI Agentic Company - a virtual organization of AI agents "
|
||||
"designed to operate as a complete software development workforce."
|
||||
),
|
||||
provider=AgentProvider(
|
||||
organization="RoboCo",
|
||||
url="https://github.com/roboco",
|
||||
),
|
||||
protocol_version="1.0",
|
||||
service_endpoint=f"{_get_service_endpoint()}/api/v1/a2a",
|
||||
version=settings.app_version,
|
||||
capabilities=AgentCapabilities(
|
||||
streaming=True, # We support SSE
|
||||
push_notifications=False, # Not implemented yet
|
||||
state_transition_history=True, # We track task history
|
||||
),
|
||||
default_input_modes=["text/plain", "application/json"],
|
||||
default_output_modes=["text/plain", "application/json"],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="software-development",
|
||||
name="Software Development",
|
||||
description="Full-stack software development with AI agents",
|
||||
tags=["development", "coding", "qa", "documentation"],
|
||||
),
|
||||
AgentSkill(
|
||||
id="task-management",
|
||||
name="Task Management",
|
||||
description="Create and manage development tasks",
|
||||
tags=["tasks", "kanban", "planning"],
|
||||
),
|
||||
AgentSkill(
|
||||
id="code-review",
|
||||
name="Code Review",
|
||||
description="Review and quality assurance of code",
|
||||
tags=["qa", "review", "testing"],
|
||||
),
|
||||
],
|
||||
documentation_url="https://github.com/roboco/docs",
|
||||
security_schemes={
|
||||
"bearerAuth": SecurityScheme(type="http", scheme="bearer"),
|
||||
},
|
||||
security=[{"bearerAuth": []}],
|
||||
)
|
||||
|
||||
|
||||
async def _build_agent_card(agent: AgentTable) -> AgentCard:
|
||||
"""Build an Agent Card for a specific agent."""
|
||||
agent_id = str(agent.id)
|
||||
agent_slug = agent.slug
|
||||
|
||||
# Map role to skills
|
||||
role_skills: dict[str, list[AgentSkill]] = {
|
||||
"developer": [
|
||||
AgentSkill(
|
||||
id="coding",
|
||||
name="Code Development",
|
||||
description="Write and implement code",
|
||||
tags=["development", "coding"],
|
||||
),
|
||||
AgentSkill(
|
||||
id="debugging",
|
||||
name="Debugging",
|
||||
description="Debug and fix code issues",
|
||||
tags=["debugging", "troubleshooting"],
|
||||
),
|
||||
],
|
||||
"qa": [
|
||||
AgentSkill(
|
||||
id="testing",
|
||||
name="Testing",
|
||||
description="Test code and verify quality",
|
||||
tags=["qa", "testing"],
|
||||
),
|
||||
AgentSkill(
|
||||
id="review",
|
||||
name="Code Review",
|
||||
description="Review code for quality and issues",
|
||||
tags=["qa", "review"],
|
||||
),
|
||||
],
|
||||
"documenter": [
|
||||
AgentSkill(
|
||||
id="documentation",
|
||||
name="Documentation",
|
||||
description="Write technical documentation",
|
||||
tags=["documentation", "writing"],
|
||||
),
|
||||
],
|
||||
"cell_pm": [
|
||||
AgentSkill(
|
||||
id="coordination",
|
||||
name="Task Coordination",
|
||||
description="Coordinate tasks within the cell",
|
||||
tags=["management", "coordination"],
|
||||
),
|
||||
],
|
||||
"main_pm": [
|
||||
AgentSkill(
|
||||
id="planning",
|
||||
name="Project Planning",
|
||||
description="Plan and coordinate across cells",
|
||||
tags=["management", "planning"],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
skills = role_skills.get(agent.role, [])
|
||||
|
||||
return AgentCard(
|
||||
id=agent_id,
|
||||
name=agent.name,
|
||||
description=f"{agent.name} - {agent.role} agent in RoboCo",
|
||||
provider=AgentProvider(
|
||||
organization="RoboCo",
|
||||
url="https://github.com/roboco",
|
||||
),
|
||||
protocol_version="1.0",
|
||||
service_endpoint=f"{_get_service_endpoint()}/api/v1/a2a",
|
||||
version=settings.app_version,
|
||||
capabilities=AgentCapabilities(
|
||||
streaming=True,
|
||||
push_notifications=False,
|
||||
state_transition_history=True,
|
||||
),
|
||||
default_input_modes=["text/plain", "application/json"],
|
||||
default_output_modes=["text/plain", "application/json"],
|
||||
skills=skills,
|
||||
metadata={
|
||||
"slug": agent_slug,
|
||||
"role": agent.role,
|
||||
"team": agent.team,
|
||||
},
|
||||
security_schemes={
|
||||
"bearerAuth": SecurityScheme(type="http", scheme="bearer"),
|
||||
},
|
||||
security=[{"bearerAuth": []}],
|
||||
)
|
||||
|
||||
|
||||
def _task_to_a2a(task: TaskTable) -> A2ATask:
|
||||
"""Convert a RoboCo TaskTable to A2A Task."""
|
||||
task_id = str(task.id)
|
||||
|
||||
# Build status - get status value as string
|
||||
if hasattr(task.status, "value"):
|
||||
status_value = task.status.value
|
||||
else:
|
||||
status_value = str(task.status)
|
||||
a2a_state = task_status_to_a2a_state(status_value)
|
||||
status_message = None
|
||||
if task.dev_notes:
|
||||
status_message = A2AMessage(
|
||||
role="agent",
|
||||
parts=[TextPart(text=task.dev_notes)],
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
a2a_status = A2ATaskStatus(
|
||||
state=a2a_state,
|
||||
message=status_message,
|
||||
timestamp=task.updated_at or task.created_at,
|
||||
)
|
||||
|
||||
# Build artifacts from task outputs (if any)
|
||||
artifacts: list[A2AArtifact] = []
|
||||
|
||||
# Build metadata from task fields
|
||||
metadata: dict[str, Any] = {
|
||||
"roboco_status": status_value,
|
||||
"priority": task.priority,
|
||||
"team": task.team,
|
||||
}
|
||||
if task.assigned_to:
|
||||
metadata["assigned_to"] = str(task.assigned_to)
|
||||
if task.parent_task_id:
|
||||
metadata["parent_task_id"] = str(task.parent_task_id)
|
||||
|
||||
return A2ATask(
|
||||
id=task_id,
|
||||
context_id=task_id, # Use task_id as context_id
|
||||
status=a2a_status,
|
||||
artifacts=artifacts,
|
||||
history=[], # Would need to load from message history
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WELL-KNOWN ENDPOINTS (mounted at root)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@wellknown_router.get("/.well-known/agent.json")
|
||||
async def get_system_agent_card() -> JSONResponse:
|
||||
"""
|
||||
Get the system-level Agent Card.
|
||||
|
||||
Per A2A specification, returns the agent's public identity and capabilities.
|
||||
"""
|
||||
card = _build_system_agent_card()
|
||||
return JSONResponse(
|
||||
content=card.model_dump(by_alias=True, exclude_none=True),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@wellknown_router.get("/agents/{agent_id}/.well-known/agent.json")
|
||||
async def get_agent_card(
|
||||
agent_id: str,
|
||||
db: DbSession,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Get Agent Card for a specific agent.
|
||||
|
||||
Accepts either a UUID string or agent slug (e.g., "be-dev-1").
|
||||
"""
|
||||
# Try to parse as UUID first
|
||||
try:
|
||||
uuid = UUID(agent_id)
|
||||
result = await db.execute(select(AgentTable).where(AgentTable.id == uuid))
|
||||
except ValueError:
|
||||
# Not a UUID, try slug lookup
|
||||
result = await db.execute(
|
||||
select(AgentTable).where(AgentTable.slug == agent_id)
|
||||
)
|
||||
|
||||
agent = result.scalar_one_or_none()
|
||||
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent not found: {agent_id}",
|
||||
)
|
||||
|
||||
card = await _build_agent_card(agent)
|
||||
return JSONResponse(
|
||||
content=card.model_dump(by_alias=True, exclude_none=True),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# A2A API ENDPOINTS (mounted at /api/v1/a2a)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/message/send")
|
||||
async def send_message(
|
||||
request: SendMessageRequest,
|
||||
db: DbSession,
|
||||
) -> SendMessageResponse:
|
||||
"""
|
||||
Send a message to create or update an A2A task.
|
||||
|
||||
This is the primary A2A interaction endpoint. Messages sent here
|
||||
create new tasks or continue existing conversations.
|
||||
"""
|
||||
message = request.message
|
||||
|
||||
# Extract task_id from message if present
|
||||
task_id_str = message.task_id
|
||||
|
||||
if task_id_str:
|
||||
# Update existing task
|
||||
try:
|
||||
task_uuid = UUID(task_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid task ID: {task_id_str}",
|
||||
) from None
|
||||
|
||||
result = await db.execute(
|
||||
select(TaskTable).where(TaskTable.id == task_uuid)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task not found: {task_id_str}",
|
||||
)
|
||||
|
||||
# Update task dev_notes with new message
|
||||
text_parts = [p for p in message.parts if p.type == "text"]
|
||||
if text_parts:
|
||||
text_part = text_parts[0]
|
||||
if hasattr(text_part, "text"):
|
||||
new_text = text_part.text
|
||||
if task.dev_notes:
|
||||
task.dev_notes = f"{task.dev_notes}\n\n{new_text}"
|
||||
else:
|
||||
task.dev_notes = new_text
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
else:
|
||||
# Create new task from message
|
||||
# Note: For proper implementation, this should use TaskService
|
||||
# which handles session creation and proper agent context
|
||||
text_parts = [p for p in message.parts if p.type == "text"]
|
||||
title = "A2A Task"
|
||||
description = ""
|
||||
|
||||
if text_parts:
|
||||
text_part = text_parts[0]
|
||||
if hasattr(text_part, "text"):
|
||||
first_text = text_part.text
|
||||
# Use first line as title, rest as description
|
||||
lines = first_text.split("\n", 1)
|
||||
title = lines[0][:200] # Truncate title
|
||||
description = lines[1] if len(lines) > 1 else first_text
|
||||
|
||||
# Get a system agent to use as creator
|
||||
# In production, this should come from authenticated context
|
||||
result = await db.execute(
|
||||
select(AgentTable).where(AgentTable.role == "main_pm").limit(1)
|
||||
)
|
||||
system_agent = result.scalar_one_or_none()
|
||||
|
||||
if system_agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No system agent available to create tasks",
|
||||
)
|
||||
|
||||
from roboco.models.base import Team
|
||||
|
||||
task = TaskTable(
|
||||
title=title,
|
||||
description=description,
|
||||
acceptance_criteria=["Task completed as specified"],
|
||||
status=TaskStatus.PENDING,
|
||||
priority=5,
|
||||
team=Team.BACKEND,
|
||||
created_by=system_agent.id,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return SendMessageResponse(task=_task_to_a2a(task))
|
||||
|
||||
|
||||
@router.post("/message/stream")
|
||||
async def send_message_stream(
|
||||
request: Request,
|
||||
body: SendMessageRequest,
|
||||
db: DbSession,
|
||||
) -> EventSourceResponse:
|
||||
"""
|
||||
Send a message with SSE streaming response.
|
||||
|
||||
Per A2A specification, this endpoint streams task updates in real-time
|
||||
as the task progresses through its lifecycle.
|
||||
|
||||
Returns Server-Sent Events with task state updates.
|
||||
"""
|
||||
message = body.message
|
||||
|
||||
async def generate_task_events() -> AsyncGenerator[dict[str, Any]]:
|
||||
"""Generate SSE events for task lifecycle."""
|
||||
# Create or get task
|
||||
task_id_str = message.task_id
|
||||
|
||||
if task_id_str:
|
||||
# Get existing task
|
||||
try:
|
||||
task_uuid = UUID(task_id_str)
|
||||
except ValueError:
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": f"Invalid task ID: {task_id_str}",
|
||||
}
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(TaskTable).where(TaskTable.id == task_uuid)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": f"Task not found: {task_id_str}",
|
||||
}
|
||||
return
|
||||
|
||||
# Send initial task state
|
||||
a2a_task = _task_to_a2a(task)
|
||||
yield {
|
||||
"event": "task.status",
|
||||
"id": str(task.id),
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
|
||||
# Stream updates while task is in progress
|
||||
poll_count = 0
|
||||
max_polls = 60 # Poll for up to 60 iterations (5 minutes at 5s interval)
|
||||
|
||||
while poll_count < max_polls:
|
||||
# Check for client disconnect
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
await asyncio.sleep(5) # Poll interval
|
||||
poll_count += 1
|
||||
|
||||
# Refresh task state
|
||||
await db.refresh(task)
|
||||
|
||||
# Get current status
|
||||
if hasattr(task.status, "value"):
|
||||
current_status = task.status.value
|
||||
else:
|
||||
current_status = str(task.status)
|
||||
|
||||
# Send update
|
||||
a2a_task = _task_to_a2a(task)
|
||||
yield {
|
||||
"event": "task.status",
|
||||
"id": f"{task.id}-{poll_count}",
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
|
||||
# Stop if task is in terminal state
|
||||
if current_status in ["completed", "cancelled"]:
|
||||
yield {
|
||||
"event": "task.complete",
|
||||
"id": f"{task.id}-final",
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
break
|
||||
|
||||
else:
|
||||
# New task - send creation event
|
||||
yield {
|
||||
"event": "task.creating",
|
||||
"data": "Creating new task from message...",
|
||||
}
|
||||
|
||||
# Note: Full task creation logic would go here
|
||||
# For now, send a placeholder
|
||||
yield {
|
||||
"event": "error",
|
||||
"data": "Task creation via streaming not yet implemented",
|
||||
}
|
||||
|
||||
return EventSourceResponse(
|
||||
generate_task_events(),
|
||||
ping=15, # Keep connection alive every 15 seconds
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/subscribe")
|
||||
async def subscribe_to_task(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
db: DbSession,
|
||||
) -> EventSourceResponse:
|
||||
"""
|
||||
Subscribe to task updates via SSE.
|
||||
|
||||
Opens a persistent connection that streams task state changes
|
||||
until the task reaches a terminal state or client disconnects.
|
||||
"""
|
||||
try:
|
||||
task_uuid = UUID(task_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid task ID: {task_id}",
|
||||
) from None
|
||||
|
||||
result = await db.execute(select(TaskTable).where(TaskTable.id == task_uuid))
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task not found: {task_id}",
|
||||
)
|
||||
|
||||
async def generate_updates() -> AsyncGenerator[dict[str, Any]]:
|
||||
"""Stream task updates."""
|
||||
poll_count = 0
|
||||
max_polls = 720 # 1 hour at 5s interval
|
||||
last_status = None
|
||||
|
||||
while poll_count < max_polls:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
# Refresh task state from DB
|
||||
await db.refresh(task)
|
||||
|
||||
# Get current status
|
||||
if hasattr(task.status, "value"):
|
||||
current_status = task.status.value
|
||||
else:
|
||||
current_status = str(task.status)
|
||||
|
||||
# Only send update if status changed
|
||||
if current_status != last_status:
|
||||
a2a_task = _task_to_a2a(task)
|
||||
yield {
|
||||
"event": "task.status",
|
||||
"id": f"{task_id}-{poll_count}",
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
last_status = current_status
|
||||
|
||||
# Stop if terminal
|
||||
if current_status in ["completed", "cancelled"]:
|
||||
yield {
|
||||
"event": "task.complete",
|
||||
"id": f"{task_id}-final",
|
||||
"data": a2a_task.model_dump_json(by_alias=True),
|
||||
}
|
||||
break
|
||||
|
||||
await asyncio.sleep(5)
|
||||
poll_count += 1
|
||||
|
||||
return EventSourceResponse(
|
||||
generate_updates(),
|
||||
ping=15,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
db: DbSession,
|
||||
_history_length: int | None = Query(
|
||||
None, alias="historyLength", description="Number of history turns to include"
|
||||
),
|
||||
) -> A2ATask:
|
||||
"""
|
||||
Get the state of an A2A task.
|
||||
|
||||
Returns task details including status, artifacts, and optionally history.
|
||||
"""
|
||||
try:
|
||||
task_uuid = UUID(task_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid task ID: {task_id}",
|
||||
) from None
|
||||
|
||||
result = await db.execute(select(TaskTable).where(TaskTable.id == task_uuid))
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task not found: {task_id}",
|
||||
)
|
||||
|
||||
return _task_to_a2a(task)
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(
|
||||
db: DbSession,
|
||||
page_size: int = Query(20, alias="pageSize", ge=1, le=100),
|
||||
page_token: str | None = Query(None, alias="pageToken"),
|
||||
_filter_str: str | None = Query(None, alias="filter"),
|
||||
order_by: str | None = Query(None, alias="orderBy"),
|
||||
) -> ListTasksResponse:
|
||||
"""
|
||||
List A2A tasks with pagination.
|
||||
|
||||
Supports filtering and pagination via page tokens.
|
||||
"""
|
||||
query = select(TaskTable)
|
||||
|
||||
# Apply ordering
|
||||
if order_by:
|
||||
if order_by == "created_at desc":
|
||||
query = query.order_by(TaskTable.created_at.desc())
|
||||
elif order_by == "created_at asc":
|
||||
query = query.order_by(TaskTable.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(TaskTable.created_at.desc())
|
||||
else:
|
||||
query = query.order_by(TaskTable.created_at.desc())
|
||||
|
||||
# Handle pagination
|
||||
offset = 0
|
||||
if page_token:
|
||||
with contextlib.suppress(ValueError):
|
||||
offset = int(page_token)
|
||||
|
||||
query = query.offset(offset).limit(page_size + 1)
|
||||
|
||||
result = await db.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(tasks) > page_size
|
||||
if has_more:
|
||||
tasks = tasks[:page_size]
|
||||
|
||||
next_page_token = str(offset + page_size) if has_more else None
|
||||
|
||||
return ListTasksResponse(
|
||||
tasks=[_task_to_a2a(t) for t in tasks],
|
||||
next_page_token=next_page_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel")
|
||||
async def cancel_task(
|
||||
task_id: str,
|
||||
db: DbSession,
|
||||
request: CancelTaskRequest | None = None,
|
||||
) -> A2ATask:
|
||||
"""
|
||||
Cancel an A2A task.
|
||||
|
||||
Transitions the task to cancelled state.
|
||||
"""
|
||||
try:
|
||||
task_uuid = UUID(task_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid task ID: {task_id}",
|
||||
) from None
|
||||
|
||||
result = await db.execute(select(TaskTable).where(TaskTable.id == task_uuid))
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task not found: {task_id}",
|
||||
)
|
||||
|
||||
# Check if task can be cancelled
|
||||
if hasattr(task.status, "value"):
|
||||
status_value = task.status.value
|
||||
else:
|
||||
status_value = str(task.status)
|
||||
terminal_states = ["completed", "cancelled"]
|
||||
if status_value in terminal_states:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Task already in terminal state: {status_value}",
|
||||
)
|
||||
|
||||
# Cancel the task
|
||||
task.status = TaskStatus.CANCELLED
|
||||
if request and request.reason:
|
||||
reason_text = f"Cancellation reason: {request.reason}"
|
||||
if task.dev_notes:
|
||||
task.dev_notes = f"{task.dev_notes}\n\n{reason_text}"
|
||||
else:
|
||||
task.dev_notes = reason_text
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_a2a(task)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AGENT DISCOVERY ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def discover_agents(
|
||||
db: DbSession,
|
||||
role: str | None = Query(None, description="Filter by agent role"),
|
||||
team: str | None = Query(None, description="Filter by team"),
|
||||
skill: str | None = Query(None, description="Filter by skill tag"),
|
||||
) -> list[AgentCard]:
|
||||
"""
|
||||
Discover agents matching criteria.
|
||||
|
||||
Returns a list of AgentCards for agents that match the specified filters.
|
||||
This enables A2A clients to find agents with specific capabilities.
|
||||
"""
|
||||
query = select(AgentTable)
|
||||
|
||||
if role:
|
||||
query = query.where(AgentTable.role == role)
|
||||
if team:
|
||||
query = query.where(AgentTable.team == team)
|
||||
|
||||
result = await db.execute(query)
|
||||
agents = result.scalars().all()
|
||||
|
||||
# Build cards for all matching agents
|
||||
cards = []
|
||||
for agent in agents:
|
||||
card = await _build_agent_card(agent)
|
||||
cards.append(card)
|
||||
|
||||
# Filter by skill tag if specified
|
||||
if skill:
|
||||
cards = [
|
||||
card
|
||||
for card in cards
|
||||
if any(skill.lower() in tag.lower() for s in card.skills for tag in s.tags)
|
||||
]
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}/card")
|
||||
async def get_agent_card_by_id(
|
||||
agent_id: str,
|
||||
db: DbSession,
|
||||
) -> AgentCard:
|
||||
"""
|
||||
Get Agent Card for a specific agent by ID or slug.
|
||||
|
||||
Alternative to the /.well-known/agent.json endpoint for programmatic access.
|
||||
"""
|
||||
# Try to parse as UUID first
|
||||
try:
|
||||
uuid = UUID(agent_id)
|
||||
result = await db.execute(select(AgentTable).where(AgentTable.id == uuid))
|
||||
except ValueError:
|
||||
# Not a UUID, try slug lookup
|
||||
result = await db.execute(
|
||||
select(AgentTable).where(AgentTable.slug == agent_id)
|
||||
)
|
||||
|
||||
agent = result.scalar_one_or_none()
|
||||
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Agent not found: {agent_id}",
|
||||
)
|
||||
|
||||
return await _build_agent_card(agent)
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
WebSocket Event Bridge
|
||||
|
||||
Consumes events from Redis Streams and forwards them to WebSocket clients.
|
||||
This enables real-time updates to connected clients while maintaining
|
||||
durable message delivery through the stream.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from roboco.api.websocket import broadcast_notification, manager
|
||||
from roboco.events import Event, EventType, get_event_bus
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Handler for notification events
|
||||
async def _handle_notification_sent(event: Event) -> None:
|
||||
"""Handle NOTIFICATION_SENT events and forward to WebSocket."""
|
||||
data = event.data
|
||||
|
||||
notification_id_str = data.get("notification_id")
|
||||
recipient_id_str = data.get("recipient_id")
|
||||
notification_type = data.get("type", "unknown")
|
||||
subject = data.get("subject", "")
|
||||
priority = data.get("priority", "normal")
|
||||
|
||||
if not notification_id_str or not recipient_id_str:
|
||||
logger.warning(
|
||||
"Incomplete notification event",
|
||||
event_id=str(event.id),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
notification_id = UUID(notification_id_str)
|
||||
recipient_id = UUID(recipient_id_str)
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID in notification event", error=str(e))
|
||||
return
|
||||
|
||||
# Check if recipient has WebSocket connections
|
||||
connections = manager.notification_connections.get(recipient_id, set())
|
||||
if connections:
|
||||
await broadcast_notification(
|
||||
agent_ids=[recipient_id],
|
||||
notification_id=notification_id,
|
||||
notification_type=notification_type,
|
||||
subject=subject,
|
||||
priority=priority,
|
||||
)
|
||||
logger.debug(
|
||||
"Notification forwarded to WebSocket",
|
||||
notification_id=notification_id_str,
|
||||
recipient=recipient_id_str,
|
||||
connection_count=len(connections),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_session_event(event: Event) -> None:
|
||||
"""Handle session lifecycle events and forward to WebSocket."""
|
||||
data = event.data
|
||||
|
||||
session_id_str = data.get("session_id")
|
||||
if not session_id_str:
|
||||
return
|
||||
|
||||
try:
|
||||
session_id = UUID(session_id_str)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
connections = manager.session_connections.get(session_id, set())
|
||||
if not connections:
|
||||
return
|
||||
|
||||
# Forward event to session subscribers
|
||||
event_payload = {
|
||||
"type": f"session.{event.type.value.split('.')[-1]}",
|
||||
"session_id": session_id_str,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
await manager.broadcast_to_session(session_id, event_payload)
|
||||
logger.debug(
|
||||
"Session event forwarded to WebSocket",
|
||||
event_type=event.type.value,
|
||||
session_id=session_id_str,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_agent_event(event: Event) -> None:
|
||||
"""Handle agent lifecycle events and forward to WebSocket."""
|
||||
data = event.data
|
||||
|
||||
agent_id_str = data.get("agent_id") or event.source_agent
|
||||
if not agent_id_str:
|
||||
return
|
||||
|
||||
try:
|
||||
agent_id = UUID(agent_id_str)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
connections = manager.agent_connections.get(agent_id, set())
|
||||
if not connections:
|
||||
return
|
||||
|
||||
event_payload = {
|
||||
"type": f"agent.{event.type.value.split('.')[-1]}",
|
||||
"agent_id": agent_id_str,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
await manager.broadcast_to_agent_watchers(agent_id, event_payload)
|
||||
logger.debug(
|
||||
"Agent event forwarded to WebSocket",
|
||||
event_type=event.type.value,
|
||||
agent_id=agent_id_str,
|
||||
)
|
||||
|
||||
|
||||
def register_websocket_bridge_handlers() -> None:
|
||||
"""
|
||||
Register event handlers that forward events to WebSocket clients.
|
||||
|
||||
Call this during application startup after the event bus is initialized.
|
||||
"""
|
||||
bus = get_event_bus()
|
||||
|
||||
# Notification events -> WebSocket
|
||||
bus.subscribe(EventType.NOTIFICATION_SENT, _handle_notification_sent)
|
||||
bus.subscribe(EventType.NOTIFICATION_ACKED, _handle_notification_sent)
|
||||
|
||||
# Session events -> WebSocket
|
||||
bus.subscribe(EventType.SESSION_CREATED, _handle_session_event)
|
||||
bus.subscribe(EventType.SESSION_CLOSED, _handle_session_event)
|
||||
bus.subscribe(EventType.SESSION_TIMEOUT, _handle_session_event)
|
||||
|
||||
# Agent events -> WebSocket
|
||||
bus.subscribe(EventType.AGENT_SPAWNED, _handle_agent_event)
|
||||
bus.subscribe(EventType.AGENT_STOPPED, _handle_agent_event)
|
||||
bus.subscribe(EventType.AGENT_WAITING, _handle_agent_event)
|
||||
bus.subscribe(EventType.AGENT_RESUMED, _handle_agent_event)
|
||||
bus.subscribe(EventType.AGENT_ERROR, _handle_agent_event)
|
||||
|
||||
logger.info("WebSocket bridge handlers registered")
|
||||
|
||||
|
||||
async def start_websocket_bridge() -> None:
|
||||
"""
|
||||
Start the WebSocket bridge.
|
||||
|
||||
This registers handlers and ensures they're connected to the event stream.
|
||||
Should be called during application startup.
|
||||
"""
|
||||
register_websocket_bridge_handlers()
|
||||
logger.info("WebSocket bridge started")
|
||||
+5
-12
@@ -13,10 +13,9 @@ import uvicorn
|
||||
|
||||
from roboco.api.deps import set_orchestrator
|
||||
from roboco.api.websocket import broadcast_agent_chunk
|
||||
from roboco.api.websocket_bridge import start_websocket_bridge
|
||||
from roboco.config import settings
|
||||
from roboco.db import bootstrap_database
|
||||
from roboco.events import init_event_bus, register_default_handlers, set_event_context
|
||||
from roboco.events import EventBus, register_default_handlers, set_event_context
|
||||
from roboco.runtime import AgentOrchestrator, set_reasoning_stream_callback
|
||||
from roboco.services.notification import NotificationService
|
||||
|
||||
@@ -64,18 +63,12 @@ async def main(
|
||||
logger.info("Orchestrator skipped, exiting")
|
||||
return
|
||||
|
||||
# Initialize event bus (Redis Streams with consumer groups)
|
||||
event_bus = await init_event_bus(
|
||||
consumer_name=f"orchestrator-{settings.host}:{settings.port}",
|
||||
recover_pending=True, # Recover unacknowledged messages from previous run
|
||||
)
|
||||
# Initialize event bus
|
||||
event_bus = EventBus()
|
||||
await event_bus.connect()
|
||||
register_default_handlers(event_bus)
|
||||
|
||||
# Register WebSocket bridge handlers (forward stream events to WebSocket clients)
|
||||
await start_websocket_bridge()
|
||||
|
||||
await event_bus.start_listening()
|
||||
logger.info("Event bus initialized (Redis Streams)")
|
||||
logger.info("Event bus initialized")
|
||||
|
||||
# Initialize orchestrator
|
||||
orchestrator = AgentOrchestrator(
|
||||
|
||||
@@ -195,39 +195,6 @@ class Settings(BaseSettings):
|
||||
session_max_content_length: int = Field(default=50000, ge=1)
|
||||
message_max_length: int = Field(default=10000, ge=1)
|
||||
|
||||
# ==========================================================================
|
||||
# Kubernetes
|
||||
# ==========================================================================
|
||||
k8s_enabled: bool = Field(
|
||||
default=False, description="Enable K8s mode (use Jobs API instead of Docker)"
|
||||
)
|
||||
k8s_namespace: str = Field(default="roboco", description="K8s namespace for agents")
|
||||
k8s_agent_image: str = Field(
|
||||
default="ghcr.io/renzof/roboco-agent",
|
||||
description="Base image for agent containers",
|
||||
)
|
||||
k8s_agent_image_tag: str = Field(
|
||||
default="latest", description="Image tag for agent containers"
|
||||
)
|
||||
k8s_agent_memory_request: str = Field(
|
||||
default="256Mi", description="Memory request for agent pods"
|
||||
)
|
||||
k8s_agent_memory_limit: str = Field(
|
||||
default="1Gi", description="Memory limit for agent pods"
|
||||
)
|
||||
k8s_agent_cpu_request: str = Field(
|
||||
default="100m", description="CPU request for agent pods"
|
||||
)
|
||||
k8s_agent_cpu_limit: str = Field(
|
||||
default="500m", description="CPU limit for agent pods"
|
||||
)
|
||||
k8s_job_ttl: int = Field(
|
||||
default=3600, ge=60, description="TTL in seconds for completed agent jobs"
|
||||
)
|
||||
k8s_job_backoff_limit: int = Field(
|
||||
default=0, ge=0, description="Number of retries for failed jobs"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Event System for RoboCo
|
||||
|
||||
Handles workflow triggers and event-driven communication between components.
|
||||
|
||||
Uses Redis Streams for durable message delivery with consumer groups.
|
||||
All events are persisted and delivered with at-least-once semantics.
|
||||
"""
|
||||
|
||||
from roboco.events.bus import Event, EventBus, EventType, get_event_bus, init_event_bus
|
||||
@@ -19,16 +16,13 @@ from roboco.events.handlers import (
|
||||
register_default_handlers,
|
||||
set_event_context,
|
||||
)
|
||||
from roboco.events.stream_bus import StreamEventBus, get_stream_event_bus
|
||||
|
||||
__all__ = [
|
||||
"Event",
|
||||
"EventBus",
|
||||
"EventType",
|
||||
"StreamEventBus",
|
||||
"get_event_bus",
|
||||
"get_event_context",
|
||||
"get_stream_event_bus",
|
||||
"handle_blocker_resolved",
|
||||
"handle_handoff_created",
|
||||
"handle_qa_result",
|
||||
|
||||
+178
-38
@@ -1,56 +1,196 @@
|
||||
"""
|
||||
Event Bus
|
||||
|
||||
Redis Streams-based event system for durable workflow triggers.
|
||||
|
||||
This module re-exports StreamEventBus as EventBus for backward compatibility.
|
||||
All events are now persisted using Redis Streams with consumer groups for
|
||||
guaranteed at-least-once delivery.
|
||||
Redis-based pub/sub event system for workflow triggers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from roboco.events.stream_bus import (
|
||||
StreamEventBus,
|
||||
get_stream_event_bus,
|
||||
init_stream_event_bus,
|
||||
)
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.models.events import Event, EventType
|
||||
|
||||
# Type for event handlers (re-export for backward compatibility)
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Type for event handlers
|
||||
EventHandler = Callable[[Event], Coroutine[Any, Any, None]]
|
||||
|
||||
# Re-export StreamEventBus as EventBus for backward compatibility
|
||||
EventBus = StreamEventBus
|
||||
|
||||
__all__ = [
|
||||
"Event",
|
||||
"EventBus",
|
||||
"EventHandler",
|
||||
"EventType",
|
||||
"get_event_bus",
|
||||
"init_event_bus",
|
||||
class EventBus:
|
||||
"""
|
||||
Event bus for publishing and subscribing to events.
|
||||
|
||||
Uses Redis pub/sub for distributed event handling.
|
||||
"""
|
||||
|
||||
CHANNEL_PREFIX = "roboco:events:"
|
||||
|
||||
def __init__(self, redis_url: str | None = None):
|
||||
self.redis_url = redis_url or settings.redis_url
|
||||
self._redis: redis.Redis | None = None
|
||||
self._pubsub: redis.client.PubSub | None = None
|
||||
self._handlers: dict[EventType, list[EventHandler]] = {}
|
||||
self._running = False
|
||||
self._listen_task: asyncio.Task | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to Redis."""
|
||||
self._redis = redis.from_url(self.redis_url)
|
||||
self._pubsub = self._redis.pubsub()
|
||||
logger.info("EventBus connected to Redis")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Redis."""
|
||||
self._running = False
|
||||
|
||||
if self._listen_task:
|
||||
self._listen_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._listen_task
|
||||
|
||||
if self._pubsub:
|
||||
await self._pubsub.close()
|
||||
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
|
||||
logger.info("EventBus disconnected")
|
||||
|
||||
def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
||||
"""Subscribe a handler to an event type."""
|
||||
if event_type not in self._handlers:
|
||||
self._handlers[event_type] = []
|
||||
self._handlers[event_type].append(handler)
|
||||
logger.debug("Handler subscribed", event_type=event_type.value)
|
||||
|
||||
def unsubscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
||||
"""Unsubscribe a handler from an event type."""
|
||||
if event_type in self._handlers:
|
||||
self._handlers[event_type] = [
|
||||
h for h in self._handlers[event_type] if h != handler
|
||||
]
|
||||
|
||||
async def publish(self, event: Event) -> None:
|
||||
"""Publish an event."""
|
||||
if not self._redis:
|
||||
raise RuntimeError("EventBus not connected")
|
||||
|
||||
def get_event_bus() -> StreamEventBus:
|
||||
"""Get or create the global event bus instance."""
|
||||
return get_stream_event_bus()
|
||||
channel = f"{self.CHANNEL_PREFIX}{event.type.value}"
|
||||
await self._redis.publish(channel, event.to_json())
|
||||
|
||||
|
||||
async def init_event_bus(
|
||||
consumer_name: str | None = None,
|
||||
recover_pending: bool = True,
|
||||
) -> StreamEventBus:
|
||||
"""
|
||||
Initialize and start the event bus.
|
||||
|
||||
Args:
|
||||
consumer_name: Unique name for this consumer instance
|
||||
recover_pending: Whether to recover unacknowledged messages on startup
|
||||
"""
|
||||
return await init_stream_event_bus(
|
||||
consumer_name=consumer_name,
|
||||
recover_pending=recover_pending,
|
||||
logger.info(
|
||||
"Event published",
|
||||
event_type=event.type.value,
|
||||
event_id=str(event.id),
|
||||
source=event.source_agent,
|
||||
)
|
||||
|
||||
async def publish_task_event(
|
||||
self,
|
||||
event_type: EventType,
|
||||
task_id: str,
|
||||
agent_id: str | None = None,
|
||||
**extra_data: Any,
|
||||
) -> None:
|
||||
"""Convenience method to publish task-related events."""
|
||||
event = Event(
|
||||
type=event_type,
|
||||
data={"task_id": task_id, **extra_data},
|
||||
source_agent=agent_id,
|
||||
)
|
||||
await self.publish(event)
|
||||
|
||||
async def start_listening(self) -> None:
|
||||
"""Start listening for events."""
|
||||
if not self._pubsub:
|
||||
raise RuntimeError("EventBus not connected")
|
||||
|
||||
# Subscribe to all event channels we have handlers for
|
||||
channels = [f"{self.CHANNEL_PREFIX}{et.value}" for et in self._handlers]
|
||||
|
||||
if not channels:
|
||||
logger.warning("No event handlers registered, nothing to subscribe to")
|
||||
return
|
||||
|
||||
await self._pubsub.subscribe(*channels)
|
||||
self._running = True
|
||||
self._listen_task = asyncio.create_task(self._listen_loop())
|
||||
logger.info("EventBus listening", channels=len(channels))
|
||||
|
||||
async def _listen_loop(self) -> None:
|
||||
"""Main event listening loop."""
|
||||
while self._running:
|
||||
try:
|
||||
if self._pubsub is None:
|
||||
break
|
||||
message = await self._pubsub.get_message(
|
||||
ignore_subscribe_messages=True,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
if message and message["type"] == "message":
|
||||
await self._handle_message(message)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in event loop", error=str(e))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _handle_message(self, message: dict) -> None:
|
||||
"""Handle an incoming message."""
|
||||
try:
|
||||
event = Event.from_json(message["data"])
|
||||
|
||||
handlers = self._handlers.get(event.type, [])
|
||||
if not handlers:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Handling event",
|
||||
event_type=event.type.value,
|
||||
handler_count=len(handlers),
|
||||
)
|
||||
|
||||
# Run all handlers concurrently
|
||||
tasks = [handler(event) for handler in handlers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Log any errors
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Event handler error",
|
||||
event_type=event.type.value,
|
||||
handler=handlers[i].__name__,
|
||||
error=str(result),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to handle message", error=str(e))
|
||||
|
||||
|
||||
class _EventBusHolder:
|
||||
"""Holder for singleton EventBus instance."""
|
||||
|
||||
instance: EventBus | None = None
|
||||
|
||||
|
||||
def get_event_bus() -> EventBus:
|
||||
"""Get or create the global event bus instance."""
|
||||
if _EventBusHolder.instance is None:
|
||||
_EventBusHolder.instance = EventBus()
|
||||
return _EventBusHolder.instance
|
||||
|
||||
|
||||
async def init_event_bus() -> EventBus:
|
||||
"""Initialize and start the event bus."""
|
||||
bus = get_event_bus()
|
||||
await bus.connect()
|
||||
return bus
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
"""
|
||||
Stream Event Bus
|
||||
|
||||
Redis Streams-based event system with durable message delivery.
|
||||
Replaces the pub/sub-based EventBus with persistence and consumer groups.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from redis.exceptions import ResponseError
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.models.events import Event, EventType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Type for event handlers
|
||||
EventHandler = Callable[[Event], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
class StreamEventBus:
|
||||
"""
|
||||
Event bus using Redis Streams for durable message delivery.
|
||||
|
||||
Features:
|
||||
- Message persistence (survives Redis restart with AOF)
|
||||
- Consumer groups for at-least-once delivery
|
||||
- Message acknowledgment after successful processing
|
||||
- Automatic stream trimming (configurable retention)
|
||||
"""
|
||||
|
||||
STREAM_PREFIX = "roboco:stream:"
|
||||
DEFAULT_GROUP = "roboco-handlers"
|
||||
MAX_STREAM_LENGTH = 10000 # Trim streams to this length
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: str | None = None,
|
||||
consumer_name: str | None = None,
|
||||
group_name: str | None = None,
|
||||
):
|
||||
self.redis_url = redis_url or settings.redis_url
|
||||
self.consumer_name = consumer_name or f"consumer-{id(self)}"
|
||||
self.group_name = group_name or self.DEFAULT_GROUP
|
||||
self._redis: redis.Redis | None = None
|
||||
self._handlers: dict[EventType, list[EventHandler]] = {}
|
||||
self._running = False
|
||||
self._listen_task: asyncio.Task | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to Redis."""
|
||||
self._redis = redis.from_url(self.redis_url)
|
||||
logger.info("StreamEventBus connected to Redis")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if the event bus is connected to Redis."""
|
||||
return self._redis is not None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Redis."""
|
||||
self._running = False
|
||||
|
||||
if self._listen_task:
|
||||
self._listen_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._listen_task
|
||||
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
|
||||
logger.info("StreamEventBus disconnected")
|
||||
|
||||
def _get_stream_name(self, event_type: EventType) -> str:
|
||||
"""Get stream name for event type (grouped by prefix)."""
|
||||
# Group by event category: task.*, agent.*, notification.*, etc.
|
||||
category = event_type.value.split(".")[0]
|
||||
return f"{self.STREAM_PREFIX}{category}"
|
||||
|
||||
def _get_all_stream_names(self) -> list[str]:
|
||||
"""Get all stream names for registered handlers."""
|
||||
categories = set()
|
||||
for event_type in self._handlers:
|
||||
category = event_type.value.split(".")[0]
|
||||
categories.add(category)
|
||||
return [f"{self.STREAM_PREFIX}{cat}" for cat in categories]
|
||||
|
||||
async def _ensure_consumer_group(self, stream: str) -> None:
|
||||
"""Ensure consumer group exists for stream."""
|
||||
if not self._redis:
|
||||
return
|
||||
try:
|
||||
await self._redis.xgroup_create(
|
||||
stream,
|
||||
self.group_name,
|
||||
id="0",
|
||||
mkstream=True,
|
||||
)
|
||||
logger.debug("Created consumer group", stream=stream, group=self.group_name)
|
||||
except ResponseError as e:
|
||||
if "BUSYGROUP" not in str(e):
|
||||
raise
|
||||
# Group already exists, that's fine
|
||||
|
||||
def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
||||
"""Subscribe a handler to an event type."""
|
||||
if event_type not in self._handlers:
|
||||
self._handlers[event_type] = []
|
||||
self._handlers[event_type].append(handler)
|
||||
logger.debug("Handler subscribed", event_type=event_type.value)
|
||||
|
||||
def unsubscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
||||
"""Unsubscribe a handler from an event type."""
|
||||
if event_type in self._handlers:
|
||||
self._handlers[event_type] = [
|
||||
h for h in self._handlers[event_type] if h != handler
|
||||
]
|
||||
|
||||
async def publish(self, event: Event) -> str:
|
||||
"""
|
||||
Publish an event to the stream.
|
||||
|
||||
Returns the message ID assigned by Redis.
|
||||
"""
|
||||
if not self._redis:
|
||||
raise RuntimeError("StreamEventBus not connected")
|
||||
|
||||
stream = self._get_stream_name(event.type)
|
||||
|
||||
# Add to stream with automatic ID (*) and trim to max length
|
||||
raw_message_id = await self._redis.xadd(
|
||||
stream,
|
||||
{
|
||||
"type": event.type.value,
|
||||
"data": event.to_json(),
|
||||
},
|
||||
maxlen=self.MAX_STREAM_LENGTH,
|
||||
approximate=True,
|
||||
)
|
||||
# Convert bytes to str if needed
|
||||
message_id = (
|
||||
raw_message_id.decode()
|
||||
if isinstance(raw_message_id, bytes)
|
||||
else str(raw_message_id)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Event published to stream",
|
||||
event_type=event.type.value,
|
||||
event_id=str(event.id),
|
||||
stream=stream,
|
||||
message_id=message_id,
|
||||
source=event.source_agent,
|
||||
)
|
||||
|
||||
return message_id
|
||||
|
||||
async def publish_task_event(
|
||||
self,
|
||||
event_type: EventType,
|
||||
task_id: str,
|
||||
agent_id: str | None = None,
|
||||
**extra_data: Any,
|
||||
) -> str:
|
||||
"""Convenience method to publish task-related events."""
|
||||
event = Event(
|
||||
type=event_type,
|
||||
data={"task_id": task_id, **extra_data},
|
||||
source_agent=agent_id,
|
||||
)
|
||||
return await self.publish(event)
|
||||
|
||||
async def start_listening(self) -> None:
|
||||
"""Start listening for events."""
|
||||
if not self._redis:
|
||||
raise RuntimeError("StreamEventBus not connected")
|
||||
|
||||
streams = self._get_all_stream_names()
|
||||
if not streams:
|
||||
logger.warning("No event handlers registered, nothing to subscribe to")
|
||||
return
|
||||
|
||||
# Ensure consumer groups exist for all streams
|
||||
for stream in streams:
|
||||
await self._ensure_consumer_group(stream)
|
||||
|
||||
self._running = True
|
||||
self._listen_task = asyncio.create_task(self._listen_loop())
|
||||
logger.info("StreamEventBus listening", streams=streams)
|
||||
|
||||
async def _listen_loop(self) -> None:
|
||||
"""Main event listening loop using XREADGROUP."""
|
||||
if not self._redis:
|
||||
return
|
||||
|
||||
streams = self._get_all_stream_names()
|
||||
# Build stream dict: {stream_name: ">"} (> = only new messages)
|
||||
stream_dict = dict.fromkeys(streams, ">")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Block for 5 seconds waiting for new messages
|
||||
results = await self._redis.xreadgroup(
|
||||
self.group_name,
|
||||
self.consumer_name,
|
||||
stream_dict,
|
||||
count=10,
|
||||
block=5000,
|
||||
)
|
||||
|
||||
if not results:
|
||||
continue
|
||||
|
||||
for stream_name, messages in results:
|
||||
for message_id, data in messages:
|
||||
await self._handle_message(stream_name, message_id, data)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in stream event loop", error=str(e))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _handle_message(
|
||||
self,
|
||||
stream: str,
|
||||
message_id: str,
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""Handle an incoming message and ACK on success."""
|
||||
if not self._redis:
|
||||
return
|
||||
|
||||
try:
|
||||
event_data = data.get(b"data") or data.get("data")
|
||||
if isinstance(event_data, bytes):
|
||||
event_data = event_data.decode()
|
||||
|
||||
if not event_data or not isinstance(event_data, str):
|
||||
logger.error("Invalid event data", message_id=message_id)
|
||||
await self._redis.xack(stream, self.group_name, message_id)
|
||||
return
|
||||
|
||||
event = Event.from_json(event_data)
|
||||
|
||||
handlers = self._handlers.get(event.type, [])
|
||||
if not handlers:
|
||||
# No handlers but still ACK to prevent redelivery
|
||||
await self._redis.xack(stream, self.group_name, message_id)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Handling event from stream",
|
||||
event_type=event.type.value,
|
||||
message_id=message_id,
|
||||
handler_count=len(handlers),
|
||||
)
|
||||
|
||||
# Run all handlers concurrently
|
||||
tasks = [handler(event) for handler in handlers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Log any errors
|
||||
all_succeeded = True
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
all_succeeded = False
|
||||
logger.error(
|
||||
"Event handler error",
|
||||
event_type=event.type.value,
|
||||
handler=handlers[i].__name__,
|
||||
error=str(result),
|
||||
)
|
||||
|
||||
# ACK the message if all handlers succeeded
|
||||
# If any failed, message stays pending and can be reclaimed later
|
||||
if all_succeeded:
|
||||
await self._redis.xack(stream, self.group_name, message_id)
|
||||
logger.debug("Message acknowledged", message_id=message_id)
|
||||
else:
|
||||
logger.warning(
|
||||
"Message not acknowledged due to handler errors",
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to handle stream message",
|
||||
error=str(e),
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
async def recover_pending(self, idle_time_ms: int = 60000) -> int:
|
||||
"""
|
||||
Recover pending messages that weren't acknowledged.
|
||||
|
||||
Useful for startup to process messages from crashed consumers.
|
||||
|
||||
Args:
|
||||
idle_time_ms: Only recover messages idle for this long (default 1 minute)
|
||||
|
||||
Returns:
|
||||
Number of messages recovered
|
||||
"""
|
||||
if not self._redis:
|
||||
return 0
|
||||
|
||||
recovered = 0
|
||||
streams = self._get_all_stream_names()
|
||||
|
||||
for stream in streams:
|
||||
try:
|
||||
# Claim pending messages from any consumer
|
||||
pending = await self._redis.xpending(stream, self.group_name)
|
||||
if not pending or pending["pending"] == 0:
|
||||
continue
|
||||
|
||||
# Get pending message details
|
||||
pending_details = await self._redis.xpending_range(
|
||||
stream,
|
||||
self.group_name,
|
||||
min="-",
|
||||
max="+",
|
||||
count=100,
|
||||
)
|
||||
|
||||
for msg in pending_details:
|
||||
msg_id = msg["message_id"]
|
||||
idle = msg["time_since_delivered"]
|
||||
|
||||
if idle >= idle_time_ms:
|
||||
# Claim message for this consumer
|
||||
claimed = await self._redis.xclaim(
|
||||
stream,
|
||||
self.group_name,
|
||||
self.consumer_name,
|
||||
min_idle_time=idle_time_ms,
|
||||
message_ids=[msg_id],
|
||||
)
|
||||
if claimed:
|
||||
recovered += 1
|
||||
# Process the claimed message
|
||||
for claim_id, data in claimed:
|
||||
await self._handle_message(stream, claim_id, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error recovering pending messages",
|
||||
stream=stream,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if recovered:
|
||||
logger.info("Recovered pending messages", count=recovered)
|
||||
|
||||
return recovered
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SINGLETON ACCESS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class _StreamEventBusHolder:
|
||||
"""Holder for singleton StreamEventBus instance."""
|
||||
|
||||
instance: StreamEventBus | None = None
|
||||
|
||||
|
||||
def get_stream_event_bus() -> StreamEventBus:
|
||||
"""Get or create the global stream event bus instance."""
|
||||
if _StreamEventBusHolder.instance is None:
|
||||
_StreamEventBusHolder.instance = StreamEventBus()
|
||||
return _StreamEventBusHolder.instance
|
||||
|
||||
|
||||
async def init_stream_event_bus(
|
||||
consumer_name: str | None = None,
|
||||
recover_pending: bool = True,
|
||||
) -> StreamEventBus:
|
||||
"""
|
||||
Initialize and start the stream event bus.
|
||||
|
||||
Args:
|
||||
consumer_name: Unique name for this consumer instance
|
||||
recover_pending: Whether to recover unacknowledged messages on startup
|
||||
"""
|
||||
bus = get_stream_event_bus()
|
||||
if consumer_name:
|
||||
bus.consumer_name = consumer_name
|
||||
await bus.connect()
|
||||
|
||||
if recover_pending:
|
||||
await bus.recover_pending()
|
||||
|
||||
return bus
|
||||
@@ -1,447 +0,0 @@
|
||||
"""
|
||||
A2A (Agent-to-Agent) Protocol Models
|
||||
|
||||
Implements Google's A2A protocol for agent interoperability.
|
||||
See: https://a2a-protocol.org/latest/specification/
|
||||
|
||||
This module defines the data structures for:
|
||||
- AgentCard: Agent metadata and capability discovery
|
||||
- Task: Work unit lifecycle management
|
||||
- Message: Communication between agents
|
||||
- Skill: Capability units an agent can perform
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from roboco.models.base import RobocoBase
|
||||
|
||||
# =============================================================================
|
||||
# ENUMS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class A2ATaskState(str, Enum):
|
||||
"""
|
||||
A2A Task lifecycle states.
|
||||
|
||||
Per A2A specification section 4.2.1.
|
||||
"""
|
||||
|
||||
SUBMITTED = "submitted" # Task acknowledged and created
|
||||
WORKING = "working" # Actively processing
|
||||
COMPLETED = "completed" # Finished successfully (terminal)
|
||||
FAILED = "failed" # Finished with error (terminal)
|
||||
CANCELLED = "cancelled" # Stopped before completion (terminal)
|
||||
INPUT_REQUIRED = "input_required" # Awaiting additional information
|
||||
REJECTED = "rejected" # Agent declined the task (terminal)
|
||||
AUTH_REQUIRED = "auth_required" # Needs client authentication
|
||||
|
||||
|
||||
class A2APartType(str, Enum):
|
||||
"""Types of content parts in a message."""
|
||||
|
||||
TEXT = "text"
|
||||
FILE = "file"
|
||||
DATA = "data"
|
||||
ARTIFACT = "artifact"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AGENT CARD MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AgentProvider(RobocoBase):
|
||||
"""Provider information for an agent."""
|
||||
|
||||
organization: str = Field(..., description="Organization name")
|
||||
url: str | None = Field(default=None, description="Organization URL")
|
||||
|
||||
|
||||
class AgentCapabilities(RobocoBase):
|
||||
"""Capabilities supported by an agent."""
|
||||
|
||||
streaming: bool = Field(default=False, description="Supports SSE streaming")
|
||||
push_notifications: bool = Field(
|
||||
default=False, description="Supports webhook push notifications"
|
||||
)
|
||||
state_transition_history: bool = Field(
|
||||
default=False, description="Returns task state history"
|
||||
)
|
||||
|
||||
|
||||
class SecurityScheme(RobocoBase):
|
||||
"""Security scheme for authentication."""
|
||||
|
||||
type: str = Field(..., description="Scheme type (apiKey, http, oauth2)")
|
||||
name: str | None = Field(default=None, description="Name of the header/param")
|
||||
scheme: str | None = Field(default=None, description="HTTP auth scheme (bearer)")
|
||||
bearer_format: str | None = Field(
|
||||
default=None, alias="bearerFormat", description="Format hint for tokens"
|
||||
)
|
||||
in_location: str | None = Field(
|
||||
default=None, alias="in", description="Where to send (header, query, cookie)"
|
||||
)
|
||||
|
||||
|
||||
class AgentSkill(RobocoBase):
|
||||
"""A capability unit an agent can perform."""
|
||||
|
||||
id: str = Field(..., description="Unique skill identifier")
|
||||
name: str = Field(..., description="Human-readable skill name")
|
||||
description: str = Field(..., description="What this skill does")
|
||||
tags: list[str] = Field(default_factory=list, description="Capability categories")
|
||||
examples: list[str] = Field(
|
||||
default_factory=list, description="Example invocations"
|
||||
)
|
||||
input_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain"],
|
||||
alias="inputModes",
|
||||
description="Supported input MIME types",
|
||||
)
|
||||
output_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain"],
|
||||
alias="outputModes",
|
||||
description="Supported output MIME types",
|
||||
)
|
||||
|
||||
|
||||
class AgentCard(RobocoBase):
|
||||
"""
|
||||
A2A Agent Card - The agent's public identity and capabilities.
|
||||
|
||||
Published at /.well-known/agent.json per A2A specification.
|
||||
Acts as the agent's "business card" for discovery.
|
||||
"""
|
||||
|
||||
# Required fields
|
||||
id: str = Field(..., description="Unique agent identifier")
|
||||
name: str = Field(..., description="Human-readable agent name")
|
||||
provider: AgentProvider = Field(..., description="Provider information")
|
||||
protocol_version: str = Field(
|
||||
default="1.0",
|
||||
alias="protocolVersion",
|
||||
description="Supported A2A protocol version",
|
||||
)
|
||||
service_endpoint: str = Field(
|
||||
..., alias="serviceEndpoint", description="Base URL for A2A operations"
|
||||
)
|
||||
capabilities: AgentCapabilities = Field(
|
||||
default_factory=AgentCapabilities, description="Supported features"
|
||||
)
|
||||
security_schemes: dict[str, SecurityScheme] = Field(
|
||||
default_factory=dict,
|
||||
alias="securitySchemes",
|
||||
description="Available auth methods",
|
||||
)
|
||||
security: list[dict[str, list[str]]] = Field(
|
||||
default_factory=list, description="Required security scheme(s)"
|
||||
)
|
||||
|
||||
# Optional fields
|
||||
description: str | None = Field(
|
||||
default=None, description="Agent purpose and capabilities"
|
||||
)
|
||||
skills: list[AgentSkill] = Field(
|
||||
default_factory=list, description="Available operations"
|
||||
)
|
||||
default_input_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain", "application/json"],
|
||||
alias="defaultInputModes",
|
||||
description="Default accepted input MIME types",
|
||||
)
|
||||
default_output_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain", "application/json"],
|
||||
alias="defaultOutputModes",
|
||||
description="Default output MIME types",
|
||||
)
|
||||
documentation_url: str | None = Field(
|
||||
default=None, alias="documentationUrl", description="Agent documentation URL"
|
||||
)
|
||||
version: str | None = Field(default=None, description="Agent version")
|
||||
supports_extended_agent_card: bool = Field(
|
||||
default=False,
|
||||
alias="supportsExtendedAgentCard",
|
||||
description="Whether authenticated card available",
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom metadata"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
extra="allow", # A2A cards may have extensions
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MESSAGE MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TextPart(RobocoBase):
|
||||
"""Plain text content part."""
|
||||
|
||||
type: Literal["text"] = "text"
|
||||
text: str = Field(..., description="Text content")
|
||||
|
||||
|
||||
class FilePart(RobocoBase):
|
||||
"""File reference content part."""
|
||||
|
||||
type: Literal["file"] = "file"
|
||||
file: dict[str, Any] = Field(
|
||||
..., description="File data (uri, mimeType, name, data)"
|
||||
)
|
||||
|
||||
|
||||
class DataPart(RobocoBase):
|
||||
"""Structured JSON data content part."""
|
||||
|
||||
type: Literal["data"] = "data"
|
||||
data: dict[str, Any] = Field(..., description="Structured data")
|
||||
|
||||
|
||||
class ArtifactPart(RobocoBase):
|
||||
"""Reference to a generated artifact."""
|
||||
|
||||
type: Literal["artifact"] = "artifact"
|
||||
artifact: dict[str, Any] = Field(..., description="Artifact reference")
|
||||
|
||||
|
||||
# Union type for message parts
|
||||
Part = Annotated[
|
||||
TextPart | FilePart | DataPart | ArtifactPart, Field(discriminator="type")
|
||||
]
|
||||
|
||||
|
||||
class A2AMessage(RobocoBase):
|
||||
"""
|
||||
A2A Message - A communication turn between agents.
|
||||
|
||||
Contains one or more parts with content.
|
||||
"""
|
||||
|
||||
role: Literal["user", "agent"] = Field(
|
||||
..., description="Message sender role"
|
||||
)
|
||||
parts: list[Part] = Field(..., description="Content parts")
|
||||
context_id: str | None = Field(
|
||||
default=None, alias="contextId", description="Conversation grouping"
|
||||
)
|
||||
task_id: str | None = Field(
|
||||
default=None, alias="taskId", description="Associated task"
|
||||
)
|
||||
message_id: str = Field(
|
||||
default_factory=lambda: str(uuid4()),
|
||||
alias="messageId",
|
||||
description="Unique message ID",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TASK MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class A2ATaskStatus(RobocoBase):
|
||||
"""Status container for an A2A task."""
|
||||
|
||||
state: A2ATaskState = Field(..., description="Current lifecycle state")
|
||||
message: A2AMessage | None = Field(
|
||||
default=None, description="Associated status message"
|
||||
)
|
||||
timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
description="When status was recorded",
|
||||
)
|
||||
|
||||
|
||||
class A2AArtifact(RobocoBase):
|
||||
"""An output artifact produced by a task."""
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid4()), description="Artifact ID")
|
||||
name: str = Field(..., description="Artifact name")
|
||||
parts: list[Part] = Field(..., description="Artifact content parts")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom metadata"
|
||||
)
|
||||
|
||||
|
||||
class A2ATask(RobocoBase):
|
||||
"""
|
||||
A2A Task - A unit of work with lifecycle management.
|
||||
|
||||
Maps to RoboCo's internal TaskTable but follows A2A semantics.
|
||||
"""
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid4()), description="Server-generated task ID"
|
||||
)
|
||||
context_id: str = Field(
|
||||
..., alias="contextId", description="Groups related interactions"
|
||||
)
|
||||
status: A2ATaskStatus = Field(..., description="Current task status")
|
||||
artifacts: list[A2AArtifact] = Field(
|
||||
default_factory=list, description="Output artifacts"
|
||||
)
|
||||
history: list[A2AMessage] = Field(
|
||||
default_factory=list, description="Interaction history"
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom metadata"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JSON-RPC REQUEST/RESPONSE MODELS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SendMessageConfiguration(RobocoBase):
|
||||
"""Configuration for SendMessage request."""
|
||||
|
||||
accepted_output_modes: list[str] = Field(
|
||||
default_factory=lambda: ["text/plain", "application/json"],
|
||||
alias="acceptedOutputModes",
|
||||
description="Client-accepted output MIME types",
|
||||
)
|
||||
history_length: int | None = Field(
|
||||
default=None,
|
||||
alias="historyLength",
|
||||
description="Number of history turns to include in response",
|
||||
)
|
||||
blocking: bool = Field(
|
||||
default=False, description="Wait for task completion before responding"
|
||||
)
|
||||
push_notification_config: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
alias="pushNotificationConfig",
|
||||
description="Webhook config for async updates",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class SendMessageRequest(RobocoBase):
|
||||
"""Request payload for SendMessage JSON-RPC method."""
|
||||
|
||||
message: A2AMessage = Field(..., description="Message to send")
|
||||
configuration: SendMessageConfiguration | None = Field(
|
||||
default=None, description="Request configuration"
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom request metadata"
|
||||
)
|
||||
|
||||
|
||||
class SendMessageResponse(RobocoBase):
|
||||
"""Response payload for SendMessage JSON-RPC method."""
|
||||
|
||||
task: A2ATask = Field(..., description="Created or updated task")
|
||||
|
||||
|
||||
class GetTaskRequest(RobocoBase):
|
||||
"""Request payload for GetTask JSON-RPC method."""
|
||||
|
||||
name: str = Field(..., description="Task resource name (tasks/{id})")
|
||||
history_length: int | None = Field(
|
||||
default=None,
|
||||
alias="historyLength",
|
||||
description="Number of history turns to include",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class ListTasksRequest(RobocoBase):
|
||||
"""Request payload for ListTasks JSON-RPC method."""
|
||||
|
||||
page_size: int = Field(
|
||||
default=20, alias="pageSize", ge=1, le=100, description="Results per page"
|
||||
)
|
||||
page_token: str | None = Field(
|
||||
default=None, alias="pageToken", description="Pagination token"
|
||||
)
|
||||
filter: str | None = Field(default=None, description="Filter expression")
|
||||
order_by: str | None = Field(
|
||||
default=None, alias="orderBy", description="Sort order"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class ListTasksResponse(RobocoBase):
|
||||
"""Response payload for ListTasks JSON-RPC method."""
|
||||
|
||||
tasks: list[A2ATask] = Field(..., description="Task list")
|
||||
next_page_token: str | None = Field(
|
||||
default=None, alias="nextPageToken", description="Token for next page"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class CancelTaskRequest(RobocoBase):
|
||||
"""Request payload for CancelTask JSON-RPC method."""
|
||||
|
||||
name: str = Field(..., description="Task resource name (tasks/{id})")
|
||||
reason: str | None = Field(default=None, description="Cancellation reason")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATE MAPPING UTILITIES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def task_status_to_a2a_state(roboco_status: str) -> A2ATaskState:
|
||||
"""
|
||||
Map RoboCo TaskStatus to A2A TaskState.
|
||||
|
||||
This enables interoperability between RoboCo's internal
|
||||
task lifecycle and the A2A protocol.
|
||||
"""
|
||||
mapping = {
|
||||
"backlog": A2ATaskState.SUBMITTED,
|
||||
"pending": A2ATaskState.SUBMITTED,
|
||||
"claimed": A2ATaskState.WORKING,
|
||||
"in_progress": A2ATaskState.WORKING,
|
||||
"blocked": A2ATaskState.INPUT_REQUIRED,
|
||||
"paused": A2ATaskState.INPUT_REQUIRED,
|
||||
"verifying": A2ATaskState.WORKING,
|
||||
"needs_revision": A2ATaskState.INPUT_REQUIRED,
|
||||
"awaiting_qa": A2ATaskState.WORKING,
|
||||
"awaiting_documentation": A2ATaskState.WORKING,
|
||||
"awaiting_pm_review": A2ATaskState.WORKING,
|
||||
"completed": A2ATaskState.COMPLETED,
|
||||
"cancelled": A2ATaskState.CANCELLED,
|
||||
}
|
||||
return mapping.get(roboco_status, A2ATaskState.WORKING)
|
||||
|
||||
|
||||
def a2a_state_to_task_status(a2a_state: A2ATaskState) -> str:
|
||||
"""
|
||||
Map A2A TaskState back to RoboCo TaskStatus.
|
||||
|
||||
Used when creating tasks via A2A protocol.
|
||||
"""
|
||||
mapping = {
|
||||
A2ATaskState.SUBMITTED: "pending",
|
||||
A2ATaskState.WORKING: "in_progress",
|
||||
A2ATaskState.COMPLETED: "completed",
|
||||
A2ATaskState.FAILED: "cancelled", # RoboCo uses cancelled for failures
|
||||
A2ATaskState.CANCELLED: "cancelled",
|
||||
A2ATaskState.INPUT_REQUIRED: "blocked",
|
||||
A2ATaskState.REJECTED: "cancelled",
|
||||
A2ATaskState.AUTH_REQUIRED: "blocked",
|
||||
}
|
||||
return mapping.get(a2a_state, "pending")
|
||||
+12
-401
@@ -10,10 +10,6 @@ The orchestrator is the BRAIN of the system:
|
||||
- Agents receive their assignment at spawn time
|
||||
- Agents scan for more work after completing a task
|
||||
- Agents only call roboco_agent_idle() when truly no work remains
|
||||
|
||||
Supports two backends:
|
||||
- Docker: Uses Docker CLI to spawn containers (default)
|
||||
- Kubernetes: Uses K8s Jobs API to spawn agent pods (when k8s_enabled=True)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -23,7 +19,7 @@ import os
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
@@ -43,10 +39,6 @@ from roboco.models.runtime import (
|
||||
)
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
|
||||
# K8s imports (only when K8s mode is enabled)
|
||||
if TYPE_CHECKING:
|
||||
from kubernetes import client as k8s_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Reverse mapping: UUID -> slug
|
||||
@@ -114,15 +106,11 @@ class AgentOrchestrator:
|
||||
Manages Claude Code containers for all agents.
|
||||
|
||||
Responsibilities:
|
||||
- Spawn agents as Docker containers or K8s Jobs
|
||||
- Monitor health via docker inspect or K8s API
|
||||
- Spawn agents as Docker containers
|
||||
- Monitor health via docker inspect
|
||||
- Handle waiting states and respawning
|
||||
- Provide status API
|
||||
- Cost-efficient on-demand spawning
|
||||
|
||||
Backends:
|
||||
- Docker (default): Uses Docker CLI to spawn containers
|
||||
- Kubernetes: Uses K8s Jobs API when settings.k8s_enabled=True
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -144,51 +132,15 @@ class AgentOrchestrator:
|
||||
self._running = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# K8s clients (initialized lazily when k8s_enabled)
|
||||
self._k8s_batch_v1: k8s_client.BatchV1Api | None = None
|
||||
self._k8s_core_v1: k8s_client.CoreV1Api | None = None
|
||||
self._k8s_initialized = False
|
||||
|
||||
# =========================================================================
|
||||
# LIFECYCLE
|
||||
# =========================================================================
|
||||
|
||||
def _init_k8s(self) -> None:
|
||||
"""Initialize Kubernetes clients."""
|
||||
if self._k8s_initialized:
|
||||
return
|
||||
|
||||
from kubernetes import client, config
|
||||
|
||||
# Load in-cluster config (when running in K8s)
|
||||
# or kubeconfig for local dev
|
||||
try:
|
||||
config.load_incluster_config()
|
||||
logger.info("K8s: loaded in-cluster config")
|
||||
except config.ConfigException:
|
||||
try:
|
||||
config.load_kube_config()
|
||||
logger.info("K8s: loaded kubeconfig")
|
||||
except config.ConfigException:
|
||||
logger.warning("K8s: no config found, K8s mode disabled")
|
||||
return
|
||||
|
||||
self._k8s_batch_v1 = client.BatchV1Api()
|
||||
self._k8s_core_v1 = client.CoreV1Api()
|
||||
self._k8s_initialized = True
|
||||
logger.info("K8s clients initialized", namespace=settings.k8s_namespace)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the orchestrator."""
|
||||
self._running = True
|
||||
|
||||
# Initialize K8s if enabled
|
||||
if settings.k8s_enabled:
|
||||
self._init_k8s()
|
||||
if not self._k8s_initialized:
|
||||
logger.warning("K8s mode requested but initialization failed")
|
||||
else:
|
||||
# Docker mode: Ensure agent image is built
|
||||
# Ensure agent image is built
|
||||
await self._ensure_agent_image()
|
||||
|
||||
# Ensure agent Claude settings have MCP tools allowed
|
||||
@@ -202,7 +154,6 @@ class AgentOrchestrator:
|
||||
"Orchestrator started",
|
||||
dispatcher_interval=self.dispatcher_interval,
|
||||
internal_api_url=self._api_url,
|
||||
k8s_enabled=settings.k8s_enabled,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
@@ -384,8 +335,6 @@ class AgentOrchestrator:
|
||||
"""
|
||||
Spawn a Claude Code container for an agent.
|
||||
|
||||
Uses Docker containers by default, or K8s Jobs when k8s_enabled=True.
|
||||
|
||||
Args:
|
||||
agent_id: Agent identifier (e.g., "be-dev-1")
|
||||
initial_prompt: Optional initial prompt
|
||||
@@ -413,8 +362,7 @@ class AgentOrchestrator:
|
||||
# Ensure agent Claude settings have MCP tools allowed
|
||||
self._ensure_agent_claude_settings()
|
||||
|
||||
# Docker mode: Ensure agent-specific Docker image is built
|
||||
if not settings.k8s_enabled:
|
||||
# Ensure agent-specific Docker image is built
|
||||
await self._ensure_agent_image(agent_id)
|
||||
|
||||
# Generate MCP config
|
||||
@@ -443,34 +391,20 @@ class AgentOrchestrator:
|
||||
|
||||
self._instances[agent_id] = instance
|
||||
|
||||
# Spawn the container/job
|
||||
# Spawn the container
|
||||
try:
|
||||
if settings.k8s_enabled and self._k8s_initialized:
|
||||
# K8s mode: Create ConfigMaps and spawn Job
|
||||
system_prompt = blueprint_path.read_text()
|
||||
mcp_config_data = json.loads(mcp_config_path.read_text())
|
||||
await self._create_k8s_agent_configmaps(
|
||||
agent_id, system_prompt, mcp_config_data
|
||||
)
|
||||
job_name = await self._spawn_k8s_job(config, initial_prompt, task_id)
|
||||
instance.container_id = job_name # Store job name as container_id
|
||||
else:
|
||||
# Docker mode: Spawn container
|
||||
container_id = await self._spawn_container(config, initial_prompt)
|
||||
instance.container_id = container_id
|
||||
|
||||
instance.state = AgentState.ACTIVE
|
||||
instance.started_at = datetime.now(UTC)
|
||||
instance.last_activity = datetime.now(UTC)
|
||||
|
||||
cid = instance.container_id[:12] if instance.container_id else None
|
||||
logger.info(
|
||||
"Agent spawned",
|
||||
agent_id=agent_id,
|
||||
container_id=cid,
|
||||
container_id=container_id[:12],
|
||||
model=model,
|
||||
task_id=task_id,
|
||||
k8s_enabled=settings.k8s_enabled,
|
||||
)
|
||||
|
||||
return instance
|
||||
@@ -613,315 +547,6 @@ class AgentOrchestrator:
|
||||
)
|
||||
await proc.wait()
|
||||
|
||||
# =========================================================================
|
||||
# K8S JOB MANAGEMENT
|
||||
# =========================================================================
|
||||
|
||||
async def _spawn_k8s_job(
|
||||
self,
|
||||
config: AgentConfig,
|
||||
initial_prompt: str | None = None,
|
||||
task_id: str | None = None,
|
||||
) -> str:
|
||||
"""Spawn an agent as a K8s Job."""
|
||||
from kubernetes import client
|
||||
|
||||
if not self._k8s_batch_v1:
|
||||
raise RuntimeError("K8s client not initialized")
|
||||
|
||||
job_name = f"agent-{config.agent_id}"
|
||||
if task_id:
|
||||
job_name = f"{job_name}-{task_id[:8]}"
|
||||
|
||||
# Delete existing job if any
|
||||
await self._delete_k8s_job(job_name)
|
||||
|
||||
# Build environment variables
|
||||
env_vars = [
|
||||
client.V1EnvVar(name="ROBOCO_AGENT_ID", value=config.agent_id),
|
||||
client.V1EnvVar(name="ROBOCO_API_URL", value="http://roboco-api:8000"),
|
||||
client.V1EnvVar(
|
||||
name="ROBOCO_ANTHROPIC_API_KEY",
|
||||
value_from=client.V1EnvVarSource(
|
||||
secret_key_ref=client.V1SecretKeySelector(
|
||||
name="roboco-secrets",
|
||||
key="anthropic-api-key",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
if task_id:
|
||||
env_vars.append(client.V1EnvVar(name="ROBOCO_TASK_ID", value=task_id))
|
||||
|
||||
# Build command with initial prompt
|
||||
cmd_prompt = initial_prompt or (
|
||||
"You may have been spawned without a specific task assignment. "
|
||||
"Follow your standard workflow:\n\n"
|
||||
"1. Call `roboco_task_scan()` to find available work for your role\n"
|
||||
"2. If tasks are found, claim one with `roboco_task_claim(task_id)` "
|
||||
"and begin the full task lifecycle "
|
||||
"(UNDERSTAND -> PLAN -> EXECUTE -> VERIFY -> HANDOFF)\n"
|
||||
"3. If no tasks are available, call `roboco_agent_idle()` "
|
||||
"to shutdown gracefully\n\n"
|
||||
"Start now by scanning for work."
|
||||
)
|
||||
|
||||
# Agent image based on role
|
||||
agent_image = f"{settings.k8s_agent_image}:{settings.k8s_agent_image_tag}"
|
||||
|
||||
# Build Job spec
|
||||
job = client.V1Job(
|
||||
api_version="batch/v1",
|
||||
kind="Job",
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=job_name,
|
||||
namespace=settings.k8s_namespace,
|
||||
labels={
|
||||
"app": "roboco-agent",
|
||||
"agent-id": config.agent_id,
|
||||
"task-id": task_id[:8] if task_id else "none",
|
||||
},
|
||||
),
|
||||
spec=client.V1JobSpec(
|
||||
ttl_seconds_after_finished=settings.k8s_job_ttl,
|
||||
backoff_limit=settings.k8s_job_backoff_limit,
|
||||
template=client.V1PodTemplateSpec(
|
||||
metadata=client.V1ObjectMeta(
|
||||
labels={
|
||||
"app": "roboco-agent",
|
||||
"agent-id": config.agent_id,
|
||||
},
|
||||
),
|
||||
spec=client.V1PodSpec(
|
||||
restart_policy="Never",
|
||||
service_account_name="roboco-agent",
|
||||
containers=[
|
||||
client.V1Container(
|
||||
name="agent",
|
||||
image=agent_image,
|
||||
args=[
|
||||
"--model",
|
||||
MODEL_MAP.get(config.model, config.model),
|
||||
"--system-prompt-file",
|
||||
"/app/system-prompt.md",
|
||||
"--mcp-config",
|
||||
"/app/mcp-config.json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"-p",
|
||||
cmd_prompt,
|
||||
],
|
||||
env=env_vars,
|
||||
resources=client.V1ResourceRequirements(
|
||||
requests={
|
||||
"memory": settings.k8s_agent_memory_request,
|
||||
"cpu": settings.k8s_agent_cpu_request,
|
||||
},
|
||||
limits={
|
||||
"memory": settings.k8s_agent_memory_limit,
|
||||
"cpu": settings.k8s_agent_cpu_limit,
|
||||
},
|
||||
),
|
||||
volume_mounts=[
|
||||
client.V1VolumeMount(
|
||||
name="system-prompt",
|
||||
mount_path="/app/system-prompt.md",
|
||||
sub_path="system-prompt.md",
|
||||
read_only=True,
|
||||
),
|
||||
client.V1VolumeMount(
|
||||
name="mcp-config",
|
||||
mount_path="/app/mcp-config.json",
|
||||
sub_path="mcp-config.json",
|
||||
read_only=True,
|
||||
),
|
||||
client.V1VolumeMount(
|
||||
name="claude-auth",
|
||||
mount_path="/home/agent/.claude",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
volumes=[
|
||||
client.V1Volume(
|
||||
name="system-prompt",
|
||||
config_map=client.V1ConfigMapVolumeSource(
|
||||
name=f"agent-prompt-{config.agent_id}",
|
||||
),
|
||||
),
|
||||
client.V1Volume(
|
||||
name="mcp-config",
|
||||
config_map=client.V1ConfigMapVolumeSource(
|
||||
name=f"agent-mcp-{config.agent_id}",
|
||||
),
|
||||
),
|
||||
client.V1Volume(
|
||||
name="claude-auth",
|
||||
secret=client.V1SecretVolumeSource(
|
||||
secret_name="claude-auth",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Create the Job
|
||||
self._k8s_batch_v1.create_namespaced_job(
|
||||
namespace=settings.k8s_namespace,
|
||||
body=job,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"K8s: spawned agent job",
|
||||
job_name=job_name,
|
||||
agent_id=config.agent_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
return job_name
|
||||
|
||||
async def _delete_k8s_job(self, job_name: str) -> None:
|
||||
"""Delete a K8s Job if it exists."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
if not self._k8s_batch_v1:
|
||||
return
|
||||
|
||||
try:
|
||||
self._k8s_batch_v1.delete_namespaced_job(
|
||||
name=job_name,
|
||||
namespace=settings.k8s_namespace,
|
||||
propagation_policy="Background",
|
||||
)
|
||||
logger.debug("K8s: deleted job", job_name=job_name)
|
||||
except ApiException as e:
|
||||
# 404 = not found, which is expected when job doesn't exist
|
||||
if e.status != 404: # noqa: PLR2004
|
||||
logger.warning("K8s: failed to delete job", job_name=job_name, error=e)
|
||||
|
||||
async def _stop_k8s_agent(self, agent_id: str) -> None:
|
||||
"""Stop an agent by deleting its K8s Job."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
if not self._k8s_batch_v1:
|
||||
return
|
||||
|
||||
# Find jobs for this agent
|
||||
try:
|
||||
jobs = self._k8s_batch_v1.list_namespaced_job(
|
||||
namespace=settings.k8s_namespace,
|
||||
label_selector=f"agent-id={agent_id}",
|
||||
)
|
||||
|
||||
for job in jobs.items:
|
||||
await self._delete_k8s_job(job.metadata.name)
|
||||
except ApiException as e:
|
||||
logger.warning(
|
||||
"K8s: failed to list jobs for agent", agent_id=agent_id, error=e
|
||||
)
|
||||
|
||||
async def _get_k8s_agent_status( # noqa: PLR0911
|
||||
self, agent_id: str
|
||||
) -> AgentState:
|
||||
"""Get agent status from K8s Job status."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
if not self._k8s_batch_v1:
|
||||
return AgentState.OFFLINE
|
||||
|
||||
try:
|
||||
jobs = self._k8s_batch_v1.list_namespaced_job(
|
||||
namespace=settings.k8s_namespace,
|
||||
label_selector=f"agent-id={agent_id}",
|
||||
)
|
||||
|
||||
if not jobs.items:
|
||||
return AgentState.OFFLINE
|
||||
|
||||
# Get most recent job
|
||||
job = sorted(
|
||||
jobs.items,
|
||||
key=lambda j: j.metadata.creation_timestamp or datetime.min,
|
||||
reverse=True,
|
||||
)[0]
|
||||
|
||||
if job.status.succeeded and job.status.succeeded > 0:
|
||||
return AgentState.OFFLINE # Completed successfully
|
||||
if job.status.failed and job.status.failed > 0:
|
||||
return AgentState.OFFLINE # Failed
|
||||
if job.status.active and job.status.active > 0:
|
||||
return AgentState.ACTIVE
|
||||
return AgentState.STARTING
|
||||
|
||||
except ApiException as e:
|
||||
logger.warning(
|
||||
"K8s: failed to get job status", agent_id=agent_id, error=e
|
||||
)
|
||||
return AgentState.OFFLINE
|
||||
|
||||
async def _create_k8s_agent_configmaps(
|
||||
self,
|
||||
agent_id: str,
|
||||
system_prompt: str,
|
||||
mcp_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Create ConfigMaps for agent prompt and MCP config."""
|
||||
from kubernetes import client
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
if not self._k8s_core_v1:
|
||||
return
|
||||
|
||||
# System prompt ConfigMap
|
||||
prompt_cm = client.V1ConfigMap(
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=f"agent-prompt-{agent_id}",
|
||||
namespace=settings.k8s_namespace,
|
||||
labels={"app": "roboco-agent", "agent-id": agent_id},
|
||||
),
|
||||
data={"system-prompt.md": system_prompt},
|
||||
)
|
||||
|
||||
# MCP config ConfigMap
|
||||
mcp_cm = client.V1ConfigMap(
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=f"agent-mcp-{agent_id}",
|
||||
namespace=settings.k8s_namespace,
|
||||
labels={"app": "roboco-agent", "agent-id": agent_id},
|
||||
),
|
||||
data={"mcp-config.json": json.dumps(mcp_config, indent=2)},
|
||||
)
|
||||
|
||||
for cm in [prompt_cm, mcp_cm]:
|
||||
try:
|
||||
# Try to create, update if exists
|
||||
try:
|
||||
self._k8s_core_v1.create_namespaced_config_map(
|
||||
namespace=settings.k8s_namespace,
|
||||
body=cm,
|
||||
)
|
||||
except ApiException as e:
|
||||
# 409 = conflict (already exists), so update instead
|
||||
if e.status == 409: # noqa: PLR2004
|
||||
self._k8s_core_v1.replace_namespaced_config_map(
|
||||
name=cm.metadata.name,
|
||||
namespace=settings.k8s_namespace,
|
||||
body=cm,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
except ApiException as e:
|
||||
logger.error(
|
||||
"K8s: failed to create configmap",
|
||||
name=cm.metadata.name,
|
||||
error=e,
|
||||
)
|
||||
raise
|
||||
|
||||
async def _generate_mcp_config(self, agent_id: str) -> Path:
|
||||
"""Generate role-aware MCP config for an agent.
|
||||
|
||||
@@ -1165,7 +790,7 @@ class AgentOrchestrator:
|
||||
# =========================================================================
|
||||
|
||||
async def stop_agent(self, agent_id: str, graceful: bool = True) -> None:
|
||||
"""Stop an agent container or K8s Job."""
|
||||
"""Stop an agent container."""
|
||||
async with self._lock:
|
||||
if agent_id not in self._instances:
|
||||
return
|
||||
@@ -1174,12 +799,6 @@ class AgentOrchestrator:
|
||||
|
||||
if instance.container_id:
|
||||
instance.state = AgentState.STOPPING
|
||||
|
||||
if settings.k8s_enabled and self._k8s_initialized:
|
||||
# K8s mode: Delete the Job
|
||||
await self._stop_k8s_agent(agent_id)
|
||||
else:
|
||||
# Docker mode: Stop and remove container
|
||||
container_name = f"roboco-agent-{agent_id}"
|
||||
|
||||
if graceful:
|
||||
@@ -1211,9 +830,7 @@ class AgentOrchestrator:
|
||||
instance.state = AgentState.OFFLINE
|
||||
instance.container_id = None
|
||||
|
||||
logger.info(
|
||||
"Agent stopped", agent_id=agent_id, k8s_enabled=settings.k8s_enabled
|
||||
)
|
||||
logger.info("Agent stopped", agent_id=agent_id)
|
||||
|
||||
# =========================================================================
|
||||
# WAITING STATE MANAGEMENT
|
||||
@@ -1369,13 +986,7 @@ Start by:
|
||||
if instance.container_id is None:
|
||||
continue
|
||||
|
||||
# Check if agent is still running
|
||||
if settings.k8s_enabled and self._k8s_initialized:
|
||||
# K8s mode: Check Job status
|
||||
k8s_state = await self._get_k8s_agent_status(agent_id)
|
||||
is_running = k8s_state in (AgentState.ACTIVE, AgentState.STARTING)
|
||||
else:
|
||||
# Docker mode: Check container status
|
||||
# Check if container is still running
|
||||
container_name = f"roboco-agent-{agent_id}"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
@@ -1387,15 +998,15 @@ Start by:
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
|
||||
is_running = stdout.decode().strip() == "true"
|
||||
|
||||
if not is_running:
|
||||
cid = instance.container_id[:12] if instance.container_id else None
|
||||
logger.warning(
|
||||
"Agent container/job stopped",
|
||||
"Agent container stopped",
|
||||
agent_id=agent_id,
|
||||
container_id=cid,
|
||||
k8s_enabled=settings.k8s_enabled,
|
||||
)
|
||||
instance.state = AgentState.OFFLINE
|
||||
instance.error_count += 1
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
"""
|
||||
A2A (Agent-to-Agent) Protocol Service
|
||||
|
||||
Provides business logic for A2A protocol operations including:
|
||||
- Agent discovery and card generation
|
||||
- Task lifecycle management via A2A semantics
|
||||
- Message handling and routing
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, TaskTable
|
||||
from roboco.models.a2a import (
|
||||
A2AArtifact,
|
||||
A2AMessage,
|
||||
A2ATask,
|
||||
A2ATaskStatus,
|
||||
AgentCapabilities,
|
||||
AgentCard,
|
||||
AgentProvider,
|
||||
AgentSkill,
|
||||
SecurityScheme,
|
||||
TextPart,
|
||||
task_status_to_a2a_state,
|
||||
)
|
||||
from roboco.models.base import TaskStatus, Team
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class A2AService:
|
||||
"""
|
||||
Service layer for A2A protocol operations.
|
||||
|
||||
Provides methods for:
|
||||
- Building Agent Cards for discovery
|
||||
- Converting between RoboCo tasks and A2A tasks
|
||||
- Processing A2A messages
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
"""Initialize with database session."""
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
def get_service_endpoint() -> str:
|
||||
"""Build service endpoint URL from settings."""
|
||||
connect_host = "127.0.0.1" if settings.host == "0.0.0.0" else settings.host
|
||||
return f"http://{connect_host}:{settings.port}"
|
||||
|
||||
@staticmethod
|
||||
def build_system_agent_card() -> AgentCard:
|
||||
"""
|
||||
Build the system-level Agent Card for RoboCo.
|
||||
|
||||
This card represents the entire RoboCo system and is served
|
||||
at /.well-known/agent.json
|
||||
"""
|
||||
return AgentCard(
|
||||
id="roboco-system",
|
||||
name="RoboCo System",
|
||||
description=(
|
||||
"RoboCo is an AI Agentic Company - a virtual organization of "
|
||||
"AI agents designed to operate as a complete software "
|
||||
"development workforce."
|
||||
),
|
||||
provider=AgentProvider(
|
||||
organization="RoboCo",
|
||||
url="https://github.com/roboco",
|
||||
),
|
||||
protocol_version="1.0",
|
||||
service_endpoint=f"{A2AService.get_service_endpoint()}/api/v1/a2a",
|
||||
version=settings.app_version,
|
||||
capabilities=AgentCapabilities(
|
||||
streaming=True,
|
||||
push_notifications=False,
|
||||
state_transition_history=True,
|
||||
),
|
||||
default_input_modes=["text/plain", "application/json"],
|
||||
default_output_modes=["text/plain", "application/json"],
|
||||
skills=[
|
||||
AgentSkill(
|
||||
id="software-development",
|
||||
name="Software Development",
|
||||
description="Full-stack software development with AI agents",
|
||||
tags=["development", "coding", "qa", "documentation"],
|
||||
),
|
||||
AgentSkill(
|
||||
id="task-management",
|
||||
name="Task Management",
|
||||
description="Create and manage development tasks",
|
||||
tags=["tasks", "kanban", "planning"],
|
||||
),
|
||||
AgentSkill(
|
||||
id="code-review",
|
||||
name="Code Review",
|
||||
description="Review and quality assurance of code",
|
||||
tags=["qa", "review", "testing"],
|
||||
),
|
||||
],
|
||||
documentation_url="https://github.com/roboco/docs",
|
||||
security_schemes={
|
||||
"bearerAuth": SecurityScheme(type="http", scheme="bearer"),
|
||||
},
|
||||
security=[{"bearerAuth": []}],
|
||||
)
|
||||
|
||||
async def build_agent_card(self, agent_id: str) -> AgentCard | None:
|
||||
"""
|
||||
Build an Agent Card for a specific agent.
|
||||
|
||||
Args:
|
||||
agent_id: Either a UUID string or agent slug
|
||||
|
||||
Returns:
|
||||
AgentCard for the agent, or None if not found
|
||||
"""
|
||||
# Try to parse as UUID first
|
||||
try:
|
||||
uuid = UUID(agent_id)
|
||||
result = await self.session.execute(
|
||||
select(AgentTable).where(AgentTable.id == uuid)
|
||||
)
|
||||
except ValueError:
|
||||
# Not a UUID, try slug lookup
|
||||
result = await self.session.execute(
|
||||
select(AgentTable).where(AgentTable.slug == agent_id)
|
||||
)
|
||||
|
||||
agent = result.scalar_one_or_none()
|
||||
if agent is None:
|
||||
return None
|
||||
|
||||
return self._agent_to_card(agent)
|
||||
|
||||
def _agent_to_card(self, agent: AgentTable) -> AgentCard:
|
||||
"""Convert an AgentTable row to an AgentCard."""
|
||||
agent_id = str(agent.id)
|
||||
agent_slug = agent.slug
|
||||
|
||||
# Map role to skills
|
||||
role_skills: dict[str, list[AgentSkill]] = {
|
||||
"developer": [
|
||||
AgentSkill(
|
||||
id="coding",
|
||||
name="Code Development",
|
||||
description="Write and implement code",
|
||||
tags=["development", "coding"],
|
||||
),
|
||||
AgentSkill(
|
||||
id="debugging",
|
||||
name="Debugging",
|
||||
description="Debug and fix code issues",
|
||||
tags=["debugging", "troubleshooting"],
|
||||
),
|
||||
],
|
||||
"qa": [
|
||||
AgentSkill(
|
||||
id="testing",
|
||||
name="Testing",
|
||||
description="Test code and verify quality",
|
||||
tags=["qa", "testing"],
|
||||
),
|
||||
AgentSkill(
|
||||
id="review",
|
||||
name="Code Review",
|
||||
description="Review code for quality and issues",
|
||||
tags=["qa", "review"],
|
||||
),
|
||||
],
|
||||
"documenter": [
|
||||
AgentSkill(
|
||||
id="documentation",
|
||||
name="Documentation",
|
||||
description="Write technical documentation",
|
||||
tags=["documentation", "writing"],
|
||||
),
|
||||
],
|
||||
"cell_pm": [
|
||||
AgentSkill(
|
||||
id="coordination",
|
||||
name="Task Coordination",
|
||||
description="Coordinate tasks within the cell",
|
||||
tags=["management", "coordination"],
|
||||
),
|
||||
],
|
||||
"main_pm": [
|
||||
AgentSkill(
|
||||
id="planning",
|
||||
name="Project Planning",
|
||||
description="Plan and coordinate across cells",
|
||||
tags=["management", "planning"],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
skills = role_skills.get(agent.role, [])
|
||||
|
||||
return AgentCard(
|
||||
id=agent_id,
|
||||
name=agent.name,
|
||||
description=f"{agent.name} - {agent.role} agent in RoboCo",
|
||||
provider=AgentProvider(
|
||||
organization="RoboCo",
|
||||
url="https://github.com/roboco",
|
||||
),
|
||||
protocol_version="1.0",
|
||||
service_endpoint=f"{self.get_service_endpoint()}/api/v1/a2a",
|
||||
version=settings.app_version,
|
||||
capabilities=AgentCapabilities(
|
||||
streaming=True,
|
||||
push_notifications=False,
|
||||
state_transition_history=True,
|
||||
),
|
||||
default_input_modes=["text/plain", "application/json"],
|
||||
default_output_modes=["text/plain", "application/json"],
|
||||
skills=skills,
|
||||
metadata={
|
||||
"slug": agent_slug,
|
||||
"role": agent.role,
|
||||
"team": agent.team,
|
||||
},
|
||||
security_schemes={
|
||||
"bearerAuth": SecurityScheme(type="http", scheme="bearer"),
|
||||
},
|
||||
security=[{"bearerAuth": []}],
|
||||
)
|
||||
|
||||
def task_to_a2a(self, task: TaskTable) -> A2ATask:
|
||||
"""
|
||||
Convert a RoboCo TaskTable to A2A Task.
|
||||
|
||||
This is the canonical conversion that maintains semantic
|
||||
mapping between RoboCo's internal task model and A2A.
|
||||
"""
|
||||
task_id = str(task.id)
|
||||
|
||||
# Get status value as string
|
||||
if hasattr(task.status, "value"):
|
||||
status_value = task.status.value
|
||||
else:
|
||||
status_value = str(task.status)
|
||||
|
||||
a2a_state = task_status_to_a2a_state(status_value)
|
||||
|
||||
# Build status message from dev_notes if present
|
||||
status_message = None
|
||||
if task.dev_notes:
|
||||
status_message = A2AMessage(
|
||||
role="agent",
|
||||
parts=[TextPart(text=task.dev_notes)],
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
a2a_status = A2ATaskStatus(
|
||||
state=a2a_state,
|
||||
message=status_message,
|
||||
timestamp=task.updated_at or task.created_at,
|
||||
)
|
||||
|
||||
# Build artifacts from task outputs (future: populate from outputs)
|
||||
artifacts: list[A2AArtifact] = []
|
||||
|
||||
# Build metadata
|
||||
metadata: dict[str, str | int] = {
|
||||
"roboco_status": status_value,
|
||||
"priority": task.priority,
|
||||
"team": str(task.team),
|
||||
}
|
||||
if task.assigned_to:
|
||||
metadata["assigned_to"] = str(task.assigned_to)
|
||||
if task.parent_task_id:
|
||||
metadata["parent_task_id"] = str(task.parent_task_id)
|
||||
|
||||
return A2ATask(
|
||||
id=task_id,
|
||||
context_id=task_id,
|
||||
status=a2a_status,
|
||||
artifacts=artifacts,
|
||||
history=[],
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def get_task(self, task_id: str) -> A2ATask | None:
|
||||
"""
|
||||
Get a task by ID and return as A2A Task.
|
||||
|
||||
Args:
|
||||
task_id: Task UUID string
|
||||
|
||||
Returns:
|
||||
A2ATask or None if not found
|
||||
"""
|
||||
try:
|
||||
task_uuid = UUID(task_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TaskTable).where(TaskTable.id == task_uuid)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
return None
|
||||
|
||||
return self.task_to_a2a(task)
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
page_size: int = 20,
|
||||
offset: int = 0,
|
||||
order_by: str | None = None,
|
||||
) -> tuple[list[A2ATask], bool]:
|
||||
"""
|
||||
List tasks with pagination.
|
||||
|
||||
Args:
|
||||
page_size: Number of results to return
|
||||
offset: Starting offset
|
||||
order_by: Sort order ("created_at desc" or "created_at asc")
|
||||
|
||||
Returns:
|
||||
Tuple of (tasks, has_more)
|
||||
"""
|
||||
query = select(TaskTable)
|
||||
|
||||
# Apply ordering
|
||||
if order_by == "created_at asc":
|
||||
query = query.order_by(TaskTable.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(TaskTable.created_at.desc())
|
||||
|
||||
# Apply pagination (fetch one extra to detect more)
|
||||
query = query.offset(offset).limit(page_size + 1)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
tasks = list(result.scalars().all())
|
||||
|
||||
has_more = len(tasks) > page_size
|
||||
if has_more:
|
||||
tasks = tasks[:page_size]
|
||||
|
||||
return [self.task_to_a2a(t) for t in tasks], has_more
|
||||
|
||||
async def create_task_from_message(
|
||||
self,
|
||||
title: str,
|
||||
description: str,
|
||||
created_by: UUID,
|
||||
team: Team = Team.BACKEND,
|
||||
) -> A2ATask:
|
||||
"""
|
||||
Create a new task from an A2A message.
|
||||
|
||||
Args:
|
||||
title: Task title
|
||||
description: Task description
|
||||
created_by: Agent ID creating the task
|
||||
team: Team assignment
|
||||
|
||||
Returns:
|
||||
Created A2ATask
|
||||
"""
|
||||
task = TaskTable(
|
||||
title=title,
|
||||
description=description,
|
||||
acceptance_criteria=["Task completed as specified"],
|
||||
status=TaskStatus.PENDING,
|
||||
priority=5,
|
||||
team=team,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.session.add(task)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(task)
|
||||
|
||||
logger.info(
|
||||
"Created task from A2A message",
|
||||
task_id=str(task.id),
|
||||
title=title,
|
||||
)
|
||||
|
||||
return self.task_to_a2a(task)
|
||||
|
||||
async def cancel_task(self, task_id: str, reason: str | None = None) -> A2ATask:
|
||||
"""
|
||||
Cancel a task.
|
||||
|
||||
Args:
|
||||
task_id: Task UUID string
|
||||
reason: Optional cancellation reason
|
||||
|
||||
Returns:
|
||||
Updated A2ATask
|
||||
|
||||
Raises:
|
||||
ValueError: If task not found or already in terminal state
|
||||
"""
|
||||
try:
|
||||
task_uuid = UUID(task_id)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid task ID: {task_id}") from e
|
||||
|
||||
result = await self.session.execute(
|
||||
select(TaskTable).where(TaskTable.id == task_uuid)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if task is None:
|
||||
raise ValueError(f"Task not found: {task_id}")
|
||||
|
||||
# Check if cancellable
|
||||
if hasattr(task.status, "value"):
|
||||
status_value = task.status.value
|
||||
else:
|
||||
status_value = str(task.status)
|
||||
|
||||
if status_value in ["completed", "cancelled"]:
|
||||
raise ValueError(f"Task already in terminal state: {status_value}")
|
||||
|
||||
# Cancel the task
|
||||
task.status = TaskStatus.CANCELLED
|
||||
if reason:
|
||||
reason_text = f"Cancellation reason: {reason}"
|
||||
if task.dev_notes:
|
||||
task.dev_notes = f"{task.dev_notes}\n\n{reason_text}"
|
||||
else:
|
||||
task.dev_notes = reason_text
|
||||
|
||||
await self.session.flush()
|
||||
await self.session.refresh(task)
|
||||
|
||||
logger.info("Cancelled task via A2A", task_id=task_id, reason=reason)
|
||||
|
||||
return self.task_to_a2a(task)
|
||||
|
||||
async def discover_agents(
|
||||
self,
|
||||
role: str | None = None,
|
||||
team: str | None = None,
|
||||
skill_tag: str | None = None,
|
||||
) -> list[AgentCard]:
|
||||
"""
|
||||
Discover agents matching criteria.
|
||||
|
||||
Args:
|
||||
role: Filter by agent role
|
||||
team: Filter by team
|
||||
skill_tag: Filter by skill tag (future)
|
||||
|
||||
Returns:
|
||||
List of matching AgentCards
|
||||
"""
|
||||
query = select(AgentTable)
|
||||
|
||||
if role:
|
||||
query = query.where(AgentTable.role == role)
|
||||
if team:
|
||||
query = query.where(AgentTable.team == team)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
agents = result.scalars().all()
|
||||
|
||||
cards = [self._agent_to_card(agent) for agent in agents]
|
||||
|
||||
# Filter by skill tag if specified
|
||||
if skill_tag:
|
||||
cards = [
|
||||
card
|
||||
for card in cards
|
||||
if any(skill_tag in skill.tags for skill in card.skills)
|
||||
]
|
||||
|
||||
return cards
|
||||
@@ -401,15 +401,6 @@ filecache = [
|
||||
{ name = "filelock" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "6.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
@@ -773,15 +764,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/26/035d1c308882514a1e6ddca27f9d3e570d67a0e293e7b4d910a70c8fe32b/dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57", size = 11925, upload-time = "2024-11-08T16:52:03.844Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "durationpy"
|
||||
version = "0.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.1"
|
||||
@@ -933,20 +915,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e8/68e274b2a30e1fdfd25bdc27194382be3f233929c8f727c0440d58ac074f/glom-22.1.0-py2.py3-none-any.whl", hash = "sha256:5339da206bf3532e01a83a35aca202960ea885156986d190574b779598e9e772", size = 100687, upload-time = "2022-01-24T09:34:02.391Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-auth"
|
||||
version = "2.45.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
{ name = "pyasn1-modules" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/00/3c794502a8b892c404b2dea5b3650eb21bfc7069612fbfd15c7f17c1cb0d/google_auth-2.45.0.tar.gz", hash = "sha256:90d3f41b6b72ea72dd9811e765699ee491ab24139f34ebf1ca2b9cc0c38708f3", size = 320708, upload-time = "2025-12-15T22:58:42.889Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl", hash = "sha256:82344e86dc00410ef5382d99be677c6043d72e502b625aa4f4afa0bdacca0f36", size = 233312, upload-time = "2025-12-15T22:58:40.777Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "googleapis-common-protos"
|
||||
version = "1.72.0"
|
||||
@@ -1360,27 +1328,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kubernetes"
|
||||
version = "34.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "durationpy" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-oauthlib" },
|
||||
{ name = "six" },
|
||||
{ name = "urllib3" },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/55/3f880ef65f559cbed44a9aa20d3bdbc219a2c3a3bac4a30a513029b03ee9/kubernetes-34.1.0.tar.gz", hash = "sha256:8fe8edb0b5d290a2f3ac06596b23f87c658977d46b5f8df9d0f4ea83d0003912", size = 1083771, upload-time = "2025-09-29T20:23:49.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ec/65f7d563aa4a62dd58777e8f6aa882f15db53b14eb29aba0c28a20f7eb26/kubernetes-34.1.0-py2.py3-none-any.whl", hash = "sha256:bffba2272534e224e6a7a74d582deb0b545b7c9879d2cd9e4aae9481d1f2cc2a", size = 2008380, upload-time = "2025-09-29T20:23:47.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace"
|
||||
version = "0.4.0"
|
||||
@@ -2120,7 +2067,7 @@ name = "nvidia-cudnn-cu12"
|
||||
version = "9.10.2.21"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" },
|
||||
{ name = "nvidia-cublas-cu12" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
|
||||
@@ -2131,7 +2078,7 @@ name = "nvidia-cufft-cu12"
|
||||
version = "11.3.3.83"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" },
|
||||
{ name = "nvidia-nvjitlink-cu12" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
|
||||
@@ -2158,9 +2105,9 @@ name = "nvidia-cusolver-cu12"
|
||||
version = "11.7.3.90"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" },
|
||||
{ name = "nvidia-cusparse-cu12", marker = "sys_platform != 'win32'" },
|
||||
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" },
|
||||
{ name = "nvidia-cublas-cu12" },
|
||||
{ name = "nvidia-cusparse-cu12" },
|
||||
{ name = "nvidia-nvjitlink-cu12" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
|
||||
@@ -2171,7 +2118,7 @@ name = "nvidia-cusparse-cu12"
|
||||
version = "12.5.8.93"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" },
|
||||
{ name = "nvidia-nvjitlink-cu12" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
|
||||
@@ -2217,15 +2164,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "olefile"
|
||||
version = "0.47"
|
||||
@@ -2531,7 +2469,7 @@ name = "pexpect"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "ptyprocess" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
|
||||
wheels = [
|
||||
@@ -2840,18 +2778,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
@@ -3326,19 +3252,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests-oauthlib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "oauthlib" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requirements-parser"
|
||||
version = "0.13.0"
|
||||
@@ -3376,7 +3289,6 @@ dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "hiredis" },
|
||||
{ name = "httpx" },
|
||||
{ name = "kubernetes" },
|
||||
{ name = "mcp" },
|
||||
{ name = "openai" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
@@ -3388,7 +3300,6 @@ dependencies = [
|
||||
{ name = "python-toon" },
|
||||
{ name = "redis" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "structlog" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tiktoken" },
|
||||
@@ -3447,7 +3358,6 @@ requires-dist = [
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx", marker = "extra == 'dev'" },
|
||||
{ name = "ipython", marker = "extra == 'dev'" },
|
||||
{ name = "kubernetes" },
|
||||
{ name = "mcp" },
|
||||
{ name = "mkdocs", marker = "extra == 'docs'" },
|
||||
{ name = "mkdocs-material", marker = "extra == 'docs'" },
|
||||
@@ -3473,7 +3383,6 @@ requires-dist = [
|
||||
{ name = "safety", marker = "extra == 'dev'" },
|
||||
{ name = "semgrep", marker = "extra == 'dev'" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"] },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "structlog" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tiktoken" },
|
||||
@@ -4379,11 +4288,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.3.0"
|
||||
version = "2.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4544,15 +4453,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
|
||||
Reference in New Issue
Block a user