adcp 0.1.2__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adcp/config.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ """Configuration management for AdCP CLI."""
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ CONFIG_DIR = Path.home() / ".adcp"
10
+ CONFIG_FILE = CONFIG_DIR / "config.json"
11
+
12
+
13
+ def ensure_config_dir() -> None:
14
+ """Ensure config directory exists."""
15
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
16
+
17
+
18
+ def load_config() -> dict[str, Any]:
19
+ """Load configuration file."""
20
+ if not CONFIG_FILE.exists():
21
+ return {"agents": {}}
22
+
23
+ with open(CONFIG_FILE) as f:
24
+ return cast(dict[str, Any], json.load(f))
25
+
26
+
27
+ def save_config(config: dict[str, Any]) -> None:
28
+ """Save configuration file with atomic write."""
29
+ ensure_config_dir()
30
+
31
+ # Write to temporary file first
32
+ temp_file = CONFIG_FILE.with_suffix(".tmp")
33
+ with open(temp_file, "w") as f:
34
+ json.dump(config, f, indent=2)
35
+
36
+ # Atomic rename
37
+ temp_file.replace(CONFIG_FILE)
38
+
39
+
40
+ def save_agent(
41
+ alias: str, url: str, protocol: str | None = None, auth_token: str | None = None
42
+ ) -> None:
43
+ """Save agent configuration."""
44
+ config = load_config()
45
+
46
+ if "agents" not in config:
47
+ config["agents"] = {}
48
+
49
+ config["agents"][alias] = {
50
+ "agent_uri": url,
51
+ "protocol": protocol or "mcp",
52
+ }
53
+
54
+ if auth_token:
55
+ config["agents"][alias]["auth_token"] = auth_token
56
+
57
+ save_config(config)
58
+
59
+
60
+ def get_agent(alias: str) -> dict[str, Any] | None:
61
+ """Get agent configuration by alias."""
62
+ config = load_config()
63
+ result = config.get("agents", {}).get(alias)
64
+ return cast(dict[str, Any], result) if result is not None else None
65
+
66
+
67
+ def list_agents() -> dict[str, Any]:
68
+ """List all saved agents."""
69
+ config = load_config()
70
+ return cast(dict[str, Any], config.get("agents", {}))
71
+
72
+
73
+ def remove_agent(alias: str) -> bool:
74
+ """Remove agent configuration."""
75
+ config = load_config()
76
+
77
+ if alias in config.get("agents", {}):
78
+ del config["agents"][alias]
79
+ save_config(config)
80
+ return True
81
+
82
+ return False
adcp/exceptions.py ADDED
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ """Exception hierarchy for AdCP client."""
4
+
5
+
6
+ class ADCPError(Exception):
7
+ """Base exception for all AdCP client errors."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ agent_id: str | None = None,
13
+ agent_uri: str | None = None,
14
+ suggestion: str | None = None,
15
+ ):
16
+ """Initialize exception with context."""
17
+ self.message = message
18
+ self.agent_id = agent_id
19
+ self.agent_uri = agent_uri
20
+ self.suggestion = suggestion
21
+
22
+ full_message = message
23
+ if agent_id:
24
+ full_message = f"[Agent: {agent_id}] {full_message}"
25
+ if agent_uri:
26
+ full_message = f"{full_message}\n URI: {agent_uri}"
27
+ if suggestion:
28
+ full_message = f"{full_message}\n 💡 {suggestion}"
29
+
30
+ super().__init__(full_message)
31
+
32
+
33
+ class ADCPConnectionError(ADCPError):
34
+ """Connection to agent failed."""
35
+
36
+ def __init__(self, message: str, agent_id: str | None = None, agent_uri: str | None = None):
37
+ """Initialize connection error."""
38
+ suggestion = (
39
+ "Check that the agent URI is correct and the agent is running.\n"
40
+ " Try testing with: python -m adcp test --config <agent-id>"
41
+ )
42
+ super().__init__(message, agent_id, agent_uri, suggestion)
43
+
44
+
45
+ class ADCPAuthenticationError(ADCPError):
46
+ """Authentication failed (401, 403)."""
47
+
48
+ def __init__(self, message: str, agent_id: str | None = None, agent_uri: str | None = None):
49
+ """Initialize authentication error."""
50
+ suggestion = (
51
+ "Check that your auth_token is valid and not expired.\n"
52
+ " Verify auth_type ('bearer' vs 'token') and auth_header are correct.\n"
53
+ " Some agents (like Optable) require auth_type='bearer' and "
54
+ "auth_header='Authorization'"
55
+ )
56
+ super().__init__(message, agent_id, agent_uri, suggestion)
57
+
58
+
59
+ class ADCPTimeoutError(ADCPError):
60
+ """Request timed out."""
61
+
62
+ def __init__(
63
+ self,
64
+ message: str,
65
+ agent_id: str | None = None,
66
+ agent_uri: str | None = None,
67
+ timeout: float | None = None,
68
+ ):
69
+ """Initialize timeout error."""
70
+ suggestion = (
71
+ f"The request took longer than {timeout}s." if timeout else "The request timed out."
72
+ )
73
+ suggestion += "\n Try increasing the timeout value or check if the agent is overloaded."
74
+ super().__init__(message, agent_id, agent_uri, suggestion)
75
+
76
+
77
+ class ADCPProtocolError(ADCPError):
78
+ """Protocol-level error (malformed response, unexpected format)."""
79
+
80
+ def __init__(self, message: str, agent_id: str | None = None, protocol: str | None = None):
81
+ """Initialize protocol error."""
82
+ suggestion = (
83
+ f"The agent returned an unexpected {protocol} response format."
84
+ if protocol
85
+ else "Unexpected response format."
86
+ )
87
+ suggestion += "\n Enable debug mode to see the full request/response."
88
+ super().__init__(message, agent_id, None, suggestion)
89
+
90
+
91
+ class ADCPToolNotFoundError(ADCPError):
92
+ """Requested tool not found on agent."""
93
+
94
+ def __init__(
95
+ self, tool_name: str, agent_id: str | None = None, available_tools: list[str] | None = None
96
+ ):
97
+ """Initialize tool not found error."""
98
+ message = f"Tool '{tool_name}' not found on agent"
99
+ suggestion = "List available tools with: python -m adcp list-tools --config <agent-id>"
100
+ if available_tools:
101
+ tools_list = ", ".join(available_tools[:5])
102
+ if len(available_tools) > 5:
103
+ tools_list += f", ... ({len(available_tools)} total)"
104
+ suggestion = f"Available tools: {tools_list}"
105
+ super().__init__(message, agent_id, None, suggestion)
106
+
107
+
108
+ class ADCPWebhookError(ADCPError):
109
+ """Webhook handling error."""
110
+
111
+
112
+ class ADCPWebhookSignatureError(ADCPWebhookError):
113
+ """Webhook signature verification failed."""
114
+
115
+ def __init__(self, message: str = "Invalid webhook signature", agent_id: str | None = None):
116
+ """Initialize webhook signature error."""
117
+ suggestion = (
118
+ "Verify that the webhook_secret matches the secret configured on the agent.\n"
119
+ " Webhook signatures use HMAC-SHA256 for security."
120
+ )
121
+ super().__init__(message, agent_id, None, suggestion)
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  """Protocol adapters for AdCP."""
2
4
 
3
5
  from adcp.protocols.a2a import A2AAdapter
adcp/protocols/a2a.py CHANGED
@@ -1,21 +1,59 @@
1
+ from __future__ import annotations
2
+
1
3
  """A2A protocol adapter using HTTP client.
