sycommon-python-lib 0.2.6a3__py3-none-any.whl → 0.2.6a5__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
@@ -982,7 +982,7 @@ class DeepAgent:
982
982
  不在此处重试整个 graph。
983
983
  """
984
984
  import httpx
985
- from openai import APIConnectionError, APIError, APITimeoutError
985
+ from openai import APIConnectionError, APIError, APITimeoutError, BadRequestError
986
986
 
987
987
  last_error = None
988
988
  sandbox_retries = 0
@@ -1010,6 +1010,18 @@ class DeepAgent:
1010
1010
  print(
1011
1011
  f"[DeepAgent] _astream_with_retry: stream ended normally, total_chunks={chunk_count}", flush=True)
1012
1012
  return
1013
+ except BadRequestError as e:
1014
+ # BadRequestError(含 ContextOverflowError)不重试:
1015
+ # 上下文溢出由 SummarizationMiddleware 在模型调用层处理,
1016
+ # 重试只会浪费时间且无法恢复。
1017
+ from langchain_core.exceptions import ContextOverflowError as _COE
1018
+ if isinstance(e, _COE):
1019
+ SYLogger.warning(
1020
+ f"[DeepAgent] ContextOverflowError: {e}, 不重试,交由 SummarizationMiddleware 处理")
1021
+ else:
1022
+ SYLogger.warning(
1023
+ f"[DeepAgent] BadRequestError: {e}, 不重试")
1024
+ raise
1013
1025
  except (APIConnectionError, APIError, APITimeoutError, ConnectionError, httpx.RemoteProtocolError) as e:
1014
1026
  last_error = e
1015
1027
  print(
@@ -25,6 +25,68 @@ if TYPE_CHECKING:
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
+
29
+ # ==================== ContextOverflowError 识别补丁 ====================
30
+ # MiniMax2.5 等兼容 OpenAI API 的模型返回上下文溢出错误时,
31
+ # 错误格式为 "This model's maximum context length is N tokens...",
32
+ # 而 langchain_openai 的 _handle_openai_bad_request 只识别 OpenAI 原生格式:
33
+ # - "context_length_exceeded"
34
+ # - "Input tokens exceed the configured limit"
35
+ # - "prompt is too long"
36
+ # 导致 MiniMax 的 BadRequestError 不被转换为 ContextOverflowError,
37
+ # SummarizationMiddleware 无法捕获并触发压缩。
38
+ # 此补丁扩展错误模式匹配,使 MiniMax/vLLM 等兼容 API 的溢出错误也被正确识别。
39
+ try:
40
+ import langchain_openai.chat_models.base as _lc_oai_base
41
+ import openai
42
+ from langchain_core.exceptions import ContextOverflowError
43
+
44
+ _orig_handle_bad_request = _lc_oai_base._handle_openai_bad_request
45
+
46
+ def _patched_handle_openai_bad_request(e: openai.BadRequestError) -> None:
47
+ """扩展版 _handle_openai_bad_request,识别更多上下文溢出错误格式。"""
48
+ error_str = str(e)
49
+ error_msg = getattr(e, 'message', '') or ''
50
+
51
+ # 新增模式:覆盖 MiniMax/vLLM/其他兼容 API 的错误格式
52
+ if (
53
+ "maximum context length is" in error_str
54
+ or "Please reduce the length of the input prompt" in error_msg
55
+ or "maximum context length" in error_msg
56
+ ):
57
+ raise _lc_oai_base.OpenAIContextOverflowError(
58
+ message=e.message, response=e.response, body=e.body
59
+ ) from e
60
+
61
+ # 回退到原始处理逻辑
62
+ _orig_handle_bad_request(e)
63
+
64
+ _lc_oai_base._handle_openai_bad_request = _patched_handle_openai_bad_request
65
+
66
+ # 同步 patch _handle_openai_api_error
67
+ _orig_handle_api_error = _lc_oai_base._handle_openai_api_error
68
+
69
+ def _patched_handle_openai_api_error(e: openai.APIError) -> None:
70
+ """扩展版 _handle_openai_api_error,识别更多上下文溢出错误格式。"""
71
+ error_str = str(e)
72
+ if "maximum context length is" in error_str:
73
+ raise _lc_oai_base.OpenAIAPIContextOverflowError(
74
+ message=e.message, request=e.request, body=e.body
75
+ ) from e
76
+ _orig_handle_api_error(e)
77
+
78
+ _lc_oai_base._handle_openai_api_error = _patched_handle_openai_api_error
79
+
80
+ logger.info(
81
+ "[ContextOverflowPatch] 已扩展 _handle_openai_bad_request/_handle_openai_api_error "
82
+ "以识别 MiniMax/vLLM 上下文溢出错误格式"
83
+ )
84
+ except ImportError:
85
+ logger.debug("[ContextOverflowPatch] langchain_openai 不可用,跳过补丁")
86
+ except Exception as _patch_err:
87
+ logger.warning(f"[ContextOverflowPatch] 补丁应用失败: {_patch_err}")
88
+
89
+
28
90
  # 中文摘要提示词,替代 langchain DEFAULT_SUMMARY_PROMPT
29
91
  # 使用纯自然段落格式(无标题/小标题/列表标记),避免结构化标题被模型回显到用户输出
30
92
  CUSTOM_SUMMARY_PROMPT = """<role>
