Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ config.json
/src/octopal/tools/communication/__pycache__
/src/octopal/mcp_servers/__pycache__
/src/octopal/infrastructure/observability/__pycache__
/src/octopal/interop/__pycache__
/src/octopal/interop/a2a/__pycache__
4 changes: 3 additions & 1 deletion src/octopal/gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from octopal.gateway.dashboard import register_dashboard_routes
from octopal.gateway.ws import register_ws_routes
from octopal.infrastructure.config.settings import Settings
from octopal.interop.a2a.routes import register_a2a_routes
from octopal.runtime.octo.core import Octo
from octopal.tools.skills.management import ensure_skills_layout

Expand All @@ -34,7 +35,8 @@ def build_app(settings: Settings, octo: Octo | None = None) -> FastAPI:
app.state.memory = octo.memory
app.state.canon = octo.canon

register_a2a_routes(app)
register_ws_routes(app)
register_dashboard_routes(app)
register_whatsapp_routes(app)
register_dashboard_routes(app)
return app
24 changes: 24 additions & 0 deletions src/octopal/infrastructure/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,29 @@ class ObservabilityConfig(BaseModel):
langfuse_host: str | None = None


class A2APeerConfig(BaseModel):
enabled: bool = True
name: str | None = None
agent_card_url: str | None = None
base_url: str | None = None
token: str | None = None
capabilities: list[str] = Field(default_factory=lambda: ["chat"])
trust_level: str = "trusted"


class A2AConfig(BaseModel):
enabled: bool = False
public_base_url: str | None = None
agent_name: str = "Octopal"
agent_description: str = (
"A personal AI agent with memory, scheduled tasks, and worker orchestration."
)
protocol_version: str = "1.0"
max_payload_chars: int = 16000
max_requests_per_minute: int = 30
peers: dict[str, A2APeerConfig] = Field(default_factory=dict)


class ConnectorCredentials(BaseModel):
client_id: str | None = None
client_secret: str | None = None
Expand Down Expand Up @@ -162,6 +185,7 @@ class OctopalConfig(BaseModel):
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
search: SearchConfig = Field(default_factory=SearchConfig)
observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
a2a: A2AConfig = Field(default_factory=A2AConfig)
connectors: ConnectorsConfig = Field(default_factory=ConnectorsConfig)

log_level: str = "INFO"
Expand Down
6 changes: 5 additions & 1 deletion src/octopal/infrastructure/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic_settings import BaseSettings, SettingsConfigDict

from octopal.channels import DEFAULT_USER_CHANNEL
from octopal.infrastructure.config.models import ConnectorsConfig, OctopalConfig
from octopal.infrastructure.config.models import A2AConfig, ConnectorsConfig, OctopalConfig


class Settings(BaseSettings):
Expand Down Expand Up @@ -114,6 +114,7 @@ class Settings(BaseSettings):

# Connectors
connectors: ConnectorsConfig = Field(default_factory=ConnectorsConfig)
a2a: A2AConfig = Field(default_factory=A2AConfig)

# Comma-separated list of Telegram chat IDs allowed to interact with the octo
# Get your chat ID by messaging @userinfobot on Telegram
Expand Down Expand Up @@ -276,6 +277,9 @@ def _sync_settings_from_config(settings: Settings, config: OctopalConfig) -> Non
updates["langfuse_secret_key"] = config.observability.langfuse_secret_key
updates["langfuse_host"] = config.observability.langfuse_host

# A2A interop
updates["a2a"] = config.a2a

# Common
updates["log_level"] = config.log_level
updates["debug_prompts"] = config.debug_prompts
Expand Down
2 changes: 2 additions & 0 deletions src/octopal/interop/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Interop modules for external agent protocols."""

2 changes: 2 additions & 0 deletions src/octopal/interop/a2a/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Minimal A2A protocol integration."""

52 changes: 52 additions & 0 deletions src/octopal/interop/a2a/agent_card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from __future__ import annotations

from urllib.parse import urljoin

from octopal.infrastructure.config.models import A2AConfig


