asap-protocol 0.1.0__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.
asap/schemas.py ADDED
@@ -0,0 +1,203 @@
1
+ """Schema export helpers for ASAP models.
2
+
3
+ This module provides utilities for exporting JSON schemas from ASAP
4
+ Pydantic models, enabling schema validation and tooling integration.
5
+
6
+ Example:
7
+ >>> from asap.schemas import get_schema_json, list_schema_entries
8
+ >>> schema = get_schema_json("agent")
9
+ >>> schema["title"]
10
+ 'Agent'
11
+ """
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ from asap.models import (
17
+ Agent,
18
+ Artifact,
19
+ ArtifactNotify,
20
+ Conversation,
21
+ DataPart,
22
+ Envelope,
23
+ FilePart,
24
+ Manifest,
25
+ McpResourceData,
26
+ McpResourceFetch,
27
+ McpToolCall,
28
+ McpToolResult,
29
+ Message,
30
+ MessageSend,
31
+ ResourcePart,
32
+ StateQuery,
33
+ StateRestore,
34
+ StateSnapshot,
35
+ Task,
36
+ TaskCancel,
37
+ TaskRequest,
38
+ TaskResponse,
39
+ TaskUpdate,
40
+ TemplatePart,
41
+ TextPart,
42
+ )
43
+ from asap.models.base import ASAPBaseModel
44
+
45
+ # Schema registry mapping names to model classes
46
+ SCHEMA_REGISTRY: dict[str, type[ASAPBaseModel]] = {
47
+ "agent": Agent,
48
+ "manifest": Manifest,
49
+ "conversation": Conversation,
50
+ "task": Task,
51
+ "message": Message,
52
+ "artifact": Artifact,
53
+ "state_snapshot": StateSnapshot,
54
+ "text_part": TextPart,
55
+ "data_part": DataPart,
56
+ "file_part": FilePart,
57
+ "resource_part": ResourcePart,
58
+ "template_part": TemplatePart,
59
+ "task_request": TaskRequest,
60
+ "task_response": TaskResponse,
61
+ "task_update": TaskUpdate,
62
+ "task_cancel": TaskCancel,
63
+ "message_send": MessageSend,
64
+ "state_query": StateQuery,
65
+ "state_restore": StateRestore,
66
+ "artifact_notify": ArtifactNotify,
67
+ "mcp_tool_call": McpToolCall,
68
+ "mcp_tool_result": McpToolResult,
69
+ "mcp_resource_fetch": McpResourceFetch,
70
+ "mcp_resource_data": McpResourceData,
71
+ "envelope": Envelope,
72
+ }
73
+
74
+ # Total number of schemas in the registry
75
+ TOTAL_SCHEMA_COUNT = len(SCHEMA_REGISTRY)
76
+
77
+ # Schema output path mapping for directory organization
78
+ _SCHEMA_PATHS: dict[str, str] = {
79
+ # Entities
80
+ "agent": "entities/agent.schema.json",
81
+ "manifest": "entities/manifest.schema.json",
82
+ "conversation": "entities/conversation.schema.json",
83
+ "task": "entities/task.schema.json",
84
+ "message": "entities/message.schema.json",
85
+ "artifact": "entities/artifact.schema.json",
86
+ "state_snapshot": "entities/state_snapshot.schema.json",
87
+ # Parts
88
+ "text_part": "parts/text_part.schema.json",
89
+ "data_part": "parts/data_part.schema.json",
90
+ "file_part": "parts/file_part.schema.json",
91
+ "resource_part": "parts/resource_part.schema.json",
92
+ "template_part": "parts/template_part.schema.json",
93
+ # Payloads
94
+ "task_request": "payloads/task_request.schema.json",
95
+ "task_response": "payloads/task_response.schema.json",
96
+ "task_update": "payloads/task_update.schema.json",
97
+ "task_cancel": "payloads/task_cancel.schema.json",
98
+ "message_send": "payloads/message_send.schema.json",
99
+ "state_query": "payloads/state_query.schema.json",
100
+ "state_restore": "payloads/state_restore.schema.json",
101
+ "artifact_notify": "payloads/artifact_notify.schema.json",
102
+ "mcp_tool_call": "payloads/mcp_tool_call.schema.json",
103
+ "mcp_tool_result": "payloads/mcp_tool_result.schema.json",
104
+ "mcp_resource_fetch": "payloads/mcp_resource_fetch.schema.json",
105
+ "mcp_resource_data": "payloads/mcp_resource_data.schema.json",
106
+ # Envelope (root level)
107
+ "envelope": "envelope.schema.json",
108
+ }
109
+
110
+
111
+ def list_schema_entries(output_dir: Path) -> list[tuple[str, Path]]:
112
+ """List all available schema names and their output paths.
113
+
114
+ Args:
115
+ output_dir: Base directory where schemas are written.
116
+
117
+ Returns:
118
+ List of (schema_name, output_path) tuples.
119
+
120
+ Example:
121
+ >>> from pathlib import Path
122
+ >>> entries = list_schema_entries(Path("schemas"))
123
+ >>> any(name == "agent" for name, _ in entries)
124
+ True
125
+ """
126
+ return [(name, output_dir / rel_path) for name, rel_path in _SCHEMA_PATHS.items()]
127
+
128
+
129
+ def get_schema_json(schema_name: str) -> dict[str, object]:
130
+ """Return the JSON schema for a named model.
131
+
132
+ Args:
133
+ schema_name: Schema identifier (e.g., "agent", "task_request").
134
+
135
+ Returns:
136
+ JSON schema dictionary for the model.
137
+
138
+ Raises:
139
+ ValueError: If the schema name is not recognized.
140
+
141
+ Example:
142
+ >>> schema = get_schema_json("agent")
143
+ >>> schema["title"]
144
+ 'Agent'
145
+ >>> "properties" in schema
146
+ True
147
+ """
148
+ if schema_name not in SCHEMA_REGISTRY:
149
+ raise ValueError(f"Unknown schema name: {schema_name}")
150
+ return SCHEMA_REGISTRY[schema_name].model_json_schema()
151
+
152
+
153
+ def export_schema(model_class: type[ASAPBaseModel], output_path: Path) -> Path:
154
+ """Export JSON Schema for a model to a file.
155
+
156
+ Creates parent directories if they don't exist. Overwrites existing files.
157
+
158
+ Args:
159
+ model_class: Pydantic model class to export.
160
+ output_path: Path to write the schema file.
161
+
162
+ Returns:
163
+ The path that was written.
164
+
165
+ Example:
166
+ >>> from pathlib import Path
167
+ >>> from asap.models import Agent
168
+ >>> path = export_schema(Agent, Path("/tmp/agent.schema.json"))
169
+ >>> path.exists()
170
+ True
171
+ """
172
+ schema = model_class.model_json_schema()
173
+ output_path.parent.mkdir(parents=True, exist_ok=True)
174
+ output_path.write_text(json.dumps(schema, indent=2), encoding="utf-8")
175
+ return output_path
176
+
177
+
178
+ def export_all_schemas(output_dir: Path) -> list[Path]:
179
+ """Export all ASAP model schemas to the given directory.
180
+
181
+ Creates the directory structure (entities/, parts/, payloads/) and
182
+ writes all schema files.
183
+
184
+ Args:
185
+ output_dir: Base directory to write schemas into.
186
+
187
+ Returns:
188
+ List of schema file paths that were written.
189
+
190
+ Example:
191
+ >>> from pathlib import Path
192
+ >>> paths = export_all_schemas(Path("/tmp/schemas"))
193
+ >>> len(paths) == 24
194
+ True
195
+ """
196
+ written_paths: list[Path] = []
197
+
198
+ for name, rel_path in _SCHEMA_PATHS.items():
199
+ model_class = SCHEMA_REGISTRY[name]
200
+ output_path = output_dir / rel_path
201
+ written_paths.append(export_schema(model_class, output_path))
202
+
203
+ return written_paths
asap/state/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """ASAP State Management Module.
2
+
3
+ This module provides state machine functionality for managing
4
+ task lifecycles and state transitions in the ASAP protocol.
5
+
6
+ Example:
7
+ >>> from asap.models.enums import TaskStatus
8
+ >>> can_transition(TaskStatus.SUBMITTED, TaskStatus.WORKING)
9
+ True
10
+ """
11
+
12
+ from .machine import can_transition, transition
13
+ from .snapshot import InMemorySnapshotStore, SnapshotStore
14
+ from asap.models.enums import TaskStatus
15
+
16
+ __all__ = [
17
+ "TaskStatus",
18
+ "can_transition",
19
+ "transition",
20
+ "SnapshotStore",
21
+ "InMemorySnapshotStore",
22
+ ]
asap/state/machine.py ADDED
@@ -0,0 +1,86 @@
1
+ """ASAP Task State Machine.
2
+
3
+ This module implements the task state machine for the ASAP protocol,
4
+ managing valid state transitions and providing transition validation.
5
+
6
+ Example:
7
+ >>> from asap.models.enums import TaskStatus
8
+ >>> can_transition(TaskStatus.SUBMITTED, TaskStatus.WORKING)
9
+ True
10
+ """
11
+
12
+ from datetime import datetime, timezone
13
+
14
+ from asap.errors import InvalidTransitionError
15
+ from asap.models.entities import Task
16
+ from asap.models.enums import TaskStatus
17
+
18
+
19
+ # Valid state transitions mapping
20
+ VALID_TRANSITIONS: dict[TaskStatus, set[TaskStatus]] = {
21
+ TaskStatus.SUBMITTED: {TaskStatus.WORKING, TaskStatus.CANCELLED},
22
+ TaskStatus.WORKING: {
23
+ TaskStatus.COMPLETED,
24
+ TaskStatus.FAILED,
25
+ TaskStatus.CANCELLED,
26
+ TaskStatus.INPUT_REQUIRED,
27
+ },
28
+ TaskStatus.INPUT_REQUIRED: {TaskStatus.WORKING, TaskStatus.CANCELLED},
29
+ TaskStatus.COMPLETED: set(), # Terminal state
30
+ TaskStatus.FAILED: set(), # Terminal state
31
+ TaskStatus.CANCELLED: set(), # Terminal state
32
+ }
33
+
34
+
35
+ def can_transition(from_status: TaskStatus, to_status: TaskStatus) -> bool:
36
+ """Check if a transition from one status to another is valid.
37
+
38
+ Args:
39
+ from_status: Current task status
40
+ to_status: Target task status
41
+
42
+ Returns:
43
+ True if the transition is valid, False otherwise
44
+
45
+ Example:
46
+ >>> can_transition(TaskStatus.SUBMITTED, TaskStatus.WORKING)
47
+ True
48
+ >>> can_transition(TaskStatus.COMPLETED, TaskStatus.WORKING)
49
+ False
50
+ """
51
+ valid_targets = VALID_TRANSITIONS.get(from_status, set())
52
+ return to_status in valid_targets
53
+
54
+
55
+ def transition(task: Task, new_status: TaskStatus) -> Task:
56
+ """Transition a task to a new status with validation.
57
+
58
+ Args:
59
+ task: The task to transition
60
+ new_status: The target status
61
+
62
+ Returns:
63
+ New task instance with updated status and updated_at timestamp
64
+
65
+ Raises:
66
+ InvalidTransitionError: If the transition is not valid
67
+
68
+ Example:
69
+ >>> task = Task(
70
+ ... id="task_01HX5K4N...",
71
+ ... conversation_id="conv_01HX5K3M...",
72
+ ... status=TaskStatus.SUBMITTED,
73
+ ... created_at=datetime.now(timezone.utc),
74
+ ... updated_at=datetime.now(timezone.utc),
75
+ ... )
76
+ >>> updated = transition(task, TaskStatus.WORKING)
77
+ >>> updated.status
78
+ <TaskStatus.WORKING: 'working'>
79
+ """
80
+ if not can_transition(task.status, new_status):
81
+ raise InvalidTransitionError(
82
+ from_state=task.status.value, to_state=new_status.value, details={"task_id": task.id}
83
+ )
84
+
85
+ # Create new task instance with updated status and timestamp (immutable approach)
86
+ return task.model_copy(update={"status": new_status, "updated_at": datetime.now(timezone.utc)})
asap/state/snapshot.py ADDED
@@ -0,0 +1,265 @@
1
+ """ASAP Snapshot Store for task state persistence.
2
+
3
+ This module provides interfaces and implementations for storing and retrieving
4
+ task state snapshots, enabling state persistence across agent restarts.
5
+
6
+ Example:
7
+ >>> store = InMemorySnapshotStore()
8
+ >>> store.list_versions("task_01HX5K4N...")
9
+ []
10
+ """
11
+
12
+ import threading
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ from asap.models.entities import StateSnapshot
16
+ from asap.models.types import TaskID
17
+
18
+
19
+ @runtime_checkable
20
+ class SnapshotStore(Protocol):
21
+ """Protocol for snapshot storage implementations.
22
+
23
+ Provides the interface for storing and retrieving task state snapshots.
24
+ Implementations can use various backends (memory, database, file system, etc.).
25
+ This uses Protocol for duck typing, allowing any class that implements
26
+ these methods to be used as a SnapshotStore.
27
+
28
+ Example:
29
+ >>> class CustomStore:
30
+ ... def save(self, snapshot: StateSnapshot) -> None:
31
+ ... pass
32
+ ... def get(self, task_id: TaskID, version: int | None = None) -> StateSnapshot | None:
33
+ ... return None
34
+ ... def list_versions(self, task_id: TaskID) -> list[int]:
35
+ ... return []
36
+ ... def delete(self, task_id: TaskID, version: int | None = None) -> bool:
37
+ ... return False
38
+ >>> isinstance(CustomStore(), SnapshotStore)
39
+ True
40
+ """
41
+
42
+ def save(self, snapshot: StateSnapshot) -> None:
43
+ """Save a snapshot to the store.
44
+
45
+ Args:
46
+ snapshot: The snapshot to save
47
+
48
+ Example:
49
+ >>> from datetime import datetime, timezone
50
+ >>> store = InMemorySnapshotStore()
51
+ >>> snapshot = StateSnapshot(
52
+ ... id="snap_01HX5K7R...",
53
+ ... task_id="task_01HX5K4N...",
54
+ ... version=1,
55
+ ... data={"status": "submitted"},
56
+ ... created_at=datetime.now(timezone.utc),
57
+ ... )
58
+ >>> store.save(snapshot)
59
+ """
60
+ ...
61
+
62
+ def get(self, task_id: TaskID, version: int | None = None) -> StateSnapshot | None:
63
+ """Retrieve a snapshot for the given task.
64
+
65
+ Args:
66
+ task_id: The task ID to retrieve snapshots for
67
+ version: Optional specific version to retrieve. If None, returns latest.
68
+
69
+ Returns:
70
+ The snapshot if found, None otherwise
71
+
72
+ Example:
73
+ >>> store = InMemorySnapshotStore()
74
+ >>> store.get("task_01HX5K4N...")
75
+ None
76
+ """
77
+ ...
78
+
79
+ def list_versions(self, task_id: TaskID) -> list[int]:
80
+ """List all available versions for a task.
81
+
82
+ Args:
83
+ task_id: The task ID to list versions for
84
+
85
+ Returns:
86
+ List of version numbers in ascending order
87
+
88
+ Example:
89
+ >>> store = InMemorySnapshotStore()
90
+ >>> store.list_versions("task_01HX5K4N...")
91
+ []
92
+ """
93
+ ...
94
+
95
+ def delete(self, task_id: TaskID, version: int | None = None) -> bool:
96
+ """Delete snapshot(s) for a task.
97
+
98
+ Args:
99
+ task_id: The task ID
100
+ version: If provided, delete only this version. Otherwise delete all.
101
+
102
+ Returns:
103
+ True if any snapshots were deleted, False otherwise
104
+
105
+ Example:
106
+ >>> store = InMemorySnapshotStore()
107
+ >>> store.delete("task_01HX5K4N...")
108
+ False
109
+ """
110
+ ...
111
+
112
+
113
+ class InMemorySnapshotStore:
114
+ """In-memory implementation of SnapshotStore.
115
+
116
+ Stores snapshots in memory using dictionaries. Useful for testing
117
+ and simple applications that don't require persistence across restarts.
118
+
119
+ This implementation is thread-safe using RLock for concurrent access.
120
+ """
121
+
122
+ def __init__(self) -> None:
123
+ """Initialize the in-memory snapshot store.
124
+
125
+ Example:
126
+ >>> store = InMemorySnapshotStore()
127
+ >>> isinstance(store, InMemorySnapshotStore)
128
+ True
129
+ """
130
+ self._lock = threading.RLock()
131
+ # task_id -> version -> snapshot
132
+ self._snapshots: dict[TaskID, dict[int, StateSnapshot]] = {}
133
+ # task_id -> latest version
134
+ self._latest_versions: dict[TaskID, int] = {}
135
+
136
+ def save(self, snapshot: StateSnapshot) -> None:
137
+ """Save a snapshot to the in-memory store.
138
+
139
+ Args:
140
+ snapshot: The snapshot to save
141
+
142
+ Example:
143
+ >>> from datetime import datetime, timezone
144
+ >>> store = InMemorySnapshotStore()
145
+ >>> snapshot = StateSnapshot(
146
+ ... id="snap_01HX5K7R...",
147
+ ... task_id="task_01HX5K4N...",
148
+ ... version=1,
149
+ ... data={"status": "submitted"},
150
+ ... created_at=datetime.now(timezone.utc),
151
+ ... )
152
+ >>> store.save(snapshot)
153
+ """
154
+ with self._lock:
155
+ task_id = snapshot.task_id
156
+
157
+ # Initialize storage for this task if needed
158
+ if task_id not in self._snapshots:
159
+ self._snapshots[task_id] = {}
160
+
161
+ # Store the snapshot
162
+ self._snapshots[task_id][snapshot.version] = snapshot
163
+
164
+ # Update latest version
165
+ self._latest_versions[task_id] = max(
166
+ self._latest_versions.get(task_id, 0), snapshot.version
167
+ )
168
+
169
+ def get(self, task_id: TaskID, version: int | None = None) -> StateSnapshot | None:
170
+ """Retrieve a snapshot from the in-memory store.
171
+
172
+ Args:
173
+ task_id: The task ID to retrieve snapshots for
174
+ version: Optional specific version to retrieve. If None, returns latest.
175
+
176
+ Returns:
177
+ The snapshot if found, None otherwise
178
+
179
+ Example:
180
+ >>> store = InMemorySnapshotStore()
181
+ >>> store.get("task_01HX5K4N...")
182
+ None
183
+ """
184
+ with self._lock:
185
+ if task_id not in self._snapshots:
186
+ return None
187
+
188
+ if version is None:
189
+ # Return latest version
190
+ latest_version = self._latest_versions.get(task_id)
191
+ if latest_version is None:
192
+ return None
193
+ return self._snapshots[task_id].get(latest_version)
194
+
195
+ # Return specific version
196
+ return self._snapshots[task_id].get(version)
197
+
198
+ def list_versions(self, task_id: TaskID) -> list[int]:
199
+ """List all available versions for a task.
200
+
201
+ Args:
202
+ task_id: The task ID to list versions for
203
+
204
+ Returns:
205
+ List of version numbers in ascending order
206
+
207
+ Example:
208
+ >>> store = InMemorySnapshotStore()
209
+ >>> store.list_versions("task_01HX5K4N...")
210
+ []
211
+ """
212
+ with self._lock:
213
+ if task_id not in self._snapshots:
214
+ return []
215
+
216
+ return sorted(self._snapshots[task_id].keys())
217
+
218
+ def delete(self, task_id: TaskID, version: int | None = None) -> bool:
219
+ """Delete snapshot(s) for a task.
220
+
221
+ Args:
222
+ task_id: The task ID
223
+ version: If provided, delete only this version. Otherwise delete all.
224
+
225
+ Returns:
226
+ True if any snapshots were deleted, False otherwise
227
+
228
+ Example:
229
+ >>> store = InMemorySnapshotStore()
230
+ >>> store.delete("task_01HX5K4N...")
231
+ False
232
+ """
233
+ with self._lock:
234
+ if task_id not in self._snapshots:
235
+ return False
236
+
237
+ if version is None:
238
+ # Delete all versions for this task
239
+ if task_id in self._snapshots:
240
+ del self._snapshots[task_id]
241
+ if task_id in self._latest_versions:
242
+ del self._latest_versions[task_id]
243
+ return True
244
+ return False
245
+
246
+ # Delete specific version
247
+ if version in self._snapshots[task_id]:
248
+ del self._snapshots[task_id][version]
249
+
250
+ # Update latest version if needed
251
+ if self._latest_versions.get(task_id) == version:
252
+ if self._snapshots[task_id]:
253
+ self._latest_versions[task_id] = max(self._snapshots[task_id].keys())
254
+ else:
255
+ del self._latest_versions[task_id]
256
+
257
+ # Clean up empty task dict
258
+ if not self._snapshots[task_id]:
259
+ del self._snapshots[task_id]
260
+ if task_id in self._latest_versions:
261
+ del self._latest_versions[task_id]
262
+
263
+ return True
264
+
265
+ return False
@@ -0,0 +1,84 @@
1
+ """ASAP Protocol HTTP Transport Layer.
2
+
3
+ This module provides HTTP-based transport for ASAP messages using:
4
+ - JSON-RPC 2.0 for request/response wrapping
5
+ - FastAPI for server implementation
6
+ - httpx for async client implementation
7
+ - Handler registry for payload dispatch
8
+
9
+ Public exports:
10
+ JsonRpcRequest: JSON-RPC 2.0 request wrapper
11
+ JsonRpcResponse: JSON-RPC 2.0 response wrapper
12
+ JsonRpcError: JSON-RPC 2.0 error object
13
+ JsonRpcErrorResponse: JSON-RPC 2.0 error response wrapper
14
+ create_app: FastAPI application factory
15
+ HandlerRegistry: Registry for payload handlers
16
+ HandlerNotFoundError: Error for missing handlers
17
+ Handler: Type alias for handler functions
18
+ create_echo_handler: Factory for echo handler
19
+ create_default_registry: Factory for default registry
20
+ ASAPClient: Async HTTP client for agent communication
21
+ ASAPConnectionError: Connection error exception
22
+ ASAPTimeoutError: Timeout error exception
23
+ ASAPRemoteError: Remote error exception
24
+
25
+ Example:
26
+ >>> from asap.transport import ASAPClient, create_app
27
+ >>> from asap.models.entities import Manifest, Capability, Endpoint, Skill
28
+ >>> manifest = Manifest(
29
+ ... id="urn:asap:agent:demo",
30
+ ... name="Demo Agent",
31
+ ... version="1.0.0",
32
+ ... description="Demo manifest",
33
+ ... capabilities=Capability(
34
+ ... asap_version="0.1",
35
+ ... skills=[Skill(id="echo", description="Echo")],
36
+ ... state_persistence=False,
37
+ ... ),
38
+ ... endpoints=Endpoint(asap="http://localhost:8000/asap"),
39
+ ... )
40
+ >>> app = create_app(manifest)
41
+ """
42
+
43
+ from asap.transport.client import (
44
+ ASAPClient,
45
+ ASAPConnectionError,
46
+ ASAPRemoteError,
47
+ ASAPTimeoutError,
48
+ )
49
+ from asap.transport.handlers import (
50
+ Handler,
51
+ HandlerNotFoundError,
52
+ HandlerRegistry,
53
+ create_default_registry,
54
+ create_echo_handler,
55
+ )
56
+ from asap.transport.jsonrpc import (
57
+ JsonRpcError,
58
+ JsonRpcErrorResponse,
59
+ JsonRpcRequest,
60
+ JsonRpcResponse,
61
+ )
62
+ from asap.transport.server import ASAPRequestHandler, create_app
63
+
64
+ __all__ = [
65
+ # JSON-RPC
66
+ "JsonRpcRequest",
67
+ "JsonRpcResponse",
68
+ "JsonRpcError",
69
+ "JsonRpcErrorResponse",
70
+ # Server
71
+ "create_app",
72
+ "ASAPRequestHandler",
73
+ # Handlers
74
+ "HandlerRegistry",
75
+ "HandlerNotFoundError",
76
+ "Handler",
77
+ "create_echo_handler",
78
+ "create_default_registry",
79
+ # Client
80
+ "ASAPClient",
81
+ "ASAPConnectionError",
82
+ "ASAPTimeoutError",
83
+ "ASAPRemoteError",
84
+ ]