kolega-code 0.1.0__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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from kolega_code.cli.skills import activated_skill_names, discover_skills
|
|
6
|
+
from kolega_code.llm.models import Message, TextBlock, ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_skill(root: Path, name: str, description: str = "Use this skill for testing.", body: str = "Do the work.") -> Path:
|
|
10
|
+
skill_dir = root / name
|
|
11
|
+
skill_dir.mkdir(parents=True)
|
|
12
|
+
skill_file = skill_dir / "SKILL.md"
|
|
13
|
+
skill_file.write_text(
|
|
14
|
+
f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n",
|
|
15
|
+
encoding="utf-8",
|
|
16
|
+
)
|
|
17
|
+
return skill_dir
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_discover_skills_loads_user_and_project_spec_paths(tmp_path: Path) -> None:
|
|
21
|
+
project = tmp_path / "project"
|
|
22
|
+
user_home = tmp_path / "home"
|
|
23
|
+
project.mkdir()
|
|
24
|
+
|
|
25
|
+
user_skills = user_home / ".agents" / "skills"
|
|
26
|
+
project_skills = project / ".agents" / "skills"
|
|
27
|
+
write_skill(user_skills, "user-skill", "Use this user skill.")
|
|
28
|
+
write_skill(project_skills, "project-skill", "Use this project skill.")
|
|
29
|
+
|
|
30
|
+
catalog = discover_skills(project, user_home=user_home)
|
|
31
|
+
|
|
32
|
+
assert list(catalog.skills) == ["project-skill", "user-skill"]
|
|
33
|
+
assert catalog.skills["project-skill"].scope == "project"
|
|
34
|
+
assert catalog.skills["user-skill"].scope == "user"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_discover_skills_does_not_scan_singular_project_agent_path(tmp_path: Path) -> None:
|
|
38
|
+
project = tmp_path / "project"
|
|
39
|
+
user_home = tmp_path / "home"
|
|
40
|
+
project.mkdir()
|
|
41
|
+
write_skill(project / ".agent" / "skills", "ignored-skill", "Use this ignored skill.")
|
|
42
|
+
|
|
43
|
+
catalog = discover_skills(project, user_home=user_home)
|
|
44
|
+
|
|
45
|
+
assert "ignored-skill" not in catalog.skills
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_project_skill_overrides_user_skill_with_same_name(tmp_path: Path) -> None:
|
|
49
|
+
project = tmp_path / "project"
|
|
50
|
+
user_home = tmp_path / "home"
|
|
51
|
+
project.mkdir()
|
|
52
|
+
write_skill(user_home / ".agents" / "skills", "shared-skill", "Use the user skill.", body="user body")
|
|
53
|
+
write_skill(project / ".agents" / "skills", "shared-skill", "Use the project skill.", body="project body")
|
|
54
|
+
|
|
55
|
+
catalog = discover_skills(project, user_home=user_home)
|
|
56
|
+
|
|
57
|
+
assert catalog.skills["shared-skill"].scope == "project"
|
|
58
|
+
assert "project body" in catalog.activation_content("shared-skill")
|
|
59
|
+
assert any("overrides user skill" in diagnostic.message for diagnostic in catalog.diagnostics)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_discover_skills_supports_folded_yaml_description(tmp_path: Path) -> None:
|
|
63
|
+
project = tmp_path / "project"
|
|
64
|
+
user_home = tmp_path / "home"
|
|
65
|
+
skills_root = project / ".agents" / "skills"
|
|
66
|
+
skill_dir = skills_root / "folded-skill"
|
|
67
|
+
skill_dir.mkdir(parents=True)
|
|
68
|
+
(skill_dir / "SKILL.md").write_text(
|
|
69
|
+
"""---
|
|
70
|
+
name: folded-skill
|
|
71
|
+
description: >
|
|
72
|
+
Use this folded skill
|
|
73
|
+
when testing YAML descriptions.
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
Follow folded instructions.
|
|
77
|
+
""",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
catalog = discover_skills(project, user_home=user_home)
|
|
82
|
+
|
|
83
|
+
assert catalog.skills["folded-skill"].description == "Use this folded skill when testing YAML descriptions."
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_discover_skills_skips_missing_description_and_malformed_yaml(tmp_path: Path) -> None:
|
|
87
|
+
project = tmp_path / "project"
|
|
88
|
+
user_home = tmp_path / "home"
|
|
89
|
+
skills_root = project / ".agents" / "skills"
|
|
90
|
+
missing = skills_root / "missing-description"
|
|
91
|
+
malformed = skills_root / "malformed-skill"
|
|
92
|
+
missing.mkdir(parents=True)
|
|
93
|
+
malformed.mkdir(parents=True)
|
|
94
|
+
(missing / "SKILL.md").write_text("---\nname: missing-description\n---\nbody\n", encoding="utf-8")
|
|
95
|
+
(malformed / "SKILL.md").write_text(
|
|
96
|
+
"---\nname: malformed-skill\ndescription: Use when: yaml breaks\n---\nbody\n",
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
catalog = discover_skills(project, user_home=user_home)
|
|
101
|
+
|
|
102
|
+
assert catalog.skills == {}
|
|
103
|
+
assert len(catalog.diagnostics) == 2
|
|
104
|
+
assert all(diagnostic.severity == "error" for diagnostic in catalog.diagnostics)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_activation_content_wraps_body_and_lists_resources(tmp_path: Path) -> None:
|
|
108
|
+
project = tmp_path / "project"
|
|
109
|
+
user_home = tmp_path / "home"
|
|
110
|
+
skill_dir = write_skill(project / ".agents" / "skills", "resource-skill", "Use resources.", body="# Steps")
|
|
111
|
+
(skill_dir / "references").mkdir()
|
|
112
|
+
(skill_dir / "references" / "guide.md").write_text("reference content", encoding="utf-8")
|
|
113
|
+
(skill_dir / "scripts").mkdir()
|
|
114
|
+
(skill_dir / "scripts" / "run.py").write_text("print('ok')", encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
catalog = discover_skills(project, user_home=user_home)
|
|
117
|
+
content = catalog.activation_content("resource-skill")
|
|
118
|
+
|
|
119
|
+
assert '<skill_content name="resource-skill">' in content
|
|
120
|
+
assert "# Steps" in content
|
|
121
|
+
assert "Skill directory:" in content
|
|
122
|
+
assert "<file>references/guide.md</file>" in content
|
|
123
|
+
assert "<file>scripts/run.py</file>" in content
|
|
124
|
+
assert "name: resource-skill" not in content
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_read_resource_rejects_path_traversal_and_caps_content(tmp_path: Path) -> None:
|
|
128
|
+
project = tmp_path / "project"
|
|
129
|
+
user_home = tmp_path / "home"
|
|
130
|
+
skill_dir = write_skill(project / ".agents" / "skills", "read-skill")
|
|
131
|
+
(skill_dir / "big.txt").write_text("a" * 20, encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
catalog = discover_skills(project, user_home=user_home)
|
|
134
|
+
|
|
135
|
+
assert catalog.read_resource("read-skill", "big.txt", max_chars=5).startswith("aaaaa\n\n[truncated")
|
|
136
|
+
with pytest.raises(ValueError, match="inside the skill directory"):
|
|
137
|
+
catalog.read_resource("read-skill", "../SKILL.md")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_activated_skill_names_scans_text_and_tool_results() -> None:
|
|
141
|
+
history = [
|
|
142
|
+
Message("user", [TextBlock('<skill_content name="one">body</skill_content>')]),
|
|
143
|
+
Message("user", '<skill_content name="two">body</skill_content>'),
|
|
144
|
+
Message(
|
|
145
|
+
"user",
|
|
146
|
+
[
|
|
147
|
+
ToolResult(
|
|
148
|
+
tool_use_id="toolu_123",
|
|
149
|
+
content=[TextBlock('<skill_content name="three">body</skill_content>')],
|
|
150
|
+
name="activate_skill",
|
|
151
|
+
is_error=False,
|
|
152
|
+
)
|
|
153
|
+
],
|
|
154
|
+
),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
assert activated_skill_names(history) == {"one", "two", "three"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ..skills import SkillCatalog, SkillRecord
|
|
4
|
+
from ..slash_commands import (
|
|
5
|
+
SKILLS_LIST_COMMAND,
|
|
6
|
+
THREAD_RESET_COMMANDS,
|
|
7
|
+
TUI_COMMAND_NAMES,
|
|
8
|
+
CommandScope,
|
|
9
|
+
agent_command_names,
|
|
10
|
+
all_command_entries,
|
|
11
|
+
search_commands,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _catalog(*names: str) -> SkillCatalog:
|
|
16
|
+
skills = {
|
|
17
|
+
name: SkillRecord(
|
|
18
|
+
name=name,
|
|
19
|
+
description=f"Description of {name}",
|
|
20
|
+
skill_dir=Path("/tmp") / name,
|
|
21
|
+
skill_file=Path("/tmp") / name / "SKILL.md",
|
|
22
|
+
scope="project",
|
|
23
|
+
)
|
|
24
|
+
for name in names
|
|
25
|
+
}
|
|
26
|
+
return SkillCatalog(skills=skills)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_agent_command_names_match_command_processor():
|
|
30
|
+
from kolega_code.agent.utils.commands import CommandProcessor
|
|
31
|
+
|
|
32
|
+
assert agent_command_names() == {spec.name for spec in CommandProcessor.SPECS}
|
|
33
|
+
assert THREAD_RESET_COMMANDS <= agent_command_names()
|
|
34
|
+
assert SKILLS_LIST_COMMAND in TUI_COMMAND_NAMES
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_all_command_entries_unique_with_descriptions():
|
|
38
|
+
entries = all_command_entries(_catalog("demo-skill"))
|
|
39
|
+
names = [entry.name for entry in entries]
|
|
40
|
+
assert len(names) == len(set(names))
|
|
41
|
+
assert all(entry.description for entry in entries)
|
|
42
|
+
assert all(entry.token == f"/{entry.name}" for entry in entries)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_all_command_entries_include_each_scope():
|
|
46
|
+
entries = all_command_entries(_catalog("demo-skill"))
|
|
47
|
+
by_name = {entry.name: entry for entry in entries}
|
|
48
|
+
assert by_name["help"].scope is CommandScope.AGENT
|
|
49
|
+
assert by_name["plan"].scope is CommandScope.TUI
|
|
50
|
+
assert by_name["demo-skill"].scope is CommandScope.SKILL
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_builtin_command_shadows_skill_with_same_name():
|
|
54
|
+
entries = all_command_entries(_catalog("plan"))
|
|
55
|
+
matches = [entry for entry in entries if entry.name == "plan"]
|
|
56
|
+
assert len(matches) == 1
|
|
57
|
+
assert matches[0].scope is CommandScope.TUI
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_search_commands_prefix_matches_first():
|
|
61
|
+
results = search_commands("c", _catalog())
|
|
62
|
+
names = [entry.name for entry in results]
|
|
63
|
+
prefix = [name for name in names if name.startswith("c")]
|
|
64
|
+
assert prefix == names[: len(prefix)]
|
|
65
|
+
assert "clear" in prefix and "compress" in prefix and "context" in prefix and "copy" in prefix
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_search_commands_substring_after_prefix():
|
|
69
|
+
results = search_commands("ui", _catalog())
|
|
70
|
+
names = [entry.name for entry in results]
|
|
71
|
+
assert "quit" in names
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_search_commands_empty_query_lists_all_up_to_limit():
|
|
75
|
+
catalog = _catalog("a-skill", "b-skill")
|
|
76
|
+
assert len(search_commands("", catalog, limit=5)) == 5
|
|
77
|
+
everything = search_commands("", catalog, limit=100)
|
|
78
|
+
assert {entry.name for entry in everything} >= {"help", "plan", "a-skill", "b-skill"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_search_commands_includes_skills_dynamically():
|
|
82
|
+
results = search_commands("demo", _catalog("demo-skill"))
|
|
83
|
+
assert [entry.name for entry in results] == ["demo-skill"]
|
|
84
|
+
assert results[0].description == "Description of demo-skill"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_search_commands_no_match():
|
|
88
|
+
assert search_commands("zzz", _catalog()) == []
|
kolega_code/cli/theme.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Visual design tokens for the Kolega Code CLI.
|
|
2
|
+
|
|
3
|
+
This module is the single source of truth for colors, glyphs, spacing, and
|
|
4
|
+
truncation limits used by both the Textual TUI (app.py) and the plain CLI
|
|
5
|
+
commands (main.py). It must stay importable without rich or textual installed,
|
|
6
|
+
so those libraries are only imported lazily inside helpers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Color:
|
|
17
|
+
"""Semantic color roles as Rich style strings."""
|
|
18
|
+
|
|
19
|
+
ACCENT = "cyan"
|
|
20
|
+
SUCCESS = "green"
|
|
21
|
+
WARNING = "yellow"
|
|
22
|
+
ERROR = "red"
|
|
23
|
+
MUTED = "bright_black"
|
|
24
|
+
USER = "cyan"
|
|
25
|
+
AGENT = "magenta"
|
|
26
|
+
TOOL = "blue"
|
|
27
|
+
THINKING = "bright_black"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Glyph:
|
|
31
|
+
"""Unicode glyphs used in the UI. Use g() to apply ASCII fallbacks."""
|
|
32
|
+
|
|
33
|
+
USER = "❯" # ❯
|
|
34
|
+
AGENT = "●" # ●
|
|
35
|
+
STATUS = "●" # ●
|
|
36
|
+
TOOL = "⏺" # ⏺
|
|
37
|
+
SUB_AGENT = "◆" # ◆
|
|
38
|
+
PLAN = "◆" # ◆
|
|
39
|
+
QUESTION = "?"
|
|
40
|
+
INSET_BAR = "│" # │
|
|
41
|
+
INSET_ELBOW = "└" # └
|
|
42
|
+
ELLIPSIS = "…" # …
|
|
43
|
+
BULLET_SEP = "·" # ·
|
|
44
|
+
BAR_FILLED = "█" # █
|
|
45
|
+
BAR_EMPTY = "░" # ░
|
|
46
|
+
CHECK = "✓" # ✓
|
|
47
|
+
CROSS = "✗" # ✗
|
|
48
|
+
DOWN = "↓" # ↓
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ASCII_FALLBACKS = {
|
|
52
|
+
Glyph.USER: ">",
|
|
53
|
+
Glyph.AGENT: "*",
|
|
54
|
+
Glyph.TOOL: "*",
|
|
55
|
+
Glyph.SUB_AGENT: "*",
|
|
56
|
+
Glyph.INSET_BAR: "|",
|
|
57
|
+
Glyph.INSET_ELBOW: "`-",
|
|
58
|
+
Glyph.ELLIPSIS: "...",
|
|
59
|
+
Glyph.BULLET_SEP: "-",
|
|
60
|
+
Glyph.BAR_FILLED: "#",
|
|
61
|
+
Glyph.BAR_EMPTY: "-",
|
|
62
|
+
Glyph.CHECK: "ok",
|
|
63
|
+
Glyph.CROSS: "x",
|
|
64
|
+
Glyph.DOWN: "v",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" # ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
|
|
68
|
+
SPINNER_FRAMES_ASCII = "|/-\\"
|
|
69
|
+
SPINNER_INTERVAL = 0.25
|
|
70
|
+
|
|
71
|
+
# Truncation and layout limits
|
|
72
|
+
TOOL_RESULT_PREVIEW_CHARS = 500
|
|
73
|
+
TOOL_STREAM_PREVIEW_CHARS = 4_000
|
|
74
|
+
TOOL_FULL_CONTENT_CAP_CHARS = 50_000
|
|
75
|
+
SUB_AGENT_TAIL_CHARS = 200
|
|
76
|
+
SUB_AGENT_TASK_PREVIEW_CHARS = 120
|
|
77
|
+
CONTEXT_BAR_WIDTH = 18
|
|
78
|
+
INSET_WIDTH = 2
|
|
79
|
+
MARKDOWN_CODE_THEME = "monokai"
|
|
80
|
+
RENDER_COALESCE_INTERVAL = 0.05
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@lru_cache(maxsize=None)
|
|
84
|
+
def supports_unicode(encoding: Optional[str] = None) -> bool:
|
|
85
|
+
"""Whether the output encoding can represent the glyphs above.
|
|
86
|
+
|
|
87
|
+
Probes the original stdout (sys.__stdout__) because Textual redirects
|
|
88
|
+
sys.stdout while the app is running.
|
|
89
|
+
"""
|
|
90
|
+
if encoding is None:
|
|
91
|
+
encoding = getattr(sys.__stdout__, "encoding", None)
|
|
92
|
+
if encoding is None:
|
|
93
|
+
import locale
|
|
94
|
+
|
|
95
|
+
encoding = locale.getpreferredencoding(False) or "ascii"
|
|
96
|
+
try:
|
|
97
|
+
Glyph.TOOL.encode(encoding)
|
|
98
|
+
SPINNER_FRAMES.encode(encoding)
|
|
99
|
+
except (UnicodeEncodeError, LookupError):
|
|
100
|
+
return False
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def g(glyph: str) -> str:
|
|
105
|
+
"""Return the glyph, or its ASCII fallback on limited encodings."""
|
|
106
|
+
if supports_unicode():
|
|
107
|
+
return glyph
|
|
108
|
+
return ASCII_FALLBACKS.get(glyph, glyph)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def spinner_frames() -> str:
|
|
112
|
+
return SPINNER_FRAMES if supports_unicode() else SPINNER_FRAMES_ASCII
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
LOG_LEVEL_COLORS = {
|
|
116
|
+
"debug": Color.MUTED,
|
|
117
|
+
"info": Color.MUTED,
|
|
118
|
+
"ok": Color.SUCCESS,
|
|
119
|
+
"warn": Color.WARNING,
|
|
120
|
+
"warning": Color.WARNING,
|
|
121
|
+
"error": Color.ERROR,
|
|
122
|
+
"critical": Color.ERROR,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def log_level_color(level: str) -> str:
|
|
127
|
+
"""Semantic color for a log level, defaulting to muted."""
|
|
128
|
+
return LOG_LEVEL_COLORS.get(level.lower(), Color.MUTED)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def styled(text: str, style: str) -> str:
|
|
132
|
+
"""Wrap text in Rich markup for the given style."""
|
|
133
|
+
return f"[{style}]{text}[/{style}]"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def role_header(
|
|
137
|
+
glyph: str,
|
|
138
|
+
label: str,
|
|
139
|
+
color: str,
|
|
140
|
+
*,
|
|
141
|
+
label_style: str = "bold",
|
|
142
|
+
state: Optional[str] = None,
|
|
143
|
+
detail: Optional[str] = None,
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Render the shared entry-header grammar.
|
|
146
|
+
|
|
147
|
+
GRAMMAR: <colored glyph> <bold label> [ · state] [ · detail]
|
|
148
|
+
The glyph carries the semantic color; state and detail are muted.
|
|
149
|
+
"""
|
|
150
|
+
parts = [styled(g(glyph), color), styled(label, label_style)]
|
|
151
|
+
sep = g(Glyph.BULLET_SEP)
|
|
152
|
+
if state:
|
|
153
|
+
parts.append(styled(f"{sep} {state}", "dim"))
|
|
154
|
+
if detail:
|
|
155
|
+
parts.append(styled(f"{sep} {detail}", "dim"))
|
|
156
|
+
return " ".join(parts)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def context_bar(usage_percentage: float, width: int = CONTEXT_BAR_WIDTH) -> str:
|
|
160
|
+
"""Render a usage bar like █████░░░░ for the status dashboard."""
|
|
161
|
+
filled = max(0, min(width, round((usage_percentage / 100) * width)))
|
|
162
|
+
return g(Glyph.BAR_FILLED) * filled + g(Glyph.BAR_EMPTY) * (width - filled)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_rich_theme():
|
|
166
|
+
"""Build a rich Theme mapping semantic names to the palette (lazy import)."""
|
|
167
|
+
from rich.theme import Theme
|
|
168
|
+
|
|
169
|
+
return Theme(
|
|
170
|
+
{
|
|
171
|
+
"accent": Color.ACCENT,
|
|
172
|
+
"success": Color.SUCCESS,
|
|
173
|
+
"warning": Color.WARNING,
|
|
174
|
+
"error": Color.ERROR,
|
|
175
|
+
"muted": Color.MUTED,
|
|
176
|
+
"user": Color.USER,
|
|
177
|
+
"agent": Color.AGENT,
|
|
178
|
+
"tool": Color.TOOL,
|
|
179
|
+
}
|
|
180
|
+
)
|
kolega_code/config.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field, model_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelProvider(str, Enum):
|
|
8
|
+
"""Supported model providers."""
|
|
9
|
+
|
|
10
|
+
ANTHROPIC = "anthropic"
|
|
11
|
+
OPENAI = "openai"
|
|
12
|
+
GOOGLE = "google"
|
|
13
|
+
GROQ = "groq"
|
|
14
|
+
TOGETHER = "together"
|
|
15
|
+
FIREWORKS = "fireworks"
|
|
16
|
+
XAI = "xai"
|
|
17
|
+
LLAMA = "llama"
|
|
18
|
+
DASHSCOPE = "dashscope"
|
|
19
|
+
MOONSHOT = "moonshot"
|
|
20
|
+
DEEPSEEK = "deepseek"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RateLimitConfig(BaseModel):
|
|
24
|
+
"""Rate limit configuration for a specific LLM."""
|
|
25
|
+
|
|
26
|
+
requests_per_minute: int = Field(default=60, description="Maximum number of requests allowed per minute", gt=0)
|
|
27
|
+
|
|
28
|
+
tokens_per_minute: int = Field(default=80_000, description="Maximum number of tokens allowed per minute", gt=0)
|
|
29
|
+
|
|
30
|
+
max_retries: int = Field(default=3, description="Maximum number of retries for failed requests", ge=0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ModelConfig(BaseModel):
|
|
34
|
+
"""Configuration for a specific model type (long context, fast, or thinking)."""
|
|
35
|
+
|
|
36
|
+
provider: ModelProvider = Field(description="Provider to use for this model configuration")
|
|
37
|
+
|
|
38
|
+
model: str = Field(description="Model identifier to use")
|
|
39
|
+
|
|
40
|
+
rate_limits: RateLimitConfig = Field(default_factory=RateLimitConfig, description="Rate limits for this model")
|
|
41
|
+
|
|
42
|
+
# Only used for thinking config
|
|
43
|
+
thinking_tokens: Optional[int] = Field(
|
|
44
|
+
default=None, description="Number of tokens to reserve for thinking operations", gt=0
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AgentConfig(BaseModel):
|
|
49
|
+
"""Configuration for the agent system.
|
|
50
|
+
|
|
51
|
+
This class contains all configuration parameters needed to run the agent,
|
|
52
|
+
including API keys for different providers and model configurations for
|
|
53
|
+
various operational modes (long context, fast, and thinking).
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
# Create a default configuration
|
|
57
|
+
config = AgentConfig()
|
|
58
|
+
|
|
59
|
+
# Create a custom configuration
|
|
60
|
+
config = AgentConfig(
|
|
61
|
+
anthropic_api_key="your_anthropic_key",
|
|
62
|
+
long_context_config=ModelConfig(
|
|
63
|
+
provider=ModelProvider.ANTHROPIC,
|
|
64
|
+
model="claude-3-7-sonnet-20250219"
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Get an API key for a specific provider
|
|
69
|
+
api_key = config.get_api_key(ModelProvider.ANTHROPIC)
|
|
70
|
+
|
|
71
|
+
# Access model configurations
|
|
72
|
+
long_context_model = config.long_context_config
|
|
73
|
+
fast_model = config.fast_config
|
|
74
|
+
thinking_model = config.thinking_config
|
|
75
|
+
|
|
76
|
+
API keys can be set directly or loaded from environment variables.
|
|
77
|
+
Model configurations define which models to use for different operational
|
|
78
|
+
contexts and their respective token limits and rate limits.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
# API Keys for different providers
|
|
82
|
+
anthropic_api_key: Optional[str] = Field(default=None, description="API key for Anthropic")
|
|
83
|
+
openai_api_key: Optional[str] = Field(default=None, description="API key for OpenAI")
|
|
84
|
+
google_api_key: Optional[str] = Field(default=None, description="API key for Google")
|
|
85
|
+
groq_api_key: Optional[str] = Field(default=None, description="API key for Groq")
|
|
86
|
+
together_api_key: Optional[str] = Field(default=None, description="API key for Together.ai")
|
|
87
|
+
fireworks_api_key: Optional[str] = Field(default=None, description="API key for Fireworks.ai")
|
|
88
|
+
xai_api_key: Optional[str] = Field(default=None, description="API key for X.ai")
|
|
89
|
+
dashscope_api_key: Optional[str] = Field(default=None, description="API key for Dashscope (Alibaba Model Studio)")
|
|
90
|
+
moonshot_api_key: Optional[str] = Field(default=None, description="API key for Moonshot.ai")
|
|
91
|
+
deepseek_api_key: Optional[str] = Field(default=None, description="API key for DeepSeek")
|
|
92
|
+
|
|
93
|
+
# Langfuse configuration
|
|
94
|
+
langfuse_enabled: bool = Field(default=False, description="Enable Langfuse tracing")
|
|
95
|
+
langfuse_host: Optional[str] = Field(default=None, description="Langfuse host URL")
|
|
96
|
+
langfuse_public_key: Optional[str] = Field(default=None, description="Langfuse public key")
|
|
97
|
+
langfuse_secret_key: Optional[str] = Field(default=None, description="Langfuse secret key")
|
|
98
|
+
environment: Optional[str] = Field(default="development", description="Environment name (development, production)")
|
|
99
|
+
|
|
100
|
+
# Model configurations
|
|
101
|
+
long_context_config: ModelConfig = Field(
|
|
102
|
+
default_factory=lambda: ModelConfig(provider=ModelProvider.ANTHROPIC, model="claude-opus-4-7"),
|
|
103
|
+
description="Configuration for long context operations",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
fast_config: ModelConfig = Field(
|
|
107
|
+
default_factory=lambda: ModelConfig(provider=ModelProvider.ANTHROPIC, model="claude-haiku-4-5-20251001"),
|
|
108
|
+
description="Configuration for fast operations",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
edit_model_config: ModelConfig = Field(
|
|
112
|
+
default_factory=lambda: ModelConfig(provider=ModelProvider.ANTHROPIC, model="claude-sonnet-4-6"),
|
|
113
|
+
description="Configuration for applying edits",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
thinking_config: ModelConfig = Field(
|
|
117
|
+
default_factory=lambda: ModelConfig(
|
|
118
|
+
provider=ModelProvider.ANTHROPIC, model="claude-opus-4-7", thinking_tokens=1024
|
|
119
|
+
),
|
|
120
|
+
description="Configuration for thinking operations",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def get_api_key(self, provider: ModelProvider) -> Optional[str]:
|
|
124
|
+
"""Get the API key for a specific provider."""
|
|
125
|
+
api_key_map = {
|
|
126
|
+
ModelProvider.ANTHROPIC: self.anthropic_api_key,
|
|
127
|
+
ModelProvider.OPENAI: self.openai_api_key,
|
|
128
|
+
ModelProvider.GOOGLE: self.google_api_key,
|
|
129
|
+
ModelProvider.GROQ: self.groq_api_key,
|
|
130
|
+
ModelProvider.TOGETHER: self.together_api_key,
|
|
131
|
+
ModelProvider.FIREWORKS: self.fireworks_api_key,
|
|
132
|
+
ModelProvider.XAI: self.xai_api_key,
|
|
133
|
+
ModelProvider.DASHSCOPE: self.dashscope_api_key,
|
|
134
|
+
ModelProvider.MOONSHOT: self.moonshot_api_key,
|
|
135
|
+
ModelProvider.DEEPSEEK: self.deepseek_api_key,
|
|
136
|
+
ModelProvider.LLAMA: None, # Local model, no API key needed
|
|
137
|
+
}
|
|
138
|
+
return api_key_map[provider]
|
|
139
|
+
|
|
140
|
+
@model_validator(mode="after")
|
|
141
|
+
def validate_provider_api_key(self) -> "AgentConfig":
|
|
142
|
+
"""Validates that if a model provider is specified, the corresponding API key is provided."""
|
|
143
|
+
configs = [
|
|
144
|
+
(self.long_context_config, "long context"),
|
|
145
|
+
(self.fast_config, "fast"),
|
|
146
|
+
(self.thinking_config, "thinking"),
|
|
147
|
+
(self.edit_model_config, "edit model"),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
for config, config_name in configs:
|
|
151
|
+
if config.provider != ModelProvider.LLAMA and self.get_api_key(config.provider) is None:
|
|
152
|
+
raise ValueError(f"Missing API key for {config_name} provider '{config.provider.value}'")
|
|
153
|
+
|
|
154
|
+
return self
|