def build_agent_card(config: A2AConfig, *, base_url: str) -> dict[str, object]:
root_url = (config.public_base_url or base_url).rstrip("/") + "/"
interface_url = urljoin(root_url, "a2a/v1")
return {
"name": config.agent_name,
"description": config.agent_description,
"version": "1.0.0",
"supportedInterfaces": [
{
"url": interface_url,
"protocolBinding": "HTTP+JSON",
"protocolVersion": config.protocol_version,
}
],
"capabilities": {
"streaming": False,
"pushNotifications": False,
"extendedAgentCard": False,
},
"securitySchemes": {
"peerBearer": {
"type": "http",
"scheme": "bearer",
"description": "Invite-only peer token configured in Octopal.",
}
},
"securityRequirements": [{"peerBearer": []}],
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [
{
"id": "peer-chat",
"name": "Trusted Peer Chat",
"description": (
"Accepts text messages from authenticated trusted peer agents and "
"routes them through Octopal policy."
),
"tags": ["chat", "agent-to-agent", "trusted-peer"],
"examples": ["Send a private note to this Octopal instance."],
"inputModes": ["text/plain"],
"outputModes": ["text/plain"],
}
],
}

67 changes: 67 additions & 0 deletions src/octopal/interop/a2a/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from __future__ import annotations

from typing import Any
from uuid import uuid4

import httpx

from octopal.infrastructure.config.models import A2AConfig, A2APeerConfig


class A2AClientError(RuntimeError):
pass


async def send_peer_message(
config: A2AConfig,
*,
peer_id: str,
text: str,
context_id: str | None = None,
timeout_seconds: float = 60.0,
) -> dict[str, Any]:
peer = config.peers.get(peer_id)
if peer is None or not peer.enabled:
raise A2AClientError(f"A2A peer {peer_id!r} is not configured or enabled.")
if "chat" not in {item.strip().lower() for item in peer.capabilities}:
raise A2AClientError(f"A2A peer {peer_id!r} does not allow chat.")
endpoint = _message_send_endpoint(peer)
token = str(peer.token or "").strip()
if not token:
raise A2AClientError(f"A2A peer {peer_id!r} has no bearer token configured.")

payload = {
"message": {
"role": "ROLE_USER",
"parts": [{"text": text}],
"messageId": f"octopal-message-{uuid4().hex}",
"contextId": context_id or f"octopal-peer-{peer_id}",
}
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"A2A-Version": config.protocol_version,
}
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.post(endpoint, headers=headers, json=payload)
if response.status_code >= 400:
raise A2AClientError(
f"A2A peer {peer_id!r} returned HTTP {response.status_code}: {response.text[:500]}"
)
data = response.json()
if not isinstance(data, dict):
raise A2AClientError(f"A2A peer {peer_id!r} returned a non-object response.")
return data


def _message_send_endpoint(peer: A2APeerConfig) -> str:
base_url = str(peer.base_url or "").strip()
if not base_url:
card_url = str(peer.agent_card_url or "").strip()
suffix = "/.well-known/agent-card.json"
if card_url.endswith(suffix):
base_url = card_url[: -len(suffix)] + "/a2a/v1"
if not base_url:
raise A2AClientError("A2A peer requires base_url or agent_card_url.")
return base_url.rstrip("/") + "/message:send"
35 changes: 35 additions & 0 deletions src/octopal/interop/a2a/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

from typing import Any

from pydantic import BaseModel, ConfigDict, Field


class A2APart(BaseModel):
model_config = ConfigDict(extra="allow")

text: str | None = None


class A2AMessage(BaseModel):
model_config = ConfigDict(extra="allow")

role: str = "ROLE_USER"
parts: list[A2APart] = Field(default_factory=list)
message_id: str | None = Field(default=None, alias="messageId")
context_id: str | None = Field(default=None, alias="contextId")
task_id: str | None = Field(default=None, alias="taskId")
metadata: dict[str, Any] = Field(default_factory=dict)


class A2AMessageSendRequest(BaseModel):
model_config = ConfigDict(extra="allow")

message: A2AMessage
metadata: dict[str, Any] = Field(default_factory=dict)


def message_text(message: A2AMessage) -> str:
parts = [str(part.text or "").strip() for part in message.parts]
return "\n\n".join(part for part in parts if part).strip()

Loading