Skip to content

Commit ece2596

Browse files
committed
Merge PR #714: ACP v1 stdio agent
2 parents b07a9f6 + 1f4d055 commit ece2596

22 files changed

Lines changed: 3452 additions & 10 deletions

File tree

‎cecli/args.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,24 @@ def get_parser(default_config_files, git_root):
13131313
help="Specify server configuration (WebSocket and headless) as a JSON/YAML string",
13141314
default=None,
13151315
)
1316+
group.add_argument(
1317+
"--acp",
1318+
action="store_true",
1319+
default=False,
1320+
help=(
1321+
"Run as an Agent Client Protocol (ACP) agent over stdio, for editors such as Zed"
1322+
" (stdout carries the protocol)"
1323+
),
1324+
)
1325+
group.add_argument(
1326+
"--acp-models",
1327+
action="append",
1328+
metavar="PATTERN",
1329+
help=(
1330+
"Limit the ACP model picker's provider lists to models matching this pattern,"
1331+
" matched like /models (can be used multiple times)"
1332+
),
1333+
)
13161334

13171335
##########
13181336
group = parser.add_argument_group("Deprecated model settings")

‎cecli/coders/agent_coder.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,7 @@ async def _execute_local_tools(self, tool_calls_list):
842842
for tool_call in tool_calls_list:
843843
tool_name = tool_call.function.name
844844
result_message = ""
845+
self.io.tool_call_event(tool_call.id, "in_progress")
845846
try:
846847
if responses.unprefix_tool_name(tool_name)[1].lower() in self.write_tools:
847848
used_write_tool = True

‎cecli/coders/base_coder.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3284,6 +3284,7 @@ async def _execute_mcp_tools(self, server, tool_calls):
32843284
continue
32853285

32863286
tool_id_set.add(tool_call.id)
3287+
self.io.tool_call_event(tool_call.id, "in_progress")
32873288

32883289
try:
32893290
# Arguments can be a stream of JSON objects.
@@ -3490,6 +3491,11 @@ async def process_tool_calls(self, tool_call_response):
34903491
for server, server_responses in tool_responses_by_server.items():
34913492
for tool_response in server_responses:
34923493
tool_responses.append(tool_response)
3494+
self.io.tool_call_event(
3495+
tool_response["tool_call_id"],
3496+
"completed",
3497+
result=tool_response.get("content"),
3498+
)
34933499

