sycommon-python-lib 0.2.6a4__py3-none-any.whl → 0.2.6a6__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.
sycli/acp/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """ACP (Agent Communication Protocol) support for sycli.
2
+
3
+ Dynamically exposes local CLI tools as ACP agents.
4
+ """
5
+
6
+ # Patch uvicorn config type aliases for compatibility with uvicorn>=0.34
7
+ # acp-sdk uses uvicorn.config.LoopSetupType etc. which were removed in uvicorn 0.48+
8
+ import uvicorn.config as _uvicorn_config
9
+
10
+ _TYPE_ALIASES = (
11
+ "LoopSetupType",
12
+ "HTTPProtocolType",
13
+ "WSProtocolType",
14
+ "LifespanType",
15
+ "InterfaceType",
16
+ )
17
+ for _alias in _TYPE_ALIASES:
18
+ if not hasattr(_uvicorn_config, _alias):
19
+ setattr(_uvicorn_config, _alias, str)
20
+
21
+ if not hasattr(_uvicorn_config, "LOGGING_CONFIG"):
22
+ _uvicorn_config.LOGGING_CONFIG = {
23
+ "version": 1,
24
+ "disable_existing_loggers": False,
25
+ "formatters": {},
26
+ "handlers": {},
27
+ "loggers": {},
28
+ }
29
+
30
+ del _uvicorn_config, _TYPE_ALIASES, _alias
31
+
32
+ from sycli.acp.config import ACPConfig, ToolArgument, ToolDef
33
+ from sycli.acp.executor import build_cli_command, execute_command, stream_command
34
+ from sycli.acp.registry import CLIToolAgent, build_agents
35
+ from sycli.acp.server import create_acp_server, run_acp_server
36
+
37
+ __all__ = [
38
+ "ACPConfig",
39
+ "CLIToolAgent",
40
+ "ToolArgument",
41
+ "ToolDef",
42
+ "build_agents",
43
+ "build_cli_command",
44
+ "create_acp_server",
45
+ "execute_command",
46
+ "run_acp_server",
47
+ "stream_command",
48
+ ]
sycli/acp/config.py ADDED
@@ -0,0 +1,112 @@
1
+ """ACP configuration models.
2
+
3
+ Defines the Pydantic models for declaring which CLI tools to expose
4
+ as ACP agents. Configuration is loaded from a YAML or JSON file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ class ToolArgument(BaseModel):
17
+ """Definition of a named argument that the CLI tool accepts."""
18
+
19
+ name: str = Field(description="Argument name (used for key=value parsing)")
20
+ description: str = Field(default="", description="Human-readable description")
21
+ required: bool = Field(default=False, description="Whether this argument is required")
22
+ default: str | None = Field(default=None, description="Default value if not provided")
23
+ flag: str | None = Field(
24
+ default=None,
25
+ description="CLI flag (e.g. '--path', '-v'); None means positional",
26
+ )
27
+ separator: str = Field(
28
+ default=" ",
29
+ description="Separator between flag and value: ' ' for '--flag value', '=' for '--flag=value'",
30
+ )
31
+
32
+
33
+ class ToolDef(BaseModel):
34
+ """Definition of a single CLI tool to expose as an ACP agent."""
35
+
36
+ name: str = Field(description="ACP agent name")
37
+ command: str = Field(description="The actual CLI command to run")
38
+ description: str = Field(default="", description="Agent description for ACP discovery")
39
+ args: list[str] = Field(default_factory=list, description="Fixed arguments always appended")
40
+ tool_args: list[ToolArgument] = Field(
41
+ default_factory=list, description="Dynamic arguments mapped from ACP message input"
42
+ )
43
+ timeout: int = Field(default=120, description="Execution timeout in seconds")
44
+ working_dir: str | None = Field(default=None, description="Override working directory")
45
+ env: dict[str, str] = Field(default_factory=dict, description="Extra environment variables")
46
+ stream_output: bool = Field(default=True, description="Stream stdout line-by-line")
47
+ input_mode: str = Field(
48
+ default="args",
49
+ description="How to pass input: 'args' (parse into CLI args) | 'stdin' (pipe to stdin)",
50
+ )
51
+ output_content_type: str = Field(
52
+ default="text/plain", description="ACP content type for output"
53
+ )
54
+ allowed_exit_codes: list[int] = Field(
55
+ default_factory=lambda: [0], description="Exit codes considered success"
56
+ )
57
+
58
+
59
+ class ACPConfig(BaseModel):
60
+ """Root ACP server configuration."""
61
+
62
+ host: str = Field(default="127.0.0.1", description="Server bind host")
63
+ port: int = Field(default=8100, description="Server bind port")
64
+ tools: list[ToolDef] = Field(default_factory=list, description="Registered CLI tools")
65
+ global_timeout: int = Field(default=300, description="Global maximum execution timeout")
66
+ max_output_bytes: int = Field(
67
+ default=100_000, description="Maximum output size in bytes (matches sandbox.py)"
68
+ )
69
+
70
+ @classmethod
71
+ def load(cls, path: Path) -> ACPConfig:
72
+ """Load ACP configuration from a YAML or JSON file.
73
+
74
+ Args:
75
+ path: Path to the configuration file.
76
+
77
+ Returns:
78
+ Parsed ACPConfig instance.
79
+
80
+ Raises:
81
+ FileNotFoundError: If the config file doesn't exist.
82
+ ValueError: If the file cannot be parsed.
83
+ """
84
+ if not path.exists():
85
+ raise FileNotFoundError(f"ACP config file not found: {path}")
86
+
87
+ text = path.read_text(encoding="utf-8")
88
+
89
+ if path.suffix in (".yaml", ".yml"):
90
+ try:
91
+ data = yaml.safe_load(text)
92
+ except yaml.YAMLError as e:
93
+ raise ValueError(f"Failed to parse YAML config: {e}") from e
94
+ elif path.suffix == ".json":
95
+ try:
96
+ data = json.loads(text)
97
+ except json.JSONDecodeError as e:
98
+ raise ValueError(f"Failed to parse JSON config: {e}") from e
99
+ else:
100
+ # Try YAML first, then JSON
101
+ try:
102
+ data = yaml.safe_load(text)
103
+ except yaml.YAMLError:
104
+ try:
105
+ data = json.loads(text)
106
+ except json.JSONDecodeError as e:
107
+ raise ValueError(f"Failed to parse config file: {e}") from e
108
+
109
+ if not isinstance(data, dict):
110
+ raise ValueError("Config file must contain a mapping (dict)")
111
+
112
+ return cls.model_validate(data)
sycli/acp/executor.py ADDED
@@ -0,0 +1,332 @@
1
+ """Async subprocess executor for ACP tool agents.
2
+
3
+ Provides command execution with process isolation, timeout handling,
4
+ output truncation, and line-by-line streaming — reusing patterns from
5
+ sycommon's sandbox service.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import signal
13
+ import sys
14
+ from collections.abc import AsyncGenerator
15
+ from typing import TYPE_CHECKING
16
+
17
+ from pydantic import BaseModel
18
+
19
+ if TYPE_CHECKING:
20
+ from sycli.acp.config import ToolDef
21
+
22
+ # Process group isolation on Unix
23
+ _SUBPROCESS_NEW_SESSION = sys.platform != "win32"
24
+
25
+ # Maximum output size per stream (matches sandbox.py)
26
+ MAX_OUTPUT_BYTES = 100_000
27
+
28
+
29
+ class ExecutionResult(BaseModel):
30
+ """Result of a CLI command execution."""
31
+
32
+ stdout: str = ""
33
+ stderr: str = ""
34
+ exit_code: int = 0
35
+ truncated: bool = False
36
+ timed_out: bool = False
37
+
38
+
39
+ def _kill_process_tree(process: asyncio.subprocess.Process) -> None:
40
+ """Kill a process and its entire process group.
41
+
42
+ Mirrors sandbox.py's _kill_process_tree pattern.
43
+ """
44
+ try:
45
+ if sys.platform == "win32":
46
+ process.kill()
47
+ else:
48
+ pgid = os.getpgid(process.pid)
49
+ os.killpg(pgid, signal.SIGKILL)
50
+ except (ProcessLookupError, PermissionError, OSError):
51
+ try:
52
+ process.kill()
53
+ except ProcessLookupError:
54
+ pass
55
+
56
+
57
+ def _truncate_output(output: str, max_bytes: int = MAX_OUTPUT_BYTES) -> tuple[str, bool]:
58
+ """Truncate output to max_bytes, returning (truncated_output, was_truncated)."""
59
+ encoded = output.encode("utf-8", errors="replace")
60
+ if len(encoded) <= max_bytes:
61
+ return output, False
62
+ truncated = encoded[:max_bytes].decode("utf-8", errors="replace")
63
+ return truncated + f"\n... (truncated, {len(encoded)} bytes total)", True
64
+
65
+
66
+ async def execute_command(
67
+ command: str,
68
+ *,
69
+ cwd: str | None = None,
70
+ timeout: int = 120,
71
+ env: dict[str, str] | None = None,
72
+ max_output_bytes: int = MAX_OUTPUT_BYTES,
73
+ stdin_input: str | None = None,
74
+ ) -> ExecutionResult:
75
+ """Execute a CLI command asynchronously.
76
+
77
+ Mirrors sandbox.py execute endpoint logic:
78
+ - asyncio.create_subprocess_shell with process group isolation
79
+ - Timeout with process tree kill
80
+ - Output truncation at max_output_bytes
81
+
82
+ Args:
83
+ command: Shell command string to execute.
84
+ cwd: Working directory for the command.
85
+ timeout: Maximum execution time in seconds.
86
+ env: Additional environment variables (merged with os.environ).
87
+ max_output_bytes: Maximum output size before truncation.
88
+ stdin_input: Optional text to pipe to stdin.
89
+
90
+ Returns:
91
+ ExecutionResult with stdout, stderr, exit_code, and status flags.
92
+ """
93
+ full_env = None
94
+ if env:
95
+ full_env = {**os.environ, **env}
96
+
97
+ stdin_data = stdin_input.encode("utf-8") if stdin_input else None
98
+
99
+ process = await asyncio.create_subprocess_shell(
100
+ command,
101
+ stdout=asyncio.subprocess.PIPE,
102
+ stderr=asyncio.subprocess.PIPE,
103
+ stdin=asyncio.subprocess.PIPE if stdin_data else asyncio.subprocess.DEVNULL,
104
+ start_new_session=_SUBPROCESS_NEW_SESSION,
105
+ cwd=cwd,
106
+ env=full_env,
107
+ )
108
+
109
+ timed_out = False
110
+ try:
111
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
112
+ process.communicate(input=stdin_data),
113
+ timeout=timeout,
114
+ )
115
+ except asyncio.TimeoutError:
116
+ _kill_process_tree(process)
117
+ try:
118
+ await process.wait()
119
+ except Exception:
120
+ pass
121
+ timed_out = True
122
+ stdout_bytes = b""
123
+ stderr_bytes = b""
124
+
125
+ stdout_raw = stdout_bytes.decode("utf-8", errors="replace") if stdout_bytes else ""
126
+ stderr_raw = stderr_bytes.decode("utf-8", errors="replace") if stderr_bytes else ""
127
+
128
+ stdout, stdout_truncated = _truncate_output(stdout_raw, max_output_bytes)
129
+ stderr, stderr_truncated = _truncate_output(stderr_raw, max_output_bytes)
130
+
131
+ return ExecutionResult(
132
+ stdout=stdout,
133
+ stderr=stderr,
134
+ exit_code=process.returncode if process.returncode is not None else -1,
135
+ truncated=stdout_truncated or stderr_truncated,
136
+ timed_out=timed_out,
137
+ )
138
+
139
+
140
+ async def stream_command(
141
+ command: str,
142
+ *,
143
+ cwd: str | None = None,
144
+ timeout: int = 120,
145
+ env: dict[str, str] | None = None,
146
+ max_output_bytes: int = MAX_OUTPUT_BYTES,
147
+ ) -> AsyncGenerator[str, None]:
148
+ """Execute a CLI command and yield stdout lines as they arrive.
149
+
150
+ For ACP stream mode: each yielded line becomes a MessagePart.
151
+ Uses asyncio.create_subprocess_shell with stdout=PIPE and
152
+ iterates process.stdout line by line.
153
+
154
+ Args:
155
+ command: Shell command string to execute.
156
+ cwd: Working directory for the command.
157
+ timeout: Maximum execution time in seconds.
158
+ env: Additional environment variables.
159
+ max_output_bytes: Maximum total output before stopping.
160
+
161
+ Yields:
162
+ Each line from stdout as a string.
163
+ """
164
+ full_env = None
165
+ if env:
166
+ full_env = {**os.environ, **env}
167
+
168
+ process = await asyncio.create_subprocess_shell(
169
+ command,
170
+ stdout=asyncio.subprocess.PIPE,
171
+ stderr=asyncio.subprocess.PIPE,
172
+ start_new_session=_SUBPROCESS_NEW_SESSION,
173
+ cwd=cwd,
174
+ env=full_env,
175
+ )
176
+
177
+ total_bytes = 0
178
+ try:
179
+ try:
180
+ async with asyncio.timeout(timeout):
181
+ if process.stdout is not None:
182
+ async for raw_line in process.stdout:
183
+ line = raw_line.decode("utf-8", errors="replace")
184
+ total_bytes += len(raw_line)
185
+ if total_bytes > max_output_bytes:
186
+ yield "... (output truncated)"
187
+ break
188
+ yield line
189
+ except asyncio.TimeoutError:
190
+ _kill_process_tree(process)
191
+ yield f"\n... (timed out after {timeout}s)"
192
+
193
+ # Wait for process to finish
194
+ try:
195
+ await asyncio.wait_for(process.wait(), timeout=5)
196
+ except asyncio.TimeoutError:
197
+ _kill_process_tree(process)
198
+ finally:
199
+ try:
200
+ if process.returncode is None:
201
+ _kill_process_tree(process)
202
+ try:
203
+ await process.wait()
204
+ except Exception:
205
+ pass
206
+ except Exception:
207
+ pass
208
+
209
+
210
+ def build_cli_command(tool_def: ToolDef, input_text: str) -> str:
211
+ """Build the full CLI command string from a ToolDef and user input.
212
+
213
+ Parses the input message text to extract tool_args values.
214
+
215
+ For input_mode="args":
216
+ Parses key=value pairs or positional args from message text.
217
+ Example: "target=tests/test_api.py verbose=true"
218
+ -> "pytest tests/test_api.py -v"
219
+
220
+ For input_mode="stdin":
221
+ Returns the command as-is (input will be piped to stdin).
222
+
223
+ Args:
224
+ tool_def: Tool configuration.
225
+ input_text: Raw text extracted from ACP input messages.
226
+
227
+ Returns:
228
+ Complete shell command string ready for execution.
229
+ """
230
+ parts = [tool_def.command]
231
+
232
+ # Add fixed arguments
233
+ if tool_def.args:
234
+ parts.extend(tool_def.args)
235
+
236
+ if tool_def.input_mode == "stdin":
237
+ # Input will be piped separately; no arg mapping needed
238
+ return " ".join(parts)
239
+
240
+ # Parse input text into key-value pairs
241
+ parsed_args = _parse_input_text(input_text)
242
+
243
+ # Map tool_args to CLI flags/positionals
244
+ for arg_def in tool_def.tool_args:
245
+ value = parsed_args.get(arg_def.name)
246
+
247
+ if value is None:
248
+ if arg_def.required and arg_def.default is None:
249
+ # Required arg missing — skip gracefully, let the CLI tool report the error
250
+ continue
251
+ if arg_def.default is not None:
252
+ value = arg_def.default
253
+ else:
254
+ continue
255
+
256
+ if arg_def.flag is not None:
257
+ if _is_truthy(value):
258
+ # Boolean flag present — only emit the flag itself (e.g. -v, --verbose)
259
+ parts.append(arg_def.flag)
260
+ else:
261
+ # Value flag (e.g. --path /some/dir, -e PATTERN)
262
+ parts.append(f"{arg_def.flag}{arg_def.separator}{value}")
263
+ else:
264
+ # Positional argument
265
+ parts.append(value)
266
+
267
+ # Any remaining unparsed input text becomes trailing positional args
268
+ remaining = _get_remaining_input(parsed_args, tool_def.tool_args)
269
+ if remaining:
270
+ parts.append(remaining)
271
+
272
+ return " ".join(parts)
273
+
274
+
275
+ def _parse_input_text(text: str) -> dict[str, str]:
276
+ """Parse input text into key=value pairs.
277
+
278
+ Supports:
279
+ - "key=value" pairs (space or newline separated)
280
+ - Bare values are stored under the key "__positional__"
281
+
282
+ Args:
283
+ text: Raw input text from ACP message.
284
+
285
+ Returns:
286
+ Dict mapping argument names to values.
287
+ """
288
+ result: dict[str, str] = {}
289
+ positional: list[str] = []
290
+
291
+ # Split on whitespace (spaces, newlines, tabs)
292
+ tokens = text.strip().split()
293
+
294
+ for token in tokens:
295
+ if "=" in token:
296
+ key, _, val = token.partition("=")
297
+ key = key.strip()
298
+ val = val.strip()
299
+ if key:
300
+ result[key] = val
301
+ else:
302
+ positional.append(token)
303
+
304
+ if positional:
305
+ result["__positional__"] = " ".join(positional)
306
+
307
+ return result
308
+
309
+
310
+ def _is_truthy(value: str) -> bool:
311
+ """Check if a string value represents a boolean 'true' (flag present, no value needed)."""
312
+ return value.lower() in ("true", "yes", "1", "")
313
+
314
+
315
+ def _get_remaining_input(
316
+ parsed: dict[str, str], tool_args: list
317
+ ) -> str:
318
+ """Get remaining input that wasn't matched to any tool_arg definition."""
319
+ known_keys = {arg.name for arg in tool_args}
320
+ known_keys.add("__positional__")
321
+
322
+ remaining = []
323
+ for key, value in parsed.items():
324
+ if key not in known_keys:
325
+ remaining.append(f"{key}={value}" if "=" in f"{key}={value}" else value)
326
+
327
+ # Also include positional text
328
+ positional = parsed.get("__positional__")
329
+ if positional:
330
+ remaining.insert(0, positional)
331
+
332
+ return " ".join(remaining)
sycli/acp/registry.py ADDED
@@ -0,0 +1,155 @@
1
+ """Dynamic ACP agent registry.
2
+
3
+ Creates CLIToolAgent (AgentManifest subclass) instances at runtime,
4
+ one per ToolDef in the configuration. Each agent wraps a CLI tool
5
+ and executes it via the executor module when a run is requested.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import AsyncGenerator
12
+ from typing import TYPE_CHECKING
13
+
14
+ from acp_sdk.models import Message, MessagePart, Metadata
15
+ from acp_sdk.server.agent import AgentManifest
16
+ from acp_sdk.server.context import Context
17
+ from acp_sdk.server.types import RunYield, RunYieldResume
18
+
19
+ from sycli.acp.executor import build_cli_command, execute_command, stream_command
20
+
21
+ if TYPE_CHECKING:
22
+ from sycli.acp.config import ACPConfig, ToolDef
23
+
24
+
25
+ class CLIToolAgent(AgentManifest):
26
+ """An AgentManifest that wraps a CLI tool.
27
+
28
+ Each instance is configured with a ToolDef. The run() method
29
+ is an async generator, enabling both streaming and batch modes
30
+ via the ACP SDK executor.
31
+ """
32
+
33
+ def __init__(self, tool_def: ToolDef, project_root: str) -> None:
34
+ self._tool_def = tool_def
35
+ self._project_root = project_root
36
+
37
+ @property
38
+ def name(self) -> str:
39
+ return self._tool_def.name
40
+
41
+ @property
42
+ def description(self) -> str:
43
+ return self._tool_def.description or f"CLI tool: {self._tool_def.command}"
44
+
45
+ @property
46
+ def input_content_types(self) -> list[str]:
47
+ return ["text/plain"]
48
+
49
+ @property
50
+ def output_content_types(self) -> list[str]:
51
+ return [self._tool_def.output_content_type]
52
+
53
+ @property
54
+ def metadata(self) -> Metadata:
55
+ return Metadata(
56
+ tags=["cli-tool", self._tool_def.command],
57
+ )
58
+
59
+ async def run(
60
+ self, input: list[Message], context: Context
61
+ ) -> AsyncGenerator[RunYield, RunYieldResume]:
62
+ """Execute the CLI tool and yield results.
63
+
64
+ Async generator enabling:
65
+ - Stream mode: yields MessagePart per stdout line
66
+ - Sync/async mode: yields complete Message at the end
67
+
68
+ Flow:
69
+ 1. Extract text from input Messages (concatenate text/plain parts)
70
+ 2. Build CLI command via build_cli_command()
71
+ 3. If stream_output=True: stream_command(), yield MessagePart per line
72
+ 4. Otherwise: execute_command(), yield single Message
73
+ """
74
+ # Step 1: Extract input text
75
+ input_text = _extract_input_text(input)
76
+
77
+ # Step 2: Build command
78
+ command = build_cli_command(self._tool_def, input_text)
79
+
80
+ # Step 3: Determine working directory
81
+ cwd = self._tool_def.working_dir or self._project_root
82
+
83
+ # Step 4: Build environment
84
+ env = None
85
+ if self._tool_def.env:
86
+ env = {**os.environ, **self._tool_def.env}
87
+
88
+ if self._tool_def.stream_output:
89
+ # Streaming: yield MessagePart per stdout line
90
+ async for line in stream_command(
91
+ command,
92
+ cwd=cwd,
93
+ timeout=self._tool_def.timeout,
94
+ env=env,
95
+ ):
96
+ yield MessagePart(
97
+ content=line,
98
+ content_type="text/plain",
99
+ )
100
+ else:
101
+ # Batch: execute and yield complete output
102
+ result = await execute_command(
103
+ command,
104
+ cwd=cwd,
105
+ timeout=self._tool_def.timeout,
106
+ env=env,
107
+ max_output_bytes=100_000,
108
+ stdin_input=input_text if self._tool_def.input_mode == "stdin" else None,
109
+ )
110
+
111
+ if result.timed_out:
112
+ output = f"Error: Command timed out after {self._tool_def.timeout}s\n{result.stdout}"
113
+ elif result.exit_code not in self._tool_def.allowed_exit_codes:
114
+ parts = []
115
+ if result.stdout:
116
+ parts.append(result.stdout)
117
+ if result.stderr:
118
+ parts.append(f"[stderr]\n{result.stderr}")
119
+ parts.append(f"Exit code: {result.exit_code}")
120
+ output = "\n".join(parts)
121
+ else:
122
+ output = result.stdout or "(no output)"
123
+
124
+ yield Message(
125
+ role="agent",
126
+ parts=[MessagePart(content=output, content_type="text/plain")],
127
+ )
128
+
129
+
130
+ def _extract_input_text(messages: list[Message]) -> str:
131
+ """Extract and concatenate all text/plain content from input messages."""
132
+ parts: list[str] = []
133
+ for msg in messages:
134
+ for part in msg.parts:
135
+ content_type = getattr(part, "content_type", None) or "text/plain"
136
+ content = getattr(part, "content", None)
137
+ if content_type in ("text/plain", None) and content:
138
+ parts.append(content)
139
+ return "\n".join(parts)
140
+
141
+
142
+ def build_agents(config: ACPConfig, project_root: str) -> list[AgentManifest]:
143
+ """Create AgentManifest instances for all tools in config.
144
+
145
+ Args:
146
+ config: ACP configuration with tool definitions.
147
+ project_root: Project root directory for command execution.
148
+
149
+ Returns:
150
+ List of CLIToolAgent instances ready for Server.register().
151
+ """
152
+ return [
153
+ CLIToolAgent(tool_def=tool_def, project_root=project_root)
154
+ for tool_def in config.tools
155
+ ]
sycli/acp/server.py ADDED
@@ -0,0 +1,69 @@
1
+ """ACP server bootstrap for sycli.
2
+
3
+ Creates and starts an ACP server with all CLI tool agents registered.
4
+ Uses the acp_sdk FastAPI app directly with uvicorn to avoid version
5
+ compatibility issues between acp-sdk and uvicorn's positional parameters.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from acp_sdk.server.app import create_app
13
+ import uvicorn
14
+
15
+ from sycli.acp.registry import build_agents
16
+
17
+ if TYPE_CHECKING:
18
+ from acp_sdk.server import Server
19
+ from sycli.acp.config import ACPConfig
20
+
21
+
22
+ def create_acp_app(config: ACPConfig, project_root: str):
23
+ """Create a FastAPI app with all registered tool agents.
24
+
25
+ Args:
26
+ config: ACP configuration (loaded from .sycli/acp.yaml).
27
+ project_root: Project root directory for command execution.
28
+
29
+ Returns:
30
+ FastAPI application instance with ACP routes.
31
+ """
32
+ agents = build_agents(config, project_root)
33
+ app = create_app(*agents)
34
+ return app
35
+
36
+
37
+ def create_acp_server(config: ACPConfig, project_root: str) -> Server:
38
+ """Create and configure an ACP Server with all registered tool agents.
39
+
40
+ Args:
41
+ config: ACP configuration (loaded from .sycli/acp.yaml).
42
+ project_root: Project root directory for command execution.
43
+
44
+ Returns:
45
+ Configured Server instance (not yet started).
46
+ """
47
+ from acp_sdk.server import Server
48
+
49
+ server = Server()
50
+ agents = build_agents(config, project_root)
51
+ server.register(*agents)
52
+ return server
53
+
54
+
55
+ def run_acp_server(config: ACPConfig, project_root: str) -> None:
56
+ """Create and start the ACP server (blocking until shutdown).
57
+
58
+ This is the main entry point called from acp_cmd.py.
59
+ Uses uvicorn directly to avoid acp-sdk / uvicorn version mismatches.
60
+ """
61
+ app = create_acp_app(config, project_root)
62
+
63
+ uvicorn.run(
64
+ app,
65
+ host=config.host,
66
+ port=config.port,
67
+ log_level="info",
68
+ headers=[("server", "acp")],
69
+ )
sycli/cli.py CHANGED
@@ -168,6 +168,13 @@ def _create_parser() -> argparse.ArgumentParser:
168
168
  # interactive
