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.
- deepagents_acp/__init__.py +0 -0
- deepagents_acp/__main__.py +22 -0
- deepagents_acp/agent.py +648 -0
- deepagents_acp/py.typed.py +0 -0
- deepagents_acp/utils.py +77 -0
- deepagents_acp-0.0.1.dist-info/METADATA +86 -0
- deepagents_acp-0.0.1.dist-info/RECORD +9 -0
- deepagents_acp-0.0.1.dist-info/WHEEL +4 -0
- deepagents_acp-0.0.1.dist-info/entry_points.txt +2 -0
|
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()
|
deepagents_acp/agent.py
ADDED
|
@@ -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
|
deepagents_acp/utils.py
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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,,
|