34943500
ConversationService.get_manager(self).add_message(
34953501
message_dict=tool_response,
@@ -3507,6 +3513,12 @@ def _print_tool_call_info(self, server_tool_calls):
35073513

35083514
for server, tool_calls in server_tool_calls.items():
35093515
for tool_call in tool_calls:
3516+
self.io.tool_call_event(
3517+
tool_call.id,
3518+
"pending",
3519+
name=tool_call.function.name,
3520+
arguments=tool_call.function.arguments,
3521+
)
35103522
try:
35113523
if ToolRegistry.get_tool(tool_call.function.name.lower()):
35123524
ToolRegistry.get_tool(tool_call.function.name.lower()).format_output(
@@ -4828,7 +4840,7 @@ def show_usage_report(self):
48284840
if self.tui and self.tui():
48294841
self.tui().update_cost(self.usage_report.replace("\n", " "))
48304842
else:
4831-
self.io.tool_output(self.usage_report)
4843+
self.io.tool_output(self.usage_report, type="usage-report")
48324844
self.io.rule()
48334845

48344846
self.message_cost = 0.0

‎cecli/helpers/agents/service.py‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@
4141
AGENT_INHERIT_PREFIXES = ("ws:", "wf:")
4242

4343

44+
def _subagent_event(coder: Any, agent_uuid: Any, event: str, **kwargs: Any) -> None:
45+
"""Report a sub-agent's lifecycle to the IO (see ``InputOutput.subagent_event``)."""
46+
io = getattr(coder, "io", None)
47+
if io is None:
48+
return
49+
try:
50+
io.subagent_event(str(agent_uuid), event, **kwargs)
51+
except Exception:
52+
logger.warning("Failed to report sub-agent event %r", event, exc_info=True)
53+
54+
55+
def _was_cancelled(info: "SubAgentInfo") -> bool:
56+
"""Whether the current run was cancelled, even if the coder absorbed it as a ^C."""
57+
if info.cancel_requested:
58+
return True
59+
cancelling = getattr(asyncio.current_task(), "cancelling", None) # Python 3.11+
60+
return bool(cancelling and cancelling())
61+
62+
4463
class SubAgentStatus(Enum):
4564
"""Status of a sub-agent."""
4665

@@ -66,6 +85,7 @@ class SubAgentInfo:
6685
auto_reap: bool = True # If True, agent may be automatically reaped when FINISHED
6786
independent: bool = False
6887
detached: bool = False
88+
cancel_requested: bool = False # the current run was cancelled (see _was_cancelled)
6989

7090

7191
class AgentService:
@@ -522,6 +542,8 @@ def _cleanup_sub_agent(self, agent_uuid: str) -> None:
522542
except (AttributeError, RuntimeError):
523543
logger.warning("Failed to notify TUI to remove sub-agent container", exc_info=True)
524544

545+
_subagent_event(self.coder, info.coder.uuid, "removed")
546+
525547
# Cancel any tracked generate task to avoid floating tasks
526548
if info.generate_task is not None and not info.generate_task.done():
527549
info.generate_task.cancel()
@@ -733,6 +755,16 @@ async def _create_sub_agent_coder(
733755
except Exception:
734756
logger.warning("Failed to notify TUI to create sub-agent container", exc_info=True)
735757

758+
_subagent_event(
759+
parent_coder,
760+
new_coder.uuid,
761+
"created",
762+
parent_uuid=str(parent_coder.uuid),
763+
name=name,
764+
description=metadata.get("description") if isinstance(metadata, dict) else None,
765+
root=str(getattr(new_coder, "root", "") or ""),
766+
)
767+
736768
# Initialize system prompt from config
737769
system_prompt = getattr(config, "prompt", "")
738770
from cecli.helpers.conversation.service import ConversationService
@@ -807,8 +839,14 @@ def start_generate_task(self, info: SubAgentInfo, user_message: str) -> asyncio.
807839

808840
async def _run_generate():
809841
info.status = SubAgentStatus.RUNNING
842+
info.cancel_requested = False
843+
_subagent_event(info.coder, info.coder.uuid, "running")
844+
stop_reason, error = "end_turn", None
810845
try:
811846
await info.coder.generate(user_message=user_message, preproc=True)
847+
if _was_cancelled(info):
848+
stop_reason = "cancelled"
849+
info.summary = info.summary or DEFAULT_SUMMARY_INTERRUPTED
812850
await self._inject_sub_agent_result(info)
813851
if info.status == SubAgentStatus.RUNNING:
814852
info.status = SubAgentStatus.FINISHED
@@ -831,12 +869,14 @@ async def _cleanup():
831869
fire_and_forget(_cleanup())
832870

833871
except asyncio.CancelledError:
872+
stop_reason = "cancelled"
834873
await self._inject_sub_agent_result(info)
835874
info.status = SubAgentStatus.FINISHED
836875
info.summary = info.summary or DEFAULT_SUMMARY_INTERRUPTED
837876
logger.debug("Sub-agent %s generate cancelled (interrupted)", info.name)
838877
raise
839878
except Exception as exc:
879+
stop_reason, error = "error", str(exc) or exc.__class__.__name__
840880
await self._inject_sub_agent_result(info)
841881
info.status = SubAgentStatus.ERROR
842882
info.error = str(exc)
@@ -849,6 +889,10 @@ async def _cleanup():
849889
raise
850890
except SwitchCoderSignal:
851891
raise
892+
finally:
893+
_subagent_event(
894+
info.coder, info.coder.uuid, "idle", stop_reason=stop_reason, error=error
895+
)
852896

853897
# Cancel any previous generate task to prevent duplicate concurrent generates
854898
if info.generate_task is not None and not info.generate_task.done():
@@ -986,6 +1030,10 @@ async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None:
9861030
force=True,
9871031
)
9881032

1033+
_subagent_event(
1034+
parent_coder, parent_coder.uuid, "message", sender_uuid=coder_uuid, text=user_content
1035+
)
1036+
9891037
async def invoke(
9901038
self,
9911039
name: str,
@@ -1011,8 +1059,20 @@ async def invoke(
10111059
return None
10121060

10131061
# Blocking: run the sub-agent with the prompt using start_generate_task
1062+
sender = parent if parent is not None else self.coder
1063+
_subagent_event(
1064+
sender, new_coder.uuid, "message", sender_uuid=str(sender.uuid), text=prompt
1065+
)
10141066
task = self.start_generate_task(info, prompt)
1015-
await task
1067+
try:
1068+
await asyncio.shield(task)
1069+
except asyncio.CancelledError:
1070+
if not task.done():
1071+
# The caller was cancelled: stop the sub-agent it was waiting for
1072+
info.cancel_requested = True
1073+
task.cancel()
1074+
raise
1075+
# Only the sub-agent was cancelled; its summary says it was interrupted
10161076
return info.summary
10171077

10181078
async def spawn(
@@ -1040,6 +1100,10 @@ async def spawn(
10401100
name, auto_reap=auto_reap, parent=parent, independent=independent
10411101
)
10421102
if prompt:
1103+
sender = parent if parent is not None else self.coder
1104+
_subagent_event(
1105+
sender, new_coder.uuid, "message", sender_uuid=str(sender.uuid), text=prompt
1106+
)
10431107
self.start_generate_task(info, prompt)
10441108
return new_coder, info
10451109

@@ -1079,6 +1143,19 @@ async def wait(self, parent: Any) -> List[str]:
10791143

10801144
return [info.summary for info in children]
10811145

1146+
def cancel_sub_agent(self, agent_uuid: str) -> bool:
1147+
"""Stop a sub-agent's current work, leaving its parent running.
1148+
1149+
A parent waiting on it (a blocking delegation) gets the interrupted
1150+
summary. Returns False if the sub-agent was not working.
1151+
"""
1152+
info = self.sub_agents.get(str(agent_uuid))
1153+
if info is None or info.generate_task is None or info.generate_task.done():
1154+
return False
1155+
info.cancel_requested = True
1156+
info.generate_task.cancel()
1157+
return True
1158+
10821159
def get_active_agents(self) -> List[Dict[str, Any]]:
10831160
"""Return list of active sub-agents for display."""
10841161
return [

‎cecli/helpers/io_proxy.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ def assistant_output(self, message: str = "", pretty: Any = None, **kwargs: Any)
126126
kwargs["coder_uuid"] = self._coder_uuid
127127
return self._target.assistant_output(message=message, pretty=pretty, **kwargs)
128128

129+
def tool_call_event(self, tool_call_id: str, status: str, **kwargs: Any) -> Any:
130+
"""Forward tool_call_event with coder_uuid injected."""
131+
if "coder_uuid" not in kwargs:
132+
kwargs["coder_uuid"] = self._coder_uuid
133+
return self._target.tool_call_event(tool_call_id, status, **kwargs)
134+
129135
def reset_streaming_response(self, **kwargs) -> Any:
130136
"""Forward reset_streaming_response with coder_uuid injected."""
131137
if "coder_uuid" not in kwargs:

‎cecli/helpers/onboarding/providers.py‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import importlib.resources as importlib_resources
44
import json
55
import os
6-
from typing import Dict, List
6+
from typing import Collection, Dict, List, Optional
77

88
# Providers that authenticate through mechanisms other than a simple _API_KEY
99
# (e.g. GitHub Copilot tokens or AWS credentials), so onboarding should never
@@ -61,12 +61,19 @@ def _load_metadata() -> Dict:
6161
return data
6262

6363

64-
def _models_from_metadata(data: Dict, slug: str) -> List[str]:
65-
"""Return selectable model names for a provider from the metadata dict."""
64+
def _models_from_metadata(
65+
data: Dict, slug: str, modes: Optional[Collection[str]] = None
66+
) -> List[str]:
67+
"""Return selectable model names for a provider from the metadata dict.
68+
69+
``modes`` keeps only models with those metadata modes (e.g. ``("chat",)``).
70+
"""
6671
models = []
6772
for name, meta in data.items():
6873
if not isinstance(meta, dict):
6974
continue
75+
if modes and meta.get("mode", "chat") not in modes:
76+
continue
7077
provider = meta.get("litellm_provider") or ""
7178
if provider == slug:
7279
models.append(name if name.startswith(slug + "/") else f"{slug}/{name}")
@@ -128,9 +135,12 @@ def provider_display(entry: Dict) -> str:
128135
return label
129136

130137

131-
def get_models_for_provider(slug: str) -> List[str]:
132-
"""Return the selectable model names for a provider."""
133-
models = _models_from_metadata(_load_metadata(), slug)
138+
def get_models_for_provider(slug: str, modes: Optional[Collection[str]] = None) -> List[str]:
139+
"""Return the selectable model names for a provider.
140+
141+
``modes`` filters the bundled metadata by mode; a live fetch is not filtered.
142+
"""
143+
models = _models_from_metadata(_load_metadata(), slug, modes)
134144
if models:
135145
return sorted(set(models))
136146

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""``cecli --acp``: cecli as an Agent Client Protocol (ACP) v1 agent.
2+
3+
ACP clients such as Zed launch cecli as a subprocess and drive it with
4+
newline-delimited JSON-RPC 2.0 on stdin/stdout:
5+
https://agentclientprotocol.com/protocol/v1/schema
6+
7+
- ``agent``: AcpAgent, which answers the client and runs each prompt as a turn
8+
- ``transport``: the channel to the client (stdio)
9+
- ``io``: AcpInputOutput, which routes cecli's input and output through the agent
10+
- ``updates``: cecli's output as ``session/update`` notifications
11+
- ``config``: the mode, model and effort pickers
12+
- ``mcp``: MCP servers passed by the client
13+
14+
The WebSocket bridge in the parent package is a separate feature, which streams
15+
cecli's events to dashboards.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from cecli.helpers.server.acp.agent.agent import AcpAgent
21+
from cecli.helpers.server.acp.agent.transport import StdioTransport
22+
23+
_agent: AcpAgent | None = None
24+
25+
26+
def get_agent() -> AcpAgent:
27+
global _agent
28+
if _agent is None:
29+
_agent = AcpAgent(StdioTransport())
30+
return _agent

0 commit comments

Comments
 (0)