kagent-claude 0.2.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.
@@ -0,0 +1,23 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from ._a2a import KAgentApp
4
+ from ._executor import ClaudeAgentExecutor, ClaudeAgentExecutorConfig
5
+ from ._hitl import ApprovalDecision, HitlBridge
6
+ from ._session_store import ClaudeSessionStore, SessionStore
7
+ from ._tracing import trace_query
8
+
9
+ __all__ = [
10
+ "KAgentApp",
11
+ "ClaudeAgentExecutor",
12
+ "ClaudeAgentExecutorConfig",
13
+ "ClaudeSessionStore",
14
+ "SessionStore",
15
+ "HitlBridge",
16
+ "ApprovalDecision",
17
+ "trace_query",
18
+ ]
19
+
20
+ try:
21
+ __version__ = version("kagent-claude")
22
+ except PackageNotFoundError:
23
+ __version__ = "0.0.0-dev"
kagent/claude/_a2a.py ADDED
@@ -0,0 +1,132 @@
1
+ """KAgent Claude Agent SDK A2A Server Integration."""
2
+
3
+ import faulthandler
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+
7
+ import httpx
8
+ from a2a.server.apps import A2AStarletteApplication
9
+ from a2a.server.request_handlers import DefaultRequestHandler
10
+ from a2a.types import AgentCard
11
+ from claude_agent_sdk import ClaudeAgentOptions
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.responses import PlainTextResponse
14
+
15
+ from kagent.core import KAgentConfig, configure_logging, configure_tracing
16
+ from kagent.core.a2a import (
17
+ KAgentRequestContextBuilder,
18
+ KAgentTaskStore,
19
+ get_a2a_max_content_length,
20
+ )
21
+
22
+ from ._executor import ClaudeAgentExecutor, ClaudeAgentExecutorConfig
23
+ from ._session_store import ClaudeSessionStore
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _health_check(request: Request) -> PlainTextResponse:
29
+ return PlainTextResponse("OK")
30
+
31
+
32
+ def _thread_dump(request: Request) -> PlainTextResponse:
33
+ import tempfile
34
+
35
+ with tempfile.TemporaryFile(mode="w+") as tmp:
36
+ faulthandler.dump_traceback(file=tmp, all_threads=True)
37
+ tmp.seek(0)
38
+ return PlainTextResponse(tmp.read())
39
+
40
+
41
+ class KAgentApp:
42
+ """
43
+ Builds an A2A-compliant HTTP server wrapping the Claude Agent SDK.
44
+
45
+ Usage:
46
+ app = KAgentApp(
47
+ options=ClaudeAgentOptions(allowed_tools=["Bash", "Read"]),
48
+ agent_card=AgentCard(...),
49
+ config=KAgentConfig(), # reads from KAGENT_URL, KAGENT_NAME, KAGENT_NAMESPACE env vars
50
+ )
51
+ app.run()
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ options: ClaudeAgentOptions,
58
+ agent_card: AgentCard,
59
+ config: KAgentConfig = None,
60
+ executor_config: ClaudeAgentExecutorConfig | None = None,
61
+ tracing: bool = True,
62
+ ):
63
+ self._options = options
64
+ self.agent_card = AgentCard.model_validate(agent_card)
65
+ self.config = config or KAgentConfig()
66
+ self._enable_tracing = tracing
67
+ self._session_store = ClaudeSessionStore()
68
+ self._executor_config = executor_config or ClaudeAgentExecutorConfig()
69
+
70
+ def build(self) -> FastAPI:
71
+ """Construct and return the FastAPI ASGI application."""
72
+ http_client = httpx.AsyncClient(base_url=self.config.url)
73
+
74
+ agent_executor = ClaudeAgentExecutor(
75
+ options=self._options,
76
+ session_store=self._session_store,
77
+ app_name=self.config.app_name,
78
+ config=self._executor_config,
79
+ )
80
+
81
+ task_store = KAgentTaskStore(http_client)
82
+ request_context_builder = KAgentRequestContextBuilder(task_store=task_store)
83
+ request_handler = DefaultRequestHandler(
84
+ agent_executor=agent_executor,
85
+ task_store=task_store,
86
+ request_context_builder=request_context_builder,
87
+ )
88
+
89
+ max_content_length = get_a2a_max_content_length()
90
+ a2a_app = A2AStarletteApplication(
91
+ agent_card=self.agent_card,
92
+ http_handler=request_handler,
93
+ max_content_length=max_content_length,
94
+ )
95
+
96
+ # Lifespan for graceful shutdown
97
+ @asynccontextmanager
98
+ async def lifespan(app: FastAPI):
99
+ logger.info(f"KAgent Claude starting: {self.config.app_name}")
100
+ yield
101
+ # Shutdown: cancel running queries
102
+ await agent_executor.shutdown()
103
+ await http_client.aclose()
104
+ logger.info(f"KAgent Claude stopped: {self.config.app_name}")
105
+
106
+ faulthandler.enable()
107
+ app = FastAPI(
108
+ title=f"KAgent Claude: {self.config.app_name}",
109
+ description=f"Claude Agent SDK with KAgent integration: {self.agent_card.description}",
110
+ version=self.agent_card.version,
111
+ lifespan=lifespan,
112
+ )
113
+
114
+ configure_logging()
115
+
116
+ if self._enable_tracing:
117
+ try:
118
+ configure_tracing(self.config.name, self.config.namespace, app)
119
+ except Exception:
120
+ logger.exception("Failed to configure tracing")
121
+
122
+ app.add_route("/health", methods=["GET"], route=_health_check)
123
+ app.add_route("/thread_dump", methods=["GET"], route=_thread_dump)
124
+ a2a_app.add_routes_to_app(app)
125
+
126
+ return app
127
+
128
+ def run(self, host: str = "0.0.0.0", port: int = 8080) -> None:
129
+ """Start the uvicorn server. Blocks until shutdown."""
130
+ import uvicorn
131
+
132
+ uvicorn.run(self.build(), host=host, port=port)
@@ -0,0 +1,217 @@
1
+ """
2
+ Convert Claude Agent SDK messages to A2A DataParts for streaming intermediate events.
3
+
4
+ The Claude Agent SDK yields several message types during execution:
5
+ - SystemMessage (subtype="init") — session initialization
6
+ - AssistantMessage — Claude's thinking/response with content blocks
7
+ - ToolUseBlock (in AssistantMessage.content) — tool invocation
8
+ - ToolResultBlock (in UserMessage/ResultMessage) — tool result
9
+ - ResultMessage — final aggregated result
10
+
11
+ This module converts these into structured A2A DataParts so the kagent
12
+ dashboard can display real-time agent activity.
13
+ """
14
+
15
+ import hashlib
16
+ from datetime import datetime, timezone
17
+ from typing import Any
18
+
19
+ from a2a.types import DataPart, Message, Part, Role, TaskState, TaskStatus, TaskStatusUpdateEvent, TextPart
20
+
21
+ from kagent.core.a2a import (
22
+ A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL,
23
+ A2A_DATA_PART_METADATA_TYPE_KEY,
24
+ get_kagent_metadata_key,
25
+ )
26
+
27
+ # Metadata type for tool results (matching langgraph's convention)
28
+ A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE = "function_response"
29
+
30
+
31
+ def convert_assistant_message(message) -> list[Part] | None:
32
+ """
33
+ Convert an AssistantMessage to A2A Parts.
34
+
35
+ AssistantMessage has a .content list with TextBlock and ToolUseBlock items.
36
+ Returns Parts for streaming to the dashboard, or None if nothing useful.
37
+ """
38
+ content = getattr(message, "content", None)
39
+ if not content:
40
+ return None
41
+
42
+ parts: list[Part] = []
43
+
44
+ for block in content:
45
+ block_type = getattr(block, "type", None)
46
+
47
+ if block_type == "tool_use":
48
+ # Tool invocation — emit as a DataPart with function_call metadata
49
+ tool_name = getattr(block, "name", "unknown_tool")
50
+ tool_input = getattr(block, "input", {})
51
+ tool_id = getattr(block, "id", "")
52
+
53
+ data_part = DataPart(
54
+ data={
55
+ "id": tool_id,
56
+ "name": tool_name,
57
+ "args": tool_input,
58
+ },
59
+ metadata={
60
+ get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL,
61
+ },
62
+ )
63
+ parts.append(Part(data_part))
64
+
65
+ elif block_type == "text" or (hasattr(block, "text") and isinstance(getattr(block, "text", None), str)):
66
+ text = getattr(block, "text", "")
67
+ if text:
68
+ parts.append(Part(TextPart(text=text)))
69
+
70
+ return parts if parts else None
71
+
72
+
73
+ def convert_tool_result_message(message) -> list[Part] | None:
74
+ """
75
+ Convert a tool result message to A2A Parts.
76
+
77
+ In the Claude Agent SDK, tool results appear as content blocks with
78
+ type="tool_result" containing the output.
79
+ """
80
+ content = getattr(message, "content", None)
81
+ if not content:
82
+ return None
83
+
84
+ parts: list[Part] = []
85
+
86
+ for block in content:
87
+ block_type = getattr(block, "type", None)
88
+
89
+ if block_type == "tool_result":
90
+ tool_use_id = getattr(block, "tool_use_id", "")
91
+ tool_name = getattr(block, "name", None) or "tool_result"
92
+ result_content = getattr(block, "content", "")
93
+
94
+ # Result content can be a string or a list of content blocks
95
+ if isinstance(result_content, list):
96
+ text_parts = []
97
+ for sub in result_content:
98
+ if hasattr(sub, "text"):
99
+ text_parts.append(sub.text)
100
+ result_text = "\n".join(text_parts)
101
+ else:
102
+ result_text = str(result_content) if result_content else ""
103
+
104
+ # Truncate very long results for the dashboard
105
+ display_text = result_text[:2000] + "..." if len(result_text) > 2000 else result_text
106
+
107
+ data_part = DataPart(
108
+ data={
109
+ "id": tool_use_id,
110
+ "name": tool_name,
111
+ "response": {"result": display_text},
112
+ },
113
+ metadata={
114
+ get_kagent_metadata_key(A2A_DATA_PART_METADATA_TYPE_KEY): A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE,
115
+ },
116
+ )
117
+ parts.append(Part(data_part))
118
+
119
+ return parts if parts else None
120
+
121
+
122
+ def classify_sdk_message(message) -> str:
123
+ """
124
+ Classify a Claude Agent SDK message by type.
125
+
126
+ Returns one of: "system", "assistant", "user", "result", "unknown"
127
+ """
128
+ type_name = type(message).__name__
129
+
130
+ if type_name == "SystemMessage":
131
+ return "system"
132
+ elif type_name == "AssistantMessage":
133
+ return "assistant"
134
+ elif type_name == "UserMessage":
135
+ return "user"
136
+ elif type_name == "ResultMessage":
137
+ return "result"
138
+ else:
139
+ return "unknown"
140
+
141
+
142
+ def convert_message_to_parts(message) -> list[Part] | None:
143
+ """
144
+ Convert any Claude Agent SDK message to A2A Parts for streaming.
145
+
146
+ Returns None if the message shouldn't be streamed (e.g., system init,
147
+ final result which is handled separately).
148
+ """
149
+ msg_type = classify_sdk_message(message)
150
+
151
+ if msg_type == "assistant":
152
+ return convert_assistant_message(message)
153
+ elif msg_type == "user":
154
+ # User messages in the SDK are typically tool results
155
+ return convert_tool_result_message(message)
156
+ elif msg_type == "system":
157
+ # System messages (init, etc.) — don't stream
158
+ return None
159
+ elif msg_type == "result":
160
+ # Final result — handled by the main executor flow
161
+ return None
162
+ else:
163
+ return None
164
+
165
+
166
+ def make_message_id(message, index: int) -> str:
167
+ """Generate a deterministic message ID for deduplication."""
168
+ # Use message type + index for a stable ID
169
+ type_name = type(message).__name__
170
+ content_hash = hashlib.md5(
171
+ f"{type_name}:{index}".encode(), usedforsecurity=False
172
+ ).hexdigest()[:12]
173
+ return f"msg-{content_hash}"
174
+
175
+
176
+ class StreamingEventEmitter:
177
+ """
178
+ Manages streaming of intermediate events to the A2A event queue.
179
+
180
+ Handles deduplication (won't re-emit the same message) and provides
181
+ a clean interface for the executor to stream events.
182
+ """
183
+
184
+ def __init__(self, task_id: str, context_id: str):
185
+ self.task_id = task_id
186
+ self.context_id = context_id
187
+ self._sent_ids: set[str] = set()
188
+
189
+ def should_emit(self, message_id: str) -> bool:
190
+ """Check if this message has already been emitted."""
191
+ if message_id in self._sent_ids:
192
+ return False
193
+ self._sent_ids.add(message_id)
194
+ return True
195
+
196
+ def build_streaming_event(
197
+ self,
198
+ parts: list[Part],
199
+ message_id: str,
200
+ metadata: dict[str, Any] | None = None,
201
+ ) -> TaskStatusUpdateEvent:
202
+ """Build a TaskStatusUpdateEvent for streaming intermediate progress."""
203
+ return TaskStatusUpdateEvent(
204
+ task_id=self.task_id,
205
+ status=TaskStatus(
206
+ state=TaskState.working,
207
+ timestamp=datetime.now(timezone.utc).isoformat(),
208
+ message=Message(
209
+ message_id=message_id,
210
+ role=Role.agent,
211
+ parts=parts,
212
+ ),
213
+ ),
214
+ context_id=self.context_id,
215
+ final=False,
216
+ metadata=metadata,
217
+ )
@@ -0,0 +1,186 @@
1
+ """Error classification and user-friendly error messages for Claude Agent SDK errors."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Claude Agent SDK specific error patterns
10
+ _ANTHROPIC_RATE_LIMIT_PATTERNS = (
11
+ "rate_limit",
12
+ "429",
13
+ "too many requests",
14
+ "overloaded",
15
+ )
16
+
17
+ _ANTHROPIC_AUTH_PATTERNS = (
18
+ "authentication",
19
+ "unauthorized",
20
+ "invalid api key",
21
+ "401",
22
+ "invalid x-api-key",
23
+ )
24
+
25
+ _ANTHROPIC_CONTEXT_PATTERNS = (
26
+ "context window",
27
+ "too many tokens",
28
+ "maximum context length",
29
+ "prompt is too long",
30
+ )
31
+
32
+ _NETWORK_PATTERNS = (
33
+ "connection",
34
+ "timeout",
35
+ "dns",
36
+ "ssl",
37
+ "certificate",
38
+ "network",
39
+ )
40
+
41
+ _PERMISSION_PATTERNS = (
42
+ "permission denied",
43
+ "not allowed",
44
+ "forbidden",
45
+ "403",
46
+ )
47
+
48
+ _CLI_NOT_FOUND_PATTERNS = (
49
+ "cli not found",
50
+ "command not found",
51
+ "no such file or directory",
52
+ "claude: not found",
53
+ )
54
+
55
+
56
+ @dataclass
57
+ class ClassifiedError:
58
+ """A classified error with user-friendly message and structured metadata."""
59
+
60
+ error_type: str
61
+ user_message: str
62
+ detail: str
63
+ is_transient: bool = False
64
+
65
+
66
+ def classify_error(exception: Exception) -> ClassifiedError:
67
+ """
68
+ Classify an exception into a user-friendly error with structured metadata.
69
+
70
+ Returns a ClassifiedError with:
71
+ - error_type: machine-readable category (e.g., "rate_limit", "auth", "timeout")
72
+ - user_message: what to show the user
73
+ - detail: raw exception info for debugging
74
+ - is_transient: whether this error might succeed on retry
75
+ """
76
+ error_str = str(exception).lower()
77
+ error_class = type(exception).__name__
78
+ raw_detail = f"{error_class}: {exception}"
79
+
80
+ # Rate limiting
81
+ if any(p in error_str for p in _ANTHROPIC_RATE_LIMIT_PATTERNS):
82
+ return ClassifiedError(
83
+ error_type="rate_limit",
84
+ user_message="Claude is currently rate-limited. The request will be retried automatically.",
85
+ detail=raw_detail,
86
+ is_transient=True,
87
+ )
88
+
89
+ # Authentication errors
90
+ if any(p in error_str for p in _ANTHROPIC_AUTH_PATTERNS):
91
+ return ClassifiedError(
92
+ error_type="auth",
93
+ user_message="Authentication failed. Please verify the Anthropic API key is valid.",
94
+ detail=raw_detail,
95
+ is_transient=False,
96
+ )
97
+
98
+ # Context window exceeded
99
+ if any(p in error_str for p in _ANTHROPIC_CONTEXT_PATTERNS):
100
+ return ClassifiedError(
101
+ error_type="context_overflow",
102
+ user_message="The conversation exceeded Claude's context window. Please start a new conversation.",
103
+ detail=raw_detail,
104
+ is_transient=False,
105
+ )
106
+
107
+ # Network / connectivity
108
+ if any(p in error_str for p in _NETWORK_PATTERNS):
109
+ return ClassifiedError(
110
+ error_type="network",
111
+ user_message="A network error occurred while communicating with Claude. This may be transient.",
112
+ detail=raw_detail,
113
+ is_transient=True,
114
+ )
115
+
116
+ # Permission denied (tool execution)
117
+ if any(p in error_str for p in _PERMISSION_PATTERNS):
118
+ return ClassifiedError(
119
+ error_type="permission",
120
+ user_message="A tool execution was denied due to insufficient permissions.",
121
+ detail=raw_detail,
122
+ is_transient=False,
123
+ )
124
+
125
+ # CLI not found
126
+ if any(p in error_str for p in _CLI_NOT_FOUND_PATTERNS):
127
+ return ClassifiedError(
128
+ error_type="cli_not_found",
129
+ user_message="The Claude Agent SDK CLI binary was not found. Ensure claude-agent-sdk is installed correctly.",
130
+ detail=raw_detail,
131
+ is_transient=False,
132
+ )
133
+
134
+ # Timeout (asyncio)
135
+ if isinstance(exception, (TimeoutError, asyncio.TimeoutError)):
136
+ return ClassifiedError(
137
+ error_type="timeout",
138
+ user_message="The Claude query timed out. The task may be too complex or Claude may be slow to respond.",
139
+ detail=raw_detail,
140
+ is_transient=True,
141
+ )
142
+
143
+ # Cancellation
144
+ if isinstance(exception, asyncio.CancelledError):
145
+ return ClassifiedError(
146
+ error_type="cancelled",
147
+ user_message="The task was cancelled.",
148
+ detail=raw_detail,
149
+ is_transient=False,
150
+ )
151
+
152
+ # Process error (CLI exited non-zero)
153
+ if "exit code" in error_str or "process" in error_str:
154
+ return ClassifiedError(
155
+ error_type="process_error",
156
+ user_message="The Claude process exited unexpectedly. This may indicate a configuration issue.",
157
+ detail=raw_detail,
158
+ is_transient=True,
159
+ )
160
+
161
+ # JSON decode error (malformed response from CLI)
162
+ if "json" in error_str and ("decode" in error_str or "parse" in error_str):
163
+ return ClassifiedError(
164
+ error_type="parse_error",
165
+ user_message="Failed to parse Claude's response. This is usually a transient issue.",
166
+ detail=raw_detail,
167
+ is_transient=True,
168
+ )
169
+
170
+ # Unknown / generic
171
+ return ClassifiedError(
172
+ error_type="unknown",
173
+ user_message=f"An unexpected error occurred: {error_class}",
174
+ detail=raw_detail,
175
+ is_transient=False,
176
+ )
177
+
178
+
179
+ def get_error_metadata(classified: ClassifiedError) -> dict:
180
+ """Build structured error metadata for A2A event metadata."""
181
+ return {
182
+ "kagent.claude.error_type": classified.error_type,
183
+ "kagent.claude.error_detail": classified.detail,
184
+ "kagent.claude.error_transient": classified.is_transient,
185
+ }
186
+