2
4
 
3
5
  The official a2a-sdk is primarily for building A2A servers. For client functionality,
4
6
  we implement the A2A protocol using HTTP requests as per the A2A specification.
5
7
  """
6
8
 
9
+ import logging
10
+ import time
7
11
  from typing import Any
8
12
  from uuid import uuid4
9
13
 
10
14
  import httpx
11
15
 
16
+ from adcp.exceptions import (
17
+ ADCPAuthenticationError,
18
+ ADCPConnectionError,
19
+ ADCPTimeoutError,
20
+ )
12
21
  from adcp.protocols.base import ProtocolAdapter
13
- from adcp.types.core import TaskResult, TaskStatus
22
+ from adcp.types.core import AgentConfig, DebugInfo, TaskResult, TaskStatus
23
+
24
+ logger = logging.getLogger(__name__)
14
25
 
15
26
 
16
27
  class A2AAdapter(ProtocolAdapter):
17
28
  """Adapter for A2A protocol following the Agent2Agent specification."""
18
29
 
30
+ def __init__(self, agent_config: AgentConfig):
31
+ """Initialize A2A adapter with reusable HTTP client."""
32
+ super().__init__(agent_config)
33
+ self._client: httpx.AsyncClient | None = None
34
+
35
+ async def _get_client(self) -> httpx.AsyncClient:
36
+ """Get or create the HTTP client with connection pooling."""
37
+ if self._client is None:
38
+ # Configure connection pooling for better performance
39
+ limits = httpx.Limits(
40
+ max_keepalive_connections=10,
41
+ max_connections=20,
42
+ keepalive_expiry=30.0,
43
+ )
44
+ self._client = httpx.AsyncClient(limits=limits)
45
+ logger.debug(
46
+ f"Created HTTP client with connection pooling for agent {self.agent_config.id}"
47
+ )
48
+ return self._client
49
+
50
+ async def close(self) -> None:
51
+ """Close the HTTP client and clean up resources."""
52
+ if self._client is not None:
53
+ logger.debug(f"Closing A2A adapter client for agent {self.agent_config.id}")
54
+ await self._client.aclose()
55
+ self._client = None
56
+
19
57
  async def call_tool(self, tool_name: str, params: dict[str, Any]) -> TaskResult[Any]:
20
58
  """
