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.
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}[/]")