@@ -78,6 +78,11 @@ def clamp_max_tokens(
78
78
  将 model.max_tokens = min(max_output_tokens, context_window - input - buffer),
79
79
  最低保底 min_output。
80
80
 
81
+ 安全措施:
82
+ - context_window 乘以 0.95 作为有效上限,防止模型实际上下文窗口小于配置值
83
+ (如 MiniMax2.5 配置 200000 但实际为 196608)
84
+ - tiktoken 计数可能与模型内部 tokenizer 存在偏差,额外余量可以覆盖
85
+
81
86
  Args:
82
87
  model: 拥有 ``max_tokens`` 属性的 ChatModel 实例。
83
88
  messages: 即将发送给模型的消息列表。
@@ -89,8 +94,10 @@ def clamp_max_tokens(
89
94
  Returns:
90
95
  调整后的 max_tokens 值。
91
96
  """
97
+ # 有效上下文窗口 = 配置值 * 0.95,防止配置值大于模型实际上下文
98
+ effective_context = int(context_window * 0.95)
92
99
  input_est = count_messages_tokens(messages)
93
- available = context_window - input_est - buffer
100
+ available = effective_context - input_est - buffer
94
101
  dynamic_max = min(max_output_tokens, max(available, min_output))
95
102
  model.max_tokens = dynamic_max
96
103
  return dynamic_max
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.6a3
3
+ Version: 0.2.6a5
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,9 +13,9 @@ 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
16
+ Requires-Dist: kafka-python==2.3.2
17
+ Requires-Dist: langchain==1.3.7
18
+ Requires-Dist: langchain-core==1.4.6
19
19
  Requires-Dist: langchain-openai==1.2.2
20
20
  Requires-Dist: langfuse==4.7.1
21
21
  Requires-Dist: langgraph==1.2.4
@@ -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
- Requires-Dist: starlette[full]==1.2.1
37
+ Requires-Dist: starlette[full]==1.3.0
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
43
  Requires-Dist: langchain-mcp-adapters==0.2.2
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
@@ -129,9 +135,9 @@ sycommon/services.py,sha256=pUcV0xFrn1hrArD9YwE8m6CMrrWPwNBAqUIWhgsVkzY,24943
129
135
  sycommon/agent/__init__.py,sha256=mxceAeUifQ-DKvWp7ZEJIFlmOCb5wpYHPGQw3rwEN8I,4378
130
136
  sycommon/agent/agent_manager.py,sha256=UhhaekEumT7g4v_Z1UB4jTp13X0n8M8erYaQdkGGWkA,13620
131
137
  sycommon/agent/chat_events.py,sha256=t7qWa6OrIWLfqtd1AnqaVP67QWkn1JxpCBTZYQpoLuM,14226
132
- sycommon/agent/deep_agent.py,sha256=TY2C4AnF609ZFjjz2OrpcAqjRZ2v2fPFtG7LzpWVpgQ,66892
138
+ sycommon/agent/deep_agent.py,sha256=qzRms1paE0bTtMBe1ae7SgQMK_EZEtvqyV8fsUFyxSM,67704
133
139
  sycommon/agent/multi_agent_team.py,sha256=mskS-FJbAB_n5kghjHhiWxpA3L9cD1lObeW_wjrLI6s,27079
134
- sycommon/agent/summarization_utils.py,sha256=fiwvKYE_eQc1EtBiPT4UyOZoqQdd4JwEsLRDsJNioLQ,14928
140
+ sycommon/agent/summarization_utils.py,sha256=AjdJmUWIY0kgf1qYvhMMGyB2_v70CEC-SxqSUOU3ZfA,17673
135
141
  sycommon/agent/mcp/__init__.py,sha256=iKrdDhIrFsNIkqG_kgcwNe-nOiM6uVfolKv44LfQ-FQ,636
136
142
  sycommon/agent/mcp/models.py,sha256=zda4ho-UTYOJ5oPAZPZ-vxhfDRLghUCsMroHnA2A2QQ,1490
137
143
  sycommon/agent/mcp/tool_loader.py,sha256=OQa-lE7RL2s3Lqh-kNOKaUrb130hvorB_HjyFBqZBZs,10960
@@ -190,7 +196,7 @@ sycommon/llm/struct_token.py,sha256=WYNa7o2cC_3X6dJw3ZN1Zgn83HmhdD8GCUlSiEV7qus,
190
196
  sycommon/llm/sy_langfuse.py,sha256=27RyPUkScqJYHrupOEyUs23fDszoaA66FFgv5Qc7-B4,4443
191
197
  sycommon/llm/token_usage_es_service.py,sha256=-RBCwZ8jS436n8DBUBm3Lf5O6TsQMaQgIj5oYBLO4Ss,19909
192
198
  sycommon/llm/token_usage_mysql_service.py,sha256=1WhdpZ8M4v4UNP5W0ui3A-Cx5yfZiX73l77FTJvL_d8,32644
193
- sycommon/llm/token_utils.py,sha256=AT9Rl-uHeXbBK-psQ_X0EIi6ZrSmZMO_aNHFuIAq344,3428
199
+ sycommon/llm/token_utils.py,sha256=-7cONuu--9yHJQ5kNhLisMFFz8OgFfBfh2pCENpfSUc,3852
194
200
  sycommon/llm/usage_token.py,sha256=uFskOhl6IMeJtysJ6tMAmBb2taVLm5lRWjqcrbnkx54,8736
195
201
  sycommon/llm/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4,sha256=Ijkht27pm96ZW3_3OFE-7xAPtR0YyTWXoRO8_-hlsqc,1681126
196
202
  sycommon/llm/tiktoken_cache/fb374d419588a4632f3f557e76b4b70aebbca790,sha256=RGqVOMtsNI41FhINfAiwn1fDZJXirP_-WaW_iwz7Gi0,3613922
@@ -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.6a3.dist-info/METADATA,sha256=gKIykJEMsoqXkDXIHU1w3Kq5YK99dJcUCE8WUTfG3-g,7913
288
- sycommon_python_lib-0.2.6a3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
289
- sycommon_python_lib-0.2.6a3.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
290
- sycommon_python_lib-0.2.6a3.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
291
- sycommon_python_lib-0.2.6a3.dist-info/RECORD,,
293
+ sycommon_python_lib-0.2.6a5.dist-info/METADATA,sha256=TopwMFzHdAQpiLylalKZk40bf9Oe9mgm2QswKOcIRcs,7943
294
+ sycommon_python_lib-0.2.6a5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
295
+ sycommon_python_lib-0.2.6a5.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
296
+ sycommon_python_lib-0.2.6a5.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
297
+ sycommon_python_lib-0.2.6a5.dist-info/RECORD,,