169
169
  cdp_sub.add_parser("interactive", help="Start interactive CDP REPL")
170
170
 
171
+ # ── acp ──
172
+ p_acp = sub.add_parser("acp", help="Start ACP server exposing CLI tools as agents")
173
+ p_acp.add_argument("--config", "-c", help="Path to ACP config file (default: .sycli/acp.yaml)")
174
+ p_acp.add_argument("--host", help="Override server host")
175
+ p_acp.add_argument("--port", "-p", type=int, help="Override server port")
176
+ p_acp.add_argument("--project-root", "-r", default=".", help="Project root directory")
177
+
171
178
  # ── version ──
172
179
  sub.add_parser("version", help="Show version")
173
180
 
@@ -234,6 +241,10 @@ def main() -> None:
234
241
  from sycli.commands.cdp_cmd import handle_cdp
235
242
  sys.exit(handle_cdp(args))
236
243
 
244
+ elif args.command == "acp":
245
+ from sycli.commands.acp_cmd import handle_acp
246
+ sys.exit(handle_acp(args))
247
+
237
248
 
238
249
  def _handle_status(args) -> None:
239
250
  """Show current RL state."""
@@ -0,0 +1,116 @@
1
+ """sycli acp command — start ACP server exposing CLI tools as agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import signal
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from sycli.core.display import error, header, info, success, warning
10
+
11
+
12
+ def handle_acp(args) -> int:
13
+ """Handle `sycli acp` command.
14
+
15
+ Flow:
16
+ 1. Resolve project root
17
+ 2. Load .sycli/acp.yaml (or --config path)
18
+ 3. Validate tool definitions
19
+ 4. Print registered agents summary
20
+ 5. Start ACP server (blocking)
21
+
22
+ Args:
23
+ args: Parsed argparse namespace with config, host, port, project_root.
24
+
25
+ Returns:
26
+ Exit code (0 for success, 1 for error).
27
+ """
28
+ project_root = Path(args.project_root or ".").resolve()
29
+ sycli_dir = project_root / ".sycli"
30
+
31
+ if not sycli_dir.exists():
32
+ error(".sycli/ not found. Run `sycli init` first.")
33
+ return 1
34
+
35
+ # Determine config path
36
+ config_path = Path(args.config) if args.config else sycli_dir / "acp.yaml"
37
+ if not config_path.exists():
38
+ config_path = sycli_dir / "acp.json"
39
+
40
+ if not config_path.exists():
41
+ error(f"ACP config not found at {sycli_dir / 'acp.yaml'}")
42
+ error("Create one with your tool definitions. Example:")
43
+ error("""
44
+ # .sycli/acp.yaml
45
+ host: "127.0.0.1"
46
+ port: 8100
47
+ tools:
48
+ - name: "git-status"
49
+ command: "git"
50
+ args: ["status", "--porcelain"]
51
+ description: "Show git working tree status"
52
+ timeout: 30
53
+ """)
54
+ return 1
55
+
56
+ # Load config
57
+ from sycli.acp.config import ACPConfig
58
+
59
+ try:
60
+ config = ACPConfig.load(config_path)
61
+ except Exception as e:
62
+ error(f"Failed to load ACP config: {e}")
63
+ return 1
64
+
65
+ # Override port/host from CLI args
66
+ if args.host:
67
+ config.host = args.host
68
+ if args.port:
69
+ config.port = args.port
70
+
71
+ if not config.tools:
72
+ error("No tools defined in ACP config. Add tools to acp.yaml.")
73
+ return 1
74
+
75
+ # Print summary
76
+ header("ACP Server Configuration")
77
+ info(f"Config: {config_path}")
78
+ info(f"Host: {config.host}")
79
+ info(f"Port: {config.port}")
80
+ info(f"Agents: {len(config.tools)} registered")
81
+ print()
82
+ for tool in config.tools:
83
+ desc = tool.description or "(no description)"
84
+ info(f" {tool.name}: {tool.command} {' '.join(tool.args) if tool.args else ''}")
85
+ info(f" {desc}")
86
+ print()
87
+
88
+ # Start server
89
+ from sycli.acp.server import run_acp_server
90
+
91
+ # Graceful shutdown on SIGINT
92
+ _shutdown = False
93
+
94
+ def _signal_handler(sig, frame):
95
+ nonlocal _shutdown
96
+ if _shutdown:
97
+ sys.exit(1)
98
+ _shutdown = True
99
+ warning("\nShutting down ACP server...")
100
+
101
+ signal.signal(signal.SIGINT, _signal_handler)
102
+
103
+ try:
104
+ success(f"Starting ACP server on http://{config.host}:{config.port}")
105
+ run_acp_server(config, str(project_root))
106
+ except KeyboardInterrupt:
107
+ warning("ACP server stopped.")
108
+ return 0
109
+ except Exception as e:
110
+ error(f"ACP server failed: {e}")
111
+ import traceback
112
+
113
+ traceback.print_exc()
114
+ return 1
115
+
116
+ return 0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.6a4
3
+ Version: 0.2.6a6
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: aio-pika==9.6.2
8
- Requires-Dist: aiohttp==3.13.5
8
+ Requires-Dist: aiohttp==3.14.1
9
9
  Requires-Dist: aiomysql==0.3.2
10
10
  Requires-Dist: anyio==4.13.0
11
11
  Requires-Dist: decorator==5.3.1
@@ -13,10 +13,10 @@ Requires-Dist: deepagents==0.6.8
13
13
  Requires-Dist: elasticsearch==9.4.1
14
14
  Requires-Dist: fastapi==0.136.3
15
15
  Requires-Dist: jinja2==3.1.6
16
- Requires-Dist: kafka-python==2.3.1
17
- Requires-Dist: langchain==1.3.4
18
- Requires-Dist: langchain-core==1.4.0
19
- Requires-Dist: langchain-openai==1.2.2
16
+ Requires-Dist: kafka-python==2.3.2
17
+ Requires-Dist: langchain==1.3.7
18
+ Requires-Dist: langchain-core==1.4.4
19
+ Requires-Dist: langchain-openai==1.3.0
20
20
  Requires-Dist: langfuse==4.7.1
21
21
  Requires-Dist: langgraph==1.2.4
22
22
  Requires-Dist: langgraph-checkpoint-postgres==3.1.0
@@ -29,20 +29,21 @@ Requires-Dist: psutil==7.2.2
29
29
  Requires-Dist: pyxxl==0.4.7
30
30
  Requires-Dist: pydantic==2.13.4
31
31
  Requires-Dist: python-dotenv==1.2.2
32
- Requires-Dist: python-multipart==0.0.30
32
+ Requires-Dist: python-multipart==0.0.32
33
33
  Requires-Dist: pyyaml==6.0.3
34
34
  Requires-Dist: redis==7.4.0
35
- Requires-Dist: sentry-sdk[fastapi]==2.61.1
35
+ Requires-Dist: sentry-sdk[fastapi]==2.62.0
36
36
  Requires-Dist: sqlalchemy[asyncio]==2.0.50
37
37
  Requires-Dist: starlette[full]==1.2.1
38
38
  Requires-Dist: tiktoken==0.13.0
39
- Requires-Dist: uvicorn==0.48.0
39
+ Requires-Dist: uvicorn==0.49.0
40
40
  Requires-Dist: wecom-aibot-python-sdk==1.0.2
41
41
  Requires-Dist: twine==6.2.0
42
42
  Requires-Dist: minio==7.2.20
43
- Requires-Dist: langchain-mcp-adapters==0.2.2
43
+ Requires-Dist: langchain-mcp-adapters==0.3.0
44
44
  Requires-Dist: fastapi-mcp==0.4.0
45
45
  Requires-Dist: psycopg[binary,pool]==3.3.4
46
+ Requires-Dist: acp-sdk>=1.0.3
46
47
 
47
48
  # sycommon-python-lib
48
49
 
@@ -61,7 +61,12 @@ command/templates/web/tools/mq.py.tpl,sha256=FQpV_dXpDzC-b7kWtSLylhqBCLgUN6GyRhr
61
61
  nexus/__init__.py,sha256=MDZdgwMEFUSPvQ2ovibj7GE-4X4JX6ihNAB6GwVPa9g,1225
62
62
  sycli/__init__.py,sha256=mKtD5rrPbg4cF_FrYku5KJrcovLjVdJHvOPW8dit3zY,81
63
63
  sycli/__main__.py,sha256=sYL4dyeRvAN2ef71s2_K8plLCzlaVcuYo7_y6VGz9Tg,115
64
- sycli/cli.py,sha256=Uk0y6I8IK1PlPkwW6rqC93yOkgaCZnj7f39C-sv18d0,21642
64
+ sycli/cli.py,sha256=nlThDw1yqtnXoI_jJgF4Zc1ipArTTQefrTE3IvZNotI,22208
65
+ sycli/acp/__init__.py,sha256=wDhvqqIUNvCor3K47ymrdIltVdn-oTJ9AaOBgtNQEgY,1331
66
+ sycli/acp/config.py,sha256=1BLgj2Dao7233ZfSYG9NhIVkJGFL46bm2LkBsTQ5XUk,4360
67
+ sycli/acp/executor.py,sha256=D2XOoaSfksdit72AiSWiyekaBOIJLsX1k85aSIaagSI,10247
68
+ sycli/acp/registry.py,sha256=qaPN9RGdu5YWI3axgPV-vSGVlRGxSqCMrYBPJWnHFNo,5281
69
+ sycli/acp/server.py,sha256=guMLa6yzP3eMyzaHOpciwoSw9VYJszrv_D2oS2N4rmo,1955
65
70
  sycli/agents/__init__.py,sha256=th-UR3Upcb0xHhjmt_x1b26q80wZGxt_HJMjxyuTV7g,31
66
71
  sycli/agents/factory.py,sha256=huSwOSw1hHsl_XJs1YVMo47EcW5SqAK3hivI_aV1glE,8999
67
72
  sycli/agents/prompts.py,sha256=YVVm9kEsoSSOWxvoEDa3DiKPDdkH6KtC18R5euqylW8,16466
@@ -78,6 +83,7 @@ sycli/cdp/capabilities/page_errors.py,sha256=bZlyJHC09hMuYN04X_Z60Nh4Nq2V8v65xxg
78
83
  sycli/cdp/capabilities/performance.py,sha256=z_OQNnd8n2luhOURY-RpkuLG3JpjpAqkK3yTjZxDS5g,1971
79
84
  sycli/cdp/capabilities/screenshot.py,sha256=qCi2niTs0vNMJtu2bFuwAPH7FRQpJPoQTgWNKHQC2VU,1736
80
85
  sycli/commands/__init__.py,sha256=ikkj1Ig33tgxEK9Sm9IQp3DwyCe2WzgawSB5vtER9SU,30
86
+ sycli/commands/acp_cmd.py,sha256=XOzczk8IIxHIXNDmN29SALM_Dt7vF1lsSQgkdMCxr94,3138
81
87
  sycli/commands/cdp_cmd.py,sha256=7Jziye60Vwqz0jD1sUU9z0Iry7NsMJ8m5MTRsaE4UV0,8790
82
88
  sycli/commands/chat_cmd.py,sha256=kdT2WIAIF2zXBiA53iHKMjAH1jK4e4LwQstfflBiSaI,8812
83
89
  sycli/commands/create_cmd.py,sha256=VU9ZJs6NPcgi_TKe2YjOi-zaplnVdckwNu8JF8wRw6M,3698
@@ -284,8 +290,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
284
290
  sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
285
291
  sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
286
292
  sycommon/xxljob/xxljob_service.py,sha256=JIEJaGXhqrTLcyxlyynSrsHg9bBnDNzX-D4qIWLRPUE,6815
287
- sycommon_python_lib-0.2.6a4.dist-info/METADATA,sha256=og-e-U_j2lvFePetXDCzD_KIp4M0yYWh5WoieUyPXbY,7913
288
- sycommon_python_lib-0.2.6a4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
289
- sycommon_python_lib-0.2.6a4.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
290
- sycommon_python_lib-0.2.6a4.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
291
- sycommon_python_lib-0.2.6a4.dist-info/RECORD,,
293
+ sycommon_python_lib-0.2.6a6.dist-info/METADATA,sha256=KeXzYptn5kE7K0wLGFB9BCCPErPbMzcIu-U0Oaj5bkM,7943
294
+ sycommon_python_lib-0.2.6a6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
295
+ sycommon_python_lib-0.2.6a6.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
296
+ sycommon_python_lib-0.2.6a6.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
297
+ sycommon_python_lib-0.2.6a6.dist-info/RECORD,,