21
59
  Call a tool using A2A protocol.
@@ -23,80 +61,117 @@ class A2AAdapter(ProtocolAdapter):
23
61
  A2A uses a tasks/send endpoint to initiate tasks. The agent responds with
24
62
  task status and may require multiple roundtrips for completion.
25
63
  """
26
- async with httpx.AsyncClient() as client:
27
- headers = {"Content-Type": "application/json"}
28
-
29
- if self.agent_config.auth_token:
30
- headers["Authorization"] = f"Bearer {self.agent_config.auth_token}"
31
-
32
- # Construct A2A message
33
- message = {
34
- "role": "user",
35
- "parts": [
36
- {
37
- "type": "text",
38
- "text": self._format_tool_request(tool_name, params),
39
- }
40
- ],
64
+ start_time = time.time() if self.agent_config.debug else None
65
+ client = await self._get_client()
66
+
67
+ headers = {"Content-Type": "application/json"}
68
+
69
+ if self.agent_config.auth_token:
70
+ # Support custom auth headers and types
71
+ if self.agent_config.auth_type == "bearer":
72
+ headers[self.agent_config.auth_header] = f"Bearer {self.agent_config.auth_token}"
73
+ else:
74
+ headers[self.agent_config.auth_header] = self.agent_config.auth_token
75
+
76
+ # Construct A2A message
77
+ message = {
78
+ "role": "user",
79
+ "parts": [
80
+ {
81
+ "type": "text",
82
+ "text": self._format_tool_request(tool_name, params),
83
+ }
84
+ ],
85
+ }
86
+
87
+ # A2A uses message/send endpoint
88
+ url = f"{self.agent_config.agent_uri}/message/send"
89
+
90
+ request_data = {
91
+ "message": message,
92
+ "context_id": str(uuid4()),
93
+ }
94
+
95
+ debug_info = None
96
+ if self.agent_config.debug:
97
+ debug_request = {
98
+ "url": url,
99
+ "method": "POST",
100
+ "headers": {
101
+ k: v
102
+ if k.lower() not in ("authorization", self.agent_config.auth_header.lower())
103
+ else "***"
104
+ for k, v in headers.items()
105
+ },
106
+ "body": request_data,
41
107
  }
42
108
 
43
- # A2A uses message/send endpoint
44
- url = f"{self.agent_config.agent_uri}/message/send"
109
+ try:
110
+ response = await client.post(
111
+ url,
112
+ json=request_data,
113
+ headers=headers,
114
+ timeout=self.agent_config.timeout,
115
+ )
116
+ response.raise_for_status()
117
+
118
+ data = response.json()
119
+
120
+ if self.agent_config.debug and start_time:
121
+ duration_ms = (time.time() - start_time) * 1000
122
+ debug_info = DebugInfo(
123
+ request=debug_request,
124
+ response={"status": response.status_code, "body": data},
125
+ duration_ms=duration_ms,
126
+ )
45
127
 
46
- request_data = {
47
- "message": message,
48
- "context_id": str(uuid4()),
49
- }
128
+ # Parse A2A response format
129
+ # A2A tasks have lifecycle: submitted, working, completed, failed, input-required
130
+ task_status = data.get("task", {}).get("status")
50
131
 
51
- try:
52
- response = await client.post(
53
- url,
54
- json=request_data,
55
- headers=headers,
56
- timeout=30.0,
132
+ if task_status in ("completed", "working"):
133
+ # Extract the result from the response message
134
+ result_data = self._extract_result(data)
135
+
136
+ return TaskResult[Any](
137
+ status=TaskStatus.COMPLETED,
138
+ data=result_data,
139
+ success=True,
140
+ metadata={"task_id": data.get("task", {}).get("id")},
141
+ debug_info=debug_info,
57
142
  )
58
- response.raise_for_status()
59
-
60
- data = response.json()
61
-
62
- # Parse A2A response format
63
- # A2A tasks have lifecycle: submitted, working, completed, failed, input-required
64
- task_status = data.get("task", {}).get("status")
65
-
66
- if task_status in ("completed", "working"):
67
- # Extract the result from the response message
68
- result_data = self._extract_result(data)
69
-
70
- return TaskResult[Any](
71
- status=TaskStatus.COMPLETED,
72
- data=result_data,
73
- success=True,
74
- metadata={"task_id": data.get("task", {}).get("id")},
75
- )
76
- elif task_status == "failed":
77
- return TaskResult[Any](
78
- status=TaskStatus.FAILED,
79
- error=data.get("message", {})
80
- .get("parts", [{}])[0]
81
- .get("text", "Task failed"),
82
- success=False,
83
- )
84
- else:
85
- # Handle other states (submitted, input-required)
86
- return TaskResult[Any](
87
- status=TaskStatus.SUBMITTED,
88
- data=data,
89
- success=True,
90
- metadata={"task_id": data.get("task", {}).get("id")},
91
- )
92
-
93
- except httpx.HTTPError as e:
143
+ elif task_status == "failed":
94
144
  return TaskResult[Any](
95
145
  status=TaskStatus.FAILED,
96
- error=str(e),
146
+ error=data.get("message", {}).get("parts", [{}])[0].get("text", "Task failed"),
97
147
  success=False,
148
+ debug_info=debug_info,
149
+ )
150
+ else:
151
+ # Handle other states (submitted, input-required)
152
+ return TaskResult[Any](
153
+ status=TaskStatus.SUBMITTED,
154
+ data=data,
155
+ success=True,
156
+ metadata={"task_id": data.get("task", {}).get("id")},
157
+ debug_info=debug_info,
98
158
  )
99
159
 
160
+ except httpx.HTTPError as e:
161
+ if self.agent_config.debug and start_time:
162
+ duration_ms = (time.time() - start_time) * 1000
163
+ debug_info = DebugInfo(
164
+ request=debug_request,
165
+ response={"error": str(e)},
166
+ duration_ms=duration_ms,
167
+ )
168
+ return TaskResult[Any](
169
+ status=TaskStatus.FAILED,
170
+ error=str(e),
171
+ success=False,
172
+ debug_info=debug_info,
173
+ )
174
+
100
175
  def _format_tool_request(self, tool_name: str, params: dict[str, Any]) -> str:
101
176
  """Format tool request as natural language for A2A."""
102
177
  # For AdCP tools, we format as a structured request
@@ -135,25 +210,64 @@ class A2AAdapter(ProtocolAdapter):
135
210
  their capabilities through the agent card. For AdCP, we rely on the
136
211
  standard AdCP tool set.
137
212
  """
