handoffkit 0.2.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.
- handoffkit/__init__.py +22 -0
- handoffkit/agent.py +91 -0
- handoffkit/cli.py +49 -0
- handoffkit/errors.py +33 -0
- handoffkit/handoff.py +86 -0
- handoffkit/memory.py +44 -0
- handoffkit/protocol.py +60 -0
- handoffkit/protocols/__init__.py +5 -0
- handoffkit/protocols/compressed.py +31 -0
- handoffkit/protocols/hybrid_min.py +17 -0
- handoffkit/protocols/hybrid_state.py +52 -0
- handoffkit/protocols/natural.py +22 -0
- handoffkit/providers/__init__.py +21 -0
- handoffkit/providers/base.py +16 -0
- handoffkit/providers/echo_provider.py +26 -0
- handoffkit/providers/ollama_provider.py +48 -0
- handoffkit/providers/openai_compatible.py +140 -0
- handoffkit/providers/openai_provider.py +80 -0
- handoffkit/py.typed +1 -0
- handoffkit/runner.py +51 -0
- handoffkit/schemas.py +45 -0
- handoffkit/tool.py +196 -0
- handoffkit/tools/__init__.py +15 -0
- handoffkit/tools/filesystem.py +34 -0
- handoffkit/tools/shell.py +53 -0
- handoffkit/tools/text.py +54 -0
- handoffkit-0.2.0.dist-info/METADATA +317 -0
- handoffkit-0.2.0.dist-info/RECORD +32 -0
- handoffkit-0.2.0.dist-info/WHEEL +5 -0
- handoffkit-0.2.0.dist-info/entry_points.txt +2 -0
- handoffkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- handoffkit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Helpers for OpenAI-compatible providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from handoffkit.errors import ProviderConfigurationError, ProviderExecutionError
|
|
13
|
+
from handoffkit.providers.openai_provider import (
|
|
14
|
+
DEFAULT_OPENAI_BASE_URL,
|
|
15
|
+
DEFAULT_OPENAI_MODEL,
|
|
16
|
+
OpenAIProvider,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
DEFAULT_MODEL_CANDIDATES = ("gpt-5.4", DEFAULT_OPENAI_MODEL)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ModelSelectionResult:
|
|
24
|
+
"""Result returned by OpenAI-compatible model selection."""
|
|
25
|
+
|
|
26
|
+
model: str
|
|
27
|
+
base_url: str
|
|
28
|
+
models_endpoint_ok: bool
|
|
29
|
+
available_models: list[str] = field(default_factory=list)
|
|
30
|
+
attempted_models: list[str] = field(default_factory=list)
|
|
31
|
+
errors: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_openai_compatible_models(
|
|
35
|
+
*,
|
|
36
|
+
api_key: str | None = None,
|
|
37
|
+
base_url: str | None = None,
|
|
38
|
+
timeout: float = 20.0,
|
|
39
|
+
) -> list[str]:
|
|
40
|
+
"""Return model ids from an OpenAI-compatible `/models` endpoint."""
|
|
41
|
+
resolved_api_key = api_key or os.getenv("OPENAI_API_KEY")
|
|
42
|
+
if not resolved_api_key:
|
|
43
|
+
raise ProviderConfigurationError("OPENAI_API_KEY is required to list models.")
|
|
44
|
+
configured_base_url = base_url or os.getenv("OPENAI_BASE_URL") or DEFAULT_OPENAI_BASE_URL
|
|
45
|
+
resolved_base_url = configured_base_url.rstrip("/")
|
|
46
|
+
request = urllib.request.Request(
|
|
47
|
+
f"{resolved_base_url}/models",
|
|
48
|
+
headers={"Authorization": f"Bearer {resolved_api_key}"},
|
|
49
|
+
method="GET",
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
53
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
54
|
+
except urllib.error.HTTPError as exc:
|
|
55
|
+
detail = exc.read().decode("utf-8", errors="replace").replace(
|
|
56
|
+
resolved_api_key, "[redacted-api-key]"
|
|
57
|
+
)
|
|
58
|
+
raise ProviderExecutionError(
|
|
59
|
+
f"OpenAI-compatible models request failed with HTTP {exc.code}: {detail}"
|
|
60
|
+
) from exc
|
|
61
|
+
except urllib.error.URLError as exc:
|
|
62
|
+
raise ProviderExecutionError(
|
|
63
|
+
f"OpenAI-compatible models request failed for {resolved_base_url}: {exc.reason}"
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
raw_items: Any = body.get("data", body if isinstance(body, list) else [])
|
|
67
|
+
models: list[str] = []
|
|
68
|
+
for item in raw_items:
|
|
69
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
70
|
+
models.append(item["id"])
|
|
71
|
+
elif isinstance(item, str):
|
|
72
|
+
models.append(item)
|
|
73
|
+
return sorted(set(models))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def choose_openai_compatible_model(
|
|
77
|
+
*,
|
|
78
|
+
api_key: str | None = None,
|
|
79
|
+
base_url: str | None = None,
|
|
80
|
+
model: str | None = None,
|
|
81
|
+
fallback_models: tuple[str, ...] = DEFAULT_MODEL_CANDIDATES,
|
|
82
|
+
timeout: float = 30.0,
|
|
83
|
+
) -> ModelSelectionResult:
|
|
84
|
+
"""Choose the first working model for an OpenAI-compatible endpoint."""
|
|
85
|
+
resolved_api_key = api_key or os.getenv("OPENAI_API_KEY")
|
|
86
|
+
if not resolved_api_key:
|
|
87
|
+
raise ProviderConfigurationError("OPENAI_API_KEY is required to choose a model.")
|
|
88
|
+
configured_base_url = base_url or os.getenv("OPENAI_BASE_URL") or DEFAULT_OPENAI_BASE_URL
|
|
89
|
+
resolved_base_url = configured_base_url.rstrip("/")
|
|
90
|
+
env_model = model or os.getenv("OPENAI_MODEL")
|
|
91
|
+
candidates = [env_model] if env_model else list(fallback_models)
|
|
92
|
+
result = ModelSelectionResult(
|
|
93
|
+
model="",
|
|
94
|
+
base_url=resolved_base_url,
|
|
95
|
+
models_endpoint_ok=False,
|
|
96
|
+
attempted_models=[],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
result.available_models = list_openai_compatible_models(
|
|
101
|
+
api_key=resolved_api_key,
|
|
102
|
+
base_url=resolved_base_url,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
)
|
|
105
|
+
result.models_endpoint_ok = True
|
|
106
|
+
except ProviderExecutionError as exc:
|
|
107
|
+
result.errors["/models"] = str(exc)
|
|
108
|
+
|
|
109
|
+
for candidate in candidates:
|
|
110
|
+
if not candidate:
|
|
111
|
+
continue
|
|
112
|
+
if result.available_models and candidate not in result.available_models:
|
|
113
|
+
result.errors[candidate] = "Model not listed by /models endpoint."
|
|
114
|
+
continue
|
|
115
|
+
result.attempted_models.append(candidate)
|
|
116
|
+
provider = OpenAIProvider(
|
|
117
|
+
api_key=resolved_api_key,
|
|
118
|
+
base_url=resolved_base_url,
|
|
119
|
+
model=candidate,
|
|
120
|
+
timeout=timeout,
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
response = provider.generate(
|
|
124
|
+
"Reply with exactly: OK",
|
|
125
|
+
max_tokens=8,
|
|
126
|
+
temperature=0,
|
|
127
|
+
)
|
|
128
|
+
except ProviderExecutionError as exc:
|
|
129
|
+
result.errors[candidate] = str(exc)
|
|
130
|
+
continue
|
|
131
|
+
if response.strip():
|
|
132
|
+
result.model = candidate
|
|
133
|
+
return result
|
|
134
|
+
result.errors[candidate] = "Model returned an empty response."
|
|
135
|
+
|
|
136
|
+
attempted = ", ".join(candidates)
|
|
137
|
+
raise ProviderExecutionError(
|
|
138
|
+
f"No OpenAI-compatible model succeeded for {resolved_base_url}. "
|
|
139
|
+
f"Candidates: {attempted}. Errors: {result.errors}"
|
|
140
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Minimal OpenAI-compatible HTTP provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from handoffkit.errors import ProviderConfigurationError, ProviderExecutionError
|
|
12
|
+
from handoffkit.providers.base import BaseProvider
|
|
13
|
+
|
|
14
|
+
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
|
15
|
+
DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OpenAIProvider(BaseProvider):
|
|
19
|
+
"""Provider that calls OpenAI or an OpenAI-compatible API."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
model: str | None = None,
|
|
24
|
+
*,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
base_url: str | None = None,
|
|
27
|
+
timeout: float = 60.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.model = model or os.getenv("OPENAI_MODEL") or DEFAULT_OPENAI_MODEL
|
|
30
|
+
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
|
31
|
+
configured_base_url = base_url or os.getenv("OPENAI_BASE_URL") or DEFAULT_OPENAI_BASE_URL
|
|
32
|
+
self.base_url = configured_base_url.rstrip("/")
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
if not self.api_key:
|
|
35
|
+
raise ProviderConfigurationError(
|
|
36
|
+
"OPENAI_API_KEY is required for OpenAIProvider. "
|
|
37
|
+
"Set the environment variable or pass api_key explicitly."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def generate(self, prompt: str, **kwargs: Any) -> str:
|
|
41
|
+
"""Generate text using OpenAI chat completions."""
|
|
42
|
+
payload = {
|
|
43
|
+
"model": kwargs.pop("model", self.model),
|
|
44
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
45
|
+
**kwargs,
|
|
46
|
+
}
|
|
47
|
+
data = json.dumps(payload).encode("utf-8")
|
|
48
|
+
request = urllib.request.Request(
|
|
49
|
+
f"{self.base_url}/chat/completions",
|
|
50
|
+
data=data,
|
|
51
|
+
headers={
|
|
52
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
},
|
|
55
|
+
method="POST",
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
59
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
60
|
+
except urllib.error.HTTPError as exc:
|
|
61
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
62
|
+
raise ProviderExecutionError(
|
|
63
|
+
f"OpenAI-compatible request failed with HTTP {exc.code}: "
|
|
64
|
+
f"{self._sanitize_error_detail(detail)}"
|
|
65
|
+
) from exc
|
|
66
|
+
except urllib.error.URLError as exc:
|
|
67
|
+
raise ProviderExecutionError(
|
|
68
|
+
f"OpenAI-compatible request failed for {self.base_url}: {exc.reason}"
|
|
69
|
+
) from exc
|
|
70
|
+
choices = body.get("choices") or []
|
|
71
|
+
if not choices:
|
|
72
|
+
return ""
|
|
73
|
+
message = choices[0].get("message") or {}
|
|
74
|
+
return str(message.get("content", ""))
|
|
75
|
+
|
|
76
|
+
def _sanitize_error_detail(self, detail: str) -> str:
|
|
77
|
+
"""Return provider error text without leaking the configured API key."""
|
|
78
|
+
if not self.api_key:
|
|
79
|
+
return detail
|
|
80
|
+
return detail.replace(self.api_key, "[redacted-api-key]")
|
handoffkit/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
handoffkit/runner.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Team runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from handoffkit.agent import Agent
|
|
8
|
+
from handoffkit.protocol import HandoffProtocol
|
|
9
|
+
from handoffkit.schemas import AgentOutput, TeamRunResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Team:
|
|
13
|
+
"""Sequential multi-agent team runner."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
agents: Sequence[Agent],
|
|
18
|
+
*,
|
|
19
|
+
protocol: HandoffProtocol | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
if not agents:
|
|
22
|
+
raise ValueError("Team requires at least one agent.")
|
|
23
|
+
self.agents = list(agents)
|
|
24
|
+
self.protocol = protocol or HandoffProtocol(mode="hybrid_state")
|
|
25
|
+
|
|
26
|
+
def run(self, task: str) -> TeamRunResult:
|
|
27
|
+
"""Run the task through all agents in sequence."""
|
|
28
|
+
outputs: list[AgentOutput] = []
|
|
29
|
+
handoffs = []
|
|
30
|
+
|
|
31
|
+
first = self.agents[0]
|
|
32
|
+
current_output = first.run(task)
|
|
33
|
+
outputs.append(AgentOutput(agent=first.name, output=current_output))
|
|
34
|
+
|
|
35
|
+
for previous, current in zip(self.agents, self.agents[1:], strict=False):
|
|
36
|
+
handoff = self.protocol.transfer(
|
|
37
|
+
from_agent=previous,
|
|
38
|
+
to_agent=current,
|
|
39
|
+
task=task,
|
|
40
|
+
summary=current_output,
|
|
41
|
+
)
|
|
42
|
+
handoffs.append(handoff)
|
|
43
|
+
current_output = current.run(task, handoff_state=handoff)
|
|
44
|
+
outputs.append(AgentOutput(agent=current.name, output=current_output))
|
|
45
|
+
|
|
46
|
+
return TeamRunResult(
|
|
47
|
+
task=task,
|
|
48
|
+
final_output=current_output,
|
|
49
|
+
agent_outputs=outputs,
|
|
50
|
+
handoffs=handoffs,
|
|
51
|
+
)
|
handoffkit/schemas.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared schema helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
ProtocolMode = Literal["natural", "compressed", "hybrid_min", "hybrid_state"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AgentOutput:
|
|
13
|
+
"""Output produced by a named agent."""
|
|
14
|
+
|
|
15
|
+
agent: str
|
|
16
|
+
output: str
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, str]:
|
|
19
|
+
"""Return a dictionary representation."""
|
|
20
|
+
return {"agent": self.agent, "output": self.output}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TeamRunResult:
|
|
25
|
+
"""Result returned by Team.run."""
|
|
26
|
+
|
|
27
|
+
task: str
|
|
28
|
+
final_output: str
|
|
29
|
+
agent_outputs: list[AgentOutput]
|
|
30
|
+
handoffs: list[Any]
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
"""Return a JSON-friendly result."""
|
|
34
|
+
return {
|
|
35
|
+
"task": self.task,
|
|
36
|
+
"final_output": self.final_output,
|
|
37
|
+
"agent_outputs": [output.to_dict() for output in self.agent_outputs],
|
|
38
|
+
"handoffs": [
|
|
39
|
+
handoff.to_dict() if hasattr(handoff, "to_dict") else handoff
|
|
40
|
+
for handoff in self.handoffs
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
return self.final_output
|
handoffkit/tool.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Tool decorator and metadata model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, TypeVar, get_args, get_origin, get_type_hints
|
|
10
|
+
|
|
11
|
+
from handoffkit.errors import ToolExecutionError
|
|
12
|
+
|
|
13
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _type_name(value: Any) -> str:
|
|
17
|
+
"""Return a readable name for a type annotation."""
|
|
18
|
+
if value is inspect.Signature.empty:
|
|
19
|
+
return "Any"
|
|
20
|
+
if hasattr(value, "__name__"):
|
|
21
|
+
return value.__name__
|
|
22
|
+
return str(value).replace("typing.", "")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _json_schema_type(value: Any) -> str:
|
|
26
|
+
"""Map a Python annotation to a simple JSON schema type."""
|
|
27
|
+
if value is inspect.Signature.empty or value is Any:
|
|
28
|
+
return "string"
|
|
29
|
+
|
|
30
|
+
origin = get_origin(value)
|
|
31
|
+
target = origin or value
|
|
32
|
+
if target is str:
|
|
33
|
+
return "string"
|
|
34
|
+
if target is int:
|
|
35
|
+
return "integer"
|
|
36
|
+
if target is float:
|
|
37
|
+
return "number"
|
|
38
|
+
if target is bool:
|
|
39
|
+
return "boolean"
|
|
40
|
+
if target is list:
|
|
41
|
+
return "array"
|
|
42
|
+
if target is dict:
|
|
43
|
+
return "object"
|
|
44
|
+
return "string"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _json_schema_for_annotation(value: Any) -> dict[str, Any]:
|
|
48
|
+
"""Return a compact JSON-schema-like object for one annotation."""
|
|
49
|
+
schema: dict[str, Any] = {"type": _json_schema_type(value)}
|
|
50
|
+
origin = get_origin(value)
|
|
51
|
+
args = get_args(value)
|
|
52
|
+
if (origin is list or value is list) and args:
|
|
53
|
+
schema["items"] = _json_schema_for_annotation(args[0])
|
|
54
|
+
if origin is dict and len(args) == 2:
|
|
55
|
+
schema["additionalProperties"] = _json_schema_for_annotation(args[1])
|
|
56
|
+
return schema
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ToolParameter:
|
|
61
|
+
"""Metadata about one tool parameter."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
annotation: str
|
|
65
|
+
required: bool
|
|
66
|
+
default: Any = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Tool:
|
|
70
|
+
"""Executable wrapper around a Python function."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
func: Callable[..., Any],
|
|
75
|
+
*,
|
|
76
|
+
name: str | None = None,
|
|
77
|
+
description: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
if not callable(func):
|
|
80
|
+
raise TypeError("Tool requires a callable.")
|
|
81
|
+
functools.update_wrapper(self, func)
|
|
82
|
+
self.func = func
|
|
83
|
+
self.name = name or func.__name__
|
|
84
|
+
self.description = description or inspect.getdoc(func) or ""
|
|
85
|
+
self.signature = inspect.signature(func)
|
|
86
|
+
self.type_hints = get_type_hints(func)
|
|
87
|
+
self.parameters = self._build_parameters()
|
|
88
|
+
self.return_type = _type_name(
|
|
89
|
+
self.type_hints.get("return", self.signature.return_annotation)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _build_parameters(self) -> dict[str, ToolParameter]:
|
|
93
|
+
parameters: dict[str, ToolParameter] = {}
|
|
94
|
+
for name, parameter in self.signature.parameters.items():
|
|
95
|
+
annotation = self.type_hints.get(name, parameter.annotation)
|
|
96
|
+
required = parameter.default is inspect.Signature.empty
|
|
97
|
+
default = None if required else parameter.default
|
|
98
|
+
parameters[name] = ToolParameter(
|
|
99
|
+
name=name,
|
|
100
|
+
annotation=_type_name(annotation),
|
|
101
|
+
required=required,
|
|
102
|
+
default=default,
|
|
103
|
+
)
|
|
104
|
+
return parameters
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def original_function(self) -> Callable[..., Any]:
|
|
108
|
+
"""Return the wrapped Python function."""
|
|
109
|
+
return self.func
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, Any]:
|
|
112
|
+
"""Serialize tool metadata to a dictionary."""
|
|
113
|
+
return {
|
|
114
|
+
"name": self.name,
|
|
115
|
+
"description": self.description,
|
|
116
|
+
"parameters": {
|
|
117
|
+
name: {
|
|
118
|
+
"type": parameter.annotation,
|
|
119
|
+
"required": parameter.required,
|
|
120
|
+
"default": parameter.default,
|
|
121
|
+
}
|
|
122
|
+
for name, parameter in self.parameters.items()
|
|
123
|
+
},
|
|
124
|
+
"return_type": self.return_type,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def to_schema(self) -> dict[str, Any]:
|
|
128
|
+
"""Return a simple JSON-schema-compatible tool description."""
|
|
129
|
+
properties: dict[str, Any] = {}
|
|
130
|
+
required: list[str] = []
|
|
131
|
+
for name, parameter in self.signature.parameters.items():
|
|
132
|
+
annotation = self.type_hints.get(name, parameter.annotation)
|
|
133
|
+
properties[name] = _json_schema_for_annotation(annotation)
|
|
134
|
+
if parameter.default is inspect.Signature.empty:
|
|
135
|
+
required.append(name)
|
|
136
|
+
return {
|
|
137
|
+
"name": self.name,
|
|
138
|
+
"description": self.description,
|
|
139
|
+
"parameters": {
|
|
140
|
+
"type": "object",
|
|
141
|
+
"properties": properties,
|
|
142
|
+
"required": required,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def run(self, **kwargs: Any) -> Any:
|
|
147
|
+
"""Run the wrapped function with keyword arguments."""
|
|
148
|
+
try:
|
|
149
|
+
bound = self.signature.bind(**kwargs)
|
|
150
|
+
bound.apply_defaults()
|
|
151
|
+
return self.func(*bound.args, **bound.kwargs)
|
|
152
|
+
except TypeError:
|
|
153
|
+
raise
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
if exc.__class__.__module__.startswith("handoffkit"):
|
|
156
|
+
raise
|
|
157
|
+
raise ToolExecutionError(f"Tool {self.name!r} failed: {exc}") from exc
|
|
158
|
+
|
|
159
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
160
|
+
"""Call the tool like the original function."""
|
|
161
|
+
if args:
|
|
162
|
+
bound = self.signature.bind(*args, **kwargs)
|
|
163
|
+
bound.apply_defaults()
|
|
164
|
+
return self.func(*bound.args, **bound.kwargs)
|
|
165
|
+
return self.run(**kwargs)
|
|
166
|
+
|
|
167
|
+
def __repr__(self) -> str:
|
|
168
|
+
return f"Tool(name={self.name!r})"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def tool(
|
|
172
|
+
func: F | None = None,
|
|
173
|
+
*,
|
|
174
|
+
name: str | None = None,
|
|
175
|
+
description: str | None = None,
|
|
176
|
+
) -> Tool | Callable[[F], Tool]:
|
|
177
|
+
"""Convert a function into an executable Tool.
|
|
178
|
+
|
|
179
|
+
Can be used as `@tool` or `@tool(name="custom_name")`.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def decorator(inner: F) -> Tool:
|
|
183
|
+
return Tool(inner, name=name, description=description)
|
|
184
|
+
|
|
185
|
+
if func is None:
|
|
186
|
+
return decorator
|
|
187
|
+
return decorator(func)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def ensure_tool(candidate: Tool | Callable[..., Any]) -> Tool:
|
|
191
|
+
"""Return a Tool instance for a Tool or callable."""
|
|
192
|
+
if isinstance(candidate, Tool):
|
|
193
|
+
return candidate
|
|
194
|
+
if callable(candidate):
|
|
195
|
+
return Tool(candidate)
|
|
196
|
+
raise TypeError(f"Expected Tool or callable, got {type(candidate).__name__}.")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Included tools."""
|
|
2
|
+
|
|
3
|
+
from handoffkit.tools.filesystem import file_exists, list_files, read_file, write_file
|
|
4
|
+
from handoffkit.tools.shell import run_command
|
|
5
|
+
from handoffkit.tools.text import extract_keywords, summarize_text
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"extract_keywords",
|
|
9
|
+
"file_exists",
|
|
10
|
+
"list_files",
|
|
11
|
+
"read_file",
|
|
12
|
+
"run_command",
|
|
13
|
+
"summarize_text",
|
|
14
|
+
"write_file",
|
|
15
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Filesystem tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from handoffkit.tool import tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@tool
|
|
11
|
+
def read_file(path: str) -> str:
|
|
12
|
+
"""Read a UTF-8 text file."""
|
|
13
|
+
return Path(path).read_text(encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@tool
|
|
17
|
+
def write_file(path: str, content: str) -> str:
|
|
18
|
+
"""Write content to a UTF-8 text file."""
|
|
19
|
+
target = Path(path)
|
|
20
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
target.write_text(content, encoding="utf-8")
|
|
22
|
+
return str(target)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@tool
|
|
26
|
+
def list_files(path: str) -> list[str]:
|
|
27
|
+
"""List files and directories in a directory."""
|
|
28
|
+
return sorted(str(item) for item in Path(path).iterdir())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@tool
|
|
32
|
+
def file_exists(path: str) -> bool:
|
|
33
|
+
"""Return whether a path exists."""
|
|
34
|
+
return Path(path).exists()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shell command tool with a small safety policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from handoffkit.errors import DangerousCommandError
|
|
11
|
+
from handoffkit.tool import tool
|
|
12
|
+
|
|
13
|
+
DANGEROUS_PATTERNS = [
|
|
14
|
+
re.compile(r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-rf|-fr)\b", re.IGNORECASE),
|
|
15
|
+
re.compile(r"\bdel\s+/(s|q)\b", re.IGNORECASE),
|
|
16
|
+
re.compile(r"\brmdir\s+/(s|q)\b", re.IGNORECASE),
|
|
17
|
+
re.compile(r"\brd\s+/(s|q)\b", re.IGNORECASE),
|
|
18
|
+
re.compile(r"\bformat\b", re.IGNORECASE),
|
|
19
|
+
re.compile(r"\bshutdown\b", re.IGNORECASE),
|
|
20
|
+
re.compile(r"\breboot\b", re.IGNORECASE),
|
|
21
|
+
re.compile(r"\bmkfs(\.[a-z0-9]+)?\b", re.IGNORECASE),
|
|
22
|
+
re.compile(r"\bdiskpart\b", re.IGNORECASE),
|
|
23
|
+
re.compile(r"\bRemove-Item\b.*\b-Recurse\b", re.IGNORECASE),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _assert_safe(command: str) -> None:
|
|
28
|
+
normalized = " ".join(command.strip().split())
|
|
29
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
30
|
+
if pattern.search(normalized):
|
|
31
|
+
raise DangerousCommandError(f"Blocked dangerous command: {command}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@tool
|
|
35
|
+
def run_command(command: str, cwd: str | None = None) -> dict[str, Any]:
|
|
36
|
+
"""Run a shell command and return command, cwd, return code, stdout, and stderr."""
|
|
37
|
+
_assert_safe(command)
|
|
38
|
+
working_dir = str(Path(cwd).resolve()) if cwd else None
|
|
39
|
+
completed = subprocess.run(
|
|
40
|
+
command,
|
|
41
|
+
cwd=working_dir,
|
|
42
|
+
shell=True,
|
|
43
|
+
check=False,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
)
|
|
47
|
+
return {
|
|
48
|
+
"command": command,
|
|
49
|
+
"cwd": working_dir,
|
|
50
|
+
"returncode": completed.returncode,
|
|
51
|
+
"stdout": completed.stdout,
|
|
52
|
+
"stderr": completed.stderr,
|
|
53
|
+
}
|
handoffkit/tools/text.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Small text tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from handoffkit.tool import tool
|
|
9
|
+
|
|
10
|
+
STOPWORDS = {
|
|
11
|
+
"a",
|
|
12
|
+
"an",
|
|
13
|
+
"and",
|
|
14
|
+
"are",
|
|
15
|
+
"as",
|
|
16
|
+
"at",
|
|
17
|
+
"be",
|
|
18
|
+
"by",
|
|
19
|
+
"for",
|
|
20
|
+
"from",
|
|
21
|
+
"in",
|
|
22
|
+
"is",
|
|
23
|
+
"it",
|
|
24
|
+
"of",
|
|
25
|
+
"on",
|
|
26
|
+
"or",
|
|
27
|
+
"that",
|
|
28
|
+
"the",
|
|
29
|
+
"to",
|
|
30
|
+
"with",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@tool
|
|
35
|
+
def summarize_text(text: str, max_chars: int = 500) -> str:
|
|
36
|
+
"""Return a compact character-limited summary."""
|
|
37
|
+
clean = " ".join(text.strip().split())
|
|
38
|
+
if len(clean) <= max_chars:
|
|
39
|
+
return clean
|
|
40
|
+
if max_chars <= 3:
|
|
41
|
+
return clean[:max_chars]
|
|
42
|
+
return clean[: max_chars - 3].rstrip() + "..."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@tool
|
|
46
|
+
def extract_keywords(text: str) -> list[str]:
|
|
47
|
+
"""Extract simple frequency-based keywords."""
|
|
48
|
+
words = [
|
|
49
|
+
word.lower()
|
|
50
|
+
for word in re.findall(r"[A-Za-z][A-Za-z0-9_+-]{2,}", text)
|
|
51
|
+
if word.lower() not in STOPWORDS
|
|
52
|
+
]
|
|
53
|
+
counts = Counter(words)
|
|
54
|
+
return [word for word, _ in counts.most_common(10)]
|