deepagents-acp 0.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.
File without changes
@@ -0,0 +1,22 @@
1
+ import argparse
2
+ import asyncio
3
+ import os
4
+
5
+ from deepagents_acp.agent import run_agent
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(description="Run ACP DeepAgent with specified root directory")
10
+ parser.add_argument(
11
+ "--root-dir",
12
+ type=str,
13
+ default=None,
14
+ help="Root directory accessible to the agent (default: current working directory)",
15
+ )
16
+ args = parser.parse_args()
17
+ root_dir = args.root_dir if args.root_dir else os.getcwd()
18
+ asyncio.run(run_agent(root_dir))
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,648 @@
1
+ import json
2
+ from typing import Any
3
+ from uuid import uuid4
4
+
5
+ from acp import (
6
+ Agent as ACPAgent,
7
+ InitializeResponse,
8
+ NewSessionResponse,
9
+ PromptResponse,
10
+ SetSessionModeResponse,
11
+ run_agent as run_acp_agent,
12
+ start_edit_tool_call,
13
+ start_tool_call,
14
+ text_block,
15
+ tool_content,
16
+ tool_diff_content,
17
+ update_agent_message,
18
+ update_tool_call,
19
+ )
20
+ from acp.interfaces import Client
21
+ from acp.schema import (
22
+ AgentCapabilities,
23
+ AgentPlanUpdate,
24
+ AudioContentBlock,
25
+ ClientCapabilities,
26
+ EmbeddedResourceContentBlock,
27
+ HttpMcpServer,
28
+ ImageContentBlock,
29
+ Implementation,
30
+ McpServerStdio,
31
+ PermissionOption,
32
+ PlanEntry,
33
+ PromptCapabilities,
34
+ ResourceContentBlock,
35
+ SessionMode,
36
+ SessionModeState,
37
+ SseMcpServer,
38
+ TextContentBlock,
39
+ ToolCallStart,
40
+ ToolCallUpdate,
41
+ )
42
+ from deepagents import create_deep_agent
43
+ from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
44
+ from deepagents.graph import Checkpointer, CompiledStateGraph
45
+ from dotenv import load_dotenv
46
+ from langchain_core.runnables import RunnableConfig
47
+ from langgraph.checkpoint.memory import MemorySaver
48
+ from langgraph.types import Command, StateSnapshot
49
+
50
+ from deepagents_acp.utils import (
51
+ convert_audio_block_to_content_blocks,
52
+ convert_embedded_resource_block_to_content_blocks,
53
+ convert_image_block_to_content_blocks,
54
+ convert_resource_block_to_content_blocks,
55
+ convert_text_block_to_content_blocks,
56
+ )
57
+
58
+ load_dotenv()
59
+
60
+
61
+ class ACPDeepAgent(ACPAgent):
62
+ _conn: Client
63
+
64
+ _deepagent: CompiledStateGraph
65
+ _root_dir: str
66
+ _checkpointer: Checkpointer
67
+ _mode: str
68
+
69
+ @staticmethod
70
+ def _get_interrupt_config(mode_id: str) -> dict:
71
+ """Get interrupt configuration for a given mode"""
72
+ mode_to_interrupt = {
73
+ "ask_before_edits": {
74
+ "edit_file": {"allowed_decisions": ["approve", "reject"]},
75
+ "write_file": {"allowed_decisions": ["approve", "reject"]},
76
+ "write_todos": {"allowed_decisions": ["approve", "reject"]},
77
+ },
78
+ "auto": {
79
+ "write_todos": {"allowed_decisions": ["approve", "reject"]},
80
+ },
81
+ }
82
+ return mode_to_interrupt.get(mode_id, {})
83
+
84
+ def _create_deepagent(self, mode: str):
85
+ """Create a DeepAgent with the appropriate configuration for the given mode"""
86
+ interrupt_config = self._get_interrupt_config(mode)
87
+
88
+ def create_backend(tr):
89
+ ephemeral_backend = StateBackend(tr)
90
+ return CompositeBackend(
91
+ default=FilesystemBackend(root_dir=self._root_dir, virtual_mode=True),
92
+ routes={
93
+ "/memories/": ephemeral_backend,
94
+ "/conversation_history/": ephemeral_backend,
95
+ },
96
+ )
97
+
98
+ return create_deep_agent(
99
+ checkpointer=self._checkpointer,
100
+ backend=create_backend,
101
+ interrupt_on=interrupt_config,
102
+ )
103
+
104
+ def __init__(
105
+ self,
106
+ root_dir: str,
107
+ checkpointer: Checkpointer,
108
+ mode: str,
109
+ ):
110
+ self._root_dir = root_dir
111
+ self._checkpointer = checkpointer
112
+ self._mode = mode
113
+ self._deepagent = self._create_deepagent(mode)
114
+ self._cancelled = False
115
+ self._session_plans: dict[str, list[dict[str, Any]]] = {} # Track current plan per session
116
+ super().__init__()
117
+
118
+ def on_connect(self, conn: Client) -> None:
119
+ self._conn = conn
120
+
121
+ async def initialize(
122
+ self,
123
+ protocol_version: int,
124
+ client_capabilities: ClientCapabilities | None = None,
125
+ client_info: Implementation | None = None,
126
+ **kwargs: Any,
127
+ ) -> InitializeResponse:
128
+ return InitializeResponse(
129
+ protocol_version=protocol_version,
130
+ agent_capabilities=AgentCapabilities(
131
+ prompt_capabilities=PromptCapabilities(
132
+ image=True,
133
+ # embedded_context=True,
134
+ )
135
+ ),
136
+ )
137
+
138
+ async def new_session(
139
+ self,
140
+ cwd: str,
141
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
142
+ **kwargs: Any,
143
+ ) -> NewSessionResponse:
144
+ # Define available modes
145
+ available_modes = [
146
+ SessionMode(
147
+ id="ask_before_edits",
148
+ name="Ask before edits",
149
+ description="Ask permission before edits and writes",
150
+ ),
151
+ SessionMode(
152
+ id="auto",
153
+ name="Accept edits",
154
+ description="Auto-accept edit operations",
155
+ ),
156
+ ]
157
+
158
+ return NewSessionResponse(
159
+ session_id=uuid4().hex,
160
+ modes=SessionModeState(
161
+ available_modes=available_modes,
162
+ current_mode_id=self._mode,
163
+ ),
164
+ )
165
+
166
+ async def set_session_mode(
167
+ self,
168
+ mode_id: str,
169
+ session_id: str,
170
+ **kwargs: Any,
171
+ ) -> SetSessionModeResponse:
172
+ # Recreate the deep agent with new mode configuration
173
+ self._deepagent = self._create_deepagent(mode_id)
174
+ self._mode = mode_id
175
+
176
+ return SetSessionModeResponse()
177
+
178
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
179
+ """Cancel the current execution."""
180
+ self._cancelled = True
181
+
182
+ async def _log_text(self, session_id: str, text: str):
183
+ update = update_agent_message(text_block(text))
184
+ await self._conn.session_update(session_id=session_id, update=update, source="DeepAgent")
185
+
186
+ def _all_tasks_completed(self, plan: list[dict[str, Any]]) -> bool:
187
+ """Check if all tasks in a plan are completed.
188
+
189
+ Args:
190
+ plan: List of todo dictionaries
191
+
192
+ Returns:
193
+ True if all tasks have status 'completed', False otherwise
194
+ """
195
+ if not plan:
196
+ return True
197
+
198
+ return all(todo.get("status") == "completed" for todo in plan)
199
+
200
+ async def _clear_plan(self, session_id: str) -> None:
201
+ """Clear the plan by sending an empty plan update.
202
+
203
+ Args:
204
+ session_id: The session ID
205
+ """
206
+ update = AgentPlanUpdate(
207
+ session_update="plan",
208
+ entries=[],
209
+ )
210
+ await self._conn.session_update(
211
+ session_id=session_id,
212
+ update=update,
213
+ source="DeepAgent",
214
+ )
215
+ # Clear the stored plan for this session
216
+ self._session_plans[session_id] = []
217
+
218
+ async def _handle_todo_update(
219
+ self,
220
+ session_id: str,
221
+ todos: list[dict[str, Any]],
222
+ log_plan: bool = True,
223
+ ) -> None:
224
+ """Handle todo list updates from write_todos tool.
225
+
226
+ Args:
227
+ session_id: The session ID
228
+ todos: List of todo dictionaries with 'content' and 'status' fields
229
+ log_plan: Whether to log the plan as a visible text message
230
+ """
231
+ # Convert todos to PlanEntry objects
232
+ entries = []
233
+ for todo in todos:
234
+ # Extract fields from todo dict
235
+ content = todo.get("content", "")
236
+ status = todo.get("status", "pending")
237
+
238
+ # Validate and cast status to PlanEntryStatus
239
+ if status not in ("pending", "in_progress", "completed"):
240
+ status = "pending"
241
+
242
+ # Create PlanEntry with default priority of "medium"
243
+ entry = PlanEntry(
244
+ content=content,
245
+ status=status, # type: ignore
246
+ priority="medium",
247
+ )
248
+ entries.append(entry)
249
+
250
+ # Send plan update notification
251
+ update = AgentPlanUpdate(
252
+ session_update="plan",
253
+ entries=entries,
254
+ )
255
+ await self._conn.session_update(
256
+ session_id=session_id,
257
+ update=update,
258
+ source="DeepAgent",
259
+ )
260
+
261
+ # Optionally send a visible text message showing the plan
262
+ if log_plan:
263
+ plan_text = "## Plan\n\n"
264
+ for i, todo in enumerate(todos, 1):
265
+ content = todo.get("content", "")
266
+ plan_text += f"{i}. {content}\n"
267
+
268
+ await self._log_text(session_id=session_id, text=plan_text)
269
+
270
+ async def _process_tool_call_chunks(
271
+ self,
272
+ session_id: str,
273
+ message_chunk: Any,
274
+ active_tool_calls: dict,
275
+ tool_call_accumulator: dict,
276
+ ) -> None:
277
+ """Process tool call chunks and start tool calls when complete."""
278
+ if (
279
+ not isinstance(message_chunk, str)
280
+ and hasattr(message_chunk, "tool_call_chunks")
281
+ and message_chunk.tool_call_chunks
282
+ ):
283
+ for chunk in message_chunk.tool_call_chunks:
284
+ chunk_id = chunk.get("id")
285
+ chunk_name = chunk.get("name")
286
+ chunk_args = chunk.get("args", "")
287
+ chunk_index = chunk.get("index", 0)
288
+
289
+ # Initialize accumulator for this index if we have id and name
290
+ if chunk_id and chunk_name:
291
+ if (
292
+ chunk_index not in tool_call_accumulator
293
+ or chunk_id != tool_call_accumulator[chunk_index]
294
+ ):
295
+ tool_call_accumulator[chunk_index] = {
296
+ "id": chunk_id,
297
+ "name": chunk_name,
298
+ "args_str": "",
299
+ }
300
+
301
+ # Accumulate args string chunks using index
302
+ if chunk_args and chunk_index in tool_call_accumulator:
303
+ tool_call_accumulator[chunk_index]["args_str"] += chunk_args
304
+
305
+ # After processing chunks, try to start any tool calls with complete args
306
+ for index, acc in tool_call_accumulator.items():
307
+ tool_id = acc.get("id")
308
+ tool_name = acc.get("name")
309
+ args_str = acc.get("args_str", "")
310
+
311
+ # Only start if we haven't started yet and have parseable args
312
+ if tool_id and tool_id not in active_tool_calls and args_str:
313
+ try:
314
+ tool_args = json.loads(args_str)
315
+
316
+ # Mark as started and store args for later reference
317
+ active_tool_calls[tool_id] = {
318
+ "name": tool_name,
319
+ "args": tool_args,
320
+ }
321
+
322
+ # Create the appropriate tool call update
323
+ update = self._create_tool_call_update(tool_id, tool_name, tool_args)
324
+
325
+ await self._conn.session_update(
326
+ session_id=session_id,
327
+ update=update,
328
+ source="DeepAgent",
329
+ )
330
+
331
+ # If this is write_todos, send the plan update immediately
332
+ if tool_name == "write_todos" and isinstance(tool_args, dict):
333
+ todos = tool_args.get("todos", [])
334
+ await self._handle_todo_update(session_id, todos, log_plan=False)
335
+ except json.JSONDecodeError:
336
+ pass
337
+
338
+ def _create_tool_call_update(
339
+ self, tool_id: str, tool_name: str, tool_args: dict[str, Any]
340
+ ) -> ToolCallStart:
341
+ """Create a tool call update based on tool type and arguments."""
342
+ kind_map: dict = {
343
+ "read_file": "read",
344
+ "edit_file": "edit",
345
+ "write_file": "edit",
346
+ "ls": "search",
347
+ "glob": "search",
348
+ "grep": "search",
349
+ }
350
+ tool_kind = kind_map.get(tool_name, "other")
351
+
352
+ # Determine title and create appropriate update based on tool type
353
+ if tool_name == "read_file" and isinstance(tool_args, dict):
354
+ path = tool_args.get("file_path")
355
+ title = f"Read `{path}`" if path else tool_name
356
+ return start_tool_call(
357
+ tool_call_id=tool_id,
358
+ title=title,
359
+ kind=tool_kind,
360
+ status="pending",
361
+ )
362
+ elif tool_name == "edit_file" and isinstance(tool_args, dict):
363
+ path = tool_args.get("file_path", "")
364
+ old_string = tool_args.get("old_string", "")
365
+ new_string = tool_args.get("new_string", "")
366
+ title = f"Edit `{path}`" if path else tool_name
367
+
368
+ # Only create diff if we have both old and new strings
369
+ if path and old_string and new_string:
370
+ diff_content = tool_diff_content(
371
+ path=path,
372
+ new_text=new_string,
373
+ old_text=old_string,
374
+ )
375
+ return start_edit_tool_call(
376
+ tool_call_id=tool_id,
377
+ title=title,
378
+ path=path,
379
+ content=diff_content,
380
+ # This is silly but for some reason content isn't passed through
381
+ extra_options=[diff_content],
382
+ )
383
+ else:
384
+ # Fallback to generic tool call if data incomplete
385
+ return start_tool_call(
386
+ tool_call_id=tool_id,
387
+ title=title,
388
+ kind=tool_kind,
389
+ status="pending",
390
+ )
391
+ elif tool_name == "write_file" and isinstance(tool_args, dict):
392
+ path = tool_args.get("file_path")
393
+ title = f"Write `{path}`" if path else tool_name
394
+ return start_tool_call(
395
+ tool_call_id=tool_id,
396
+ title=title,
397
+ kind=tool_kind,
398
+ status="pending",
399
+ )
400
+ else:
401
+ title = tool_name
402
+ return start_tool_call(
403
+ tool_call_id=tool_id,
404
+ title=title,
405
+ kind=tool_kind,
406
+ status="pending",
407
+ )
408
+
409
+ async def prompt(
410
+ self,
411
+ prompt: list[
412
+ TextContentBlock
413
+ | ImageContentBlock
414
+ | AudioContentBlock
415
+ | ResourceContentBlock
416
+ | EmbeddedResourceContentBlock
417
+ ],
418
+ session_id: str,
419
+ **kwargs: Any,
420
+ ) -> PromptResponse:
421
+ # Reset cancellation flag for new prompt
422
+ self._cancelled = False
423
+
424
+ # Convert ACP content blocks to LangChain multimodal content format
425
+ content_blocks = []
426
+
427
+ for block in prompt:
428
+ if isinstance(block, TextContentBlock):
429
+ content_blocks.extend(convert_text_block_to_content_blocks(block))
430
+ elif isinstance(block, ImageContentBlock):
431
+ content_blocks.extend(convert_image_block_to_content_blocks(block))
432
+ elif isinstance(block, AudioContentBlock):
433
+ content_blocks.extend(convert_audio_block_to_content_blocks(block))
434
+ elif isinstance(block, ResourceContentBlock):
435
+ content_blocks.extend(
436
+ convert_resource_block_to_content_blocks(block, root_dir=self._root_dir)
437
+ )
438
+ elif isinstance(block, EmbeddedResourceContentBlock):
439
+ content_blocks.extend(convert_embedded_resource_block_to_content_blocks(block))
440
+ # Stream the deep agent response with multimodal content
441
+ config: RunnableConfig = {"configurable": {"thread_id": session_id}}
442
+
443
+ # Track active tool calls and accumulate chunks by index
444
+ active_tool_calls = {}
445
+ tool_call_accumulator = {} # index -> {id, name, args_str}
446
+
447
+ current_state = None
448
+ user_decisions = []
449
+
450
+ while current_state is None or current_state.interrupts:
451
+ # Check for cancellation
452
+ if self._cancelled:
453
+ self._cancelled = False # Reset for next prompt
454
+ return PromptResponse(stop_reason="cancelled")
455
+
456
+ async for message_chunk, metadata in self._deepagent.astream(
457
+ Command(resume={"decisions": user_decisions})
458
+ if user_decisions
459
+ else {"messages": [{"role": "user", "content": content_blocks}]},
460
+ config=config,
461
+ stream_mode="messages",
462
+ ):
463
+ # Check for cancellation during streaming
464
+ if self._cancelled:
465
+ self._cancelled = False # Reset for next prompt
466
+ return PromptResponse(stop_reason="cancelled")
467
+
468
+ # Process tool call chunks
469
+ await self._process_tool_call_chunks(
470
+ session_id,
471
+ message_chunk,
472
+ active_tool_calls,
473
+ tool_call_accumulator,
474
+ )
475
+
476
+ if isinstance(message_chunk, str):
477
+ await self._log_text(text=message_chunk, session_id=session_id)
478
+ # Check for tool results (ToolMessage responses)
479
+ elif hasattr(message_chunk, "type") and message_chunk.type == "tool":
480
+ # This is a tool result message
481
+ tool_call_id = getattr(message_chunk, "tool_call_id", None)
482
+ if tool_call_id and tool_call_id in active_tool_calls:
483
+ if active_tool_calls[tool_call_id].get("name") != "edit_file":
484
+ # Update the tool call with completion status and result
485
+ content = getattr(message_chunk, "content", "")
486
+ update = update_tool_call(
487
+ tool_call_id=tool_call_id,
488
+ status="completed",
489
+ content=[tool_content(text_block(str(content)))],
490
+ )
491
+ await self._conn.session_update(
492
+ session_id=session_id, update=update, source="DeepAgent"
493
+ )
494
+
495
+ elif message_chunk.content:
496
+ # content can be a string or a list of content blocks
497
+ if isinstance(message_chunk.content, str):
498
+ text = message_chunk.content
499
+ elif isinstance(message_chunk.content, list):
500
+ # Extract text from content blocks
501
+ text = ""
502
+ for block in message_chunk.content:
503
+ if isinstance(block, dict) and block.get("type") == "text":
504
+ text += block.get("text", "")
505
+ elif isinstance(block, str):
506
+ text += block
507
+ else:
508
+ text = str(message_chunk.content)
509
+
510
+ if text:
511
+ await self._log_text(text=text, session_id=session_id)
512
+
513
+ # Check if the agent is interrupted (waiting for HITL approval)
514
+ current_state = await self._deepagent.aget_state(config)
515
+ user_decisions = await self._handle_interrupts(
516
+ current_state=current_state,
517
+ session_id=session_id,
518
+ active_tool_calls=active_tool_calls,
519
+ )
520
+
521
+ return PromptResponse(stop_reason="end_turn")
522
+
523
+ async def _handle_interrupts(
524
+ self, *, current_state: StateSnapshot, session_id: str, active_tool_calls: dict
525
+ ):
526
+ user_decisions = []
527
+ if current_state.next and current_state.interrupts:
528
+ # Agent is interrupted, request permission from user
529
+ for interrupt in current_state.interrupts:
530
+ # Get the tool call info from the interrupt
531
+ tool_call_id = interrupt.id
532
+ interrupt_value = interrupt.value
533
+
534
+ # Extract action requests from interrupt_value
535
+ action_requests = []
536
+ if isinstance(interrupt_value, dict):
537
+ # DeepAgents wraps tool calls in action_requests
538
+ action_requests = interrupt_value.get("action_requests", [])
539
+
540
+ # Process each action request
541
+ for action in action_requests:
542
+ tool_name = action.get("name", "tool")
543
+ tool_args = action.get("args", {})
544
+
545
+ # Check if this is write_todos - auto-approve updates to existing plan
546
+ if tool_name == "write_todos" and isinstance(tool_args, dict):
547
+ new_todos = tool_args.get("todos", [])
548
+
549
+ # Auto-approve if there's an existing plan that's not fully completed
550
+ if session_id in self._session_plans:
551
+ existing_plan = self._session_plans[session_id]
552
+ all_completed = self._all_tasks_completed(existing_plan)
553
+
554
+ if not all_completed:
555
+ # Plan is in progress, auto-approve updates
556
+ # Store the updated plan (status and content may have changed)
557
+ self._session_plans[session_id] = new_todos
558
+ user_decisions.append({"type": "approve"})
559
+ continue
560
+
561
+ # Create a title for the permission request
562
+ if tool_name == "write_todos":
563
+ title = "Review Plan"
564
+ # Log the plan text when requesting approval
565
+ todos = tool_args.get("todos", [])
566
+ plan_text = "## Plan\n\n"
567
+ for i, todo in enumerate(todos, 1):
568
+ content = todo.get("content", "")
569
+ plan_text += f"{i}. {content}\n"
570
+ await self._log_text(session_id=session_id, text=plan_text)
571
+ elif tool_name == "edit_file" and isinstance(tool_args, dict):
572
+ file_path = tool_args.get("file_path", "file")
573
+ title = f"Edit `{file_path}`"
574
+ elif tool_name == "write_file" and isinstance(tool_args, dict):
575
+ file_path = tool_args.get("file_path", "file")
576
+ title = f"Write `{file_path}`"
577
+ else:
578
+ title = tool_name
579
+
580
+ # Create permission options
581
+ options = [
582
+ PermissionOption(
583
+ option_id="approve",
584
+ name="Approve",
585
+ kind="allow_once",
586
+ ),
587
+ PermissionOption(
588
+ option_id="reject",
589
+ name="Reject",
590
+ kind="reject_once",
591
+ ),
592
+ ]
593
+
594
+ # Request permission from the client
595
+ tool_call_update = ToolCallUpdate(
596
+ tool_call_id=tool_call_id,
597
+ title=title,
598
+ )
599
+ response = await self._conn.request_permission(
600
+ session_id=session_id,
601
+ tool_call=tool_call_update,
602
+ options=options,
603
+ )
604
+ # Handle the user's decision
605
+ if response.outcome.outcome == "selected":
606
+ decision_type = response.outcome.option_id
607
+
608
+ # If rejecting a plan, clear it and provide feedback
609
+ if tool_name == "write_todos" and decision_type == "reject":
610
+ await self._clear_plan(session_id)
611
+ user_decisions.append(
612
+ {
613
+ "type": decision_type,
614
+ "feedback": (
615
+ "The user rejected the plan. Please ask them for feedback "
616
+ "on how the plan can be improved, then create a new "
617
+ "and improved plan using this same write_todos tool."
618
+ ),
619
+ }
620
+ )
621
+ elif tool_name == "write_todos" and decision_type == "approve":
622
+ # Store the approved plan for future comparisons
623
+ self._session_plans[session_id] = tool_args.get("todos", [])
624
+ user_decisions.append({"type": decision_type})
625
+ else:
626
+ user_decisions.append({"type": decision_type})
627
+ else:
628
+ # User cancelled, treat as rejection
629
+ user_decisions.append({"type": "reject"})
630
+
631
+ # If cancelling a plan, clear it
632
+ if tool_name == "write_todos":
633
+ await self._clear_plan(session_id)
634
+ return user_decisions
635
+
636
+
637
+ async def run_agent(root_dir: str) -> None:
638
+ checkpointer = MemorySaver()
639
+
640
+ # Start with ask_before_edits mode (ask before edits)
641
+ mode_id = "ask_before_edits"
642
+
643
+ acp_agent = ACPDeepAgent(
644
+ root_dir=root_dir,
645
+ mode=mode_id,
646
+ checkpointer=checkpointer,
647
+ )
648
+ await run_acp_agent(acp_agent)
File without changes
@@ -0,0 +1,77 @@
1
+ from acp.schema import (
2
+ AudioContentBlock,
3
+ EmbeddedResourceContentBlock,
4
+ ImageContentBlock,
5
+ ResourceContentBlock,
6
+ TextContentBlock,
7
+ )
8
+
9
+
10
+ def convert_text_block_to_content_blocks(block: TextContentBlock):
11
+ return [{"type": "text", "text": block.text}]
12
+
13
+
14
+ def convert_image_block_to_content_blocks(block: ImageContentBlock):
15
+ # Image blocks contain visual data
16
+ # Primary case: inline base64 data (data is already a base64 string)
17
+ if block.data:
18
+ data_uri = f"data:{block.mime_type};base64,{block.data}"
19
+ return [{"type": "image_url", "image_url": {"url": data_uri}}]
20
+
21
+ # No data available
22
+ return [{"type": "text", "text": "[Image: no data available]"}]
23
+
24
+
25
+ def convert_audio_block_to_content_blocks(block: AudioContentBlock):
26
+ raise Exception("Audio is not currently supported.")
27
+
28
+
29
+ def convert_resource_block_to_content_blocks(block: ResourceContentBlock, *, root_dir: str):
30
+ # Resource blocks reference external resources
31
+ resource_text = f"[Resource: {block.name}"
32
+ if block.uri:
33
+ # Truncate root_dir from path while preserving file:// prefix
34
+ uri = block.uri
35
+ has_file_prefix = uri.startswith("file://")
36
+ if has_file_prefix:
37
+ path = uri[7:] # Remove "file://" temporarily
38
+ else:
39
+ path = uri
40
+
41
+ # Remove root_dir prefix to get path relative to agent's working directory
42
+ if path.startswith(root_dir):
43
+ path = path[len(root_dir) :].lstrip("/")
44
+
45
+ # Restore file:// prefix if it was present
46
+ uri = f"file://{path}" if has_file_prefix else path
47
+ resource_text += f"\nURI: {uri}"
48
+ if block.description:
49
+ resource_text += f"\nDescription: {block.description}"
50
+ if block.mime_type:
51
+ resource_text += f"\nMIME type: {block.mime_type}"
52
+ resource_text += "]"
53
+ return [{"type": "text", "text": resource_text}]
54
+
55
+
56
+ def convert_embedded_resource_block_to_content_blocks(
57
+ block: EmbeddedResourceContentBlock,
58
+ ) -> list[dict]:
59
+ # Embedded resource blocks contain the resource data inline
60
+ resource = block.resource
61
+ if hasattr(resource, "text"):
62
+ mime_type = getattr(resource, "mime_type", "application/text")
63
+ return [{"type": "text", "text": f"[Embedded {mime_type} resource: {resource.text}"}]
64
+ elif hasattr(resource, "blob"):
65
+ mime_type = getattr(resource, "mime_type", "application/octet-stream")
66
+ data_uri = f"data:{mime_type};base64,{resource.blob}"
67
+ return [
68
+ {
69
+ "type": "text",
70
+ "text": f"[Embedded resource: {data_uri}]",
71
+ }
72
+ ]
73
+ else:
74
+ raise Exception(
75
+ "Could not parse embedded resource block. "
76
+ "Block expected either a `text` or `blob` property."
77
+ )
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: deepagents-acp
3
+ Version: 0.0.1
4
+ Summary: Agent Client Protocol integration for Deep Agents
5
+ Project-URL: Homepage, https://docs.langchain.com/oss/python/deepagents/overview
6
+ Project-URL: Documentation, https://reference.langchain.com/python/deepagents/
7
+ Project-URL: Repository, https://github.com/langchain-ai/deepagents
8
+ Project-URL: Issues, https://github.com/langchain-ai/deepagents/issues
9
+ Project-URL: Twitter, https://x.com/LangChain
10
+ License: MIT
11
+ Keywords: acp,agent,agent-client-protocol,ai-agents,deepagents
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: agent-client-protocol>=0.7.1
20
+ Requires-Dist: deepagents
21
+ Requires-Dist: python-dotenv>=1.2.1
22
+ Description-Content-Type: text/markdown
23
+
24
+ # DeepAgents ACP integration
25
+
26
+ This repo contains an [Agent Client Protocol (ACP)](https://agentclientprotocol.com/overview/introduction) connector that allows you to run a Python [DeepAgent](https://docs.langchain.com/oss/python/deepagents/overview) within a text editor that supports ACP such as [Zed](https://zed.dev/).
27
+
28
+ The DeepAgent lives as code in `deepagents_acp/agent.py`, and can interact with the files of a project you have open in your ACP-compatible editor.
29
+
30
+ ![DeepAgents ACP Demo](./static/img/deepagentsacp.gif)
31
+
32
+ Out of the box, your agent uses Anthropic's Claude models to do things like write code with its built-in filesystem tools, but you can also extend it with additional tools or agent architectures!
33
+
34
+ ## Getting started
35
+
36
+ First, make sure you have [Zed](https://zed.dev/) and [`uv`](https://docs.astral.sh/uv/) installed.
37
+
38
+ Next, clone this repo:
39
+
40
+ ```sh
41
+ git clone git@github.com:langchain-ai/deepagents.git
42
+ ```
43
+
44
+ Then, navigate into the newly created folder and run `uv sync`:
45
+
46
+ ```sh
47
+ cd deepagents/libs/acp
48
+ uv sync
49
+ ```
50
+
51
+ Rename the `.env.example` file to `.env` and add your [Anthropic](https://claude.com/platform/api) API key. You may also optionally set up tracing for your DeepAgent using [LangSmith](https://smith.langchain.com/) by populating the other env vars in the example file:
52
+
53
+ ```ini
54
+ ANTHROPIC_API_KEY=""
55
+
56
+ # Set up LangSmith tracing for your DeepAgent (optional)
57
+
58
+ # LANGSMITH_TRACING=true
59
+ # LANGSMITH_API_KEY=""
60
+ # LANGSMITH_PROJECT="deepagents-acp"
61
+ ```
62
+
63
+ Finally, add this to your Zed `settings.json`:
64
+
65
+ ```json
66
+ {
67
+ "agent_servers": {
68
+ "DeepAgents": {
69
+ "type": "custom",
70
+ "command": "/your/absolute/path/to/deepagents-acp/run.sh"
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ You must also make sure that the `run.sh` entrypoint file is executable - this should be the case by default, but if you see permissions issues, run:
77
+
78
+ ```sh
79
+ chmod +x run.sh
80
+ ```
81
+
82
+ Now, open Zed's Agents Panel (e.g. with `CMD + Shift + ?`). You should see an option to create a new DeepAgent thread:
83
+
84
+ ![](./static/img/newdeepagent.png)
85
+
86
+ And that's it! You can now use the DeepAgent in Zed to interact with your project.
@@ -0,0 +1,9 @@
1
+ deepagents_acp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ deepagents_acp/__main__.py,sha256=CM3ykI6j6mmrS35slUKAL5es5-yN_gK0o-Jol3QY2m0,555
3
+ deepagents_acp/agent.py,sha256=n1D6R1DQy5AK5jxA404uYNfFVaiSzXA1ciUfjObjg7g,25462
4
+ deepagents_acp/py.typed.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ deepagents_acp/utils.py,sha256=0WMWLcm-_MOCK8D3QX9gSrCl5UPOw-LLQxDBF5hiyYU,2790
6
+ deepagents_acp-0.0.1.dist-info/METADATA,sha256=fOowHkFqvgNQ8C3A2QQQSW2Sf9rxVoWKOn5VEBJnRFU,3128
7
+ deepagents_acp-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ deepagents_acp-0.0.1.dist-info/entry_points.txt,sha256=YmPAgpMKLVGmkebI3Uopy0h5k70Qw6MyAuM_ZYn_npk,64
9
+ deepagents_acp-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deepagents-acp = deepagents_acp.__main__:main