138
- async with httpx.AsyncClient() as client:
139
- headers = {"Content-Type": "application/json"}
140
-
141
- if self.agent_config.auth_token:
142
- headers["Authorization"] = f"Bearer {self.agent_config.auth_token}"
143
-
144
- # Try to fetch agent card (OpenAPI spec)
145
- url = f"{self.agent_config.agent_uri}/agent-card"
146
-
147
- try:
148
- response = await client.get(url, headers=headers, timeout=10.0)
149
- response.raise_for_status()
150
-
151
- data = response.json()
152
-
153
- # Extract skills from agent card
154
- skills = data.get("skills", [])
155
- return [skill.get("name", "") for skill in skills if skill.get("name")]
156
-
157
- except httpx.HTTPError:
158
- # If agent card is not available, return empty list
159
- return []
213
+ client = await self._get_client()
214
+
215
+ headers = {"Content-Type": "application/json"}
216
+
217
+ if self.agent_config.auth_token:
218
+ # Support custom auth headers and types
219
+ if self.agent_config.auth_type == "bearer":
220
+ headers[self.agent_config.auth_header] = f"Bearer {self.agent_config.auth_token}"
221
+ else:
222
+ headers[self.agent_config.auth_header] = self.agent_config.auth_token
223
+
224
+ # Try to fetch agent card from standard A2A location
225
+ # A2A spec uses /.well-known/agent.json for agent card
226
+ url = f"{self.agent_config.agent_uri}/.well-known/agent.json"
227
+
228
+ logger.debug(f"Fetching A2A agent card for {self.agent_config.id} from {url}")
229
+
230
+ try:
231
+ response = await client.get(url, headers=headers, timeout=self.agent_config.timeout)
232
+ response.raise_for_status()
233
+
234
+ data = response.json()
235
+
236
+ # Extract skills from agent card
237
+ skills = data.get("skills", [])
238
+ tool_names = [skill.get("name", "") for skill in skills if skill.get("name")]
239
+
240
+ logger.info(f"Found {len(tool_names)} tools from A2A agent {self.agent_config.id}")
241
+ return tool_names
242
+
243
+ except httpx.HTTPStatusError as e:
244
+ status_code = e.response.status_code
245
+ if status_code in (401, 403):
246
+ logger.error(f"Authentication failed for A2A agent {self.agent_config.id}")
247
+ raise ADCPAuthenticationError(
248
+ f"Authentication failed: HTTP {status_code}",
249
+ agent_id=self.agent_config.id,
250
+ agent_uri=self.agent_config.agent_uri,
251
+ ) from e
252
+ else:
253
+ logger.error(f"HTTP {status_code} error fetching agent card: {e}")
254
+ raise ADCPConnectionError(
255
+ f"Failed to fetch agent card: HTTP {status_code}",
256
+ agent_id=self.agent_config.id,
257
+ agent_uri=self.agent_config.agent_uri,
258
+ ) from e
259
+ except httpx.TimeoutException as e:
260
+ logger.error(f"Timeout fetching agent card for {self.agent_config.id}")
261
+ raise ADCPTimeoutError(
262
+ f"Timeout fetching agent card: {e}",
263
+ agent_id=self.agent_config.id,
264
+ agent_uri=self.agent_config.agent_uri,
265
+ timeout=self.agent_config.timeout,
266
+ ) from e
267
+ except httpx.HTTPError as e:
268
+ logger.error(f"HTTP error fetching agent card: {e}")
269
+ raise ADCPConnectionError(
270
+ f"Failed to fetch agent card: {e}",
271
+ agent_id=self.agent_config.id,
272
+ agent_uri=self.agent_config.agent_uri,
273
+ ) from e
adcp/protocols/base.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  """Base protocol adapter interface."""
2
4
 
3
5
  from abc import ABC, abstractmethod
@@ -36,3 +38,12 @@ class ProtocolAdapter(ABC):
36
38
  List of tool names
37
39
  """
38
40
  pass
41
+
42
+ @abstractmethod
43
+ async def close(self) -> None:
44
+ """
45
+ Close the adapter and clean up resources.
46
+
47
+ Implementations should close any open connections, clients, or other resources.
48
+ """
49
+ pass