sa-assistant 0.1.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.
- agents/__init__.py +32 -0
- agents/orchestrator.py +283 -0
- agents/specialists.py +230 -0
- agentskills/__init__.py +38 -0
- agentskills/discovery.py +107 -0
- agentskills/errors.py +38 -0
- agentskills/models.py +48 -0
- agentskills/parser.py +183 -0
- agentskills/prompt.py +70 -0
- agentskills/tool.py +138 -0
- cli/__init__.py +51 -0
- cli/callback.py +145 -0
- cli/components.py +346 -0
- cli/console.py +106 -0
- cli/mdstream.py +236 -0
- mcp_client/client.py +12 -0
- model/__init__.py +20 -0
- model/load.py +87 -0
- prompts/__init__.py +65 -0
- prompts/system_prompts.py +464 -0
- sa_assistant-0.1.1.dist-info/METADATA +77 -0
- sa_assistant-0.1.1.dist-info/RECORD +25 -0
- sa_assistant-0.1.1.dist-info/WHEEL +5 -0
- sa_assistant-0.1.1.dist-info/entry_points.txt +2 -0
- sa_assistant-0.1.1.dist-info/top_level.txt +6 -0
agentskills/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""스킬 관련 예외 정의"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SkillError(Exception):
|
|
5
|
+
"""스킬 관련 기본 예외"""
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParseError(SkillError):
|
|
10
|
+
"""SKILL.md 파싱 실패 시 발생"""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationError(SkillError):
|
|
15
|
+
"""스킬 속성 검증 실패 시 발생"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, errors: list[str] | None = None):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.errors = errors if errors is not None else [message]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SkillNotFoundError(SkillError):
|
|
23
|
+
"""요청한 스킬을 찾을 수 없을 때 발생"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SkillActivationError(SkillError):
|
|
28
|
+
"""스킬 활성화 실패 시 발생"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"SkillError",
|
|
34
|
+
"ParseError",
|
|
35
|
+
"ValidationError",
|
|
36
|
+
"SkillNotFoundError",
|
|
37
|
+
"SkillActivationError",
|
|
38
|
+
]
|
agentskills/models.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""스킬 데이터 모델"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SkillProperties:
|
|
9
|
+
"""SKILL.md frontmatter에서 파싱된 스킬 메타데이터
|
|
10
|
+
|
|
11
|
+
Progressive Disclosure Phase 1: 경량 메타데이터만 포함 (~100 tokens)
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
name: 스킬 이름 (kebab-case)
|
|
15
|
+
description: 스킬 설명 및 사용 시점
|
|
16
|
+
path: SKILL.md 파일의 절대 경로
|
|
17
|
+
skill_dir: 스킬 디렉토리의 절대 경로
|
|
18
|
+
license: 라이선스 정보 (선택)
|
|
19
|
+
allowed_tools: 스킬이 사용하는 도구 패턴 (선택)
|
|
20
|
+
metadata: 커스텀 메타데이터 (선택)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
description: str
|
|
25
|
+
path: str
|
|
26
|
+
skill_dir: str
|
|
27
|
+
license: Optional[str] = None
|
|
28
|
+
allowed_tools: Optional[str] = None
|
|
29
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
"""딕셔너리로 변환 (None 값 제외)"""
|
|
33
|
+
result = {
|
|
34
|
+
"name": self.name,
|
|
35
|
+
"description": self.description,
|
|
36
|
+
"path": self.path,
|
|
37
|
+
"skill_dir": self.skill_dir,
|
|
38
|
+
}
|
|
39
|
+
if self.license is not None:
|
|
40
|
+
result["license"] = self.license
|
|
41
|
+
if self.allowed_tools is not None:
|
|
42
|
+
result["allowed_tools"] = self.allowed_tools
|
|
43
|
+
if self.metadata:
|
|
44
|
+
result["metadata"] = self.metadata
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["SkillProperties"]
|
agentskills/parser.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""SKILL.md 파일 파싱
|
|
2
|
+
|
|
3
|
+
YAML frontmatter와 Markdown body를 분리하여 파싱합니다.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from .errors import ParseError, ValidationError
|
|
13
|
+
from .models import SkillProperties
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_skill_md(skill_dir: Path) -> Optional[Path]:
|
|
17
|
+
"""스킬 디렉토리에서 SKILL.md 파일 찾기
|
|
18
|
+
|
|
19
|
+
SKILL.md (대문자) 우선, skill.md (소문자)도 허용
|
|
20
|
+
"""
|
|
21
|
+
for name in ("SKILL.md", "skill.md"):
|
|
22
|
+
path = skill_dir / name
|
|
23
|
+
if path.exists():
|
|
24
|
+
return path
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_skill_md(content: str) -> tuple[dict, str]:
|
|
29
|
+
"""SKILL.md 내용을 frontmatter와 body로 분리
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
content: SKILL.md 파일 내용
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
(frontmatter dict, body string) 튜플
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ParseError: frontmatter가 유효하지 않을 때
|
|
39
|
+
"""
|
|
40
|
+
match = re.match(r'^---\s*\n(.*?)\n---\s*\n?(.*)$', content, re.DOTALL)
|
|
41
|
+
if not match:
|
|
42
|
+
raise ParseError("SKILL.md는 YAML frontmatter (---)로 시작해야 합니다")
|
|
43
|
+
|
|
44
|
+
frontmatter_str = match.group(1)
|
|
45
|
+
body = match.group(2).strip()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
frontmatter = yaml.safe_load(frontmatter_str)
|
|
49
|
+
except yaml.YAMLError as e:
|
|
50
|
+
raise ParseError(f"frontmatter YAML 파싱 오류: {e}")
|
|
51
|
+
|
|
52
|
+
if not isinstance(frontmatter, dict):
|
|
53
|
+
raise ParseError("SKILL.md frontmatter는 YAML 매핑이어야 합니다")
|
|
54
|
+
|
|
55
|
+
# metadata 필드가 있으면 문자열로 변환
|
|
56
|
+
if "metadata" in frontmatter and isinstance(frontmatter["metadata"], dict):
|
|
57
|
+
frontmatter["metadata"] = {
|
|
58
|
+
str(k): str(v) for k, v in frontmatter["metadata"].items()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return frontmatter, body
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_metadata(skill_dir: str | Path) -> SkillProperties:
|
|
65
|
+
"""SKILL.md frontmatter에서 메타데이터 로드 (Phase 1)
|
|
66
|
+
|
|
67
|
+
경량 메타데이터만 로드하여 컨텍스트 절약
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
skill_dir: 스킬 디렉토리 경로
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
SkillProperties (instructions 미포함)
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ParseError: SKILL.md가 없거나 YAML이 유효하지 않을 때
|
|
77
|
+
ValidationError: 필수 필드가 없을 때
|
|
78
|
+
"""
|
|
79
|
+
skill_dir = Path(skill_dir).resolve()
|
|
80
|
+
skill_md = find_skill_md(skill_dir)
|
|
81
|
+
|
|
82
|
+
if skill_md is None:
|
|
83
|
+
raise ParseError(f"SKILL.md를 찾을 수 없습니다: {skill_dir}")
|
|
84
|
+
|
|
85
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
86
|
+
frontmatter, _ = _parse_skill_md(content)
|
|
87
|
+
|
|
88
|
+
# 필수 필드 검증
|
|
89
|
+
if "name" not in frontmatter:
|
|
90
|
+
raise ValidationError("frontmatter에 'name' 필드가 필요합니다")
|
|
91
|
+
if "description" not in frontmatter:
|
|
92
|
+
raise ValidationError("frontmatter에 'description' 필드가 필요합니다")
|
|
93
|
+
|
|
94
|
+
name = frontmatter["name"]
|
|
95
|
+
description = frontmatter["description"]
|
|
96
|
+
|
|
97
|
+
if not isinstance(name, str) or not name.strip():
|
|
98
|
+
raise ValidationError("'name'은 비어있지 않은 문자열이어야 합니다")
|
|
99
|
+
if not isinstance(description, str) or not description.strip():
|
|
100
|
+
raise ValidationError("'description'은 비어있지 않은 문자열이어야 합니다")
|
|
101
|
+
|
|
102
|
+
return SkillProperties(
|
|
103
|
+
name=name.strip(),
|
|
104
|
+
description=description.strip(),
|
|
105
|
+
path=str(skill_md.absolute()),
|
|
106
|
+
skill_dir=str(skill_dir.absolute()),
|
|
107
|
+
license=frontmatter.get("license"),
|
|
108
|
+
allowed_tools=frontmatter.get("allowed-tools"),
|
|
109
|
+
metadata=frontmatter.get("metadata", {}),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_instructions(skill_path: str | Path) -> str:
|
|
114
|
+
"""SKILL.md body에서 instructions 로드 (Phase 2)
|
|
115
|
+
|
|
116
|
+
스킬이 활성화될 때 전체 Markdown body를 로드
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
skill_path: SKILL.md 파일 경로
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Markdown body (instructions)
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ParseError: 파일을 읽거나 파싱할 수 없을 때
|
|
126
|
+
"""
|
|
127
|
+
skill_path = Path(skill_path)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
content = skill_path.read_text(encoding="utf-8")
|
|
131
|
+
_, instructions = _parse_skill_md(content)
|
|
132
|
+
return instructions
|
|
133
|
+
except Exception as e:
|
|
134
|
+
raise ParseError(f"instructions 로드 실패 ({skill_path}): {e}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_resource(skill_dir: str | Path, resource_path: str) -> str:
|
|
138
|
+
"""스킬 디렉토리에서 리소스 파일 로드 (Phase 3)
|
|
139
|
+
|
|
140
|
+
scripts/, references/, assets/ 등의 파일을 필요 시 로드
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
skill_dir: 스킬 디렉토리 경로
|
|
144
|
+
resource_path: 상대 경로 (예: "references/html2pptx.md")
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
리소스 파일 내용
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ParseError: 리소스를 읽을 수 없거나 디렉토리 외부일 때
|
|
151
|
+
"""
|
|
152
|
+
skill_dir = Path(skill_dir).resolve()
|
|
153
|
+
resource_file = (skill_dir / resource_path).resolve()
|
|
154
|
+
|
|
155
|
+
# 보안: 스킬 디렉토리 내부인지 확인
|
|
156
|
+
try:
|
|
157
|
+
resource_file.relative_to(skill_dir)
|
|
158
|
+
except ValueError:
|
|
159
|
+
raise ParseError(f"리소스 경로가 스킬 디렉토리 외부입니다: {resource_path}")
|
|
160
|
+
|
|
161
|
+
if not resource_file.exists():
|
|
162
|
+
raise ParseError(f"리소스를 찾을 수 없습니다: {resource_path}")
|
|
163
|
+
|
|
164
|
+
if not resource_file.is_file():
|
|
165
|
+
raise ParseError(f"리소스가 파일이 아닙니다: {resource_path}")
|
|
166
|
+
|
|
167
|
+
# 파일 크기 제한 (10MB)
|
|
168
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
169
|
+
if resource_file.stat().st_size > MAX_FILE_SIZE:
|
|
170
|
+
raise ParseError(f"리소스가 너무 큽니다 (최대 10MB): {resource_path}")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
return resource_file.read_text(encoding="utf-8")
|
|
174
|
+
except Exception as e:
|
|
175
|
+
raise ParseError(f"리소스 읽기 실패 ({resource_path}): {e}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
__all__ = [
|
|
179
|
+
"find_skill_md",
|
|
180
|
+
"load_metadata",
|
|
181
|
+
"load_instructions",
|
|
182
|
+
"load_resource",
|
|
183
|
+
]
|
agentskills/prompt.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""스킬 프롬프트 생성
|
|
2
|
+
|
|
3
|
+
시스템 프롬프트에 포함할 스킬 메타데이터 XML 생성
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List
|
|
7
|
+
from .models import SkillProperties
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SKILLS_SYSTEM_PROMPT = """
|
|
11
|
+
## Skills System
|
|
12
|
+
|
|
13
|
+
You have access to a skills library that provides specialized capabilities.
|
|
14
|
+
|
|
15
|
+
<skills_instructions>
|
|
16
|
+
**스킬 사용 방법:**
|
|
17
|
+
|
|
18
|
+
스킬은 **Progressive Disclosure** 패턴을 따릅니다:
|
|
19
|
+
- 시스템 프롬프트에는 스킬 메타데이터(이름, 설명)만 포함됩니다
|
|
20
|
+
- 전체 instructions는 스킬이 필요할 때 로드됩니다
|
|
21
|
+
|
|
22
|
+
1. **스킬 적용 여부 확인**: 사용자 요청이 아래 스킬 설명과 일치하는지 확인
|
|
23
|
+
2. **스킬 instructions 로드**: `skill(skill_name="스킬이름")` 도구 호출
|
|
24
|
+
3. **instructions 따르기**: 로드된 워크플로우, 예제, 베스트 프랙티스 준수
|
|
25
|
+
4. **리소스 파일 접근**: 필요 시 `file_read`로 추가 파일 로드
|
|
26
|
+
|
|
27
|
+
**스킬 사용 시점:**
|
|
28
|
+
- 사용자 요청이 스킬 도메인과 일치할 때 (예: "PPT 만들어줘" → pptx 스킬)
|
|
29
|
+
- 전문 지식이나 구조화된 워크플로우가 필요할 때
|
|
30
|
+
- 복잡한 작업에 검증된 패턴이 있을 때
|
|
31
|
+
</skills_instructions>
|
|
32
|
+
|
|
33
|
+
<available_skills>
|
|
34
|
+
{skills_list}
|
|
35
|
+
</available_skills>
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def generate_skills_prompt(skills: List[SkillProperties]) -> str:
|
|
40
|
+
"""스킬 메타데이터로 시스템 프롬프트 섹션 생성
|
|
41
|
+
|
|
42
|
+
Progressive Disclosure Phase 1: 메타데이터만 포함하여 컨텍스트 절약
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
skills: 발견된 스킬 속성 리스트
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
XML 형식의 프롬프트 텍스트
|
|
49
|
+
"""
|
|
50
|
+
if not skills:
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
# XML 스킬 리스트 생성 (메타데이터만)
|
|
54
|
+
skill_elements = []
|
|
55
|
+
for skill in sorted(skills, key=lambda s: s.name):
|
|
56
|
+
skill_xml = (
|
|
57
|
+
" <skill>\n"
|
|
58
|
+
f" <name>{skill.name}</name>\n"
|
|
59
|
+
f" <description>{skill.description}</description>\n"
|
|
60
|
+
f" <location>{skill.path}</location>\n"
|
|
61
|
+
" </skill>"
|
|
62
|
+
)
|
|
63
|
+
skill_elements.append(skill_xml)
|
|
64
|
+
|
|
65
|
+
skills_list = "\n".join(skill_elements)
|
|
66
|
+
|
|
67
|
+
return SKILLS_SYSTEM_PROMPT.format(skills_list=skills_list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["generate_skills_prompt"]
|
agentskills/tool.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""스킬 도구 - Progressive Disclosure Phase 2
|
|
2
|
+
|
|
3
|
+
스킬이 필요할 때 전체 instructions를 로드하는 Strands 도구
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Dict
|
|
9
|
+
|
|
10
|
+
from strands import tool
|
|
11
|
+
|
|
12
|
+
from .models import SkillProperties
|
|
13
|
+
from .errors import SkillNotFoundError, SkillActivationError
|
|
14
|
+
from .parser import load_instructions
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _scan_skill_resources(skill_dir: Path) -> List[str]:
|
|
20
|
+
"""스킬 디렉토리에서 사용 가능한 리소스 스캔
|
|
21
|
+
|
|
22
|
+
references/, scripts/, assets/ 하위 디렉토리의 파일 목록 반환
|
|
23
|
+
"""
|
|
24
|
+
resources = []
|
|
25
|
+
for subdir in ["references", "scripts", "assets"]:
|
|
26
|
+
resource_dir = skill_dir / subdir
|
|
27
|
+
if resource_dir.exists() and resource_dir.is_dir():
|
|
28
|
+
for file_path in sorted(resource_dir.rglob("*")):
|
|
29
|
+
if file_path.is_file():
|
|
30
|
+
resources.append(str(file_path.absolute()))
|
|
31
|
+
return resources
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_skill_header(skill: SkillProperties) -> str:
|
|
35
|
+
"""스킬 헤더 문자열 생성 (메타데이터 + 리소스 목록)"""
|
|
36
|
+
header = (
|
|
37
|
+
f"# Skill: {skill.name}\n\n"
|
|
38
|
+
f"**Description:** {skill.description}\n\n"
|
|
39
|
+
f"**Skill Directory:** `{skill.skill_dir}/`\n\n"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# allowed-tools가 있으면 표시
|
|
43
|
+
if skill.allowed_tools:
|
|
44
|
+
header += f"\n**IMPORTANT:** Only use these tools: `{skill.allowed_tools}`\n"
|
|
45
|
+
|
|
46
|
+
# 리소스 파일 목록
|
|
47
|
+
skill_dir = Path(skill.skill_dir)
|
|
48
|
+
resources = _scan_skill_resources(skill_dir)
|
|
49
|
+
|
|
50
|
+
if resources:
|
|
51
|
+
header += "\n**Available Resources (use file_read to access):**\n"
|
|
52
|
+
for resource in resources:
|
|
53
|
+
header += f"- `{resource}`\n"
|
|
54
|
+
header += "\n"
|
|
55
|
+
|
|
56
|
+
header += "---\n\n"
|
|
57
|
+
|
|
58
|
+
return header
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_skill_tool(skills: List[SkillProperties], skills_dir: str | Path):
|
|
62
|
+
"""스킬 활성화 도구 생성 (Inline Mode)
|
|
63
|
+
|
|
64
|
+
Progressive Disclosure를 구현하는 팩토리 함수:
|
|
65
|
+
- Phase 1: 시스템 프롬프트에 메타데이터 (이미 로드됨)
|
|
66
|
+
- Phase 2: 스킬 호출 시 전체 instructions 로드
|
|
67
|
+
- Phase 3: LLM이 file_read로 리소스 접근
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
skills: 발견된 스킬 속성 리스트
|
|
71
|
+
skills_dir: 스킬 기본 디렉토리
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
@tool 데코레이터가 적용된 Strands 도구 함수
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> from agentskills import discover_skills, create_skill_tool
|
|
78
|
+
>>> from strands import Agent
|
|
79
|
+
>>> from strands_tools import file_read
|
|
80
|
+
>>>
|
|
81
|
+
>>> skills = discover_skills("./skills")
|
|
82
|
+
>>> skill_tool = create_skill_tool(skills, "./skills")
|
|
83
|
+
>>>
|
|
84
|
+
>>> agent = Agent(tools=[skill_tool, file_read])
|
|
85
|
+
"""
|
|
86
|
+
skills_dir = Path(skills_dir).expanduser().resolve()
|
|
87
|
+
|
|
88
|
+
# 빠른 조회를 위한 맵 생성
|
|
89
|
+
skill_map: Dict[str, SkillProperties] = {skill.name: skill for skill in skills}
|
|
90
|
+
|
|
91
|
+
@tool
|
|
92
|
+
def skill(skill_name: str) -> str:
|
|
93
|
+
"""전문 스킬 instructions를 로드합니다.
|
|
94
|
+
|
|
95
|
+
사용자 요청이 특정 스킬 도메인과 일치할 때 호출하세요.
|
|
96
|
+
로드된 instructions에는 워크플로우, 예제, 베스트 프랙티스가 포함됩니다.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
skill_name: 스킬 이름 (시스템 프롬프트의 available_skills 참조)
|
|
100
|
+
- "pptx": PowerPoint 프레젠테이션 생성
|
|
101
|
+
- "docx": Word 문서 생성
|
|
102
|
+
- "mcp": MCP 도구 사용 가이드
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
스킬 헤더 + 전체 instructions + 리소스 정보
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
skill(skill_name="pptx") # PPTX 생성 스킬 로드
|
|
109
|
+
"""
|
|
110
|
+
# 스킬 존재 여부 확인
|
|
111
|
+
if skill_name not in skill_map:
|
|
112
|
+
available = ", ".join(skill_map.keys())
|
|
113
|
+
raise SkillNotFoundError(
|
|
114
|
+
f"스킬 '{skill_name}'을 찾을 수 없습니다. "
|
|
115
|
+
f"사용 가능한 스킬: {available}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
skill_props = skill_map[skill_name]
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Phase 2: instructions 로드
|
|
122
|
+
instructions = load_instructions(skill_props.path)
|
|
123
|
+
logger.info(f"스킬 로드됨: {skill_name}")
|
|
124
|
+
|
|
125
|
+
# 헤더 + instructions 조합
|
|
126
|
+
header = _build_skill_header(skill_props)
|
|
127
|
+
return header + instructions
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"스킬 '{skill_name}' 로드 오류: {e}", exc_info=True)
|
|
131
|
+
raise SkillActivationError(
|
|
132
|
+
f"스킬 '{skill_name}' 로드 실패: {e}"
|
|
133
|
+
) from e
|
|
134
|
+
|
|
135
|
+
return skill
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
__all__ = ["create_skill_tool"]
|
cli/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""SA Assistant CLI Module - Rich-based modern CLI interface."""
|
|
2
|
+
|
|
3
|
+
from .console import (
|
|
4
|
+
SAConsole,
|
|
5
|
+
get_console,
|
|
6
|
+
print_error,
|
|
7
|
+
print_warning,
|
|
8
|
+
print_info,
|
|
9
|
+
print_success,
|
|
10
|
+
)
|
|
11
|
+
from .components import (
|
|
12
|
+
AgentPanel,
|
|
13
|
+
ToolStatus,
|
|
14
|
+
ResponseRenderer,
|
|
15
|
+
print_banner,
|
|
16
|
+
print_help,
|
|
17
|
+
)
|
|
18
|
+
from .callback import (
|
|
19
|
+
RichCallbackHandler,
|
|
20
|
+
MinimalCallbackHandler,
|
|
21
|
+
VerboseCallbackHandler,
|
|
22
|
+
)
|
|
23
|
+
from .mdstream import (
|
|
24
|
+
MarkdownStream,
|
|
25
|
+
SimpleMarkdownStream,
|
|
26
|
+
SAMarkdown,
|
|
27
|
+
render_markdown,
|
|
28
|
+
render_code,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"SAConsole",
|
|
33
|
+
"get_console",
|
|
34
|
+
"print_error",
|
|
35
|
+
"print_warning",
|
|
36
|
+
"print_info",
|
|
37
|
+
"print_success",
|
|
38
|
+
"AgentPanel",
|
|
39
|
+
"ToolStatus",
|
|
40
|
+
"ResponseRenderer",
|
|
41
|
+
"RichCallbackHandler",
|
|
42
|
+
"MinimalCallbackHandler",
|
|
43
|
+
"VerboseCallbackHandler",
|
|
44
|
+
"print_banner",
|
|
45
|
+
"print_help",
|
|
46
|
+
"MarkdownStream",
|
|
47
|
+
"SimpleMarkdownStream",
|
|
48
|
+
"SAMarkdown",
|
|
49
|
+
"render_markdown",
|
|
50
|
+
"render_code",
|
|
51
|
+
]
|
cli/callback.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""SA Assistant Rich Callback Handler - Strands Agent callback to Rich UI."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Set, Optional
|
|
4
|
+
from .console import get_console
|
|
5
|
+
from .components import ToolStatus
|
|
6
|
+
from .mdstream import MarkdownStream
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RichCallbackHandler:
|
|
10
|
+
"""Rich-based Strands Agent callback handler with markdown streaming.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
handler = RichCallbackHandler()
|
|
14
|
+
agent = Agent(callback_handler=handler)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
show_tool_calls: bool = True,
|
|
20
|
+
show_thinking: bool = False,
|
|
21
|
+
use_markdown_stream: bool = True,
|
|
22
|
+
):
|
|
23
|
+
self.console = get_console()
|
|
24
|
+
self.show_tool_calls = show_tool_calls
|
|
25
|
+
self.show_thinking = show_thinking
|
|
26
|
+
self.use_markdown_stream = use_markdown_stream
|
|
27
|
+
|
|
28
|
+
self._tool_status = ToolStatus()
|
|
29
|
+
self._printed_tool_ids: Set[str] = set()
|
|
30
|
+
self._in_response = False
|
|
31
|
+
self._text_buffer = ""
|
|
32
|
+
self._md_stream: Optional[MarkdownStream] = None
|
|
33
|
+
|
|
34
|
+
def reset(self) -> None:
|
|
35
|
+
"""Reset state for new conversation."""
|
|
36
|
+
self._printed_tool_ids.clear()
|
|
37
|
+
self._in_response = False
|
|
38
|
+
self._text_buffer = ""
|
|
39
|
+
if self._md_stream:
|
|
40
|
+
self._md_stream.finish()
|
|
41
|
+
self._md_stream = None
|
|
42
|
+
|
|
43
|
+
def __call__(self, **kwargs) -> None:
|
|
44
|
+
"""Strands Agent callback entry point."""
|
|
45
|
+
if "data" in kwargs:
|
|
46
|
+
self._handle_data(kwargs["data"])
|
|
47
|
+
|
|
48
|
+
if "current_tool_use" in kwargs and self.show_tool_calls:
|
|
49
|
+
self._handle_tool_use(kwargs["current_tool_use"])
|
|
50
|
+
|
|
51
|
+
if "thinking" in kwargs and self.show_thinking:
|
|
52
|
+
self._handle_thinking(kwargs["thinking"])
|
|
53
|
+
|
|
54
|
+
if "tool_result" in kwargs:
|
|
55
|
+
self._handle_tool_result(kwargs["tool_result"])
|
|
56
|
+
|
|
57
|
+
if "stop" in kwargs:
|
|
58
|
+
self._handle_stop()
|
|
59
|
+
|
|
60
|
+
def _handle_data(self, data: str) -> None:
|
|
61
|
+
"""Handle streaming text."""
|
|
62
|
+
if not self._in_response:
|
|
63
|
+
self._in_response = True
|
|
64
|
+
if self.use_markdown_stream:
|
|
65
|
+
self._md_stream = MarkdownStream(console=self.console)
|
|
66
|
+
|
|
67
|
+
self._text_buffer += data
|
|
68
|
+
|
|
69
|
+
if self.use_markdown_stream and self._md_stream:
|
|
70
|
+
self._md_stream.update(self._text_buffer)
|
|
71
|
+
else:
|
|
72
|
+
print(data, end="", flush=True)
|
|
73
|
+
|
|
74
|
+
def _handle_tool_use(self, tool_use: Any) -> None:
|
|
75
|
+
"""Handle tool invocation."""
|
|
76
|
+
if not isinstance(tool_use, dict):
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
tool_id = tool_use.get("toolUseId", "")
|
|
80
|
+
tool_name = tool_use.get("name", "")
|
|
81
|
+
|
|
82
|
+
if tool_id and tool_id in self._printed_tool_ids:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if not tool_name:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if tool_id:
|
|
89
|
+
self._printed_tool_ids.add(tool_id)
|
|
90
|
+
|
|
91
|
+
self._finalize_response()
|
|
92
|
+
|
|
93
|
+
args = tool_use.get("input", {})
|
|
94
|
+
self._tool_status.tool_start(tool_id, tool_name, args)
|
|
95
|
+
|
|
96
|
+
def _handle_thinking(self, thinking: str) -> None:
|
|
97
|
+
"""Handle thinking text."""
|
|
98
|
+
display = f"{thinking[:100]}..." if len(thinking) > 100 else thinking
|
|
99
|
+
self.console.print(f"[text.thinking]💭 {display}[/]")
|
|
100
|
+
|
|
101
|
+
def _handle_tool_result(self, result: Any) -> None:
|
|
102
|
+
"""Handle tool result."""
|
|
103
|
+
if isinstance(result, dict) and result.get("status") == "error":
|
|
104
|
+
error_msg = result.get("error", "Unknown error")
|
|
105
|
+
self.console.print(f"[tool.error] ↳ Error: {error_msg}[/]")
|
|
106
|
+
|
|
107
|
+
def _handle_stop(self) -> None:
|
|
108
|
+
"""Handle stop event - finalize markdown rendering."""
|
|
109
|
+
self._finalize_response()
|
|
110
|
+
|
|
111
|
+
def _finalize_response(self) -> None:
|
|
112
|
+
"""Finalize current response."""
|
|
113
|
+
if self._in_response:
|
|
114
|
+
if self.use_markdown_stream and self._md_stream:
|
|
115
|
+
self._md_stream.update(self._text_buffer, final=True)
|
|
116
|
+
self._md_stream = None
|
|
117
|
+
else:
|
|
118
|
+
print()
|
|
119
|
+
self._in_response = False
|
|
120
|
+
self._text_buffer = ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MinimalCallbackHandler:
|
|
124
|
+
"""Minimal callback handler - text streaming only."""
|
|
125
|
+
|
|
126
|
+
def __call__(self, **kwargs) -> None:
|
|
127
|
+
if "data" in kwargs:
|
|
128
|
+
print(kwargs["data"], end="", flush=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class VerboseCallbackHandler(RichCallbackHandler):
|
|
132
|
+
"""Verbose callback handler - shows all events."""
|
|
133
|
+
|
|
134
|
+
def __init__(self):
|
|
135
|
+
super().__init__(show_tool_calls=True, show_thinking=True)
|
|
136
|
+
|
|
137
|
+
def __call__(self, **kwargs) -> None:
|
|
138
|
+
super().__call__(**kwargs)
|
|
139
|
+
|
|
140
|
+
if "message" in kwargs:
|
|
141
|
+
self.console.print("[text.dim]📨 Message event[/]")
|
|
142
|
+
|
|
143
|
+
if "stop" in kwargs:
|
|
144
|
+
reason = kwargs.get("stop_reason", "unknown")
|
|
145
|
+
self.console.print(f"[text.dim]🛑 Stop reason: {reason}[/]")
|