crabcode 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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +232 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""SkillTool — lets the model invoke user-defined skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from crabcode_core.types.tool import PermissionBehavior, PermissionResult, Tool, ToolResult
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from crabcode_core.skills.loader import SkillDefinition
|
|
11
|
+
from crabcode_core.types.tool import ToolContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SkillTool(Tool):
|
|
15
|
+
"""Execute a user-defined skill loaded from SKILL.md files.
|
|
16
|
+
|
|
17
|
+
The model calls this tool when the user invokes a skill by name (e.g.
|
|
18
|
+
``/commit``) or when the task naturally matches a skill's description.
|
|
19
|
+
The tool expands the skill's Markdown content and returns it so the model
|
|
20
|
+
can follow the instructions.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name = "Skill"
|
|
24
|
+
is_read_only = True
|
|
25
|
+
is_concurrency_safe = True
|
|
26
|
+
|
|
27
|
+
input_schema: dict[str, Any] = {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"skill_name": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Name of the skill to execute.",
|
|
33
|
+
},
|
|
34
|
+
"user_input": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "The user's additional input or context for the skill.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"required": ["skill_name"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def __init__(self, skills: list[SkillDefinition]) -> None:
|
|
43
|
+
self._skills = skills
|
|
44
|
+
self._skill_map = {s.name: s for s in skills}
|
|
45
|
+
|
|
46
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
47
|
+
lines = [
|
|
48
|
+
"Execute a user-defined skill.\n",
|
|
49
|
+
"When a user invokes /<skill-name> or their request clearly matches a skill's",
|
|
50
|
+
"description, call this tool with the matching skill_name BEFORE generating",
|
|
51
|
+
"any other response. The tool will return the skill's instructions, which you",
|
|
52
|
+
"should then follow to complete the task.\n",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
if self._skills:
|
|
56
|
+
lines.append("## Available skills\n")
|
|
57
|
+
for skill in self._skills:
|
|
58
|
+
lines.append(f"### {skill.name}")
|
|
59
|
+
if skill.description:
|
|
60
|
+
lines.append(f"Description: {skill.description}")
|
|
61
|
+
if skill.when_to_use:
|
|
62
|
+
lines.append(f"When to use: {skill.when_to_use}")
|
|
63
|
+
lines.append("")
|
|
64
|
+
|
|
65
|
+
lines.append(
|
|
66
|
+
"IMPORTANT: Only invoke skills listed above. "
|
|
67
|
+
"Do not guess or invent skill names."
|
|
68
|
+
)
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
|
|
72
|
+
skill_name = tool_input.get("skill_name", "")
|
|
73
|
+
if skill_name not in self._skill_map:
|
|
74
|
+
available = ", ".join(self._skill_map) or "(none)"
|
|
75
|
+
return f"Unknown skill '{skill_name}'. Available skills: {available}"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
async def check_permissions(
|
|
79
|
+
self,
|
|
80
|
+
tool_input: dict[str, Any],
|
|
81
|
+
context: ToolContext,
|
|
82
|
+
) -> PermissionResult:
|
|
83
|
+
return PermissionResult(behavior=PermissionBehavior.ALLOW)
|
|
84
|
+
|
|
85
|
+
async def call(
|
|
86
|
+
self,
|
|
87
|
+
tool_input: dict[str, Any],
|
|
88
|
+
context: ToolContext,
|
|
89
|
+
) -> ToolResult:
|
|
90
|
+
skill_name = tool_input.get("skill_name", "")
|
|
91
|
+
user_input = tool_input.get("user_input", "")
|
|
92
|
+
|
|
93
|
+
skill = self._skill_map.get(skill_name)
|
|
94
|
+
if not skill:
|
|
95
|
+
available = ", ".join(self._skill_map) or "(none)"
|
|
96
|
+
return ToolResult(
|
|
97
|
+
is_error=True,
|
|
98
|
+
result_for_model=f"Unknown skill '{skill_name}'. Available: {available}",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
content = skill.content
|
|
102
|
+
if user_input:
|
|
103
|
+
# Support explicit $USER_INPUT placeholder; otherwise append
|
|
104
|
+
if "$USER_INPUT" in content:
|
|
105
|
+
content = content.replace("$USER_INPUT", user_input)
|
|
106
|
+
else:
|
|
107
|
+
content = f"{content}\n\nUser input: {user_input}"
|
|
108
|
+
|
|
109
|
+
return ToolResult(
|
|
110
|
+
result_for_model=content,
|
|
111
|
+
result_for_display=f"[Skill: {skill_name}]",
|
|
112
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Core type definitions for CrabCode."""
|
|
2
|
+
|
|
3
|
+
from crabcode_core.types.message import (
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
ContentBlock,
|
|
6
|
+
Message,
|
|
7
|
+
MessageRole,
|
|
8
|
+
SystemMessage,
|
|
9
|
+
TextBlock,
|
|
10
|
+
ThinkingBlock,
|
|
11
|
+
ToolResultBlock,
|
|
12
|
+
ToolUseBlock,
|
|
13
|
+
UserMessage,
|
|
14
|
+
)
|
|
15
|
+
from crabcode_core.types.tool import (
|
|
16
|
+
PermissionBehavior,
|
|
17
|
+
PermissionResult,
|
|
18
|
+
Tool,
|
|
19
|
+
ToolContext,
|
|
20
|
+
ToolResult,
|
|
21
|
+
)
|
|
22
|
+
from crabcode_core.types.event import (
|
|
23
|
+
CompactEvent,
|
|
24
|
+
CoreEvent,
|
|
25
|
+
ErrorEvent,
|
|
26
|
+
PermissionRequestEvent,
|
|
27
|
+
PermissionResponseEvent,
|
|
28
|
+
StreamTextEvent,
|
|
29
|
+
ThinkingEvent,
|
|
30
|
+
ToolResultEvent,
|
|
31
|
+
ToolUseEvent,
|
|
32
|
+
TurnCompleteEvent,
|
|
33
|
+
)
|
|
34
|
+
from crabcode_core.types.config import (
|
|
35
|
+
ApiConfig,
|
|
36
|
+
CrabCodeSettings,
|
|
37
|
+
McpServerConfig,
|
|
38
|
+
PermissionRule,
|
|
39
|
+
PermissionsSettings,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AssistantMessage",
|
|
44
|
+
"ContentBlock",
|
|
45
|
+
"Message",
|
|
46
|
+
"MessageRole",
|
|
47
|
+
"SystemMessage",
|
|
48
|
+
"TextBlock",
|
|
49
|
+
"ThinkingBlock",
|
|
50
|
+
"ToolResultBlock",
|
|
51
|
+
"ToolUseBlock",
|
|
52
|
+
"UserMessage",
|
|
53
|
+
"PermissionBehavior",
|
|
54
|
+
"PermissionResult",
|
|
55
|
+
"Tool",
|
|
56
|
+
"ToolContext",
|
|
57
|
+
"ToolResult",
|
|
58
|
+
"CompactEvent",
|
|
59
|
+
"CoreEvent",
|
|
60
|
+
"ErrorEvent",
|
|
61
|
+
"PermissionRequestEvent",
|
|
62
|
+
"PermissionResponseEvent",
|
|
63
|
+
"StreamTextEvent",
|
|
64
|
+
"ThinkingEvent",
|
|
65
|
+
"ToolResultEvent",
|
|
66
|
+
"ToolUseEvent",
|
|
67
|
+
"TurnCompleteEvent",
|
|
68
|
+
"ApiConfig",
|
|
69
|
+
"CrabCodeSettings",
|
|
70
|
+
"McpServerConfig",
|
|
71
|
+
"PermissionRule",
|
|
72
|
+
"PermissionsSettings",
|
|
73
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Configuration types for CrabCode settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PermissionRule(BaseModel):
|
|
11
|
+
"""A single permission rule for tool access control."""
|
|
12
|
+
tool: str
|
|
13
|
+
path: str | None = None
|
|
14
|
+
command: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PermissionsSettings(BaseModel):
|
|
18
|
+
allow: list[PermissionRule] = Field(default_factory=list)
|
|
19
|
+
deny: list[PermissionRule] = Field(default_factory=list)
|
|
20
|
+
ask: list[PermissionRule] = Field(default_factory=list)
|
|
21
|
+
default_mode: str | None = None
|
|
22
|
+
additional_directories: list[str] = Field(default_factory=list)
|
|
23
|
+
run_everything: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class McpServerConfig(BaseModel):
|
|
27
|
+
command: list[str] | None = None
|
|
28
|
+
url: str | None = None
|
|
29
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
30
|
+
disabled: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApiConfig(BaseModel):
|
|
34
|
+
"""API backend configuration."""
|
|
35
|
+
provider: str | None = None # anthropic | openai | codex | bedrock | vertex | router
|
|
36
|
+
model: str | None = None
|
|
37
|
+
base_url: str | None = None
|
|
38
|
+
api_key_env: str | None = None
|
|
39
|
+
format: str | None = None # anthropic | openai | codex (for routers)
|
|
40
|
+
max_tokens: int = 16384
|
|
41
|
+
thinking_enabled: bool = True
|
|
42
|
+
thinking_budget: int = 10000
|
|
43
|
+
timeout: int = 300 # seconds, for API calls
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgentSettings(BaseModel):
|
|
47
|
+
"""Settings for the built-in Agent (sub-agent) tool."""
|
|
48
|
+
max_turns: int = 10
|
|
49
|
+
timeout: int = 300
|
|
50
|
+
max_output_chars: int = 12000
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DisplaySettings(BaseModel):
|
|
54
|
+
"""Settings for tool result display in the terminal."""
|
|
55
|
+
default_max_lines: int = 50
|
|
56
|
+
tool_max_lines: dict[str, int] = {}
|
|
57
|
+
max_chars: int = 50_000
|
|
58
|
+
|
|
59
|
+
# Built-in defaults merged under tool_max_lines overrides
|
|
60
|
+
_TOOL_DEFAULTS: dict[str, int] = {
|
|
61
|
+
"Agent": 120,
|
|
62
|
+
"Bash": 60,
|
|
63
|
+
"Grep": 50,
|
|
64
|
+
"Glob": 30,
|
|
65
|
+
"Read": 80,
|
|
66
|
+
"Lint": 60,
|
|
67
|
+
"CodebaseSearch": 50,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def get_max_lines(self, tool_name: str) -> int:
|
|
71
|
+
"""Return max display lines for a tool, considering overrides."""
|
|
72
|
+
if tool_name in self.tool_max_lines:
|
|
73
|
+
return self.tool_max_lines[tool_name]
|
|
74
|
+
return self._TOOL_DEFAULTS.get(tool_name, self.default_max_lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CrabCodeSettings(BaseModel):
|
|
78
|
+
"""Full settings.json schema."""
|
|
79
|
+
permissions: PermissionsSettings = Field(default_factory=PermissionsSettings)
|
|
80
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
81
|
+
mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict)
|
|
82
|
+
api: ApiConfig = Field(default_factory=ApiConfig)
|
|
83
|
+
models: dict[str, ApiConfig] = Field(default_factory=dict)
|
|
84
|
+
default_model: str | None = None
|
|
85
|
+
hooks: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
|
86
|
+
auto_compact_enabled: bool = True
|
|
87
|
+
max_context_length: int | None = None
|
|
88
|
+
language: str | None = None
|
|
89
|
+
output_style: str | None = None
|
|
90
|
+
prompt_profile: dict[str, Any] | None = None
|
|
91
|
+
extra_tools: list[str] = Field(default_factory=list)
|
|
92
|
+
tool_settings: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
93
|
+
agent: AgentSettings = Field(default_factory=AgentSettings)
|
|
94
|
+
display: DisplaySettings = Field(default_factory=DisplaySettings)
|
|
95
|
+
|
|
96
|
+
model_config = {"extra": "allow"}
|
|
97
|
+
|
|
98
|
+
def get_api_config(self, model_name: str | None = None) -> ApiConfig:
|
|
99
|
+
"""Return the ApiConfig for a named model, falling back to the default api config."""
|
|
100
|
+
name = model_name or self.default_model
|
|
101
|
+
if name and name in self.models:
|
|
102
|
+
return self.models[name]
|
|
103
|
+
return self.api
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Event types for Core <-> Frontend communication.
|
|
2
|
+
|
|
3
|
+
The Core emits these events as an async stream. Frontends (CLI, Web UI, SDK)
|
|
4
|
+
consume them to render output, handle permission prompts, etc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Union
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class StreamTextEvent:
|
|
15
|
+
"""A chunk of streamed text from the model."""
|
|
16
|
+
text: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ThinkingEvent:
|
|
21
|
+
"""A chunk of thinking/reasoning from the model."""
|
|
22
|
+
text: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolUseEvent:
|
|
27
|
+
"""The model wants to use a tool."""
|
|
28
|
+
tool_name: str
|
|
29
|
+
tool_input: dict[str, Any]
|
|
30
|
+
tool_use_id: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ToolResultEvent:
|
|
35
|
+
"""Result from a tool execution."""
|
|
36
|
+
tool_use_id: str
|
|
37
|
+
tool_name: str
|
|
38
|
+
result: str
|
|
39
|
+
is_error: bool = False
|
|
40
|
+
result_for_display: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class PermissionRequestEvent:
|
|
45
|
+
"""Core is requesting permission from the frontend to use a tool."""
|
|
46
|
+
tool_name: str
|
|
47
|
+
tool_input: dict[str, Any]
|
|
48
|
+
tool_use_id: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class PermissionResponseEvent:
|
|
53
|
+
"""Frontend's response to a permission request."""
|
|
54
|
+
tool_use_id: str
|
|
55
|
+
allowed: bool
|
|
56
|
+
always_allow: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class CompactEvent:
|
|
61
|
+
"""Conversation was compacted."""
|
|
62
|
+
summary: str
|
|
63
|
+
messages_before: int = 0
|
|
64
|
+
messages_after: int = 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ErrorEvent:
|
|
69
|
+
"""An error occurred."""
|
|
70
|
+
message: str
|
|
71
|
+
recoverable: bool = True
|
|
72
|
+
error_type: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class TurnCompleteEvent:
|
|
77
|
+
"""An agentic turn completed."""
|
|
78
|
+
reason: str = "end_turn"
|
|
79
|
+
turn_count: int = 0
|
|
80
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class StreamModeEvent:
|
|
85
|
+
"""Signals a phase transition in the streaming lifecycle.
|
|
86
|
+
|
|
87
|
+
Modes:
|
|
88
|
+
- "requesting": API request sent, waiting for first chunk
|
|
89
|
+
- "thinking": model entered extended thinking
|
|
90
|
+
- "responding": model is streaming text output
|
|
91
|
+
"""
|
|
92
|
+
mode: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
CoreEvent = Union[
|
|
96
|
+
StreamTextEvent,
|
|
97
|
+
ThinkingEvent,
|
|
98
|
+
ToolUseEvent,
|
|
99
|
+
ToolResultEvent,
|
|
100
|
+
PermissionRequestEvent,
|
|
101
|
+
CompactEvent,
|
|
102
|
+
ErrorEvent,
|
|
103
|
+
TurnCompleteEvent,
|
|
104
|
+
StreamModeEvent,
|
|
105
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Message types for the CrabCode conversation protocol.
|
|
2
|
+
|
|
3
|
+
Internal format follows Anthropic's message structure. API adapters
|
|
4
|
+
translate to/from provider-specific formats.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Literal, Union
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MessageRole(str, Enum):
|
|
18
|
+
USER = "user"
|
|
19
|
+
ASSISTANT = "assistant"
|
|
20
|
+
SYSTEM = "system"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- Content blocks ---
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TextBlock(BaseModel):
|
|
27
|
+
type: Literal["text"] = "text"
|
|
28
|
+
text: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ToolUseBlock(BaseModel):
|
|
32
|
+
type: Literal["tool_use"] = "tool_use"
|
|
33
|
+
id: str
|
|
34
|
+
name: str
|
|
35
|
+
input: dict[str, Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolResultBlock(BaseModel):
|
|
39
|
+
type: Literal["tool_result"] = "tool_result"
|
|
40
|
+
tool_use_id: str
|
|
41
|
+
content: str
|
|
42
|
+
is_error: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ThinkingBlock(BaseModel):
|
|
46
|
+
type: Literal["thinking"] = "thinking"
|
|
47
|
+
thinking: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SignatureBlock(BaseModel):
|
|
51
|
+
type: Literal["signature"] = "signature"
|
|
52
|
+
signature: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
ContentBlock = Union[TextBlock, ToolUseBlock, ToolResultBlock, ThinkingBlock, SignatureBlock]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- Messages ---
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Message(BaseModel):
|
|
62
|
+
"""Base message in a conversation."""
|
|
63
|
+
|
|
64
|
+
uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
65
|
+
parent_uuid: str | None = None
|
|
66
|
+
role: MessageRole
|
|
67
|
+
content: list[ContentBlock] | str
|
|
68
|
+
timestamp: str = Field(
|
|
69
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
70
|
+
)
|
|
71
|
+
is_compact_summary: bool = False
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def text_content(self) -> str:
|
|
75
|
+
"""Extract plain text from content blocks."""
|
|
76
|
+
if isinstance(self.content, str):
|
|
77
|
+
return self.content
|
|
78
|
+
parts: list[str] = []
|
|
79
|
+
for block in self.content:
|
|
80
|
+
if isinstance(block, TextBlock):
|
|
81
|
+
parts.append(block.text)
|
|
82
|
+
return "".join(parts)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def tool_use_blocks(self) -> list[ToolUseBlock]:
|
|
86
|
+
if isinstance(self.content, str):
|
|
87
|
+
return []
|
|
88
|
+
return [b for b in self.content if isinstance(b, ToolUseBlock)]
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def has_tool_use(self) -> bool:
|
|
92
|
+
return len(self.tool_use_blocks) > 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class UserMessage(Message):
|
|
96
|
+
role: Literal[MessageRole.USER] = MessageRole.USER
|
|
97
|
+
tool_use_result: str | None = None
|
|
98
|
+
source_tool_assistant_uuid: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AssistantMessage(Message):
|
|
102
|
+
role: Literal[MessageRole.ASSISTANT] = MessageRole.ASSISTANT
|
|
103
|
+
api_error: str | None = None
|
|
104
|
+
usage: dict[str, Any] | None = None
|
|
105
|
+
request_id: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SystemMessage(Message):
|
|
109
|
+
role: Literal[MessageRole.SYSTEM] = MessageRole.SYSTEM
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_user_message(
|
|
113
|
+
content: list[ContentBlock] | str,
|
|
114
|
+
**kwargs: Any,
|
|
115
|
+
) -> UserMessage:
|
|
116
|
+
return UserMessage(content=content, **kwargs)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def create_assistant_message(
|
|
120
|
+
content: list[ContentBlock] | str,
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> AssistantMessage:
|
|
123
|
+
return AssistantMessage(content=content, **kwargs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create_tool_result_message(
|
|
127
|
+
tool_use_id: str,
|
|
128
|
+
result: str,
|
|
129
|
+
is_error: bool = False,
|
|
130
|
+
source_tool_assistant_uuid: str | None = None,
|
|
131
|
+
) -> UserMessage:
|
|
132
|
+
"""Create a user message containing a tool result."""
|
|
133
|
+
return UserMessage(
|
|
134
|
+
content=[
|
|
135
|
+
ToolResultBlock(
|
|
136
|
+
tool_use_id=tool_use_id,
|
|
137
|
+
content=result,
|
|
138
|
+
is_error=is_error,
|
|
139
|
+
)
|
|
140
|
+
],
|
|
141
|
+
parent_uuid=source_tool_assistant_uuid,
|
|
142
|
+
tool_use_result=result,
|
|
143
|
+
source_tool_assistant_uuid=source_tool_assistant_uuid,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def deserialize_content(raw: Any) -> list[ContentBlock] | str:
|
|
148
|
+
"""Deserialize raw JSON content back into typed ContentBlock list."""
|
|
149
|
+
if isinstance(raw, str):
|
|
150
|
+
return raw
|
|
151
|
+
if not isinstance(raw, list):
|
|
152
|
+
return str(raw) if raw else ""
|
|
153
|
+
|
|
154
|
+
_BLOCK_MAP: dict[str, type[BaseModel]] = {
|
|
155
|
+
"text": TextBlock,
|
|
156
|
+
"tool_use": ToolUseBlock,
|
|
157
|
+
"tool_result": ToolResultBlock,
|
|
158
|
+
"thinking": ThinkingBlock,
|
|
159
|
+
"signature": SignatureBlock,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
blocks: list[ContentBlock] = []
|
|
163
|
+
for item in raw:
|
|
164
|
+
if not isinstance(item, dict):
|
|
165
|
+
continue
|
|
166
|
+
block_type = item.get("type", "")
|
|
167
|
+
cls = _BLOCK_MAP.get(block_type)
|
|
168
|
+
if cls:
|
|
169
|
+
try:
|
|
170
|
+
blocks.append(cls.model_validate(item))
|
|
171
|
+
except Exception:
|
|
172
|
+
if "text" in item:
|
|
173
|
+
blocks.append(TextBlock(text=item["text"]))
|
|
174
|
+
elif "text" in item:
|
|
175
|
+
blocks.append(TextBlock(text=item["text"]))
|
|
176
|
+
|
|
177
|
+
return blocks if blocks else ""
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tool system types for CrabCode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from crabcode_core.types.message import AssistantMessage, Message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PermissionBehavior(str, Enum):
|
|
16
|
+
ALLOW = "allow"
|
|
17
|
+
DENY = "deny"
|
|
18
|
+
ASK = "ask"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ToolEventCallback = Callable[[str, str, dict[str, Any]], None]
|
|
22
|
+
"""Signature: (tool_name, event_type, data)"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PermissionResult:
|
|
27
|
+
behavior: PermissionBehavior = PermissionBehavior.ALLOW
|
|
28
|
+
reason: str | None = None
|
|
29
|
+
updated_input: dict[str, Any] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ToolResult:
|
|
34
|
+
"""Result from executing a tool."""
|
|
35
|
+
|
|
36
|
+
data: Any = None
|
|
37
|
+
result_for_model: str = ""
|
|
38
|
+
result_for_display: str | None = None
|
|
39
|
+
is_error: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolContext:
|
|
44
|
+
"""Context passed to tool execution."""
|
|
45
|
+
|
|
46
|
+
cwd: str = "."
|
|
47
|
+
messages: list[Message] = field(default_factory=list)
|
|
48
|
+
abort_signal: Any | None = None
|
|
49
|
+
session_id: str = ""
|
|
50
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
on_event: ToolEventCallback | None = None
|
|
52
|
+
tool_config: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
CanUseToolFn = Callable[
|
|
56
|
+
["Tool", dict[str, Any], "ToolContext"],
|
|
57
|
+
Coroutine[Any, Any, PermissionResult],
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Tool(ABC):
|
|
62
|
+
"""Base class for all CrabCode tools."""
|
|
63
|
+
|
|
64
|
+
name: str = ""
|
|
65
|
+
description: str = ""
|
|
66
|
+
input_schema: dict[str, Any] = {}
|
|
67
|
+
is_read_only: bool = False
|
|
68
|
+
is_concurrency_safe: bool = False
|
|
69
|
+
is_enabled: bool = True
|
|
70
|
+
|
|
71
|
+
_cached_prompt: str | None = None
|
|
72
|
+
_background_task: asyncio.Task[Any] | None = None
|
|
73
|
+
_setup_context: ToolContext | None = None
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def call(
|
|
77
|
+
self,
|
|
78
|
+
tool_input: dict[str, Any],
|
|
79
|
+
context: ToolContext,
|
|
80
|
+
) -> ToolResult:
|
|
81
|
+
"""Execute the tool with given input."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
async def setup(self, context: ToolContext) -> None:
|
|
85
|
+
"""Called once during session init. Override for heavy initialization."""
|
|
86
|
+
self._setup_context = context
|
|
87
|
+
|
|
88
|
+
def emit_event(self, event_type: str, data: dict[str, Any] | None = None) -> None:
|
|
89
|
+
"""Emit a tool event (progress, ready, error, etc.)."""
|
|
90
|
+
if self._setup_context and self._setup_context.on_event:
|
|
91
|
+
self._setup_context.on_event(self.name, event_type, data or {})
|
|
92
|
+
|
|
93
|
+
async def wait_ready(self) -> None:
|
|
94
|
+
"""Wait for background initialization to complete."""
|
|
95
|
+
if self._background_task and not self._background_task.done():
|
|
96
|
+
await self._background_task
|
|
97
|
+
|
|
98
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
99
|
+
"""Return the tool description for the system prompt / API tools list."""
|
|
100
|
+
return self.description
|
|
101
|
+
|
|
102
|
+
async def validate_input(
|
|
103
|
+
self, tool_input: dict[str, Any]
|
|
104
|
+
) -> str | None:
|
|
105
|
+
"""Validate input. Return error string or None if valid."""
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
async def check_permissions(
|
|
109
|
+
self,
|
|
110
|
+
tool_input: dict[str, Any],
|
|
111
|
+
context: ToolContext,
|
|
112
|
+
) -> PermissionResult:
|
|
113
|
+
"""Check if the tool can be used with the given input."""
|
|
114
|
+
return PermissionResult()
|
|
115
|
+
|
|
116
|
+
def to_api_schema(self) -> dict[str, Any]:
|
|
117
|
+
"""Convert to API tool schema for model requests.
|
|
118
|
+
|
|
119
|
+
Uses the detailed prompt from get_prompt() when available,
|
|
120
|
+
falling back to the short description.
|
|
121
|
+
"""
|
|
122
|
+
return {
|
|
123
|
+
"name": self.name,
|
|
124
|
+
"description": self._cached_prompt or self.description,
|
|
125
|
+
"input_schema": self.input_schema,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async def resolve_prompt(self, **kwargs: Any) -> None:
|
|
129
|
+
"""Pre-resolve the detailed prompt for use in to_api_schema().
|
|
130
|
+
|
|
131
|
+
Call this once during setup so to_api_schema() can include the
|
|
132
|
+
full tool description without being async.
|
|
133
|
+
"""
|
|
134
|
+
prompt = await self.get_prompt(**kwargs)
|
|
135
|
+
if prompt and prompt != self.description:
|
|
136
|
+
self._cached_prompt = prompt
|
|
137
|
+
else:
|
|
138
|
+
self._cached_prompt = None
|